p2p-media-loader-core
Version:
P2P Media Loader core functionality
502 lines • 22.3 kB
JavaScript
import { HttpRequestExecutor } from "./http-loader.js";
import { P2PLoadersContainer } from "./p2p/loaders-container.js";
import { RequestsContainer } from "./requests/request-container.js";
import { EngineRequest } from "./requests/engine-request.js";
import * as QueueUtils from "./utils/queue.js";
import * as LoggerUtils from "./utils/logger.js";
import * as StreamUtils from "./utils/stream.js";
import * as Utils from "./utils/utils.js";
import debug from "debug";
const FAILED_ATTEMPTS_CLEAR_INTERVAL = 60000;
const PEER_UPDATE_LATENCY = 1000;
export class HybridLoader {
constructor(streamManifestUrl, lastRequestedSegment, streamDetails, config, bandwidthCalculators, segmentStorage, eventTarget) {
Object.defineProperty(this, "streamManifestUrl", {
enumerable: true,
configurable: true,
writable: true,
value: streamManifestUrl
});
Object.defineProperty(this, "lastRequestedSegment", {
enumerable: true,
configurable: true,
writable: true,
value: lastRequestedSegment
});
Object.defineProperty(this, "streamDetails", {
enumerable: true,
configurable: true,
writable: true,
value: streamDetails
});
Object.defineProperty(this, "config", {
enumerable: true,
configurable: true,
writable: true,
value: config
});
Object.defineProperty(this, "bandwidthCalculators", {
enumerable: true,
configurable: true,
writable: true,
value: bandwidthCalculators
});
Object.defineProperty(this, "segmentStorage", {
enumerable: true,
configurable: true,
writable: true,
value: segmentStorage
});
Object.defineProperty(this, "eventTarget", {
enumerable: true,
configurable: true,
writable: true,
value: eventTarget
});
Object.defineProperty(this, "requests", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "engineRequest", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "p2pLoaders", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "playback", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "segmentAvgDuration", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "logger", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "storageCleanUpIntervalId", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "levelChangedTimestamp", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "lastQueueProcessingTimeStamp", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "randomHttpDownloadInterval", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "isProcessQueueMicrotaskCreated", {
enumerable: true,
configurable: true,
writable: true,
value: false
});
Object.defineProperty(this, "requestProcessQueueMicrotask", {
enumerable: true,
configurable: true,
writable: true,
value: (force = true) => {
const now = performance.now();
if ((!force &&
this.lastQueueProcessingTimeStamp !== undefined &&
now - this.lastQueueProcessingTimeStamp <= 1000) ||
this.isProcessQueueMicrotaskCreated) {
return;
}
this.isProcessQueueMicrotaskCreated = true;
queueMicrotask(() => {
try {
this.processQueue();
this.lastQueueProcessingTimeStamp = now;
}
finally {
this.isProcessQueueMicrotaskCreated = false;
}
});
}
});
const activeStream = this.lastRequestedSegment.stream;
this.playback = { position: this.lastRequestedSegment.startTime, rate: 1 };
this.segmentAvgDuration = StreamUtils.getSegmentAvgDuration(activeStream);
this.requests = new RequestsContainer(this.requestProcessQueueMicrotask, this.bandwidthCalculators, this.playback, this.config, this.eventTarget);
this.p2pLoaders = new P2PLoadersContainer(this.streamManifestUrl, this.lastRequestedSegment.stream, this.requests, this.segmentStorage, this.config, this.eventTarget, this.requestProcessQueueMicrotask);
this.logger = debug(`p2pml-core:hybrid-loader-${activeStream.type}`);
this.logger.color = "coral";
this.setIntervalLoading();
}
setIntervalLoading() {
const peersCount = this.p2pLoaders.currentLoader.connectedPeerCount;
const randomTimeout = Math.random() * PEER_UPDATE_LATENCY * peersCount + PEER_UPDATE_LATENCY;
this.randomHttpDownloadInterval = window.setTimeout(() => {
this.loadRandomThroughHttp();
this.setIntervalLoading();
}, randomTimeout);
}
// api method for engines
async loadSegment(segment, callbacks) {
this.logger(`requests: ${LoggerUtils.getSegmentString(segment)}`);
const { stream } = segment;
if (stream !== this.lastRequestedSegment.stream) {
this.logger(`stream changed to ${LoggerUtils.getStreamString(stream)}`);
this.p2pLoaders.changeCurrentLoader(stream);
}
this.lastRequestedSegment = segment;
const swarmId = this.config.swarmId ?? this.streamManifestUrl;
const streamSwarmId = StreamUtils.getStreamSwarmId(swarmId, stream);
this.segmentStorage.onSegmentRequested(swarmId, streamSwarmId, segment.externalId, segment.startTime, segment.endTime, stream.type, this.streamDetails.isLive);
const engineRequest = new EngineRequest(segment, callbacks);
try {
const hasSegment = this.segmentStorage.hasSegment(swarmId, streamSwarmId, segment.externalId);
if (hasSegment) {
const data = await this.segmentStorage.getSegmentData(swarmId, streamSwarmId, segment.externalId);
if (data) {
const { queueDownloadRatio } = this.generateQueue();
engineRequest.resolve(data, this.getBandwidth(queueDownloadRatio));
return;
}
}
this.engineRequest?.abort();
this.engineRequest = engineRequest;
}
catch {
engineRequest.reject();
}
finally {
this.requestProcessQueueMicrotask();
}
}
processRequests(queueSegmentIds, queueDownloadRatio) {
const { stream } = this.lastRequestedSegment;
const { httpErrorRetries } = this.config;
const now = performance.now();
for (const request of this.requests.items()) {
const { downloadSource: type, status, segment, isHandledByProcessQueue, } = request;
const engineRequest = this.engineRequest?.segment === segment
? this.engineRequest
: undefined;
switch (status) {
case "loading":
if (!queueSegmentIds.has(segment.runtimeId) && !engineRequest) {
request.abortFromProcessQueue();
this.requests.remove(request);
}
break;
case "succeed": {
if (!type)
break;
if (type === "http") {
this.p2pLoaders.currentLoader.broadcastAnnouncement();
}
if (engineRequest) {
engineRequest.resolve(request.data, this.getBandwidth(queueDownloadRatio));
this.engineRequest = undefined;
}
this.requests.remove(request);
const swarmId = this.config.swarmId ?? this.streamManifestUrl;
const streamSwarmId = StreamUtils.getStreamSwarmId(swarmId, stream);
void this.segmentStorage.storeSegment(swarmId, streamSwarmId, segment.externalId, request.data, segment.startTime, segment.endTime, segment.stream.type, this.streamDetails.isLive);
break;
}
case "failed":
if (type === "http" && !isHandledByProcessQueue) {
this.p2pLoaders.currentLoader.broadcastAnnouncement();
}
if (!engineRequest &&
!stream.segments.has(request.segment.runtimeId)) {
this.requests.remove(request);
}
if (request.failedAttempts.httpAttemptsCount >= httpErrorRetries &&
engineRequest) {
this.engineRequest = undefined;
engineRequest.reject();
}
break;
case "not-started":
this.requests.remove(request);
break;
case "aborted":
this.requests.remove(request);
break;
}
request.markHandledByProcessQueue();
const { lastAttempt } = request.failedAttempts;
if (lastAttempt &&
now - lastAttempt.error.timestamp > FAILED_ATTEMPTS_CLEAR_INTERVAL) {
request.failedAttempts.clear();
}
}
}
processQueue() {
const { queue, queueSegmentIds, queueDownloadRatio } = this.generateQueue();
this.processRequests(queueSegmentIds, queueDownloadRatio);
const { simultaneousHttpDownloads, simultaneousP2PDownloads, httpErrorRetries, } = this.config;
if (this.engineRequest?.shouldBeStartedImmediately &&
this.engineRequest.status === "pending" &&
this.requests.executingHttpCount < simultaneousHttpDownloads) {
const { segment } = this.engineRequest;
const request = this.requests.get(segment);
if (!request ||
request.status === "not-started" ||
(request.status === "failed" &&
request.failedAttempts.httpAttemptsCount <
this.config.httpErrorRetries)) {
this.loadThroughHttp(segment);
}
}
for (const item of queue) {
const { statuses, segment } = item;
const request = this.requests.get(segment);
if (statuses.isHighDemand) {
if (request?.downloadSource === "http" &&
request.status === "loading") {
continue;
}
if (request?.downloadSource === "http" &&
request.status === "failed" &&
request.failedAttempts.httpAttemptsCount >= httpErrorRetries) {
continue;
}
const isP2PLoadingRequest = request?.status === "loading" && request.downloadSource === "p2p";
if (this.requests.executingHttpCount < simultaneousHttpDownloads) {
if (isP2PLoadingRequest)
request.abortFromProcessQueue();
this.loadThroughHttp(segment);
continue;
}
if (this.abortLastHttpLoadingInQueueAfterItem(queue, segment) &&
this.requests.executingHttpCount < simultaneousHttpDownloads) {
if (isP2PLoadingRequest)
request.abortFromProcessQueue();
this.loadThroughHttp(segment);
continue;
}
if (isP2PLoadingRequest)
continue;
if (this.requests.executingP2PCount < simultaneousP2PDownloads) {
this.loadThroughP2P(segment);
continue;
}
if (this.abortLastP2PLoadingInQueueAfterItem(queue, segment) &&
this.requests.executingP2PCount < simultaneousP2PDownloads) {
this.loadThroughP2P(segment);
continue;
}
}
else if (statuses.isP2PDownloadable) {
if (request?.status === "loading")
continue;
if (this.requests.executingP2PCount < simultaneousP2PDownloads) {
this.loadThroughP2P(segment);
}
else if (this.p2pLoaders.currentLoader.isSegmentLoadedBySomeone(segment)) {
if (this.abortLastP2PLoadingInQueueAfterItem(queue, segment) &&
this.requests.executingP2PCount < simultaneousP2PDownloads) {
this.loadThroughP2P(segment);
}
}
}
}
}
// api method for engines
abortSegmentRequest(segmentRuntimeId) {
if (this.engineRequest?.segment.runtimeId !== segmentRuntimeId)
return;
this.engineRequest.abort();
this.logger("abort: ", LoggerUtils.getSegmentString(this.engineRequest.segment));
this.engineRequest = undefined;
this.requestProcessQueueMicrotask();
}
loadThroughHttp(segment) {
const request = this.requests.getOrCreateRequest(segment);
new HttpRequestExecutor(request, this.config, this.eventTarget);
this.p2pLoaders.currentLoader.broadcastAnnouncement();
}
loadThroughP2P(segment) {
this.p2pLoaders.currentLoader.downloadSegment(segment);
}
loadRandomThroughHttp() {
const availableStorageCapacityPercent = this.getAvailableStorageCapacityPercent();
if (availableStorageCapacityPercent <= 10)
return;
const { simultaneousHttpDownloads, httpErrorRetries } = this.config;
const p2pLoader = this.p2pLoaders.currentLoader;
if (this.requests.executingHttpCount >= simultaneousHttpDownloads ||
!p2pLoader.connectedPeerCount) {
return;
}
const segmentsToLoad = [];
for (const { segment, statuses } of QueueUtils.generateQueue(this.lastRequestedSegment, this.playback, this.config, this.p2pLoaders.currentLoader, availableStorageCapacityPercent)) {
const swarmId = this.config.swarmId ?? this.streamManifestUrl;
const streamSwarmId = StreamUtils.getStreamSwarmId(swarmId, segment.stream);
if (!statuses.isHttpDownloadable ||
statuses.isP2PDownloadable ||
this.segmentStorage.hasSegment(swarmId, streamSwarmId, segment.externalId)) {
continue;
}
const request = this.requests.get(segment);
if (request &&
(request.status === "loading" ||
request.status === "succeed" ||
request.failedAttempts.httpAttemptsCount >= httpErrorRetries)) {
continue;
}
segmentsToLoad.push(segment);
}
if (!segmentsToLoad.length)
return;
const availableHttpDownloads = simultaneousHttpDownloads - this.requests.executingHttpCount;
if (availableHttpDownloads === 0)
return;
const peersCount = p2pLoader.connectedPeerCount + 1;
const safeRandomSegmentsCount = Math.min(segmentsToLoad.length, simultaneousHttpDownloads * peersCount);
const randomIndices = Utils.shuffleArray(Array.from({ length: safeRandomSegmentsCount }, (_, i) => i));
let probability = safeRandomSegmentsCount / peersCount;
for (const randomIndex of randomIndices) {
if (this.requests.executingHttpCount >= simultaneousHttpDownloads) {
break;
}
if (probability >= 1 || Math.random() <= probability) {
const segment = segmentsToLoad[randomIndex];
this.loadThroughHttp(segment);
}
probability--;
if (probability <= 0)
break;
}
}
abortLastHttpLoadingInQueueAfterItem(queue, segment) {
for (const { segment: itemSegment } of Utils.arrayBackwards(queue)) {
if (itemSegment === segment)
break;
const request = this.requests.get(itemSegment);
if (request?.downloadSource === "http" && request.status === "loading") {
request.abortFromProcessQueue();
return true;
}
}
return false;
}
abortLastP2PLoadingInQueueAfterItem(queue, segment) {
for (const { segment: itemSegment } of Utils.arrayBackwards(queue)) {
if (itemSegment === segment)
break;
const request = this.requests.get(itemSegment);
if (request?.downloadSource === "p2p" && request.status === "loading") {
request.abortFromProcessQueue();
return true;
}
}
return false;
}
getAvailableStorageCapacityPercent() {
const { totalCapacity, usedCapacity } = this.segmentStorage.getUsage();
return 100 - (usedCapacity / totalCapacity) * 100;
}
generateQueue() {
const queue = [];
const queueSegmentIds = new Set();
let maxPossibleLength = 0;
let alreadyLoadedCount = 0;
const availableStorageCapacityPercent = this.getAvailableStorageCapacityPercent();
for (const item of QueueUtils.generateQueue(this.lastRequestedSegment, this.playback, this.config, this.p2pLoaders.currentLoader, availableStorageCapacityPercent)) {
maxPossibleLength++;
const { segment } = item;
const swarmId = this.config.swarmId ?? this.streamManifestUrl;
const streamSwarmId = StreamUtils.getStreamSwarmId(swarmId, segment.stream);
if (this.segmentStorage.hasSegment(swarmId, streamSwarmId, segment.externalId) ||
this.requests.get(segment)?.status === "succeed") {
alreadyLoadedCount++;
continue;
}
queue.push(item);
queueSegmentIds.add(segment.runtimeId);
}
return {
queue,
queueSegmentIds,
maxPossibleLength,
alreadyLoadedCount,
queueDownloadRatio: maxPossibleLength !== 0 ? alreadyLoadedCount / maxPossibleLength : 0,
};
}
getBandwidth(queueDownloadRatio) {
const { http, all } = this.bandwidthCalculators;
const { activeLevelBitrate } = this.streamDetails;
if (this.streamDetails.activeLevelBitrate === 0) {
return all.getBandwidthLoadingOnly(3);
}
const bandwidth = Math.max(all.getBandwidth(30, this.levelChangedTimestamp), all.getBandwidth(60, this.levelChangedTimestamp), all.getBandwidth(90, this.levelChangedTimestamp));
if (queueDownloadRatio >= 0.8 || bandwidth >= activeLevelBitrate * 0.9) {
return Math.max(all.getBandwidthLoadingOnly(1), all.getBandwidthLoadingOnly(3), all.getBandwidthLoadingOnly(5));
}
const httpRealBandwidth = Math.max(http.getBandwidthLoadingOnly(1), http.getBandwidthLoadingOnly(3), http.getBandwidthLoadingOnly(5));
return Math.max(bandwidth, httpRealBandwidth);
}
notifyLevelChanged() {
this.levelChangedTimestamp = performance.now();
}
sendBroadcastAnnouncement(sendEmptySegmentsAnnouncement = false) {
this.p2pLoaders.currentLoader.broadcastAnnouncement(sendEmptySegmentsAnnouncement);
}
updatePlayback(position, rate) {
const isRateChanged = this.playback.rate !== rate;
const isPositionChanged = this.playback.position !== position;
if (!isRateChanged && !isPositionChanged)
return;
const isPositionSignificantlyChanged = Math.abs(position - this.playback.position) / this.segmentAvgDuration >
0.5;
if (isPositionChanged)
this.playback.position = position;
if (isRateChanged && rate !== 0)
this.playback.rate = rate;
if (isPositionSignificantlyChanged) {
this.logger("position significantly changed");
this.engineRequest?.markAsShouldBeStartedImmediately();
}
this.segmentStorage.onPlaybackUpdated(position, rate);
this.requestProcessQueueMicrotask(isPositionSignificantlyChanged);
}
updateStream(stream) {
if (stream !== this.lastRequestedSegment.stream)
return;
this.logger(`update stream: ${LoggerUtils.getStreamString(stream)}`);
this.requestProcessQueueMicrotask();
}
destroy() {
clearInterval(this.storageCleanUpIntervalId);
clearInterval(this.randomHttpDownloadInterval);
this.storageCleanUpIntervalId = undefined;
this.engineRequest?.abort();
this.requests.destroy();
this.p2pLoaders.destroy();
}
}
//# sourceMappingURL=hybrid-loader.js.map