Skip to content

Commit 828ce4f

Browse files
authored
dash support (#1047)
* add dash js * PlyrPlayer: implement dash player, not tested yet * add test dash videos * update mime type thing * add dash to video services * add mpd parser * refactor youtube video duration parsing into a new file * add dash to omniplayer * add dash service adapter * add some unit tests
1 parent 8338d83 commit 828ce4f

File tree

15 files changed

+503
-98
lines changed

15 files changed

+503
-98
lines changed

client/package.json

+54-53
Original file line numberDiff line numberDiff line change
@@ -1,54 +1,55 @@
11
{
2-
"name": "ott-client",
3-
"version": "0.8.0",
4-
"license": "AGPL-3.0-or-later",
5-
"type": "module",
6-
"scripts": {
7-
"serve": "vite serve",
8-
"build": "vite build",
9-
"lint": "tsc --noEmit && eslint --ext .js,.ts,.vue --fix .",
10-
"i18n:report": "vue-cli-service i18n:report --src \"./src/**/*.?(js|vue)\" --locales \"./src/locales/**/*.json\"",
11-
"lint-ci": "tsc --noEmit && eslint .",
12-
"test": "vitest run --coverage",
13-
"cy:open": "cypress open",
14-
"cy:run": "cypress run --component"
15-
},
16-
"dependencies": {
17-
"@fortawesome/fontawesome-free": "^5.14.1",
18-
"@mdi/font": "^3.9.97",
19-
"@peertube/embed-api": "^0.0.6",
20-
"@vimeo/player": "^2.20.1",
21-
"@vueuse/core": "^9.6.0",
22-
"hls.js": "1.4.8",
23-
"load-script": "^1.0.0",
24-
"material-design-icons-iconfont": "^5.0.1",
25-
"ott-common": "./common",
26-
"plyr": "3.7.8",
27-
"sortablejs": "^1.15.0",
28-
"sortablejs-vue3": "^1.2.3",
29-
"video.js": "^7.15.4",
30-
"vue": "3.2.47",
31-
"vue-axios": "^2.1.5",
32-
"vue-i18n": "9.2.2",
33-
"vue-router": "^4.1.5",
34-
"vue-slider-component": "4.1.0-beta.6",
35-
"vuetify": "3.3.3",
36-
"vuex": "4.1.0"
37-
},
38-
"devDependencies": {
39-
"@cypress/vue": "^5.0.3",
40-
"@types/video.js": "^7.3.29",
41-
"@types/vimeo__player": "^2.16.0",
42-
"@types/web": "0.0.80",
43-
"@vitejs/plugin-vue": "^4.0.0",
44-
"@vitest/coverage-c8": "^0.25.1",
45-
"@vue/eslint-config-typescript": "^11.0.3",
46-
"@vue/test-utils": "2.2.1",
47-
"eslint-plugin-vue": "9.7.0",
48-
"jsdom": "^21.1.0",
49-
"sass": "^1.41.1",
50-
"vite": "^4.4.2",
51-
"vite-plugin-vuetify": "1.0.2",
52-
"vitest": "^0.25.1"
53-
}
54-
}
2+
"name": "ott-client",
3+
"version": "0.8.0",
4+
"license": "AGPL-3.0-or-later",
5+
"type": "module",
6+
"scripts": {
7+
"serve": "vite serve",
8+
"build": "vite build",
9+
"lint": "tsc --noEmit && eslint --ext .js,.ts,.vue --fix .",
10+
"i18n:report": "vue-cli-service i18n:report --src \"./src/**/*.?(js|vue)\" --locales \"./src/locales/**/*.json\"",
11+
"lint-ci": "tsc --noEmit && eslint .",
12+
"test": "vitest run --coverage",
13+
"cy:open": "cypress open",
14+
"cy:run": "cypress run --component"
15+
},
16+
"dependencies": {
17+
"@fortawesome/fontawesome-free": "^5.14.1",
18+
"@mdi/font": "^3.9.97",
19+
"@peertube/embed-api": "^0.0.6",
20+
"@vimeo/player": "^2.20.1",
21+
"@vueuse/core": "^9.6.0",
22+
"dashjs": "4.7.1",
23+
"hls.js": "1.4.8",
24+
"load-script": "^1.0.0",
25+
"material-design-icons-iconfont": "^5.0.1",
26+
"ott-common": "./common",
27+
"plyr": "3.7.8",
28+
"sortablejs": "^1.15.0",
29+
"sortablejs-vue3": "^1.2.3",
30+
"video.js": "^7.15.4",
31+
"vue": "3.2.47",
32+
"vue-axios": "^2.1.5",
33+
"vue-i18n": "9.2.2",
34+
"vue-router": "^4.1.5",
35+
"vue-slider-component": "4.1.0-beta.6",
36+
"vuetify": "3.3.3",
37+
"vuex": "4.1.0"
38+
},
39+
"devDependencies": {
40+
"@cypress/vue": "^5.0.3",
41+
"@types/video.js": "^7.3.29",
42+
"@types/vimeo__player": "^2.16.0",
43+
"@types/web": "0.0.80",
44+
"@vitejs/plugin-vue": "^4.0.0",
45+
"@vitest/coverage-c8": "^0.25.1",
46+
"@vue/eslint-config-typescript": "^11.0.3",
47+
"@vue/test-utils": "2.2.1",
48+
"eslint-plugin-vue": "9.7.0",
49+
"jsdom": "^21.1.0",
50+
"sass": "^1.41.1",
51+
"vite": "^4.4.2",
52+
"vite-plugin-vuetify": "1.0.2",
53+
"vitest": "^0.25.1"
54+
}
55+
}

