Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

nicovideo.jp support #510

Draft
wants to merge 15 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions src/modules/processing/match.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import streamable from "./services/streamable.js";
import twitch from "./services/twitch.js";
import rutube from "./services/rutube.js";
import dailymotion from "./services/dailymotion.js";
import nicovideo from "./services/nicovideo.js";

let freebind;

Expand Down Expand Up @@ -185,6 +186,14 @@ export default async function(host, patternMatch, lang, obj) {
case "dailymotion":
r = await dailymotion(patternMatch);
break;
case "nicovideo":
r = await nicovideo({
id: patternMatch.id,
quality: obj.vQuality,
isAudioOnly: isAudioOnly,
isAudioMuted: obj.isAudioMuted,
});
break;
default:
return createResponse("error", {
t: loc(lang, 'ErrorUnsupported')
Expand Down
1 change: 1 addition & 0 deletions src/modules/processing/matchActionDecider.js
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ export default function(r, host, userFormat, isAudioOnly, lang, isAudioMuted, di
case "video":
switch (host) {
case "bilibili":
case "nicovideo":
params = { type: "render" };
break;
case "youtube":
Expand Down
197 changes: 197 additions & 0 deletions src/modules/processing/services/nicovideo.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
import { env, genericUserAgent } from "../../config.js";
import { cleanString } from "../../sub/utils.js";
import HLS from "hls-parser";
import util from "node:util";

const NICOVIDEO_EMBED_FRONTEND_HEADERS = {
"x-frontend-id": "70",
"x-frontend-version": "0",
"x-niconico-langauge": "ja-jp",
"x-request-with": "https://embed.nicovideo.jp",
};

const NICOVIDEO_EMBED_URL = "https://embed.nicovideo.jp/watch/%s";
const NICOVIDEO_AUTHOR_DATA_URL = "https://embed.nicovideo.jp/users/%d";
const NICOVIDEO_GUEST_API_URL =
"https://www.nicovideo.jp/api/watch/v3_guest/%s?_frontendId=70&_frontendVersion=0&actionTrackId=%s";
const NICOVIDEO_HLS_API_URL =
"https://nvapi.nicovideo.jp/v1/watch/%s/access-rights/hls?actionTrackId=%s";

class CobaltError extends Error {
constructor(locMessage) {
super(); // gdsfkgjsoiredgjhredszdfpijgkertoindsuf
this.locMessage = locMessage;
}
}

async function getBasicVideoInformation(id) {
const page = await fetch(util.format(NICOVIDEO_EMBED_URL, id), {
headers: { "user-agent": genericUserAgent },
})
.then((response) => response.text())
.catch(() => {
throw new CobaltError("ErrorCouldntFetch");
});

const data = JSON.parse(
page
.split('data-props="')
.pop()
.split('" data-style-map="')
.shift()
.replaceAll(""", '"')
);

const author = await fetch(
util.format(NICOVIDEO_AUTHOR_DATA_URL, data.videoUploaderId),
{
headers: { "user-agent": genericUserAgent },
}
)
.then((response) => response.json())
.catch(() => {
throw new CobaltError("ErrorCouldntFetch");
});

return { ...data, author };
}

async function fetchGuestData(id, actionTrackId) {
const data = await fetch(
util.format(NICOVIDEO_GUEST_API_URL, id, actionTrackId),
{
headers: { "user-agent": genericUserAgent },
}
)
.then((response) => response.json())
.catch(() => {
throw new CobaltError("ErrorCouldntFetch");
});

if (data?.meta?.status !== 200) {
throw new CobaltError("ErrorBadFetch");
}

const { videos, audios, accessRightKey } = data.data.media.domand;

// getting the HQ audio
const { id: audioId } = audios
.filter((audio) => audio.isAvailable)
.reduce((firstAudio, secondAudio) =>
firstAudio.bitRate > secondAudio.bitRate ? firstAudio : secondAudio
);

return {
accessRightKey,
outputs: videos
.filter((video) => video.isAvailable)
.map((video) => [video.id, audioId]),
};
}

async function fetchContentURL(id, actionTrackId, accessRightKey, outputs) {
const data = await fetch(
util.format(NICOVIDEO_HLS_API_URL, id, actionTrackId),
{
method: "POST",
headers: {
"user-agent": genericUserAgent,
"content-type": "application/json; charset=utf-8",
"x-access-right-key": accessRightKey,
...NICOVIDEO_EMBED_FRONTEND_HEADERS,
},
body: JSON.stringify({ outputs }),
}
)
.then((response) => response.json())
.catch(() => {
throw new CobaltError("ErrorCouldntFetch");
});

if (data?.meta?.status !== 201) {
throw new CobaltError("ErrorBadFetch");
}

return data.data.contentUrl;
}

async function getHLSContent(contentURL, quality, isAudioOnly, isAudioMuted) {
const hls = await fetch(contentURL)
.then((response) => response.text())
.then((response) => HLS.parse(response));

const height = quality === "max" ? 9000 : parseInt(quality, 10);
let hlsContent = hls.variants.find(
(variant) => variant.resolution.height === height
);

if (hlsContent === undefined) {
hlsContent = hls.variants.reduce((firstVariant, secondVariant) =>
firstVariant.bandwidth > secondVariant.bandwidth
? firstVariant
: secondVariant
);
}

const audioUrl = hlsContent.audio.pop().uri;
return isAudioOnly
? { resolution: null, urls: audioUrl, type: "audio" }
: {
resolution: hlsContent.resolution,
urls: isAudioMuted ? hlsContent.uri : [hlsContent.uri, audioUrl],
type: "video",
};
}

export default async function nicovideo({
id,
quality,
isAudioOnly,
isAudioMuted,
}) {
try {
const { actionTrackId, title, author, lengthInSeconds } =
await getBasicVideoInformation(id);

if (lengthInSeconds > env.durationLimit) {
throw new CobaltError(["ErrorLengthLimit", env.durationLimit / 60]);
}

const { resolution, urls, type } = await fetchGuestData(id, actionTrackId)
.then(({ accessRightKey, outputs }) =>
fetchContentURL(id, actionTrackId, accessRightKey, outputs)
)
.then((contentURL) =>
getHLSContent(contentURL, quality, isAudioOnly, isAudioMuted)
);

return {
urls,
isAudioOnly: type === "audio",
fileMetadata: {
title: cleanString(title.trim()),
artist: author.nickname
? cleanString(author.nickname.trim())
: undefined,
},
// bible accurate object concatenation
filenameAttributes: {
service: "nicovideo",
id,
title,
author: author.nickname,
...(type === "video"
? {
extension: "mp4",
qualityLabel: `${resolution.height}p`,
resolution: `${resolution.width}x${resolution.height}`,
}
: {}),
},
...(typeof urls === "string" ? { isM3U8: true } : {}),
...(type === "audio" ? { bestAudio: "mp3" } : {}),
};
} catch (error) {
return { error: error.locMessage ?? "ErrorSomethingWentWrong" };
}
}
7 changes: 7 additions & 0 deletions src/modules/processing/servicesConfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,13 @@
"alias": "dailymotion videos",
"patterns": ["video/:id"],
"enabled": true
},
"nicovideo": {
"alias": "niconico videos",
"tld": "jp",
"patterns": ["watch/:id"],
"subdomains": ["www", "sp", "embed"],
"enabled": true
}
}
}
4 changes: 4 additions & 0 deletions src/modules/processing/servicesPatternTesters.js
Original file line number Diff line number Diff line change
Expand Up @@ -54,4 +54,8 @@ export const testers = {

"youtube": (patternMatch) =>
patternMatch.id?.length <= 11,

"nicovideo": (patternMatch) =>
// checking if this page is video and if identifier is number
patternMatch.id.startsWith("sm") && !isNaN(parseInt(patternMatch.id.substring(2), 10))
}
5 changes: 2 additions & 3 deletions src/modules/stream/manage.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,11 @@ import { nanoid } from "nanoid";
import { decryptStream, encryptStream, generateHmac } from "../sub/crypto.js";
import { env } from "../config.js";
import { strict as assert } from "assert";
import { isM3UService } from "./shared.js";

// optional dependency
const freebind = env.freebindCIDR && await import('freebind').catch(() => {});

const M3U_SERVICES = ['dailymotion', 'vimeo', 'rutube'];

const streamCache = new NodeCache({
stdTTL: env.streamLifespan,
checkperiod: 10,
Expand Down Expand Up @@ -108,7 +107,7 @@ export function destroyInternalStream(url) {
function wrapStream(streamInfo) {
/* m3u8 links are currently not supported
* for internal streams, skip them */
if (M3U_SERVICES.includes(streamInfo.service)) {
if (isM3UService(streamInfo.service)) {
return streamInfo;
}

Expand Down
8 changes: 7 additions & 1 deletion src/modules/stream/shared.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,10 @@ export function getHeaders(service) {
// Converting all header values to strings
return Object.entries({ ...defaultHeaders, ...serviceHeaders[service] })
.reduce((p, [key, val]) => ({ ...p, [key]: String(val) }), {})
}
}

const M3U_SERVICES = ["dailymotion", "vimeo", "rutube", "nicovideo"];

export function isM3UService(service) {
return M3U_SERVICES.includes(service);
}
4 changes: 2 additions & 2 deletions src/modules/stream/types.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { create as contentDisposition } from "content-disposition-header";
import { metadataManager } from "../sub/utils.js";
import { destroyInternalStream } from "./manage.js";
import { env, ffmpegArgs } from "../config.js";
import { getHeaders, closeResponse } from "./shared.js";
import { getHeaders, closeResponse, isM3UService } from "./shared.js";

function toRawHeaders(headers) {
return Object.entries(headers)
Expand Down Expand Up @@ -215,7 +215,7 @@ export function streamVideoOnly(streamInfo, res) {
args.push('-an')
}

if (["vimeo", "rutube", "dailymotion"].includes(streamInfo.service)) {
if (isM3UService(streamInfo.service)) {
args.push('-bsf:a', 'aac_adtstoasc')
}

Expand Down
39 changes: 39 additions & 0 deletions src/test/tests.json
Original file line number Diff line number Diff line change
Expand Up @@ -531,6 +531,45 @@
"status": "error"
}
}],
"nicovideo": [{
"name": "only 360p video",
"url": "https://www.nicovideo.jp/watch/sm13876659",
"params": {},
"expected": {
"code": 200,
"status": "stream"
}
}, {
"name": "only 360p video muted",
"url": "https://www.nicovideo.jp/watch/sm7055008",
"params": {
"isAudioMuted": true
},
"expected": {
"code": 200,
"status": "stream"
}
}, {
"name": "niconico video converted to audio",
"url": "https://www.nicovideo.jp/watch/sm6287843",
"params": {
"isAudioOnly": true
},
"expected": {
"code": 200,
"status": "stream"
}
}, {
"name": "max 720p video in 480p",
"url": "https://www.nicovideo.jp/watch/sm43808466",
"params": {
"vQuality": "480"
},
"expected": {
"code": 200,
"status": "stream"
}
}],
"bilibili": [{
"name": "1080p video",
"url": "https://www.bilibili.com/video/BV18i4y1m7xV/",
Expand Down