Skip to content

Commit

Permalink
dash support (#1047)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
dyc3 authored Jan 14, 2024
1 parent 8338d83 commit 828ce4f
Show file tree
Hide file tree
Showing 15 changed files with 503 additions and 98 deletions.
107 changes: 54 additions & 53 deletions client/package.json
Original file line number Diff line number Diff line change
@@ -1,54 +1,55 @@
{
"name": "ott-client",
"version": "0.8.0",
"license": "AGPL-3.0-or-later",
"type": "module",
"scripts": {
"serve": "vite serve",
"build": "vite build",
"lint": "tsc --noEmit && eslint --ext .js,.ts,.vue --fix .",
"i18n:report": "vue-cli-service i18n:report --src \"./src/**/*.?(js|vue)\" --locales \"./src/locales/**/*.json\"",
"lint-ci": "tsc --noEmit && eslint .",
"test": "vitest run --coverage",
"cy:open": "cypress open",
"cy:run": "cypress run --component"
},
"dependencies": {
"@fortawesome/fontawesome-free": "^5.14.1",
"@mdi/font": "^3.9.97",
"@peertube/embed-api": "^0.0.6",
"@vimeo/player": "^2.20.1",
"@vueuse/core": "^9.6.0",
"hls.js": "1.4.8",
"load-script": "^1.0.0",
"material-design-icons-iconfont": "^5.0.1",
"ott-common": "./common",
"plyr": "3.7.8",
"sortablejs": "^1.15.0",
"sortablejs-vue3": "^1.2.3",
"video.js": "^7.15.4",
"vue": "3.2.47",
"vue-axios": "^2.1.5",
"vue-i18n": "9.2.2",
"vue-router": "^4.1.5",
"vue-slider-component": "4.1.0-beta.6",
"vuetify": "3.3.3",
"vuex": "4.1.0"
},
"devDependencies": {
"@cypress/vue": "^5.0.3",
"@types/video.js": "^7.3.29",
"@types/vimeo__player": "^2.16.0",
"@types/web": "0.0.80",
"@vitejs/plugin-vue": "^4.0.0",
"@vitest/coverage-c8": "^0.25.1",
"@vue/eslint-config-typescript": "^11.0.3",
"@vue/test-utils": "2.2.1",
"eslint-plugin-vue": "9.7.0",
"jsdom": "^21.1.0",
"sass": "^1.41.1",
"vite": "^4.4.2",
"vite-plugin-vuetify": "1.0.2",
"vitest": "^0.25.1"
}
}
"name": "ott-client",
"version": "0.8.0",
"license": "AGPL-3.0-or-later",
"type": "module",
"scripts": {
"serve": "vite serve",
"build": "vite build",
"lint": "tsc --noEmit && eslint --ext .js,.ts,.vue --fix .",
"i18n:report": "vue-cli-service i18n:report --src \"./src/**/*.?(js|vue)\" --locales \"./src/locales/**/*.json\"",
"lint-ci": "tsc --noEmit && eslint .",
"test": "vitest run --coverage",
"cy:open": "cypress open",
"cy:run": "cypress run --component"
},
"dependencies": {
"@fortawesome/fontawesome-free": "^5.14.1",
"@mdi/font": "^3.9.97",
"@peertube/embed-api": "^0.0.6",
"@vimeo/player": "^2.20.1",
"@vueuse/core": "^9.6.0",
"dashjs": "4.7.1",
"hls.js": "1.4.8",
"load-script": "^1.0.0",
"material-design-icons-iconfont": "^5.0.1",
"ott-common": "./common",
"plyr": "3.7.8",
"sortablejs": "^1.15.0",
"sortablejs-vue3": "^1.2.3",
"video.js": "^7.15.4",
"vue": "3.2.47",
"vue-axios": "^2.1.5",
"vue-i18n": "9.2.2",
"vue-router": "^4.1.5",
"vue-slider-component": "4.1.0-beta.6",
"vuetify": "3.3.3",
"vuex": "4.1.0"
},
"devDependencies": {
"@cypress/vue": "^5.0.3",
"@types/video.js": "^7.3.29",
"@types/vimeo__player": "^2.16.0",
"@types/web": "0.0.80",
"@vitejs/plugin-vue": "^4.0.0",
"@vitest/coverage-c8": "^0.25.1",
"@vue/eslint-config-typescript": "^11.0.3",
"@vue/test-utils": "2.2.1",
"eslint-plugin-vue": "9.7.0",
"jsdom": "^21.1.0",
"sass": "^1.41.1",
"vite": "^4.4.2",
"vite-plugin-vuetify": "1.0.2",
"vitest": "^0.25.1"
}
}
5 changes: 5 additions & 0 deletions client/src/components/AddPreview.vue
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,11 @@ export const AddPreview = defineComponent({
"test hls 1",
"https://demo.unified-streaming.com/k8s/features/stable/video/tears-of-steel/tears-of-steel.ism/.m3u8",
],
[
"test dash 0",
"https://dash.akamaized.net/dash264/TestCases/1a/sony/SNE_DASH_SD_CASE1A_REVISED.mpd",
],
["test dash 1", "https://dash.akamaized.net/envivio/EnvivioDash3/manifest.mpd"],
["test peertube 0", "https://the.jokertv.eu/w/7C5YZTLVudL4FLN4JmVvnA"],
]
: [];
Expand Down
2 changes: 1 addition & 1 deletion client/src/components/players/OmniPlayer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@
<PlyrPlayer
v-else-if="
!!source &&
['direct', 'hls', 'reddit', 'tubi', 'pluto'].includes(source.service)
['direct', 'hls', 'dash', 'reddit', 'tubi', 'pluto'].includes(source.service)
"
ref="player"
:service="source.service"
Expand Down
44 changes: 44 additions & 0 deletions client/src/components/players/PlyrPlayer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import { defineComponent, onMounted, ref, watch, onBeforeUnmount, toRefs } from "vue";
import Plyr from "plyr";
import Hls from "hls.js";
import dashjs from "dashjs";
import "plyr/src/sass/plyr.scss";
import { useStore } from "@/store";
Expand Down Expand Up @@ -36,6 +37,7 @@ export default defineComponent({
const videoElem = ref<HTMLVideoElement | undefined>();
const player = ref<Plyr | undefined>();
let hls: Hls | undefined = undefined;
let dash: dashjs.MediaPlayerClass | undefined = undefined;
const store = useStore();
function play() {
Expand Down Expand Up @@ -247,6 +249,48 @@ export default defineComponent({
hls.on(Hls.Events.KEY_LOADED, () => {
console.info("PlyrPlayer: hls.js key loaded");
});
} else if (videoMime.value === "application/dash+xml") {
if (!videoElem.value) {
console.error("video element not ready");
return;
}
dash = dashjs.MediaPlayer().create();
// HACK: force the video element to be recreated...
player.value.source = {
type: "video",
sources: [],
poster: thumbnail.value,
};
videoElem.value = document.querySelector("video") as HTMLVideoElement;
// ...so that we can use dash.js to change the video source
dash.initialize(videoElem.value, videoUrl.value, false);
dash.on("manifestLoaded", () => {
console.info("PlyrPlayer: dash.js manifest loaded");
emit("ready");
store.commit("captions/SET_AVAILABLE_TRACKS", {
tracks: getCaptionsTracks(),
});
});
dash.on("error", (event: unknown) => {
console.error("PlyrPlayer: dash.js error:", event);
emit("error");
});
dash.on("playbackError", (event: unknown) => {
console.error("PlyrPlayer: dash.js playback error:", event);
emit("error");
});
dash.on("streamInitialized", () => {
console.info("PlyrPlayer: dash.js stream initialized");
});
dash.on("bufferStalled", () => {
console.info("PlyrPlayer: dash.js buffer stalled");
emit("buffering");
});
dash.on("bufferLoaded", () => {
console.info("PlyrPlayer: dash.js buffer loaded");
emit("ready");
});
} else {
hls?.destroy();
hls = undefined;
Expand Down
1 change: 1 addition & 0 deletions common/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export const ALL_VIDEO_SERVICES = [
"dailymotion",
"direct",
"hls",
"dash",
"tubi",
"reddit",
"googledrive",
Expand Down
4 changes: 4 additions & 0 deletions server/infoextractor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { Counter } from "prom-client";
import { conf } from "./ott-config";
import PeertubeAdapter from "./services/peertube";
import PlutoAdapter from "./services/pluto";
import DashVideoAdapter from "./services/dash";

const log = getLogger("infoextract");

Expand Down Expand Up @@ -82,6 +83,9 @@ export async function initExtractor() {
if (enabled.includes("hls")) {
adapters.push(new HlsVideoAdapter());
}
if (enabled.includes("dash")) {
adapters.push(new DashVideoAdapter());
}
if (enabled.includes("pluto")) {
adapters.push(new PlutoAdapter());
}
Expand Down
3 changes: 2 additions & 1 deletion server/mime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const mimeTypes = {
"audio/aac": ["aac"],
"audio/flac": ["flac"],
"audio/x-aiff": ["aif", "aiff", "aifc"],
"application/dash+xml": ["mpd"],
};

export function getMimeType(extension: string): string | undefined {
Expand All @@ -28,7 +29,7 @@ export function getMimeType(extension: string): string | undefined {
}

export function isSupportedMimeType(mimeType: string): boolean {
if (mimeType === "application/x-mpegURL") {
if (mimeType === "application/x-mpegURL" || mimeType === "application/dash+xml") {
return true;
}
if (/^video\/(?!x-flv)(?!x-matroska)(?!x-ms-wmv)(?!x-msvideo)[a-z0-9-]+$/.exec(mimeType)) {
Expand Down
3 changes: 2 additions & 1 deletion server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
"express": "^4.17.1",
"express-session": "^1.17.0",
"m3u8-parser": "^6.2.0",
"@liveinstantly/dash-mpd-parser": "0.5.0",
"nocache": "^3.0.0",
"node-abort-controller": "3.0.1",
"node-mailjet": "^6.0.3",
Expand Down Expand Up @@ -65,4 +66,4 @@
"redis-mock": "^0.56.3",
"sqlite3": "5.1.5"
}
}
}
120 changes: 120 additions & 0 deletions server/services/dash.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import URL from "url";
import _ from "lodash";
import { ServiceAdapter } from "../serviceadapter";
import {
LocalFileException,
UnsupportedMimeTypeException,
UnsupportedVideoType,
} from "../exceptions";
import { getMimeType, isSupportedMimeType } from "../mime";
import { getLogger } from "../logger";
import { Video } from "../../common/models/video";
import { DashMPD } from "@liveinstantly/dash-mpd-parser";
import axios from "axios";
import { parseIso8601Duration } from "./parsing/iso8601";

const log = getLogger("dash");

export default class DashVideoAdapter extends ServiceAdapter {
get serviceId(): "dash" {
return "dash";
}

get isCacheSafe(): boolean {
return false;
}

isCollectionURL(link: string): boolean {
return false;
}

getVideoId(link: string): string {
return link;
}

canHandleURL(link: string): boolean {
const url = URL.parse(link);
return /\/*\.(mpd)$/.test((url.path ?? "/").split("?")[0]);
}

async fetchVideoInfo(link: string): Promise<Video> {
const url = URL.parse(link);
if (url.protocol === "file:") {
throw new LocalFileException();
}
const fileName = (url.pathname ?? "").split("/").slice(-1)[0].trim();
const extension = fileName.split(".").slice(-1)[0];
const mime = getMimeType(extension) ?? "unknown";
if (!isSupportedMimeType(mime)) {
throw new UnsupportedMimeTypeException(mime);
}
return await this.handleMpd(url);
}

async handleMpd(url: URL.UrlWithStringQuery): Promise<Video> {
const resp = await axios.get(url.href);
const mpd = new DashMPD();
mpd.parse(resp.data);
const manifest = mpd.getJSON();

return this.parseMpdManifest(url, manifest);
}

parseMpdManifest(url: URL.UrlWithStringQuery, manifest: any): Video {
// docs for how the parser works: https://github.com/liveinstantly/dash-mpd-parser

log.debug(JSON.stringify(manifest));

const profiles: string = manifest["MPD"]["@profiles"] ?? "";
if (profiles.includes("isoff-live")) {
// live streams are not supported right now
// technically, there are VOD streams that use this profile, but im feeling lazy rn
throw new UnsupportedVideoType("livestream");
}

const durationRaw: string = manifest["MPD"]["@mediaPresentationDuration"];
const duration = parseIso8601Duration(durationRaw);

const title = this.extractTitle(manifest);

return {
service: this.serviceId,
id: url.href,
title: title ?? url.pathname?.split("/").slice(-1)[0] ?? url.href,
description: `Full Link: ${url.href}`,
mime: "application/dash+xml",
length: duration,
dash_url: url.href,
};
}

/**
* Attempts to find a title for the video from the manifest. Returns undefined if no title is found.
*
* Video metadata is not always available in the manifest, and it's not standardized, so this method will probably usually fail.
*/
extractTitle(manifest: any): string | undefined {
try {
if ("ProgramInformation" in manifest["MPD"]) {
return manifest["MPD"]["ProgramInformation"]["Title"];
}

const periods = manifest["MPD"]["Period"];
for (const period of periods) {
const adaptationSets = period["AdaptationSet"];
for (const adaptationSet of adaptationSets) {
const representations = adaptationSet["Representation"];
for (const representation of representations) {
if ("Title" in representation) {
return representation["Title"];
}
}
}
}
} catch (e) {
log.warn("Error extracting title from manifest", e);
}

return undefined;
}
}
Loading

0 comments on commit 828ce4f

Please sign in to comment.