client/src/components/AddPreview.vue

+5
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,11 @@ export const AddPreview = defineComponent({
217217
"test hls 1",
218218
"https://demo.unified-streaming.com/k8s/features/stable/video/tears-of-steel/tears-of-steel.ism/.m3u8",
219219
],
220+
[
221+
"test dash 0",
222+
"https://dash.akamaized.net/dash264/TestCases/1a/sony/SNE_DASH_SD_CASE1A_REVISED.mpd",
223+
],
224+
["test dash 1", "https://dash.akamaized.net/envivio/EnvivioDash3/manifest.mpd"],
220225
["test peertube 0", "https://the.jokertv.eu/w/7C5YZTLVudL4FLN4JmVvnA"],
221226
]
222227
: [];

client/src/components/players/OmniPlayer.vue

+1-1
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@
7474
<PlyrPlayer
7575
v-else-if="
7676
!!source &&
77-
['direct', 'hls', 'reddit', 'tubi', 'pluto'].includes(source.service)
77+
['direct', 'hls', 'dash', 'reddit', 'tubi', 'pluto'].includes(source.service)
7878
"
7979
ref="player"
8080
:service="source.service"

client/src/components/players/PlyrPlayer.vue

+44
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import { defineComponent, onMounted, ref, watch, onBeforeUnmount, toRefs } from "vue";
99
import Plyr from "plyr";
1010
import Hls from "hls.js";
11+
import dashjs from "dashjs";
1112
import "plyr/src/sass/plyr.scss";
1213
import { useStore } from "@/store";
1314
@@ -36,6 +37,7 @@ export default defineComponent({
3637
const videoElem = ref<HTMLVideoElement | undefined>();
3738
const player = ref<Plyr | undefined>();
3839
let hls: Hls | undefined = undefined;
40+
let dash: dashjs.MediaPlayerClass | undefined = undefined;
3941
const store = useStore();
4042
4143
function play() {
@@ -247,6 +249,48 @@ export default defineComponent({
247249
hls.on(Hls.Events.KEY_LOADED, () => {
248250
console.info("PlyrPlayer: hls.js key loaded");
249251
});
252+
} else if (videoMime.value === "application/dash+xml") {
253+
if (!videoElem.value) {
254+
console.error("video element not ready");
255+
return;
256+
}
257+
dash = dashjs.MediaPlayer().create();
258+
// HACK: force the video element to be recreated...
259+
player.value.source = {
260+
type: "video",
261+
sources: [],
262+
poster: thumbnail.value,
263+
};
264+
videoElem.value = document.querySelector("video") as HTMLVideoElement;
265+
// ...so that we can use dash.js to change the video source
266+
dash.initialize(videoElem.value, videoUrl.value, false);
267+
268+
dash.on("manifestLoaded", () => {
269+
console.info("PlyrPlayer: dash.js manifest loaded");
270+
emit("ready");
271+
store.commit("captions/SET_AVAILABLE_TRACKS", {
272+
tracks: getCaptionsTracks(),
273+
});
274+
});
275+
dash.on("error", (event: unknown) => {
276+
console.error("PlyrPlayer: dash.js error:", event);
277+
emit("error");
278+
});
279+
dash.on("playbackError", (event: unknown) => {
280+
console.error("PlyrPlayer: dash.js playback error:", event);
281+
emit("error");
282+
});
283+
dash.on("streamInitialized", () => {
284+
console.info("PlyrPlayer: dash.js stream initialized");
285+
});
286+
dash.on("bufferStalled", () => {
287+
console.info("PlyrPlayer: dash.js buffer stalled");
288+
emit("buffering");
289+
});
290+
dash.on("bufferLoaded", () => {
291+
console.info("PlyrPlayer: dash.js buffer loaded");
292+
emit("ready");
293+
});
250294
} else {
251295
hls?.destroy();
252296
hls = undefined;

common/constants.ts

+1
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export const ALL_VIDEO_SERVICES = [
77
"dailymotion",
88
"direct",
99
"hls",
10+
"dash",
1011
"tubi",
1112
"reddit",
1213
"googledrive",

server/infoextractor.ts

+4
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import { Counter } from "prom-client";
2626
import { conf } from "./ott-config";
2727
import PeertubeAdapter from "./services/peertube";
2828
import PlutoAdapter from "./services/pluto";
29+
import DashVideoAdapter from "./services/dash";
2930

3031
const log = getLogger("infoextract");
3132

@@ -82,6 +83,9 @@ export async function initExtractor() {
8283
if (enabled.includes("hls")) {
8384
adapters.push(new HlsVideoAdapter());
8485
}
86+
if (enabled.includes("dash")) {
87+
adapters.push(new DashVideoAdapter());
88+
}
8589
if (enabled.includes("pluto")) {
8690
adapters.push(new PlutoAdapter());
8791
}

server/mime.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ const mimeTypes = {
1717
"audio/aac": ["aac"],
1818
"audio/flac": ["flac"],
1919
"audio/x-aiff": ["aif", "aiff", "aifc"],
20+
"application/dash+xml": ["mpd"],
2021
};
2122

2223
export function getMimeType(extension: string): string | undefined {
@@ -28,7 +29,7 @@ export function getMimeType(extension: string): string | undefined {
2829
}
2930

3031
export function isSupportedMimeType(mimeType: string): boolean {
31-
if (mimeType === "application/x-mpegURL") {
32+
if (mimeType === "application/x-mpegURL" || mimeType === "application/dash+xml") {
3233
return true;
3334
}
3435
if (/^video\/(?!x-flv)(?!x-matroska)(?!x-ms-wmv)(?!x-msvideo)[a-z0-9-]+$/.exec(mimeType)) {

server/package.json

+2-1
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
"express": "^4.17.1",
2222
"express-session": "^1.17.0",
2323
"m3u8-parser": "^6.2.0",
24+
"@liveinstantly/dash-mpd-parser": "0.5.0",
2425
"nocache": "^3.0.0",
2526
"node-abort-controller": "3.0.1",
2627
"node-mailjet": "^6.0.3",
@@ -65,4 +66,4 @@
6566
"redis-mock": "^0.56.3",
6667
"sqlite3": "5.1.5"
6768
}
68-
}
69+
}

server/services/dash.ts

+120
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import URL from "url";
2+
import _ from "lodash";
3+
import { ServiceAdapter } from "../serviceadapter";
4+
import {
5+
LocalFileException,
6+
UnsupportedMimeTypeException,
7+
UnsupportedVideoType,
8+
} from "../exceptions";
9+
import { getMimeType, isSupportedMimeType } from "../mime";
10+
import { getLogger } from "../logger";
11+
import { Video } from "../../common/models/video";
12+
import { DashMPD } from "@liveinstantly/dash-mpd-parser";
13+
import axios from "axios";
14+
import { parseIso8601Duration } from "./parsing/iso8601";
15+
16+
const log = getLogger("dash");
17+
18+
export default class DashVideoAdapter extends ServiceAdapter {
19+
get serviceId(): "dash" {
20+
return "dash";
21+
}
22+
23+
get isCacheSafe(): boolean {
24+
return false;
25+
}
26+
27+
isCollectionURL(link: string): boolean {
28+
return false;
29+
}
30+
31+
getVideoId(link: string): string {
32+
return link;
33+
}
34+
35+
canHandleURL(link: string): boolean {
36+
const url = URL.parse(link);
37+
return /\/*\.(mpd)$/.test((url.path ?? "/").split("?")[0]);
38+
}
39+
40+
async fetchVideoInfo(link: string): Promise<Video> {
41+
const url = URL.parse(link);
42+
if (url.protocol === "file:") {
43+
throw new LocalFileException();
44+
}
45+
const fileName = (url.pathname ?? "").split("/").slice(-1)[0].trim();
46+
const extension = fileName.split(".").slice(-1)[0];
47+
const mime = getMimeType(extension) ?? "unknown";
48+
if (!isSupportedMimeType(mime)) {
49+
throw new UnsupportedMimeTypeException(mime);
50+
}
51+
return await this.handleMpd(url);
52+
}
53+
54+
async handleMpd(url: URL.UrlWithStringQuery): Promise<Video> {
55+
const resp = await axios.get(url.href);
56+
const mpd = new DashMPD();
57+
mpd.parse(resp.data);
58+
const manifest = mpd.getJSON();
59+
60+
return this.parseMpdManifest(url, manifest);
61+
}
62+
63+
parseMpdManifest(url: URL.UrlWithStringQuery, manifest: any): Video {
64+
// docs for how the parser works: https://github.com/liveinstantly/dash-mpd-parser
65+
66+
log.debug(JSON.stringify(manifest));
67+
68+
const profiles: string = manifest["MPD"]["@profiles"] ?? "";
69+
if (profiles.includes("isoff-live")) {
70+
// live streams are not supported right now
71+
// technically, there are VOD streams that use this profile, but im feeling lazy rn
72+
throw new UnsupportedVideoType("livestream");
73+
}
74+
75+
const durationRaw: string = manifest["MPD"]["@mediaPresentationDuration"];
76+
const duration = parseIso8601Duration(durationRaw);
77+
78+
const title = this.extractTitle(manifest);
79+
80+
return {
81+
service: this.serviceId,
82+
id: url.href,
83+
title: title ?? url.pathname?.split("/").slice(-1)[0] ?? url.href,
84+
description: `Full Link: ${url.href}`,
85+
mime: "application/dash+xml",
86+
length: duration,
87+
dash_url: url.href,
88+
};
89+
}
90+
91+
/**
92+
* Attempts to find a title for the video from the manifest. Returns undefined if no title is found.
93+
*
94+
* Video metadata is not always available in the manifest, and it's not standardized, so this method will probably usually fail.
95+
*/
96+
extractTitle(manifest: any): string | undefined {
97+
try {
98+
if ("ProgramInformation" in manifest["MPD"]) {
99+
return manifest["MPD"]["ProgramInformation"]["Title"];
100+
}
101+
102+
const periods = manifest["MPD"]["Period"];
103+
for (const period of periods) {
104+
const adaptationSets = period["AdaptationSet"];
105+
for (const adaptationSet of adaptationSets) {
106+
const representations = adaptationSet["Representation"];
107+
for (const representation of representations) {
108+
if ("Title" in representation) {
109+
return representation["Title"];
110+
}
111+
}
112+
}
113+
}
114+
} catch (e) {
115+
log.warn("Error extracting title from manifest", e);
116+
}
117+
118+
return undefined;
119+
}
120+
}

0 commit comments

Comments
 (0)