├── static └── no-image.jpg ├── database ├── index.js ├── connect.js ├── user.js ├── mylist.js ├── schema.js ├── db.js └── work.js ├── auth ├── utils.js └── routes.js ├── Dockerfile ├── scraper ├── utils.js ├── axios.js ├── hvdb.js └── dlsite.js ├── routes ├── config.js ├── auth.js ├── file.js ├── mylist.js ├── user.js └── work.js ├── package.json ├── api.js ├── README.md ├── .gitignore ├── config.js ├── 用户文档.md ├── app.js ├── filesystem ├── utils.js └── scanner.js ├── routes.js └── LICENSE /static/no-image.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yodhcn/kikoeru-express/HEAD/static/no-image.jpg -------------------------------------------------------------------------------- /database/index.js: -------------------------------------------------------------------------------- 1 | const work = require('./work'); 2 | const user = require('./user'); 3 | const mylist = require('./mylist'); 4 | 5 | 6 | module.exports = { 7 | work, 8 | user, 9 | mylist, 10 | }; -------------------------------------------------------------------------------- /auth/utils.js: -------------------------------------------------------------------------------- 1 | const jwt = require('jsonwebtoken'); 2 | const md5 = require('md5'); 3 | 4 | const { getConfig } = require('../config'); 5 | 6 | const config = getConfig(); 7 | 8 | const signtoken = (obj) => jwt.sign(obj, config.jwtsecret, {expiresIn: config.expiresIn}); 9 | 10 | const cmd5 = (str) => md5(str + config.md5secret); 11 | 12 | 13 | module.exports = { 14 | signtoken, 15 | md5: cmd5, 16 | } 17 | -------------------------------------------------------------------------------- /database/connect.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const { sqliteFolderDir } = require('../config'); 3 | 4 | // knex 操作数据库 5 | const knex = require('knex')({ 6 | client: 'sqlite3', // 数据库类型 7 | useNullAsDefault: true, 8 | connection: { // 连接参数 9 | filename: path.join(sqliteFolderDir, 'db.sqlite3'), 10 | }, 11 | pool: { 12 | // 激活外键检查 (sqlite3 默认关闭外键限制) 13 | afterCreate: (conn, cb) => conn.run('PRAGMA foreign_keys = ON', cb) 14 | }, 15 | acquireConnectionTimeout: 5000, // 连接计时器 16 | }); 17 | 18 | module.exports = knex; -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:12-slim 2 | 3 | # Create app directory 4 | WORKDIR /usr/src/kikoeru 5 | 6 | # Install app dependencies 7 | # A wildcard is used to ensure both package.json AND package-lock.json are copied 8 | # where available (npm@5+) 9 | COPY package*.json ./ 10 | 11 | RUN npm install --registry=https://registry.npm.taobao.org 12 | # If you are building your code for production 13 | # RUN npm ci --only=production 14 | 15 | # Bundle app source 16 | COPY . . 17 | 18 | # 持久化 19 | VOLUME [ "/usr/src/kikoeru/sqlite", "/usr/src/kikoeru/config", "/usr/src/kikoeru/covers"] 20 | 21 | EXPOSE 8888 22 | CMD [ "node", "app.js" ] -------------------------------------------------------------------------------- /scraper/utils.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Generates a hash integer from a given string. Hopefully only temporary until 3 | * reshnix exposes VA ids for scraping. 4 | * @param {String} name 5 | */ 6 | const hashNameIntoInt = (name) => { 7 | let hash = ''; 8 | 9 | for (let i = 0; i < name.length; i += 1) { 10 | const char = name.charCodeAt(i); 11 | // eslint-disable-next-line no-bitwise 12 | hash = ((hash << 5) - hash) + char; 13 | } 14 | 15 | // eslint-disable-next-line no-bitwise 16 | hash |= 0; 17 | hash = Math.abs(Math.round(hash / 1000)); 18 | return hash; 19 | }; 20 | 21 | /** 22 | * 判断一个字符串中是否包含字母 23 | * @param {String} str 24 | */ 25 | const hasLetter = (str) => { 26 | for (let i in str) { 27 | let asc = str.charCodeAt(i); 28 | if ((asc >= 65 && asc <= 90 || asc >= 97 && asc <= 122)) { 29 | return true; 30 | } 31 | } 32 | return false; 33 | }; 34 | 35 | 36 | module.exports = { 37 | hashNameIntoInt, hasLetter 38 | }; -------------------------------------------------------------------------------- /routes/config.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash'); 2 | const express = require('express'); 3 | const { config, setConfig } = require('../config'); 4 | 5 | const router = express.Router(); 6 | 7 | // 更新配置文件 8 | router.post('/config', (req, res, next) => { 9 | if (!config.auth || req.user.name === 'admin') { 10 | try { 11 | const configClone = _.cloneDeep(req.body.config); 12 | delete configClone.md5secret; 13 | delete configClone.jwtsecret; 14 | setConfig(configClone); 15 | res.send({ message: '保存成功.' }) 16 | } catch(err) { 17 | next(err); 18 | } 19 | } else { 20 | res.status(401).send({ error: '只有 admin 账号能修改配置文件.' }); 21 | } 22 | }); 23 | 24 | // 获取配置文件 25 | router.get('/config', (req, res, next) => { 26 | if (!config.auth || req.user.name === 'admin') { 27 | try { 28 | const configClone = _.cloneDeep(config); 29 | delete configClone.md5secret; 30 | delete configClone.jwtsecret; 31 | res.send({ config: configClone }); 32 | } catch(err) { 33 | next(err); 34 | } 35 | } else { 36 | res.status(401).send({ error: '只有 admin 账号能读取配置文件.' }); 37 | } 38 | }); 39 | 40 | 41 | module.exports = router; 42 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "kikoeru-express", 3 | "version": "0.3.0", 4 | "description": "server", 5 | "bin": "app.js", 6 | "pkg": { 7 | "scripts": [ 8 | "filesystem/scanner.js", 9 | "dist/**/*.js" 10 | ], 11 | "assets": [ 12 | "dist/**/*", 13 | "static/**/*" 14 | ] 15 | }, 16 | "scripts": { 17 | "start": "node app.js", 18 | "dev": "nodemon app.js", 19 | "scan": "node ./filesystem/scanner.js", 20 | "build": "pkg -t win package.json --out-path package" 21 | }, 22 | "author": "Watanuki-Kimihiro", 23 | "license": "GPL-3.0-or-later", 24 | "dependencies": { 25 | "axios": "^0.19.2", 26 | "body-parser": "^1.19.0", 27 | "cheerio": "^1.0.0-rc.3", 28 | "connect-history-api-fallback": "^1.6.0", 29 | "express": "^4.17.1", 30 | "express-jwt": "^5.3.1", 31 | "express-validator": "^6.4.0", 32 | "htmlparser2": "^4.1.0", 33 | "jsonwebtoken": "^8.5.1", 34 | "knex": "^0.20.11", 35 | "limit-promise": "^1.0.4", 36 | "md5": "^2.2.1", 37 | "natural-orderby": "^2.0.3", 38 | "recursive-readdir": "^2.2.2", 39 | "socket.io": "^2.3.0", 40 | "socketio-jwt-auth": "^0.1.0", 41 | "sqlite3": "^4.1.1", 42 | "string-random": "^0.1.3", 43 | "tunnel-agent": "^0.6.0" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /api.js: -------------------------------------------------------------------------------- 1 | const expressJwt = require('express-jwt'); // 把 JWT 的 payload 部分赋值于 req.user 2 | const authRoutes = require('./routes/auth'); 3 | const configRoutes = require('./routes/config'); 4 | const fileRoutes = require('./routes/file'); 5 | const mylistRoutes = require('./routes/mylist'); 6 | const userRoutes = require('./routes/user'); 7 | const workRoutes = require('./routes/work'); 8 | const { config } = require('./config'); 9 | 10 | /** 11 | * Get token from header or query string. 12 | */ 13 | const getToken = req => { 14 | if (req.headers.authorization && req.headers.authorization.split(' ')[0] === 'Bearer') { 15 | return req.headers.authorization.split(' ')[1]; 16 | } else if (req.query && req.query.token) { 17 | return req.query.token; 18 | } else { 19 | return null; 20 | } 21 | }; 22 | 23 | 24 | module.exports = app => { 25 | if (config.auth) { 26 | // expressJwt 中间件 27 | // 验证指定 http 请求的 JsonWebTokens 的有效性, 如果有效就将 JsonWebTokens 的值设置到 req.user 里面, 然后路由到相应的 router 28 | app.use('/api', expressJwt({ secret: config.jwtsecret, getToken }).unless({ path: ['/api/me'] })); 29 | } 30 | app.use('/api', authRoutes); 31 | app.use('/api', configRoutes); 32 | app.use('/api', fileRoutes); 33 | app.use('/api', mylistRoutes); 34 | app.use('/api', userRoutes); 35 | app.use('/api', workRoutes); 36 | }; 37 | -------------------------------------------------------------------------------- /database/user.js: -------------------------------------------------------------------------------- 1 | const knex = require('./connect'); 2 | 3 | /** 4 | * 创建一个新用户 5 | * @param {Object} user User object. 6 | */ 7 | const createUser = user => knex.transaction(async trx => { 8 | const userRes = await trx('t_user') 9 | .where('name', '=', user.name) 10 | .first(); 11 | 12 | if (userRes) { 13 | throw new Error(`用户 ${user.name} 已存在.`); 14 | } 15 | 16 | return trx('t_user') 17 | .insert({ 18 | name: user.name, 19 | password: user.password, 20 | group: user.group 21 | }); 22 | }); 23 | 24 | /** 25 | * 更新用户密码 26 | * @param {String} username 用户名 27 | * @param {String} newPassword 新密码 28 | */ 29 | const updateUserPassword = (username, newPassword) => knex.transaction(async trx => { 30 | const updatedNum = await trx('t_user') 31 | .where('name', '=', username) 32 | .update({ 33 | password: newPassword 34 | }); 35 | 36 | if (updatedNum === 0) { 37 | throw new Error(`不存在用户名为 ${username} 的用户.`); 38 | } 39 | 40 | return updatedNum; 41 | }); 42 | 43 | /** 44 | * 重置用户密码为 "password" 45 | * @param {String} username 用户名 46 | */ 47 | const resetUserPassword = username => knex.transaction(async trx => { 48 | const updatedNum = await trx('t_user') 49 | .where('name', '=', username) 50 | .update({ 51 | password: 'password' 52 | }); 53 | 54 | if (updatedNum === 0) { 55 | throw new Error(`不存在用户名为 ${username} 的用户.`); 56 | } 57 | 58 | return updatedNum; 59 | }); 60 | 61 | /** 62 | * 删除用户 63 | * @param {String} username 用户名 64 | */ 65 | const deleteUsers = usernames => knex.transaction(trx => trx('t_user') 66 | .where('name', 'in', usernames) 67 | .del()); 68 | 69 | const getUsers = () => knex('t_user') 70 | .select('name', 'group') 71 | .whereNot('name', '=', 'admin') 72 | 73 | module.exports = { 74 | createUser, updateUserPassword, resetUserPassword, deleteUsers, getUsers 75 | }; -------------------------------------------------------------------------------- /routes/auth.js: -------------------------------------------------------------------------------- 1 | const md5 = require('md5'); 2 | const jwt = require('jsonwebtoken'); 3 | const { check, validationResult } = require('express-validator'); // 后端校验 4 | const express = require('express'); 5 | const db = require('../database'); 6 | const { config } = require('../config'); 7 | 8 | const signtoken = (obj) => jwt.sign(obj, config.jwtsecret, { expiresIn: config.expiresIn }); 9 | 10 | const cmd5 = str => md5(str + config.md5secret); 11 | 12 | const router = express.Router(); 13 | 14 | // 用户登录 15 | router.post('/auth/me', [ 16 | check('username') 17 | .isLength({ min: 5 }) 18 | .withMessage('用户名长度至少为 5'), 19 | check('password') 20 | .isLength({ min: 8 }) 21 | .withMessage('密码长度至少为 8') 22 | ], (req, res, next) => { 23 | // Finds the validation errors in this request and wraps them in an object with handy functions 24 | const errors = validationResult(req); 25 | if (!errors.isEmpty()) { 26 | return res.status(422).send({ errors: errors.array() }); 27 | } 28 | 29 | const username = req.body.username; 30 | const password = req.body.password; 31 | 32 | db.knex('t_user') 33 | .select('name', 'group') 34 | .where('name', '=', username) 35 | .andWhere('password', '=', cmd5(password)) 36 | .first() 37 | .then(user => { 38 | if (!user) { 39 | res.status(401).send({error: '用户名或密码错误.'}); 40 | } else { 41 | const token = signtoken(user); 42 | res.send({ token }); 43 | } 44 | }) 45 | .catch((err) => { 46 | next(err); 47 | }); 48 | }); 49 | 50 | if (config.auth) { 51 | router.get('/auth/me', expressJwt({ secret: config.jwtsecret })); 52 | } 53 | 54 | // 获取用户信息 55 | router.get('/auth/me', (req, res, next) => { 56 | // 同时告诉客户端,服务器是否在启用用户验证 57 | const user = config.auth 58 | ? { name: req.user.name, group: req.user.group } 59 | : { name: 'admin', group: 'administrator' } 60 | 61 | res.send({ isUsingAuth: config.auth, user }); 62 | }); 63 | 64 | 65 | module.exports = router; 66 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Kikoeru 2 | 一个同人音声专用的音乐流媒体服务器,详细的使用说明见[**用户文档**](https://github.com/Watanuki-Kimihiro/kikoeru-express/blob/master/%E7%94%A8%E6%88%B7%E6%96%87%E6%A1%A3.md) 3 | 4 | ![截图.png](https://i.loli.net/2020/04/22/hjXW4PdsaoIt97U.png) 5 | 6 | ### 功能介绍 7 | - 从 DLSite 爬取音声元数据 8 | - 通过标签或关键字快速检索想要找到的音声 9 | - 根据音声元数据对检索结果进行排序 10 | - 可以选择通过 JWT 验证用户或关闭用户认证功能 11 | - 支持在 Web 端修改配置文件和扫描音声库 12 | - 支持为音声库添加多个根文件夹 13 | 14 | ### 安装部署 15 | ```bash 16 | # 安装依赖 17 | npm install 18 | 19 | # 启动服务器 20 | npm start 21 | 22 | # Express listening on http://localhost:8888 23 | ``` 24 | 本项目还有打包好的 **Windows 系统下可用的 exe 可执行文件**与 **docker 镜像**版本,docker 镜像的使用说明详见[**用户文档**](https://github.com/Watanuki-Kimihiro/kikoeru-express/blob/master/%E7%94%A8%E6%88%B7%E6%96%87%E6%A1%A3.md) 25 | 26 | ### 技术栈 27 | - axios (网络请求) 28 | - express (构建后端服务) 29 | - sqlite3 (文件型数据库) 30 | - knexjs (操作数据库) 31 | - cheerio (将 html 解析为 jQuery 对象) 32 | - jsonwebtoken (用户认证) 33 | - socket.io (用于将扫描音声库的结果实时传给客户端) 34 | - child_process (nodejs 子进程) 35 | - pkg (打包为可执行文件) 36 | 37 | 38 | ### 项目目录结构 39 | ``` 40 | ├── auth/ # 用户认证相关路由 41 | ├── config/ # 存放配置文件 42 | ├── covers/ # 存放音声封面 43 | ├── database/ # 操作数据库相关代码 44 | ├── dist/ # 存放前端项目 kikoeru-quasar 构建的 SPA 45 | ├── filesystem/ # 存放扫描相关代码 46 | ├── package/ # 存放 pkg 打包后的可执行文件 47 | ├── scraper/ # 存放爬虫相关代码 48 | ├── sqlite/ # 存放 sqlite 数据库文件 49 | ├── static/ # 存放静态资源 50 | ├── .gitignore # git 忽略路径 51 | ├── api.js # 为 express 实例添加路由与 jwt 验证中间件 52 | ├── app.js # 项目入口文件 53 | ├── config.js # 用于生成与修改 config.json 配置文件 54 | ├── Dockerfile # 用于构建 docker 镜像的文本文件 55 | ├── package.json # npm 脚本和依赖项 56 | └── routes.js # 主要路由 57 | ``` 58 | 59 | 60 | ### TODO 61 | - [ ] 添加计划任务,定期更新音声的动态元数据 62 | - [ ] 允许用户手动修改音声元数据 63 | - [ ] 手动添加抓不到元数据的音声 64 | - [ ] 爬取音声的简介信息 65 | - [ ] 添加收藏功能 66 | - [ ] 添加保存歌单功能 67 | 68 | ### 感谢 69 | 本项目的大部分后端代码来自于开源项目 [kikoeru](https://github.com/nortonandrews/kikoeru) 70 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # TypeScript v1 declaration files 45 | typings/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | 78 | # Next.js build output 79 | .next 80 | 81 | # Nuxt.js build / generate output 82 | .nuxt 83 | dist 84 | 85 | # Gatsby files 86 | .cache/ 87 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 88 | # https://nextjs.org/blog/next-9-1#public-directory-support 89 | # public 90 | 91 | # vuepress build output 92 | .vuepress/dist 93 | 94 | # Serverless directories 95 | .serverless/ 96 | 97 | # FuseBox cache 98 | .fusebox/ 99 | 100 | # DynamoDB Local files 101 | .dynamodb/ 102 | 103 | # TernJS port file 104 | .tern-port 105 | 106 | # Visual Studio Code 107 | .vscode/ 108 | 109 | # Windows Batch 110 | /*.bat 111 | 112 | # Package 113 | package 114 | 115 | # Config 116 | config 117 | 118 | # Covers 119 | covers 120 | 121 | # SQLite 122 | sqlite -------------------------------------------------------------------------------- /scraper/axios.js: -------------------------------------------------------------------------------- 1 | const originAxios = require('axios'); 2 | const { httpsOverHttp, httpOverHttp } = require('tunnel-agent'); 3 | const { config } = require('../config'); 4 | 5 | const axios = originAxios.create(); 6 | 7 | // 使用 http 代理 8 | axios.interceptors.request.use(function (axiosConfig) { 9 | // 代理设置 10 | const tunnelOptions = { 11 | proxy: { 12 | port: config.httpProxyPort || null, 13 | host: config.httpProxyHost || null 14 | } 15 | }; 16 | 17 | if (tunnelOptions.proxy.port) { 18 | axiosConfig.proxy = false; // 强制禁用环境变量中的代理配置 19 | axiosConfig.httpAgent = httpOverHttp(tunnelOptions); 20 | axiosConfig.httpsAgent = httpsOverHttp(tunnelOptions); 21 | } 22 | 23 | return axiosConfig; 24 | }); 25 | 26 | const retryGet = async (url, axiosConfig) => { 27 | const defaultLimit = config.retry || 5; 28 | const defaultRetryDelay = config.retryDelay || 2000; 29 | let defaultTimeout = 10000; 30 | if (url.indexOf('dlsite') !== -1) { 31 | defaultTimeout = config.dlsiteTimeout || defaultTimeout; 32 | } else if (url.indexOf('hvdb') !== -1) { 33 | defaultTimeout = config.hvdbTimeout || defaultTimeout; 34 | } 35 | 36 | // 添加自定义的 retry 参数 37 | axiosConfig.retry = { 38 | limit: (axiosConfig.retry && axiosConfig.retry.limit) || defaultLimit, 39 | retryCount: (axiosConfig.retry && axiosConfig.retry.retryCount) || 0, 40 | retryDelay: (axiosConfig.retry && axiosConfig.retry.retryDelay) || defaultRetryDelay, 41 | timeout: (axiosConfig.retry && axiosConfig.retry.timeout) || defaultTimeout 42 | }; 43 | 44 | // 超时自动取消请求 45 | const abort = originAxios.CancelToken.source(); 46 | const timeoutId = setTimeout( 47 | () => abort.cancel(`Timeout of ${axiosConfig.retry.timeout}ms.`), 48 | axiosConfig.retry.timeout 49 | ); 50 | axiosConfig.cancelToken = abort.token; 51 | 52 | try { 53 | const res = await axios.get(url, axiosConfig); 54 | clearTimeout(timeoutId); 55 | 56 | return res; 57 | } catch (err) { 58 | // 重试延时 59 | const backoff = new Promise((resolve) => { 60 | setTimeout(() => resolve(), axiosConfig.retry.retryDelay); 61 | }); 62 | 63 | // 错误重试 64 | if (axiosConfig.retry.retryCount < axiosConfig.retry.limit && !err.response) { 65 | axiosConfig.retry.retryCount += 1; 66 | await backoff; 67 | console.log(`${url} 第 ${axiosConfig.retry.retryCount} 次重试请求`); 68 | 69 | return retryGet(url, axiosConfig); 70 | } else { 71 | throw err; 72 | } 73 | } 74 | }; 75 | 76 | axios.retryGet = retryGet; 77 | 78 | 79 | module.exports = axios; 80 | -------------------------------------------------------------------------------- /config.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | const stringRandom = require('string-random'); 4 | 5 | const rootDir = process.pkg ? path.join(process.execPath, '..') : __dirname; 6 | const configPath = path.join(rootDir, 'config', 'config.json'); // 配置文件路径 7 | const coverFolderDir = path.join(rootDir, 'covers'); 8 | const sqliteFolderDir = path.join(rootDir, 'sqlite'); 9 | 10 | let config = null; 11 | 12 | const defaultConfig = { 13 | maxParallelism: 16, 14 | rootFolders: [ 15 | // { 16 | // name: '', 17 | // path: '' 18 | // } 19 | ], 20 | auth: false, 21 | md5secret: stringRandom(14), 22 | jwtsecret: stringRandom(14), 23 | expiresIn: 2592000, 24 | maxRecursionDepth: 2, 25 | pageSize: 12, 26 | tagLanguage: 'zh-cn', 27 | retry: 5, 28 | dlsiteTimeout: 10000, 29 | hvdbTimeout: 10000, 30 | retryDelay: 2000, 31 | httpProxyHost: '', 32 | httpProxyPort: 0 33 | }; 34 | 35 | const initConfig = () => fs.writeFileSync(configPath, JSON.stringify(defaultConfig, null, "\t")); 36 | 37 | const setConfig = newConfig => { 38 | // 更新 config 39 | for(let key in config) { 40 | if (newConfig[key]) { 41 | config[key] = newConfig[key]; 42 | } 43 | } 44 | 45 | // 保存 config 46 | fs.writeFileSync(configPath, JSON.stringify(config, null, "\t")); 47 | }; 48 | 49 | const init = () => { 50 | if (!fs.existsSync(sqliteFolderDir)) { 51 | try { 52 | fs.mkdirSync(sqliteFolderDir, { recursive: true }); 53 | } catch (err) { 54 | throw new Error(`在创建存放数据库文件的文件夹时出错: ${err.message}`); 55 | } 56 | } 57 | 58 | if (!fs.existsSync(coverFolderDir)) { 59 | try { 60 | fs.mkdirSync(coverFolderDir, { recursive: true }); 61 | } catch (err) { 62 | throw new Error(`在创建存放音声封面的文件夹时出错: ${err.message}`); 63 | } 64 | } 65 | 66 | if (!fs.existsSync(configPath)) { 67 | const configFolderDir = path.dirname(configPath); 68 | if (!fs.existsSync(configFolderDir)) { 69 | try { 70 | fs.mkdirSync(configFolderDir, { recursive: true }); 71 | } catch (err) { 72 | throw new Error(`在创建存放配置文件的文件夹时出错: ${err.message}`); 73 | } 74 | } 75 | 76 | try { 77 | initConfig(); 78 | } catch (err) { 79 | throw new Error(`在初始化配置文件时出错: ${err.message}`); 80 | } 81 | } 82 | 83 | if (!config) { 84 | try { 85 | config = JSON.parse(fs.readFileSync(configPath)); 86 | } catch (err) { 87 | throw new Error(`在解析 config.json 时出错: ${err.message}`); 88 | } 89 | } 90 | }; 91 | 92 | try { 93 | init(); 94 | } catch (err) { 95 | console.error(err); 96 | } 97 | 98 | 99 | module.exports = { 100 | coverFolderDir, sqliteFolderDir, config, 101 | setConfig 102 | }; 103 | -------------------------------------------------------------------------------- /routes/file.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const jschardet = require("jschardet"); // 检测文本编码 3 | const iconv = require('iconv-lite'); // 文本解码 4 | const express = require('express'); 5 | const knex = require('../database/connect'); 6 | const { coverFolderDir, config } = require('../config'); 7 | 8 | const router = express.Router(); 9 | 10 | router.get('/file/cover/:id', (req, res, next) => { 11 | const rjcode = (`000000${req.params.id}`).slice(-6); 12 | const type = req.query.type || 'main'; // 'main' or 'sam' 13 | res.sendFile(path.join(coverFolderDir, `RJ${rjcode}_img_${type}.jpg`), (err) => { 14 | if (err) { 15 | res.sendFile(path.join(__dirname, '..', 'static', 'no-image.jpg'), (err2) => { 16 | if (err2) { 17 | next(err2); 18 | } 19 | }); 20 | } 21 | }); 22 | }); 23 | 24 | router.get('/file/stream/:id/:index', (req, res, next) => { 25 | knex('t_work') 26 | .select('root_folder', 'dir', 'tracks') 27 | .where('id', '=', req.params.id) 28 | .first() 29 | .then((work) => { 30 | if (!work) { 31 | res.status(404).send({ error: `不存在 id 为 ${workid} 的音声.` }); 32 | } else { 33 | const rootFolder = config.rootFolders.find(rootFolder => rootFolder.name === work.root_folder); 34 | if (rootFolder) { 35 | const tracks = JSON.parse(work.tracks); 36 | const track = tracks[req.params.index]; 37 | res.sendFile(path.join(rootFolder.path, work.dir, track.subtitle || '', track.title)); 38 | } else { 39 | res.status(500).send({ error: `找不到文件夹: '${work.root_folder}',请尝试重启服务器或重新扫描.` }); 40 | } 41 | } 42 | }); 43 | }); 44 | 45 | router.get('/file/lyric/:id/:index', (req, res, next) => { 46 | knex('t_work') 47 | .select('root_folder', 'dir', 'tracks') 48 | .where('id', '=', req.params.id) 49 | .first() 50 | .then((work) => { 51 | if (!work) { 52 | res.status(404).send({ error: `不存在 id 为 ${workid} 的音声.` }); 53 | } else { 54 | const rootFolder = config.rootFolders.find(rootFolder => rootFolder.name === work.root_folder); 55 | if (rootFolder) { 56 | const tracks = JSON.parse(work.tracks); 57 | const track = tracks[req.params.index]; 58 | const lrcFile = track.title.split('.')[0] + '.lrc'; 59 | fs.readFile(path.join(rootFolder.path, work.dir, track.subtitle || '', lrcFile), (err, data) => { 60 | if (err) { 61 | next(err); 62 | } else { 63 | const lyric = iconv.decode(data, jschardet.detect(data).encoding); 64 | res.send({ lyric }); 65 | } 66 | }); 67 | } else { 68 | res.status(500).send({error: `找不到文件夹: "${work.root_folder}",请尝试重启服务器或重新扫描.`}); 69 | } 70 | } 71 | }); 72 | }); 73 | 74 | 75 | module.exports = router; 76 | -------------------------------------------------------------------------------- /用户文档.md: -------------------------------------------------------------------------------- 1 | ### 介绍 2 | #### kikoeru 是一个网络应用程序 3 | > 网络应用程序(英语:web application,英文简称 Webapp)分为客户端到服务器架构或无服务器后端架构。其中的客户端就是网页浏览器。常见的网页应用程序有Webmail、网络购物、网络拍卖、wiki、网络论坛、博客、网络游戏等诸多应用。 4 | 网络应用程序风行的原因之一,是因为可以直接在各种电脑平台上运行,不需要事先安装或定期升级等程序。 5 | (来自维基百科: [网络应用程序](https://zh.wikipedia.org/wiki/%E7%BD%91%E7%BB%9C%E5%BA%94%E7%94%A8%E7%A8%8B%E5%BA%8F)) 6 | 7 | ### 使用说明 8 | #### 1.下载并打开可执行文件运行服务端程序 [releases](https://github.com/Watanuki-Kimihiro/kikoeru-express/releases) 9 | 10 | #### 2.使用浏览器连接服务端 11 | 在运行服务端的电脑上,直接通过浏览器访问 http://localhost:8888 12 | 13 | 如果你还有其它设备与运行服务端的电脑在同一局域网内,并且想在这些设备上使用,这时就需要知道运行服务端的电脑在局域网内的 IP。 以 Windows 系统的电脑为例,首先在运行服务端的电脑上按照教程查看内网 IP https://jingyan.baidu.com/article/9f63fb91f0fa9889400f0ed9.html 14 | 15 | 例如查到运行服务端的电脑的 IP 为 192.168.123.164,再在局域网内的其它的设备(电脑或手机)上,通过浏览器访问 http://192.168.123.164:8888 16 | 17 | #### 3.初次运行,点击 "admin" 进入后台管理页面 18 | ![01.png](https://i.loli.net/2020/04/22/j81VBrIfRyG9TzC.png) 19 | 20 | #### 4.添加根文件夹并保存 21 | ![02.png](https://i.loli.net/2020/04/22/aMwLEKgjfVHvyJT.png) 22 | 23 | #### 5.执行扫描 24 | **注意: 文件夹名称中不带 RJ 号的音声文件夹是扫描不到的** 25 | 26 | 点击展开可以看到日志 27 | 28 | 常见错误有以下四种: 29 | 1. ➜ 在抓取元数据/下载封面过程中出错: timeout of 2000ms exceeded 30 | 2. ➜ 在抓取元数据/下载封面过程中出错: Client network socket disconnected before secure TLS connection was established 31 | 3. ➜ 在抓取元数据/下载封面过程中出错: read ECONNRESET 32 | 4. ➜ 在抓取元数据/下载封面过程中出错: Couldn't request work page HTML (https://www.dlsite.com/maniax/work/=/product_id/RJ200879.html), received: 404. 33 | 34 | 第一种错误是请求超时,一般稍后重试可以解决,如果屡次产生都失败,就要考虑是不是网络问题,如果是因为 DLsite 被墙了就需要到 [高级设置] 页面中的 "爬虫相关设置" 中设置代理。 35 | 36 | 第二种错误与第三种错误一般会批量出现,当发现有大量这种错误出现时,可以考虑先终止扫描进程,稍后重试,一般可以解决。 37 | 38 | 第四种错误时因为该音声在 DLSite 上的页面已经不存在了,可能是因为下架或被收到一个合集里打包售卖。这种错误目前无法解决,后续会考虑增加手动添加音声的功能。 39 | 40 | ![03.png](https://i.loli.net/2020/04/22/pb5GtPoVKL6mwzA.png) 41 | 42 | #### 6.初次扫描后需要重启服务端程序 43 | ![04.png](https://i.loli.net/2020/04/22/kfLF6qP2b3t9oiS.png) 44 | 45 | #### 7.注意数据库中只会存储一种语言的标签 46 | ![05.png](https://i.loli.net/2020/04/22/eSRPvprqzosADXh.png) 47 | 48 | #### 8.关于如何启用 http 代理 49 | 你应该只在 DLsite 被墙时启用 http 代理 50 | 51 | 下面以 v2rayN 为例,找到 http 代理的端口号 52 | ![11.png](https://i.loli.net/2020/04/22/VAbRHezhwx4lrON.png) 53 | 54 | 然后在 [高级设置] 页面中的 "爬虫相关设置" 中设置代理并点击保存按钮保存设置 55 | 56 | 注意填写错误的端口号会在爬虫时出错: 57 | 58 | 1. ➜ 在抓取元数据过程中出错: tunneling socket could not be established, cause=socket hang up (错填成 socks 代理服务的端口号) 59 | 2. ➜ 在抓取元数据过程中出错: tunneling socket could not be established, cause=connect ECONNREFUSED 127.0.0.1:10887 (错填成无效的端口号,端口 10887 上并没有启用任何服务) 60 | 61 | ![12.png](https://i.loli.net/2020/04/22/yJRU63XLrflHNnG.png) 62 | 63 | ### docke 镜像使用说明 64 | #### 1.下载 docker 镜像文件 65 | 66 | #### 2.从文件中添加镜像 67 | ![06.png](https://i.loli.net/2020/04/22/HqFvgnMybEzhjQm.png) 68 | 69 | #### 3.挂载数据卷 70 | ![07.png](https://i.loli.net/2020/04/22/mcM5D4vfAgoYbCQ.png) 71 | 72 | #### 4.映射端口 73 | ![08.png](https://i.loli.net/2020/04/22/5d2JkIf8XCe3KyG.png) 74 | 75 | #### 5.注意在 [音声库] 页面添加新的根文件夹时,要填写容器内部的路径 76 | ![09.png](https://i.loli.net/2020/04/22/FoHhi8nEzJRSMwG.png) 77 | -------------------------------------------------------------------------------- /scraper/hvdb.js: -------------------------------------------------------------------------------- 1 | const htmlparser = require('htmlparser2'); // 解析器 2 | 3 | const axios = require('./axios'); // 数据请求 4 | const { hashNameIntoInt } = require('./utils'); 5 | 6 | /** 7 | * Scrapes work metadata from public HVDB page HTML. 8 | * @param {number} id Work id. 9 | */ 10 | const scrapeWorkMetadataFromHVDB = id => new Promise((resolve, reject) => { 11 | const rjcode = (`000000${id}`).slice(-6); 12 | const url = `https://hvdb.me/Dashboard/WorkDetails/${id}`; 13 | 14 | console.log(`[RJ${rjcode}] 从 HVDB 抓取元数据...`); 15 | axios.retryGet(url, { retry: {} }) 16 | .then(response => { 17 | console.log('res HVDB') 18 | return response.data 19 | }) 20 | .then((data) => { //解析 21 | const work = { id, tags: [], vas: [] }; 22 | let writeTo; 23 | 24 | const parser = new htmlparser.Parser({ 25 | onopentag: (name, attrs) => { // 标签名 属性 26 | if (name === 'input') { 27 | if (attrs.id === 'Name') { 28 | work.title = attrs.value; 29 | } else if (attrs.name === 'SFW') { 30 | work.nsfw = attrs.value === 'false'; 31 | } 32 | } 33 | 34 | if (name === 'a') { 35 | if (attrs.href.indexOf('CircleWorks') !== -1) { 36 | work.circle = { 37 | id: attrs.href.substring(attrs.href.lastIndexOf('/') + 1), 38 | }; 39 | writeTo = 'circle.name'; 40 | } else if (attrs.href.indexOf('TagWorks') !== -1) { 41 | work.tags.push({ 42 | id: attrs.href.substring(attrs.href.lastIndexOf('/') + 1), 43 | }); 44 | writeTo = 'tag.name'; 45 | } else if (attrs.href.indexOf('CVWorks') !== -1) { 46 | work.vas.push({ 47 | //id: hashNameIntoInt(attrs.href), // TODO: RESHNIX!!! 48 | }); 49 | writeTo = 'va.name'; 50 | } 51 | } 52 | }, 53 | onclosetag: () => { writeTo = null; }, 54 | ontext: (text) => { 55 | switch (writeTo) { 56 | case 'circle.name': 57 | work.circle.name = text; 58 | break; 59 | case 'tag.name': 60 | work.tags[work.tags.length - 1].name = text; 61 | break; 62 | case 'va.name': 63 | work.vas[work.vas.length - 1].name = text; 64 | work.vas[work.vas.length - 1].id = hashNameIntoInt(text); 65 | break; 66 | default: 67 | } 68 | }, 69 | }, { decodeEntities: true }); 70 | parser.write(data); 71 | parser.end(); 72 | 73 | if (work.tags.length === 0 && work.vas.length === 0) { 74 | reject(new Error('Couldn\'t parse data from HVDB work page.')); 75 | } else { 76 | console.log(`[RJ${rjcode}] 成功从 HVDB 抓取元数据...`); 77 | resolve(work); 78 | } 79 | }) 80 | .catch((error) => { 81 | if (error.response) { 82 | // 请求已发出,但服务器响应的状态码不在 2xx 范围内 83 | reject(new Error(`Couldn't request work page HTML (${url}), received: ${error.response.status}.`)); 84 | } else if (error.request) { 85 | reject(error); 86 | console.log(error.request); 87 | } else { 88 | console.log('Error', error.message); 89 | reject(error); 90 | } 91 | }); 92 | }); 93 | 94 | 95 | module.exports = scrapeWorkMetadataFromHVDB; 96 | -------------------------------------------------------------------------------- /routes/mylist.js: -------------------------------------------------------------------------------- 1 | const { check, validationResult } = require('express-validator'); // 后端校验 2 | const express = require('express'); 3 | const db = require('../database'); 4 | const { config } = require('../config'); 5 | 6 | const router = express.Router(); 7 | 8 | router.post('/mylist/update_mylist', [ 9 | check('type') 10 | .custom(value => { 11 | if (['create', 'delete', 'rename'].indexOf(value) === -1) { 12 | throw new Error("type 必须为 ['create', 'delete', 'rename'] 中的一个."); 13 | } 14 | return true 15 | }) 16 | ], async (req, res, next) => { 17 | const errors = validationResult(req); 18 | if (!errors.isEmpty()) { 19 | return res.status(422).json({ errors: errors.array() }); 20 | } 21 | 22 | const username = config.auth ? req.user.name : 'admin'; 23 | const type = req.body.type; 24 | 25 | try { 26 | switch (type) { 27 | case 'create': 28 | const mylistId = await db.mylist.createMylist(username, req.body.mylist_name); 29 | res.send({ result: true, mylist_id: mylistId }); 30 | break; 31 | case 'delete': 32 | await db.mylist.deleteMylist(username, req.body.mylist_id); 33 | res.send({ result: true }); 34 | break; 35 | case 'rename': 36 | await db.mylist.renameMylist(username, req.body.mylist_id, req.body.mylist_name); 37 | res.send({ result: true }); 38 | break; 39 | } 40 | } catch (err) { 41 | if (err.message.indexOf('不存在') !== -1) { 42 | res.status(404).send({ error: err.message }); 43 | } else if (err.message.indexOf('不允许') !== -1) { 44 | res.status(403).send({ error: err.message }); 45 | } else { 46 | next(err); 47 | } 48 | } 49 | }); 50 | 51 | router.post('/mylist/update_mylist_work', [ 52 | check('type') 53 | .custom(value => { 54 | if (['add', 'delete', 'order'].indexOf(value) === -1) { 55 | throw new Error("type 必须为 ['add', 'delete', 'order'] 中的一个."); 56 | } 57 | return true 58 | }) 59 | ], async (req, res, next) => { 60 | const errors = validationResult(req); 61 | if (!errors.isEmpty()) { 62 | return res.status(422).json({ errors: errors.array() }); 63 | } 64 | 65 | const username = config.auth ? req.user.name : 'admin'; 66 | const type = req.body.type; 67 | 68 | try { 69 | switch (type) { 70 | case 'add': 71 | await db.mylist.addMylistWork(username, req.body.mylist_id, req.body.mylist_work_id); 72 | break; 73 | case 'delete': 74 | await db.mylist.deleteMylistWork(username, req.body.mylist_id, req.body.mylist_work_id); 75 | break; 76 | case 'order': 77 | await db.mylist.orderMylistWork(username, req.body.mylist_id, req.body.works); 78 | break; 79 | } 80 | 81 | res.send({ result: true }); 82 | } catch (err) { 83 | if (err.message.indexOf('不存在') !== -1) { 84 | res.status(404).send({ error: err.message }); 85 | } else if (err.message.indexOf('不允许') !== -1) { 86 | res.status(403).send({ error: err.message }); 87 | } else { 88 | next(err); 89 | } 90 | } 91 | }); 92 | 93 | router.get('/mylist/mylists', async (req, res, next) => { 94 | const username = config.auth ? req.user.name : 'admin'; 95 | try { 96 | const mylists = await db.mylist.getMylists(username); 97 | for (let i=0; i { 105 | mylist_works[j] = workRes; 106 | }) 107 | ); 108 | } 109 | await Promise.all(promises); 110 | 111 | mylists[i].mylist_works = mylist_works; 112 | } 113 | 114 | res.send({ mylists }); 115 | } catch (err) { 116 | next(err); 117 | } 118 | }); 119 | 120 | 121 | module.exports = router; 122 | -------------------------------------------------------------------------------- /app.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const express = require('express'); 3 | const bodyParser = require('body-parser'); // 获取 req.body 4 | const history = require('connect-history-api-fallback'); 5 | const http = require('http'); 6 | const jwtAuth = require('socketio-jwt-auth'); // 用于 JWT 验证的 socket.io 中间件 7 | const child_process = require('child_process'); // 子进程 8 | 9 | const { getConfig } = require('./config'); 10 | const config = getConfig(); 11 | 12 | const api = require('./api'); 13 | 14 | const app = express(); 15 | 16 | // parse application/x-www-form-urlencoded 17 | app.use(bodyParser.urlencoded({ extended: true })); 18 | // parse application/json 19 | app.use(bodyParser.json()); 20 | 21 | // connect-history-api-fallback 中间件后所有的 GET 请求都会变成 index (default: './index.html'). 22 | app.use(history({ 23 | // 将所有带 api 的 GET 请求都代理到 parsedUrl.path, 其实就是原来的路径 24 | rewrites: [ 25 | { 26 | from: /^\/api\/.*$/, 27 | to: context => context.parsedUrl.path 28 | } 29 | ] 30 | })); 31 | // Expose API routes 32 | api(app); 33 | 34 | // Serve WebApp routes 35 | app.use(express.static(path.join(__dirname, './dist'))); 36 | 37 | const server = http.createServer(app); 38 | // websocket 握手依赖 http 服务 39 | const io = require('socket.io')(server); 40 | 41 | if (config.auth) { 42 | io.use(jwtAuth.authenticate({ 43 | secret: config.jwtsecret 44 | }, (payload, done) => { 45 | const user = { 46 | name: payload.name, 47 | group: payload.group 48 | }; 49 | 50 | if (user.name === 'admin') { 51 | done(null, user); 52 | } else { 53 | done(null, false, '只有 admin 账号能登录管理后台.'); 54 | } 55 | })); 56 | } 57 | 58 | let scanner = null; 59 | 60 | // 有新的客户端连接时触发 61 | io.on('connection', function (socket) { 62 | // console.log('connection'); 63 | socket.emit('success', { 64 | message: '成功登录管理后台.', 65 | user: socket.request.user 66 | }); 67 | 68 | // socket.on('disconnect', () => { 69 | // console.log('disconnect'); 70 | // }); 71 | 72 | socket.on('ON_SCANNER_PAGE', () => { 73 | if (scanner) { 74 | // 防止用户在扫描过程中刷新页面 75 | scanner.send({ 76 | emit: 'SCAN_INIT_STATE' 77 | }); 78 | } 79 | }); 80 | 81 | socket.on('PERFORM_SCAN', () => { 82 | if (!scanner) { 83 | scanner = child_process.fork(path.join(__dirname, './filesystem/scanner.js'), { silent: false }); // 子进程 84 | scanner.on('exit', (code) => { 85 | scanner = null; 86 | if (code) { 87 | io.emit('SCAN_ERROR'); 88 | } 89 | }); 90 | 91 | scanner.on('message', (m) => { 92 | if (m.event) { 93 | io.emit(m.event, m.payload); 94 | } 95 | }); 96 | } 97 | }); 98 | 99 | socket.on('KILL_SCAN_PROCESS', () => { 100 | scanner.send({ 101 | exit: 1 102 | }); 103 | }); 104 | 105 | // 发生错误时触发 106 | socket.on('error', (err) => { 107 | console.error(err); 108 | }); 109 | }); 110 | 111 | // 返回错误响应 112 | app.use((err, req, res, next) => { 113 | if (err.name === 'UnauthorizedError') { 114 | // 验证错误 115 | res.status(401).send({ error: err.message }); 116 | } else if (err.code === 'SQLITE_ERROR') { 117 | if (err.message.indexOf('no such table') !== -1) { 118 | res.status(500).send({ error: '数据库结构尚未建立,请先执行扫描.'}); 119 | } 120 | } else { 121 | res.status(500).send({ error: err.message || err }); 122 | } 123 | }); 124 | 125 | 126 | // 获取本机IP地址 127 | const getIPAddress = () => { 128 | const interfaces = require('os').networkInterfaces(); 129 | for(let devName in interfaces){ 130 | const iface = interfaces[devName]; 131 | for (let i=0; i { 144 | console.log(`使用浏览器在本机访问 http://localhost:${process.env.PORT || 8888}`); 145 | console.log(`或在局域网访问 http://${LOCAL_IP}:${process.env.PORT || 8888}`); 146 | }); 147 | -------------------------------------------------------------------------------- /routes/user.js: -------------------------------------------------------------------------------- 1 | const md5 = require('md5'); 2 | const { check, validationResult } = require('express-validator'); // 后端校验 3 | const express = require('express'); 4 | const db = require('../database'); 5 | const { config } = require('../config'); 6 | 7 | const cmd5 = str => md5(str + config.md5secret); 8 | 9 | const router = express.Router(); 10 | 11 | // 创建一个新用户 (仅 admin 账号拥有权限) 12 | router.post('/user/user', [ 13 | check('username') 14 | .isLength({ min: 5 }) 15 | .withMessage('用户名长度至少为 5'), 16 | check('password') 17 | .isLength({ min: 8 }) 18 | .withMessage('密码长度至少为 8'), 19 | check('group') 20 | .custom(value => { 21 | if (['user', 'gaust'].indexOf(value) === -1) { 22 | throw new Error("用户组名称必须为 ['user', 'gaust'] 中的一个.") 23 | } 24 | return true 25 | }) 26 | ], (req, res, next) => { 27 | // Finds the validation errors in this request and wraps them in an object with handy functions 28 | const errors = validationResult(req); 29 | if (!errors.isEmpty()) { 30 | return res.status(422).send({ errors: errors.array() }); 31 | } 32 | 33 | const user = { 34 | name: req.body.username, 35 | password: req.body.password, 36 | group: req.body.group 37 | }; 38 | 39 | if (!config.auth || req.user.name === 'admin') { 40 | db.user.createUser({ 41 | name: user.name, 42 | password: cmd5(user.password), 43 | group: user.group 44 | }) 45 | .then(() => res.send({ message: `成功创建新用户: ${user.name}` })) 46 | .catch((err) => { 47 | if (err.message.indexOf('已存在') !== -1) { 48 | res.status(403).send({ error: err.message }); 49 | } else { 50 | next(err); 51 | } 52 | }); 53 | } else { 54 | res.status(401).send({ error: '只有 admin 账号能创建新用户.' }); 55 | } 56 | }); 57 | 58 | // 更新用户密码 59 | router.put('/user/user', [ 60 | check('username') 61 | .isLength({ min: 5 }) 62 | .withMessage('用户名长度至少为 5'), 63 | check('newPassword') 64 | .isLength({ min: 8 }) 65 | .withMessage('密码长度至少为 8') 66 | ], (req, res, next) => { 67 | const errors = validationResult(req); 68 | if (!errors.isEmpty()) { 69 | return res.status(422).json({ errors: errors.array() }); 70 | } 71 | 72 | const username = req.body.username; 73 | const newPassword = req.body.newPassword; 74 | 75 | if (!config.auth || username === 'admin' || req.user.name === username) { 76 | db.user.updateUserPassword(username, cmd5(newPassword)) 77 | .then(() => res.send({ message: '密码修改成功.' })) 78 | .catch((err) => { 79 | if (err.message.indexOf('不存在') !== -1) { 80 | res.status(404).send({ error: err.message }); 81 | } else { 82 | next(err); 83 | } 84 | }); 85 | } else { 86 | res.status(403).send({ error: '不允许修改其他用户的密码.' }); 87 | } 88 | }); 89 | 90 | // 删除用户 (仅 admin 账号拥有权限) 91 | router.delete('/user/users', [ 92 | check('usernames') 93 | .custom(value => { 94 | if (!value.isArray()) { 95 | throw new Error(`usernames 必须是一个数组.`); 96 | } 97 | if (!value.length) { 98 | throw new Error(`数组 usernames 的长度至少为 1`); 99 | } 100 | 101 | return true 102 | }) 103 | ], (req, res, next) => { 104 | const errors = validationResult(req); 105 | if (!errors.isEmpty()) { 106 | return res.status(422).json({ errors: errors.array() }); 107 | } 108 | 109 | const usernames = req.body.usernames; 110 | 111 | if (!config.auth || req.user.name === 'admin') { 112 | if (usernames.indexOf('admin') !== -1) { 113 | db.deleteUser(usernames) 114 | .then(() => { 115 | res.send({ message: '删除成功.' }); 116 | }) 117 | .catch((err) => { 118 | next(err); 119 | }); 120 | } else { 121 | res.status(403).send({ error: '不允许删除内置的管理员账号.' }); 122 | } 123 | } else { 124 | res.status(401).send({ error: '只有 admin 账号能删除用户.' }); 125 | } 126 | }); 127 | 128 | // 获取所有用户 129 | router.get('/user/users', (req, res, next) => { 130 | db.user.getUsers() 131 | .then(users => { 132 | res.send({ users }); 133 | }) 134 | .catch((err) => { 135 | next(err); 136 | }); 137 | }); 138 | 139 | module.exports = router; 140 | -------------------------------------------------------------------------------- /database/mylist.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash'); 2 | const knex = require('./connect'); 3 | 4 | /** 5 | * 创建收藏列表 6 | * @param {String} username 用户名 7 | * @param {String} mylistName 收藏列表名称 8 | */ 9 | const createMylist = (username, mylistName) => knex.transaction(async trx => { 10 | const indexRes = await trx('t_mylist') 11 | .insert({ 12 | user_name: username, 13 | name: mylistName, 14 | works: JSON.stringify(works) 15 | }); 16 | const mylistId = indexRes[0]; 17 | 18 | return mylistId; 19 | }); 20 | 21 | /** 22 | * 删除收藏列表 23 | * @param {String} username 用户名 24 | * @param {Number} mylistId 收藏列表 id 25 | */ 26 | const deleteMylist = (username, mylistId) => knex.transaction(async trx => { 27 | const deletedNum = await trx('t_mylist') 28 | .where('user_name', '=', username) 29 | .andWhere('id', '=', mylistId) 30 | .del(); 31 | 32 | if (deletedNum === 0) { 33 | throw new Error(`不存在 id 为 ${mylistId} 且属于用户 ${username} 的收藏列表.`); 34 | } 35 | 36 | return deletedNum; 37 | }); 38 | 39 | /** 40 | * 重命名收藏列表 41 | * @param {String} username 用户名 42 | * @param {Number} mylistId 收藏列表 id 43 | * @param {String} username 收藏列表名称 44 | */ 45 | const renameMylist = (username, mylistId, mylistName) => knex.transaction(async trx => { 46 | const updatedNum = await trx('t_mylist') 47 | .where('id', '=', mylistId) 48 | .andWhere('user_name', '=', username) 49 | .update({ 50 | name: mylistName 51 | }); 52 | 53 | if (updatedNum === 0) { 54 | throw new Error(`不存在 id 为 ${mylistId} 且属于用户 ${username} 的收藏列表.`); 55 | } 56 | 57 | return updatedNum; 58 | }); 59 | 60 | /** 61 | * 获取用户的全部收藏列表 62 | * @param {String} username 用户名 63 | */ 64 | const getMylists = async username => knex('t_mylist') 65 | .select('id', 'name as mylist_name', 'works as mylist_works') 66 | .where('user_name', '=', username); 67 | 68 | /** 69 | * 添加收藏列表音声 70 | * @param {String} username 用户名 71 | * @param {Number} mylistId 收藏列表 id 72 | * @param {Number} workid 音声 id 73 | */ 74 | const addMylistWork = (username, mylistId, workid) => knex.transaction(async trx => { 75 | const mylistRes = await trx('t_mylist') 76 | .select('works') 77 | .where('id', '=', mylistId) 78 | .andWhere('user_name', '=', username) 79 | .first(); 80 | 81 | if (!mylistRes) { 82 | throw new Error(`不存在 id 为 ${mylistId} 且属于用户 ${username} 的收藏列表.`); 83 | } 84 | 85 | const works = JSON.parse(mylistRes.works); 86 | const index = works.findIndex(work => work === workid); 87 | 88 | if (index !== -1) { 89 | throw new Error('不允许在收藏列表中添加重复的音声.'); 90 | } 91 | 92 | try { 93 | await trx('t_mylist_t_work_relation') 94 | .insert({ 95 | mylist_id: mylistId, 96 | work_id: workid 97 | }); 98 | } catch (err) { 99 | if (err.message.indexOf('FOREIGN KEY constraint failed') !== -1) { 100 | throw new Error(`不存在 id 为 ${workid} 的音声.`); 101 | } else { 102 | throw err; 103 | } 104 | } 105 | 106 | works.push(workid); 107 | await trx('t_mylist') 108 | .where('id', '=', mylistId) 109 | .update({ 110 | works: JSON.stringify(works) 111 | }); 112 | }); 113 | 114 | /** 115 | * 删除收藏列表音声 116 | * @param {String} username 用户名 117 | * @param {Number} mylistId 收藏列表 id 118 | * @param {Number} workid 音声 id 119 | */ 120 | const deleteMylistWork = (username, mylistId, workid) => knex.transaction(async trx => { 121 | const mylistRes = await trx('t_mylist') 122 | .select('works') 123 | .where('id', '=', mylistId) 124 | .andWhere('user_name', '=', username) 125 | .first(); 126 | 127 | if (!mylistRes) { 128 | throw new Error(`不存在 id 为 ${mylistId} 且属于用户 ${username} 的收藏列表.`); 129 | } 130 | 131 | const works = JSON.parse(mylistRes.works); 132 | const index = works.findIndex(work => work === workid); 133 | 134 | if (index === -1) { 135 | throw new Error(`在 id 为 ${mylistId} 的收藏列表中, 不存在 id 为 ${workid} 的音声.`); 136 | } 137 | 138 | await trx('t_mylist_t_work_relation') 139 | .where('mylist_id', '=', mylistId) 140 | .andWhere('work_id', '=', workid) 141 | .del(); 142 | 143 | works.splice(index, 1); 144 | await trx('t_mylist') 145 | .where('id', '=', mylistId) 146 | .update({ 147 | works: JSON.stringify(works) 148 | }); 149 | }); 150 | 151 | /** 152 | * 排序收藏列表音声 153 | * @param {String} username 用户名 154 | * @param {Number} mylistId 收藏列表 id 155 | * @param {Array} works 音声 id 数组 156 | */ 157 | const orderMylistWork = (username, mylistId, works) => knex.transaction(async trx => { 158 | const mylistRes = await trx('t_mylist') 159 | .select('works') 160 | .where('id', '=', mylistId) 161 | .andWhere('user_name', '=', username) 162 | .first(); 163 | 164 | if (!mylistRes) { 165 | throw new Error(`不存在 id 为 ${mylistId} 且属于用户 ${username} 的收藏列表.`); 166 | } 167 | 168 | const oldWorks = JSON.parse(mylistRes.works); 169 | const oldWorksClone = _.cloneDeep(oldWorks); 170 | const worksClone = _.cloneDeep(works); 171 | 172 | if (!_.isEqual(oldWorksClone.sort(), worksClone.sort())) { 173 | throw new Error('不允许在排序音声的同时添加或删除音声.'); 174 | } 175 | 176 | await trx('t_mylist') 177 | .where('id', '=', mylistId) 178 | .update({ 179 | works: JSON.stringify(works) 180 | }); 181 | }); 182 | 183 | 184 | module.exports = { 185 | createMylist, deleteMylist, renameMylist, getMylists, 186 | addMylistWork, deleteMylistWork, orderMylistWork 187 | }; 188 | -------------------------------------------------------------------------------- /routes/work.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const db = require('../database'); 3 | const { config } = require('../config'); 4 | 5 | const addMetadataForWorks = (username, works) => { 6 | const promises = []; 7 | for (let i=0; i { 11 | works[i] = workRes; 12 | }) 13 | ); 14 | } 15 | 16 | return Promise.all(promises); 17 | }; 18 | 19 | const router = express.Router(); 20 | 21 | // 获取音声元数据 22 | router.get('/work/work/:id', (req, res, next) => { 23 | db.getWorkMetadata(req.params.id) 24 | .then(work => res.send(work)) 25 | .catch(err => { 26 | if (err.message.indexOf('不存在') !== -1) { 27 | res.status(404).send({ error: err.message }); 28 | } else { 29 | next(err); 30 | } 31 | }); 32 | }); 33 | 34 | // 获取音声列表 35 | router.get('/work/works', async (req, res, next) => { 36 | const username = config.auth ? req.user.name : 'admin'; 37 | 38 | const releaseTerm = req.query.release_term; 39 | const ageCategory = req.query.age_category; 40 | 41 | const currentPage = parseInt(req.query.page) || 1; 42 | const order_by = req.query.order_by || 'release'; 43 | const sort = req.query.sort || 'desc'; 44 | const offset = (currentPage - 1) * config.pageSize; 45 | 46 | try { 47 | const query = () => db.work.worksFilter( 48 | db.work.getWorksBy(), 49 | releaseTerm, ageCategory 50 | ); 51 | const countRes = await query().count('* as count').first(); 52 | const works = await query().offset(offset).limit(config.pageSize).orderBy(order_by, sort); 53 | await addMetadataForWorks(username, works); 54 | 55 | res.send({ 56 | works, 57 | pagination: { 58 | currentPage, 59 | pageSize: config.pageSize, 60 | totalCount: countRes.count 61 | } 62 | }); 63 | } catch (err) { 64 | next(err); 65 | } 66 | }); 67 | 68 | // 获取包含指定关键字的音声列表 69 | router.get('/work/works/search/:keyword?', async (req, res, next) => { 70 | const username = config.auth ? req.user.name : 'admin'; 71 | const keyword = req.params.keyword && req.params.keyword.trim(); 72 | 73 | const releaseTerm = req.query.release_term; 74 | const ageCategory = req.query.age_category; 75 | 76 | const currentPage = parseInt(req.query.page) || 1; 77 | const order_by = req.query.order_by || 'release'; 78 | const sort = req.query.sort || 'desc'; 79 | const offset = (currentPage - 1) * config.pageSize; 80 | 81 | try { 82 | const query = () => db.work.worksFilter( 83 | db.work.getWorksByKeyWord(username, keyword), 84 | releaseTerm, ageCategory 85 | ); 86 | const countRes = await query().count('id as count').first(); 87 | const works = await query().offset(offset).limit(config.pageSize).orderBy(order_by, sort); 88 | await addMetadataForWorks(username, works); 89 | 90 | res.send({ 91 | works, 92 | pagination: { 93 | currentPage, 94 | pageSize: config.pageSize, 95 | totalCount: countRes.count 96 | } 97 | }); 98 | } catch(err) { 99 | next(err); 100 | } 101 | }); 102 | 103 | // 获取属于指定社团/系列/声优/标签的音声列表 104 | router.get('/work/works/:field/:id', async (req, res, next) => { 105 | const username = config.auth ? req.user.name : 'admin'; 106 | 107 | const releaseTerm = req.query.release_term; 108 | const ageCategory = req.query.age_category; 109 | 110 | const currentPage = parseInt(req.query.page) || 1; 111 | const order_by = req.query.order_by || 'release'; 112 | const sort = req.query.sort || 'desc'; 113 | const offset = (currentPage - 1) * config.pageSize; 114 | 115 | if (['circle', 'series', 'va', 'user_tag', 'dlsite_tag'].indexOf(req.params.field) === -1) { 116 | next(); 117 | } else { 118 | try { 119 | let workQuery; 120 | switch(req.params.field) { 121 | case 'circle': case 'series': case 'va': 122 | workQuery = () => db.work.getWorksBy(req.params.id, req.params.field); 123 | break; 124 | case 'user_tag': 125 | workQuery = () => db.work.getWorksByUserTag(username, req.params.id); 126 | break; 127 | case 'dlsite_tag': 128 | workQuery = () => db.work.getWorksByDlsiteTag(username, req.params.id); 129 | break; 130 | } 131 | 132 | const query = () => db.work.worksFilter( 133 | workQuery(), 134 | releaseTerm, ageCategory 135 | ); 136 | const countRes = await query().count('* as count').first(); 137 | const works = await query().offset(offset).limit(config.pageSize).orderBy(order_by, sort); 138 | await addMetadataForWorks(username, works); 139 | 140 | res.send({ 141 | works, 142 | pagination: { 143 | currentPage, 144 | pageSize: config.pageSize, 145 | totalCount: countRes.count 146 | } 147 | }); 148 | } catch (err) { 149 | next(err); 150 | } 151 | } 152 | }); 153 | 154 | // 获取社团/系列/声优/标签列表 155 | router.get('/work/(:field)s/', (req, res, next) => { 156 | const username = config.auth ? req.user.name : 'admin'; 157 | 158 | if (['circle', 'series' , 'va', 'dlsite_tag', 'user_tag'].indexOf(req.params.field) === -1) { 159 | next(); 160 | } else { 161 | db.work.getLabels(username, req.params.field) 162 | .then(list => res.send(list)) 163 | .catch(err => next(err)); 164 | } 165 | }); 166 | 167 | 168 | module.exports = router; 169 | -------------------------------------------------------------------------------- /filesystem/utils.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | const recursiveReaddir = require('recursive-readdir'); 4 | const { orderBy } = require('natural-orderby'); 5 | 6 | const { getConfig } = require('../config'); 7 | const config = getConfig(); 8 | 9 | /** 10 | * Returns list of playable tracks in a given folder. Track is an object 11 | * containing 'title', 'subtitle' and 'hash'. 12 | * @param {Number} id Work identifier. Currently, RJ/RE code. 13 | * @param {String} dir Work directory (absolute). 14 | */ 15 | const getTrackList = (id, dir) => recursiveReaddir(dir) 16 | .then((files) => { 17 | // Filter out any files not matching these extensions 18 | const filteredFiles = files.filter((file) => { 19 | const ext = path.extname(file); 20 | 21 | return (ext === '.mp3' || ext === '.ogg' || ext === '.opus' || ext === '.wav' || ext === '.flac' || ext === '.webm' || ext === '.mp4'|| ext === '.m4a'); 22 | }); 23 | 24 | // Sort by folder and title 25 | const sortedFiles = orderBy(filteredFiles.map((file) => { 26 | const shortFilePath = file.replace(path.join(dir, '/'), ''); 27 | const dirName = path.dirname(shortFilePath); 28 | 29 | return { 30 | title: path.basename(file), 31 | subtitle: dirName === '.' ? null : dirName, 32 | }; 33 | }), [v => v.subtitle, v => v.title]); 34 | 35 | // Add hash to each file 36 | const sortedHashedFiles = sortedFiles.map( 37 | (file, index) => ({ 38 | title: file.title, 39 | subtitle: file.subtitle, 40 | hash: `${id}/${index}`, 41 | }), 42 | ); 43 | 44 | return sortedHashedFiles; 45 | }) 46 | .catch((err) => { throw new Error(`Failed to get tracklist from disk: ${err}`); }); 47 | 48 | /** 49 | * 转换成树状结构 50 | * @param {Array} tracks 51 | * @param {String} workTitle 52 | */ 53 | const toTree = (tracks, workTitle) => { 54 | const tree = []; 55 | 56 | // 插入文件夹 57 | tracks.forEach(track => { 58 | let fatherFolder = tree; 59 | const path = track.subtitle ? track.subtitle.split('\\') : []; 60 | path.forEach(folderName => { 61 | const index = fatherFolder.findIndex(item => item.type === 'folder' && item.title === folderName); 62 | if (index === -1) { 63 | fatherFolder.push({ 64 | type: 'folder', 65 | title: folderName, 66 | children: [] 67 | }); 68 | } 69 | fatherFolder = fatherFolder.find(item => item.type === 'folder' && item.title === folderName).children; 70 | }); 71 | }); 72 | 73 | // 插入文件 74 | tracks.forEach(track => { 75 | let fatherFolder = tree; 76 | const path = track.subtitle ? track.subtitle.split('\\') : []; 77 | path.forEach(folderName => { 78 | fatherFolder = fatherFolder.find(item => item.type === 'folder' && item.title === folderName).children; 79 | }); 80 | 81 | fatherFolder.push({ 82 | type: 'file', 83 | hash: track.hash, 84 | title: track.title, 85 | workTitle 86 | }); 87 | }); 88 | 89 | return tree; 90 | }; 91 | 92 | /** 93 | * 返回一个成员为指定根文件夹下所有包含 RJ 号的音声文件夹对象的数组, 94 | * 音声文件夹对象 { relativePath: '相对路径', rootFolderName: '根文件夹别名', id: '音声ID' } 95 | * @param {Object} rootFolder 根文件夹对象 { name: '别名', path: '绝对路径' } 96 | */ 97 | async function* getFolderList(rootFolder, current = '', depth = 0) { // 异步生成器函数 async function*() {} 98 | // 浅层遍历 99 | const folders = await fs.promises.readdir(path.join(rootFolder.path, current)); 100 | 101 | for (const folder of folders) { 102 | const absolutePath = path.resolve(rootFolder.path, current, folder); 103 | const relativePath = path.join(current, folder); 104 | 105 | // eslint-disable-next-line no-await-in-loop 106 | if ((await fs.promises.stat(absolutePath)).isDirectory()) { // 检查是否为文件夹 107 | if (folder.match(/RJ\d{6}/)) { // 检查文件夹名称中是否含有RJ号 108 | // Found a work folder, don't go any deeper. 109 | yield { absolutePath, relativePath, rootFolderName: rootFolder.name, id: parseInt(folder.match(/RJ(\d{6})/)[1]) }; 110 | } else if (depth + 1 < config.scannerMaxRecursionDepth) { 111 | // 若文件夹名称中不含有RJ号,就进入该文件夹内部 112 | // Found a folder that's not a work folder, go inside if allowed. 113 | yield* getFolderList(rootFolder, relativePath, depth + 1); 114 | } 115 | } 116 | } 117 | } 118 | 119 | /** 120 | * Deletes a work's cover image from disk. 121 | * @param {String} rjcode Work RJ code (only the 6 digits, zero-padded). 122 | */ 123 | const deleteCoverImageFromDisk = rjcode => new Promise((resolve, reject) => { 124 | const types = ['main', 'sam']; 125 | types.forEach(type => { 126 | try { 127 | fs.unlinkSync(path.join(config.coverFolderDir, `RJ${rjcode}_img_${type}.jpg`)); 128 | } catch (err) { 129 | reject(err); 130 | } 131 | }); 132 | 133 | resolve(); 134 | }); 135 | 136 | /** 137 | * Saves cover image to disk. 138 | * @param {ReadableStream} stream Image data stream. 139 | * @param {String} rjcode Work RJ code (only the 6 digits, zero-padded). 140 | * @param {String} types img type: ('main', 'sam', 'sam@2x' or 'sam@3x'). 141 | */ 142 | const saveCoverImageToDisk = (stream, rjcode, type) => new Promise((resolve, reject) => { 143 | // TODO: don't assume image is a jpg? 144 | try { 145 | stream.pipe( 146 | fs.createWriteStream(path.join(config.coverFolderDir, `RJ${rjcode}_img_${type}.jpg`)) 147 | .on('close', () => resolve()), 148 | ); 149 | } catch (err) { 150 | reject(err); 151 | } 152 | }); 153 | 154 | 155 | module.exports = { 156 | getTrackList, 157 | toTree, 158 | getFolderList, 159 | deleteCoverImageFromDisk, 160 | saveCoverImageToDisk, 161 | }; 162 | 163 | -------------------------------------------------------------------------------- /routes.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const express = require('express'); 3 | 4 | const db = require('./database/db'); 5 | const { getTrackList, toTree } = require('./filesystem/utils'); 6 | 7 | const { getConfig } = require('./config'); 8 | const config = getConfig(); 9 | 10 | const PAGE_SIZE = config.pageSize || 12; 11 | const router = express.Router(); 12 | 13 | // GET work cover image 14 | router.get('/cover/:id', (req, res, next) => { 15 | const rjcode = (`000000${req.params.id}`).slice(-6); 16 | const type = req.query.type || 'main'; // 'main' or 'sam' 17 | res.sendFile(path.join(config.coverFolderDir, `RJ${rjcode}_img_${type}.jpg`), (err) => { 18 | if (err) { 19 | res.sendFile(path.join(__dirname, './static/no-image.jpg'), (err2) => { 20 | if (err2) { 21 | next(err2); 22 | } 23 | }); 24 | } 25 | }); 26 | }); 27 | 28 | // GET work metadata 29 | router.get('/work/:id', (req, res, next) => { 30 | db.getWorkMetadata(req.params.id) 31 | .then(work => res.send(work)) 32 | .catch(err => next(err)); 33 | }); 34 | 35 | // GET track list in work folder 36 | router.get('/tracks/:id', (req, res, next) => { 37 | db.knex('t_work') 38 | .select('title', 'root_folder', 'dir') 39 | .where('id', '=', req.params.id) 40 | .first() 41 | .then((work) => { 42 | const rootFolder = config.rootFolders.find(rootFolder => rootFolder.name === work.root_folder); 43 | if (rootFolder) { 44 | getTrackList(req.params.id, path.join(rootFolder.path, work.dir)) 45 | .then(tracks => res.send(toTree(tracks, work.title))); 46 | } else { 47 | res.status(500).send({error: `找不到文件夹: "${work.root_folder}",请尝试重启服务器或重新扫描.`}); 48 | } 49 | }) 50 | .catch(err => next(err)); 51 | }); 52 | 53 | // GET (stream) a specific track from work folder 54 | router.get('/stream/:id/:index', (req, res, next) => { 55 | db.knex('t_work') 56 | .select('root_folder', 'dir') 57 | .where('id', '=', req.params.id) 58 | .first() 59 | .then((work) => { 60 | const rootFolder = config.rootFolders.find(rootFolder => rootFolder.name === work.root_folder); 61 | if (rootFolder) { 62 | getTrackList(req.params.id, path.join(rootFolder.path, work.dir)) 63 | .then((tracks) => { 64 | const track = tracks[req.params.index]; 65 | res.sendFile(path.join(rootFolder.path, work.dir, track.subtitle || '', track.title)); 66 | }) 67 | .catch(err => next(err)); 68 | } else { 69 | res.status(500).send({error: `找不到文件夹: "${work.root_folder}",请尝试重启服务器或重新扫描.`}); 70 | } 71 | }); 72 | }); 73 | 74 | // GET list of work ids 75 | router.get('/works', async (req, res, next) => { 76 | const currentPage = parseInt(req.query.page) || 1; 77 | // 通过 "音声id, 贩卖日, 售出数, 评论数量, 价格, 平均评价" 排序 78 | // ['id', 'release', 'dl_count', 'review_count', 'price', 'rate_average_2dp'] 79 | const order = req.query.order || 'release'; 80 | const sort = req.query.sort || 'desc'; 81 | const offset = (currentPage - 1) * PAGE_SIZE; 82 | 83 | try { 84 | const query = () => db.getWorksBy(); 85 | const totalCount = await query().count('id as count'); 86 | const works = await query().offset(offset).limit(PAGE_SIZE).orderBy(order, sort); 87 | 88 | res.send({ 89 | works, 90 | pagination: { 91 | currentPage, 92 | pageSize: PAGE_SIZE, 93 | totalCount: totalCount[0]['count'] 94 | } 95 | }); 96 | } catch(err) { 97 | next(err); 98 | } 99 | }); 100 | 101 | // GET name of a circle/tag/VA 102 | router.get('/get-name/:field/:id', (req, res, next) => { 103 | if (req.params.field === 'undefined') { 104 | return res.send(null); 105 | } 106 | 107 | return db.knex(`t_${req.params.field}`) 108 | .select('name') 109 | .where('id', '=', req.params.id) 110 | .first() 111 | .then(name => res.send(name.name)) 112 | .catch(err => next(err)); 113 | }); 114 | 115 | router.get('/search/:keyword?', async (req, res, next) => { 116 | const keyword = req.params.keyword ? req.params.keyword.trim() : ''; 117 | const currentPage = parseInt(req.query.page) || 1; 118 | // 通过 "音声id, 贩卖日, 售出数, 评论数量, 价格, 平均评价" 排序 119 | // ['id', 'release', 'dl_count', 'review_count', 'price', 'rate_average_2dp'] 120 | const order = req.query.order || 'release'; 121 | const sort = req.query.sort || 'desc'; 122 | const offset = (currentPage - 1) * PAGE_SIZE; 123 | 124 | try { 125 | const query = () => db.getWorksByKeyWord(keyword); 126 | const totalCount = await query().count('id as count'); 127 | const works = await query().offset(offset).limit(PAGE_SIZE).orderBy(order, sort); 128 | 129 | res.send({ 130 | works, 131 | pagination: { 132 | currentPage, 133 | pageSize: PAGE_SIZE, 134 | totalCount: totalCount[0]['count'] 135 | } 136 | }); 137 | } catch(err) { 138 | next(err); 139 | } 140 | }); 141 | 142 | // GET list of work ids, restricted by circle/tag/VA 143 | router.get('/:field/:id', async (req, res, next) => { 144 | const currentPage = parseInt(req.query.page) || 1; 145 | // 通过 "音声id, 贩卖日, 售出数, 评论数量, 价格, 平均评价" 排序 146 | // ['id', 'release', 'dl_count', 'review_count', 'price', 'rate_average_2dp'] 147 | const order = req.query.order || 'release'; 148 | const sort = req.query.sort || 'desc'; // ['desc', 'asc] 149 | const offset = (currentPage - 1) * PAGE_SIZE; 150 | 151 | try { 152 | const query = () => db.getWorksBy(req.params.id, req.params.field); 153 | const totalCount = await query().count('id as count'); 154 | const works = await query().offset(offset).limit(PAGE_SIZE).orderBy(order, sort); 155 | 156 | res.send({ 157 | works, 158 | pagination: { 159 | currentPage, 160 | pageSize: PAGE_SIZE, 161 | totalCount: totalCount[0]['count'] 162 | } 163 | }); 164 | } catch(err) { 165 | next(err); 166 | } 167 | }); 168 | 169 | // GET list of circles/tags/VAs 170 | router.get('/(:field)s/', (req, res, next) => { 171 | const field = req.params.field; 172 | db.getLabels(field) 173 | .orderBy(`name`, 'asc') 174 | .then(list => res.send(list)) 175 | .catch(err => next(err)); 176 | }); 177 | 178 | 179 | module.exports = router; 180 | -------------------------------------------------------------------------------- /database/schema.js: -------------------------------------------------------------------------------- 1 | const knex = require('./connect'); 2 | 3 | // 数据库结构 4 | const createSchema = () => knex.schema 5 | .createTable('t_circle', (table) => { 6 | table.increments(); // id自增列(INTEGER 类型),会被用作主键 [社团id] 7 | table.string('name').notNullable(); // VARCHAR 类型 [社团名称] 8 | }) 9 | .createTable('t_series', (table) => { 10 | table.increments(); // id自增列(INTEGER 类型),会被用作主键 [系列id] 11 | table.string('name').notNullable(); // VARCHAR 类型 [系列名称] 12 | table.integer('circle_id').notNullable(); // INTEGER 类型 [社团id] 13 | table.foreign('circle_id').references('id').inTable('t_circle'); // FOREIGN KEY 外键 14 | }) 15 | .createTable('t_work', (table) => { 16 | table.increments(); // id自增列(INTEGER 类型),会被用作主键 [音声id] 17 | table.string('root_folder').notNullable(); // VARCHAR 类型 [根文件夹别名] 18 | table.string('dir').notNullable(); // VARCHAR 类型 [相对存储路径] 19 | table.text('tracks').notNullable(); // TEXT 类型 [音频文件存储路径] 20 | table.string('title').notNullable(); // VARCHAR 类型 [音声名称] 21 | table.integer('circle_id').notNullable(); // INTEGER 类型 [社团id] 22 | table.integer('series_id'); // INTEGER 类型 [系列id] 23 | table.string('age_ratings').notNullable(); // VARCHAR 类型 [年龄指定] 24 | table.string('release').notNullable(); // VARCHAR 类型 [贩卖日 (YYYY-MM-DD)] 25 | 26 | table.integer('dl_count').notNullable(); // INTEGER 类型 [售出数] 27 | table.integer('price').notNullable(); // INTEGER 类型 [价格] 28 | table.integer('review_count').notNullable(); // INTEGER 类型 [评论数量] 29 | table.integer('rate_count').notNullable(); // INTEGER 类型 [评价数量] 30 | table.integer('rate_average').notNullable(); // INTEGER 类型 [平均评价] 31 | table.float('rate_average_2dp').notNullable(); // FLOAT 类型 [平均评价] 32 | table.text('rate_count_detail').notNullable(); // TEXT 类型 [评价分布明细] 33 | table.text('rank'); // TEXT 类型 [历史销售成绩] 34 | 35 | table.foreign('circle_id').references('id').inTable('t_circle'); // FOREIGN KEY 外键 36 | table.foreign('series_id').references('id').inTable('t_series'); // FOREIGN KEY 外键 37 | table.index(['circle_id', 'series_id', 'release', 'dl_count', 'review_count', 'price', 'rate_average_2dp']); // INDEX 索引 38 | }) 39 | .createTable('t_va', (table) => { 40 | table.increments(); // id自增列(INTEGER 类型),会被用作主键 [声优id] 41 | table.string('name').notNullable(); // VARCHAR 类型 [声优名称] 42 | }) 43 | .createTable('t_va_t_work_relation', (table) => { 44 | table.integer('va_id').notNullable(); 45 | table.integer('work_id').notNullable(); 46 | table.foreign('va_id').references('id').inTable('t_va'); // FOREIGN KEY 外键 47 | table.foreign('work_id').references('id').inTable('t_work'); // FOREIGN KEY 外键 48 | table.primary(['va_id', 'work_id']); // PRIMARY KEYprimary 主键 49 | }) 50 | .createTable('t_dlsite_tag', (table) => { 51 | table.increments(); // id自增列(INTEGER 类型),会被用作主键 [标签id] 52 | table.string('name').notNullable(); // VARCHAR 类型 [标签名称] 53 | table.string('category').notNullable(); // VARCHAR 类型 [标签类别] 54 | }) 55 | .createTable('t_dlsite_tag_t_work_relation', (table) => { 56 | table.integer('tag_id').notNullable(); 57 | table.integer('work_id').notNullable(); 58 | table.foreign('tag_id').references('id').inTable('t_dlsite_tag'); // FOREIGN KEY 外键 59 | table.foreign('work_id').references('id').inTable('t_work'); // FOREIGN KEY 外键 60 | table.primary(['tag_id', 'work_id']); // PRIMARY KEYprimary 主键 61 | }) 62 | .createTable('t_user', (table) => { 63 | table.string('name').notNullable(); // VARCHAR 类型 [用户名] 64 | table.string('password').notNullable(); // VARCHAR 类型 [密码(经MD5加密)] 65 | table.string('group').notNullable(); // VARCHAR 类型 [用户组] 66 | table.primary(['name']); // PRIMARY KEYprimary 主键 67 | }) 68 | .createTable('t_user_tag', (table) => { // 存储用户标签 69 | table.increments(); // id自增列(INTEGER 类型),会被用作主键 [标签id] 70 | table.string('name').notNullable(); // VARCHAR 类型 [标签名称] 71 | table.string('created_by').notNullable(); // VARCHAR 类型 [创建标签的用户] 72 | table.foreign('created_by').references('name').inTable('t_user'); // FOREIGN KEY 外键 73 | }) 74 | .createTable('t_user_t_dlsite_tag_t_work_relation', (table) => { 75 | table.string('user_name').notNullable(); 76 | table.integer('tag_id').notNullable(); 77 | table.integer('work_id').notNullable(); 78 | table.foreign('tag_id').references('id').inTable('t_dlsite_tag'); // FOREIGN KEY 外键 79 | table.foreign('work_id').references('id').inTable('t_work'); // FOREIGN KEY 外键 80 | table.primary(['user_name', 'tag_id', 'work_id']); // PRIMARY KEYprimary 主键 81 | }) 82 | .createTable('t_user_t_user_tag_t_work_relation', (table) => { 83 | table.string('user_name').notNullable(); 84 | table.integer('tag_id').notNullable(); 85 | table.integer('work_id').notNullable(); 86 | table.foreign('tag_id').references('id').inTable('t_user_tag'); // FOREIGN KEY 外键 87 | table.foreign('work_id').references('id').inTable('t_work'); // FOREIGN KEY 外键 88 | table.primary(['user_name', 'tag_id', 'work_id']); // PRIMARY KEYprimary 主键 89 | }) 90 | .createTable('t_mylist', (table) => { 91 | table.increments(); // id自增列(INTEGER 类型),会被用作主键 [收藏列表id] 92 | table.string('user_name').notNullable(); 93 | table.string('name').notNullable(); // VARCHAR 类型 [收藏列表名称] 94 | table.text('works').notNullable(); // TEXT 类型 [收藏列表] 95 | table.foreign('user_name').references('name').inTable('t_user'); // FOREIGN KEY 外键 96 | }) 97 | .createTable('t_mylist_t_work_relation', (table) => { 98 | table.integer('mylist_id').notNullable(); 99 | table.integer('work_id').notNullable(); 100 | table.foreign('mylist_id').references('id').inTable('t_mylist'); // FOREIGN KEY 外键 101 | table.foreign('work_id').references('id').inTable('t_work'); // FOREIGN KEY 外键 102 | table.primary(['mylist_id', 'work_id']); // PRIMARY KEYprimary 主键 103 | }) 104 | .createTable('t_playlist', (table) => { 105 | table.increments(); // id自增列(INTEGER 类型),会被用作主键 [播放列表id] 106 | table.string('user_name').notNullable(); 107 | table.string('name').notNullable(); // VARCHAR 类型 [播放列表名称] 108 | table.text('tracks').notNullable(); // TEXT 类型 [播放列表] 109 | table.foreign('user_name').references('name').inTable('t_user'); // FOREIGN KEY 外键 110 | }) 111 | .createTable('t_playlist_t_work_relation', (table) => { 112 | table.integer('playlist_id').notNullable(); 113 | table.integer('work_id').notNullable(); 114 | table.foreign('playlist_id').references('id').inTable('t_playlist'); // FOREIGN KEY 外键 115 | table.foreign('work_id').references('id').inTable('t_work'); // FOREIGN KEY 外键 116 | table.primary(['playlist_id', 'work_id']); // PRIMARY KEYprimary 主键 117 | }) 118 | .createTable('t_user_t_work_relation', (table) => { 119 | table.string('user_name').notNullable(); 120 | table.integer('work_id').notNullable(); 121 | table.string('collect_type').notNullable(); // wish(想听) collect(听过) do(在听) dropped(抛弃) 122 | table.foreign('user_name').references('name').inTable('t_user'); // FOREIGN KEY 外键 123 | table.foreign('work_id').references('id').inTable('t_work'); // FOREIGN KEY 外键 124 | table.primary(['user_name', 'work_id']); // PRIMARY KEYprimary 主键 125 | }) 126 | .then(() => { 127 | console.log(' * 成功构建数据库结构.'); 128 | }) 129 | .catch((err) => { 130 | if (err.toString().indexOf('table `t_circle` already exists') !== -1) { 131 | console.log(' * 数据库结构已经存在.'); 132 | } else { 133 | throw err; 134 | } 135 | }); 136 | 137 | module.exports = { createSchema }; 138 | -------------------------------------------------------------------------------- /auth/routes.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const { check, validationResult } = require('express-validator'); // 后端校验 3 | const expressJwt = require('express-jwt'); // 把 JWT 的 payload 部分赋值于 req.user 4 | 5 | const { signtoken, md5 } = require('./utils'); 6 | const db = require('../database/db'); 7 | 8 | const { getConfig, setConfig } = require('../config'); 9 | const config = getConfig(); 10 | 11 | const router = express.Router(); 12 | 13 | // 用户登录 14 | router.post('/me', [ 15 | check('name') 16 | .isLength({ min: 5 }) 17 | .withMessage('用户名长度至少为 5'), 18 | check('password') 19 | .isLength({ min: 5 }) 20 | .withMessage('密码长度至少为 5') 21 | ], (req, res, next) => { 22 | // Finds the validation errors in this request and wraps them in an object with handy functions 23 | const errors = validationResult(req); 24 | if (!errors.isEmpty()) { 25 | return res.status(422).send({ errors: errors.array() }); 26 | } 27 | 28 | const name = req.body.name; 29 | const password = req.body.password; 30 | 31 | db.knex('t_user') 32 | .where('name', '=', name) 33 | .andWhere('password', '=', md5(password)) 34 | .first() 35 | .then((user) => { 36 | if (!user) { 37 | res.status(401).send({error: '用户名或密码错误.'}); 38 | } else { 39 | const token = signtoken(user); 40 | res.send({ token }); 41 | } 42 | }) 43 | .catch((err) => { 44 | next(err); 45 | }); 46 | }); 47 | 48 | if (config.auth) { 49 | router.get('/me', expressJwt({ secret: config.jwtsecret })); 50 | } 51 | 52 | // 获取用户信息 53 | router.get('/me', (req, res, next) => { 54 | // 同时告诉客户端,服务器是否启用用户验证 55 | const auth = config.auth; 56 | const user = config.auth 57 | ? { name: req.user.name, group: req.user.group } 58 | : { name: 'admin', group: 'administrator' } 59 | res.send({ user, auth }); 60 | }); 61 | 62 | // 创建一个新用户 (只有 admin 账号拥有权限) 63 | router.post('/user', [ 64 | check('name') 65 | .isLength({ min: 5 }) 66 | .withMessage('用户名长度至少为 5'), 67 | check('password') 68 | .isLength({ min: 5 }) 69 | .withMessage('密码长度至少为 5'), 70 | check('group') 71 | .custom(value => { 72 | if (value !== 'user' && value !== 'gaust') { 73 | throw new Error(`用户组名称必须为 ['user', 'gaust'] 的一个.`) 74 | } 75 | return true 76 | }) 77 | ], (req, res, next) => { 78 | // Finds the validation errors in this request and wraps them in an object with handy functions 79 | const errors = validationResult(req); 80 | if (!errors.isEmpty()) { 81 | return res.status(422).send({ errors: errors.array() }); 82 | } 83 | 84 | const user = { 85 | name: req.body.name, 86 | password: req.body.password, 87 | group: req.body.group 88 | }; 89 | 90 | if (!config.auth || req.user.name === 'admin') { 91 | db.createUser({ 92 | name: user.name, 93 | password: md5(user.password), 94 | group: user.group 95 | }) 96 | .then(() => res.send({ message: `用户 ${user.name} 创建成功.` })) 97 | .catch((err) => { 98 | if (err.message.indexOf('已存在') !== -1) { 99 | res.status(403).send({ error: err.message }); 100 | } else { 101 | next(err); 102 | } 103 | }); 104 | } else { 105 | res.status(401).send({ error: '只有 admin 账号能创建新用户.' }); 106 | } 107 | }); 108 | 109 | // 更新用户密码 110 | router.put('/user', [ 111 | check('name') 112 | .isLength({ min: 5 }) 113 | .withMessage('用户名长度至少为 5'), 114 | check('newPassword') 115 | .isLength({ min: 5 }) 116 | .withMessage('密码长度至少为 5') 117 | ], (req, res, next) => { 118 | // Finds the validation errors in this request and wraps them in an object with handy functions 119 | const errors = validationResult(req); 120 | if (!errors.isEmpty()) { 121 | return res.status(422).json({errors: errors.array()}); 122 | } 123 | 124 | const user = { 125 | name: req.body.name 126 | }; 127 | const newPassword = md5(req.body.newPassword); 128 | 129 | if (!config.auth || req.user.name === 'admin' || req.user.name === user.name) { 130 | db.updateUserPassword(user, newPassword) 131 | .then(() => res.send({ message: '密码修改成功.' })) 132 | .catch((err) => { 133 | if (err.message.indexOf('用户名错误.') !== -1) { 134 | res.status(401).send({ error: '用户名错误.' }); 135 | } else { 136 | next(err); 137 | } 138 | }); 139 | } else { 140 | res.status(403).send({ error: '只能修改自己账号的密码.' }); 141 | } 142 | }); 143 | 144 | // 删除用户 (仅 admin 账号拥有权限) 145 | router.delete('/user', (req, res, next) => { 146 | const users = req.body.users 147 | 148 | if (!config.auth || req.user.name === 'admin') { 149 | if (!users.find(user => user.name === 'admin')) { 150 | db.deleteUser(users) 151 | .then(() => { 152 | res.send({ message: '删除成功.' }); 153 | }) 154 | .catch((err) => { 155 | next(err); 156 | }); 157 | } else { 158 | res.status(403).send({ error: '不能删除内置的管理员账号.' }); 159 | } 160 | } else { 161 | res.status(401).send({ error: '只有 admin 账号能删除用户.' }); 162 | } 163 | }); 164 | 165 | // 获取所有用户 166 | router.get('/users', (req, res, next) => { 167 | db.knex('t_user') 168 | .select('name', 'group') 169 | .then((users) => { 170 | res.send({ users }); 171 | }) 172 | .catch((err) => { 173 | next(err); 174 | }); 175 | }); 176 | 177 | // 修改配置文件 178 | router.put('/config', (req, res, next) => { 179 | if (!config.auth || req.user.name === 'admin') { 180 | try { 181 | setConfig(req.body.config); 182 | res.send({ message: '保存成功.' }) 183 | } catch(err) { 184 | next(err); 185 | } 186 | } else { 187 | res.status(401).send({ error: '只有 admin 账号能修改配置文件.' }); 188 | } 189 | }); 190 | 191 | // 获取配置文件 192 | router.get('/config', (req, res, next) => { 193 | if (!config.auth || req.user.name === 'admin') { 194 | try { 195 | res.send({ config: getConfig() }); 196 | } catch(err) { 197 | next(err); 198 | } 199 | } else { 200 | res.status(401).send({ error: '只有 admin 账号能读取配置文件.' }); 201 | } 202 | }); 203 | 204 | // 创建一个新用户播放列表 205 | router.post('/playlist', (req, res, next) => { 206 | const username = config.auth ? req.user.name : 'admin'; 207 | db.createUserPlaylist(username, { 208 | username, 209 | name: req.body.playlistName, 210 | tracks: req.body.tracks 211 | }) 212 | .then(() => res.send({ message: '播放列表创建成功.' })) 213 | .catch((err) => { 214 | if (err.message.indexOf('已存在') !== -1) { 215 | res.status(403).send({ error: err.message }); 216 | } else { 217 | next(err); 218 | } 219 | }); 220 | }); 221 | 222 | // 更新用户播放列表 223 | router.put('/playlist', (req, res, next) => { 224 | const username = config.auth ? req.user.name : 'admin'; 225 | db.updateUserPlaylist(username, req.body.oldPlaylistName, { 226 | username, 227 | name: req.body.newPlaylistName, 228 | tracks: req.body.tracks 229 | }) 230 | .then(() => res.send({ message: '播放列表更新成功.' })) 231 | .catch((err) => { 232 | if (err.message.indexOf('不存在') !== -1) { 233 | res.status(403).send({ error: err.message }); 234 | } else { 235 | next(err); 236 | } 237 | }); 238 | }); 239 | 240 | // 删除用户播放列表 241 | router.delete('/playlist', (req, res, next) => { 242 | const username = config.auth ? req.user.name : 'admin'; 243 | db.deleteUserPlaylists(username, req.body.playlistNames) 244 | .then(() => res.send({ message: '播放列表删除成功.' })) 245 | .catch((err) => next(err)); 246 | }); 247 | 248 | // 查询用户所有的播放列表 249 | router.get('/playlist', (req, res, next) => { 250 | const username = config.auth ? req.user.name : 'admin'; 251 | db.getUserPlaylists(username) 252 | .then((playlists) => res.send({ playlists })) 253 | .catch((err) => next(err)); 254 | }); 255 | 256 | // 创建一个新用户收藏列表 257 | router.post('/mylist', (req, res, next) => { 258 | const username = config.auth ? req.user.name : 'admin'; 259 | db.createUserMylist(username, { 260 | username, 261 | name: req.body.mylistName, 262 | works: req.body.works 263 | }) 264 | .then(() => res.send({ message: '收藏列表创建成功.' })) 265 | .catch((err) => { 266 | if (err.message.indexOf('已存在') !== -1) { 267 | res.status(403).send({ error: err.message }); 268 | } else { 269 | next(err); 270 | } 271 | }); 272 | }); 273 | 274 | // 更新用户收藏列表 275 | router.put('/mylist', (req, res, next) => { 276 | const username = config.auth ? req.user.name : 'admin'; 277 | db.updateUserMylist(username, { 278 | id: req.body.id, 279 | name: req.body.name, 280 | works: req.body.works 281 | }) 282 | .then(() => res.send({ message: '收藏列表更新成功.' })) 283 | .catch((err) => { 284 | if (err.message.indexOf('不存在') !== -1) { 285 | res.status(403).send({ error: err.message }); 286 | } else { 287 | next(err); 288 | } 289 | }); 290 | }); 291 | 292 | // 删除用户收藏列表 293 | router.delete('/mylist', (req, res, next) => { 294 | const username = config.auth ? req.user.name : 'admin'; 295 | db.deleteUserMylists(username, req.body.mylistIds) 296 | .then(() => res.send({ message: '收藏列表删除成功.' })) 297 | .catch((err) => next(err)); 298 | }); 299 | 300 | // 查询用户所有的收藏列表 301 | router.get('/mylist', (req, res, next) => { 302 | const username = config.auth ? req.user.name : 'admin'; 303 | db.getUserMylists(username) 304 | .then((mylists) => { 305 | res.send({ mylists }) 306 | }) 307 | .catch((err) => next(err)); 308 | }); 309 | 310 | module.exports = router; 311 | -------------------------------------------------------------------------------- /scraper/dlsite.js: -------------------------------------------------------------------------------- 1 | const cheerio = require('cheerio'); // 解析器 2 | const axios = require('./axios'); // 数据请求 3 | const { hashNameIntoInt, hasLetter } = require('./utils'); 4 | const scrapeWorkMetadataFromHVDB = require('./hvdb'); 5 | 6 | /** 7 | * Scrapes static work metadata from public DLsite page HTML. 8 | * @param {number} id Work id. 9 | * @param {String} language 标签语言,'ja-jp', 'zh-tw' or 'zh-cn',默认'zh-cn' 10 | */ 11 | const scrapeWorkStaticMetadata = async (id, language) => { 12 | const rjcode = (`000000${id}`).slice(-6); 13 | const url = `https://www.dlsite.com/maniax/work/=/product_id/RJ${rjcode}.html`; 14 | 15 | let COOKIE_LOCALE, AGE_RATINGS, GENRE, VA, RELEASE, SERIES, WORK_FORMAT; 16 | switch (language) { 17 | case 'ja-jp': 18 | COOKIE_LOCALE = 'locale=ja-jp' 19 | AGE_RATINGS = '年齢指定'; 20 | GENRE = 'ジャンル'; 21 | VA = '声優'; 22 | RELEASE = '販売日'; 23 | SERIES = 'シリーズ名'; 24 | WORK_FORMAT = '作品形式'; 25 | break; 26 | case 'zh-tw': 27 | COOKIE_LOCALE = 'locale=zh-tw' 28 | AGE_RATINGS = '年齡指定'; 29 | GENRE = '分類'; 30 | VA = '聲優'; 31 | RELEASE = '販賣日'; 32 | SERIES = '系列名'; 33 | WORK_FORMAT = '作品形式'; 34 | break; 35 | default: 36 | COOKIE_LOCALE = 'locale=zh-cn' 37 | AGE_RATINGS = '年龄指定'; 38 | GENRE = '分类'; 39 | VA = '声优'; 40 | RELEASE = '贩卖日'; 41 | SERIES = '系列名'; 42 | WORK_FORMAT = '作品类型'; 43 | } 44 | 45 | // 请求网页 46 | let res = null; 47 | try { 48 | res = await axios.retryGet(url, { 49 | retry: {}, 50 | headers: { "cookie": COOKIE_LOCALE } // 自定义请求头 51 | }); 52 | } catch (err) { 53 | if (err.response) { 54 | // 请求已发出,但服务器响应的状态码不在 2xx 范围内 55 | throw new Error(`Couldn't request work page HTML (${url}), received: ${err.response.status}.`); 56 | } else { 57 | throw err; 58 | } 59 | } 60 | 61 | // 解析 html 62 | const work = { 63 | id, 64 | title: null, 65 | circle: null, 66 | age_ratings: null, 67 | release: null, 68 | series: null, 69 | tags: [], 70 | vas: [] 71 | }; 72 | try { 73 | // 转换成 jQuery 对象 74 | const $ = cheerio.load(res.data); 75 | 76 | // 作品类型 77 | const workFormatElement = $('#work_outline').children('tbody').children('tr').children('th') 78 | .filter(function() { 79 | return $(this).text() === WORK_FORMAT; 80 | }).parent().children('td'); 81 | 82 | const workFormatText = workFormatElement.text(); 83 | if (workFormatText) { 84 | switch (language) { 85 | case 'ja-jp': 86 | if (workFormatText.indexOf('ボイス・ASMR') === -1) { 87 | throw new Error(`[RJ${rjcode}] 不是音声类型的作品.`); 88 | } 89 | break; 90 | case 'zh-tw': 91 | if (workFormatText.indexOf('聲音作品・ASMR') === -1) { 92 | throw new Error(`[RJ${rjcode}] 不是音声类型的作品.`); 93 | } 94 | break; 95 | default: 96 | if (workFormatText.indexOf('音声・ASMR') === -1) { 97 | throw new Error(`[RJ${rjcode}] 不是音声类型的作品.`); 98 | } 99 | } 100 | } else { 101 | throw new Error('解析[作品类型]失败.'); 102 | } 103 | 104 | // // 作品类型 105 | // const workFormatElements = $('#work_outline').children('tbody').children('tr').children('th') 106 | // .filter(function() { 107 | // return $(this).text() === WORK_FORMAT; 108 | // }).parent().children('td').children('div').children('a'); 109 | // if (!workFormatElements.length) { 110 | // throw new Error('解析[作品类型]失败.'); 111 | // } 112 | // const SOUElement = workFormatElements.filter(function() { 113 | // const tagUrl = $(this).attr('href'); 114 | // return tagUrl && tagUrl.indexOf('SOU') !== -1; 115 | // }); 116 | // if (SOUElement.length === 0) { 117 | // throw new Error(`[RJ${rjcode}] 不是音声类型的作品.`); 118 | // } 119 | 120 | // 标题 121 | const titleElement = $(`a[href="${url}"]`); 122 | const titleText = titleElement.text(); 123 | if (titleText) { 124 | work.title = titleText; 125 | } else { 126 | throw new Error('解析[标题]失败.'); 127 | } 128 | 129 | // 社团 130 | const circleElement = $('span[class="maker_name"]').children('a'); 131 | const circleUrl = circleElement.attr('href'); 132 | const circleName = circleElement.text(); 133 | const circleId = circleUrl && circleUrl.match(/RG(\d{5})/) && parseInt(circleUrl.match(/RG(\d{5})/)[1]); 134 | if (circleId && circleName) { 135 | work.circle = { 136 | id: circleId, 137 | name: circleName 138 | }; 139 | } else { 140 | throw new Error('解析[社团]失败.'); 141 | } 142 | 143 | // 年龄指定 144 | const ageRatingsElement = $('#work_outline').children('tbody').children('tr').children('th') 145 | .filter(function() { 146 | return $(this).text() === AGE_RATINGS; 147 | }).parent().children('td'); 148 | const ageRatingsText = ageRatingsElement.text(); 149 | switch (language) { 150 | case 'ja-jp': 151 | if (ageRatingsText.indexOf('全年齢') !== -1) { 152 | work.age_ratings = 'G' 153 | } else if (ageRatingsText.indexOf('R-15') !== -1) { 154 | work.age_ratings = 'R15' 155 | } else if (ageRatingsText.indexOf('18禁') !== -1) { 156 | work.age_ratings = 'R18' 157 | } 158 | break; 159 | case 'zh-tw': 160 | if (ageRatingsText.indexOf('全年齢') !== -1) { 161 | work.age_ratings = 'G' 162 | } else if (ageRatingsText.indexOf('R-15') !== -1) { 163 | work.age_ratings = 'R15' 164 | } else if (ageRatingsText.indexOf('18禁') !== -1) { 165 | work.age_ratings = 'R18' 166 | } 167 | break; 168 | default: 169 | if (ageRatingsText.indexOf('全年龄') !== -1) { 170 | work.age_ratings = 'G' 171 | } else if (ageRatingsText.indexOf('R-15') !== -1) { 172 | work.age_ratings = 'R15' 173 | } else if (ageRatingsText.indexOf('18禁') !== -1) { 174 | work.age_ratings = 'R18' 175 | } 176 | } 177 | if (!work.age_ratings) { 178 | throw new Error('解析[年龄指定]失败.'); 179 | } 180 | 181 | // 贩卖日 (YYYY-MM-DD) 182 | const releaseElement = $('#work_outline').children('tbody').children('tr').children('th') 183 | .filter(function() { 184 | return $(this).text() === RELEASE; 185 | }).parent().children('td'); 186 | const releaseText = releaseElement.text(); 187 | const release = releaseText.replace(/[^0-9]/ig, ''); 188 | if (release.length >= 8) { 189 | work.release = `${release.slice(0, 4)}-${release.slice(4, 6)}-${release.slice(6, 8)}`; 190 | } else { 191 | throw new Error('解析[贩卖日]失败.'); 192 | } 193 | 194 | // 系列 195 | const seriesElement = $('#work_outline').children('tbody').children('tr').children('th') 196 | .filter(function() { 197 | return $(this).text() === SERIES; 198 | }).parent().children('td').children('a'); 199 | const seriesUrl = seriesElement.attr('href'); 200 | const seriesName = seriesElement.text(); 201 | const seriesId = seriesUrl && seriesUrl.match(/SRI(\d{10})/) && parseInt(seriesUrl.match(/SRI(\d{10})/)[1]); 202 | if (seriesId && seriesName) { 203 | work.series = { 204 | id: seriesId, 205 | name: seriesName 206 | }; 207 | } 208 | 209 | // 标签 210 | const tagElements = $('#work_outline').children('tbody').children('tr').children('th') 211 | .filter(function() { 212 | return $(this).text() === GENRE; 213 | }).parent().children('td').children('div').children('a'); 214 | tagElements.each(function() { 215 | const tagUrl = $(this).attr('href'); 216 | const tagId = tagUrl && tagUrl.match(/genre\/(\d{3})/) && parseInt(tagUrl.match(/genre\/(\d{3})/)[1]); 217 | const tagName = $(this).text() 218 | if (tagId && tagName) { 219 | work.tags.push({ 220 | id: tagId, 221 | name: tagName 222 | }); 223 | } 224 | }); 225 | 226 | // 声优 227 | const cvElements = $('#work_outline').children('tbody').children('tr').children('th') 228 | .filter(function() { 229 | return $(this).text() === VA; 230 | }).parent().children('td').children('a'); 231 | cvElements.each(function() { 232 | const vaName = $(this).text(); 233 | if (vaName) { 234 | work.vas.push({ 235 | id: hashNameIntoInt(vaName), 236 | name: vaName 237 | }); 238 | } 239 | }); 240 | } catch (err) { 241 | throw new Error(`在解析 html 过程中出错: ${err.message}`); 242 | } 243 | 244 | if (work.vas.length === 0) { 245 | // 当在 DLsite 抓不到声优信息时, 从 HVDB 抓取声优信息 246 | const metadata = await scrapeWorkMetadataFromHVDB(id); 247 | if (metadata.vas.length <= 1) { 248 | // N/A 249 | work.vas = metadata.vas; 250 | } else { 251 | // 过滤掉英文的声优名 252 | metadata.vas.forEach(function(va) { 253 | if (!hasLetter(va.name)) { 254 | work.vas.push(va); 255 | } 256 | }); 257 | } 258 | } 259 | 260 | return work; 261 | }; 262 | 263 | /** 264 | * Requests dynamic work metadata from public DLsite API. 265 | * @param {number} id Work id. 266 | */ 267 | const scrapeWorkDynamicMetadata = async id => { 268 | const rjcode = (`000000${id}`).slice(-6); 269 | const url = `https://www.dlsite.com/maniax-touch/product/info/ajax?product_id=RJ${rjcode}`; 270 | 271 | let res = null; 272 | try { 273 | res = await axios.retryGet(url, { retry: {} }); 274 | } catch (err) { 275 | if (err.response) { 276 | // 请求已发出,但服务器响应的状态码不在 2xx 范围内 277 | throw new Error(`Couldn't request work page HTML (${url}), received: ${err.response.status}.`); 278 | } else { 279 | throw err; 280 | } 281 | } 282 | 283 | const data = res.data[`RJ${rjcode}`]; 284 | const work = { 285 | price: data.price, // 价格 286 | dl_count: data.dl_count, // 售出数 287 | rate_average: data.rate_average, // 平均评价 288 | rate_average_2dp: data.rate_average_2dp, // 平均评价 289 | rate_count: data.rate_count, // 评价数量 290 | rate_count_detail: data.rate_count_detail, // 评价分布明细 291 | review_count: data.review_count, // 评论数量 292 | rank: data.rank // 历史销售成绩 293 | }; 294 | 295 | return work; 296 | }; 297 | 298 | /** 299 | * Scrapes work metadata from public DLsite page HTML. 300 | * @param {number} id Work id. 301 | * @param {String} language 标签语言,'ja-jp', 'zh-tw' or 'zh-cn',默认'zh-cn' 302 | */ 303 | const scrapeWorkMetadata = (id, language) => { 304 | return Promise.all([ 305 | scrapeWorkStaticMetadata(id, language), 306 | scrapeWorkDynamicMetadata(id) 307 | ]) 308 | .then(res => Object.assign(res[0], res[1])) 309 | }; 310 | 311 | /** 312 | * 爬取 dlsite 的全部标签 313 | * @param {String} language 标签语言,'ja-jp', 'zh-tw' or 'zh-cn',默认'zh-cn' 314 | */ 315 | const scrapeAllTags = async language => { 316 | const url = 'https://www.dlsite.com/maniax/fs'; 317 | 318 | let COOKIE_LOCALE; 319 | switch (language) { 320 | case 'ja-jp': 321 | COOKIE_LOCALE = 'locale=ja-jp' 322 | break; 323 | case 'zh-tw': 324 | COOKIE_LOCALE = 'locale=zh-tw' 325 | break; 326 | default: 327 | COOKIE_LOCALE = 'locale=zh-cn' 328 | } 329 | 330 | // 请求网页 331 | let res = null; 332 | try { 333 | res = await axios.retryGet(url, { 334 | retry: {}, 335 | headers: { "cookie": COOKIE_LOCALE } // 自定义请求头 336 | }); 337 | } catch (err) { 338 | if (err.response) { 339 | // 请求已发出,但服务器响应的状态码不在 2xx 范围内 340 | throw new Error(`Couldn't request work page HTML (${url}), received: ${err.response.status}.`); 341 | } else { 342 | throw err; 343 | } 344 | } 345 | 346 | // 解析 html 347 | const tags = {}; 348 | try { 349 | const $ = cheerio.load(res.data); 350 | 351 | $('#fs_search').children('fieldset').eq(2) 352 | .children('table').children('tbody').children('tr').eq(1) 353 | .children('td').children('div') 354 | .children('div[class="frame_double_list_box list_triple_row"]') 355 | .each(function() { 356 | const tagCategory = $(this).children('div').children('dl').children('dt') 357 | .children('p').children('span').text(); 358 | 359 | $(this).children('div').children('dl').children('dd') 360 | .each(function() { 361 | const tagElement = $(this).children('label'); 362 | const tagId = tagElement.attr('for') && tagElement.attr('for').match(/genre_(\d{3})/) && parseInt(tagElement.attr('for').match(/genre_(\d{3})/)[1]); 363 | const tagName = tagElement.text(); 364 | if (tagId && tagName && tagCategory) { 365 | tags[tagId] = { 366 | id: tagId, 367 | name: tagName, 368 | category: tagCategory 369 | }; 370 | } 371 | }); 372 | }); 373 | } catch (err) { 374 | throw new Error(`在解析 html 过程中出错: ${err.message}`); 375 | } 376 | 377 | return tags; 378 | }; 379 | 380 | 381 | module.exports = { 382 | scrapeAllTags, 383 | scrapeWorkMetadata, 384 | scrapeWorkDynamicMetadata 385 | }; 386 | -------------------------------------------------------------------------------- /database/db.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | 4 | const { getConfig } = require('../config'); 5 | const config = getConfig(); 6 | 7 | const databaseFolderDir = config.databaseFolderDir; 8 | if (!fs.existsSync(databaseFolderDir)) { 9 | try { 10 | fs.mkdirSync(databaseFolderDir, { recursive: true }); 11 | } catch(err) { 12 | console.error(` ! 在创建存放数据库文件的文件夹时出错: ${err.message}`); 13 | } 14 | } 15 | 16 | // knex 操作数据库 17 | const knex = require('knex')({ 18 | client: 'sqlite3', // 数据库类型 19 | useNullAsDefault: true, 20 | connection: { // 连接参数 21 | filename: path.join(databaseFolderDir, 'db.sqlite3'), 22 | }, 23 | acquireConnectionTimeout: 5000, // 连接计时器 24 | }); 25 | 26 | /** 27 | * Takes a work metadata object and inserts it into the database. 28 | * @param {Object} work Work object. 29 | */ 30 | // Using trx as a query builder: 31 | const insertWorkMetadata = work => knex.transaction(trx => trx.raw( 32 | trx('t_circle') 33 | .insert({ 34 | id: work.circle.id, 35 | name: work.circle.name, 36 | }).toString().replace('insert', 'insert or ignore'), 37 | ) 38 | .then(() => trx('t_work') 39 | .insert({ 40 | id: work.id, 41 | root_folder: work.rootFolderName, 42 | dir: work.dir, 43 | title: work.title, 44 | circle_id: work.circle.id, 45 | nsfw: work.nsfw, 46 | release: work.release, 47 | 48 | dl_count: work.dl_count, 49 | price: work.price, 50 | review_count: work.review_count, 51 | rate_count: work.rate_count, 52 | rate_average_2dp: work.rate_average_2dp, 53 | rate_count_detail: JSON.stringify(work.rate_count_detail), 54 | rank: work.rank ? JSON.stringify(work.rank) : null 55 | })) 56 | .then(() => { 57 | // Now that work is in the database, insert relationships 58 | const promises = []; 59 | 60 | for (let i = 0; i < work.tags.length; i += 1) { 61 | promises.push(trx.raw( 62 | trx('t_tag') 63 | .insert({ 64 | id: work.tags[i].id, 65 | name: work.tags[i].name, 66 | }).toString().replace('insert', 'insert or ignore'), 67 | ) 68 | .then(() => trx('r_tag_work') 69 | .insert({ 70 | tag_id: work.tags[i].id, 71 | work_id: work.id, 72 | }))); 73 | } 74 | 75 | for (let i = 0; i < work.vas.length; i += 1) { 76 | promises.push(trx.raw( 77 | trx('t_va') 78 | .insert({ 79 | id: work.vas[i].id, 80 | name: work.vas[i].name, 81 | }).toString().replace('insert', 'insert or ignore'), 82 | ) 83 | .then(() => trx('r_va_work') 84 | .insert({ 85 | va_id: work.vas[i].id, 86 | work_id: work.id, 87 | }))); 88 | } 89 | 90 | return Promise.all(promises) 91 | .then(() => trx); 92 | })); 93 | 94 | /** 95 | * 更新音声的动态元数据 96 | * @param {Object} work Work object. 97 | */ 98 | const updateWorkMetadata = work => knex.transaction(trx => trx('t_work') 99 | .where('id', '=', work.id) 100 | .update({ 101 | dl_count: work.dl_count, 102 | price: work.price, 103 | review_count: work.review_count, 104 | rate_count: work.rate_count, 105 | rate_average_2dp: work.rate_average_2dp, 106 | rate_count_detail: JSON.stringify(work.rate_count_detail), 107 | rank: work.rank ? JSON.stringify(work.rank) : null 108 | })); 109 | 110 | 111 | /** 112 | * Fetches metadata for a specific work id. 113 | * @param {Number} id Work identifier. 114 | */ 115 | const getWorkMetadata = id => new Promise((resolve, reject) => { 116 | // TODO: do this all in a single transaction? 117 | knex('t_work') 118 | .select('*') 119 | .where('id', '=', id) 120 | .first() 121 | .then((workRes) => { 122 | if (!workRes) { 123 | throw new Error(`There is no work with id ${id} in the database.`); 124 | } 125 | 126 | knex('t_circle') 127 | .select('name') 128 | .where('t_circle.id', '=', workRes.circle_id) 129 | .first() 130 | .then((circleRes) => { 131 | const work = { 132 | id: workRes.id, 133 | title: workRes.title, 134 | circle: { id: workRes.circle_id, name: circleRes.name }, 135 | nsfw: Boolean(workRes.nsfw), 136 | release: workRes.release, 137 | 138 | dl_count: workRes.dl_count, 139 | price: workRes.price, 140 | review_count: workRes.review_count, 141 | rate_count: workRes.rate_count, 142 | rate_average_2dp: workRes.rate_average_2dp, 143 | rate_count_detail: JSON.parse(workRes.rate_count_detail), 144 | rank: workRes.rank ? JSON.parse(workRes.rank) : null 145 | }; 146 | 147 | knex('r_tag_work') 148 | .select('tag_id', 'name') 149 | .where('r_tag_work.work_id', '=', id) 150 | .join('t_tag', 't_tag.id', '=', 'r_tag_work.tag_id') 151 | .then((tagsRes) => { 152 | work.tags = tagsRes.map(tag => ({ id: tag.tag_id, name: tag.name })); 153 | 154 | knex('r_va_work') 155 | .select('va_id', 'name') 156 | .where('r_va_work.work_id', '=', id) 157 | .join('t_va', 't_va.id', '=', 'r_va_work.va_id') 158 | .then((vaRes) => { 159 | work.vas = vaRes.map(va => ({ id: va.va_id, name: va.name })); 160 | resolve(work); 161 | }); 162 | }); 163 | }); 164 | }) 165 | .catch(err => reject(err)); 166 | }); 167 | 168 | /** 169 | * Tests if the given circle, tags and VAs are orphans and if so, removes them. 170 | * @param {*} trx Knex transaction object. 171 | * @param {*} circle Circle id to check. 172 | * @param {*} tags Array of tag ids to check. 173 | * @param {*} vas Array of VA ids to check. 174 | */ 175 | const cleanupOrphans = (trx, circle, tags, vas) => new Promise(async (resolve, reject) => { 176 | const getCount = (tableName, colName, colValue) => new Promise((resolveCount, rejectCount) => { 177 | trx(tableName) 178 | .select(colName) 179 | .where(colName, '=', colValue) 180 | .count() 181 | .first() 182 | .then(res => res['count(*)']) 183 | .then(count => resolveCount(count)) 184 | .catch(err => rejectCount(err)); 185 | }); 186 | 187 | const promises = []; 188 | promises.push(new Promise((resolveCircle, rejectCircle) => { 189 | getCount('t_work', 'circle_id', circle) 190 | .then((count) => { 191 | if (count === 0) { 192 | trx('t_circle') 193 | .del() 194 | .where('id', '=', circle) 195 | .then(() => resolveCircle()) 196 | .catch(err => rejectCircle(err)); 197 | } else { 198 | resolveCircle(); 199 | } 200 | }); 201 | })); 202 | 203 | for (let i = 0; i < tags.length; i += 1) { 204 | const tag = tags[i]; 205 | const count = await getCount('r_tag_work', 'tag_id', tag); 206 | 207 | if (count === 0) { 208 | promises.push( 209 | trx('t_tag') 210 | .delete() 211 | .where('id', '=', tag), 212 | ); 213 | } 214 | } 215 | 216 | for (let i = 0; i < vas.length; i += 1) { 217 | const va = vas[i]; 218 | const count = await getCount('r_va_work', 'va_id', va); 219 | 220 | if (count === 0) { 221 | promises.push( 222 | trx('t_va') 223 | .delete() 224 | .where('id', '=', va), 225 | ); 226 | } 227 | } 228 | 229 | Promise.all(promises) 230 | .then((results) => { 231 | resolve(results); 232 | }) 233 | .catch(err => reject(err)); 234 | }); 235 | 236 | /** 237 | * Removes a work and then its orphaned circles, tags & VAs from the database. 238 | * @param {Integer} id Work id. 239 | */ 240 | const removeWork = id => new Promise(async (resolve, reject) => { 241 | const trx = await knex.transaction(); 242 | 243 | // Save circle, tags and VAs to array for later testing 244 | const circle = await trx('t_work').select('circle_id').where('id', '=', id).first(); 245 | const tags = await trx('r_tag_work').select('tag_id').where('work_id', '=', id); 246 | const vas = await trx('r_va_work').select('va_id').where('work_id', '=', id); 247 | 248 | // Remove work and its relationships 249 | trx('t_work') 250 | .del() 251 | .where('id', '=', id) 252 | .then(trx('r_tag_work') 253 | .del() 254 | .where('work_id', '=', id) 255 | .then(trx('r_va_work') 256 | .del() 257 | .where('work_id', '=', id) 258 | .then(() => cleanupOrphans( 259 | trx, 260 | circle.circle_id, 261 | tags.map(tag => tag.tag_id), 262 | vas.map(va => va.va_id), 263 | )) 264 | .then(trx.commit) 265 | .then(() => resolve()))) 266 | .catch(err => reject(err)); 267 | }); 268 | 269 | /** 270 | * Returns list of works by circle, tag or VA. 271 | * @param {Number} id Which id to filter by. 272 | * @param {String} field Which field to filter by. 273 | */ 274 | const getWorksBy = (id, field) => { 275 | let workIdQuery; 276 | 277 | switch (field) { 278 | case 'circle': 279 | return knex('t_work') 280 | .select('id') 281 | .where('circle_id', '=', id); 282 | 283 | case 'tag': 284 | workIdQuery = knex('r_tag_work').select('work_id').where('tag_id', '=', id); 285 | return knex('t_work') 286 | .select('id') 287 | .where('id', 'in', workIdQuery); 288 | 289 | case 'va': 290 | workIdQuery = knex('r_va_work').select('work_id').where('va_id', '=', id); 291 | return knex('t_work') 292 | .select('id') 293 | .where('id', 'in', workIdQuery); 294 | 295 | default: 296 | return knex('t_work') 297 | .select('id'); 298 | } 299 | }; 300 | 301 | /** 302 | * 根据关键字查询音声 303 | * @param {String} keyword 304 | */ 305 | const getWorksByKeyWord = (keyword) => { 306 | const workid = keyword.match(/RJ(\d{6})/) ? keyword.match(/RJ(\d{6})/)[1] : ''; 307 | if (workid) { 308 | return knex('t_work') 309 | .select('id') 310 | .where('id', '=', workid); 311 | } 312 | 313 | const circleIdQuery = knex('t_circle').select('id').where('name', 'like', `%${keyword}%`); 314 | 315 | const tagIdQuery = knex('t_tag').select('id').where('name', 'like', `%${keyword}%`); 316 | const vaIdQuery = knex('t_va').select('id').where('name', 'like', `%${keyword}%`); 317 | 318 | const workIdQuery = knex('r_tag_work').select('work_id').where('tag_id', 'in', tagIdQuery).union([ 319 | knex('r_va_work').select('work_id').where('va_id', 'in', vaIdQuery) 320 | ]); 321 | 322 | return knex('t_work') 323 | .select('id', 'release', 'dl_count', 'review_count', 'price', 'rate_average_2dp') 324 | .where('title', 'like', `%${keyword}%`) 325 | .orWhere('circle_id', 'in', circleIdQuery) 326 | .orWhere('id', 'in', workIdQuery); 327 | }; 328 | 329 | /** 330 | * 获取所有社团/标签/声优的元数据列表 331 | * @param {Starting} field ['circle', 'tag', 'va'] 中的一个 332 | */ 333 | const getLabels = (field) => { 334 | if (field === 'circle') { 335 | return knex('t_work') 336 | .join(`t_${field}`, `${field}_id`, '=', `t_${field}.id`) 337 | .select(`t_${field}.id`, 'name') 338 | .groupBy(`${field}_id`) 339 | .count(`${field}_id as count`); 340 | } else if (field === 'tag' || field === 'va') { 341 | return knex(`r_${field}_work`) 342 | .join(`t_${field}`, `${field}_id`, '=', 'id') 343 | .select('id', 'name') 344 | .groupBy(`${field}_id`) 345 | .count(`${field}_id as count`); 346 | } 347 | }; 348 | 349 | /** 350 | * 创建一个新用户 351 | * @param {Object} user User object. 352 | */ 353 | const createUser = user => knex.transaction(trx => trx('t_user') 354 | .where('name', '=', user.name) 355 | .first() 356 | .then((res) => { 357 | if (res) { 358 | throw new Error(`用户 ${user.name} 已存在.`); 359 | } 360 | return trx('t_user') 361 | .insert(user); 362 | })); 363 | 364 | /** 365 | * 更新用户密码 366 | * @param {Object} user User object. 367 | * @param {String} newPassword new password 368 | */ 369 | const updateUserPassword = (user, newPassword) => knex.transaction(trx => trx('t_user') 370 | .where('name', '=', user.name) 371 | .first() 372 | .then((res) => { 373 | if (!res) { 374 | throw new Error('用户名或密码错误.'); 375 | } 376 | return trx('t_user') 377 | .where('name', '=', user.name) 378 | .update({ 379 | password: newPassword 380 | }); 381 | })); 382 | 383 | /** 384 | * 重置用户密码为 "password" 385 | * @param {Object} user User object. 386 | */ 387 | const resetUserPassword = (user) => knex.transaction(trx => trx('t_user') 388 | .where('name', '=', user.name) 389 | .first() 390 | .then((res) => { 391 | if (!res) { 392 | throw new Error('用户名错误.'); 393 | } 394 | return trx('t_user') 395 | .where('name', '=', user.name) 396 | .update({ 397 | password: 'password' 398 | }); 399 | })); 400 | 401 | /** 402 | * 删除用户 403 | * @param {Object} user User object. 404 | */ 405 | const deleteUser = users => knex.transaction(trx => trx('t_user') 406 | .where('name', 'in', users.map(user => user.name)) 407 | .del()); 408 | 409 | 410 | 411 | 412 | 413 | /** 414 | * 创建一个新用户收藏列表 415 | * @param {String} username 416 | * @param {Object} mylist User Mylist object. 417 | */ 418 | const createUserMylist = (username, mylist) => knex.transaction(trx => 419 | trx('t_mylist') 420 | .insert({ 421 | user_name: username, 422 | name: mylist.name, 423 | works: JSON.stringify(mylist.works) 424 | })); 425 | 426 | /** 427 | * 更新用户收藏列表 428 | * @param {String} username 429 | * @param {Object} newMylist User mylist object. 430 | */ 431 | const updateUserMylist = (username, newMylist) => knex.transaction(trx => trx('t_mylist') 432 | .where('id', '=', newMylist.id) 433 | .andWhere('user_name', '=', username) 434 | .first() 435 | .then((res) => { 436 | if (!res) { 437 | throw new Error(`用户 ${username} 的收藏列表 ${mylistId} 不存在.`); 438 | } 439 | return trx('t_mylist') 440 | .where('id', '=', newMylist.id) 441 | .update({ 442 | name: newMylist.name, 443 | works: JSON.stringify(newMylist.works) 444 | }); 445 | })); 446 | 447 | /** 448 | * 删除用户收藏列表 449 | * @param {String} username 450 | * @param {Array} mylistNames 451 | */ 452 | const deleteUserMylists = (username, mylistIds) => knex.transaction(trx => trx('t_mylist') 453 | .where('user_name', '=', username) 454 | .andWhere('id', 'in', mylistIds) 455 | .del()); 456 | 457 | /** 458 | * 查询用户所有的收藏列表 459 | * @param {String} username 460 | */ 461 | const getUserMylists = username => knex('t_mylist') 462 | .select('id', 'name', 'works') 463 | .where('user_name', '=', username) 464 | .then(mylists => { 465 | return mylists.map((mylist) => { 466 | mylist.works = JSON.parse(mylist.works); 467 | return mylist; 468 | }) 469 | }); 470 | 471 | 472 | /** 473 | * 创建一个新用户播放列表 474 | * @param {String} username 475 | * @param {Object} playlist User Playlist object. 476 | */ 477 | const createUserPlaylist = (username, playlist) => knex.transaction(trx => trx('t_playlist') 478 | .where('name', '=', playlist.name) 479 | .andWhere('user_name', '=', username) 480 | // .first() 481 | .then((res) => { 482 | if (res) { 483 | throw new Error(`用户 ${username} 的播放列表 ${playlist.name} 已存在.`); 484 | } 485 | return trx('t_playlist') 486 | .insert({ 487 | user_name: username, 488 | name: playlist.name, 489 | tracks: JSON.stringify(playlist.tracks) 490 | }); 491 | })); 492 | 493 | /** 494 | * 更新用户播放列表 495 | * @param {String} username 496 | * @param {String} oldpPlaylistName 497 | * @param {Object} newPlaylist User Playlist object. 498 | */ 499 | const updateUserPlaylist = (username, oldpPlaylistName, newPlaylist) => knex.transaction(trx => trx('t_playlist') 500 | .where('name', '=', oldpPlaylistName) 501 | .andWhere('user_name', '=', username) 502 | .first() 503 | .then((res) => { 504 | if (!res) { 505 | throw new Error(`用户 ${username} 的播放列表 ${oldpPlaylistName} 不存在.`); 506 | } 507 | return trx('t_playlist') 508 | .where('name', '=', oldpPlaylistName) 509 | .andWhere('user_name', '=', username) 510 | .update({ 511 | name: newPlaylist.name, 512 | tracks: JSON.stringify(newPlaylist.tracks) 513 | }); 514 | })); 515 | 516 | /** 517 | * 删除用户播放列表 518 | * @param {String} username 519 | * @param {Array} playlistNames 520 | */ 521 | const deleteUserPlaylists = (username, playlistNames) => knex.transaction(trx => trx('t_playlist') 522 | .where('user_name', '=', username) 523 | .andWhere('name', 'in', playlistNames) 524 | .del()); 525 | 526 | /** 527 | * 查询用户所有的播放列表 528 | * @param {String} username 529 | */ 530 | const getUserPlaylists = username => knex('t_playlist') 531 | .select('name', 'tracks') 532 | .where('user_name', '=', username) 533 | .then(playlists => playlists.map((playlist) => { 534 | playlist.tracks = JSON.parse(playlist.tracks); 535 | return playlist; 536 | })); 537 | 538 | 539 | module.exports = { 540 | knex, insertWorkMetadata, getWorkMetadata, removeWork, getWorksBy, getWorksByKeyWord, updateWorkMetadata, getLabels, 541 | createUser, updateUserPassword, resetUserPassword, deleteUser, 542 | createUserMylist, updateUserMylist, deleteUserMylists, getUserMylists, 543 | createUserPlaylist, updateUserPlaylist, deleteUserPlaylists, getUserPlaylists 544 | }; 545 | 546 | createUserMylist('admin', { 547 | name: 'h00 0fff 000fg', 548 | works: [ 549 | { 550 | id: 267610, 551 | title: 'Hな小悪魔と初めてのヒプノシスプレイ', 552 | circle: { 553 | name: 'RadioERO', 554 | id: 123232 555 | } 556 | }, 557 | { 558 | id: 260230, 559 | title: 'お菓子みたいな甘ぁ~い催眠はいかがですか', 560 | circle: { 561 | name: 'Transparent Chorion', 562 | id: 122666 563 | } 564 | } 565 | ] 566 | }).then((res) => { 567 | console.log(res) 568 | }) 569 | -------------------------------------------------------------------------------- /database/work.js: -------------------------------------------------------------------------------- 1 | const moment = require('moment'); 2 | const knex = require('./connect'); 3 | 4 | /** 5 | * 将音声的元数据插入到数据库 6 | * @param {Object} work Work object. 7 | */ 8 | const insertWorkMetadata = work => knex.transaction(async trx => { 9 | await trx.raw( 10 | trx('t_circle').insert(work.circle).toString().replace('insert', 'insert or ignore') 11 | ); 12 | 13 | if (work.series) { 14 | await trx.raw( 15 | trx('t_series') 16 | .insert({ 17 | id: work.series.id, 18 | name: work.series.name, 19 | circle_id: work.circle.id 20 | }).toString().replace('insert', 'insert or ignore') 21 | ); 22 | } 23 | 24 | await trx('t_work') 25 | .insert({ 26 | id: work.id, 27 | root_folder: work.rootFolderName, 28 | dir: work.dir, 29 | tracks: JSON.stringify(work.tracks), 30 | title: work.title, 31 | circle_id: work.circle.id, 32 | series_id: work.series ? work.series.id : null, 33 | age_ratings: work.age_ratings, 34 | release: work.release, 35 | 36 | dl_count: work.dl_count, 37 | price: work.price, 38 | review_count: work.review_count, 39 | rate_count: work.rate_count, 40 | rate_average: work.rate_average, 41 | rate_average_2dp: work.rate_average_2dp, 42 | rate_count_detail: JSON.stringify(work.rate_count_detail), 43 | rank: work.rank.length ? JSON.stringify(work.rank) : null 44 | }); 45 | 46 | if (work.tags && work.tags.length) { 47 | await trx.raw( 48 | trx('t_dlsite_tag') 49 | .insert(work.tags).toString().replace('insert', 'insert or ignore') 50 | ); 51 | 52 | await trx('t_dlsite_tag_t_work_relation') 53 | .insert(work.tags.map(tag => { 54 | return { 55 | tag_id: tag.id, 56 | work_id: work.id 57 | }; 58 | })); 59 | } 60 | 61 | if (work.vas && work.vas.length) { 62 | await trx.raw( 63 | trx('t_va') 64 | .insert(work.vas).toString().replace('insert', 'insert or ignore') 65 | ); 66 | 67 | await trx('t_va_t_work_relation') 68 | .insert(work.vas.map(va => { 69 | return { 70 | va_id: va.id, 71 | work_id: work.id 72 | }; 73 | })); 74 | } 75 | }); 76 | 77 | /** 78 | * 更新音声的动态元数据 79 | * @param {Object} work Work object. 80 | */ 81 | const updateWorkMetadata = work => knex.transaction(trx => trx('t_work') 82 | .where('id', '=', work.id) 83 | .update({ 84 | dl_count: work.dl_count, 85 | price: work.price, 86 | review_count: work.review_count, 87 | rate_count: work.rate_count, 88 | rate_average_2dp: work.rate_average_2dp, 89 | rate_count_detail: JSON.stringify(work.rate_count_detail), 90 | rank: work.rank.length ? JSON.stringify(work.rank) : null 91 | })); 92 | 93 | /** 94 | * 查询音声元数据 95 | * @param {String} username 用户名 96 | * @param {Number} workid 音声 id 97 | */ 98 | const getWorkMetadata = async (username, workid) => { 99 | const workRes = await knex('t_work') 100 | .select('*') 101 | .where('id', '=', workid) 102 | .first(); 103 | 104 | if (!workRes) { 105 | throw new Error(`不存在 id 为 ${workid} 的音声.`); 106 | } 107 | 108 | const circleRes = await knex('t_circle') 109 | .select('id', 'name') 110 | .where('t_circle.id', '=', workRes.circle_id) 111 | .first(); 112 | 113 | const seriesRes = await knex('t_series') 114 | .select('id', 'name') 115 | .where('t_series.id', '=', workRes.series_id) 116 | .first(); 117 | 118 | const vasRes = await knex('t_va_t_work_relation') 119 | .select('va_id as id', 'name') 120 | .where('work_id', '=', workid) 121 | .join('t_va', 't_va.id', '=', 'va_id'); 122 | 123 | const originalTagsRes = await knex('t_dlsite_tag_t_work_relation') 124 | .select('tag_id as id', 'name') 125 | .where('work_id', '=', workid) 126 | .join('t_dlsite_tag', 't_dlsite_tag.id', '=', 'tag_id'); 127 | 128 | const customTagsRes = await getCustomWorkTags(username, workid); 129 | 130 | const work = { 131 | id: workRes.id, 132 | title: workRes.title, 133 | circle: circleRes, 134 | series: seriesRes || null, 135 | age_ratings: workRes.age_ratings, 136 | release: workRes.release, 137 | vas: vasRes, 138 | original_tags: originalTagsRes, 139 | custom_dlsite_tags: customTagsRes.dlsite_tags, 140 | custom_user_tags: customTagsRes.user_tags, 141 | 142 | dl_count: workRes.dl_count, 143 | price: workRes.price, 144 | review_count: workRes.review_count, 145 | rate_count: workRes.rate_count, 146 | rate_average_2dp: workRes.rate_average_2dp, 147 | rate_count_detail: JSON.parse(workRes.rate_count_detail), 148 | rank: workRes.rank ? JSON.parse(workRes.rank) : [] 149 | }; 150 | 151 | return work; 152 | }; 153 | 154 | const getWorkSimpleMetadata = async id => { 155 | const workRes = await knex('t_work') 156 | .select('id', 'title', 'circle_id') 157 | .where('id', '=', id) 158 | .first(); 159 | 160 | if (!workRes) { 161 | throw new Error(`不存在 id 为 ${id} 的音声.`); 162 | } 163 | 164 | const circleRes = await knex('t_circle') 165 | .select('id', 'name') 166 | .where('t_circle.id', '=', workRes.circle_id) 167 | .first(); 168 | 169 | const work = { 170 | id: workRes.id, 171 | title: workRes.title, 172 | circle: circleRes 173 | }; 174 | 175 | return work; 176 | }; 177 | 178 | /** 179 | * 检查音声的"社团"、"系列、"标签"和"声优"是否仍在被其它音声引用; 180 | * 如果已经不再被引用,将其记录从数据库中移除 181 | * @param {Function} trx knex 事务函数 182 | * @param {Number} circleId 社团 id 183 | * @param {Number} seriesId 系列 id 184 | * @param {Array} dlsiteTags dlsite 标签对象数组 185 | * @param {Array} userTags 用户标签对象数组 186 | * @param {Array} vas 声优对象数组 187 | */ 188 | const cleanupOrphans = (trx, circleId, seriesId, vas, userTags, dlsiteTags) => { 189 | const getCount = (tableName, colName, colValue) => trx(tableName) 190 | .where(colName, '=', colValue) 191 | .count('* as count') 192 | .first() 193 | .then(res => res.count) 194 | .catch(err => { 195 | throw new Error(`在统计表 ${tableName} 中满足条件 (where ${colName} = ${colValue}) 的记录数时出现错误: ${err}`); 196 | }); 197 | 198 | const promises = []; 199 | 200 | promises.push( 201 | getCount('t_work', 'circle_id', circleId) 202 | .then(count => { 203 | if (count === 0) { 204 | return trx('t_circle').del().where('id', '=', circleId); 205 | } 206 | }) 207 | ); 208 | 209 | if (seriesId) { 210 | promises.push( 211 | getCount('t_work', 'series_id', seriesId) 212 | .then(count => { 213 | return trx('t_series').del().where('id', '=', seriesId); 214 | }) 215 | ); 216 | } 217 | 218 | vas.forEach(va => { 219 | promises.push( 220 | getCount('t_va_t_work_relation', 'va_id', va.id) 221 | .then(count => { 222 | if(count === 0) { 223 | return trx('t_va').delete().where('id', '=', va.id) 224 | } 225 | }) 226 | ); 227 | }); 228 | 229 | userTags.forEach(tag => { 230 | promises.push( 231 | getCount('t_user_t_user_tag_t_work_relation', 'tag_id', tag.id) 232 | .then(count => { 233 | if (count === 0) { 234 | return trx('t_user_tag').delete().where('id', '=', tag.id); 235 | } 236 | }) 237 | ); 238 | }); 239 | 240 | dlsiteTags.forEach(tag => { 241 | promises.push( 242 | Promise.all([ 243 | getCount('t_dlsite_tag_t_work_relation', 'tag_id', tag.id), 244 | getCount('t_user_t_dlsite_tag_t_work_relation', 'tag_id', tag.id) 245 | ]) 246 | .then(res => { 247 | if (res[0] === 0 && res[1] === 0) { 248 | return trx('t_dlsite_tag').delete().where('id', '=', tag.id); 249 | } 250 | }) 251 | ); 252 | }); 253 | 254 | return Promise.all(promises); 255 | }; 256 | 257 | /** 258 | * 将音声从数据库中移除 259 | * @param {Number} id Work id. 260 | */ 261 | const removeWork = id => knex.transaction(async trx => { 262 | const workRes = await trx('t_work').select('circle_id', 'series_id').where('id', '=', id).first(); 263 | if (!workRes) { 264 | return; 265 | } 266 | 267 | const circleId = workRes.circle_id; 268 | const seriesId = workRes.series_id; 269 | const vasRes = await trx('t_va_t_work_relation').select('va_id as id').where('work_id', '=', id); 270 | const userTagsRes = await trx('t_user_t_user_tag_t_work_relation').distinct('tag_id as id').where('work_id', '=', id); 271 | const dlsiteTagsRes = await trx('t_dlsite_tag_t_work_relation').select('tag_id as id').where('work_id', '=', id).union([ 272 | trx('t_user_t_dlsite_tag_t_work_relation').select('tag_id as id').where('work_id', '=', id) 273 | ]); 274 | const mylistsRes = await trx('t_mylist_t_work_relation').select('mylist_id as id').where('work_id', '=', id); 275 | 276 | const promises = []; 277 | 278 | // 移除关系表中与音声关联的记录 279 | promises.push( 280 | trx('t_va_t_work_relation').del().where('work_id', '=', id), 281 | trx('t_dlsite_tag_t_work_relation').del().where('work_id', '=', id), 282 | trx('t_user_t_dlsite_tag_t_work_relation').del().where('work_id', '=', id), 283 | trx('t_user_t_user_tag_t_work_relation').del().where('work_id', '=', id), 284 | trx('t_mylist_t_work_relation').del().where('work_id', '=', id) 285 | ); 286 | 287 | // 将音声从收藏列表中移除 288 | for (let i=0; i { 357 | const dlsiteTags = await knex('t_user_t_dlsite_tag_t_work_relation') 358 | .select('tag_id as id', 'name') 359 | .where('work_id', '=', workid) 360 | .andWhere('user_name', '=', username) 361 | .join('t_dlsite_tag', 't_dlsite_tag.id', '=', 'tag_id'); 362 | 363 | const userTags = await knex('t_user_t_user_tag_t_work_relation') 364 | .select('tag_id as id', 'name', 'created_by') 365 | .where('work_id', '=', workid) 366 | .andWhere('user_name', '=', username) 367 | .join('t_user_tag', 't_user_tag.id', '=', 'tag_id'); 368 | 369 | return { 370 | dlsite_tags: dlsiteTags, 371 | user_tags: userTags 372 | }; 373 | }; 374 | 375 | /** 376 | * 根据用户标签查询音声 377 | * @param {String} username 用户名 378 | * @param {Number} tagId 用户标签 id 379 | */ 380 | const getWorksByUserTag = (username, tagId) => { 381 | const workIdQuery = knex('t_user_t_user_tag_t_work_relation') 382 | .select('work_id') 383 | .where('tag_id', '=', tagId) 384 | .andWhere('user_name', '=', username); 385 | 386 | return knex('t_work') 387 | .select('id') 388 | .where('id', 'in', workIdQuery); 389 | }; 390 | 391 | /** 392 | * 根据 dliste 标签查询音声 393 | * @param {String} username 用户名 394 | * @param {Number} tagId 用户标签 id 395 | */ 396 | const getWorksByDlsiteTag = (username, tagId) => { 397 | // 查询标签被用户编辑过的音声 398 | const editedWorkIdQuery = knex('t_user_t_dlsite_tag_t_work_relation') 399 | .distinct('work_id') 400 | .where('user_name', '=', username); 401 | 402 | const workIdQuery = knex('t_user_t_dlsite_tag_t_work_relation') 403 | .select('work_id') 404 | .where('tag_id', '=', tagId) 405 | .andWhere('user_name', '=', username) 406 | .union([ 407 | knex('t_dlsite_tag_t_work_relation') 408 | .select('work_id') 409 | .where('tag_id', '=', tagId) 410 | .andWhere('work_id', 'not in', editedWorkIdQuery) // 排除标签被用户编辑过的音声 411 | ]); 412 | 413 | return knex('t_work') 414 | .select('id') 415 | .where('id', 'in', workIdQuery); 416 | }; 417 | 418 | /** 419 | * 根据关键字查询音声 420 | * @param {String} username 用户名 421 | * @param {String} keyword 关键字 422 | */ 423 | const getWorksByKeyWord = (username, keyword) => { 424 | if (!keyword) { 425 | return knex('t_work') 426 | .select('id'); 427 | } 428 | 429 | const workid = keyword.match(/RJ(\d{6})/) ? keyword.match(/RJ(\d{6})/)[1] : null; 430 | if (workid) { 431 | return knex('t_work') 432 | .select('id') 433 | .where('id', '=', workid); 434 | } 435 | 436 | const circleIdQuery = knex('t_circle').select('id').where('name', 'like', `%${keyword}%`); 437 | const seriesIdQuery = knex('t_series').select('id').where('name', 'like', `%${keyword}%`); 438 | 439 | const editedWorkIdQuery = knex('t_user_t_dlsite_tag_t_work_relation').distinct('work_id').where('user_name', '=', username); 440 | const dlsiteTagIdQuery = knex('t_dlsite_tag').select('id').where('name', 'like', `%${keyword}%`); 441 | const userTagIdQuery = knex('t_user_tag').select('id').where('name', 'like', `%${keyword}%`); 442 | const vaIdQuery = knex('t_va').select('id').where('name', 'like', `%${keyword}%`); 443 | 444 | const workIdQuery = knex('t_va_t_work_relation').select('work_id').where('va_id', 'in', vaIdQuery).union([ 445 | knex('t_user_t_user_tag_t_work_relation').select('work_id').where('tag_id', 'in', userTagIdQuery).andWhere('user_name', '=', username), 446 | knex('t_user_t_dlsite_tag_t_work_relation').select('work_id').where('tag_id', 'in', dlsiteTagIdQuery).andWhere('user_name', '=', username), 447 | knex('t_dlsite_tag_t_work_relation').select('work_id').where('tag_id', 'in', dlsiteTagIdQuery).andWhere('work_id', 'not in', editedWorkIdQuery) 448 | ]); 449 | 450 | return knex('t_work') 451 | .select('id') 452 | .where(builder => 453 | builder.where('title', 'like', `%${keyword}%`) 454 | .orWhere('circle_id', 'in', circleIdQuery) 455 | .orWhere('series_id', 'in', seriesIdQuery) 456 | .orWhere('id', 'in', workIdQuery) 457 | ); 458 | }; 459 | 460 | /** 461 | * 获取所有社团/系列/标签/声优的列表 462 | * @param {String} username 用户名 463 | * @param {String} field ['circle', 'series', 'va', 'dlsiteTag', 'userTag'] 中的一个 464 | */ 465 | const getLabels = (username, field) => { 466 | switch (field) { 467 | case 'circle': case 'series': 468 | return knex('t_work') 469 | .join(`t_${field}`, `${field}_id`, '=', `t_${field}.id`) 470 | .select(`t_${field}.id`, 'name') 471 | .groupBy(`${field}_id`) 472 | .count('* as count') 473 | .orderBy('name', 'asc'); 474 | case 'va': 475 | return knex('t_va_t_work_relation') 476 | .join('t_va', 'va_id', '=', 't_va.id') 477 | .select('t_va.id', 'name') 478 | .groupBy('va_id') 479 | .count('* as count') 480 | .orderBy('name', 'asc'); 481 | case 'dlsite_tag': 482 | const editedWorkIdQuery = knex('t_user_t_dlsite_tag_t_work_relation') 483 | .distinct('work_id') 484 | .where('user_name', '=', username); 485 | 486 | const queryString = 487 | knex.unionAll([ 488 | knex('t_user_t_dlsite_tag_t_work_relation') 489 | .join('t_dlsite_tag', 'tag_id', '=', 't_dlsite_tag.id') 490 | .select('t_dlsite_tag.id', 'name', `category`) 491 | .where('user_name', '=', username), 492 | 493 | knex('t_dlsite_tag_t_work_relation') 494 | .join('t_dlsite_tag', 'tag_id', '=', 't_dlsite_tag.id') 495 | .select('t_dlsite_tag.id', 'name', `category`) 496 | .where('work_id', 'not in', editedWorkIdQuery) 497 | ]).toString(); 498 | 499 | return knex.raw('select `id`, `name`, `category`, count(*) as count from (' + queryString + ')' + 'group by `id` order by `name` asc'); 500 | case 'user_tag': 501 | return knex('t_user_t_user_tag_t_work_relation') 502 | .join('t_user_tag', 'tag_id', '=', 't_user_tag.id') 503 | .select('t_user_tag.id', 'name', 'created_by') 504 | .where('user_name', '=', username) 505 | .groupBy('tag_id') 506 | .count('* as count') 507 | .orderBy('name', 'asc'); 508 | } 509 | }; 510 | 511 | const worksFilter = (workQuery, releaseTerm, ageCategory) => { 512 | // 发售开始日期 513 | if (releaseTerm) { 514 | let registDateStart, registDateEnd; 515 | switch (releaseTerm) { 516 | case 'week': // 一周以内 517 | registDateStart = moment(Date.now()).subtract(7, 'days').format('YYYY-MM-DD'); 518 | workQuery.andWhere('release', '>=', registDateStart); 519 | break; 520 | case 'month': // 一个月以内 521 | registDateStart = moment(Date.now()).subtract(1, 'months').format('YYYY-MM-DD'); 522 | workQuery.andWhere('release', '>=', registDateStart); 523 | break; 524 | case 'year': // 一年以内 525 | registDateStart = moment(Date.now()).subtract(1, 'years').format('YYYY-MM-DD'); 526 | workQuery.andWhere('release', '>=', registDateStart); 527 | break; 528 | case 'old': // 更以前 529 | registDateEnd = moment(Date.now()).subtract(1, 'years').subtract(1, 'days').format('YYYY-MM-DD'); 530 | workQuery.andWhere('release', '<=', 'registDateEnd'); 531 | break; 532 | } 533 | } 534 | 535 | // 年龄指定 536 | if (ageCategory) { 537 | workQuery.andWhere('age_ratings', '=', ageCategory); 538 | } 539 | 540 | return workQuery; 541 | }; 542 | 543 | 544 | module.exports = { 545 | insertWorkMetadata, updateWorkMetadata, removeWork, getWorkMetadata, getWorkSimpleMetadata, 546 | getWorksBy, getWorksByUserTag, getWorksByDlsiteTag, getWorksByKeyWord, worksFilter, getLabels 547 | }; 548 | -------------------------------------------------------------------------------- /filesystem/scanner.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | const LimitPromise = require('limit-promise'); // 限制并发数量 4 | 5 | const axios = require('../scraper/axios.js'); // 数据请求 6 | const { scrapeWorkMetadataFromDLsite, scrapeDynamicWorkMetadataFromDLsite } = require('../scraper/dlsite'); 7 | const db = require('../database/db'); 8 | const { createSchema } = require('../database/schema'); 9 | const { getFolderList, deleteCoverImageFromDisk, saveCoverImageToDisk } = require('./utils'); 10 | const { md5 } = require('../auth/utils'); 11 | 12 | const { getConfig } = require('../config'); 13 | const config = getConfig(); 14 | 15 | // 只有在子进程中 process 对象才有 send() 方法 16 | process.send = process.send || function () {}; 17 | 18 | const tasks = []; 19 | const failedTasks = []; 20 | const mainLogs = []; 21 | const results = []; 22 | 23 | const addTask = (rjcode) => tasks.push({ 24 | rjcode, 25 | result: null, 26 | logs: [] 27 | }); 28 | 29 | const removeTask = (rjcode) => { 30 | const index = tasks.findIndex(task => task.rjcode === rjcode); 31 | const task = tasks[index]; 32 | tasks.splice(index, 1); 33 | process.send({ 34 | event: 'SCAN_TASKS', 35 | payload: { 36 | tasks 37 | } 38 | }); 39 | 40 | if (task.result === 'failed') { 41 | failedTasks.push(task); 42 | process.send({ 43 | event: 'SCAN_FAILED_TASKS', 44 | payload: { 45 | failedTasks 46 | } 47 | }); 48 | } 49 | }; 50 | 51 | const addLogForTask = (rjcode, log) => { 52 | tasks.find(task => task.rjcode === rjcode).logs.push(log); 53 | process.send({ 54 | event: 'SCAN_TASKS', 55 | payload: { 56 | tasks 57 | } 58 | }); 59 | }; 60 | 61 | const addResult = (rjcode, result, count) => { 62 | results.push({ 63 | rjcode, 64 | result, 65 | count 66 | }); 67 | process.send({ 68 | event: 'SCAN_RESULTS', 69 | payload: { 70 | results 71 | } 72 | }); 73 | }; 74 | 75 | const addMainLog = (log) => { 76 | mainLogs.push(log); 77 | process.send({ 78 | event: 'SCAN_MAIN_LOGS', 79 | payload: { 80 | mainLogs 81 | } 82 | }); 83 | }; 84 | 85 | process.on('message', (m) => { 86 | if (m.emit === 'SCAN_INIT_STATE') { 87 | process.send({ 88 | event: 'SCAN_INIT_STATE', 89 | payload: { 90 | tasks, 91 | failedTasks, 92 | mainLogs, 93 | results 94 | } 95 | }); 96 | } else if (m.exit) { 97 | console.error(' ! 终止扫描进程.'); 98 | addMainLog({ 99 | level: 'error', 100 | message: '终止扫描进程.' 101 | }); 102 | 103 | process.exit(1); 104 | } 105 | }); 106 | 107 | 108 | /** 109 | * 通过数组 arr 中每个对象的 id 属性来对数组去重 110 | * @param {Array} arr 111 | */ 112 | const uniqueArr = (arr) => { 113 | const uniqueArr = []; 114 | const duplicate = {}; 115 | 116 | for (let i=0; i { 142 | const rjcode = (`000000${id}`).slice(-6); // zero-pad to 6 digits 143 | console.log(` -> [RJ${rjcode}] 从 DLSite 抓取元数据...`); 144 | addLogForTask(rjcode, { 145 | level: 'info', 146 | message: '从 DLSite 抓取元数据...' 147 | }); 148 | 149 | return scrapeWorkMetadataFromDLsite(id, tagLanguage) // 抓取该音声的元数据 150 | .then((metadata) => { 151 | // 将抓取到的元数据插入到数据库 152 | console.log(` -> [RJ${rjcode}] 元数据抓取成功,准备添加到数据库...`); 153 | addLogForTask(rjcode, { 154 | level: 'info', 155 | message: '元数据抓取成功,准备添加到数据库...' 156 | }); 157 | 158 | metadata.rootFolderName = rootFolderName; 159 | metadata.dir = dir; 160 | return db.insertWorkMetadata(metadata) 161 | .then(() => { 162 | console.log(` -> [RJ${rjcode}] 元数据成功添加到数据库.`); 163 | addLogForTask(rjcode, { 164 | level: 'info', 165 | message: '元数据成功添加到数据库.' 166 | }); 167 | 168 | return 'added'; 169 | }) 170 | .catch((err) => { 171 | console.error(` ! [RJ${rjcode}] 在插入元数据过程中出错: ${err.message}`); 172 | addLogForTask(rjcode, { 173 | level: 'error', 174 | message: `在插入元数据过程中出错: ${err.message}` 175 | }); 176 | 177 | return 'failed'; 178 | }); 179 | }) 180 | .catch((err) => { 181 | console.error(` ! [RJ${rjcode}] 在抓取元数据过程中出错: ${err.message}`); 182 | addLogForTask(rjcode, { 183 | level: 'error', 184 | message: `在抓取元数据过程中出错: ${err.message}` 185 | }); 186 | 187 | return 'failed'; 188 | }); 189 | }; 190 | 191 | /** 192 | * 从 DLsite 下载封面图片,并保存到 Images 文件夹, 193 | * 返回一个 Promise 对象,处理结果: 'added' or 'failed' 194 | * @param {number} id work id 195 | * @param {Array} types img types: ['main', 'sam', 'sam@2x', 'sam@3x'] 196 | */ 197 | const getCoverImage = (id, types) => { 198 | const rjcode = (`000000${id}`).slice(-6); // zero-pad to 6 digits 199 | const id2 = (id % 1000 === 0) ? id : parseInt(id / 1000) * 1000 + 1000; 200 | const rjcode2 = (`000000${id2}`).slice(-6); // zero-pad to 6 digits 201 | const promises = []; 202 | types.forEach(type => { 203 | const url = `https://img.dlsite.jp/modpub/images2/work/doujin/RJ${rjcode2}/RJ${rjcode}_img_${type}.jpg`; 204 | promises.push( 205 | axios.retryGet(url, { responseType: "stream", retry: {} }) 206 | .then((imageRes) => { 207 | return saveCoverImageToDisk(imageRes.data, rjcode, type) 208 | .then(() => { 209 | console.log(` -> [RJ${rjcode}] 封面 RJ${rjcode}_img_${type}.jpg 下载成功.`); 210 | addLogForTask(rjcode, { 211 | level: 'info', 212 | message: `封面 RJ${rjcode}_img_${type}.jpg 下载成功.` 213 | }); 214 | 215 | return 'added'; 216 | }); 217 | }) 218 | .catch((err) => { 219 | console.error(` ! [RJ${rjcode}] 在下载封面 RJ${rjcode}_img_${type}.jpg 过程中出错: ${err.message}`); 220 | addLogForTask(rjcode, { 221 | level: 'error', 222 | message: `在下载封面 RJ${rjcode}_img_${type}.jpg 过程中出错: ${err.message}` 223 | }); 224 | 225 | return 'failed'; 226 | }) 227 | ); 228 | }); 229 | 230 | console.log(` -> [RJ${rjcode}] 从 DLsite 下载封面...`); 231 | addLogForTask(rjcode, { 232 | level: 'info', 233 | message: `从 DLsite 下载封面...` 234 | }); 235 | 236 | return Promise.all(promises) 237 | .then((results) => { 238 | results.forEach(result => { 239 | if (result === 'failed') { 240 | return 'failed'; 241 | } 242 | }); 243 | 244 | return 'added'; 245 | }); 246 | }; 247 | 248 | /** 249 | * 获取音声元数据,获取音声封面图片, 250 | * 返回一个 Promise 对象,处理结果: 'added', 'skipped' or 'failed' 251 | * @param {string} folder 音声文件夹对象 { relativePath: '相对路径', rootFolderName: '根文件夹别名', id: '音声ID' } 252 | */ 253 | const processFolder = (folder) => db.knex('t_work') 254 | .select('id') 255 | .where('id', '=', folder.id) 256 | .count() 257 | .first() 258 | .then((res) => { 259 | const rjcode = (`000000${folder.id}`).slice(-6); // zero-pad to 6 digits 260 | const coverTypes = ['main', 'sam']; 261 | const count = res['count(*)']; 262 | if (count) { // 查询数据库,检查是否已经写入该音声的元数据 263 | // 已经成功写入元数据 264 | // 检查音声封面图片是否缺失 265 | const lostCoverTypes = []; 266 | coverTypes.forEach(type => { 267 | const coverPath = path.join(config.coverFolderDir, `RJ${rjcode}_img_${type}.jpg`); 268 | if (!fs.existsSync(coverPath)) { 269 | lostCoverTypes.push(type); 270 | } 271 | }); 272 | 273 | if (lostCoverTypes.length) { 274 | console.log(` ! [RJ${rjcode}] 封面图片缺失,重新下载封面图片...`); 275 | addTask(rjcode); 276 | addLogForTask(rjcode, { 277 | level: 'info', 278 | message: '封面图片缺失,重新下载封面图片...' 279 | }); 280 | 281 | return getCoverImage(folder.id, lostCoverTypes); 282 | } else { 283 | return 'skipped'; 284 | } 285 | } else { 286 | console.log(` * 发现新文件夹: "${folder.absolutePath}"`); 287 | addTask(rjcode); 288 | addLogForTask(rjcode, { 289 | level: 'info', 290 | message: `发现新文件夹: "${folder.absolutePath}"` 291 | }); 292 | 293 | return getMetadata(folder.id, folder.rootFolderName, folder.relativePath, config.tagLanguage) // 获取元数据 294 | .then((result) => { 295 | if (result === 'failed') { // 如果获取元数据失败,跳过封面图片下载 296 | return 'failed'; 297 | } else { // 下载封面图片 298 | return getCoverImage(folder.id, coverTypes); 299 | } 300 | }); 301 | } 302 | }); 303 | 304 | const MAX = config.maxParallelism; // 并发请求上限 305 | const limitP = new LimitPromise(MAX); // 核心控制器 306 | /** 307 | * 限制 processFolder 并发数量, 308 | * 使用控制器包装 processFolder 方法,实际上是将请求函数递交给控制器处理 309 | */ 310 | const processFolderLimited = (folder) => { 311 | return limitP.call(processFolder, folder); 312 | }; 313 | 314 | /** 315 | * 清理本地不再存在的音声: 将其元数据从数据库中移除,并删除其封面图片 316 | */ 317 | const performCleanup = () => { 318 | return db.knex('t_work') 319 | .select('id', 'root_folder', 'dir') 320 | .then((works) => { 321 | const promises = works.map(work => new Promise((resolve, reject) => { 322 | // 检查每个音声的根文件夹或本地路径是否仍然存在 323 | const rootFolder = config.rootFolders.find(rootFolder => rootFolder.name === work.root_folder); 324 | if (!rootFolder || !fs.existsSync(path.join(rootFolder.path, work.dir))) { 325 | db.removeWork(work.id) // 将其数据项从数据库中移除 326 | .then((result) => { // 然后删除其封面图片 327 | const rjcode = (`000000${work.id}`).slice(-6); // zero-pad to 6 digits 328 | deleteCoverImageFromDisk(rjcode) 329 | .catch((err) => { 330 | if (err && err.code !== 'ENOENT') { 331 | console.error(` ! [RJ${rjcode}] 在删除封面过程中出错: ${err.message}`); 332 | addMainLog({ 333 | level: 'error', 334 | message: `[RJ${rjcode}] 在删除封面过程中出错: ${err.message}` 335 | }); 336 | } 337 | }) 338 | .then(() => resolve(result)); 339 | }) 340 | .catch(err => reject(err)); 341 | } else { 342 | resolve(); 343 | } 344 | })); 345 | 346 | return Promise.all(promises); 347 | }); 348 | }; 349 | 350 | /** 351 | * 执行扫描 352 | * createCoverFolder => createSchema => cleanup => getAllFolderList => processAllFolder 353 | */ 354 | const performScan = () => { 355 | if (!fs.existsSync(config.coverFolderDir)) { 356 | try { 357 | fs.mkdirSync(config.coverFolderDir, { recursive: true }); 358 | } catch(err) { 359 | console.error(` ! 在创建存放音声封面图片的文件夹时出错: ${err.message}`); 360 | addMainLog({ 361 | level: 'error', 362 | message: `在创建存放音声封面图片的文件夹时出错: ${err.message}` 363 | }); 364 | process.exit(1); 365 | } 366 | } 367 | 368 | return createSchema() // 构建数据库结构 369 | .then(async () => { 370 | try { // 创建内置的管理员账号 371 | await db.createUser({ 372 | name: 'admin', 373 | password: md5('admin'), 374 | group: 'administrator' 375 | }); 376 | } catch(err) { 377 | if (err.message.indexOf('已存在') === -1) { 378 | console.error(` ! 在创建 admin 账号时出错: ${err.message}`); 379 | addMainLog({ 380 | level: 'error', 381 | message: `在创建 admin 账号时出错: ${err.message}` 382 | }); 383 | 384 | process.exit(1); 385 | } 386 | } 387 | 388 | try { 389 | console.log(' * 清理本地不再存在的音声的数据与封面图片...'); 390 | addMainLog({ 391 | level: 'info', 392 | message: '清理本地不再存在的音声的数据与封面图片...' 393 | }); 394 | 395 | await performCleanup(); 396 | 397 | console.log(' * 清理完成. 现在开始扫描...'); 398 | addMainLog({ 399 | level: 'info', 400 | message: '清理完成. 现在开始扫描...' 401 | }); 402 | } catch(err) { 403 | console.error(` ! 在执行清理过程中出错: ${err.message}`); 404 | addMainLog({ 405 | level: 'error', 406 | message: `在执行清理过程中出错: ${err.message}` 407 | }); 408 | 409 | process.exit(1); 410 | } 411 | 412 | let folderList = []; 413 | try { 414 | for (const rootFolder of config.rootFolders) { 415 | for await (const folder of getFolderList(rootFolder)) { 416 | folderList.push(folder); 417 | } 418 | } 419 | 420 | console.log(` * 共找到 ${folderList.length} 个音声文件夹.`); 421 | addMainLog({ 422 | level: 'info', 423 | message: `共找到 ${folderList.length} 个音声文件夹.` 424 | }); 425 | } catch (err) { 426 | console.error(` ! 在扫描根文件夹的过程中出错: ${err.message}`); 427 | addMainLog({ 428 | level: 'error', 429 | message: `在扫描根文件夹的过程中出错: ${err.message}` 430 | }); 431 | 432 | process.exit(1); 433 | } 434 | 435 | try { 436 | const counts = { 437 | added: 0, 438 | failed: 0, 439 | skipped: 0, 440 | }; 441 | 442 | // 去重,避免在之后的并行处理文件夹过程中,出现对数据库同时写入同一条记录的错误 443 | const uniqueFolderList = uniqueArr(folderList).uniqueArr; 444 | const duplicate = uniqueArr(folderList).duplicate 445 | const duplicateNum = folderList.length - uniqueFolderList.length; 446 | 447 | if (duplicateNum) { 448 | console.log(` ! 发现 ${duplicateNum} 个重复的音声文件夹.`); 449 | addMainLog({ 450 | level: 'info', 451 | message: `发现 ${duplicateNum} 个重复的音声文件夹.` 452 | }); 453 | 454 | for (const key in duplicate) { 455 | const addedFolder = uniqueFolderList.find(folder => folder.id === parseInt(key)); 456 | duplicate[key].push(addedFolder); // 最后一项为将要添加到数据库中的音声文件夹 457 | 458 | const rjcode = (`000000${key}`).slice(-6); // zero-pad to 6 digits 459 | console.log(` -> [RJ${rjcode}] 存在多个文件夹:`); 460 | addMainLog({ 461 | level: 'info', 462 | message: `[RJ${rjcode}] 存在多个文件夹:` 463 | }); 464 | 465 | // 打印音声文件夹的绝对路径 466 | duplicate[key].forEach((folder) => { 467 | const rootFolder = config.rootFolders.find(rootFolder => rootFolder.name === folder.rootFolderName); 468 | const absolutePath = path.join(rootFolder.path, folder.relativePath); 469 | console.log(` "${absolutePath}"`); 470 | addMainLog({ 471 | level: 'info', 472 | message: `"${absolutePath}"` 473 | }); 474 | }); 475 | } 476 | } 477 | 478 | counts['skipped'] += duplicateNum; 479 | 480 | const promises = uniqueFolderList.map((folder) => 481 | processFolderLimited(folder) 482 | .then((result) => { // 统计处理结果 483 | const rjcode = (`000000${folder.id}`).slice(-6); // zero-pad to 6 digits\ 484 | counts[result] += 1; 485 | 486 | if (result === 'added') { 487 | console.log(` -> [RJ${rjcode}] 添加成功! Added: ${counts.added}`); 488 | addLogForTask(rjcode, { 489 | level: 'info', 490 | message: `添加成功! Added: ${counts.added}` 491 | }); 492 | 493 | tasks.find(task => task.rjcode === rjcode).result = 'added'; 494 | removeTask(rjcode); 495 | addResult(rjcode, 'added', counts.added); 496 | } else if (result === 'failed') { 497 | console.error(` -> [RJ${rjcode}] 添加失败! Failed: ${counts.failed}`); 498 | addLogForTask(rjcode, { 499 | level: 'error', 500 | message: `添加失败! Failed: ${counts.failed}` 501 | }); 502 | 503 | tasks.find(task => task.rjcode === rjcode).result = 'failed'; 504 | removeTask(rjcode); 505 | addResult(rjcode, 'failed', counts.failed); 506 | } 507 | }) 508 | ); 509 | 510 | return Promise.all(promises).then(() => { 511 | console.log(` * 扫描完成: 新增 ${counts.added} 个,跳过 ${counts.skipped} 个,失败 ${counts.failed} 个.`); 512 | process.send({ 513 | event: 'SCAN_FINISHED', 514 | payload: { 515 | message: `扫描完成: 新增 ${counts.added} 个,跳过 ${counts.skipped} 个,失败 ${counts.failed} 个.` 516 | } 517 | }); 518 | 519 | db.knex.destroy(); 520 | process.exit(0); 521 | }); 522 | } catch (err) { 523 | console.error(` ! 在并行处理音声文件夹过程中出错: ${err.message}`); 524 | addMainLog({ 525 | level: 'error', 526 | message: `在并行处理音声文件夹过程中出错: ${err.message}` 527 | }); 528 | 529 | process.exit(1); 530 | } 531 | }) 532 | .catch((err) => { 533 | console.error(` ! 在构建数据库结构过程中出错: ${err.message}`); 534 | addMainLog({ 535 | level: 'error', 536 | message: `在构建数据库结构过程中出错: ${err.message}` 537 | }); 538 | 539 | process.exit(1); 540 | }); 541 | }; 542 | 543 | /** 544 | * 更新音声的动态元数据 545 | * @param {number} id work id 546 | */ 547 | const updateMetadata = (id) => { 548 | const rjcode = (`000000${id}`).slice(-6); // zero-pad to 6 digits 549 | return scrapeDynamicWorkMetadataFromDLsite(id) // 抓取该音声的元数据 550 | .then((metadata) => { 551 | // 将抓取到的元数据插入到数据库 552 | console.log(` -> [RJ${rjcode}] 元数据抓取成功,准备更新元数据...`); 553 | metadata.id = id; 554 | return db.updateWorkMetadata(metadata) 555 | .then(() => { 556 | console.log(` -> [RJ${rjcode}] 元数据更新成功`); 557 | return 'updated'; 558 | }); 559 | }) 560 | .catch((err) => { 561 | console.error(` ! [RJ${rjcode}] 在抓取元数据过程中出错: ${err}`); 562 | return 'failed'; 563 | }); 564 | }; 565 | 566 | const updateMetadataLimited = (id) => limitP.call(updateMetadata, id); 567 | 568 | const performUpdate = () => db.knex('t_work').select('id') 569 | .then((works) => { 570 | let processedNum = 0; 571 | const counts = { 572 | updated: 0, 573 | failed: 0, 574 | }; 575 | 576 | for (work of works) { 577 | updateMetadataLimited(work.id) 578 | .then((result) => { // 统计处理结果 579 | result === 'failed' ? counts['failed'] += 1 : counts['updated'] += 1; 580 | processedNum += 1; 581 | if (processedNum >= works.length) { 582 | console.log(` * 完成元数据更新 ${counts.updated} and failed to update ${counts.failed} works.`); 583 | process.exit(0); 584 | } 585 | }); 586 | } 587 | }); 588 | 589 | // process.send({ 590 | // event: 'SCAN_ERROR', 591 | // payload: { 592 | // error: `在并行处理音声文件夹过程中出错: ${err.message}` 593 | // } 594 | // }); 595 | 596 | performScan(); 597 | 598 | // getCoverImage(250820, ['main', 'sam']) 599 | // .then(res => { 600 | // console.log(res) 601 | // }) 602 | // .catch(err => { 603 | // console.log(err) 604 | // }) -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | Preamble 9 | 10 | The GNU General Public License is a free, copyleft license for 11 | software and other kinds of works. 12 | 13 | The licenses for most software and other practical works are designed 14 | to take away your freedom to share and change the works. By contrast, 15 | the GNU General Public License is intended to guarantee your freedom to 16 | share and change all versions of a program--to make sure it remains free 17 | software for all its users. We, the Free Software Foundation, use the 18 | GNU General Public License for most of our software; it applies also to 19 | any other work released this way by its authors. You can apply it to 20 | your programs, too. 21 | 22 | When we speak of free software, we are referring to freedom, not 23 | price. Our General Public Licenses are designed to make sure that you 24 | have the freedom to distribute copies of free software (and charge for 25 | them if you wish), that you receive source code or can get it if you 26 | want it, that you can change the software or use pieces of it in new 27 | free programs, and that you know you can do these things. 28 | 29 | To protect your rights, we need to prevent others from denying you 30 | these rights or asking you to surrender the rights. Therefore, you have 31 | certain responsibilities if you distribute copies of the software, or if 32 | you modify it: responsibilities to respect the freedom of others. 33 | 34 | For example, if you distribute copies of such a program, whether 35 | gratis or for a fee, you must pass on to the recipients the same 36 | freedoms that you received. You must make sure that they, too, receive 37 | or can get the source code. And you must show them these terms so they 38 | know their rights. 39 | 40 | Developers that use the GNU GPL protect your rights with two steps: 41 | (1) assert copyright on the software, and (2) offer you this License 42 | giving you legal permission to copy, distribute and/or modify it. 43 | 44 | For the developers' and authors' protection, the GPL clearly explains 45 | that there is no warranty for this free software. For both users' and 46 | authors' sake, the GPL requires that modified versions be marked as 47 | changed, so that their problems will not be attributed erroneously to 48 | authors of previous versions. 49 | 50 | Some devices are designed to deny users access to install or run 51 | modified versions of the software inside them, although the manufacturer 52 | can do so. This is fundamentally incompatible with the aim of 53 | protecting users' freedom to change the software. The systematic 54 | pattern of such abuse occurs in the area of products for individuals to 55 | use, which is precisely where it is most unacceptable. Therefore, we 56 | have designed this version of the GPL to prohibit the practice for those 57 | products. If such problems arise substantially in other domains, we 58 | stand ready to extend this provision to those domains in future versions 59 | of the GPL, as needed to protect the freedom of users. 60 | 61 | Finally, every program is threatened constantly by software patents. 62 | States should not allow patents to restrict development and use of 63 | software on general-purpose computers, but in those that do, we wish to 64 | avoid the special danger that patents applied to a free program could 65 | make it effectively proprietary. To prevent this, the GPL assures that 66 | patents cannot be used to render the program non-free. 67 | 68 | The precise terms and conditions for copying, distribution and 69 | modification follow. 70 | 71 | TERMS AND CONDITIONS 72 | 73 | 0. Definitions. 74 | 75 | "This License" refers to version 3 of the GNU General Public License. 76 | 77 | "Copyright" also means copyright-like laws that apply to other kinds of 78 | works, such as semiconductor masks. 79 | 80 | "The Program" refers to any copyrightable work licensed under this 81 | License. Each licensee is addressed as "you". "Licensees" and 82 | "recipients" may be individuals or organizations. 83 | 84 | To "modify" a work means to copy from or adapt all or part of the work 85 | in a fashion requiring copyright permission, other than the making of an 86 | exact copy. The resulting work is called a "modified version" of the 87 | earlier work or a work "based on" the earlier work. 88 | 89 | A "covered work" means either the unmodified Program or a work based 90 | on the Program. 91 | 92 | To "propagate" a work means to do anything with it that, without 93 | permission, would make you directly or secondarily liable for 94 | infringement under applicable copyright law, except executing it on a 95 | computer or modifying a private copy. Propagation includes copying, 96 | distribution (with or without modification), making available to the 97 | public, and in some countries other activities as well. 98 | 99 | To "convey" a work means any kind of propagation that enables other 100 | parties to make or receive copies. Mere interaction with a user through 101 | a computer network, with no transfer of a copy, is not conveying. 102 | 103 | An interactive user interface displays "Appropriate Legal Notices" 104 | to the extent that it includes a convenient and prominently visible 105 | feature that (1) displays an appropriate copyright notice, and (2) 106 | tells the user that there is no warranty for the work (except to the 107 | extent that warranties are provided), that licensees may convey the 108 | work under this License, and how to view a copy of this License. If 109 | the interface presents a list of user commands or options, such as a 110 | menu, a prominent item in the list meets this criterion. 111 | 112 | 1. Source Code. 113 | 114 | The "source code" for a work means the preferred form of the work 115 | for making modifications to it. "Object code" means any non-source 116 | form of a work. 117 | 118 | A "Standard Interface" means an interface that either is an official 119 | standard defined by a recognized standards body, or, in the case of 120 | interfaces specified for a particular programming language, one that 121 | is widely used among developers working in that language. 122 | 123 | The "System Libraries" of an executable work include anything, other 124 | than the work as a whole, that (a) is included in the normal form of 125 | packaging a Major Component, but which is not part of that Major 126 | Component, and (b) serves only to enable use of the work with that 127 | Major Component, or to implement a Standard Interface for which an 128 | implementation is available to the public in source code form. A 129 | "Major Component", in this context, means a major essential component 130 | (kernel, window system, and so on) of the specific operating system 131 | (if any) on which the executable work runs, or a compiler used to 132 | produce the work, or an object code interpreter used to run it. 133 | 134 | The "Corresponding Source" for a work in object code form means all 135 | the source code needed to generate, install, and (for an executable 136 | work) run the object code and to modify the work, including scripts to 137 | control those activities. However, it does not include the work's 138 | System Libraries, or general-purpose tools or generally available free 139 | programs which are used unmodified in performing those activities but 140 | which are not part of the work. For example, Corresponding Source 141 | includes interface definition files associated with source files for 142 | the work, and the source code for shared libraries and dynamically 143 | linked subprograms that the work is specifically designed to require, 144 | such as by intimate data communication or control flow between those 145 | subprograms and other parts of the work. 146 | 147 | The Corresponding Source need not include anything that users 148 | can regenerate automatically from other parts of the Corresponding 149 | Source. 150 | 151 | The Corresponding Source for a work in source code form is that 152 | same work. 153 | 154 | 2. Basic Permissions. 155 | 156 | All rights granted under this License are granted for the term of 157 | copyright on the Program, and are irrevocable provided the stated 158 | conditions are met. This License explicitly affirms your unlimited 159 | permission to run the unmodified Program. The output from running a 160 | covered work is covered by this License only if the output, given its 161 | content, constitutes a covered work. This License acknowledges your 162 | rights of fair use or other equivalent, as provided by copyright law. 163 | 164 | You may make, run and propagate covered works that you do not 165 | convey, without conditions so long as your license otherwise remains 166 | in force. You may convey covered works to others for the sole purpose 167 | of having them make modifications exclusively for you, or provide you 168 | with facilities for running those works, provided that you comply with 169 | the terms of this License in conveying all material for which you do 170 | not control copyright. Those thus making or running the covered works 171 | for you must do so exclusively on your behalf, under your direction 172 | and control, on terms that prohibit them from making any copies of 173 | your copyrighted material outside their relationship with you. 174 | 175 | Conveying under any other circumstances is permitted solely under 176 | the conditions stated below. Sublicensing is not allowed; section 10 177 | makes it unnecessary. 178 | 179 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 180 | 181 | No covered work shall be deemed part of an effective technological 182 | measure under any applicable law fulfilling obligations under article 183 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or 184 | similar laws prohibiting or restricting circumvention of such 185 | measures. 186 | 187 | When you convey a covered work, you waive any legal power to forbid 188 | circumvention of technological measures to the extent such circumvention 189 | is effected by exercising rights under this License with respect to 190 | the covered work, and you disclaim any intention to limit operation or 191 | modification of the work as a means of enforcing, against the work's 192 | users, your or third parties' legal rights to forbid circumvention of 193 | technological measures. 194 | 195 | 4. Conveying Verbatim Copies. 196 | 197 | You may convey verbatim copies of the Program's source code as you 198 | receive it, in any medium, provided that you conspicuously and 199 | appropriately publish on each copy an appropriate copyright notice; 200 | keep intact all notices stating that this License and any 201 | non-permissive terms added in accord with section 7 apply to the code; 202 | keep intact all notices of the absence of any warranty; and give all 203 | recipients a copy of this License along with the Program. 204 | 205 | You may charge any price or no price for each copy that you convey, 206 | and you may offer support or warranty protection for a fee. 207 | 208 | 5. Conveying Modified Source Versions. 209 | 210 | You may convey a work based on the Program, or the modifications to 211 | produce it from the Program, in the form of source code under the 212 | terms of section 4, provided that you also meet all of these conditions: 213 | 214 | a) The work must carry prominent notices stating that you modified 215 | it, and giving a relevant date. 216 | 217 | b) The work must carry prominent notices stating that it is 218 | released under this License and any conditions added under section 219 | 7. This requirement modifies the requirement in section 4 to 220 | "keep intact all notices". 221 | 222 | c) You must license the entire work, as a whole, under this 223 | License to anyone who comes into possession of a copy. This 224 | License will therefore apply, along with any applicable section 7 225 | additional terms, to the whole of the work, and all its parts, 226 | regardless of how they are packaged. This License gives no 227 | permission to license the work in any other way, but it does not 228 | invalidate such permission if you have separately received it. 229 | 230 | d) If the work has interactive user interfaces, each must display 231 | Appropriate Legal Notices; however, if the Program has interactive 232 | interfaces that do not display Appropriate Legal Notices, your 233 | work need not make them do so. 234 | 235 | A compilation of a covered work with other separate and independent 236 | works, which are not by their nature extensions of the covered work, 237 | and which are not combined with it such as to form a larger program, 238 | in or on a volume of a storage or distribution medium, is called an 239 | "aggregate" if the compilation and its resulting copyright are not 240 | used to limit the access or legal rights of the compilation's users 241 | beyond what the individual works permit. Inclusion of a covered work 242 | in an aggregate does not cause this License to apply to the other 243 | parts of the aggregate. 244 | 245 | 6. Conveying Non-Source Forms. 246 | 247 | You may convey a covered work in object code form under the terms 248 | of sections 4 and 5, provided that you also convey the 249 | machine-readable Corresponding Source under the terms of this License, 250 | in one of these ways: 251 | 252 | a) Convey the object code in, or embodied in, a physical product 253 | (including a physical distribution medium), accompanied by the 254 | Corresponding Source fixed on a durable physical medium 255 | customarily used for software interchange. 256 | 257 | b) Convey the object code in, or embodied in, a physical product 258 | (including a physical distribution medium), accompanied by a 259 | written offer, valid for at least three years and valid for as 260 | long as you offer spare parts or customer support for that product 261 | model, to give anyone who possesses the object code either (1) a 262 | copy of the Corresponding Source for all the software in the 263 | product that is covered by this License, on a durable physical 264 | medium customarily used for software interchange, for a price no 265 | more than your reasonable cost of physically performing this 266 | conveying of source, or (2) access to copy the 267 | Corresponding Source from a network server at no charge. 268 | 269 | c) Convey individual copies of the object code with a copy of the 270 | written offer to provide the Corresponding Source. This 271 | alternative is allowed only occasionally and noncommercially, and 272 | only if you received the object code with such an offer, in accord 273 | with subsection 6b. 274 | 275 | d) Convey the object code by offering access from a designated 276 | place (gratis or for a charge), and offer equivalent access to the 277 | Corresponding Source in the same way through the same place at no 278 | further charge. You need not require recipients to copy the 279 | Corresponding Source along with the object code. If the place to 280 | copy the object code is a network server, the Corresponding Source 281 | may be on a different server (operated by you or a third party) 282 | that supports equivalent copying facilities, provided you maintain 283 | clear directions next to the object code saying where to find the 284 | Corresponding Source. Regardless of what server hosts the 285 | Corresponding Source, you remain obligated to ensure that it is 286 | available for as long as needed to satisfy these requirements. 287 | 288 | e) Convey the object code using peer-to-peer transmission, provided 289 | you inform other peers where the object code and Corresponding 290 | Source of the work are being offered to the general public at no 291 | charge under subsection 6d. 292 | 293 | A separable portion of the object code, whose source code is excluded 294 | from the Corresponding Source as a System Library, need not be 295 | included in conveying the object code work. 296 | 297 | A "User Product" is either (1) a "consumer product", which means any 298 | tangible personal property which is normally used for personal, family, 299 | or household purposes, or (2) anything designed or sold for incorporation 300 | into a dwelling. In determining whether a product is a consumer product, 301 | doubtful cases shall be resolved in favor of coverage. For a particular 302 | product received by a particular user, "normally used" refers to a 303 | typical or common use of that class of product, regardless of the status 304 | of the particular user or of the way in which the particular user 305 | actually uses, or expects or is expected to use, the product. A product 306 | is a consumer product regardless of whether the product has substantial 307 | commercial, industrial or non-consumer uses, unless such uses represent 308 | the only significant mode of use of the product. 309 | 310 | "Installation Information" for a User Product means any methods, 311 | procedures, authorization keys, or other information required to install 312 | and execute modified versions of a covered work in that User Product from 313 | a modified version of its Corresponding Source. The information must 314 | suffice to ensure that the continued functioning of the modified object 315 | code is in no case prevented or interfered with solely because 316 | modification has been made. 317 | 318 | If you convey an object code work under this section in, or with, or 319 | specifically for use in, a User Product, and the conveying occurs as 320 | part of a transaction in which the right of possession and use of the 321 | User Product is transferred to the recipient in perpetuity or for a 322 | fixed term (regardless of how the transaction is characterized), the 323 | Corresponding Source conveyed under this section must be accompanied 324 | by the Installation Information. But this requirement does not apply 325 | if neither you nor any third party retains the ability to install 326 | modified object code on the User Product (for example, the work has 327 | been installed in ROM). 328 | 329 | The requirement to provide Installation Information does not include a 330 | requirement to continue to provide support service, warranty, or updates 331 | for a work that has been modified or installed by the recipient, or for 332 | the User Product in which it has been modified or installed. Access to a 333 | network may be denied when the modification itself materially and 334 | adversely affects the operation of the network or violates the rules and 335 | protocols for communication across the network. 336 | 337 | Corresponding Source conveyed, and Installation Information provided, 338 | in accord with this section must be in a format that is publicly 339 | documented (and with an implementation available to the public in 340 | source code form), and must require no special password or key for 341 | unpacking, reading or copying. 342 | 343 | 7. Additional Terms. 344 | 345 | "Additional permissions" are terms that supplement the terms of this 346 | License by making exceptions from one or more of its conditions. 347 | Additional permissions that are applicable to the entire Program shall 348 | be treated as though they were included in this License, to the extent 349 | that they are valid under applicable law. If additional permissions 350 | apply only to part of the Program, that part may be used separately 351 | under those permissions, but the entire Program remains governed by 352 | this License without regard to the additional permissions. 353 | 354 | When you convey a copy of a covered work, you may at your option 355 | remove any additional permissions from that copy, or from any part of 356 | it. (Additional permissions may be written to require their own 357 | removal in certain cases when you modify the work.) You may place 358 | additional permissions on material, added by you to a covered work, 359 | for which you have or can give appropriate copyright permission. 360 | 361 | Notwithstanding any other provision of this License, for material you 362 | add to a covered work, you may (if authorized by the copyright holders of 363 | that material) supplement the terms of this License with terms: 364 | 365 | a) Disclaiming warranty or limiting liability differently from the 366 | terms of sections 15 and 16 of this License; or 367 | 368 | b) Requiring preservation of specified reasonable legal notices or 369 | author attributions in that material or in the Appropriate Legal 370 | Notices displayed by works containing it; or 371 | 372 | c) Prohibiting misrepresentation of the origin of that material, or 373 | requiring that modified versions of such material be marked in 374 | reasonable ways as different from the original version; or 375 | 376 | d) Limiting the use for publicity purposes of names of licensors or 377 | authors of the material; or 378 | 379 | e) Declining to grant rights under trademark law for use of some 380 | trade names, trademarks, or service marks; or 381 | 382 | f) Requiring indemnification of licensors and authors of that 383 | material by anyone who conveys the material (or modified versions of 384 | it) with contractual assumptions of liability to the recipient, for 385 | any liability that these contractual assumptions directly impose on 386 | those licensors and authors. 387 | 388 | All other non-permissive additional terms are considered "further 389 | restrictions" within the meaning of section 10. If the Program as you 390 | received it, or any part of it, contains a notice stating that it is 391 | governed by this License along with a term that is a further 392 | restriction, you may remove that term. If a license document contains 393 | a further restriction but permits relicensing or conveying under this 394 | License, you may add to a covered work material governed by the terms 395 | of that license document, provided that the further restriction does 396 | not survive such relicensing or conveying. 397 | 398 | If you add terms to a covered work in accord with this section, you 399 | must place, in the relevant source files, a statement of the 400 | additional terms that apply to those files, or a notice indicating 401 | where to find the applicable terms. 402 | 403 | Additional terms, permissive or non-permissive, may be stated in the 404 | form of a separately written license, or stated as exceptions; 405 | the above requirements apply either way. 406 | 407 | 8. Termination. 408 | 409 | You may not propagate or modify a covered work except as expressly 410 | provided under this License. Any attempt otherwise to propagate or 411 | modify it is void, and will automatically terminate your rights under 412 | this License (including any patent licenses granted under the third 413 | paragraph of section 11). 414 | 415 | However, if you cease all violation of this License, then your 416 | license from a particular copyright holder is reinstated (a) 417 | provisionally, unless and until the copyright holder explicitly and 418 | finally terminates your license, and (b) permanently, if the copyright 419 | holder fails to notify you of the violation by some reasonable means 420 | prior to 60 days after the cessation. 421 | 422 | Moreover, your license from a particular copyright holder is 423 | reinstated permanently if the copyright holder notifies you of the 424 | violation by some reasonable means, this is the first time you have 425 | received notice of violation of this License (for any work) from that 426 | copyright holder, and you cure the violation prior to 30 days after 427 | your receipt of the notice. 428 | 429 | Termination of your rights under this section does not terminate the 430 | licenses of parties who have received copies or rights from you under 431 | this License. If your rights have been terminated and not permanently 432 | reinstated, you do not qualify to receive new licenses for the same 433 | material under section 10. 434 | 435 | 9. Acceptance Not Required for Having Copies. 436 | 437 | You are not required to accept this License in order to receive or 438 | run a copy of the Program. Ancillary propagation of a covered work 439 | occurring solely as a consequence of using peer-to-peer transmission 440 | to receive a copy likewise does not require acceptance. However, 441 | nothing other than this License grants you permission to propagate or 442 | modify any covered work. These actions infringe copyright if you do 443 | not accept this License. Therefore, by modifying or propagating a 444 | covered work, you indicate your acceptance of this License to do so. 445 | 446 | 10. Automatic Licensing of Downstream Recipients. 447 | 448 | Each time you convey a covered work, the recipient automatically 449 | receives a license from the original licensors, to run, modify and 450 | propagate that work, subject to this License. You are not responsible 451 | for enforcing compliance by third parties with this License. 452 | 453 | An "entity transaction" is a transaction transferring control of an 454 | organization, or substantially all assets of one, or subdividing an 455 | organization, or merging organizations. If propagation of a covered 456 | work results from an entity transaction, each party to that 457 | transaction who receives a copy of the work also receives whatever 458 | licenses to the work the party's predecessor in interest had or could 459 | give under the previous paragraph, plus a right to possession of the 460 | Corresponding Source of the work from the predecessor in interest, if 461 | the predecessor has it or can get it with reasonable efforts. 462 | 463 | You may not impose any further restrictions on the exercise of the 464 | rights granted or affirmed under this License. For example, you may 465 | not impose a license fee, royalty, or other charge for exercise of 466 | rights granted under this License, and you may not initiate litigation 467 | (including a cross-claim or counterclaim in a lawsuit) alleging that 468 | any patent claim is infringed by making, using, selling, offering for 469 | sale, or importing the Program or any portion of it. 470 | 471 | 11. Patents. 472 | 473 | A "contributor" is a copyright holder who authorizes use under this 474 | License of the Program or a work on which the Program is based. The 475 | work thus licensed is called the contributor's "contributor version". 476 | 477 | A contributor's "essential patent claims" are all patent claims 478 | owned or controlled by the contributor, whether already acquired or 479 | hereafter acquired, that would be infringed by some manner, permitted 480 | by this License, of making, using, or selling its contributor version, 481 | but do not include claims that would be infringed only as a 482 | consequence of further modification of the contributor version. For 483 | purposes of this definition, "control" includes the right to grant 484 | patent sublicenses in a manner consistent with the requirements of 485 | this License. 486 | 487 | Each contributor grants you a non-exclusive, worldwide, royalty-free 488 | patent license under the contributor's essential patent claims, to 489 | make, use, sell, offer for sale, import and otherwise run, modify and 490 | propagate the contents of its contributor version. 491 | 492 | In the following three paragraphs, a "patent license" is any express 493 | agreement or commitment, however denominated, not to enforce a patent 494 | (such as an express permission to practice a patent or covenant not to 495 | sue for patent infringement). To "grant" such a patent license to a 496 | party means to make such an agreement or commitment not to enforce a 497 | patent against the party. 498 | 499 | If you convey a covered work, knowingly relying on a patent license, 500 | and the Corresponding Source of the work is not available for anyone 501 | to copy, free of charge and under the terms of this License, through a 502 | publicly available network server or other readily accessible means, 503 | then you must either (1) cause the Corresponding Source to be so 504 | available, or (2) arrange to deprive yourself of the benefit of the 505 | patent license for this particular work, or (3) arrange, in a manner 506 | consistent with the requirements of this License, to extend the patent 507 | license to downstream recipients. "Knowingly relying" means you have 508 | actual knowledge that, but for the patent license, your conveying the 509 | covered work in a country, or your recipient's use of the covered work 510 | in a country, would infringe one or more identifiable patents in that 511 | country that you have reason to believe are valid. 512 | 513 | If, pursuant to or in connection with a single transaction or 514 | arrangement, you convey, or propagate by procuring conveyance of, a 515 | covered work, and grant a patent license to some of the parties 516 | receiving the covered work authorizing them to use, propagate, modify 517 | or convey a specific copy of the covered work, then the patent license 518 | you grant is automatically extended to all recipients of the covered 519 | work and works based on it. 520 | 521 | A patent license is "discriminatory" if it does not include within 522 | the scope of its coverage, prohibits the exercise of, or is 523 | conditioned on the non-exercise of one or more of the rights that are 524 | specifically granted under this License. You may not convey a covered 525 | work if you are a party to an arrangement with a third party that is 526 | in the business of distributing software, under which you make payment 527 | to the third party based on the extent of your activity of conveying 528 | the work, and under which the third party grants, to any of the 529 | parties who would receive the covered work from you, a discriminatory 530 | patent license (a) in connection with copies of the covered work 531 | conveyed by you (or copies made from those copies), or (b) primarily 532 | for and in connection with specific products or compilations that 533 | contain the covered work, unless you entered into that arrangement, 534 | or that patent license was granted, prior to 28 March 2007. 535 | 536 | Nothing in this License shall be construed as excluding or limiting 537 | any implied license or other defenses to infringement that may 538 | otherwise be available to you under applicable patent law. 539 | 540 | 12. No Surrender of Others' Freedom. 541 | 542 | If conditions are imposed on you (whether by court order, agreement or 543 | otherwise) that contradict the conditions of this License, they do not 544 | excuse you from the conditions of this License. If you cannot convey a 545 | covered work so as to satisfy simultaneously your obligations under this 546 | License and any other pertinent obligations, then as a consequence you may 547 | not convey it at all. For example, if you agree to terms that obligate you 548 | to collect a royalty for further conveying from those to whom you convey 549 | the Program, the only way you could satisfy both those terms and this 550 | License would be to refrain entirely from conveying the Program. 551 | 552 | 13. Use with the GNU Affero General Public License. 553 | 554 | Notwithstanding any other provision of this License, you have 555 | permission to link or combine any covered work with a work licensed 556 | under version 3 of the GNU Affero General Public License into a single 557 | combined work, and to convey the resulting work. The terms of this 558 | License will continue to apply to the part which is the covered work, 559 | but the special requirements of the GNU Affero General Public License, 560 | section 13, concerning interaction through a network will apply to the 561 | combination as such. 562 | 563 | 14. Revised Versions of this License. 564 | 565 | The Free Software Foundation may publish revised and/or new versions of 566 | the GNU General Public License from time to time. Such new versions will 567 | be similar in spirit to the present version, but may differ in detail to 568 | address new problems or concerns. 569 | 570 | Each version is given a distinguishing version number. If the 571 | Program specifies that a certain numbered version of the GNU General 572 | Public License "or any later version" applies to it, you have the 573 | option of following the terms and conditions either of that numbered 574 | version or of any later version published by the Free Software 575 | Foundation. If the Program does not specify a version number of the 576 | GNU General Public License, you may choose any version ever published 577 | by the Free Software Foundation. 578 | 579 | If the Program specifies that a proxy can decide which future 580 | versions of the GNU General Public License can be used, that proxy's 581 | public statement of acceptance of a version permanently authorizes you 582 | to choose that version for the Program. 583 | 584 | Later license versions may give you additional or different 585 | permissions. However, no additional obligations are imposed on any 586 | author or copyright holder as a result of your choosing to follow a 587 | later version. 588 | 589 | 15. Disclaimer of Warranty. 590 | 591 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY 592 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT 593 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY 594 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, 595 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 596 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM 597 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF 598 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 599 | 600 | 16. Limitation of Liability. 601 | 602 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 603 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS 604 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY 605 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE 606 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF 607 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD 608 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), 609 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF 610 | SUCH DAMAGES. 611 | 612 | 17. Interpretation of Sections 15 and 16. 613 | 614 | If the disclaimer of warranty and limitation of liability provided 615 | above cannot be given local legal effect according to their terms, 616 | reviewing courts shall apply local law that most closely approximates 617 | an absolute waiver of all civil liability in connection with the 618 | Program, unless a warranty or assumption of liability accompanies a 619 | copy of the Program in return for a fee. 620 | 621 | END OF TERMS AND CONDITIONS 622 | 623 | How to Apply These Terms to Your New Programs 624 | 625 | If you develop a new program, and you want it to be of the greatest 626 | possible use to the public, the best way to achieve this is to make it 627 | free software which everyone can redistribute and change under these terms. 628 | 629 | To do so, attach the following notices to the program. It is safest 630 | to attach them to the start of each source file to most effectively 631 | state the exclusion of warranty; and each file should have at least 632 | the "copyright" line and a pointer to where the full notice is found. 633 | 634 | 635 | Copyright (C) 636 | 637 | This program is free software: you can redistribute it and/or modify 638 | it under the terms of the GNU General Public License as published by 639 | the Free Software Foundation, either version 3 of the License, or 640 | (at your option) any later version. 641 | 642 | This program is distributed in the hope that it will be useful, 643 | but WITHOUT ANY WARRANTY; without even the implied warranty of 644 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 645 | GNU General Public License for more details. 646 | 647 | You should have received a copy of the GNU General Public License 648 | along with this program. If not, see . 649 | 650 | Also add information on how to contact you by electronic and paper mail. 651 | 652 | If the program does terminal interaction, make it output a short 653 | notice like this when it starts in an interactive mode: 654 | 655 | Copyright (C) 656 | This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 657 | This is free software, and you are welcome to redistribute it 658 | under certain conditions; type `show c' for details. 659 | 660 | The hypothetical commands `show w' and `show c' should show the appropriate 661 | parts of the General Public License. Of course, your program's commands 662 | might be different; for a GUI interface, you would use an "about box". 663 | 664 | You should also get your employer (if you work as a programmer) or school, 665 | if any, to sign a "copyright disclaimer" for the program, if necessary. 666 | For more information on this, and how to apply and follow the GNU GPL, see 667 | . 668 | 669 | The GNU General Public License does not permit incorporating your program 670 | into proprietary programs. If your program is a subroutine library, you 671 | may consider it more useful to permit linking proprietary applications with 672 | the library. If this is what you want to do, use the GNU Lesser General 673 | Public License instead of this License. But first, please read 674 | . 675 | --------------------------------------------------------------------------------