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,