├── .eslintrc.js ├── .github ├── ISSUE_TEMPLATE │ └── bug_report.md └── workflows │ └── package.yml ├── .gitignore ├── LICENSE ├── README.md ├── actions └── get-asset-path.js ├── config ├── util.js ├── webpack.config.base.js ├── webpack.main.config.js ├── webpack.renderer.dev.config.js └── webpack.renderer.prod.config.js ├── forge.config.js ├── package.json ├── src ├── main │ ├── devtools.ts │ └── main.ts ├── renderer │ ├── actions │ │ └── file.ts │ ├── components │ │ ├── FolderViewer │ │ │ ├── index.less │ │ │ └── index.tsx │ │ ├── FormItem │ │ │ └── index.tsx │ │ ├── MediaInfo │ │ │ ├── index.less │ │ │ └── index.tsx │ │ ├── NFOPreview │ │ │ └── index.ts │ │ ├── ProgressModal │ │ │ ├── index.less │ │ │ └── index.tsx │ │ ├── ToolTipIcon │ │ │ └── index.tsx │ │ ├── formItem │ │ │ └── index.tsx │ │ ├── progressModal │ │ │ ├── index.less │ │ │ └── index.tsx │ │ └── toolTipIcon │ │ │ └── index.tsx │ ├── config-store │ │ └── index.ts │ ├── constants │ │ └── file.ts │ ├── containers │ │ ├── App.tsx │ │ └── Home │ │ │ ├── HeaderContent │ │ │ └── index.tsx │ │ │ ├── MainContent │ │ │ └── index.tsx │ │ │ ├── ScrapeInfoModal │ │ │ ├── index.less │ │ │ └── index.tsx │ │ │ ├── SettingModal │ │ │ ├── form.tsx │ │ │ ├── index.less │ │ │ └── index.tsx │ │ │ ├── SiderContent │ │ │ └── index.tsx │ │ │ ├── index.less │ │ │ ├── index.less.d.ts │ │ │ └── index.tsx │ ├── index.global.less │ ├── index.html │ ├── index.tsx │ ├── lib │ │ └── promiseThrottle │ │ │ └── index.ts │ ├── reducers │ │ ├── file.ts │ │ ├── index.ts │ │ └── meta.ts │ ├── scraper │ │ ├── core │ │ │ ├── index.ts │ │ │ └── model.ts │ │ ├── heads │ │ │ ├── avsox.ts │ │ │ ├── index.ts │ │ │ ├── javbus.ts │ │ │ ├── javbus_uncensored.ts │ │ │ └── tmdb.ts │ │ ├── index.ts │ │ ├── mediaType.ts │ │ └── request.ts │ ├── store │ │ └── configureStore.ts │ ├── types │ │ ├── event.ts │ │ ├── index.ts │ │ ├── nfo.ts │ │ └── scraper.ts │ └── utils │ │ ├── emitter.ts │ │ ├── index.ts │ │ ├── video.ts │ │ └── xml.ts └── shared │ └── custom.d.ts └── tsconfig.json /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: "@typescript-eslint/parser", 3 | extends: [ 4 | "plugin:react/recommended", 5 | "plugin:@typescript-eslint/recommended" 6 | ], 7 | plugins: ["@typescript-eslint"], 8 | env: { 9 | browser: true, 10 | node: true 11 | }, 12 | settings: { 13 | react: { 14 | pragma: "React", 15 | version: "detect" 16 | } 17 | }, 18 | parserOptions: { 19 | ecmaVersion: 2019, 20 | sourceType: "module", 21 | ecmaFeatures: { 22 | jsx: true 23 | } 24 | }, 25 | rules: { 26 | "@typescript-eslint/no-explicit-any": "off", 27 | "@typescript-eslint/explicit-function-return-type": "off", 28 | "@typescript-eslint/interface-name-prefix": [ 29 | "warn", 30 | { prefixWithI: "always" } 31 | ] 32 | } 33 | }; 34 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | --- 8 | 9 | **Describe the bug** 10 | A clear and concise description of what the bug is. 11 | 12 | **Screenshots** 13 | If applicable, add screenshots to help explain your problem. 14 | 15 | **Desktop (please complete the following information):** 16 | 17 | - OS: [e.g. Ubuntu] 18 | - Scene: [Package or Dev] 19 | - Version [e.g. 22] 20 | 21 | **Additional context** 22 | Add any other context about the problem here. 23 | -------------------------------------------------------------------------------- /.github/workflows/package.yml: -------------------------------------------------------------------------------- 1 | name: package 2 | 3 | on: 4 | push: 5 | tags: 6 | - "*" 7 | 8 | jobs: 9 | build: 10 | runs-on: ${{ matrix.os }} 11 | 12 | strategy: 13 | matrix: 14 | os: [macOS-10.14, windows-2016, ubuntu-18.04] 15 | 16 | steps: 17 | - name: Context 18 | env: 19 | GITHUB_CONTEXT: ${{ toJson(github) }} 20 | run: echo "$GITHUB_CONTEXT" 21 | - uses: actions/checkout@v1 22 | with: 23 | fetch-depth: 1 24 | - name: Use Node.js 10.x 25 | uses: actions/setup-node@v1 26 | with: 27 | node-version: 10.x 28 | - name: yarn install 29 | run: | 30 | yarn install 31 | - name: Make 32 | run: | 33 | yarn make 34 | - name: Get Asset Path 35 | id: get_asset_path 36 | env: 37 | __OS: ${{ matrix.os }} 38 | tag_name: ${{ github.ref }} 39 | run: | 40 | node ./actions/get-asset-path.js 41 | - name: Upload Release 42 | uses: lucyio/upload-to-release@master 43 | with: 44 | # repo username/name 45 | name: videomanagertools/scraper 46 | # directory of all your files you want to upload (not recursive only flat, directories are skipped) 47 | path: ${{steps.get_asset_path.outputs.asset_path}} 48 | # can be enum of published, unpublished, created, prereleased 49 | action: published 50 | # release tag 51 | release_id: ${{steps.get_asset_path.outputs.version}} 52 | # release repository name 53 | release-repo: videomanagertools/scraper 54 | # secret for your github token to use 55 | repo-token: ${{ secrets.GITHUB_TOKEN }} 56 | -------------------------------------------------------------------------------- /.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 | .DS_Store 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (https://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # TypeScript v1 declaration files 40 | typings/ 41 | 42 | # TypeScript cache 43 | *.tsbuildinfo 44 | 45 | # Optional npm cache directory 46 | .npm 47 | 48 | # Optional eslint cache 49 | .eslintcache 50 | 51 | # Optional REPL history 52 | .node_repl_history 53 | 54 | # Output of 'npm pack' 55 | *.tgz 56 | 57 | # Yarn Integrity file 58 | .yarn-integrity 59 | 60 | # dotenv environment variables file 61 | .env 62 | .env.test 63 | 64 | # parcel-bundler cache (https://parceljs.org/) 65 | .cache 66 | 67 | # next.js build output 68 | .next 69 | 70 | # nuxt.js build output 71 | .nuxt 72 | 73 | # vuepress build output 74 | .vuepress/dist 75 | 76 | # Serverless directories 77 | .serverless/ 78 | 79 | # FuseBox cache 80 | .fusebox/ 81 | 82 | # DynamoDB Local files 83 | .dynamodb/ 84 | 85 | # Webpack 86 | .webpack/ 87 | 88 | # Electron-Forge 89 | out/ 90 | 91 | # lock 92 | package-lock.json 93 | yarn.lock 94 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015-present C. T. Lin 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![GitHub package.json version (branch)](https://img.shields.io/github/package-json/v/videomanagertools/scraper/master) 2 | ![GitHub Release Date](https://img.shields.io/github/release-date/videomanagertools/scraper) 3 | [![Gitter](https://badges.gitter.im/videomanagertools/uScraper.svg)](https://gitter.im/videomanagertools/uScraper?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge) 4 | ![GitHub All Releases](https://img.shields.io/github/downloads/videomanagertools/scraper/total) 5 | 6 | ![WorkFlow](https://img.shields.io/github/workflow/status/videomanagertools/scraper/package?color=orange&label=actions&logo=github&logoColor=orange&style=for-the-badge) 7 | 8 | ## 注意 9 | 10 | 这个是媒体元数据管理,推荐和 Jellyfin 配合使用。其他平台尽量兼容。 11 | 12 | 虽然有绅士模式,但是作者不推荐使用。 13 | 14 | ### 食用指南 15 | 16 | 下载对应平台的安装包,安装,打开。 17 | 18 | #### 设置 19 | 20 | 21 | 22 | 1. 预设标签 23 | 24 | 可以预设标签用于编辑电影自定义标签 25 | 26 | 2. 场景 27 | 28 | 目前有普通和绅士(滑稽),对应了不同的信息源。 29 | 30 | 设置场景后,会在【检索信息】按钮后显示可用的数据源,可根据需要切换 31 | 32 | 3. 代理 33 | 34 | 部分源可能需要使用代理访问,GTW 没屏蔽,但是不同运营商可能会屏蔽。没有代理的绅士们请自行解决。。 35 | 36 | 4. 帧截图 37 | 38 | 为了方便快速的浏览视频内容,提供了这个功能。依赖 ffmpeg,使用前请确保环境变量可用 39 | 40 | #### 建议 41 | 42 | 第一次使用请先拉出来一个测试用的文件夹,熟悉各个操作的效果后,再大批量操作。数据无价,谨慎操作 43 | 44 | ### 开发 45 | 46 | ```bash 47 | git clone https://github.com/videomanagertools/scraper.git 48 | cd scraper 49 | npm i 50 | npm run dev 51 | ``` 52 | 53 | 如果这时候看到一个 Electron 应用已经跑起来了。 54 | 55 | 如果没有,想想办法解决 56 | 57 | 如果还是没解决,提 issues,贴上报错信息,可能会得到帮助 58 | 59 | ### 后续迭代 60 | 61 | 因为电影和剧集已经有很多成熟好用的工具,如果没特殊需求,没计划做这两个 62 | 63 | 已知计划是 64 | 65 | 1. 增加音乐信息爬取 66 | 2. 抽出来一个没有 GUI 的 CLI,可以在 NAS 的 docker 中定时跑 67 | 3. 可能会有一个整合的工具,把 Download,Scrape,Move Files 串联起来 68 | 69 | ### 交流 70 | 71 | [Gitter](https://gitter.im/videomanagertools/uScraper?utm_source=share-link&utm_medium=link&utm_campaign=share-link) 72 | 73 | ### 最后 74 | 75 | 基本都是我自己平时没解决的痛点,如果刚好可以帮到你,绅士萌,star 后就尽情的享用吧。 76 | 77 | Enjoy! 78 | -------------------------------------------------------------------------------- /actions/get-asset-path.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const core = require("@actions/core"); 3 | 4 | const pkg = require("../package.json"); 5 | const forgePlatformsMapping = { 6 | "macOS-10.14": "darwin", 7 | "windows-2016": "win32", 8 | "ubuntu-18.04": "linux" 9 | }; 10 | 11 | const os = process.env.__OS; 12 | const platform = forgePlatformsMapping[os]; 13 | const assetName = `${pkg.productName}-${platform}-x64-${pkg.version}.zip`; 14 | core.setOutput("asset_path", path.join("./out/make/zip", platform, "x64")); 15 | core.setOutput("asset_name", assetName); 16 | core.setOutput("version", process.env.tag_name.replace("refs/tags/", "")); 17 | -------------------------------------------------------------------------------- /config/util.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | isDev: () => { 3 | return process.env.NODE_ENV === "development"; 4 | } 5 | }; 6 | -------------------------------------------------------------------------------- /config/webpack.config.base.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Base webpack config used across other specific configs 3 | */ 4 | 5 | const path = require("path"); 6 | const webpack = require("webpack"); 7 | const ForkTsCheckerWebpackPlugin = require("fork-ts-checker-webpack-plugin"); 8 | module.exports = { 9 | module: { 10 | rules: [ 11 | { 12 | test: /\.node$/, 13 | use: "node-loader" 14 | }, 15 | { 16 | test: /\.(m?js|node)$/, 17 | parser: { amd: false }, 18 | use: { 19 | loader: "@marshallofsound/webpack-asset-relocator-loader", 20 | options: { 21 | outputAssetBase: "native_modules" 22 | } 23 | } 24 | }, 25 | { 26 | test: /\.tsx?$/, 27 | exclude: /(node_modules|.webpack)/, 28 | loaders: [ 29 | { 30 | loader: "babel-loader", 31 | options: { 32 | cacheDirectory: true 33 | } 34 | }, 35 | { 36 | loader: "ts-loader", 37 | options: { 38 | transpileOnly: true 39 | } 40 | } 41 | ] 42 | }, 43 | { 44 | test: /\.global\.(less)$/, 45 | use: [ 46 | { 47 | loader: "style-loader" 48 | }, 49 | { 50 | loader: "css-loader", 51 | options: { 52 | sourceMap: true 53 | } 54 | }, 55 | { 56 | loader: "less-loader" 57 | } 58 | ] 59 | }, 60 | { 61 | test: /^((?!\.global).)*\.(less)$/, 62 | use: [ 63 | { 64 | loader: "style-loader" 65 | }, 66 | { 67 | loader: "css-loader", 68 | options: { 69 | modules: true, 70 | sourceMap: true 71 | } 72 | }, 73 | { 74 | loader: "less-loader" 75 | } 76 | ] 77 | } 78 | ] 79 | }, 80 | 81 | /** 82 | * Determine the array of extensions that should be used to resolve modules. 83 | */ 84 | resolve: { 85 | extensions: [".ts", ".tsx", ".less", ".js", ".css", ".json"], 86 | alias: { 87 | "@actions": path.resolve(__dirname, "../src/renderer/actions"), 88 | "@types": path.resolve(__dirname, "../src/renderer/types"), 89 | "@components": path.resolve(__dirname, "../src/renderer/components"), 90 | "@constants": path.resolve(__dirname, "../src/renderer/constants"), 91 | "@utils": path.resolve(__dirname, "../src/renderer/utils"), 92 | "@config": path.resolve(__dirname, "../src/renderer/config-store"), 93 | "@scraper": path.resolve(__dirname, "../src/renderer/scraper"), 94 | "@lib": path.resolve(__dirname, "../src/renderer/lib") 95 | } 96 | }, 97 | 98 | plugins: [ 99 | new webpack.EnvironmentPlugin({ 100 | NODE_ENV: "production", 101 | FLUENTFFMPEG_COV: false 102 | }), 103 | new ForkTsCheckerWebpackPlugin({ 104 | async: true 105 | }), 106 | new webpack.NamedModulesPlugin() 107 | ] 108 | }; 109 | -------------------------------------------------------------------------------- /config/webpack.main.config.js: -------------------------------------------------------------------------------- 1 | const baseConfig = require("./webpack.config.base"); 2 | const merge = require("webpack-merge"); 3 | module.exports = merge.smart(baseConfig, { 4 | entry: "./src/main/main.ts" 5 | }); 6 | -------------------------------------------------------------------------------- /config/webpack.renderer.dev.config.js: -------------------------------------------------------------------------------- 1 | const baseConfig = require("./webpack.config.base"); 2 | const merge = require("webpack-merge"); 3 | module.exports = merge.smart(baseConfig, {}); 4 | -------------------------------------------------------------------------------- /config/webpack.renderer.prod.config.js: -------------------------------------------------------------------------------- 1 | const baseConfig = require("./webpack.config.base"); 2 | const merge = require("webpack-merge"); 3 | const TerserPlugin = require("terser-webpack-plugin"); 4 | module.exports = merge.smart(baseConfig, { 5 | devtool: "source-map", 6 | mode: "production", 7 | target: "electron-renderer", 8 | optimization: { 9 | minimizer: [ 10 | new TerserPlugin({ 11 | parallel: true, 12 | sourceMap: true, 13 | cache: true 14 | }) 15 | ] 16 | } 17 | }); 18 | -------------------------------------------------------------------------------- /forge.config.js: -------------------------------------------------------------------------------- 1 | const { isDev } = require("./config/util"); 2 | module.exports = { 3 | makers: [ 4 | { 5 | name: "@electron-forge/maker-squirrel", 6 | config: { 7 | name: "uScraper" 8 | } 9 | }, 10 | { 11 | name: "@electron-forge/maker-zip" 12 | }, 13 | { 14 | name: "@electron-forge/maker-deb", 15 | config: { 16 | options: { 17 | maintainer: "Dec-F", 18 | homepage: "https://github.com/videomanagertools/scraper" 19 | } 20 | } 21 | }, 22 | { 23 | name: "@electron-forge/maker-rpm", 24 | config: { 25 | options: { 26 | maintainer: "Dec-F", 27 | homepage: "https://github.com/videomanagertools/scraper" 28 | } 29 | } 30 | } 31 | ], 32 | publishers: [ 33 | { 34 | name: "@electron-forge/publisher-github", 35 | config: { 36 | repository: { 37 | owner: "videomanagertools", 38 | name: "scraper" 39 | }, 40 | prerelease: true, 41 | draft: true 42 | } 43 | } 44 | ], 45 | plugins: [ 46 | [ 47 | "@electron-forge/plugin-webpack", 48 | { 49 | mainConfig: "./config/webpack.main.config.js", 50 | renderer: { 51 | config: isDev() 52 | ? "./config/webpack.renderer.dev.config.js" 53 | : "./config/webpack.renderer.prod.config.js", 54 | entryPoints: [ 55 | { 56 | html: "./src/renderer/index.html", 57 | js: "./src/renderer/index.tsx", 58 | name: "main_window" 59 | } 60 | ] 61 | } 62 | } 63 | ] 64 | ] 65 | }; 66 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "uscraper", 3 | "productName": "uscraper", 4 | "version": "0.4.1", 5 | "description": "A scraper that switches between normal mode and gentleman mode, built on Eletron, React", 6 | "main": ".webpack/main", 7 | "scripts": { 8 | "start": "electron-forge start", 9 | "package": "electron-forge package", 10 | "make": "electron-forge make", 11 | "publish": "electron-forge publish", 12 | "lint": "eslint src --fix --ext .ts,.tsx " 13 | }, 14 | "keywords": [ 15 | "electron", 16 | "react", 17 | "redux", 18 | "typescript", 19 | "scraper", 20 | "jav", 21 | "movie" 22 | ], 23 | "bugs": { 24 | "url": "https://github.com/CatbuilderBeyound2/vkit/issues" 25 | }, 26 | "author": { 27 | "name": "Dec-F", 28 | "email": "dec_fan@icloud.com" 29 | }, 30 | "license": "MIT", 31 | "config": { 32 | "forge": "./forge.config.js" 33 | }, 34 | "dependencies": { 35 | "@rematch/core": "^1.2.0", 36 | "@types/fs-extra": "^9.0.1", 37 | "@vdts/collect-video": "^0.6.2", 38 | "antd": "^3.23.3", 39 | "cheerio": "^1.0.0-rc.3", 40 | "classnames": "^2.2.6", 41 | "electron-squirrel-startup": "^1.0.0", 42 | "electron-store": "^5.0.0", 43 | "fluent-ffmpeg": "^2.1.2", 44 | "fs-extra": "^8.1.0", 45 | "lodash-es": "^4.17.15", 46 | "p-limit": "^2.2.1", 47 | "ramda": "^0.26.1", 48 | "react": "^16.11.0", 49 | "react-dom": "^16.11.0", 50 | "react-hot-loader": "^4.12.17", 51 | "react-redux": "^6.0.0", 52 | "redux": "^4.0.1", 53 | "request": "^2.88.0", 54 | "request-promise": "^4.2.4", 55 | "typesafe-actions": "^4.4.2", 56 | "xml-js": "^1.6.11" 57 | }, 58 | "devDependencies": { 59 | "@actions/core": "^1.2.0", 60 | "@babel/core": "^7.7.2", 61 | "@babel/plugin-transform-runtime": "^7.6.2", 62 | "@babel/preset-env": "^7.7.1", 63 | "@babel/runtime": "^7.7.2", 64 | "@electron-forge/cli": "6.0.0-beta.45", 65 | "@electron-forge/maker-deb": "6.0.0-beta.45", 66 | "@electron-forge/maker-rpm": "6.0.0-beta.45", 67 | "@electron-forge/maker-squirrel": "6.0.0-beta.45", 68 | "@electron-forge/maker-zip": "6.0.0-beta.45", 69 | "@electron-forge/plugin-webpack": "6.0.0-beta.45", 70 | "@marshallofsound/webpack-asset-relocator-loader": "^0.5.0", 71 | "@types/react": "^16.9.11", 72 | "@types/react-dom": "^16.9.4", 73 | "@typescript-eslint/eslint-plugin": "^2.7.0", 74 | "@typescript-eslint/parser": "^2.7.0", 75 | "babel-loader": "^8.0.6", 76 | "babel-plugin-import": "^1.12.2", 77 | "css-loader": "^3.0.0", 78 | "electron": "7.1.1", 79 | "electron-devtools-installer": "^2.2.4", 80 | "eslint": "^6.6.0", 81 | "eslint-plugin-react": "^7.16.0", 82 | "fork-ts-checker-webpack-plugin": "^3.1.0", 83 | "less-loader": "^5.0.0", 84 | "node-loader": "^0.6.0", 85 | "style-loader": "^0.23.1", 86 | "terser-webpack-plugin": "^2.2.1", 87 | "ts-loader": "^6.2.1", 88 | "typescript": "^3.7.2", 89 | "typescript-plugin-css-modules": "^2.0.2", 90 | "webpack-merge": "^4.2.2" 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/main/devtools.ts: -------------------------------------------------------------------------------- 1 | import installExtension, { 2 | REACT_DEVELOPER_TOOLS, 3 | REACT_PERF, 4 | REDUX_DEVTOOLS 5 | } from "electron-devtools-installer"; 6 | /** 7 | * Installs developer tools if we're in dev mode. 8 | * 9 | * @export 10 | * @returns {Promise} 11 | */ 12 | export async function setupDevTools(): Promise { 13 | try { 14 | const react = await installExtension(REACT_DEVELOPER_TOOLS); 15 | console.log(`installDevTools: Installed ${react}`); 16 | 17 | const perf = await installExtension(REACT_PERF); 18 | console.log(`installDevTools: Installed ${perf}`); 19 | 20 | const redux = await installExtension(REDUX_DEVTOOLS); 21 | console.log(`installDevTools: Installed ${redux}`); 22 | } catch (error) { 23 | console.warn(`installDevTools: Error occured:`, error); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/main/main.ts: -------------------------------------------------------------------------------- 1 | import { app, BrowserWindow } from "electron"; 2 | import { setupDevTools } from "./devtools"; 3 | 4 | // Handle creating/removing shortcuts on Windows when installing/uninstalling. 5 | if (require("electron-squirrel-startup")) { 6 | // eslint-disable-line global-require 7 | app.quit(); 8 | } 9 | 10 | // Keep a global reference of the window object, if you don't, the window will 11 | // be closed automatically when the JavaScript object is garbage collected. 12 | let mainWindow: any; 13 | 14 | const createWindow = (): void => { 15 | // Create the browser window. 16 | mainWindow = new BrowserWindow({ 17 | width: 800, 18 | height: 600, 19 | webPreferences: { 20 | nodeIntegration: true, 21 | webSecurity: false 22 | 23 | } 24 | }); 25 | 26 | // and load the index.html of the app. 27 | mainWindow.loadURL(MAIN_WINDOW_WEBPACK_ENTRY); 28 | 29 | mainWindow.webContents.once("dom-ready", () => { 30 | mainWindow.webContents.openDevTools(); 31 | }); 32 | // Open the DevTools. 33 | // mainWindow.webContents.openDevTools(); 34 | 35 | // Emitted when the window is closed. 36 | mainWindow.on("closed", () => { 37 | // Dereference the window object, usually you would store windows 38 | // in an array if your app supports multi windows, this is the time 39 | // when you should delete the corresponding element. 40 | mainWindow = null; 41 | }); 42 | }; 43 | 44 | // This method will be called when Electron has finished 45 | // initialization and is ready to create browser windows. 46 | // Some APIs can only be used after this event occurs. 47 | app.on("ready", () => { 48 | createWindow(); 49 | setupDevTools(); 50 | }); 51 | 52 | // Quit when all windows are closed. 53 | app.on("window-all-closed", () => { 54 | // On OS X it is common for applications and their menu bar 55 | // to stay active until the user quits explicitly with Cmd + Q 56 | if (process.platform !== "darwin") { 57 | app.quit(); 58 | } 59 | }); 60 | 61 | app.on("activate", () => { 62 | // On OS X it's common to re-create a window in the app when the 63 | // dock icon is clicked and there are no other windows open. 64 | if (mainWindow === null) { 65 | createWindow(); 66 | } 67 | }); 68 | 69 | // In this file you can include the rest of your app's specific main process 70 | // code. You can also put them in separate files and import them here. 71 | -------------------------------------------------------------------------------- /src/renderer/actions/file.ts: -------------------------------------------------------------------------------- 1 | import { action } from "typesafe-actions"; 2 | import { 3 | CHANGE_SELECTED_KEY, 4 | CHANGE_CHECKED_KEYS, 5 | SELECT_FILES, 6 | SET_SELECTED_FILENAME, 7 | UPDATE_TREE, 8 | CHANGE_FAILUREEYS 9 | } from "../constants/file"; 10 | import { IFileNode } from "@types"; 11 | 12 | export const changeSelected = (selectedKey: string) => 13 | action(CHANGE_SELECTED_KEY, selectedKey); 14 | export const changeChecked = (checkedKeys: string[]) => 15 | action(CHANGE_CHECKED_KEYS, checkedKeys); 16 | export const selectFiles = (tree: IFileNode) => action(SELECT_FILES, tree); 17 | export const setSelectedFilename = (value: string) => 18 | action(SET_SELECTED_FILENAME, value); 19 | export const updateTree = (tree: IFileNode) => action(UPDATE_TREE, tree); 20 | export const changeFailureKeys = (keys: string[]) => 21 | action(CHANGE_FAILUREEYS, keys); 22 | export default { 23 | ...changeChecked 24 | }; 25 | -------------------------------------------------------------------------------- /src/renderer/components/FolderViewer/index.less: -------------------------------------------------------------------------------- 1 | .wrapper { 2 | height: calc(100vh - 96px); 3 | overflow: auto; 4 | } 5 | -------------------------------------------------------------------------------- /src/renderer/components/FolderViewer/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import { Tree } from "antd"; 4 | import { IFileNode } from "../../types/index"; 5 | import styles from "./index.less"; 6 | 7 | const { TreeNode } = Tree; 8 | type Props = { 9 | tree: IFileNode; 10 | onSelect?: (selectedKey: string[]) => void; 11 | onCheck?: (checkedKeys: string[]) => void; 12 | selectedKeys?: string[]; 13 | checkedKeys?: string[]; 14 | filterKeys?: string[]; 15 | onlyShow?: boolean; 16 | }; 17 | class FileViewer extends React.Component { 18 | renderTreeNodes = data => { 19 | if (data.children) { 20 | return ( 21 | 22 | {data.children.map(v => this.renderTreeNodes(v))} 23 | 24 | ); 25 | } 26 | return ; 27 | }; 28 | 29 | filerNode = node => { 30 | const { filterKeys = [] } = this.props; 31 | return ( 32 | filterKeys.findIndex( 33 | v => 34 | v.indexOf(`${node.props.eventKey}-`) === 0 || 35 | v === node.props.eventKey 36 | ) !== -1 37 | ); 38 | }; 39 | 40 | render() { 41 | const { onCheck, onSelect, selectedKeys, checkedKeys, tree } = this.props; 42 | return ( 43 |
44 | 54 | {this.renderTreeNodes(tree)} 55 | 56 |
57 | ); 58 | } 59 | } 60 | 61 | export default FileViewer; 62 | -------------------------------------------------------------------------------- /src/renderer/components/FormItem/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Form, Icon, Popover } from "antd"; 3 | 4 | const { Item } = Form; 5 | interface IProps { 6 | label?: string; 7 | tips?: string; 8 | title?: string; 9 | [key: string]: any; 10 | } 11 | const FormItem = (props: IProps) => { 12 | const { label, tips, title = "提示" } = props; 13 | return ( 14 |
15 | 19 | {label} 20 | 21 | 26 | 27 | 28 | } 29 | /> 30 |
31 | ); 32 | }; 33 | 34 | export default FormItem; 35 | -------------------------------------------------------------------------------- /src/renderer/components/MediaInfo/index.less: -------------------------------------------------------------------------------- 1 | .media_title { 2 | font-size: 18px; 3 | font-weight: 800; 4 | padding: 20px 0px; 5 | text-align: center; 6 | } 7 | .media_info { 8 | font-size: 16px; 9 | background-color: #eeeeeb; 10 | min-height: 500px; 11 | max-height: calc(100vh - 96px); 12 | overflow: auto; 13 | padding-bottom: 20px; 14 | color: #333; 15 | .info_item { 16 | display: flex; 17 | flex-wrap: wrap; 18 | padding: 5px 0; 19 | .info_label { 20 | font-weight: 800; 21 | } 22 | .info_text { 23 | font-size: 14px; 24 | } 25 | } 26 | .uniqueid { 27 | color: #cc0000; 28 | } 29 | .genre, 30 | .actor { 31 | display: flex; 32 | flex-wrap: wrap; 33 | width: 100%; 34 | padding: 10px 0; 35 | } 36 | .actor { 37 | figure { 38 | background-color: #fff; 39 | overflow: hidden; 40 | margin: 5px; 41 | padding: 10px; 42 | border: none; 43 | border-radius: 0px; 44 | box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); 45 | } 46 | figcaption { 47 | text-align: center; 48 | padding: 5px 0 0 0; 49 | width: 120px; 50 | } 51 | img { 52 | width: 120px; 53 | } 54 | } 55 | .thumbnails { 56 | display: flex; 57 | flex-wrap: wrap; 58 | > div { 59 | flex-grow: 1; 60 | width: 200px; 61 | padding: 5px; 62 | img { 63 | max-width: 100%; 64 | } 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/renderer/components/MediaInfo/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Row, Col, Tag, Select, Popover } from "antd"; 3 | import cn from "classnames"; 4 | import { get } from "lodash"; 5 | import styles from "./index.less"; 6 | 7 | const { Option } = Select; 8 | 9 | interface IProps { 10 | currentMediaInfo: any; 11 | selectable?: boolean; 12 | onSelect?: (keys: string[]) => void; 13 | tags?: string[]; 14 | } 15 | const MediaInfo = ({ 16 | currentMediaInfo, 17 | selectable = false, 18 | onSelect = () => { }, 19 | tags = [] 20 | }: IProps) => { 21 | const selectedTags = get(currentMediaInfo, "tag", []).map(tag => tag._text); 22 | return ( 23 | 24 |
25 |
26 | {get(currentMediaInfo, "title._text")} 27 |
28 | 29 | 30 | 35 | 36 | 37 |
38 |
ID:
39 |
40 | {get(currentMediaInfo, "uniqueid._text")} 41 |
42 |
43 |
44 |
发行日期:
45 |
46 | {get(currentMediaInfo, "premiered._text")} 47 |
48 |
49 |
50 |
类型:
51 |
52 | {get(currentMediaInfo, "genre", []).map(g => ( 53 | {g._text} 54 | ))} 55 |
56 |
57 |
58 |
标签
59 |
60 | {selectable ? ( 61 | 73 | ) : ( 74 | selectedTags.map(g => {g._text}) 75 | )} 76 |
77 |
78 |
79 |
演员:
80 |
81 | {get(currentMediaInfo, "actor", []).map(a => ( 82 |
83 | 84 |
{a.name._text}
85 |
86 | ))} 87 |
88 |
89 | 90 |
91 | {currentMediaInfo.thumbnails ? ( 92 | 93 | 94 |
95 | {currentMediaInfo.thumbnails.map(url => ( 96 |
97 | 100 | } 101 | > 102 | 103 | 104 |
105 | ))} 106 |
107 | 108 |
109 | ) : ( 110 | "" 111 | )} 112 |
113 |
114 | ); 115 | }; 116 | export default MediaInfo; 117 | -------------------------------------------------------------------------------- /src/renderer/components/NFOPreview/index.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/videomanagertools/scraper/8d17950a9ab8d1b48f79591b4943df8c9333c53e/src/renderer/components/NFOPreview/index.ts -------------------------------------------------------------------------------- /src/renderer/components/ProgressModal/index.less: -------------------------------------------------------------------------------- 1 | .progress_modal { 2 | :global { 3 | .ant-progress-outer { 4 | width: calc(100% - 2em); 5 | } 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/renderer/components/ProgressModal/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Progress, Tooltip, Modal } from "antd"; 3 | import { TooltipProps } from "antd/lib/tooltip"; 4 | import { ProgressProps } from "antd/lib/progress"; 5 | import { ModalProps, ModalFuncProps } from "antd/lib/modal"; 6 | 7 | import styles from "./index.less"; 8 | 9 | interface IProps { 10 | tips?: TooltipProps; 11 | progress?: ProgressProps & { total: number }; 12 | modal?: T; 13 | } 14 | const getProps = ( 15 | props: IProps 16 | ): IProps => { 17 | let { tips, modal, progress } = props; 18 | progress = { 19 | // strokeColor: '#f5222d', 20 | format: () => 21 | `${Math.round((progress.successPercent / 100) * progress.total)} / ${ 22 | progress.total 23 | }`, 24 | status: "active", 25 | ...progress 26 | }; 27 | tips = { 28 | title: `${Math.round( 29 | (progress.successPercent / 100) * progress.total 30 | )}已完成 / ${Math.round( 31 | ((progress.percent - progress.successPercent) / 100) * progress.total 32 | )}运行中 / ${Math.round( 33 | ((100 - progress.percent) / 100) * progress.total 34 | )}等待中`, 35 | ...tips 36 | }; 37 | modal = { 38 | icon: null, 39 | width: "500px", 40 | ...modal 41 | }; 42 | return { 43 | progress, 44 | tips, 45 | modal 46 | }; 47 | }; 48 | const getContent = ({ tips, progress }: IProps) => ( 49 |
50 | 51 | 52 | 53 |
54 | ); 55 | const ProgressModal = (props: IProps) => { 56 | const { tips, modal, progress } = getProps(props); 57 | return ( 58 | 59 | 60 | 61 | 62 | 63 | ); 64 | }; 65 | // 只允许同时出现一个进度框 66 | let modalIns = null; 67 | ProgressModal.show = (props: IProps) => { 68 | if (modalIns) return; 69 | const { tips, modal, progress } = getProps(props); 70 | modalIns = Modal.info({ 71 | okButtonProps: { style: { display: "none" } }, 72 | content: getContent({ tips, progress }), 73 | ...modal 74 | }); 75 | }; 76 | ProgressModal.update = (props: IProps) => { 77 | if (!modalIns) return; 78 | const iprops = getProps(props); 79 | const content = getContent(iprops); 80 | modalIns.update({ content, ...iprops.modal }); 81 | }; 82 | ProgressModal.destroy = () => { 83 | modalIns.destroy(); 84 | modalIns = null; 85 | }; 86 | export default ProgressModal; 87 | -------------------------------------------------------------------------------- /src/renderer/components/ToolTipIcon/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactNode } from "react"; 2 | import { Icon, Popover } from "antd"; 3 | interface IProps { 4 | content?: string | ReactNode; 5 | title?: string; 6 | [key: string]: any; 7 | } 8 | const ToolTipIcon = (props: IProps) => { 9 | const { content, title } = props; 10 | return ( 11 | 12 | 13 | 14 | ); 15 | }; 16 | export default ToolTipIcon; 17 | -------------------------------------------------------------------------------- /src/renderer/components/formItem/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Form, Icon, Popover } from "antd"; 3 | 4 | const { Item } = Form; 5 | interface IProps { 6 | label?: string; 7 | tips?: string; 8 | title?: string; 9 | [key: string]: any; 10 | } 11 | const FormItem = (props: IProps) => { 12 | const { label, tips, title = "提示" } = props; 13 | return ( 14 |
15 | 19 | {label} 20 | 21 | 26 | 27 | 28 | } 29 | /> 30 |
31 | ); 32 | }; 33 | 34 | export default FormItem; 35 | -------------------------------------------------------------------------------- /src/renderer/components/progressModal/index.less: -------------------------------------------------------------------------------- 1 | .progress_modal { 2 | :global { 3 | .ant-progress-outer { 4 | width: calc(100% - 2em); 5 | } 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/renderer/components/progressModal/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Progress, Tooltip, Modal } from "antd"; 3 | import { TooltipProps } from "antd/lib/tooltip"; 4 | import { ProgressProps } from "antd/lib/progress"; 5 | import { ModalProps, ModalFuncProps } from "antd/lib/modal"; 6 | 7 | import styles from "./index.less"; 8 | 9 | interface IProps { 10 | tips?: TooltipProps; 11 | progress?: ProgressProps & { total: number }; 12 | modal?: T; 13 | } 14 | const getProps = ( 15 | props: IProps 16 | ): IProps => { 17 | let { tips, modal, progress } = props; 18 | progress = { 19 | // strokeColor: '#f5222d', 20 | format: () => 21 | `${Math.round((progress.successPercent / 100) * progress.total)} / ${ 22 | progress.total 23 | }`, 24 | status: "active", 25 | ...progress 26 | }; 27 | tips = { 28 | title: `${Math.round( 29 | (progress.successPercent / 100) * progress.total 30 | )}已完成 / ${Math.round( 31 | ((progress.percent - progress.successPercent) / 100) * progress.total 32 | )}运行中 / ${Math.round( 33 | ((100 - progress.percent) / 100) * progress.total 34 | )}等待中`, 35 | ...tips 36 | }; 37 | modal = { 38 | icon: null, 39 | width: "500px", 40 | ...modal 41 | }; 42 | return { 43 | progress, 44 | tips, 45 | modal 46 | }; 47 | }; 48 | const getContent = ({ tips, progress }: IProps) => ( 49 |
50 | 51 | 52 | 53 |
54 | ); 55 | const ProgressModal = (props: IProps) => { 56 | const { tips, modal, progress } = getProps(props); 57 | return ( 58 | 59 | 60 | 61 | 62 | 63 | ); 64 | }; 65 | // 只允许同时出现一个进度框 66 | let modalIns = null; 67 | ProgressModal.show = (props: IProps) => { 68 | if (modalIns) return; 69 | const { tips, modal, progress } = getProps(props); 70 | modalIns = Modal.info({ 71 | okButtonProps: { style: { display: "none" } }, 72 | content: getContent({ tips, progress }), 73 | ...modal 74 | }); 75 | }; 76 | ProgressModal.update = (props: IProps) => { 77 | if (!modalIns) return; 78 | const iprops = getProps(props); 79 | const content = getContent(iprops); 80 | modalIns.update({ content, ...iprops.modal }); 81 | }; 82 | ProgressModal.destroy = () => { 83 | modalIns.destroy(); 84 | modalIns = null; 85 | }; 86 | export default ProgressModal; 87 | -------------------------------------------------------------------------------- /src/renderer/components/toolTipIcon/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactNode } from "react"; 2 | import { Icon, Popover } from "antd"; 3 | interface IProps { 4 | content?: string | ReactNode; 5 | title?: string; 6 | [key: string]: any; 7 | } 8 | const ToolTipIcon = (props: IProps) => { 9 | const { content, title } = props; 10 | return ( 11 | 12 | 13 | 14 | ); 15 | }; 16 | export default ToolTipIcon; 17 | -------------------------------------------------------------------------------- /src/renderer/config-store/index.ts: -------------------------------------------------------------------------------- 1 | import Store from 'electron-store'; 2 | 3 | const store = new Store({ 4 | defaults: { 5 | scene: ['movie', 'normal'], 6 | tags: ['无'], 7 | proxy: { 8 | enable: false, 9 | url: 'http://127.0.0.1:1087' 10 | }, 11 | thumbnails: { 12 | enable: false, 13 | count: 30, 14 | size: '800x?', 15 | parallel: 2 16 | } 17 | } 18 | }); 19 | 20 | export default store; 21 | -------------------------------------------------------------------------------- /src/renderer/constants/file.ts: -------------------------------------------------------------------------------- 1 | export const CHANGE_SELECTED_KEY = 'CHANGE_SELECTED_KEY'; 2 | export const CHANGE_CHECKED_KEYS = 'CHANGE_CHECKED_KEYS'; 3 | export const SELECT_FILES = 'SELECT_FILES'; 4 | export const SET_SELECTED_FILENAME = 'SET_SELECTED_FILENAME'; 5 | export const UPDATE_TREE = 'UPDATE_TREE'; 6 | export const CHANGE_FAILUREEYS = 'CHANGE_FAILUREEYS'; 7 | -------------------------------------------------------------------------------- /src/renderer/containers/App.tsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { Provider } from 'react-redux'; 3 | import { hot } from 'react-hot-loader/root'; 4 | import { message } from 'antd'; 5 | import Home from './Home'; 6 | 7 | message.config({ 8 | top: 50 9 | }); 10 | type Props = { 11 | store: any; 12 | }; 13 | class Root extends Component { 14 | render() { 15 | const { store } = this.props; 16 | return ( 17 | 18 | 19 | 20 | ); 21 | } 22 | } 23 | export default hot(Root); 24 | -------------------------------------------------------------------------------- /src/renderer/containers/Home/HeaderContent/index.tsx: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import React, { useState, useRef, useEffect } from "react"; 3 | import { connect } from "react-redux"; 4 | import { Button, Row, Col, Input, Dropdown, Menu, message } from "antd"; 5 | import { shell } from "electron"; 6 | import * as R from "ramda"; 7 | import CRD from "@vdts/collect-video"; 8 | import promiseThrottle from "@lib/promiseThrottle"; 9 | import ProgressModal from "@components/ProgressModal"; 10 | import ToolTipIcon from "@components/ToolTipIcon"; 11 | import { generateFileTree, takeScreenshots } from "../../../utils"; 12 | import { 13 | selectFiles, 14 | setSelectedFilename, 15 | changeChecked, 16 | changeSelected, 17 | changeFailureKeys 18 | } from "../../../actions/file"; 19 | import scrape, { getHeadsByMediaType, getRegularByMediaType } from "@scraper"; 20 | import config from "@config"; 21 | 22 | import ScrapeInfoModal from "../ScrapeInfoModal"; 23 | import SettingModal from "../SettingModal"; 24 | 25 | const { dialog } = require("electron").remote; 26 | const mapStateToProps = ({ file }) => { 27 | const { checkedKeys, selectedFilename, selectedKey, flatTree, tree } = file; 28 | return { 29 | checkedKeys, 30 | selectedFilename, 31 | selectedKey, 32 | flatTree, 33 | tree 34 | }; 35 | }; 36 | type Props = ReturnType & { dispatch }; 37 | 38 | const matchStr: (str: string, reg: RegExp) => string = (str, reg) => { 39 | const r = str.match(reg); 40 | return r ? (r[0] ? r[0] : str) : str; 41 | }; 42 | // After the setting related operation uses redux refactoring, consider the open concurrency setting option. 43 | const HeaderContent = ({ 44 | checkedKeys, 45 | selectedFilename, 46 | selectedKey, 47 | flatTree, 48 | tree, 49 | dispatch 50 | }: Props) => { 51 | const [modalVisible, setModalVisible] = useState(false); 52 | const [settingVisible, setSettingVisible] = useState(false); 53 | const [taskQueue, setTaskQueue] = useState([]); 54 | const [tasks, setTasks] = useState([]); 55 | const scraperHead = useRef(""); 56 | 57 | useEffect(() => { 58 | if (tasks.length) { 59 | scrape.start(tasks, scraperHead.current); 60 | } 61 | }, [tasks]); 62 | const handleInput = filename => { 63 | dispatch(setSelectedFilename(filename)); 64 | }; 65 | const handleSelect = () => { 66 | dialog 67 | .showOpenDialog(null, { 68 | title: "Select", 69 | properties: ["openDirectory"] 70 | }) 71 | .then(({ canceled, filePaths }) => { 72 | if (canceled) return; 73 | if (!R.is(Array, filePaths) || R.isEmpty(filePaths)) return; 74 | const _tree = generateFileTree(filePaths); 75 | dispatch(changeChecked([])); 76 | dispatch(changeSelected("")); 77 | dispatch(changeFailureKeys([])); 78 | dispatch(selectFiles(_tree[0])); 79 | }); 80 | }; 81 | const handleScrape = (headName?: string) => { 82 | const defaultHead = getHeadsByMediaType(config.get("scene"))[0]; 83 | const regular = getRegularByMediaType(config.get("scene")) || /./; 84 | if (!defaultHead) { 85 | return message.error("当前场景未找到信息源"); 86 | } 87 | dispatch(changeFailureKeys([])); 88 | const head = headName || getHeadsByMediaType(config.get("scene"))[0].name; 89 | let _taskQueue = []; 90 | let _tasks = []; 91 | if (!checkedKeys.length) { 92 | if (!selectedFilename) { 93 | return; 94 | } 95 | const file = flatTree[selectedKey]; 96 | 97 | _taskQueue = [{ file, status: "unfired" }]; 98 | 99 | _tasks = [ 100 | { 101 | queryString: matchStr(selectedFilename, regular), 102 | file 103 | } 104 | ]; 105 | } else { 106 | _tasks = checkedKeys 107 | .map(key => { 108 | const file = flatTree[key]; 109 | return { 110 | queryString: matchStr(file.title, regular), 111 | file 112 | }; 113 | }) 114 | .filter(v => !v.file.isDir); 115 | 116 | _taskQueue = _tasks.map(({ file }) => ({ file, status: "unfired" })); 117 | } 118 | setModalVisible(true); 119 | setTaskQueue(_taskQueue); 120 | setTasks(_tasks); 121 | scraperHead.current = head; 122 | }; 123 | const handleRebuild = () => { 124 | CRD(tree.wpath) 125 | .then(res => { 126 | const _tree = generateFileTree([tree.wpath]); 127 | dispatch(changeChecked([])); 128 | dispatch(changeSelected("")); 129 | dispatch(changeFailureKeys([])); 130 | dispatch(selectFiles(_tree[0])); 131 | message.success("格式化成功"); 132 | return res; 133 | }) 134 | .catch(e => { 135 | message.error("格式化失败"); 136 | console.log(e); 137 | }); 138 | }; 139 | const handleThumbnails = () => { 140 | function getTitle(progress) { 141 | return `${Math.round( 142 | (progress.successPercent / 100) * progress.total 143 | )}已完成 / ${Math.round( 144 | ((progress.percent - progress.successPercent) / 100) * progress.total 145 | )}运行中 / ${Math.round( 146 | ((100 - progress.percent) / 100) * progress.total 147 | )}等待中`; 148 | } 149 | function getProgress( 150 | pendingCount: number, 151 | activeCount: number, 152 | itotal: number 153 | ) { 154 | return { 155 | percent: +(((itotal - pendingCount) / itotal) * 100).toFixed(0), 156 | successPercent: +( 157 | ((itotal - pendingCount - activeCount) / itotal) * 158 | 100 159 | ).toFixed(0), 160 | total: itotal 161 | }; 162 | } 163 | const setting = config.get("thumbnails"); 164 | const promises = checkedKeys 165 | .map(key => flatTree[key]) 166 | .filter(v => !v.isDir) 167 | .map(file => ({ 168 | task: takeScreenshots, 169 | arguments: [ 170 | { 171 | file: file.fullpath, 172 | count: setting.count, 173 | size: setting.size, 174 | folder: path.join(file.wpath, ".thumbnails") 175 | } 176 | ] 177 | })); 178 | const total = promises.length; 179 | ProgressModal.show({ 180 | progress: { 181 | percent: +((setting.parallel / total) * 100).toFixed(0), 182 | successPercent: 0, 183 | total 184 | }, 185 | modal: { 186 | title: getTitle( 187 | getProgress(total - setting.parallel, setting.parallel, total) 188 | ) 189 | } 190 | }); 191 | promiseThrottle(promises, { 192 | concurrency: setting.parallel, 193 | onRes: limit => { 194 | const { activeCount, pendingCount } = limit; 195 | const progress = getProgress(pendingCount, activeCount, total); 196 | console.log(limit.activeCount, limit.pendingCount, getTitle(progress)); 197 | ProgressModal.update({ 198 | progress, 199 | modal: { 200 | title: getTitle(progress) 201 | } 202 | }); 203 | } 204 | }) 205 | .then(() => 206 | setTimeout(() => { 207 | ProgressModal.destroy(); 208 | }, 2000) 209 | ) 210 | .catch(() => {}); 211 | }; 212 | const menu = ( 213 | { 215 | handleScrape(e.key); 216 | }} 217 | > 218 | {(getHeadsByMediaType(config.get("scene")) || []).map(head => ( 219 | {head.name} 220 | ))} 221 | 222 | ); 223 | const inputDisabled = !!checkedKeys.length || !selectedKey; 224 | const thumbnailsEnable = config.get("thumbnails").enable; 225 | return ( 226 | <> 227 | 228 | 229 | 230 | 231 | 232 | 233 | 234 | 235 | { 240 | handleInput(e.target.value); 241 | }} 242 | /> 243 | 244 | 245 | 254 | 263 | 264 | 265 | { 267 | handleScrape(); 268 | }} 269 | overlay={menu} 270 | > 271 | 爬取信息 272 | 273 | 274 | {thumbnailsEnable ? ( 275 | 276 | 277 | 278 | ) : ( 279 | "" 280 | )} 281 | 282 | 25 | 26 | 43 | 44 | } 45 | > 46 | 47 | 48 | ); 49 | }; 50 | export default SettingModal; 51 | -------------------------------------------------------------------------------- /src/renderer/containers/Home/SiderContent/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import { connect } from "react-redux"; 3 | import { Select, Row, Col, Button } from "antd"; 4 | import { move, mkdirp } from "fs-extra"; 5 | import path from "path"; 6 | import FolderViewer from "../../../components/FolderViewer/index"; 7 | import { changeChecked, changeSelected } from "../../../actions/file"; 8 | import { IFileNode } from "@types"; 9 | 10 | const { Option } = Select; 11 | 12 | const mapStateToProps = ({ file }) => { 13 | const { tree, checkedKeys, selectedKey, failureKeys, flatTree } = file; 14 | return { 15 | tree, 16 | checkedKeys, 17 | selectedKey, 18 | failureKeys, 19 | flatTree 20 | }; 21 | }; 22 | const mapDispatchToProps = { 23 | onChecked: changeChecked, 24 | onSelected: changeSelected 25 | }; 26 | 27 | type Props = ReturnType & 28 | typeof mapDispatchToProps & { 29 | tree: IFileNode; 30 | }; 31 | 32 | enum OptionValue { 33 | movefail = "movefail" 34 | } 35 | const SiderContent = ({ 36 | tree, 37 | onChecked, 38 | onSelected, 39 | checkedKeys, 40 | selectedKey, 41 | failureKeys, 42 | flatTree 43 | }: Props) => { 44 | const [instruction, setInstruction] = useState(null); 45 | const selectHandle = (iselectedKeys: string[]) => { 46 | onSelected(iselectedKeys[0]); 47 | }; 48 | const checkHandle = icheckedKeys => { 49 | onChecked(icheckedKeys); 50 | }; 51 | const handleChange = (val: string) => { 52 | setInstruction(val); 53 | }; 54 | const handleExec = async () => { 55 | const failPath = path.join(tree.wpath, "Fail"); 56 | switch (instruction) { 57 | case OptionValue.movefail: 58 | await Promise.all([ 59 | mkdirp(failPath), 60 | ...failureKeys.map(key => { 61 | const src = flatTree[key].fullpath; 62 | const dest = path.join( 63 | failPath, 64 | `${flatTree[key].title}.${flatTree[key].ext}` 65 | ); 66 | return src === dest ? Promise.resolve() : move(src, dest); 67 | }) 68 | ]); 69 | break; 70 | default: 71 | break; 72 | } 73 | }; 74 | return ( 75 | <> 76 | 77 | 78 | 88 | 89 | 90 | {instruction ? ( 91 | 94 | ) : ( 95 | "" 96 | )} 97 | 98 | 99 | {tree.key === "def" ? ( 100 | "" 101 | ) : ( 102 | 110 | )} 111 | 112 | ); 113 | }; 114 | 115 | export default connect(mapStateToProps, mapDispatchToProps)(SiderContent); 116 | -------------------------------------------------------------------------------- /src/renderer/containers/Home/index.less: -------------------------------------------------------------------------------- 1 | .sider { 2 | min-height: 100vh; 3 | } 4 | -------------------------------------------------------------------------------- /src/renderer/containers/Home/index.less.d.ts: -------------------------------------------------------------------------------- 1 | // This file is generated automatically 2 | export const sider: string; 3 | -------------------------------------------------------------------------------- /src/renderer/containers/Home/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { Layout } from 'antd'; 4 | import HeaderContent from './HeaderContent'; 5 | import SiderContent from './SiderContent'; 6 | import MainContent from './MainContent'; 7 | 8 | import * as styles from './index.less'; 9 | 10 | const { Header, Sider, Content } = Layout; 11 | 12 | function Home() { 13 | return ( 14 |
15 | 16 |
17 | 18 |
19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 |
28 |
29 | ); 30 | } 31 | 32 | export default Home; 33 | -------------------------------------------------------------------------------- /src/renderer/index.global.less: -------------------------------------------------------------------------------- 1 | @import '~antd/dist/antd.css'; 2 | body { 3 | position: relative; 4 | color: #333; 5 | height: 100vh; 6 | background-color: #8ec5fc; 7 | background-image: linear-gradient(62deg, #8ec5fc 0%, #e0c3fc 100%); 8 | font-family: Arial, Helvetica, Helvetica Neue, serif; 9 | overflow-y: hidden; 10 | } 11 | 12 | h2 { 13 | margin: 0; 14 | font-size: 2.25rem; 15 | font-weight: bold; 16 | letter-spacing: -0.025em; 17 | color: #fff; 18 | } 19 | 20 | p { 21 | font-size: 24px; 22 | } 23 | 24 | li { 25 | list-style: none; 26 | } 27 | 28 | a { 29 | color: white; 30 | opacity: 0.75; 31 | text-decoration: none; 32 | } 33 | 34 | a:hover { 35 | opacity: 1; 36 | text-decoration: none; 37 | cursor: pointer; 38 | } 39 | 40 | .ant-timeline-item-content { 41 | margin: 0 0 0 36px; 42 | } 43 | 44 | .ant-layout-content { 45 | background-color: #eeeeeb; 46 | } 47 | 48 | -------------------------------------------------------------------------------- /src/renderer/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | uScraper 6 | 7 | 8 |
9 | 10 | 11 | -------------------------------------------------------------------------------- /src/renderer/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import * as ReactDOM from "react-dom"; 3 | import App from "./containers/App"; 4 | import "./index.global.less"; 5 | import { configureStore } from "./store/configureStore"; 6 | const store = configureStore(); 7 | ReactDOM.render(, document.getElementById("root")); 8 | -------------------------------------------------------------------------------- /src/renderer/lib/promiseThrottle/index.ts: -------------------------------------------------------------------------------- 1 | import pLimit from "p-limit"; 2 | 3 | interface IOptions { 4 | concurrency?: number; 5 | onRes?: (limit: any) => void; 6 | } 7 | interface IPromiseTask { 8 | task: (...rest: any[]) => Promise; 9 | arguments: any[]; 10 | } 11 | function promiseThrottle(promises: IPromiseTask[], options: IOptions) { 12 | const { concurrency = 2, onRes } = options; 13 | const limit = pLimit(concurrency); 14 | return Promise.all( 15 | promises.map(promise => 16 | limit(() => 17 | promise.task(...promise.arguments).then(res => res) 18 | ).then(() => onRes(limit)) 19 | ) 20 | ); 21 | } 22 | export default promiseThrottle; 23 | -------------------------------------------------------------------------------- /src/renderer/reducers/file.ts: -------------------------------------------------------------------------------- 1 | import { ActionType } from "typesafe-actions"; 2 | import { flatTrees } from "../utils"; 3 | import * as fileViewer from "../actions/file"; 4 | import { 5 | CHANGE_SELECTED_KEY, 6 | CHANGE_CHECKED_KEYS, 7 | SELECT_FILES, 8 | SET_SELECTED_FILENAME, 9 | UPDATE_TREE, 10 | CHANGE_FAILUREEYS 11 | } from "../constants/file"; 12 | import { IFileNode } from "@types"; 13 | 14 | export type FileAction = ActionType; 15 | 16 | const defaultState = { 17 | selectedFilename: "", 18 | selectedKey: "", 19 | checkedKeys: [], 20 | tree: { 21 | title: "", 22 | key: "def", 23 | children: [] 24 | }, 25 | flatTree: {}, 26 | failureKeys: [] 27 | }; 28 | type defaultState = Readonly<{ 29 | selectedFilename: string; 30 | selectedKey: string; 31 | checkedKeys: string[]; 32 | tree: IFileNode; 33 | flatTree: Record; 34 | failureKeys: string[]; 35 | }>; 36 | 37 | export default (state: defaultState = defaultState, action: FileAction) => { 38 | switch (action.type) { 39 | case CHANGE_SELECTED_KEY: 40 | return { 41 | ...state, 42 | selectedKey: action.payload ? action.payload : "", 43 | selectedFilename: 44 | action.payload && !state.flatTree[action.payload].isDir 45 | ? state.flatTree[action.payload].title 46 | : "" 47 | }; 48 | case CHANGE_CHECKED_KEYS: 49 | return { ...state, checkedKeys: action.payload }; 50 | case SELECT_FILES: 51 | return { 52 | ...state, 53 | tree: action.payload, 54 | flatTree: flatTrees([action.payload]) 55 | }; 56 | case SET_SELECTED_FILENAME: 57 | return { ...state, selectedFilename: action.payload }; 58 | case UPDATE_TREE: 59 | return { ...state, trees: action.payload }; 60 | case CHANGE_FAILUREEYS: 61 | return { 62 | ...state, 63 | failureKeys: action.payload 64 | }; 65 | default: 66 | return state; 67 | } 68 | }; 69 | -------------------------------------------------------------------------------- /src/renderer/reducers/index.ts: -------------------------------------------------------------------------------- 1 | import { combineReducers } from 'redux'; 2 | import file from './file'; 3 | 4 | export default function createRootReducer() { 5 | return combineReducers({ 6 | file 7 | }); 8 | } 9 | -------------------------------------------------------------------------------- /src/renderer/reducers/meta.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/videomanagertools/scraper/8d17950a9ab8d1b48f79591b4943df8c9333c53e/src/renderer/reducers/meta.ts -------------------------------------------------------------------------------- /src/renderer/scraper/core/index.ts: -------------------------------------------------------------------------------- 1 | import { writeFile, mkdirp } from "fs-extra"; 2 | import { downloadImg, emitter } from "../../utils"; 3 | import { 4 | EventType, 5 | IQueryOpt, 6 | IToolHead, 7 | IFileNode, 8 | IMovieModelType 9 | } from "@types"; 10 | import config from "@config"; 11 | 12 | const saveAsserts = async (model: IMovieModelType, file) => { 13 | const proxy = config.get("proxy"); 14 | const url = proxy.enable ? proxy.url : ""; 15 | const json = model.getModel(); 16 | await mkdirp(`${file.wpath}/.actors`); 17 | return Promise.all([ 18 | writeFile( 19 | `${file.wpath + file.title}.nfo`, 20 | `${model.getXML()}` 21 | ), 22 | downloadImg( 23 | json.art.poster._text, 24 | `${file.wpath + file.title}-poster.jpg`, 25 | { proxy: url } 26 | ), 27 | downloadImg( 28 | json.art.fanart._text, 29 | `${file.wpath + file.title}-fanart.jpg`, 30 | { proxy: url } 31 | ), 32 | json.actor.map(v => 33 | downloadImg(v.thumb._text, `${file.wpath}.actors/${v.name._text}.jpg`, { 34 | proxy: url 35 | }) 36 | ) 37 | ]) 38 | .then(() => model) 39 | .catch(e => { 40 | console.log(e); 41 | console.log("save asserts error", file.wpath); 42 | }); 43 | }; 44 | interface ITaskResult { 45 | successTasks: IFileNode[]; 46 | failureTasks: IFileNode[]; 47 | } 48 | class Scraper { 49 | heads: IToolHead[] = []; 50 | 51 | stopFlag = false; 52 | 53 | loadHead(heads: IToolHead[]): Scraper { 54 | this.heads = this.heads.concat(heads); 55 | return this; 56 | } 57 | 58 | stop(): void { 59 | this.stopFlag = true; 60 | } 61 | 62 | async start(queryOpts: IQueryOpt[], name: string): Promise { 63 | this.stopFlag = false; 64 | const failureTasks = []; 65 | const successTasks = []; 66 | const { head } = this.heads.find(t => t.name === name); 67 | for (let i = 0; i < queryOpts.length; i += 1) { 68 | if (this.stopFlag) return; 69 | const str = queryOpts[i].queryString; 70 | const { file } = queryOpts[i]; 71 | emitter.emit(EventType.SCRAPE_PENDING, file, str); 72 | await head(str) 73 | .then(res => { 74 | console.log(res.getModel(), file); 75 | if (this.stopFlag) return; 76 | return saveAsserts(res, file) as Promise; 77 | }) 78 | .then(res => { 79 | emitter.emit(EventType.SCRAPE_SUCCESS, file, res.getModel()); 80 | return successTasks.push(file); 81 | }) 82 | .catch(error => { 83 | console.log(error); 84 | emitter.emit(EventType.SCRAPE_FAIL, file); 85 | failureTasks.push(file); 86 | }); 87 | } 88 | emitter.emit(EventType.SCRAPE_TASK_END, { failureTasks, successTasks }); 89 | return { 90 | failureTasks, 91 | successTasks 92 | }; 93 | } 94 | } 95 | 96 | export default Scraper; 97 | -------------------------------------------------------------------------------- /src/renderer/scraper/core/model.ts: -------------------------------------------------------------------------------- 1 | import { js2xml } from "@utils"; 2 | import { INFOModel, IMovieModelType } from "../../types"; 3 | 4 | export default class MovieModel implements IMovieModelType { 5 | model: INFOModel = {}; 6 | 7 | setModel(model: INFOModel): IMovieModelType { 8 | this.model = Object.assign({}, this.model, model); 9 | return this; 10 | } 11 | 12 | getModel(): INFOModel { 13 | return this.model; 14 | } 15 | 16 | getXML() { 17 | return js2xml({ movie: this.model }); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/renderer/scraper/heads/avsox.ts: -------------------------------------------------------------------------------- 1 | import cheerio from 'cheerio'; 2 | import MovieModel from '../core/model'; 3 | import { MediaKeys } from '@types'; 4 | 5 | export default request => ({ 6 | head: async (queryString: string): Promise => { 7 | const movieModel = new MovieModel(); 8 | const encodedQueryString = encodeURIComponent(queryString); 9 | const searchPage = await request({ 10 | url: `https://avsox.cyou/cn/search/${encodedQueryString}` 11 | }); 12 | const infoPageUrl = cheerio 13 | .load(searchPage)('.movie-box') 14 | .attr('href') 15 | .replace(/https:\/\//, 'http://'); 16 | const $ = cheerio.load(await request({ url: infoPageUrl })); 17 | movieModel.setModel({ 18 | title: { 19 | _text: $('h3') 20 | .text() 21 | .trim() 22 | }, 23 | premiered: { 24 | _text: $('.info>p:nth-child(2)') 25 | .text() 26 | .split(': ')[1] 27 | .trim() 28 | }, 29 | art: { 30 | poster: { 31 | _text: $('.bigImage') 32 | .attr('href') 33 | .trim() 34 | .replace(/https:\/\//, 'http://') 35 | }, 36 | fanart: { 37 | _text: $('.bigImage') 38 | .attr('href') 39 | .trim() 40 | .replace(/https:\/\//, 'http://') 41 | } 42 | }, 43 | actor: $('#avatar-waterfall .avatar-box') 44 | .map(index => { 45 | const $img = $('#avatar-waterfall .avatar-box img').eq(index); 46 | const $name = $('#avatar-waterfall .avatar-box>span').eq(index); 47 | return { 48 | name: { 49 | _text: $name.text().trim() 50 | }, 51 | thumb: { 52 | _text: $img 53 | .attr('src') 54 | .trim() 55 | .replace(/https:\/\//, 'http://') 56 | } 57 | }; 58 | }) 59 | .toArray(), 60 | uniqueid: [ 61 | { 62 | _attributes: { type: '1', default: true }, 63 | _text: $('.info>p:nth-child(1)>span:nth-child(2)') 64 | .text() 65 | .trim() 66 | } 67 | ], 68 | genre: $('.info .genre>a') 69 | .map((index, $genre) => ({ _text: $genre.firstChild.data.trim() })) 70 | .toArray() 71 | }); 72 | return movieModel; 73 | }, 74 | name: 'avsox', 75 | type: [MediaKeys.Movie, MediaKeys.Uncensored] 76 | }); 77 | -------------------------------------------------------------------------------- /src/renderer/scraper/heads/index.ts: -------------------------------------------------------------------------------- 1 | import r from "../request"; 2 | import { IToolHead } from "@types"; 3 | import tmdb from "./tmdb"; 4 | import javbus from "./javbus"; 5 | import avsox from "./avsox"; 6 | import javbusUncensored from "./javbus_uncensored"; 7 | 8 | const heads: IToolHead[] = [tmdb(r), avsox(r), javbus(r), javbusUncensored(r)]; 9 | export default heads; 10 | -------------------------------------------------------------------------------- /src/renderer/scraper/heads/javbus.ts: -------------------------------------------------------------------------------- 1 | import cheerio from 'cheerio'; 2 | import MovieModel from '../core/model'; 3 | import { MediaKeys } from '@types'; 4 | 5 | export default request => ({ 6 | head: async (queryString: string): Promise => { 7 | const movieModel = new MovieModel(); 8 | const encodedQueryString = encodeURIComponent(queryString); 9 | const searchPage = await request({ 10 | url: `http://www.javbus.com/search/${encodedQueryString}` 11 | }); 12 | const infoPageUrl = cheerio 13 | .load(searchPage)('.movie-box') 14 | .attr('href') 15 | .replace(/https:\/\//, 'http://'); 16 | const $ = cheerio.load(await request({ url: infoPageUrl })); 17 | movieModel.setModel({ 18 | title: { 19 | _text: $('h3') 20 | .text() 21 | .trim() 22 | }, 23 | premiered: { 24 | _text: $('.info>p:nth-child(2)') 25 | .text() 26 | .split(': ')[1] 27 | .trim() 28 | }, 29 | art: { 30 | poster: { 31 | _text: $('.bigImage') 32 | .attr('href') 33 | .trim() 34 | .replace(/https:\/\//, 'http://') 35 | }, 36 | fanart: { 37 | _text: $('.bigImage') 38 | .attr('href') 39 | .trim() 40 | .replace(/https:\/\//, 'http://') 41 | } 42 | }, 43 | actor: $('.info>ul li img') 44 | .map((index, $actor) => ({ 45 | name: { _text: $actor.attribs.title.trim() }, 46 | thumb: { 47 | _text: $actor.attribs.src.trim().replace(/https:\/\//, 'http://') 48 | } 49 | })) 50 | .toArray(), 51 | uniqueid: [ 52 | { 53 | _attributes: { type: '1', default: true }, 54 | _text: $('.info>p:nth-child(1)>span:nth-child(2)') 55 | .text() 56 | .trim() 57 | } 58 | ], 59 | genre: $('.info .genre>a') 60 | .map((index, $actor) => ({ _text: $actor.firstChild.data.trim() })) 61 | .toArray() 62 | }); 63 | return movieModel; 64 | }, 65 | name: 'javbus(骑兵)', 66 | type: [MediaKeys.Movie, MediaKeys.Gentleman] 67 | }); 68 | -------------------------------------------------------------------------------- /src/renderer/scraper/heads/javbus_uncensored.ts: -------------------------------------------------------------------------------- 1 | import cheerio from 'cheerio'; 2 | import MovieModel from '../core/model'; 3 | import { MediaKeys } from '@types'; 4 | 5 | export default request => ({ 6 | head: async (queryString: string): Promise => { 7 | const movieModel = new MovieModel(); 8 | const encodedQueryString = encodeURIComponent(queryString); 9 | const searchPage = await request({ 10 | url: `http://www.javbus.com/uncensored/search/${encodedQueryString}` 11 | }); 12 | const infoPageUrl = cheerio 13 | .load(searchPage)('.movie-box') 14 | .attr('href') 15 | .replace(/https:\/\//, 'http://'); 16 | const $ = cheerio.load(await request({ url: infoPageUrl })); 17 | movieModel.setModel({ 18 | title: { 19 | _text: $('h3') 20 | .text() 21 | .trim() 22 | }, 23 | premiered: { 24 | _text: $('.info>p:nth-child(2)') 25 | .text() 26 | .split(': ')[1] 27 | .trim() 28 | }, 29 | art: { 30 | poster: { 31 | _text: $('.bigImage') 32 | .attr('href') 33 | .trim() 34 | .replace(/https:\/\//, 'http://') 35 | }, 36 | fanart: { 37 | _text: $('.bigImage') 38 | .attr('href') 39 | .trim() 40 | .replace(/https:\/\//, 'http://') 41 | } 42 | }, 43 | actor: $('.info>ul li img') 44 | .map((index, $actor) => ({ 45 | name: { _text: $actor.attribs.title.trim() }, 46 | thumb: { 47 | _text: $actor.attribs.src.trim().replace(/https:\/\//, 'http://') 48 | } 49 | })) 50 | .toArray(), 51 | uniqueid: [ 52 | { 53 | _attributes: { type: '1', default: true }, 54 | _text: $('.info>p:nth-child(1)>span:nth-child(2)') 55 | .text() 56 | .trim() 57 | } 58 | ], 59 | genre: $('.info .genre>a') 60 | .map((index, $actor) => ({ _text: $actor.firstChild.data.trim() })) 61 | .toArray() 62 | }); 63 | return movieModel; 64 | }, 65 | name: 'javbus(步兵)', 66 | type: [MediaKeys.Movie, MediaKeys.Uncensored] 67 | }); 68 | -------------------------------------------------------------------------------- /src/renderer/scraper/heads/tmdb.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | import MovieModel from "../core/model"; 3 | import { IMovieModelType, MediaKeys } from "@types"; 4 | 5 | export default request => ({ 6 | head: async (queryString: string): Promise => { 7 | const movieModel = new MovieModel(); 8 | const baseUrl = "http://api.themoviedb.org/3"; 9 | const baseImgUrl = "http://image.tmdb.org/t/p"; 10 | const baseParam = { 11 | api_key: "72b18d1a0b41c17728448bfb2b922d26", 12 | language: "zh" 13 | }; 14 | const res = await request({ 15 | url: `${baseUrl}/search/movie`, 16 | qs: { 17 | ...baseParam, 18 | query: queryString 19 | }, 20 | json: true 21 | }); 22 | 23 | if (!res.total_results) { 24 | throw new Error("无影片信息"); 25 | } 26 | const { id } = res.results[0]; 27 | const info = await request({ 28 | url: `${baseUrl}/movie/${id}`, 29 | qs: { 30 | ...baseParam 31 | }, 32 | json: true 33 | }); 34 | const actors = await request({ 35 | url: `${baseUrl}/movie/${id}/credits`, 36 | qs: baseParam, 37 | json: true 38 | }).then(r => r.cast); 39 | const { 40 | title, 41 | release_date, 42 | overview, 43 | poster_path, 44 | backdrop_path, 45 | genres 46 | } = info; 47 | movieModel.setModel({ 48 | title: { _text: title }, 49 | plot: { 50 | _text: overview 51 | }, 52 | premiered: release_date, 53 | art: { 54 | poster: { _text: `${baseImgUrl}/w500/${poster_path}` }, 55 | fanart: { _text: `${baseImgUrl}/w1280/${backdrop_path}` } 56 | }, 57 | actor: actors.map(a => ({ 58 | name: { 59 | _text: a.name 60 | }, 61 | thumb: { 62 | _text: `${baseImgUrl}/w185/${a.profile_path}` 63 | } 64 | })), 65 | uniqueid: [ 66 | { 67 | _attributes: { 68 | default: true, 69 | type: "1" 70 | }, 71 | _text: id 72 | } 73 | ], 74 | genre: genres.map(g => ({ _text: g.name })) 75 | }); 76 | return movieModel; 77 | }, 78 | name: "TMDB", 79 | type: [MediaKeys.Movie, MediaKeys.Normal] 80 | }); 81 | -------------------------------------------------------------------------------- /src/renderer/scraper/index.ts: -------------------------------------------------------------------------------- 1 | import Scraper from './core/index'; 2 | import heads from './heads'; 3 | import mediaType, { 4 | getHeadsByMediaType, 5 | getRegularByMediaType 6 | } from './mediaType'; 7 | 8 | const scraper = new Scraper(); 9 | scraper.loadHead(heads); 10 | export default scraper; 11 | export { heads, mediaType, getHeadsByMediaType, getRegularByMediaType }; 12 | -------------------------------------------------------------------------------- /src/renderer/scraper/mediaType.ts: -------------------------------------------------------------------------------- 1 | import { MediaKeys, IMediaTypeNode, IToolHead } from "@types"; 2 | import heads from "./heads"; 3 | 4 | const mediaType: IMediaTypeNode[] = [ 5 | { 6 | value: MediaKeys.Movie, 7 | label: "电影", 8 | children: [ 9 | { 10 | value: MediaKeys.Gentleman, 11 | label: "绅士(骑兵)" 12 | }, 13 | { 14 | value: MediaKeys.Uncensored, 15 | label: "绅士(步兵)" 16 | }, 17 | { 18 | value: MediaKeys.Normal, 19 | label: "普通" 20 | } 21 | ] 22 | } 23 | ]; 24 | export default mediaType; 25 | 26 | const mediaSource = heads.reduce((acc, head) => { 27 | const sourceId = head.type.join("$$"); 28 | if (acc[sourceId]) { 29 | acc[sourceId].push(head); 30 | } else { 31 | acc[sourceId] = [head]; 32 | } 33 | return acc; 34 | }, {}); 35 | const regular = { 36 | [`${MediaKeys.Movie}$$${MediaKeys.Normal}`]: /[a-zA-Z0-9:\u4e00-\u9fa5]+/, 37 | [`${MediaKeys.Movie}$$${MediaKeys.Gentleman}`]: /\d{3,10}(_|-)\d{3,10}|[a-z]{3,10}(_|-)(\d|[a-z]){3,10}/i, 38 | [`${MediaKeys.Movie}$$${MediaKeys.Uncensored}`]: /\d{3,10}(_|-)\d{3,10}|[a-z]{3,10}(_|-)(\d|[a-z]){3,10}/i, 39 | [MediaKeys.Music]: /[a-z]/ 40 | }; 41 | export const getHeadsByMediaType: (type: string[]) => IToolHead[] = type => { 42 | const sourceId = type.join("$$"); 43 | return mediaSource[sourceId] || []; 44 | }; 45 | export const getRegularByMediaType: (type: string[]) => RegExp = type => { 46 | const sourceId = type.join("$$"); 47 | return regular[sourceId]; 48 | }; 49 | -------------------------------------------------------------------------------- /src/renderer/scraper/request.ts: -------------------------------------------------------------------------------- 1 | import request from 'request-promise'; 2 | import config from '@config'; 3 | 4 | export default opts => { 5 | const proxy = config.get('proxy'); 6 | const url = proxy.enable ? proxy.url : ''; 7 | return request({ ...opts, proxy: url }); 8 | }; 9 | -------------------------------------------------------------------------------- /src/renderer/store/configureStore.ts: -------------------------------------------------------------------------------- 1 | import { createStore, compose } from "redux"; 2 | import rootReducer from "../reducers"; 3 | 4 | declare global { 5 | /* eslint-disable-next-line */ 6 | interface Window { 7 | __REDUX_DEVTOOLS_EXTENSION__: any; 8 | } 9 | } 10 | export function configureStore(init?: any) { 11 | return createStore( 12 | rootReducer(), 13 | init, 14 | compose( 15 | window.__REDUX_DEVTOOLS_EXTENSION__ 16 | ? window.__REDUX_DEVTOOLS_EXTENSION__() 17 | : f => f 18 | ) 19 | ); 20 | } 21 | export default {}; 22 | -------------------------------------------------------------------------------- /src/renderer/types/event.ts: -------------------------------------------------------------------------------- 1 | export enum EventType { 2 | SCRAPE_SUCCESS = 'scrape_success', 3 | SCRAPE_FAIL = 'scrape_fail', 4 | SCRAPE_TASK_END = 'scrape_task_end', 5 | SCRAPE_PENDING = 'scrape_pending' 6 | } 7 | export default {}; 8 | -------------------------------------------------------------------------------- /src/renderer/types/index.ts: -------------------------------------------------------------------------------- 1 | export { INFOModel, Actor, Uniqueid } from "./nfo"; 2 | export { EventType } from "./event"; 3 | export { 4 | IQueryOpt, 5 | IMovieModelType, 6 | IToolHead, 7 | IHead, 8 | IFileNode, 9 | IMediaTypeNode, 10 | MediaKeys 11 | } from "./scraper"; 12 | -------------------------------------------------------------------------------- /src/renderer/types/nfo.ts: -------------------------------------------------------------------------------- 1 | export type Actor = { 2 | /** 3 | * 名字 4 | */ 5 | name: { _text: string }; 6 | /** 7 | * 角色 8 | */ 9 | role?: { _text: string }; 10 | /** 11 | * 排序 12 | */ 13 | order?: { _text: string }; 14 | /** 15 | * 图片 16 | */ 17 | thumb?: { _text: string }; 18 | }; 19 | export type Uniqueid = { 20 | /** 加入到标签属性中的key */ 21 | _attributes: { 22 | /** 23 | * 平台 24 | */ 25 | type: string; 26 | default?: boolean; 27 | }; 28 | /** 29 | * 标签的值 30 | */ 31 | _text: string; 32 | }; 33 | export interface INFOModel { 34 | /** 35 | * ID,可多个平台多个ID 36 | */ 37 | uniqueid?: Uniqueid[]; 38 | /** 39 | * 标题 40 | */ 41 | title?: { _text: string }; 42 | /** 43 | * 短标题 44 | */ 45 | originaltitle?: { _text: string }; 46 | /** 47 | * 年份 48 | */ 49 | premiered?: { _text: string }; 50 | /** 51 | * 年份,推荐使用premiered,这个是为了兼容 52 | */ 53 | year?: { _text: string }; 54 | /** 55 | * 简介 56 | */ 57 | plot?: { _text: string }; 58 | /** movie slogan */ 59 | tagline?: { _text: string }; 60 | /** 61 | * 电影时长,只支持分钟 62 | */ 63 | runtime?: { _text: number }; 64 | /** 65 | * MPAA电影分级 66 | */ 67 | mpaa?: { _text: string }; 68 | /** 69 | * 类型 70 | */ 71 | genre?: { _text: string }[]; 72 | /** 73 | * 制作商 74 | */ 75 | studio?: { _text: string }; 76 | /** 标签 */ 77 | tag?: { _text: string }[]; 78 | /** 79 | * art 80 | */ 81 | art?: { 82 | /** 83 | * 海报 84 | */ 85 | poster?: { _text: string }; 86 | /** 87 | * 同人画 88 | */ 89 | fanart?: { _text: string }; 90 | }; 91 | /** 92 | * actor 93 | */ 94 | actor?: Actor[]; 95 | /** 96 | * 文件信息 97 | */ 98 | fileinfo?: { 99 | /** 100 | * 流信息 101 | */ 102 | streamdetails?: { 103 | /** 104 | * 视频流信息 105 | */ 106 | video: { 107 | /** 108 | * 编解码器 109 | */ 110 | codec: string; 111 | /** 112 | * 宽高比 113 | */ 114 | aspect: string; 115 | /** 116 | * 宽度 117 | */ 118 | width: number; 119 | /** 120 | * 高度 121 | */ 122 | height: number; 123 | /** 124 | * 时长:秒 125 | */ 126 | durationinseconds: number; 127 | }; 128 | /** 129 | * 音频流信息 130 | */ 131 | audio: { 132 | /** 133 | * 编解码器 134 | */ 135 | codec: string; 136 | /** 137 | * 语言 138 | */ 139 | language: string; 140 | /** 141 | * 声道数(1-8) 142 | */ 143 | channels: number; 144 | }; 145 | }; 146 | }; 147 | dateadded?: string; 148 | } 149 | -------------------------------------------------------------------------------- /src/renderer/types/scraper.ts: -------------------------------------------------------------------------------- 1 | import { INFOModel } from "./nfo"; 2 | 3 | export interface IMovieModelType { 4 | model: INFOModel; 5 | setModel(model: INFOModel): IMovieModelType; 6 | getModel(): INFOModel; 7 | getXML(); 8 | } 9 | export interface IHead { 10 | (queryString: string): Promise; 11 | } 12 | export interface IToolHead { 13 | name: string; 14 | head: IHead; 15 | type: string[]; 16 | } 17 | export interface IQueryOpt { 18 | queryString: string; 19 | file: IFileNode; 20 | } 21 | export interface IFileNode { 22 | title: string; 23 | key: string; 24 | children: IFileNode[] | [] | null; 25 | } 26 | 27 | export enum MediaKeys { 28 | Music = "music", 29 | Jav = "jav", 30 | Movie = "movie", 31 | Gentleman = "gentleman", 32 | Normal = "normal", 33 | Uncensored = "uncensored" 34 | } 35 | 36 | export interface IMediaTypeNode { 37 | value: string; 38 | label: string; 39 | children?: IMediaTypeNode[]; 40 | } 41 | -------------------------------------------------------------------------------- /src/renderer/utils/emitter.ts: -------------------------------------------------------------------------------- 1 | import EventEmitter from 'events'; 2 | 3 | const emitter = new EventEmitter(); 4 | 5 | export default emitter; 6 | -------------------------------------------------------------------------------- /src/renderer/utils/index.ts: -------------------------------------------------------------------------------- 1 | // import * as R from 'ramda'; 2 | import fs, { readFileSync, writeFileSync, readdirSync } from "fs-extra"; 3 | import path from "path"; 4 | import { message } from "antd"; 5 | import _emitter from "./emitter"; 6 | import { IFileNode, INFOModel } from "@types"; 7 | import { xml2js, js2xml } from "./xml"; 8 | 9 | import request from "request"; 10 | 11 | export { takeScreenshots } from "./video"; 12 | export { js2xml, xml2js } from "./xml"; 13 | 14 | export function isDir(spath: string) { 15 | const stats = fs.statSync(spath); 16 | return stats.isDirectory(); 17 | } 18 | export const generateFileTree = (paths: Array): IFileNode[] => { 19 | const result = []; 20 | let fileCount = 0; 21 | function walk(wpath, key) { 22 | let walkRes = { 23 | title: "", 24 | ext: "", 25 | isDir: false, 26 | key, 27 | children: [], 28 | wpath, 29 | fullpath: wpath 30 | }; 31 | if (isDir(wpath)) { 32 | walkRes.title = path.basename(wpath); 33 | walkRes.isDir = true; 34 | const wpaths = fs.readdirSync(wpath); 35 | let needDel = true; 36 | wpaths.forEach((dpath, index) => { 37 | const ddpath = path.join(wpath, dpath); 38 | const child = walk(ddpath, `${key}-${index}`); 39 | if (child) { 40 | walkRes.children.push(child); 41 | needDel = false; 42 | } 43 | }); 44 | if (needDel) { 45 | walkRes = null; 46 | } 47 | } else if (/(.mp4|.rmvb|.avi|.wmv)$/.test(wpath)) { 48 | const name = path.basename(wpath).split("."); 49 | walkRes = { 50 | title: name[0], 51 | ext: name[1], 52 | key, 53 | isDir: false, 54 | children: null, 55 | wpath: `${path.dirname(wpath)}/`, 56 | fullpath: wpath 57 | }; 58 | fileCount += 1; 59 | } else { 60 | walkRes = null; 61 | } 62 | 63 | return walkRes; 64 | } 65 | paths.forEach((spath, index) => { 66 | result.push(walk(spath, `0-${index}`)); 67 | }); 68 | if (fileCount === 0) { 69 | message.error("no media file"); 70 | throw new Error("no media file"); 71 | } 72 | return result; 73 | }; 74 | 75 | export function flatTrees(trees: Record[]): Record { 76 | const result = {}; 77 | function walk(arr) { 78 | arr.forEach(node => { 79 | result[node.key] = node; 80 | if (node.children) { 81 | walk(node.children); 82 | } 83 | }); 84 | } 85 | walk(trees); 86 | return result; 87 | } 88 | 89 | export const sleep = time => 90 | new Promise(resolve => { 91 | // 成功执行 92 | try { 93 | setTimeout(() => { 94 | resolve(); 95 | }, time); 96 | } catch (err) { 97 | console.log(err); 98 | } 99 | }); 100 | 101 | export const downloadImg = (url, ipath, opt?) => 102 | new Promise((resolve, reject) => { 103 | request({ url, ...opt }) 104 | .pipe(fs.createWriteStream(ipath)) 105 | .on("finish", () => { 106 | resolve(ipath); 107 | }) 108 | .on("error", e => { 109 | reject(e); 110 | }); 111 | }); 112 | 113 | export const getDefaultOsPath = () => { 114 | if (process.platform === "win32") { 115 | return "C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe"; 116 | } 117 | return "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"; 118 | }; 119 | 120 | export const emitter = _emitter; 121 | 122 | export const readMediaInfoFromNFOSync = (NFOFile: string): INFOModel => { 123 | type Info = { 124 | movie: INFOModel; 125 | }; 126 | const xml = readFileSync(NFOFile, { encoding: "utf8" }); 127 | const { movie } = xml2js(xml) as Info; 128 | return { 129 | ...movie, 130 | genre: movie.genre 131 | ? Array.isArray(movie.genre) 132 | ? movie.genre 133 | : [movie.genre] 134 | : [], 135 | actor: movie.actor 136 | ? Array.isArray(movie.actor) 137 | ? movie.actor 138 | : [movie.actor] 139 | : [], 140 | tag: movie.tag 141 | ? Array.isArray(movie.tag) 142 | ? movie.tag 143 | : [movie.tag] 144 | : [] 145 | }; 146 | }; 147 | export const writeMediaInfoToNFOSync = ( 148 | ipath: string, 149 | data: INFOModel 150 | ): void => { 151 | const base = { 152 | _declaration: { 153 | _attributes: { version: "1.0", encoding: "utf-8", standalone: "yes" } 154 | } 155 | }; 156 | const json = Object.assign({}, base, { movie: data }); 157 | const xml = js2xml(json); 158 | writeFileSync(ipath, xml, "utf8"); 159 | }; 160 | export const readThumbnails = wpath => { 161 | const tbPath = path.join(wpath, ".thumbnails"); 162 | console.log(readdirSync(tbPath)); 163 | return (readdirSync(tbPath) || []) 164 | .sort((n1, n2) => parseInt(n1, 10) - parseInt(n2, 10)) 165 | .map(name => path.join(tbPath, name)); 166 | }; 167 | -------------------------------------------------------------------------------- /src/renderer/utils/video.ts: -------------------------------------------------------------------------------- 1 | import ffmpeg from "fluent-ffmpeg"; 2 | import { ensureDirSync } from "fs-extra"; 3 | /** 4 | * multiple screenshots very slow 5 | * https://github.com/fluent-ffmpeg/node-fluent-ffmpeg/issues/860 6 | */ 7 | export const takeScreenshots = ({ 8 | file, 9 | count, 10 | folder = "", 11 | size = "800x?" 12 | }) => { 13 | try { 14 | ensureDirSync(folder); 15 | } catch (err) { 16 | console.error(err); 17 | } 18 | const walk = mark => 19 | new Promise(resolve => { 20 | ffmpeg(file) 21 | .screenshots({ 22 | count: 1, 23 | folder, 24 | timemarks: [mark], 25 | filename: `%s.jpg`, 26 | size 27 | }) 28 | .on("end", () => { 29 | resolve(mark); 30 | }); 31 | }); 32 | return new Promise(resolve => { 33 | ffmpeg(file).ffprobe((err, meta) => { 34 | if (err) return; 35 | const { duration } = meta.format; 36 | const interval = Math.ceil(duration / (count + 1)); 37 | const timemarks = new Array(count) 38 | .fill(0) 39 | .map((v, i) => interval * (i + 1)); 40 | Promise.all(timemarks.map(mark => walk(mark))) 41 | .then(() => resolve(file)) 42 | .catch(error => { 43 | console.error(error); 44 | }); 45 | }); 46 | }); 47 | }; 48 | -------------------------------------------------------------------------------- /src/renderer/utils/xml.ts: -------------------------------------------------------------------------------- 1 | import xmljs from 'xml-js'; 2 | 3 | export const js2xml = data => xmljs.js2xml(data, { compact: true, spaces: 4 }); 4 | export const xml2js = xml => xmljs.xml2js(xml, { compact: true }); 5 | -------------------------------------------------------------------------------- /src/shared/custom.d.ts: -------------------------------------------------------------------------------- 1 | //for typescript-plugin-css-modules 2 | // https://github.com/mrmckeb/typescript-plugin-css-modules#custom-definitions 3 | declare module "*.less" { 4 | const classes: { [key: string]: string }; 5 | export default classes; 6 | } 7 | 8 | // for Webpack Entry 9 | declare let MAIN_WINDOW_WEBPACK_ENTRY: any; 10 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "lib": ["dom", "dom.iterable", "esnext", "es2015", "es2017"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "sourceMap": true, 8 | "esModuleInterop": true, 9 | "allowSyntheticDefaultImports": true, 10 | "emitDecoratorMetadata": true, 11 | "experimentalDecorators": true, 12 | "strict": false, 13 | "forceConsistentCasingInFileNames": true, 14 | "module": "commonjs", 15 | "moduleResolution": "node", 16 | "resolveJsonModule": true, 17 | "isolatedModules": true, 18 | "noEmit": true, 19 | "jsx": "react", 20 | "baseUrl": ".", 21 | "paths": { 22 | "@/*": ["src/*"], 23 | "@main/*": ["src/main/*"], 24 | "@renderer/*": ["src/renderer/*"], 25 | "@actions": ["src/renderer/actions"], 26 | "@types": ["src/renderer/types"], 27 | "@components/*": ["src/renderer/components/*"], 28 | "@config": ["src/renderer/config-store"], 29 | "@constants/*": ["src/renderer/constants/*"], 30 | "@utils": ["src/renderer/utils"], 31 | "@scraper": ["src/renderer/scraper"], 32 | "@lib/*": ["src/renderer/lib/*"] 33 | }, 34 | "plugins": [{ "name": "typescript-plugin-css-modules" }] 35 | }, 36 | "include": ["src"], 37 | "exclude": ["node_modules"] 38 | } 39 | --------------------------------------------------------------------------------