p2p-media-loader-core
Version:
P2P Media Loader core functionality
685 lines (594 loc) • 21 kB
text/typescript
import { HttpRequestExecutor } from "./http-loader.js";
import {
CoreEventMap,
EngineCallbacks,
SegmentWithStream,
StreamConfig,
StreamWithSegments,
} from "./types.js";
import {
Playback,
BandwidthCalculators,
StreamDetails,
} from "./internal-types.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";
import { QueueItem } from "./utils/queue.js";
import { EventTarget } from "./utils/event-target.js";
import { SegmentStorage } from "./segment-storage/index.js";
import { WebTorrentSocketPool } from "./webtorrent/webtorrent-socket-pool/index.js";
const FAILED_ATTEMPTS_CLEAR_INTERVAL = 60000;
const PEER_UPDATE_LATENCY = 1000;
export class HybridLoader {
private readonly requests: RequestsContainer;
private engineRequest?: EngineRequest;
private readonly p2pLoaders: P2PLoadersContainer;
private readonly playback: Playback;
private readonly segmentAvgDuration: number;
private readonly logger: debug.Debugger;
private levelChangedTimestamp?: number;
private lastQueueProcessingTimeStamp?: number;
private randomHttpDownloadTimeout?: number;
private initialHttpDelayTimeoutId?: number;
private isProcessQueueMicrotaskCreated = false;
private readonly swarmId: string;
private readonly createdAt = performance.now();
constructor(
private streamManifestUrl: string,
private lastRequestedSegment: Readonly<SegmentWithStream>,
private readonly streamDetails: Required<Readonly<StreamDetails>>,
private readonly config: StreamConfig,
private readonly bandwidthCalculators: BandwidthCalculators,
private readonly segmentStorage: SegmentStorage,
private readonly webTorrentSocketPool: WebTorrentSocketPool,
private readonly eventTarget: EventTarget<CoreEventMap>,
private readonly peerId: string,
) {
const activeStream = this.lastRequestedSegment.stream;
this.swarmId = this.config.swarmId ?? this.streamManifestUrl;
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.swarmId,
);
this.p2pLoaders = new P2PLoadersContainer(
this.streamManifestUrl,
this.lastRequestedSegment.stream,
this.requests,
this.segmentStorage,
this.config,
this.webTorrentSocketPool,
this.eventTarget,
this.peerId,
this.requestProcessQueueMicrotask,
);
this.logger = debug(`p2pml-core:hybrid-loader-${activeStream.type}`);
this.logger.color = "coral";
this.setIntervalLoading();
}
private setIntervalLoading() {
const peersCount = this.p2pLoaders.currentLoader.connectedPeerCount;
const randomTimeout =
Math.random() * PEER_UPDATE_LATENCY * peersCount + PEER_UPDATE_LATENCY;
this.randomHttpDownloadTimeout = window.setTimeout(() => {
this.loadRandomThroughHttp();
this.setIntervalLoading();
}, randomTimeout);
}
// api method for engines
async loadSegment(
segment: Readonly<SegmentWithStream>,
callbacks: EngineCallbacks,
) {
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 streamSwarmId = StreamUtils.getStreamSwarmId(this.swarmId, stream);
this.segmentStorage.onSegmentRequested(
this.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(
this.swarmId,
streamSwarmId,
segment.externalId,
);
if (hasSegment) {
const data = await this.segmentStorage.getSegmentData(
this.swarmId,
streamSwarmId,
segment.externalId,
);
if (data) {
const { queueDownloadRatio } = this.generateQueue();
engineRequest.resolve(data, this.getBandwidth(queueDownloadRatio));
return;
}
}
this.engineRequest?.abort();
this.engineRequest = engineRequest;
// If the engine explicitly requests a segment that previously failed during
// background pre-fetching, clear its error history so it can be retried.
const request = this.requests.get(segment);
if (request?.status === "failed") {
request.failedAttempts.clear();
}
} catch (error) {
this.logger(
`request failed for ${LoggerUtils.getSegmentString(segment)} in ${LoggerUtils.getStreamString(stream)}`,
error,
);
engineRequest.reject();
} finally {
this.requestProcessQueueMicrotask();
}
}
private requestProcessQueueMicrotask = (force = true) => {
const now = performance.now();
if (
(!force &&
this.lastQueueProcessingTimeStamp !== undefined &&
now - this.lastQueueProcessingTimeStamp <= 1000) ||
this.isProcessQueueMicrotaskCreated
) {
return;
}
this.isProcessQueueMicrotaskCreated = true;
Utils.queueMicrotask(() => {
try {
this.processQueue();
this.lastQueueProcessingTimeStamp = now;
} finally {
this.isProcessQueueMicrotaskCreated = false;
}
});
};
private processRequests(
queueSegmentIds: Set<string>,
queueDownloadRatio: number,
) {
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.cancel();
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);
this.logger(
`succeed: ${LoggerUtils.getSegmentString(segment)} (byteLength: ${request.data.byteLength})`,
);
const streamSwarmId = StreamUtils.getStreamSwarmId(
this.swarmId,
stream,
);
void this.segmentStorage.storeSegment(
this.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();
}
}
}
private processQueue() {
const { queue, queueSegmentIds, queueDownloadRatio } = this.generateQueue();
this.processRequests(queueSegmentIds, queueDownloadRatio);
const {
simultaneousHttpDownloads,
simultaneousP2PDownloads,
httpErrorRetries,
httpDownloadInitialTimeoutMs,
} = this.config;
const timeSinceStart = performance.now() - this.createdAt;
const isInitialHttpWait =
httpDownloadInitialTimeoutMs > 0 &&
timeSinceStart < httpDownloadInitialTimeoutMs;
if (isInitialHttpWait) {
this.initialHttpDelayTimeoutId ??= window.setTimeout(() => {
this.initialHttpDelayTimeoutId = undefined;
this.requestProcessQueueMicrotask();
}, httpDownloadInitialTimeoutMs - timeSinceStart);
}
const { engineRequest } = this;
if (engineRequest) {
const { segment } = engineRequest;
const request = this.requests.get(segment);
const shouldStartLoadImmediatelyEngineRequest =
engineRequest.shouldBeStartedImmediately &&
engineRequest.status === "pending" &&
(!request ||
request.status === "not-started" ||
request.status === "failed" ||
request.status === "aborted");
if (shouldStartLoadImmediatelyEngineRequest) {
// Don't abort requests when processing engine request
// to avoid race condition with aborts in the requests queue
const canLoadThroughHttp =
!isInitialHttpWait &&
(request?.failedAttempts.httpAttemptsCount ?? 0) < httpErrorRetries &&
this.requests.executingHttpCount < simultaneousHttpDownloads;
if (canLoadThroughHttp) {
this.loadThroughHttp(segment);
} else {
const canLoadThroughP2P =
this.p2pLoaders.currentLoader.isSegmentLoadedBySomeone(segment) &&
this.requests.executingP2PCount < simultaneousP2PDownloads;
if (canLoadThroughP2P) {
this.loadThroughP2P(segment);
}
}
}
}
for (const item of queue) {
const { statuses, segment } = item;
const request = this.requests.get(segment);
if (request?.status === "succeed") continue;
if (statuses.isHighDemand) {
const canLoadThroughHttp =
!isInitialHttpWait &&
(request?.failedAttempts.httpAttemptsCount ?? 0) < httpErrorRetries;
if (request?.status === "loading") {
// High-demand request is loading
const shouldSwitchFromP2PToHttp =
canLoadThroughHttp &&
request.downloadSource === "p2p" &&
(this.requests.executingHttpCount < simultaneousHttpDownloads ||
this.abortLastHttpLoadingInQueueAfterItem(queue, segment));
if (shouldSwitchFromP2PToHttp) {
request.cancel();
this.loadThroughHttp(segment);
}
continue;
}
// High-demand request is not loading
const shouldLoadThroughHttp =
canLoadThroughHttp &&
(this.requests.executingHttpCount < simultaneousHttpDownloads ||
this.abortLastHttpLoadingInQueueAfterItem(queue, segment));
if (shouldLoadThroughHttp) {
this.loadThroughHttp(segment);
continue;
}
const canLoadThroughP2P =
this.p2pLoaders.currentLoader.isSegmentLoadedBySomeone(segment) &&
(this.requests.executingP2PCount < simultaneousP2PDownloads ||
this.abortLastP2PLoadingInQueueAfterItem(queue, segment));
if (canLoadThroughP2P) {
this.loadThroughP2P(segment);
}
} else {
// Regular requests load via P2P only (random HTTP loading also runs by interval)
const canLoadThroughP2P =
statuses.isP2PDownloadable &&
request?.status !== "loading" &&
this.requests.executingP2PCount < simultaneousP2PDownloads;
if (canLoadThroughP2P) {
this.loadThroughP2P(segment);
}
}
}
}
// api method for engines
abortSegmentRequest(segmentRuntimeId: string) {
if (this.engineRequest?.segment.runtimeId !== segmentRuntimeId) return;
this.engineRequest.abort();
this.logger(
"abort: ",
LoggerUtils.getSegmentString(this.engineRequest.segment),
);
this.engineRequest = undefined;
this.requestProcessQueueMicrotask();
}
private loadThroughHttp(segment: SegmentWithStream) {
const request = this.requests.getOrCreateRequest(segment);
const executor = new HttpRequestExecutor(
request,
this.config,
this.eventTarget,
);
executor.execute();
this.p2pLoaders.currentLoader.broadcastAnnouncement();
}
private loadThroughP2P(segment: SegmentWithStream) {
this.p2pLoaders.currentLoader.downloadSegment(segment);
}
private loadRandomThroughHttp() {
const { httpDownloadInitialTimeoutMs } = this.config;
const isInitialHttpWait =
httpDownloadInitialTimeoutMs > 0 &&
performance.now() - this.createdAt < httpDownloadInitialTimeoutMs;
if (isInitialHttpWait) return;
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: SegmentWithStream[] = [];
for (const { segment, statuses } of QueueUtils.generateQueue(
this.lastRequestedSegment,
this.playback,
this.config,
this.p2pLoaders.currentLoader,
availableStorageCapacityPercent,
)) {
const streamSwarmId = StreamUtils.getStreamSwarmId(
this.swarmId,
segment.stream,
);
if (
!statuses.isHttpDownloadable ||
statuses.isP2PDownloadable ||
this.segmentStorage.hasSegment(
this.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;
}
}
private abortLastHttpLoadingInQueueAfterItem(
queue: QueueUtils.QueueItem[],
segment: SegmentWithStream,
): boolean {
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.cancel();
return true;
}
}
return false;
}
private abortLastP2PLoadingInQueueAfterItem(
queue: QueueUtils.QueueItem[],
segment: SegmentWithStream,
): boolean {
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.cancel();
return true;
}
}
return false;
}
private getAvailableStorageCapacityPercent(): number {
const { totalCapacity, usedCapacity } = this.segmentStorage.getUsage();
return 100 - (usedCapacity / totalCapacity) * 100;
}
private generateQueue() {
const queue: QueueItem[] = [];
const queueSegmentIds = new Set<string>();
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 streamSwarmId = StreamUtils.getStreamSwarmId(
this.swarmId,
segment.stream,
);
if (
this.segmentStorage.hasSegment(
this.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,
};
}
private getBandwidth(queueDownloadRatio: number) {
const { http, all } = this.bandwidthCalculators;
const { activeLevelBitrate } = this.streamDetails;
if (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: number, rate: number) {
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: StreamWithSegments) {
if (stream !== this.lastRequestedSegment.stream) return;
this.logger(`update stream: ${LoggerUtils.getStreamString(stream)}`);
this.requestProcessQueueMicrotask();
}
destroy() {
clearTimeout(this.randomHttpDownloadTimeout);
clearTimeout(this.initialHttpDelayTimeoutId);
this.engineRequest?.abort();
this.requests.destroy();
this.p2pLoaders.destroy();
}
}