diff --git a/src/modules/processing/match.js b/src/modules/processing/match.js
index e06166db..eb2d64a9 100644
--- a/src/modules/processing/match.js
+++ b/src/modules/processing/match.js
@@ -18,6 +18,7 @@ import soundcloud from "./services/soundcloud.js";
import instagram from "./services/instagram.js";
import vine from "./services/vine.js";
import pinterest from "./services/pinterest.js";
+import nicovideo from "./services/nicovideo.js";
export default async function (host, patternMatch, url, lang, obj) {
try {
@@ -114,6 +115,9 @@ export default async function (host, patternMatch, url, lang, obj) {
case "pinterest":
r = await pinterest({ id: patternMatch["id"] });
break;
+ case "nicovideo":
+ r = await nicovideo({ id: patternMatch["id"] });
+ break;
default:
return apiJSON(0, { t: errorUnsupported(lang) });
}
diff --git a/src/modules/processing/services/nicovideo.js b/src/modules/processing/services/nicovideo.js
new file mode 100644
index 00000000..eb76a123
--- /dev/null
+++ b/src/modules/processing/services/nicovideo.js
@@ -0,0 +1,130 @@
+import { genericUserAgent, maxVideoDuration } from "../../config.js";
+
+/**
+ * Regex for parsing the HTML element containing the JSON object used to
+ * authenticate the request for the video stream.
+ */
+const JS_INITIAL_WATCH_DATA_REGEX = /
]+id="js-initial-watch-data"[^>]+data-api-data="([^"]+)"/;
+
+/**
+ * Undo html escaping. e.g. `"` -> `"`
+ *
+ * @param {string} input Possibly escaped string
+ *
+ * @returns `input` with escaped characters un-escaped
+ */
+const unescapeHtml = input => {
+ return input
+ .replaceAll("&", "&")
+ .replaceAll("<", "<")
+ .replaceAll(">", ">")
+ .replaceAll(""", "\"")
+ .replaceAll("'", "'");
+}
+
+export default async function(obj) {
+ // Step 1: We need to retrieve a JSON object from an element with ID
+ // `js-initial-watch-data`.
+ const videoPageBody = await fetch(`https://www.nicovideo.jp/watch/${obj.id}`, {
+ headers: { "user-agent": genericUserAgent }
+ }).then(res => res.text());
+
+ const matches = JS_INITIAL_WATCH_DATA_REGEX.exec(videoPageBody);
+ if (matches?.length < 2) return { error: 'ErrorCouldntFetch' };
+
+ const jsonStr = unescapeHtml(matches[1]);
+
+ let jsInitialWatchData = null;
+ try {
+ jsInitialWatchData = JSON.parse(jsonStr);
+ } catch (_err) {
+ return { error: 'ErrorCouldntFetch' };
+ }
+
+ const { video } = jsInitialWatchData;
+ if (!video || !video.duration) {
+ return { error: 'ErrorCouldntFetch' };
+ }
+ if (video.duration > maxVideoDuration * 1000) {
+ return { error: ['ErrorLengthLimit', maxVideoDuration / 60000] };
+ }
+
+ // Step 2: We use the JSON object to send an HTTP request to retrieve
+ // the m3u8 for the video stream.
+ const { session } = jsInitialWatchData?.media?.delivery?.movie;
+ if (!session) {
+ return { error: 'ErrorCouldntFetch' };
+ }
+ const body = {
+ session: {
+ client_info: {
+ player_id: session.playerId,
+ },
+ content_auth: {
+ auth_type: session.authTypes.http,
+ content_key_timeout: 600000,
+ service_id: "nicovideo",
+ service_user_id: session.serviceUserId
+ },
+ content_id: session.contentId,
+ content_src_id_sets: [{
+ content_src_ids: [{
+ src_id_to_mux: {
+ video_src_ids: session.videos,
+ audio_src_ids: session.audios
+ }
+ }]
+ }],
+ content_type: "movie",
+ content_uri: "",
+ keep_method: {
+ heartbeat: {
+ lifetime: 120000
+ }
+ },
+ priority: 0,
+ protocol: {
+ name: "http",
+ parameters: {
+ http_parameters: {
+ parameters: {
+ hls_parameters: {
+ use_well_known_port: "yes",
+ use_ssl: "yes",
+ transfer_preset: "",
+ segment_duration: 6000
+ }
+ }
+ }
+ }
+ },
+ recipe_id: session.recipeId,
+ session_operation_auth: {
+ session_operation_auth_by_signature: {
+ signature: session.signature,
+ token: session.token
+ }
+ },
+ timing_constraint: "unlimited"
+ }
+ };
+ const res = await fetch("https://api.dmc.nico/api/sessions?_format=json", {
+ method: "POST",
+ body: JSON.stringify(body)
+ }).then(res => res.json());
+
+ if (res?.meta?.status !== 201) {
+ return { error: 'ErrorCouldntFetch' };
+ }
+
+ const playlistUri = res?.data?.session?.content_uri;
+ if (!playlistUri) {
+ return { error: 'ErrorCouldntFetch' };
+ }
+
+ return {
+ urls: playlistUri,
+ isM3U8: true,
+ filename: `nicovideo_${obj.id}.mp4`
+ }
+}
diff --git a/src/modules/processing/servicesConfig.json b/src/modules/processing/servicesConfig.json
index 1890edac..6ec8d637 100644
--- a/src/modules/processing/servicesConfig.json
+++ b/src/modules/processing/servicesConfig.json
@@ -67,6 +67,12 @@
"alias": "pinterest videos & stories",
"patterns": ["pin/:id"],
"enabled": true
+ },
+ "nicovideo": {
+ "alias": "niconico videos",
+ "patterns": ["watch/:id"],
+ "enabled": true,
+ "tld": "jp"
}
}
}
diff --git a/src/modules/processing/servicesPatternTesters.js b/src/modules/processing/servicesPatternTesters.js
index e3286c37..0d12a221 100644
--- a/src/modules/processing/servicesPatternTesters.js
+++ b/src/modules/processing/servicesPatternTesters.js
@@ -30,5 +30,6 @@ export const testers = {
"vine": (patternMatch) => (patternMatch["id"] && patternMatch["id"].length <= 12),
- "pinterest": (patternMatch) => (patternMatch["id"] && patternMatch["id"].length <= 128)
+ "pinterest": (patternMatch) => (patternMatch["id"] && patternMatch["id"].length <= 128),
+ "nicovideo": (patternMatch) => (patternMatch["id"] && patternMatch["id"].startsWith("sm") && patternMatch["id"].length === 10)
}
diff --git a/src/modules/stream/types.js b/src/modules/stream/types.js
index 6fee9a43..311ba91f 100644
--- a/src/modules/stream/types.js
+++ b/src/modules/stream/types.js
@@ -148,7 +148,15 @@ export function streamVideoOnly(streamInfo, res) {
]
if (streamInfo.mute) args.push('-an');
if (streamInfo.service === "vimeo") args.push('-bsf:a', 'aac_adtstoasc');
- if (format === "mp4") args.push('-movflags', 'faststart+frag_keyframe+empty_moov');
+ if (format === "mp4") {
+ // for some reason, downloading nicovideo with the `empty_moov` flag
+ // will only download one second of video
+ // TODO: figure out why
+ const movflags = streamInfo.service === 'nicovideo'
+ ? 'faststart+frag_keyframe'
+ : 'faststart+frag_keyframe+empty_moov';
+ args.push('-movflags', movflags);
+ }
args.push('-f', format, 'pipe:3');
const ffmpegProcess = spawn(ffmpeg, args, {
windowsHide: true,