nicovideo: basic niconico video support

This commit is contained in:
Tyler Lafayette 2023-06-22 17:21:30 +08:00
parent 0848923cc7
commit 4015799fdb
5 changed files with 151 additions and 2 deletions

View File

@ -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) });
}

View File

@ -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 = /<div[^>]+id="js-initial-watch-data"[^>]+data-api-data="([^"]+)"/;
/**
* Undo html escaping. e.g. `&quot;` -> `"`
*
* @param {string} input Possibly escaped string
*
* @returns `input` with escaped characters un-escaped
*/
const unescapeHtml = input => {
return input
.replaceAll("&amp;", "&")
.replaceAll("&lt;", "<")
.replaceAll("&gt;", ">")
.replaceAll("&quot;", "\"")
.replaceAll("&#039;", "'");
}
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`
}
}

View File

@ -67,6 +67,12 @@
"alias": "pinterest videos & stories",
"patterns": ["pin/:id"],
"enabled": true
},
"nicovideo": {
"alias": "niconico videos",
"patterns": ["watch/:id"],
"enabled": true,
"tld": "jp"
}
}
}

View File

@ -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)
}

View File

@ -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,