Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Resuming segment downloading. #320

Merged
merged 12 commits into from
Dec 29, 2023
22 changes: 11 additions & 11 deletions p2p-media-loader-demo/index.html
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Vite + React + TS</title>
<script type="text/javascript" src="https://cdn.jsdelivr.net/npm/hls.js@latest/dist/hls.min.js"></script>
<script type="text/javascript" src="https://cdn.jsdelivr.net/npm/clappr@latest"></script>
<script type="text/javascript"
src="https://cdn.jsdelivr.net/gh/clappr/clappr-level-selector-plugin@latest/dist/level-selector.min.js"></script>
<script type="text/javascript"
src="https://cdn.jsdelivr.net/npm/shaka-player@~4.6.0/dist/shaka-player.compiled.min.js"></script>
<script type="text/javascript"
src="https://cdn.jsdelivr.net/gh/clappr/dash-shaka-playback@latest/dist/dash-shaka-playback.external.js"></script>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Vite + React + TS</title>
<script type="text/javascript" src="https://cdn.jsdelivr.net/npm/hls.js@latest/dist/hls.min.js"></script>
<script type="text/javascript" src="https://cdn.jsdelivr.net/npm/clappr@latest"></script>
<script type="text/javascript"
src="https://cdn.jsdelivr.net/gh/clappr/clappr-level-selector-plugin@latest/dist/level-selector.min.js"></script>
<script type="text/javascript"
src="https://cdn.jsdelivr.net/npm/shaka-player@~4.6.0/dist/shaka-player.compiled.min.js"></script>
<script type="text/javascript"
src="https://cdn.jsdelivr.net/gh/clappr/dash-shaka-playback@latest/dist/dash-shaka-playback.external.js"></script>
</head>
<body>
<div id="root"></div>
Expand Down
2 changes: 2 additions & 0 deletions p2p-media-loader-demo/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ const streamUrls = {
hlsBigBunnyBuck: "https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8",
hlsByteRangeVideo:
"https://devstreaming-cdn.apple.com/videos/streaming/examples/bipbop_16x9/bipbop_16x9_variant.m3u8",
hlsOneLevelByteRangeVideo:
"https://devstreaming-cdn.apple.com/videos/streaming/examples/bipbop_16x9/gear1/prog_index.m3u8",
hlsBasicExample:
"https://devstreaming-cdn.apple.com/videos/streaming/examples/bipbop_4x3/bipbop_4x3_variant.m3u8",
hlsAdvancedVideo:
Expand Down
2 changes: 2 additions & 0 deletions packages/p2p-media-loader-core/src/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ export class Core<TStream extends Stream = Stream> {
p2pNotReceivingBytesTimeoutMs: 1000,
p2pLoaderDestroyTimeoutMs: 30 * 1000,
httpNotReceivingBytesTimeoutMs: 1000,
httpErrorRetries: 3,
p2pErrorRetries: 3,
};
private readonly bandwidthApproximator = new BandwidthApproximator();
private segmentStorage?: SegmentsMemoryStorage;
Expand Down
175 changes: 135 additions & 40 deletions packages/p2p-media-loader-core/src/http-loader.ts
Original file line number Diff line number Diff line change
@@ -1,58 +1,139 @@
import { Settings } from "./types";
import { Request, RequestError, HttpRequestErrorType } from "./request";

export async function fulfillHttpSegmentRequest(
request: Request,
settings: Pick<Settings, "httpNotReceivingBytesTimeoutMs">
) {
const headers = new Headers();
const { segment } = request;
const { url, byteRange } = segment;

if (byteRange) {
const { start, end } = byteRange;
const byteRangeString = `bytes=${start}-${end}`;
headers.set("Range", byteRangeString);
import {
Request,
RequestError,
HttpRequestErrorType,
RequestControls,
} from "./request";

type HttpSettings = Pick<Settings, "httpNotReceivingBytesTimeoutMs">;

export class HttpRequestExecutor {
private readonly requestControls: RequestControls;
private readonly requestHeaders = new Headers();
private readonly abortController = new AbortController();
private readonly expectedBytesLength?: number;
private readonly byteRange?: { start: number; end?: number };

constructor(
private readonly request: Request,
private readonly settings: HttpSettings
) {
const { byteRange } = this.request.segment;
if (byteRange) this.byteRange = { ...byteRange };

if (request.loadedBytes !== 0) {
this.byteRange = this.byteRange ?? { start: 0 };
this.byteRange.start = this.byteRange.start + request.loadedBytes;
}
if (this.request.totalBytes) {
this.expectedBytesLength =
this.request.totalBytes - this.request.loadedBytes;
}

if (this.byteRange) {
const { start, end } = this.byteRange;
this.requestHeaders.set("Range", `bytes=${start}-${end ?? ""}`);
}

const { httpNotReceivingBytesTimeoutMs } = this.settings;
this.requestControls = this.request.start(
{ type: "http" },
{
abort: () => this.abortController.abort("abort"),
notReceivingBytesTimeoutMs: httpNotReceivingBytesTimeoutMs,
}
);
void this.fetch();
}

const abortController = new AbortController();
const requestControls = request.start(
{ type: "http" },
{
abort: () => abortController.abort("abort"),
notReceivingBytesTimeoutMs: settings.httpNotReceivingBytesTimeoutMs,
private async fetch() {
const { segment } = this.request;
try {
const response = await window.fetch(segment.url, {
headers: this.requestHeaders,
signal: this.abortController.signal,
});
this.handleResponseHeaders(response);

if (!response.body) return;
const { requestControls } = this;
requestControls.firstBytesReceived();

const reader = response.body.getReader();
for await (const chunk of readStream(reader)) {
this.requestControls.addLoadedChunk(chunk);
}
requestControls.completeOnSuccess();
} catch (error) {
this.handleError(error);
}
);
try {
const fetchResponse = await window.fetch(url, {
headers,
signal: abortController.signal,
});
requestControls.firstBytesReceived();

if (!fetchResponse.ok) {
throw new RequestError("fetch-error", fetchResponse.statusText);
}

private handleResponseHeaders(response: Response) {
if (!response.ok) {
if (response.status === 406) {
this.request.clearLoadedBytes();
throw new RequestError("http-bytes-mismatch", response.statusText);
} else {
throw new RequestError("http-error", response.statusText);
}
}

if (!fetchResponse.body) return;
const totalBytesString = fetchResponse.headers.get("Content-Length");
if (totalBytesString) request.setTotalBytes(+totalBytesString);
const { byteRange } = this;
if (byteRange) {
if (response.status === 200) {
i-zolotarenko marked this conversation as resolved.
Show resolved Hide resolved
this.request.clearLoadedBytes();
} else {
if (response.status !== 206) {
this.request.clearLoadedBytes();
throw new RequestError("http-bytes-mismatch", response.statusText);
}
const contentLengthHeader = response.headers.get("Content-Length");
if (
contentLengthHeader &&
this.expectedBytesLength !== +contentLengthHeader
) {
this.request.clearLoadedBytes();
throw new RequestError("http-bytes-mismatch", response.statusText);
}

const contentRangeHeader = response.headers.get("Content-Range");
const contentRange = contentRangeHeader
? parseContentRangeHeader(contentRangeHeader)
: undefined;
if (contentRange) {
const { from, to, total } = contentRange;
if (
(total !== undefined && this.request.totalBytes !== total) ||
(from !== undefined && byteRange.start !== from) ||
(to !== undefined &&
byteRange.end !== undefined &&
byteRange.end !== to)
) {
this.request.clearLoadedBytes();
throw new RequestError("http-bytes-mismatch", response.statusText);
}
}
}
}

const reader = fetchResponse.body.getReader();
for await (const chunk of readStream(reader)) {
requestControls.addLoadedChunk(chunk);
if (response.status === 200 && this.request.totalBytes === undefined) {
const contentLengthHeader = response.headers.get("Content-Length");
if (contentLengthHeader) this.request.setTotalBytes(+contentLengthHeader);
}
requestControls.completeOnSuccess();
} catch (error) {
}

private handleError(error: unknown) {
if (error instanceof Error) {
if (error.name !== "abort") return;

const httpLoaderError: RequestError<HttpRequestErrorType> = !(
error instanceof RequestError
)
? new RequestError("fetch-error", error.message)
? new RequestError("http-error", error.message)
: error;
requestControls.abortOnError(httpLoaderError);
this.requestControls.abortOnError(httpLoaderError);
}
}
}
Expand All @@ -66,3 +147,17 @@ async function* readStream(
yield value;
}
}

function parseContentRangeHeader(headerValue: string) {
const match = headerValue
.trim()
.match(/^bytes (?:(?:(\d+)|)-(?:(\d+)|)|\*)\/(?:(\d+)|\*)$/);
if (!match) return;

const [, from, to, total] = match;
return {
from: from ? parseInt(from) : undefined,
to: to ? parseInt(to) : undefined,
total: total ? parseInt(total) : undefined,
};
}
Loading