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

Parse playlists and identify them by loaded segments. #288

Merged
merged 24 commits into from
Jun 15, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
932b882
Install m3u8-parser package.
i-zolotarenko Jun 8, 2023
31415e8
Declare m3u8-parser package types.
i-zolotarenko Jun 8, 2023
ec5ad98
Add segment manager.
i-zolotarenko Jun 8, 2023
0593452
Parse playlists.
i-zolotarenko Jun 8, 2023
f4018d0
Parse playlist and identify playlist by loaded segment.
i-zolotarenko Jun 9, 2023
c7aa0cf
Separate video, audio playlists to single class.
i-zolotarenko Jun 9, 2023
8941a63
Add byte range segments.
i-zolotarenko Jun 13, 2023
41899e3
Use only common map instead of playlist container.
i-zolotarenko Jun 13, 2023
cb30a3f
Handle master manifest load after playlist.
i-zolotarenko Jun 13, 2023
aab84cd
Add ids to playlists.
i-zolotarenko Jun 13, 2023
50ff65b
Fix issue with wrong playlist indexes.
i-zolotarenko Jun 14, 2023
e0fb013
Handle single playlist only if manifest is not loaded.
i-zolotarenko Jun 14, 2023
6ed8e1b
Install and use debug.
i-zolotarenko Jun 14, 2023
7a039e9
Merge with v1.
i-zolotarenko Jun 14, 2023
27ecb75
Move playlist and segment classes to separate module.
i-zolotarenko Jun 14, 2023
690de47
Use triple slash directive to include m3u8-parser types.
i-zolotarenko Jun 14, 2023
7dd76aa
Add is playlist manifest type guard.
i-zolotarenko Jun 14, 2023
6e20fa3
Move debug package to workspace root.
i-zolotarenko Jun 15, 2023
37b4c69
Enable curly eslint rule. Fix eslint warnings.
i-zolotarenko Jun 15, 2023
c485506
Rename segment id to localId. Remove parameters from urls.
i-zolotarenko Jun 15, 2023
1e0119b
Fix issue with request response urls.
i-zolotarenko Jun 15, 2023
d936103
Remove parameters from urls.
i-zolotarenko Jun 15, 2023
33ea338
Remove unnecessary url parameters trimming.
i-zolotarenko Jun 15, 2023
ede214b
Get segment request url based on playlist response.
i-zolotarenko Jun 15, 2023
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 .eslintrc.common.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,6 @@ module.exports = {
"prettier/prettier": "error",
"no-console": "warn",
"@typescript-eslint/prefer-nullish-coalescing": "error",
"curly": ["warn", "multi-line", "consistent"]
},
};
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ pnpm-debug.log*
lerna-debug.log*

