diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..82df346 --- /dev/null +++ b/.eslintignore @@ -0,0 +1 @@ +main.js \ No newline at end of file diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 0000000..0c4be46 --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,31 @@ +module.exports = { + root: true, + parser: "@typescript-eslint/parser", + parserOptions: { + ecmaVersion: 2020, + sourceType: "module", + }, + extends: ["prettier", "plugin:prettier/recommended"], + env: { + browser: true, + node: true, + }, + plugins: [ + "@typescript-eslint", + "jsdoc", + "prefer-arrow", + "simple-import-sort", + ], + rules: { + "simple-import-sort/imports": "error", + "simple-import-sort/exports": "error", + "prefer-arrow/prefer-arrow-functions": [ + "warn", + { + disallowPrototype: true, + singleReturnOnly: false, + classPropertiesAllowed: false, + }, + ], + }, +}; diff --git a/.gitignore b/.gitignore new file mode 100755 index 0000000..713f43b --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +# Intellij +*.iml +.idea + +# npm +node_modules +package-lock.json + +# build +main.js +*.js.map +styles.css + +# saved config +data.json \ No newline at end of file diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..dce165f --- /dev/null +++ b/.prettierrc @@ -0,0 +1,20 @@ +{ + "singleQuote": false, + "tabWidth": 2, + "trailingComma": "all", + "overrides": [ + { + "files": ".prettierrc", + "options": { + "parser": "json" + } + }, + { + "files": "*.yml", + "options": { + "tabWidth": 2, + "singleQuote": false + } + } + ] +} diff --git a/README.md b/README.md new file mode 100755 index 0000000..654ae79 --- /dev/null +++ b/README.md @@ -0,0 +1,35 @@ +# Media Extended BiliBili Plugin + +Add bilibili videos support for Media Extended plugin + +## Intro + +## How to use + +## Compatibility + +The required API feature is only available for Obsidian v0.9.12+. + +## Installation + +### From GitHub + +1. Download the Latest Release from the Releases section of the GitHub Repository +2. Put files to your vault's plugins folder: `/.obsidian/plugins/mx-bili-plugin` +3. Reload Obsidian +4. If prompted about Safe Mode, you can disable safe mode and enable the plugin. +Otherwise, head to Settings, third-party plugins, make sure safe mode is off and +enable the plugin from there. + +> Note: The `.obsidian` folder may be hidden. On macOS, you should be able to press `Command+Shift+Dot` to show the folder in Finder. + +### From Obsidian + +> Not yet available + +1. Open `Settings` > `Third-party plugin` +2. Make sure Safe mode is **off** +3. Click `Browse community plugins` +4. Search for this plugin +5. Click `Install` +6. Once installed, close the community plugins window and the patch is ready to use. diff --git a/manifest.json b/manifest.json new file mode 100755 index 0000000..26e774b --- /dev/null +++ b/manifest.json @@ -0,0 +1,10 @@ +{ + "id": "mx-bili-plugin", + "name": "Media Extended BiliBili Plugin", + "version": "0.0.0", + "minAppVersion": "0.12.2", + "description": "Add bilibili videos support for Media Extended plugin", + "author": "AidenLx", + "authorUrl": "https://github.com/AidenLx/", + "isDesktopOnly": true +} diff --git a/package.json b/package.json new file mode 100755 index 0000000..c1369fd --- /dev/null +++ b/package.json @@ -0,0 +1,97 @@ +{ + "name": "mx-bili-plugin", + "version": "0.0.0", + "description": "Add bilibili videos support for Media Extended plugin", + "main": "main.js", + "scripts": { + "dev": "sed -i '' \"s#return adapter;#return require('./adapters/http');#\" node_modules/axios/lib/defaults.js && rollup --config rollup.config.js -w", + "build": "sed -i '' \"s#return adapter;#return require('./adapters/http');#\" node_modules/axios/lib/defaults.js && rollup --config rollup.config.js", + "prettier": "prettier --write 'src/**/*.+(ts|tsx|json|html|css)'", + "eslint": "eslint . --ext .ts,.tsx --fix", + "release": "release-it" + }, + "keywords": [], + "author": "", + "license": "MIT", + "devDependencies": { + "@release-it/bumper": "^3.0.1", + "@release-it/conventional-changelog": "^3.0.1", + "@rollup/plugin-commonjs": "^19.0.1", + "@rollup/plugin-json": "^4.1.0", + "@rollup/plugin-node-resolve": "^13.0.2", + "@rollup/plugin-typescript": "^8.2.3", + "@types/express": "^4.17.13", + "@types/json-schema": "^7.0.8", + "@types/node": "^16.4.0", + "@typescript-eslint/eslint-plugin": "^4.28.4", + "@typescript-eslint/parser": "^4.28.4", + "assert-never": "^1.2.1", + "axios": "^0.21.1", + "bili-api": "github:aidenlx/bili-api", + "cz-conventional-changelog": "^3.3.0", + "eslint": "^7.31.0", + "eslint-config-prettier": "^8.3.0", + "eslint-plugin-jsdoc": "^35.5.0", + "eslint-plugin-prefer-arrow": "^1.2.3", + "eslint-plugin-prettier": "^3.4.0", + "eslint-plugin-simple-import-sort": "^7.0.0", + "express": "^4.17.1", + "http-proxy-middleware": "^2.0.1", + "json": "^11.0.0", + "obsidian": "^0.12.11", + "prettier": "^2.3.2", + "release-it": "^14.10.0", + "rollup": "^2.53.3", + "tslib": "^2.3.0", + "typescript": "^4.3.5", + "xmlbuilder2": "^2.4.1" + }, + "release-it": { + "hooks": { + "before:init": [ + "npm run prettier", + "npm run eslint" + ], + "after:bump": [ + "json -I -f manifest.json -e \"this.version='${version}'\"", + "json -I -f versions.json -e \"this['${version}']='$(cat manifest.json | json minAppVersion)'\"", + "sed -i '' \"s/available for Obsidian v.*$/available for Obsidian v$(cat manifest.json | json minAppVersion)+./\" README.md", + "git Add.", + "npm run build" + ], + "after:release": "echo Successfully released ${name} v${version} to ${repo.repository}." + }, + "git": { + "commitMessage": "chore: release v${version}", + "tagName": "${version}", + "tagAnnotation": "Release v${version}" + }, + "npm": { + "publish": false + }, + "github": { + "release": true, + "assets": [ + "main.js", + "manifest.json", + "styles.css" + ], + "proxy": "http://127.0.0.1:7890", + "releaseName": "${version}" + }, + "plugins": { + "@release-it/bumper": { + "out": "manifest.json" + }, + "@release-it/conventional-changelog": { + "preset": "angular", + "infile": "CHANGELOG.md" + } + } + }, + "config": { + "commitizen": { + "path": "./node_modules/cz-conventional-changelog" + } + } +} diff --git a/rollup.config.js b/rollup.config.js new file mode 100755 index 0000000..6bde727 --- /dev/null +++ b/rollup.config.js @@ -0,0 +1,26 @@ +import commonjs from "@rollup/plugin-commonjs"; +import json from "@rollup/plugin-json"; +import { nodeResolve } from "@rollup/plugin-node-resolve"; +import typescript from "@rollup/plugin-typescript"; + +const isProd = process.env.BUILD === "production"; + +const banner = `/* +THIS IS A GENERATED/BUNDLED FILE BY ROLLUP +if you want to view the source visit the plugins github repository +*/ +`; + +export default { + input: "src/mxbili-main.ts", + output: { + file: "main.js", + sourcemap: "inline", + sourcemapExcludeSources: isProd, + format: "cjs", + exports: "default", + banner, + }, + external: ["obsidian"], + plugins: [typescript(), nodeResolve({ browser: true }), commonjs(), json()], +}; diff --git a/src/fake-bili/bili-tools.ts b/src/fake-bili/bili-tools.ts new file mode 100644 index 0000000..0ec3bbf --- /dev/null +++ b/src/fake-bili/bili-tools.ts @@ -0,0 +1,63 @@ +import axios from "axios"; +import { returnBody } from "bili-api/player/general"; +import * as Pagelist from "bili-api/player/pagelist"; +import * as PlayUrl from "bili-api/player/playurl"; + +export const enum vidType { + avid, + bvid, +} + +export const isBiliVId = (id: string | number): vidType | null => { + if (typeof id === "number" && Number.isInteger(id)) return vidType.avid; + else if (typeof id === "string" && /^bv/i.test(id)) return vidType.bvid; + else console.log("Invalid id: " + id); + return null; +}; + +export const isDash = (obj: PlayUrl.Data): obj is PlayUrl.DashData => + (obj as PlayUrl.DashData).dash !== undefined; + +export const isTrad = (obj: PlayUrl.Data): obj is PlayUrl.TradData => + (obj as PlayUrl.TradData).durl !== undefined; + +/** + * 获取视频流URL(web端) + * @param F = typeof args.fnval + */ +export const getPlayUrl = (args: PlayUrl.Params, cookie?: string) => { + const url = "http://api.bilibili.com/x/player/playurl"; + + // @ts-ignore + const { avid, bvid } = args; + + if ( + (avid && isBiliVId(avid) === vidType.avid) || + (bvid && isBiliVId(bvid) === vidType.bvid) + ) { + let headers: any = {}; + if (cookie) headers.Cookie = cookie; + return axios.get< + F extends PlayUrl.fetch_method.dash + ? returnBody + : returnBody + >(url, { params: args, headers }); + } else throw new TypeError(`Invalid avid ${avid}/bvid ${bvid}`); +}; + +/** + * 查询视频分P列表 (avID转CID) + */ +export const getPageList = async (arg: Pagelist.Params) => { + const url = "http://api.bilibili.com/x/player/pagelist"; + + // @ts-ignore + const { aid, bvid } = arg; + + if ( + (aid && isBiliVId(aid) === vidType.avid) || + (bvid && isBiliVId(bvid) === vidType.bvid) + ) + return axios.get(url, { params: arg }); + else throw new TypeError(`Invalid aid ${aid}/bvid ${bvid}`); +}; diff --git a/src/fake-bili/dash-tool.ts b/src/fake-bili/dash-tool.ts new file mode 100644 index 0000000..182dba2 --- /dev/null +++ b/src/fake-bili/dash-tool.ts @@ -0,0 +1,56 @@ +import { DashData } from "bili-api/player/playurl"; +import { create } from "xmlbuilder2"; + +export const toMPD = (data: DashData) => { + const d = data.dash; + + let videos = d.video + .filter((v) => v.codecs.startsWith("avc1")) + .map((v) => ({ + BaseURL: v.baseUrl, + "@id": v.id, + "@mimeType": v.mimeType, + "@bandwidth": v.bandwidth, + "@codecs": v.codecs, + "@width": v.width, + "@height": v.height, + "@frameRate": v.frameRate, + "@sar": v.sar, + "@startWithSap": v.startWithSap, + SegmentBase: { + "@indexRange": v.SegmentBase.indexRange, + "@Initialization": v.SegmentBase.Initialization, + }, + })); + + let audios = d.audio.map((v) => ({ + BaseURL: v.baseUrl, + "@id": v.id, + "@mimeType": v.mimeType, + "@bandwidth": v.bandwidth, + "@codecs": v.codecs, + "@startWithSap": v.startWithSap, + SegmentBase: { + "@indexRange": v.SegmentBase.indexRange, + "@Initialization": v.SegmentBase.Initialization, + }, + })); + + // prettier-ignore + const root = create({ version: '1.0' }) + .ele("urn:mpeg:dash:schema:mpd:2011","MPD",{ + profiles: "urn:mpeg:dash:profile:isoff-on-demand:2011,http://dashif.org/guidelines/dash264", + type:"static", + minBufferTime: `PT${d.minBufferTime}S`, + mediaPresentationDuration: `PT${d.duration}S` + }) + .ele("Period", {duration: `PT${d.duration}S`}) + .ele("AdaptationSet",{ contentType:"video", bitstreamSwitching:true }) + .ele({Representation:videos}).up() + .up() + .ele("AdaptationSet",{ contentType:"audio", bitstreamSwitching:true }) + .ele({Representation:audios}).up() + + const xml = root.end({ prettyPrint: true }); + return xml; +}; diff --git a/src/fake-bili/fetch-poster.ts b/src/fake-bili/fetch-poster.ts new file mode 100644 index 0000000..9fc21c6 --- /dev/null +++ b/src/fake-bili/fetch-poster.ts @@ -0,0 +1,33 @@ +import axios from "axios"; + +const fetchBiliPoster = async ( + ...args: [aid: number] | [bvid: string] +): Promise => { + const [id] = args; + + const api = new URL("http://api.bilibili.com/x/web-interface/view"); + if (typeof id === "string") api.searchParams.append("bvid", id); + else api.searchParams.append("aid", "av" + id); + + return axios + .get(api.toString(), { + headers: { + Origin: "https://www.bilibili.com", + Referer: "https://www.bilibili.com", + "Content-Length": "0", + }, + }) + .then((response) => { + const json = response.data; + if (json.code !== 0) throw new Error(`${json.code}: ${json.message}`); + else { + return (json.data.pic as string) ?? null; + } + }) + .catch((e) => { + console.error(e); + return null; + }); +}; + +export default fetchBiliPoster; diff --git a/src/fake-bili/proxy/fake.ts b/src/fake-bili/proxy/fake.ts new file mode 100644 index 0000000..723af01 --- /dev/null +++ b/src/fake-bili/proxy/fake.ts @@ -0,0 +1,34 @@ +import { + createProxyMiddleware, + Options, + RequestHandler, +} from "http-proxy-middleware"; + +export const Route = "/fake/:host/"; +const ua = + "Mozilla/5.0 (Macintosh; Intel Mac OS X 11_2_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.90 Safari/537.36 Edg/89.0.774.63"; + +const proxyOpt: Options = { + target: "http://www.example.org", // target host + changeOrigin: true, // needed for virtual hosted sites + ws: true, // proxy websockets + pathRewrite: { + "^/fake/.+?/": "/", // rewrite path + }, + router: (req) => { + return "https://" + req.params.host; + }, + onProxyReq: (proxyReq) => { + proxyReq.setHeader("user-agent", ua); + proxyReq.setHeader("referer", "https://www.bilibili.com/"); + proxyReq.setHeader("origin", "https://www.bilibili.com"); + }, + onProxyRes: (proxyRes) => { + proxyRes.headers["Access-Control-Allow-Origin"] = "*"; + }, +}; + +export const getProxy = (): RequestHandler => { + const proxy = createProxyMiddleware(proxyOpt); + return proxy; +}; diff --git a/src/fake-bili/proxy/play-url.ts b/src/fake-bili/proxy/play-url.ts new file mode 100644 index 0000000..9a00c64 --- /dev/null +++ b/src/fake-bili/proxy/play-url.ts @@ -0,0 +1,167 @@ +import assertNever from "assert-never"; +import { statusCode } from "bili-api/player/general"; +import type * as Pagelist from "bili-api/player/pagelist"; +import type * as PlayUrl from "bili-api/player/playurl"; +import { fetch_method } from "bili-api/player/playurl"; +import { RequestHandler } from "express"; + +import { + getPageList, + getPlayUrl, + isDash, + isTrad, + vidType, +} from "../bili-tools"; +import { toMPD } from "../dash-tool"; + +type vid = + | { type: vidType.bvid; value: string } + | { type: vidType.avid; value: number }; + +export const Route = "/geturl/:vid(av\\d+|bv\\w+)"; + +let Cookie: string | undefined; +let PORT: number; + +export const getHandler = ( + port: number = 2233, + cookie?: string, +): RequestHandler => { + Cookie = cookie; + PORT = port; + const shell: RequestHandler = async (req, res, next) => { + try { + getUrl(req, res, next); + } catch (error) { + console.error(error); + res.status(500).send(error); + } + }; + return shell; +}; + +const getUrl: RequestHandler = async (req, res, next) => { + const rawId = req.params.vid; + + let id: vid; + let page: number | null; + + let idValue = rawId.substring(2); + if (/^av/i.test(rawId) && parseInt(idValue, 10)) + id = { type: vidType.avid, value: parseInt(idValue, 10) }; + else if (/^bv/i.test(rawId)) id = { type: vidType.bvid, value: idValue }; + else { + throw new Error("invalid avid/bvid"); + } + + const p = req.query.p; + if (typeof p === "string" && parseInt(p, 10)) { + page = parseInt(p, 10); + } else { + page = null; + if (p) console.error("invalid p, ignored" + p); + } + + let cid; + + try { + cid = await getCid(id, page); + } catch (error) { + throw new Error("failed to fetch cid, error:" + error); + } + + let playData; + + try { + playData = await getPlayData(id, cid); + } catch (error) { + throw new Error("failed to fetch playData, error:" + error); + } + + const mpd = toMPD(convertToFakeUrl(playData) as PlayUrl.DashData); + + res.set({ + "Content-Type": "application/dash+xml; charset=utf-8", + "Access-Control-Allow-Origin": "*", + }); + res.send(mpd); +}; + +const getCid = async (id: vid, page: number | null) => { + let argPl: Pagelist.Params; + + switch (id.type) { + case vidType.avid: + argPl = { aid: id.value }; + break; + case vidType.bvid: + argPl = { bvid: "BV" + id.value }; + break; + default: + assertNever(id); + } + + const pagelistData = (await getPageList(argPl)).data; + + if (pagelistData.code !== statusCode.success) { + throw new Error(-pagelistData.code + ": " + pagelistData.message); + } + + let cid; + if (page) cid = pagelistData.data[page].cid; + else cid = pagelistData.data[0].cid; + + return cid; +}; + +const getPlayData = async (id: vid, cid: number) => { + let argPu: PlayUrl.Params; + switch (id.type) { + case vidType.avid: + argPu = { avid: id.value, cid }; + break; + case vidType.bvid: + argPu = { bvid: "BV" + id.value, cid }; + break; + default: + assertNever(id); + } + + const fnval = fetch_method.dash; + argPu.fnval = fnval; + + let playUrlData = (await getPlayUrl(argPu, Cookie)).data; + + if (playUrlData.code !== statusCode.success) { + throw new Error(-playUrlData.code + ": " + playUrlData.message); + } + + return playUrlData.data; +}; + +const convertToFakeUrl = (obj: PlayUrl.Data) => { + const toFakeUrl = (src: string) => { + let { host, pathname, search } = new URL(src); + return `http://localhost:${PORT}/fake/${host + pathname + search}`; + }; + + if (isDash(obj)) { + const irMediaInfo = (info: PlayUrl.mediaInfo) => { + info.baseUrl = toFakeUrl(info.baseUrl); + info.base_url = toFakeUrl(info.base_url); + info.backup_url.forEach((v) => (v = toFakeUrl(v))); + info.backupUrl.forEach((v) => (v = toFakeUrl(v))); + }; + obj.dash.audio.forEach(irMediaInfo); + obj.dash.video.forEach(irMediaInfo); + } else if (isTrad(obj)) { + obj.durl.forEach((v) => { + v.url = toFakeUrl(v.url); + v.backup_url.forEach((url) => (url = toFakeUrl(url))); + }); + } else { + assertNever(obj); + } + + return obj; +}; diff --git a/src/fake-bili/proxy/server.ts b/src/fake-bili/proxy/server.ts new file mode 100644 index 0000000..b7acee4 --- /dev/null +++ b/src/fake-bili/proxy/server.ts @@ -0,0 +1,20 @@ +import { default as express } from "express"; +import { NextFunction, Request, Response } from "express"; + +import * as Fake from "./fake"; +import * as PlayUrl from "./play-url"; + +const getServer = (port: number) => { + const app = express(); + + app.use(Fake.Route, Fake.getProxy()); + app.use(PlayUrl.Route, PlayUrl.getHandler(port)); + app.use((err: Error, req: Request, res: Response, next: NextFunction) => { + console.error(err.stack); + res.status(500).send(err.message); + }); + + return app.listen(port); +}; + +export default getServer; diff --git a/src/mxbili-main.ts b/src/mxbili-main.ts new file mode 100755 index 0000000..83f205f --- /dev/null +++ b/src/mxbili-main.ts @@ -0,0 +1,82 @@ +import { debounce, Notice, Plugin, Setting } from "obsidian"; + +import fetchBiliPoster from "./fake-bili/fetch-poster"; +import getServer from "./fake-bili/proxy/server"; + +export default class MxBili extends Plugin { + settings: MxBiliSettings = DEFAULT_SETTINGS; + + server?: ReturnType; + + fetchPoster = fetchBiliPoster; + + portSetting = (containerEl: HTMLElement) => + new Setting(containerEl) + .setName("代理端口号") + .setDesc("若与现有端口冲突请手动指定其他端口") + .addText((text) => { + const save = debounce( + async (value: string) => { + this.setupProxy(+value); + this.settings.port = +value; + await this.saveSettings(); + }, + 500, + true, + ); + text + .setValue(this.settings.port.toString()) + .onChange(async (value: string) => { + text.inputEl.toggleClass("incorrect", !isVaildPort(value)); + if (isVaildPort(value)) save(value); + }); + }); + + async onload() { + console.log("loading MxBili"); + + await this.loadSettings(); + + this.setupProxy(this.settings.port); + } + + setupProxy = (port: number): void => { + if (this.server) this.server.close().listen(port); + else { + this.server = getServer(port); + this.server.on("error", (err) => { + if (err.message.includes("EADDRINUSE")) + new Notice("端口已被占用,请在Media Extended设置中更改端口号"); + else console.error(err); + }); + } + }; + + onunload() { + console.log("unloading MxBili"); + + this.server?.close(); + } + + async loadSettings() { + this.settings = { ...this.settings, ...(await this.loadData()) }; + } + + async saveSettings() { + await this.saveData(this.settings); + } +} + +interface MxBiliSettings { + port: number; +} + +const DEFAULT_SETTINGS: MxBiliSettings = { + port: 2233, +}; + +const isVaildPort = (str: string) => { + const test = + /^()([1-9]|[1-5]?[0-9]{2,4}|6[1-4][0-9]{3}|65[1-4][0-9]{2}|655[1-2][0-9]|6553[1-5])$/; + return test.test(str); +}; diff --git a/tsconfig.json b/tsconfig.json new file mode 100755 index 0000000..f2aa455 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "baseUrl": "src", + "outDir": "", + "inlineSourceMap": true, + "inlineSources": true, + "module": "ESNext", + "target": "es6", + "allowJs": true, + "noImplicitAny": true, + "moduleResolution": "node", + "importHelpers": true, + "strict": true, + "allowSyntheticDefaultImports": true, + "lib": ["dom", "es5", "scripthost", "es2015"] + }, + "include": ["src/**/*.ts"] +} diff --git a/versions.json b/versions.json new file mode 100755 index 0000000..0967ef4 --- /dev/null +++ b/versions.json @@ -0,0 +1 @@ +{}