From 14209799670c510b61f7d4005698d11a2c1d01be Mon Sep 17 00:00:00 2001 From: AidenLx Date: Thu, 22 Jul 2021 00:12:14 +0800 Subject: [PATCH] initial commit --- .eslintignore | 1 + .eslintrc.js | 31 ++++++ .gitignore | 15 +++ .prettierrc | 20 ++++ README.md | 35 +++++++ manifest.json | 10 ++ package.json | 97 +++++++++++++++++++ rollup.config.js | 26 +++++ src/fake-bili/bili-tools.ts | 63 ++++++++++++ src/fake-bili/dash-tool.ts | 56 +++++++++++ src/fake-bili/fetch-poster.ts | 33 +++++++ src/fake-bili/proxy/fake.ts | 34 +++++++ src/fake-bili/proxy/play-url.ts | 167 ++++++++++++++++++++++++++++++++ src/fake-bili/proxy/server.ts | 20 ++++ src/mxbili-main.ts | 82 ++++++++++++++++ tsconfig.json | 18 ++++ versions.json | 1 + 17 files changed, 709 insertions(+) create mode 100644 .eslintignore create mode 100644 .eslintrc.js create mode 100755 .gitignore create mode 100644 .prettierrc create mode 100755 README.md create mode 100755 manifest.json create mode 100755 package.json create mode 100755 rollup.config.js create mode 100644 src/fake-bili/bili-tools.ts create mode 100644 src/fake-bili/dash-tool.ts create mode 100644 src/fake-bili/fetch-poster.ts create mode 100644 src/fake-bili/proxy/fake.ts create mode 100644 src/fake-bili/proxy/play-url.ts create mode 100644 src/fake-bili/proxy/server.ts create mode 100755 src/mxbili-main.ts create mode 100755 tsconfig.json create mode 100755 versions.json 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 @@ +{}