node_modules
/p2p-media-loader-demo/dist
/packages/*/dist
/packages/*/lib
/packages/*/build
Expand Down
8 changes: 7 additions & 1 deletion p2p-media-loader-demo/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ const videoUrl = {
byteRangeVideo:
"https://devstreaming-cdn.apple.com/videos/streaming/examples/bipbop_16x9/bipbop_16x9_variant.m3u8",
live: "https://fcc3ddae59ed.us-west-2.playback.live-video.net/api/video/v1/us-west-2.893648527354.channel.DmumNckWFTqz.m3u8",
advancedVideo:
"https://devstreaming-cdn.apple.com/videos/streaming/examples/adv_dv_atmos/main.m3u8",
advancedVideo2:
"https://devstreaming-cdn.apple.com/videos/streaming/examples/bipbop_adv_example_hevc/master.m3u8",
basicExample:
"https://devstreaming-cdn.apple.com/videos/streaming/examples/bipbop_4x3/bipbop_4x3_variant.m3u8",
};

const players = ["hlsjs", "dplayer"] as const;
Expand All @@ -22,7 +28,7 @@ function App() {
if (!Hls.isSupported()) return;

let player: DPlayer | Hls;
const url = videoUrl.live;
const url = videoUrl.advancedVideo2;
if (playerType === "dplayer" && containerRef.current) {
player = new DPlayer({
container: containerRef.current,
Expand Down
8 changes: 6 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,17 @@
"prettier": "pnpm --recursive run prettier"
},
"devDependencies": {
"@types/debug": "^4.1.8",
"@typescript-eslint/eslint-plugin": "^5.59.2",
"@typescript-eslint/parser": "^5.59.2",
"eslint": "^8.39.0",
"eslint-plugin-prettier": "^4.2.1",
"prettier": "^2.8.8",
"rimraf": "^5.0.0",
"typescript": "^5.0.2",
"vite": "^4.3.2",
"prettier": "^2.8.8"
"vite": "^4.3.2"
},
"dependencies": {
"debug": "^4.3.4"
}
}
1 change: 1 addition & 0 deletions packages/p2p-media-loader-hlsjs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
},
"dependencies": {
"hls.js": "^1.4.5",
"m3u8-parser": "^6.2.0",
"p2p-media-loader-core": "workspace:*"
}
}
1 change: 1 addition & 0 deletions packages/p2p-media-loader-hlsjs/src/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
/// <reference types="./types/m3u8-parser.d.ts" />
export { Engine } from "./services/engine";
13 changes: 11 additions & 2 deletions packages/p2p-media-loader-hlsjs/src/services/engine.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,15 @@
import type { HlsConfig } from "hls.js";
import { PlaylistLoaderBase } from "./playlist-loader";
import { FragmentLoaderBase } from "./fragment-loader";
import { SegmentManager } from "./segment-mananger";

export class Engine {
segmentManager: SegmentManager;

constructor() {
this.segmentManager = new SegmentManager();
}

public getConfig(): Pick<HlsConfig, "pLoader" | "fLoader"> {
return {
pLoader: this.createPlaylistLoaderClass(),
Expand All @@ -11,12 +18,13 @@ export class Engine {
}

private createPlaylistLoaderClass() {
const segmentManager = this.segmentManager;
// eslint-disable-next-line @typescript-eslint/no-this-alias
const engine = this;

return class PlaylistLoader extends PlaylistLoaderBase {
constructor(config: HlsConfig) {
super(config);
super(config, segmentManager);
}

static getEngine() {
Expand All @@ -26,12 +34,13 @@ export class Engine {
}

private createFragmentLoaderClass() {
const segmentManager = this.segmentManager;
// eslint-disable-next-line @typescript-eslint/no-this-alias
const engine = this;

return class FragmentLoader extends FragmentLoaderBase {
constructor(config: HlsConfig) {
super(config);
super(config, segmentManager);
}

static getEngine() {
Expand Down
23 changes: 21 additions & 2 deletions packages/p2p-media-loader-hlsjs/src/services/fragment-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,21 @@ import type {
HlsConfig,
LoaderContext,
} from "hls.js";
import { SegmentManager } from "./segment-mananger";
import { Segment, ByteRange } from "./playlist";
import Debug from "debug";

export class FragmentLoaderBase implements Loader<FragmentLoaderContext> {
context!: FragmentLoaderContext;
config!: LoaderConfiguration | null;
callbacks!: LoaderCallbacks<FragmentLoaderContext> | null;
stats: LoaderStats;
defaultLoader: Loader<LoaderContext>;
segmentManager: SegmentManager;
private debug = Debug("p2pml:fragment-loader");

constructor(config: HlsConfig) {
constructor(config: HlsConfig, segmentManager: SegmentManager) {
this.segmentManager = segmentManager;
this.defaultLoader = new config.loader(config);
this.stats = this.defaultLoader.stats;
}
Expand All @@ -28,7 +34,20 @@ export class FragmentLoaderBase implements Loader<FragmentLoaderContext> {
this.context = context;
this.config = config;
this.callbacks = callbacks;
this.defaultLoader.load(context, config, callbacks);
this.defaultLoader.load(context, config, {
...callbacks,
onSuccess: (response, stats, context, networkDetails) => {
const { rangeStart, rangeEnd } = context;
const byteRange: ByteRange | undefined = Segment.getByteRange(
rangeStart,
rangeEnd
);
const segmentId = Segment.getSegmentLocalId(context.url, byteRange);
const playlist = this.segmentManager.getPlaylistBySegmentId(segmentId);
this.debug("downloaded segment from playlist", playlist);
return callbacks.onSuccess(response, stats, context, networkDetails);
},
});
}

abort() {
Expand Down
76 changes: 76 additions & 0 deletions packages/p2p-media-loader-hlsjs/src/services/manifest-util.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { MasterManifest, PlaylistManifest } from "m3u8-parser";
import { Playlist } from "./playlist";

export function isMasterManifest(
manifest: PlaylistManifest | MasterManifest
): manifest is MasterManifest {
const { mediaGroups, playlists } = manifest as MasterManifest;
return (
playlists !== undefined &&
Array.isArray(playlists) &&
mediaGroups !== undefined &&
typeof mediaGroups === "object"
);
}

export function isPlaylistManifest(
manifest: PlaylistManifest | MasterManifest
) {
const { mediaSequence, segments } = manifest as PlaylistManifest;
return (
mediaSequence !== undefined &&
typeof mediaSequence === "number" &&
segments !== undefined &&
Array.isArray(segments)
);
}

export function getAudioPlaylistsFromMasterManifest(
masterManifestUrl: { request: string; response: string },
masterManifest: MasterManifest
): Playlist[] {
const { mediaGroups } = masterManifest;

const audio = Object.values(mediaGroups.AUDIO);
const playlists: Playlist[] = [];
if (audio.length) {
audio.forEach((languageMap) => {
const languages = Object.values(languageMap);
languages.forEach((item) => {
playlists.push(
new Playlist({
type: "audio",
url: item.uri,
manifestUrl: masterManifestUrl,
mediaSequence: 0,
index: playlists.length,
})
);
});
});
}

return playlists;
}

export function getVideoPlaylistsFromMasterManifest(
masterManifestUrl: { request: string; response: string },
masterManifest: MasterManifest
): Playlist[] {
const uriSet = new Set<string>();
return masterManifest.playlists.reduce<Playlist[]>((list, p) => {
if (!uriSet.has(p.uri)) {
const playlist = new Playlist({
type: "video",
url: p.uri,
manifestUrl: masterManifestUrl,
mediaSequence: 0,
index: list.length,
});
list.push(playlist);
}
uriSet.add(p.uri);

return list;
}, []);
}
19 changes: 16 additions & 3 deletions packages/p2p-media-loader-hlsjs/src/services/playlist-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,20 @@ import type {
LoaderStats,
LoaderContext,
} from "hls.js";
import { SegmentManager } from "./segment-mananger";

export class PlaylistLoaderBase implements Loader<PlaylistLoaderContext> {
context!: PlaylistLoaderContext;
config!: LoaderConfiguration;
callbacks!: LoaderCallbacks<PlaylistLoaderContext>;
stats!: LoaderStats;
stats: LoaderStats;
defaultLoader: Loader<LoaderContext>;
segmentManager: SegmentManager;

constructor(config: HlsConfig) {
constructor(config: HlsConfig, segmentManager: SegmentManager) {
this.defaultLoader = new config.loader(config);
this.stats = this.defaultLoader.stats;
this.segmentManager = segmentManager;
}

load(
Expand All @@ -28,7 +31,17 @@ export class PlaylistLoaderBase implements Loader<PlaylistLoaderContext> {
this.context = context;
this.config = config;
this.callbacks = callbacks;
this.defaultLoader.load(context, config, callbacks);
this.defaultLoader.load(context, config, {
...callbacks,
onSuccess: (response, stats, context, networkDetails) => {
const { data, url: responseUrl } = response;
const { url: requestUrl } = context;
if (typeof data === "string") {
this.segmentManager.processPlaylist(data, requestUrl, responseUrl);
}
return callbacks.onSuccess(response, stats, context, networkDetails);
},
});
}

abort() {
Expand Down
84 changes: 84 additions & 0 deletions packages/p2p-media-loader-hlsjs/src/services/playlist.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { Segment as ParserSegment } from "m3u8-parser";
mrlika marked this conversation as resolved.
Show resolved Hide resolved

export class Playlist {
id: string;
index: number;
type: SegmentType;
requestUrl: string;
responseUrl?: string;
segmentsMap: Map<string, Segment> = new Map<string, Segment>();
mediaSequence: number;

constructor({
type,
url,
manifestUrl,
mediaSequence,
index,
}: {
type: SegmentType;
url: string;
manifestUrl?: { request: string; response: string };
mediaSequence: number;
index: number;
}) {
this.type = type;
this.index = index;
this.requestUrl = new URL(url, manifestUrl?.response).toString();
this.id = manifestUrl?.request
? `${getUrlWithoutParameters(manifestUrl.request)}-${type}-V${index}`
: getUrlWithoutParameters(this.requestUrl);
this.mediaSequence = mediaSequence;
}

setSegments(playlistResponseUrl: string, segments: ParserSegment[]) {
this.responseUrl = playlistResponseUrl;
const mapEntries = segments.map<[string, Segment]>((s) => {
const segment = new Segment(s.uri, playlistResponseUrl, s.byterange);
return [segment.localId, segment];
});
this.segmentsMap = new Map(mapEntries);
}
}

export class Segment {
localId: string;
url: string;
uri: string;
byteRange?: ByteRange;

constructor(uri: string, playlistUrl: string, byteRange?: ByteRange) {
this.uri = uri;
this.url = new URL(uri, playlistUrl).toString();
this.byteRange = byteRange;
this.localId = Segment.getSegmentLocalId(this.url, this.byteRange);
}

static getSegmentLocalId(segmentRequestUrl: string, byteRange?: ByteRange) {
if (!byteRange) return segmentRequestUrl;
const end = byteRange.offset + byteRange.length - 1;
return `${segmentRequestUrl}|${byteRange.offset}-${end}`;
}

static getByteRange(
rangeStart?: number,
rangeEnd?: number
): ByteRange | undefined {
if (
rangeStart === undefined ||
mrlika marked this conversation as resolved.
Show resolved Hide resolved
rangeEnd === undefined ||
rangeStart >= rangeEnd
) {
return undefined;
}
return { offset: rangeStart, length: rangeEnd - rangeStart };
}
}

type SegmentType = "video" | "audio" | "unknown";

export type ByteRange = { offset: number; length: number };

function getUrlWithoutParameters(url: string) {
return url.split("?")[0];
}
Loading