├── .github └── workflows │ ├── docker.yml │ └── pypipublish.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── MANIFEST.in ├── README.md ├── config-test.ini ├── config.ini.example ├── install.sh ├── pdm.lock ├── pyproject.toml ├── requirements.txt ├── setup.py ├── src └── differential │ ├── __init__.py │ ├── base_plugin.py │ ├── commands.py │ ├── constants.py │ ├── main.py │ ├── plugin_loader.py │ ├── plugin_register.py │ ├── plugins │ ├── __init__.py │ ├── chdbits.py │ ├── chdbits_encode.py │ ├── gazelle.py │ ├── greatposterwall.py │ ├── hdbits.py │ ├── league_official.py │ ├── lemonhd.py │ ├── nexusphp.py │ ├── pterclub.py │ ├── ptp.py │ └── unit3d.py │ ├── tools │ ├── BDinfoCli.0.7.3 │ │ ├── BDInfo.exe │ │ ├── DiscUtils.Common.dll │ │ ├── DiscUtils.dll │ │ ├── Microsoft.WindowsAPICodePack.Shell.dll │ │ ├── Microsoft.WindowsAPICodePack.ShellExtensions.dll │ │ ├── Microsoft.WindowsAPICodePack.dll │ │ └── ZedGraph.dll │ └── __init__.py │ ├── torrent.py │ ├── utils │ ├── __init__.py │ ├── binary.py │ ├── browser.py │ ├── config.py │ ├── image │ │ ├── __init__.py │ │ ├── byr.py │ │ ├── chevereto.py │ │ ├── cloudinary.py │ │ ├── hdbits.py │ │ ├── imgbox.py │ │ ├── imgurl.py │ │ ├── ptpimg.py │ │ ├── smms.py │ │ └── types.py │ ├── mediainfo.py │ ├── mediainfo_handler.py │ ├── nfo.py │ ├── parse.py │ ├── ptgen │ │ ├── bangumi.py │ │ ├── base.py │ │ ├── douban.py │ │ ├── epic.py │ │ ├── imdb.py │ │ ├── indienova.py │ │ ├── parser.py │ │ └── steam.py │ ├── ptgen_handler.py │ ├── screenshot_handler.py │ ├── torrent.py │ └── uploader │ │ ├── __init__.py │ │ ├── auto_feed.py │ │ └── easy_upload.py │ └── version.py └── usage.gif /.github/workflows/docker.yml: -------------------------------------------------------------------------------- 1 | name: Docker 2 | 3 | on: 4 | release: 5 | types: [ created ] 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Checkout repository 12 | uses: actions/checkout@v2 13 | 14 | - name: Set up QEMU (for cross-platform builds) 15 | uses: docker/setup-qemu-action@v3 16 | 17 | - name: Set up Docker Buildx 18 | uses: docker/setup-buildx-action@v3 19 | 20 | - name: Login to DockerHub 21 | uses: docker/login-action@v3 22 | with: 23 | username: ${{ secrets.REGISTRY_USERNAME }} 24 | password: ${{ secrets.REGISTRY_TOKEN }} 25 | 26 | - name: Build and push Docker images 27 | uses: docker/build-push-action@v2 28 | with: 29 | build-args: | 30 | "PDM_BUILD_SCM_VERSION=${{ github.event.release.tag_name }}" 31 | platforms: linux/amd64,linux/arm64 32 | push: true 33 | tags: | 34 | leishi1313/differential:latest 35 | leishi1313/differential:${{ github.event.release.tag_name }} -------------------------------------------------------------------------------- /.github/workflows/pypipublish.yml: -------------------------------------------------------------------------------- 1 | 2 | name: Upload Python Package to PyPi 3 | 4 | on: 5 | release: 6 | types: [created] 7 | 8 | jobs: 9 | pypi-publish: 10 | name: upload release to PyPI 11 | runs-on: ubuntu-latest 12 | permissions: 13 | # This permission is needed for private repositories. 14 | contents: read 15 | # IMPORTANT: this permission is mandatory for trusted publishing 16 | id-token: write 17 | steps: 18 | - uses: actions/checkout@v4 19 | 20 | - uses: pdm-project/setup-pdm@v4 21 | 22 | - name: Publish package distributions to PyPI 23 | env: 24 | PDM_BUILD_SCM_VERSION: ${{ github.event.release.tag_name }} 25 | run: pdm publish -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | .venv/ 110 | ENV/ 111 | env.bak/ 112 | venv.bak/ 113 | 114 | # Spyder project settings 115 | .spyderproject 116 | .spyproject 117 | 118 | # Rope project settings 119 | .ropeproject 120 | 121 | # mkdocs documentation 122 | /site 123 | 124 | # mypy 125 | .mypy_cache/ 126 | .dmypy.json 127 | dmypy.json 128 | 129 | # Pyre type checker 130 | .pyre/ 131 | 132 | # Intellij 133 | .idea/ 134 | 135 | # For development 136 | .vscode 137 | test.py 138 | *.log 139 | config.ini 140 | differential/version.py 141 | .pdm-build/ 142 | 143 | # windsurf rules 144 | .windsurfrules 145 | 146 | # next release 147 | gui.py -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM debian:stable-slim 2 | 3 | ARG PDM_BUILD_SCM_VERSION 4 | 5 | RUN mkdir -p /app 6 | COPY . /app 7 | WORKDIR /app 8 | RUN chmod +x install.sh 9 | RUN ./install.sh 10 | ENV PATH=$PATH:/root/.local/bin 11 | RUN pipx install pdm 12 | RUN PDM_BUILD_SCM_VERSION=${PDM_BUILD_SCM_VERSION} pdm install --check --prod --no-editable --global --project . 13 | 14 | CMD ["/root/.local/bin/dft"] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Lei Shi 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 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include pyproject.toml 2 | exclude usage.gif 3 | include differential/tools/**/* -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Differential 差速器 2 | 一个可以自动生成PTGen,MediaInfo,截图,并且生成发种所需内容的脚本 3 | 4 | 5 | # 为什么叫差速器 6 | 差速器是汽车上的一种能使左、右轮胎以不同转速转动的结构。使用同样的动力输入,差速器能够输出不同的转速。就如同这个工具之于PT资源,差速器帮你使用同一份资源,输出不同PT站点需要的发种数据。 7 | 8 | # 差速器能做什么? 9 | 当把大部分配置填好时,你可以仅提供资源文件的路径和一个豆瓣链接,差速器会帮你生成发种所需要的影片信息,Mediainfo,截图并上传图床,nfo文件,种子文件,并自动填写发种页面的表单( 感谢树大的[脚本](https://github.com/techmovie/easy-upload)和明日大佬的[脚本](https://github.com/tomorrow505/auto_feed_js) ) 10 | 11 | ![](./usage.gif) 12 | 13 | 14 | # 如何安装差速器 15 | 16 | > 可以移步我的[博客](https://2cn.io/dft)查看详细的教程 17 | > 也可以到[Telegram群组](https://t.me/ptdft)来讨论各种问题 18 | 19 | ## Linux 20 | 21 | ### 一键脚本安装 22 | 对于`Debian 9+`/`Ubuntu 20.04+`/`Centos 8`/`Fedora 34+`/`Archlinux`,可以使用一键脚本安装 23 | ```shell 24 | curl -Lso- https://raw.githubusercontent.com/LeiShi1313/Differential/main/install.sh | bash 25 | ``` 26 | 27 | ### 手动安装 28 | 按照[这个](https://www.mono-project.com/download/stable/#download-lin)页面,安装Mono 29 | 30 | ```shell 31 | # 安装ffmpeg和mediainfo 32 | sudo apt install ffmpeg mediainfo zlib1g-dev libjpeg-dev 33 | pip3 install Differential 34 | ``` 35 | 36 | ## Windows 37 | 38 | 安装下载并安装Python和ffmpeg,然后把ffmpeg放到Path或者你的工作目录,确认在你的工作目录`ffmpeg.exe -version`有正确输出。 39 | 40 | ```PowerShell 41 | pip.exe install Differential 42 | ``` 43 | 44 | ## Mac OS 45 | 按照[这个](https://www.mono-project.com/docs/getting-started/install/mac/)页面,安装Mono 46 | 47 | ```shell 48 | # 安装ffmpeg、mediainfo 49 | brew install ffmpeg mediainfo pipx 50 | pipx ensurepath 51 | pipx install Differential 52 | ``` 53 | 54 | ## Docker 55 | 56 | ```shell 57 | docker pull leishi1313:differential 58 | docker run --rm -v [你的媒体文件夹]:[媒体文件夹路径] -v ./config.ini:/app/config.ini leishi1313:differential dft -h 59 | ``` 60 | 61 | 62 | # 如何使用差速器 63 | 64 | 差速器支持未经过重大修改的NexusPHP/Gazelle/Unit3D站点以及部分支持[easy-upload](https://github.com/techmovie/easy-upload)和[auto_feed_js](https://github.com/tomorrow505/auto_feed_js)支持的站点。 65 | 在使用前,请先使用`dft -h`查看本工具支持的站点/现有的插件。 66 | 67 | 请先参考`config.ini.example`,在`Default`块填上各个站点/插件通用的参数,比如图床相关的几个参数,然后在各站点/插件名字对应的块填上各自特有的参数,比如截图张数等等。 68 | 69 | 当配置文件完成后,你可以通过以下命令,一键获取发种所需要的信息。当然你也可以选择通过命令行来传递所有参数。 70 | ```shell 71 | dft [插件名字] -f [种子文件夹] -u [豆瓣URL] 72 | ``` 73 | 74 | 主要参数介绍: 75 | 76 | - `config`: 配置文件的位置,默认读取当前文件夹下的`config.ini` 77 | - `log`: log文件的路径 78 | - `folder`: 种子文件或文件夹的路径 79 | - `url`: 影片的豆瓣链接,事实上,所有PTGen支持的链接这里都支持 80 | - `upload_url`: 发种页面的地址 81 | - `make_torrent`: 是否制种,默认关闭 82 | - `geenrate_nfo`: 是否利用mediainfo生成nfo文件,默认关闭 83 | - `use_short_bdinfo`: 是否使用BDInfo的Quick Summary,默认使用完整的BDInfo 84 | - `screenshot_count`: 截图生成的张数,默认为0,即不生成截图 85 | - `image_hosting`: 图床的名称,现在支持ptpimg,chevereto,imgurl和SM.MS 86 | - `image_hosting_url`: 如果是自建的图床,提供图床链接 87 | - `ptgen_url`: PTGen的地址,默认是我自建的PTGen,可能会不稳定 88 | - `announce_url`: 制种时的announce地址 89 | - `encoder_log`: 压制log的地址,如果提供的话会在介绍的mediainfo部分附上压制log 90 | - `easy_upload`: 默认关闭,开启的话会利用[easy-upload](https://github.com/techmovie/easy-upload)自动填充发种页面表单 91 | - `auto_feed`: 默认关闭,开启的话会利用[auto_feed_js](https://github.com/tomorrow505/auto_feed_js)自动填充发种页面表单 92 | - `trim_description`: 默认关闭,开启的话会省略掉上传链接的描述部分,以避免链接过长浏览器无法打开的问题 93 | - `use_short_url`: 默认关闭,开启的话使用短链接服务把上传链接缩短 94 | 95 | ## 其他插件 96 | 97 | 为保护站点信息,请到[`plugins`](https://github.com/LeiShi1313/Differential/tree/main/differential/plugins)文件夹查看或者`dft [插件名称] -h`查看支持的参数 98 | 99 | 100 | # TODO 101 | - [ ] 更好的出错管理 102 | - [ ] PTGen API Key支持 103 | - [ ] imgbox支持 104 | - [x] 短网址服务 105 | - [x] 识别已经生成过的截图,不重复截图 106 | - [x] 支持扫描原盘BDInfo 107 | -------------------------------------------------------------------------------- /config-test.ini: -------------------------------------------------------------------------------- 1 | [DEFAULT] 2 | screenshot_count = 0 3 | generate_nfo = true 4 | make_torrent = true 5 | easy_upload = true 6 | 7 | [PTerClub] 8 | 9 | [M-Team] 10 | upload_url = https://kp.m-team.cc/upload.php 11 | -------------------------------------------------------------------------------- /config.ini.example: -------------------------------------------------------------------------------- 1 | [DEFAULT] 2 | ; 是否制种 3 | make_torrent = true 4 | 5 | ; 生成截图的数量 6 | screenshot_count = 6 7 | ; 图床,差速器支持PTPIMG、自建imgurl、自建Chevereto(z4a、imgbb、猫柠的图床等)、SM.MS和BYR作为图床 8 | image_hosting = CHEVERETO 9 | ; 自建Chevereto的地址 10 | chevereto_hosting_url = https://XXX.com 11 | ; 自建Chevereto的用户名 12 | chevereto_username = XXXX 13 | ; 自建Chevereto的密码 14 | chevereto_password = YYYY 15 | ; 如果使用PTPIMG作为图床,需要设置API Key 16 | ptpimg_api_key = XXXXXXXXXXXXX 17 | 18 | ; 自动填充使用的脚本,这里使用树大的脚本 19 | easy_upload = true 20 | ; 也可以使用明日大的脚本来进行自动填充 21 | ; auto_feed = true 22 | 23 | ; 使用差速器自带的短网址服务 24 | use_short_url = true 25 | 26 | ; 差速器自带一个自建的PTGen,如果无法访问,可以提供自定义PTGen地址 27 | ;ptgen_url = https://XXXXX.com 28 | 29 | [NexusPHP] 30 | ; 发种页面的链接 31 | upload_url = https://XXXXX.com/upload.php 32 | 33 | 34 | [PTerClub] 35 | 36 | [LemonHD] 37 | 38 | [LeagueOfficial] 39 | screenshot_count = 10 40 | image_hosting = CHEVERETO 41 | chevereto_hosting_url = https://imgbox.xxxxxxx.com 42 | chevereto_username = xxxx@xxxx.com 43 | chevereto_password = xxxxxxxxxxxxxxxxxxxxxx 44 | upload_type = tv 45 | team = LeagueTV 46 | source_name = HDTV 47 | 48 | [CHDBits] 49 | -------------------------------------------------------------------------------- /install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | SUDO=$(if [ $(id -u $whoami) -gt 0 ]; then echo "sudo "; fi) 5 | 6 | get_distribution() { 7 | lsb_dist="" 8 | # Every system that we officially support has /etc/os-release 9 | if [ -r /etc/os-release ]; then 10 | lsb_dist="$(. /etc/os-release && echo "$ID")" 11 | fi 12 | # Returning an empty string here should be alright since the 13 | # case statements don't act unless you provide an actual value 14 | echo "$lsb_dist" 15 | } 16 | 17 | command_exists() { 18 | command -v "$@" > /dev/null 2>&1 19 | } 20 | 21 | reload_path() { 22 | for file in ~/.bashrc ~/.bash_profile ~/.profile; do 23 | if [ -f "$file" ]; then 24 | . "$file" 25 | fi 26 | done 27 | } 28 | 29 | do_install() { 30 | lsb_dist=$( get_distribution ) 31 | lsb_dist="$(echo "$lsb_dist" | tr '[:upper:]' '[:lower:]')" 32 | 33 | case "$lsb_dist" in 34 | ubuntu) 35 | if command_exists lsb_release; then 36 | dist_version="$(lsb_release --codename | cut -f2)" 37 | fi 38 | if [ -z "$dist_version" ] && [ -r /etc/lsb-release ]; then 39 | dist_version="$(. /etc/lsb-release && echo "$DISTRIB_CODENAME")" 40 | fi 41 | case "$dist_version" in 42 | focal) 43 | dist_version="focal" 44 | ;; 45 | bionic) 46 | dist_version="bionic" 47 | ;; 48 | xenial) 49 | dist_version="xenial" 50 | ;; 51 | *) 52 | # Mono is not available starting from Ubuntu 22.04 53 | dist_version="focal" 54 | ;; 55 | esac 56 | ;; 57 | 58 | debian|raspbian) 59 | dist_version="$(sed 's/\/.*//' /etc/debian_version | sed 's/\..*//')" 60 | case "$dist_version" in 61 | 10) 62 | dist_version="buster" 63 | ;; 64 | 9) 65 | dist_version="stretch" 66 | ;; 67 | 8) 68 | dist_version="jessie" 69 | ;; 70 | *) 71 | # Mono is not available starting from Debian 11 72 | dist_version="buster" 73 | ;; 74 | esac 75 | ;; 76 | 77 | centos|rhel|sles|amzn) 78 | if [ -z "$dist_version" ] && [ -r /etc/os-release ]; then 79 | dist_version="$(. /etc/os-release && echo "$VERSION_ID")" 80 | fi 81 | ;; 82 | 83 | *) 84 | if command_exists lsb_release; then 85 | dist_version="$(lsb_release --release | cut -f2)" 86 | fi 87 | if [ -z "$dist_version" ] && [ -r /etc/os-release ]; then 88 | dist_version="$(. /etc/os-release && echo "$VERSION_ID")" 89 | fi 90 | ;; 91 | esac 92 | 93 | case "$lsb_dist" in 94 | ubuntu | debian | raspbian) 95 | echo "正在更新依赖..." 96 | export DEBIAN_FRONTEND=noninteractive 97 | $SUDO apt-get update -qq >/dev/null 98 | pre_reqs="ffmpeg mediainfo zlib1g-dev libjpeg-dev python3 pipx" 99 | 100 | if [ "$lsb_dist" = "ubuntu" ]; then 101 | # TODO ubuntu:18.04 Cannot install due to pymediainfo error, might need install newer python 102 | TZ=Etc/UTC $SUDO apt-get install -y -qq gnupg ca-certificates apt-utils >/dev/null 103 | $SUDO gpg --homedir /tmp --no-default-keyring --keyring /usr/share/keyrings/mono-official-archive-keyring.gpg --keyserver hkp://keyserver.ubuntu.com:80 --recv-keys 3FA7E0328081BFF6A14DA29AA6A19B38D3D831EF 104 | echo "deb [signed-by=/usr/share/keyrings/mono-official-archive-keyring.gpg] https://download.mono-project.com/repo/ubuntu stable-focal main" | $SUDO tee /etc/apt/sources.list.d/mono-official-stable.list 105 | elif [ "$lsb_dist" = "debian" ] || [ "$lsb_dist" = "raspbian" ]; then 106 | $SUDO apt-get install -y -qq apt-transport-https dirmngr gnupg ca-certificates apt-utils >/dev/null 107 | $SUDO gpg --homedir /tmp --no-default-keyring --keyring /usr/share/keyrings/mono-official-archive-keyring.gpg --keyserver hkp://keyserver.ubuntu.com:80 --recv-keys 3FA7E0328081BFF6A14DA29AA6A19B38D3D831EF 108 | echo "deb [signed-by=/usr/share/keyrings/mono-official-archive-keyring.gpg] https://download.mono-project.com/repo/debian stable-buster main" | $SUDO tee /etc/apt/sources.list.d/mono-official-stable.list 109 | 110 | case "$dist_version" in 111 | stretch|jessie) 112 | # TODO: Jessie is haveing some SSL issue 113 | # might need to install openssl as well 114 | # pip is configured with locations that require TLS/SSL, however the ssl module in Python is not available. 115 | echo "正在安装python3.8..." && \ 116 | $SUDO apt-get install -y -qq wget build-essential checkinstall libreadline-gplv2-dev libncursesw5-dev libssl-dev libsqlite3-dev tk-dev libgdbm-dev libc6-dev libbz2-dev libffi-dev zlib1g-dev && \ 117 | pushd /tmp && \ 118 | $SUDO wget https://www.python.org/ftp/python/3.8.12/Python-3.8.12.tgz && \ 119 | $SUDO tar xzf Python-3.8.12.tgz && \ 120 | cd Python-3.8.12 && \ 121 | $SUDO ./configure --enable-optimizations > /dev/null && \ 122 | $SUDO make altinstall 123 | ;; 124 | esac 125 | fi 126 | 127 | echo "正在更新安装包..." && \ 128 | $SUDO apt-get update -qq >/dev/null && \ 129 | echo "正在安装依赖..." && \ 130 | TZ=Etc/UTC $SUDO apt-get install -y -qq $pre_reqs >/dev/null && \ 131 | echo "正在安装Mono..." && \ 132 | $SUDO apt-get install -y -qq mono-devel >/dev/null && \ 133 | echo "正在安装差速器..." 134 | $SUDO pipx ensurepath && reload_path 135 | pipx install Differential 136 | ;; 137 | 138 | centos | fedora | rhel) 139 | if [ "$lsb_dist" = "fedora" ]; then 140 | pkg_manager="dnf" 141 | pre_reqs="ffmpeg mediainfo python3 pipx" 142 | 143 | $SUDO rpm --import "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x3FA7E0328081BFF6A14DA29AA6A19B38D3D831EF" 144 | if [[ $dist_version -gt 28 ]]; then 145 | $SUDO su -c "curl https://download.mono-project.com/repo/centos8-stable.repo | $SUDO tee /etc/yum.repos.d/mono-centos8-stable.repo" 146 | else 147 | $SUDO su -c "curl https://download.mono-project.com/repo/centos7-stable.repo | $SUDO tee /etc/yum.repos.d/mono-centos7-stable.repo" 148 | fi 149 | $SUDO $pkg_manager install -y https://dl.fedoraproject.org/pub/epel/epel-release-latest-8.noarch.rpm 150 | $SUDO $pkg_manager config-manager --enable PowerTools && dnf install --nogpgcheck && \ 151 | $SUDO $pkg_manager install -y https://download1.rpmfusion.org/free/el/rpmfusion-free-release-8.noarch.rpm https://download1.rpmfusion.org/nonfree/el/rpmfusion-nonfree-release-8.noarch.rpm 152 | else 153 | pkg_manager="yum" 154 | pre_reqs="ffmpeg mediainfo python3 pipx zlib-devel libjpeg-devel gcc python3-devel" 155 | 156 | $SUDO $pkg_manager install -y -qq epel-release 157 | case "$dist_version" in 158 | 8) 159 | echo "Enabling PowerTools" && \ 160 | $SUDO dnf -y install dnf-plugins-core 2>&1 > /dev/null && \ 161 | $SUDO dnf upgrade -y 2>&1 > /dev/null && \ 162 | $SUDO dnf -y install https://dl.fedoraproject.org/pub/epel/epel-release-latest-8.noarch.rpm && \ 163 | $SUDO dnf config-manager --set-enabled powertools && \ 164 | $SUDO dnf -y install mediainfo 2>&1 > /dev/null 165 | $SUDO rpmkeys --import "http://keyserver.ubuntu.com/pks/lookup?op=get&search=0x3FA7E0328081BFF6A14DA29AA6A19B38D3D831EF" 166 | $SUDO $pkg_manager install -y https://download1.rpmfusion.org/free/el/rpmfusion-free-release-8.noarch.rpm https://download1.rpmfusion.org/nonfree/el/rpmfusion-nonfree-release-8.noarch.rpm 167 | ;; 168 | 7) 169 | $SUDO rpm --import http://li.nux.ro/download/nux/RPM-GPG-KEY-nux.ro 170 | $SUDO rpm -Uvh http://li.nux.ro/download/nux/dextop/el7/x86_64/nux-dextop-release-0-5.el7.nux.noarch.rpm 171 | $SUDO rpmkeys --import "http://keyserver.ubuntu.com/pks/lookup?op=get&search=0x3FA7E0328081BFF6A14DA29AA6A19B38D3D831EF" 172 | ;; 173 | 6) 174 | $SUDO rpm --import http://li.nux.ro/download/nux/RPM-GPG-KEY-nux.ro 175 | $SUDO rpm -Uvh http://li.nux.ro/download/nux/dextop/el6/x86_64/nux-dextop-release-0-2.el6.nux.noarch.rpm 176 | $SUDO rpm --import "http://keyserver.ubuntu.com/pks/lookup?op=get&search=0x3FA7E0328081BFF6A14DA29AA6A19B38D3D831EF" 177 | ;; 178 | esac 179 | $SUDO su -c "curl https://download.mono-project.com/repo/centos$dist_version-stable.repo | $SUDO tee /etc/yum.repos.d/mono-centos$dist_version-stable.repo" 180 | fi 181 | 182 | echo "正在更新安装包..." && \ 183 | $SUDO $pkg_manager update -y -qq > /dev/null && \ 184 | echo "正在安装依赖..." && \ 185 | $SUDO $pkg_manager install -y -qq $pre_reqs > /dev/null && \ 186 | echo "正在安装Mono..." && \ 187 | $SUDO $pkg_manager install -y mono-devel && \ 188 | echo "正在安装差速器..." 189 | $SUDO pipx ensurepath && reload_path 190 | pipx install Differential 191 | ;; 192 | arch) 193 | echo "正在安装依赖..." && \ 194 | $SUDO pacman -Sy --noconfirm vlc python3 python-pipx mediainfo mono ffmpeg 2>&1 > /dev/null 195 | # TODO cleanup ffmpeg ? 196 | echo "正在安装差速器..." && \ 197 | $SUDO pipx ensurepath && reload_path 198 | pipx install Differential 199 | ;; 200 | *) 201 | echo "系统版本 $lsb_dist $dist_version 还未支持!" 202 | ;; 203 | 204 | esac 205 | 206 | if command_exists dft; then 207 | dft_version="$(dft -v | awk '{print $2}')" 208 | echo "差速器$dft_version安装成功" 209 | else 210 | echo "差速器安装失败" 211 | fi 212 | } 213 | 214 | do_install -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "Differential" 3 | dynamic = ["version"] 4 | description = "A Python script for easy uploading torrents to various PT sites." 5 | readme = "README.md" 6 | authors = [ 7 | { name = "Lei Shi", email = "me@leishi.io" } 8 | ] 9 | license = { text = "MIT" } 10 | homepage = "https://github.com/leishi1313/Differential" 11 | keywords = ["PT", "mediainfo", "ptgen", "ptpimg"] 12 | classifiers = [ 13 | "Environment :: Console", 14 | "Operating System :: MacOS", 15 | "Operating System :: POSIX :: Linux", 16 | "Operating System :: Microsoft :: Windows", 17 | "Operating System :: OS Independent", 18 | "Programming Language :: Python :: 3.8", 19 | "Programming Language :: Python :: 3.9", 20 | "Programming Language :: Python :: 3.10", 21 | "Programming Language :: Python :: 3.11", 22 | "Programming Language :: Python :: 3.12", 23 | "License :: OSI Approved :: MIT License", 24 | ] 25 | 26 | requires-python = ">=3.8" 27 | 28 | dependencies = [ 29 | "loguru", 30 | "requests", 31 | "Pillow>=9.0.0", 32 | "pymediainfo>=5.1.0", 33 | "torf>=3.1.3", 34 | "bencode.py==4.0.0", 35 | "lxml>=4.7.1", 36 | ] 37 | 38 | [project.scripts] 39 | differential = "differential.main:main" 40 | dft = "differential.main:main" 41 | 42 | [build-system] 43 | requires = ["pdm-backend"] 44 | build-backend = "pdm.backend" 45 | 46 | [tool.pdm] 47 | distribution = true # This ensures the package can be built and distributed 48 | 49 | [tool.pdm.version] 50 | source = "scm" 51 | write_to = "differential/version.py" 52 | write_template = "version = '{}'" 53 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | loguru 2 | requests 3 | Pillow==9.0.1 4 | pymediainfo==5.1.0 5 | torf==3.1.3 6 | lxml==4.7.1 7 | bencode.py==4.0.0 -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from glob import glob 3 | from setuptools import setup, find_packages 4 | 5 | setup( 6 | name="Differential", 7 | packages=find_packages(), 8 | include_package_data=True, 9 | package_data={ 10 | 'differential': glob('tools/**/*') 11 | }, 12 | long_description=open("README.md", "r", encoding='utf-8').read(), 13 | long_description_content_type="text/markdown", 14 | description="a Python script for easy uploading torrents to varies PT sites.", 15 | author="Lei Shi", 16 | author_email="me@leishi.io", 17 | url="https://github.com/leishi1313/Differential", 18 | keywords=["PT", "mediainfo", "ptgen", "ptpimg"], 19 | classifiers=[ 20 | "Environment :: Console", 21 | "Operating System :: MacOS", 22 | "Operating System :: POSIX :: Linux", 23 | "Operating System :: Microsoft :: Windows", 24 | "Operating System :: OS Independent", 25 | "Programming Language :: Python :: 3.8", 26 | "Programming Language :: Python :: 3.9", 27 | "Programming Language :: Python :: 3.10", 28 | "Programming Language :: Python :: 3.11", 29 | "Programming Language :: Python :: 3.12", 30 | "License :: OSI Approved :: MIT License", 31 | ], 32 | python_requires=">=3.8", 33 | install_requires=[ 34 | "loguru", 35 | "requests", 36 | "Pillow>=9.0.0", 37 | "pymediainfo>=5.1.0", 38 | "torf>=3.1.3", 39 | "bencode.py==4.0.0", 40 | "lxml>=4.7.1", 41 | ], 42 | entry_points={ 43 | "console_scripts": [ 44 | "differential=differential.main:main", 45 | "dft=differential.main:main", 46 | ] 47 | }, 48 | ) 49 | -------------------------------------------------------------------------------- /src/differential/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeiShi1313/Differential/62c5a224e4cff3ad50e42e4f2cb5bc0a50030322/src/differential/__init__.py -------------------------------------------------------------------------------- /src/differential/base_plugin.py: -------------------------------------------------------------------------------- 1 | import re 2 | import json 3 | import argparse 4 | from pathlib import Path 5 | from typing import Optional 6 | from itertools import chain 7 | from urllib.parse import quote 8 | from abc import ABC, abstractmethod 9 | 10 | from loguru import logger 11 | 12 | from differential.plugin_register import PluginRegister 13 | from differential.torrent import TorrnetBase 14 | from differential.constants import ImageHosting 15 | from differential.utils.browser import open_link 16 | from differential.utils.torrent import make_torrent 17 | from differential.utils.parse import parse_encoder_log 18 | from differential.utils.uploader import EasyUpload, AutoFeed 19 | from differential.utils.mediainfo_handler import MediaInfoHandler 20 | from differential.utils.ptgen_handler import PTGenHandler 21 | from differential.utils.screenshot_handler import ScreenshotHandler 22 | from differential.utils.nfo import generate_nfo 23 | from differential.utils.ptgen.base import PTGenData, DataType 24 | from differential.utils.ptgen.imdb import IMDBData 25 | from differential.utils.ptgen.douban import DoubanData 26 | 27 | 28 | class Base(ABC, TorrnetBase, metaclass=PluginRegister): 29 | @classmethod 30 | @abstractmethod 31 | def add_parser(cls, parser: argparse.ArgumentParser) -> argparse.ArgumentParser: 32 | parser.add_argument( 33 | "-c", 34 | "--config", 35 | type=str, 36 | help="配置文件的路径,默认为config.ini", 37 | default="config.ini", 38 | ) 39 | parser.add_argument( 40 | "-l", "--log", type=str, help="log文件的路径", default=argparse.SUPPRESS 41 | ) 42 | parser.add_argument( 43 | "-f", 44 | "--folder", 45 | type=str, 46 | help="种子文件夹的路径", 47 | default=argparse.SUPPRESS, 48 | ) 49 | parser.add_argument( 50 | "-u", "--url", type=str, help="豆瓣链接", default=argparse.SUPPRESS 51 | ) 52 | parser.add_argument( 53 | "-uu", 54 | "--upload-url", 55 | type=str, 56 | help="PT站点上传的路径,一般为https://xxxxx.com/upload.php", 57 | default=argparse.SUPPRESS, 58 | ) 59 | parser.add_argument( 60 | "-t", 61 | "--make-torrent", 62 | action="store_true", 63 | help="是否制种,默认否", 64 | default=argparse.SUPPRESS, 65 | ) 66 | parser.add_argument( 67 | "-n", 68 | "--generate-nfo", 69 | action="store_true", 70 | help="是否用MediaInfo生成NFO文件,默认否", 71 | default=argparse.SUPPRESS, 72 | ) 73 | parser.add_argument( 74 | "-s", 75 | "--screenshot-count", 76 | type=int, 77 | help="截图数量,默认为0,即不生成截图", 78 | default=argparse.SUPPRESS, 79 | ) 80 | parser.add_argument( 81 | "--screenshot-path", 82 | type=str, 83 | help="截图文件夹,会在提供的文件夹中查找图片并上传,不会再生成截图", 84 | default=argparse.SUPPRESS, 85 | ) 86 | parser.add_argument( 87 | "--create-folder", 88 | action="store_true", 89 | dest="create_folder", 90 | help="如果目标为文件,创建文件夹并把目标文件放入其中", 91 | default=argparse.SUPPRESS, 92 | ) 93 | parser.add_argument( 94 | "--use-short-bdinfo", 95 | action="store_true", 96 | help="使用QUICK SUMMARY作为BDInfo,默认使用完整BDInfo", 97 | default=argparse.SUPPRESS, 98 | ) 99 | parser.add_argument( 100 | "--no-scan-bdinfo", 101 | action="store_false", 102 | dest="scan_bdinfo", 103 | help="如果为原盘,跳过扫描BDInfo", 104 | default=argparse.SUPPRESS, 105 | ) 106 | parser.add_argument( 107 | "--optimize-screenshot", 108 | action="store_true", 109 | help="是否压缩截图(无损),默认压缩", 110 | default=argparse.SUPPRESS, 111 | ) 112 | parser.add_argument( 113 | "--image-hosting", 114 | type=ImageHosting, 115 | help=f"图床的类型,现在支持{','.join(i.value for i in ImageHosting)}", 116 | default=argparse.SUPPRESS, 117 | ) 118 | parser.add_argument( 119 | "--ptpimg-api-key", 120 | type=str, 121 | help="PTPIMG的API Key", 122 | default=argparse.SUPPRESS, 123 | ) 124 | parser.add_argument( 125 | "--hdbits-cookie", 126 | type=str, 127 | help="HDBits的Cookie", 128 | default=argparse.SUPPRESS, 129 | ) 130 | parser.add_argument( 131 | "--hdbits-thumb-size", 132 | type=str, 133 | help="HDBits图床缩略图的大小,默认w300", 134 | default=argparse.SUPPRESS, 135 | ) 136 | parser.add_argument( 137 | "--chevereto-hosting-url", 138 | type=str, 139 | help="自建chevereto图床的地址", 140 | default=argparse.SUPPRESS, 141 | ) 142 | parser.add_argument( 143 | "--chevereto-api-key", 144 | type=str, 145 | help="自建Chevereto的API Key,详情见https://v3-docs.chevereto.com/api/#api-call", 146 | default=argparse.SUPPRESS, 147 | ) 148 | parser.add_argument( 149 | "--chevereto-username", 150 | type=str, 151 | help="如果自建Chevereto的API未开放,请设置username和password", 152 | default=argparse.SUPPRESS, 153 | ) 154 | parser.add_argument( 155 | "--chevereto-password", 156 | type=str, 157 | help="如果自建Chevereto的API未开放,请设置username和password", 158 | default=argparse.SUPPRESS, 159 | ) 160 | parser.add_argument( 161 | "--imgurl-api-key", 162 | type=str, 163 | help="Imgurl的API Key", 164 | default=argparse.SUPPRESS, 165 | ) 166 | parser.add_argument( 167 | "--smms-api-key", type=str, help="SM.MS的API Key", default=argparse.SUPPRESS 168 | ) 169 | parser.add_argument( 170 | "--byr-cookie", 171 | type=str, 172 | help="BYR的Cookie,可登录后访问任意页面F12查看", 173 | default=argparse.SUPPRESS, 174 | ) 175 | parser.add_argument( 176 | "--byr-alternative-url", 177 | type=str, 178 | help="BYR反代地址(如有),可为空", 179 | default=argparse.SUPPRESS, 180 | ) 181 | parser.add_argument( 182 | "--cloudinary-cloud-name", 183 | type=str, 184 | help="Cloudinary的cloud name", 185 | default=argparse.SUPPRESS, 186 | ) 187 | parser.add_argument( 188 | "--cloudinary-api-key", 189 | type=str, 190 | help="Cloudinary的api key", 191 | default=argparse.SUPPRESS, 192 | ) 193 | parser.add_argument( 194 | "--cloudinary-api-secret", 195 | type=str, 196 | help="Cloudinary的api secret", 197 | default=argparse.SUPPRESS, 198 | ) 199 | parser.add_argument( 200 | "--imgbox-username", 201 | type=str, 202 | help="Imgbox图床登录用户名,留空则匿名上传", 203 | default=argparse.SUPPRESS, 204 | ) 205 | parser.add_argument( 206 | "--imgbox-password", 207 | type=str, 208 | help="Imgbox图床登录密码,留空则匿名上传", 209 | default=argparse.SUPPRESS, 210 | ) 211 | parser.add_argument( 212 | "--imgbox-thumbnail-size", 213 | type=str, 214 | help="Imgbox图床缩略图的大小,默认300r", 215 | default=argparse.SUPPRESS, 216 | ) 217 | parser.add_argument( 218 | "--imgbox-family-safe", 219 | action="store_true", 220 | dest="imgbox_family_safe", 221 | help="Imgbox图床是否是非成人内容", 222 | default=argparse.SUPPRESS, 223 | ) 224 | parser.add_argument( 225 | "--imgbox-not-family-safe", 226 | action="store_false", 227 | dest="imgbox_family_safe", 228 | help="Imgbox图床是否是成人内容", 229 | default=argparse.SUPPRESS, 230 | ) 231 | parser.add_argument( 232 | "--ptgen-url", type=str, help="自定义PTGEN的地址", default=argparse.SUPPRESS 233 | ) 234 | parser.add_argument( 235 | "--ptgen-retry", 236 | type=int, 237 | help="PTGEN重试次数,默认为3次", 238 | default=argparse.SUPPRESS, 239 | ) 240 | parser.add_argument( 241 | "--announce-url", 242 | type=str, 243 | help="制种时announce地址", 244 | default=argparse.SUPPRESS, 245 | ) 246 | 247 | parser.add_argument( 248 | "--encoder-log", type=str, help="压制log的路径", default=argparse.SUPPRESS 249 | ) 250 | upload_option_group = parser.add_mutually_exclusive_group() 251 | upload_option_group.add_argument( 252 | "--easy-upload", 253 | action="store_true", 254 | help="使用树大Easy Upload插件自动填充", 255 | dest="easy_upload", 256 | default=argparse.SUPPRESS, 257 | ) 258 | upload_option_group.add_argument( 259 | "--auto-feed", 260 | action="store_true", 261 | help="使用明日大Auto Feed插件自动填充", 262 | dest="auto_feed", 263 | default=argparse.SUPPRESS, 264 | ) 265 | parser.add_argument( 266 | "--trim-description", 267 | action="store_true", 268 | help="是否在生成的链接中省略种子描述,该选项主要是为了解决浏览器限制URL长度的问题,默认关闭", 269 | default=argparse.SUPPRESS, 270 | ) 271 | parser.add_argument( 272 | "--use-short-url", 273 | action="store_true", 274 | help="是否缩短生成的上传链接", 275 | default=argparse.SUPPRESS, 276 | ) 277 | parser.add_argument( 278 | "--no-reuse-torrent", 279 | action="store_false", 280 | dest="reuse_torrent", 281 | help="是否直接在差速器已经制作的种子基础上重新制种", 282 | default=argparse.SUPPRESS, 283 | ) 284 | parser.add_argument( 285 | "--from-torrent", 286 | type=str, 287 | help="提供种子,在此基础上,直接洗种生成新种子", 288 | default=argparse.SUPPRESS, 289 | ) 290 | return parser 291 | 292 | def __init__( 293 | self, 294 | folder: str, 295 | url: str, 296 | upload_url: str, 297 | screenshot_count: int = 0, 298 | screenshot_path: str = None, 299 | optimize_screenshot: bool = True, 300 | create_folder: bool = False, 301 | use_short_bdinfo: bool = False, 302 | scan_bdinfo: bool = True, 303 | image_hosting: ImageHosting = ImageHosting.PTPIMG, 304 | chevereto_hosting_url: str = "", 305 | imgurl_hosting_url: str = "", 306 | ptpimg_api_key: str = None, 307 | hdbits_cookie: str = None, 308 | hdbits_thumb_size: str = "w300", 309 | chevereto_api_key: str = None, 310 | chevereto_username: str = None, 311 | chevereto_password: str = None, 312 | cloudinary_cloud_name: str = None, 313 | cloudinary_api_key: str = None, 314 | cloudinary_api_secret: str = None, 315 | imgurl_api_key: str = None, 316 | smms_api_key: str = None, 317 | byr_cookie: str = None, 318 | byr_alternative_url: str = None, 319 | imgbox_username: str = None, 320 | imgbox_password: str = None, 321 | imgbox_thumbnail_size: str = "300r", 322 | imgbox_family_safe: bool = True, 323 | ptgen_url: str = "https://ptgen.lgto.workers.dev", 324 | second_ptgen_url: str = "https://api.slyw.me", 325 | announce_url: str = "https://example.com", 326 | ptgen_retry: int = 3, 327 | generate_nfo: bool = False, 328 | make_torrent: bool = False, 329 | easy_upload: bool = False, 330 | auto_feed: bool = False, 331 | trim_description: bool = False, 332 | use_short_url: bool = False, 333 | encoder_log: str = "", 334 | reuse_torrent: bool = True, 335 | from_torrent: str = None, 336 | **kwargs, 337 | ): 338 | self.folder = Path(folder) 339 | self.url = url 340 | self.upload_url = upload_url 341 | 342 | self.announce_url = announce_url 343 | self.generate_nfo = generate_nfo 344 | self.make_torrent = make_torrent 345 | self.easy_upload = easy_upload 346 | self.auto_feed = auto_feed 347 | self.trim_description = trim_description 348 | self.use_short_url = use_short_url 349 | self.encoder_log = encoder_log 350 | self.reuse_torrent = reuse_torrent 351 | self.from_torrent = from_torrent 352 | 353 | self.mediainfo_handler = MediaInfoHandler( 354 | folder=self.folder, 355 | create_folder=create_folder, 356 | use_short_bdinfo=use_short_bdinfo, 357 | scan_bdinfo=scan_bdinfo, 358 | ) 359 | self.ptgen_handler = PTGenHandler( 360 | url=self.url, 361 | ptgen_url=ptgen_url, 362 | second_ptgen_url=second_ptgen_url, 363 | ptgen_retry=ptgen_retry, 364 | ) 365 | self.screenshot_handler = ScreenshotHandler( 366 | folder=self.folder, 367 | screenshot_count=screenshot_count, 368 | screenshot_path=screenshot_path, 369 | optimize_screenshot=optimize_screenshot, 370 | image_hosting=image_hosting, 371 | chevereto_hosting_url=chevereto_hosting_url, 372 | imgurl_hosting_url=imgurl_hosting_url, 373 | ptpimg_api_key=ptpimg_api_key, 374 | hdbits_cookie=hdbits_cookie, 375 | hdbits_thumb_size=hdbits_thumb_size, 376 | chevereto_username=chevereto_username, 377 | chevereto_password=chevereto_password, 378 | chevereto_api_key=chevereto_api_key, 379 | cloudinary_cloud_name=cloudinary_cloud_name, 380 | cloudinary_api_key=cloudinary_api_key, 381 | cloudinary_api_secret=cloudinary_api_secret, 382 | imgurl_api_key=imgurl_api_key, 383 | smms_api_key=smms_api_key, 384 | byr_cookie=byr_cookie, 385 | byr_alternative_url=byr_alternative_url, 386 | imgbox_username=imgbox_username, 387 | imgbox_password=imgbox_password, 388 | imgbox_thumbnail_size=imgbox_thumbnail_size, 389 | imgbox_family_safe=imgbox_family_safe, 390 | ) 391 | 392 | self.main_file: Optional[Path] = None 393 | self.ptgen: Optional[PTGenData] = None 394 | self.douban: Optional[DoubanData] = None 395 | self.imdb: Optional[IMDBData] = None 396 | 397 | def upload(self): 398 | self._prepare() 399 | if self.easy_upload: 400 | torrent_info = self.easy_upload_torrent_info 401 | if self.trim_description: 402 | # 直接打印简介部分来绕过浏览器的链接长度限制 403 | torrent_info["description"] = "" 404 | logger.trace(f"torrent_info: {torrent_info}") 405 | link = f"{self.upload_url}#torrentInfo={quote(json.dumps(torrent_info))}" 406 | logger.trace(f"已生成自动上传链接:{link}") 407 | if self.trim_description: 408 | logger.info(f"种子描述:\n{self.description}") 409 | open_link(link, self.use_short_url) 410 | elif self.auto_feed: 411 | link = f"{self.upload_url}#{self.auto_feed_info}" 412 | # if self.trim_description: 413 | # logger.info(f"种子描述:\n{self.description}") 414 | logger.trace(f"已生成自动上传链接:{link}") 415 | open_link(link, self.use_short_url) 416 | else: 417 | logger.info( 418 | "\n" 419 | f"标题: {self.title}\n" 420 | f"副标题: {self.subtitle}\n" 421 | f"豆瓣: {self.douban_url}\n" 422 | f"IMDB: {self.imdb_url}\n" 423 | f"视频编码: {self.video_codec} 音频编码: {self.audio_codec} 分辨率: {self.resolution}\n" 424 | f"描述:\n{self.description}" 425 | ) 426 | 427 | def _prepare(self): 428 | self.main_file = self.mediainfo_handler.find_mediainfo() 429 | self.ptgen, self.douban, self.imdb = self.ptgen_handler.fetch_ptgen_info() 430 | self.screenshot_handler.collect_screenshots( 431 | self.main_file, 432 | self.mediainfo_handler.resolution, 433 | self.mediainfo_handler.duration, 434 | ) 435 | 436 | if self.generate_nfo: 437 | generate_nfo(self.folder, self.mediainfo_handler.media_info) 438 | 439 | if self.make_torrent: 440 | make_torrent( 441 | self.folder, 442 | self.announce_url, 443 | self.__class__.__name__, 444 | self.reuse_torrent, 445 | self.from_torrent, 446 | ) 447 | 448 | @property 449 | def parsed_encoder_log(self): 450 | return parse_encoder_log(self.encoder_log) 451 | 452 | @property 453 | def title(self): 454 | # TODO: Either use file name or generate from mediainfo and ptgen 455 | temp_name = ( 456 | self.folder.name if self.folder.is_dir() else self.folder.stem 457 | ).replace(".", " ") 458 | temp_name = re.sub(r"(?<=5|7)( )1(?=.*$)", ".1", temp_name) 459 | return temp_name 460 | 461 | @property 462 | def subtitle(self): 463 | if not self.douban: 464 | return self.ptgen.subtitle 465 | return self.douban.subtitle 466 | 467 | @property 468 | def media_info(self): 469 | return self.mediainfo_handler.media_info 470 | 471 | @property 472 | def media_infos(self): 473 | return [] 474 | 475 | @property 476 | def description(self): 477 | return "{}\n\n[quote]{}{}[/quote]\n\n{}".format( 478 | self.ptgen.format, 479 | self.media_info, 480 | "\n\n" + self.parsed_encoder_log if self.parsed_encoder_log else "", 481 | "\n".join( 482 | [f"{uploaded}" for uploaded in self.screenshot_handler.screenshots] 483 | ), 484 | ) 485 | 486 | @property 487 | def original_description(self): 488 | return "" 489 | 490 | @property 491 | def douban_url(self): 492 | if self.douban: 493 | return f"https://movie.douban.com/subject/{self.douban.sid}" 494 | return "" 495 | 496 | @property 497 | def douban_info(self): 498 | return "" 499 | 500 | @property 501 | def imdb_url(self): 502 | return getattr(self.ptgen, "imdb_link", "") 503 | 504 | @property 505 | def screenshots(self): 506 | return [u.url for u in self.screenshot_handler.screenshots] 507 | 508 | @property 509 | def poster(self): 510 | return getattr(self.ptgen, "poster") 511 | 512 | @property 513 | def year(self): 514 | return ( 515 | self.imdb.year 516 | if self.imdb 517 | else self.douban.year if self.douban else getattr(self.ptgen, "year", "") 518 | ) 519 | 520 | @property 521 | def category(self): 522 | if self.douban: 523 | if "演唱会" in self.douban.genre and "音乐" in self.douban.genre: 524 | return "concert" 525 | if self.imdb: 526 | if "Documentary" in self.imdb.genre: 527 | return "documentary" 528 | if self.imdb.type_ == DataType.MOVIE: 529 | return "movie" 530 | if self.imdb.type_ == DataType.TV_SERIES: 531 | return "tvPack" 532 | return self.ptgen.type_ 533 | 534 | @property 535 | def video_type(self): 536 | if "webdl" in self.folder.name.lower() or "web-dl" in self.folder.name.lower(): 537 | return "web" 538 | elif "remux" in self.folder.name.lower(): 539 | return "remux" 540 | elif "hdtv" in self.folder.name.lower(): 541 | return "hdtv" 542 | elif any(e in self.folder.name.lower() for e in ("x264", "x265")): 543 | return "encode" 544 | elif "bluray" in self.folder.name.lower() and not any( 545 | e in self.folder.name.lower() for e in ("x264", "x265") 546 | ): 547 | return "bluray" 548 | elif "uhd" in self.folder.name.lower(): 549 | return "uhdbluray" 550 | for track in self.mediainfo_handler.tracks: 551 | if track.track_type == "Video": 552 | if track.encoding_settings: 553 | return "encode" 554 | return "" 555 | 556 | @property 557 | def format(self): 558 | # TODO: Maybe read from mediainfo 559 | return self.main_file.suffix 560 | 561 | @property 562 | def source(self): 563 | return "" 564 | 565 | @property 566 | def video_codec(self): 567 | for track in self.mediainfo_handler.mediainfo.tracks: 568 | if track.track_type == "Video": 569 | if track.encoded_library_name: 570 | return track.encoded_library_name 571 | if track.commercial_name == "AVC": 572 | return "h264" 573 | if track.commercial_name == "HEVC": 574 | return "hevc" 575 | # h264: "AVC/H.264", 576 | # hevc: "HEVC", 577 | # x264: "x264", 578 | # x265: "x265", 579 | # h265: "HEVC", 580 | # mpeg2: "MPEG-2", 581 | # mpeg4: "AVC/H.264", 582 | # vc1: "VC-1", 583 | # dvd: "MPEG" 584 | return "" 585 | 586 | @property 587 | def audio_codec(self): 588 | codec_map = { 589 | "AAC": "aac", 590 | "Dolby Digital Plus": "dd+", 591 | "Dolby Digital": "dd", 592 | "DTS-HD Master Audio": "dtshdma", 593 | "Dolby Digital Plus with Dolby Atmos": "atmos", 594 | "Dolby TrueHD": "truehd", 595 | "Dolby TrueHD with Dolby Atmos": "truehd", 596 | } 597 | for track in self.mediainfo_handler.mediainfo.tracks: 598 | if track.track_type == "Audio": 599 | if track.format_info == "Audio Coding 3": 600 | return "ac3" 601 | if track.format_info == "Free Lossless Audio Codec": 602 | return "flac" 603 | if track.commercial_name in codec_map: 604 | return codec_map.get(track.commercial_name) 605 | # TODO: other formats 606 | # dts: "3", 607 | # lpcm: "21", 608 | # dtsx: "3", 609 | # ape: "2", 610 | # wav: "22", 611 | # mp3: "4", 612 | # m4a: "5", 613 | # other: "7" 614 | return "" 615 | 616 | @property 617 | def resolution(self): 618 | for track in self.mediainfo_handler.mediainfo.tracks: 619 | if track.track_type == "Video": 620 | if track.height <= 480: 621 | return "480p" 622 | elif track.height <= 576: 623 | return "576p" 624 | elif track.height <= 720: 625 | return "720p" 626 | elif track.height <= 1080: 627 | if getattr(track, "scan_type__store_method") == "InterleavedFields": 628 | return "1080i" 629 | return "1080p" 630 | elif track.height <= 2160: 631 | return "2160p" 632 | elif track.height <= 4320: 633 | return "4320p" 634 | return "" 635 | 636 | @property 637 | def area(self): 638 | if self.douban: 639 | return self.douban.area 640 | return "" 641 | 642 | @property 643 | def movie_name(self): 644 | if self.imdb: 645 | return self.imdb.name 646 | if self.douban: 647 | return next(iter(self.douban.aka), "") 648 | return "" 649 | 650 | @property 651 | def movie_aka_name(self): 652 | return "" 653 | 654 | @property 655 | def size(self): 656 | for track in self.mediainfo_handler.tracks: 657 | if track.track_type == "General": 658 | return track.file_size 659 | return "" 660 | 661 | @property 662 | def tags(self): 663 | tags = {} 664 | for track in self.mediainfo_handler.tracks: 665 | if track.track_type == "General": 666 | if track.audio_language_list and "Chinese" in track.audio_language_list: 667 | tags["chinese_audio"] = True 668 | if track.text_language_list and "Chinese" in track.text_language_list: 669 | tags["chinese_subtitle"] = True 670 | # TODO: hdr, hdr10_plus, dolby_vision, diy, cantonese_audio, false,dts_x, dolby_atoms 671 | return tags 672 | 673 | @property 674 | def other_tags(self): 675 | return [] 676 | 677 | @property 678 | def comparisons(self): 679 | return [] 680 | 681 | @property 682 | def easy_upload_torrent_info(self): 683 | return EasyUpload(plugin=self).torrent_info 684 | 685 | @property 686 | def auto_feed_info(self): 687 | return AutoFeed(plugin=self).info 688 | -------------------------------------------------------------------------------- /src/differential/commands.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | from differential.version import version 3 | 4 | PRE_PARSER = argparse.ArgumentParser(add_help=False) 5 | PRE_PARSER.add_argument( 6 | "--plugin", 7 | type=str, 8 | help="使用指定的插件", 9 | default=None 10 | ) 11 | PRE_PARSER.add_argument( 12 | "--plugin-folder", 13 | type=str, 14 | help="使用指定的插件目录", 15 | default=None 16 | ) 17 | 18 | PARSER = argparse.ArgumentParser(description="Differential - 差速器 PT快速上传工具") 19 | PARSER.add_argument( 20 | "-v", 21 | "--version", 22 | help="显示差速器当前版本", 23 | action="version", 24 | version=f"Differential {version}", 25 | ) 26 | PARSER.add_argument( 27 | "--section", default="", help="指定config的section,差速器配置会依次从默认、插件默认和指定section读取并覆盖" 28 | ) 29 | subparsers = PARSER.add_subparsers(help="使用下列插件名字来查看插件的详细用法") -------------------------------------------------------------------------------- /src/differential/constants.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | 4 | BOOLEAN_STATES = { 5 | "1": True, 6 | "yes": True, 7 | "true": True, 8 | "on": True, 9 | "0": False, 10 | "no": False, 11 | "false": False, 12 | "off": False, 13 | } 14 | 15 | BOOLEAN_ARGS = ( 16 | "generate_nfo", 17 | "make_torrent", 18 | "easy_upload", 19 | "trim_description", 20 | "combine_screenshots", 21 | "imgbox_family_safe", 22 | "use_short_bdinfo", 23 | "use_short_url", 24 | "reuse_torrent", 25 | "scan_bdinfo", 26 | "create_folder", 27 | "optimize_screenshot", 28 | ) 29 | 30 | URL_SHORTENER_PATH = "https://2cn.io" 31 | 32 | 33 | class ImageHosting(Enum): 34 | PTPIMG = "ptpimg" 35 | IMGURL = "imgurl" 36 | CHEVERETO = "chevereto" 37 | SMMS = "smms" 38 | BYR = "byr" 39 | HDB = "hdb" 40 | IMGBOX = "imgbox" 41 | CLOUDINARY = "cloudinary" 42 | 43 | @staticmethod 44 | def parse(s: str): 45 | if isinstance(s, ImageHosting): 46 | return s 47 | if s.lower() == "ptpimg" or s.lower() == 'ptp': 48 | return ImageHosting.PTPIMG 49 | elif s.lower() == "imgurl": 50 | return ImageHosting.IMGURL 51 | elif s.lower() == "chevereto": 52 | return ImageHosting.CHEVERETO 53 | elif s.lower() == "smms" or s.lower() == "sm.ms": 54 | return ImageHosting.SMMS 55 | elif s.lower() == "byr": 56 | return ImageHosting.BYR 57 | elif s.lower() == "hdb" or s.lower() == 'hdbits': 58 | return ImageHosting.HDB 59 | elif s.lower() == "imgbox": 60 | return ImageHosting.IMGBOX 61 | elif s.lower() == "cloudinary": 62 | return ImageHosting.CLOUDINARY 63 | raise ValueError(f"不支持的图床:{s}") 64 | -------------------------------------------------------------------------------- /src/differential/main.py: -------------------------------------------------------------------------------- 1 | import re 2 | import sys 3 | from pathlib import Path 4 | 5 | from loguru import logger 6 | 7 | from differential.version import version 8 | from differential.commands import PRE_PARSER, PARSER 9 | from differential.utils.config import merge_config 10 | from differential.plugin_register import REGISTERED_PLUGINS 11 | from differential.plugin_loader import load_plugins_from_dir, load_plugin_from_file 12 | 13 | @logger.catch 14 | def main(): 15 | known_args, remaining_argv = PRE_PARSER.parse_known_args() 16 | load_plugins_from_dir(Path(__file__).resolve().parent.joinpath("plugins")) 17 | if known_args.plugin: 18 | load_plugin_from_file(known_args.plugin) 19 | elif known_args.plugin_folder: 20 | load_plugins_from_dir(known_args.plugin_folder) 21 | 22 | args = PARSER.parse_args(remaining_argv) 23 | logger.info("Differential 差速器 {}".format(version)) 24 | config = merge_config(args, args.section) 25 | 26 | if 'log' in config: 27 | log = config.pop('log') 28 | logger.add(log, level="TRACE", backtrace=True, diagnose=True) 29 | 30 | logger.debug("Config: {}".format(config)) 31 | if hasattr(args, 'plugin'): 32 | plugin = config.pop('plugin') 33 | try: 34 | logger.trace(config) 35 | REGISTERED_PLUGINS[plugin](**config).upload() 36 | except TypeError as e: 37 | m = re.search(r'missing \d+ required positional argument[s]{0,1}: (.*?)$', str(e)) 38 | if m: 39 | logger.error("缺少插件必需的参数,请检查输入的参数: {}".format(m.groups()[0])) 40 | return 41 | raise e 42 | else: 43 | PARSER.print_help() 44 | 45 | 46 | if __name__ == '__main__': 47 | main() 48 | -------------------------------------------------------------------------------- /src/differential/plugin_loader.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from typing import Union 3 | import importlib.util 4 | from pathlib import Path 5 | from loguru import logger 6 | 7 | def load_plugins_from_dir(directory: Union[str, Path]) -> None: 8 | """ 9 | Dynamically import all .py files from the given directory, 10 | so that any classes inheriting from PluginRegister get registered. 11 | """ 12 | plugin_dir = Path(directory) 13 | if not plugin_dir.is_dir(): 14 | logger.warning(f"插件目录 '{directory}' 不存在或不是目录。") 15 | return 16 | 17 | for py_file in plugin_dir.glob("*.py"): 18 | if py_file.name == "__init__.py": 19 | continue 20 | _import_module_from_file(py_file) 21 | 22 | 23 | def load_plugin_from_file(file_path: Union[str, Path]) -> None: 24 | """ 25 | Dynamically import a single .py file. If it contains a subclass of PluginRegister, 26 | it will be automatically registered in the global dictionary. 27 | """ 28 | path_obj = Path(file_path).resolve() 29 | if not path_obj.is_file(): 30 | logger.warning(f"插件文件 '{file_path}' 不存在或不是文件。") 31 | return 32 | 33 | _import_module_from_file(path_obj) 34 | 35 | 36 | def _import_module_from_file(py_file: Path) -> None: 37 | """ 38 | Helper function to import a Python file via importlib. 39 | """ 40 | try: 41 | spec = importlib.util.spec_from_file_location(py_file.stem, py_file) 42 | if spec and spec.loader: 43 | module = importlib.util.module_from_spec(spec) 44 | spec.loader.exec_module(module) 45 | logger.trace(f"成功导入插件文件: {py_file.name}") 46 | else: 47 | logger.warning(f"无法加载插件文件: {py_file}") 48 | except Exception as e: 49 | logger.error(f"错误导入插件文件 '{py_file}': {e}") 50 | -------------------------------------------------------------------------------- /src/differential/plugin_register.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | from abc import ABCMeta, abstractmethod 3 | from typing import Dict, TYPE_CHECKING 4 | 5 | if TYPE_CHECKING: 6 | from .base_plugin import Base 7 | 8 | REGISTERED_PLUGINS: Dict[str, "Base"] = {} 9 | 10 | 11 | class PluginRegister(ABCMeta): 12 | def __init__(cls, name, bases, attrs): 13 | super().__init__(name, bases, attrs) 14 | # Skip base class 15 | if name == "Base": 16 | return 17 | 18 | aliases = (name.lower(),) 19 | if "get_aliases" in cls.__dict__: 20 | aliases += cls.get_aliases() 21 | 22 | from differential.commands import subparsers 23 | subparser = subparsers.add_parser(name, aliases=aliases, help=cls.get_help()) 24 | subparser.set_defaults(plugin=name) 25 | cls.add_parser(subparser) 26 | 27 | for alias in aliases: 28 | REGISTERED_PLUGINS[alias] = cls 29 | REGISTERED_PLUGINS[name] = cls 30 | 31 | @classmethod 32 | @abstractmethod 33 | def get_help(mcs): 34 | raise NotImplementedError() 35 | 36 | @classmethod 37 | @abstractmethod 38 | def get_aliases(mcs): 39 | raise NotImplementedError() 40 | 41 | @classmethod 42 | @abstractmethod 43 | def add_parser(mcs, parser: argparse.ArgumentParser): 44 | raise NotImplementedError() 45 | -------------------------------------------------------------------------------- /src/differential/plugins/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeiShi1313/Differential/62c5a224e4cff3ad50e42e4f2cb5bc0a50030322/src/differential/plugins/__init__.py -------------------------------------------------------------------------------- /src/differential/plugins/chdbits.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | 3 | from differential.plugins.nexusphp import NexusPHP 4 | 5 | 6 | class CHDBits(NexusPHP): 7 | 8 | @classmethod 9 | def get_help(cls): 10 | return 'CHDBits插件,适用于CHDBits' 11 | 12 | @classmethod 13 | def add_parser(cls, parser: argparse.ArgumentParser) -> argparse.ArgumentParser: 14 | return super().add_parser(parser) 15 | 16 | def __init__(self, **kwargs): 17 | super().__init__(upload_url="https://chdbits.co/upload.php", **kwargs) 18 | -------------------------------------------------------------------------------- /src/differential/plugins/chdbits_encode.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | from pathlib import Path 3 | from typing import Optional 4 | 5 | from loguru import logger 6 | 7 | from differential.plugins.chdbits import CHDBits 8 | from differential.version import version 9 | 10 | NOTEAM = """ 11 | Generate by Differential {} made by 12 | __ __ _____ _____ __ __ 13 | \ \ / / / ____| / ____| | \/ | 14 | \ V / | | __ | | | \ / | 15 | > < | | |_ | | | | |\/| | 16 | / . \ | |__| | | |____ | | | | 17 | /_/ \_\ \_____| \_____| |_| |_| 18 | 19 | 20 | """.format(version) 21 | 22 | CHD = """ 23 | Present by 24 | $$$$$$\ $$\ $$\ $$$$$$$\ 25 | $$ __$$\ $$ | $$ | $$ __$$\ 26 | $$ / \__| $$ | $$ | $$ | $$ | 27 | $$ | $$$$$$$$ | $$ | $$ | 28 | $$ | $$ __$$ | $$ | $$ | 29 | $$ | $$\ $$ | $$ | $$ | $$ | 30 | \$$$$$$ | $$ | $$ | $$$$$$$ | 31 | \______/ \__| \__| \_______/ 32 | 33 | Generate by Differential {} 34 | 35 | """.format(version) 36 | 37 | CHDPAD = """ 38 | Present by 39 | /$$$$$$ /$$ /$$ /$$$$$$$ /$$$$$$$ /$$$$$$ /$$$$$$$ 40 | /$$__ $$| $$ | $$| $$__ $$| $$__ $$ /$$__ $$| $$__ $$ 41 | | $$ \__/| $$ | $$| $$ \ $$| $$ \ $$| $$ \ $$| $$ \ $$ 42 | | $$ | $$$$$$$$| $$ | $$| $$$$$$$/| $$$$$$$$| $$ | $$ 43 | | $$ | $$__ $$| $$ | $$| $$____/ | $$__ $$| $$ | $$ 44 | | $$ $$| $$ | $$| $$ | $$| $$ | $$ | $$| $$ | $$ 45 | | $$$$$$/| $$ | $$| $$$$$$$/| $$ | $$ | $$| $$$$$$$/ 46 | \______/ |__/ |__/|_______/ |__/ |__/ |__/|_______/ 47 | 48 | Generate by Differential {} 49 | 50 | """.format(version) 51 | 52 | 53 | class CHDBitsEncode(CHDBits): 54 | 55 | @classmethod 56 | def get_help(cls): 57 | return 'CHDBitsEncode插件,适用于CHDBits压制组' 58 | 59 | @classmethod 60 | def add_parser(cls, parser: argparse.ArgumentParser) -> argparse.ArgumentParser: 61 | super().add_parser(parser) 62 | parser.add_argument('--source-name', type=str, help='压制源名称', default=argparse.SUPPRESS) 63 | parser.add_argument('--encoder', type=str, help="压制者名字,默认Anonymous", default=argparse.SUPPRESS) 64 | parser.add_argument('--team', type=str, help="组名,默认CHD", default=argparse.SUPPRESS) 65 | 66 | def __init__( 67 | self, 68 | source_name: str, 69 | encoder: str = "Anonymous", 70 | team: str = "CHD", 71 | **kwargs, 72 | ): 73 | super().__init__(**kwargs) 74 | self.team = team 75 | self.encoder = encoder 76 | self.source_name = source_name 77 | 78 | @property 79 | def subtitle(self): 80 | if not self.douban: 81 | return "" 82 | if self.douban.chinese_title: 83 | return f"{'/'.join([self.douban.chinese_title] + self.douban.aka)}" 84 | else: 85 | return f"{'/'.join(self.douban.aka)}" 86 | 87 | @property 88 | def media_info(self): 89 | media_info = f"{self.main_file.stem}\n" 90 | imdb_link = self.imdb.imdb_link 91 | if imdb_link: 92 | media_info += f"iMDB URL........: {imdb_link}\n" 93 | imdb_rating = self.imdb.imdb_rating 94 | if imdb_rating: 95 | media_info += f"iMDB RATiNG.....: {imdb_rating}\n" 96 | 97 | genre = self._imdb.get("genre") 98 | if genre: 99 | media_info += f"GENRE...........: {','.join(genre)}\n" 100 | else: 101 | genre = self.imdb.genre 102 | if genre: 103 | media_info += f"GENRE...........: {','.join(genre)}\n" 104 | 105 | if self.source_name: 106 | media_info += f"SOURCE..........: {self.source_name} (Thanks)\n" 107 | 108 | for track in self._mediainfo.general_tracks: 109 | # TODO(leshi1313): Format it by yourself 110 | media_info += f"RUNTiME.........: {track.other_duration[0]}\n" 111 | media_info += f"FilE SiZE.......: {track.other_file_size[0]}\n" 112 | for track in self._mediainfo.video_tracks: 113 | media_info += ( 114 | f"ViDEO BiTRATE...: " 115 | f"{track.encoded_library_name if track.encoded_library_name else track.commercial_name} {track.format_profile} " 116 | f"@ {track.other_bit_rate[0]}\n" 117 | ) 118 | media_info += f"FRAME RATE......: {track.other_frame_rate[0]}\n" 119 | media_info += f"ASPECT RATiO....: {track.other_display_aspect_ratio[0]}\n" 120 | media_info += f"RESOLUTiON......: {track.width}x{track.height}\n" 121 | 122 | for idx, track in enumerate(self._mediainfo.audio_tracks): 123 | if track.other_language and len(track.other_language) > 1: 124 | media_info += ( 125 | f"AUDiO...........: {'#'+str(idx+1) if len(self._mediainfo.audio_tracks) > 1 else ''} " 126 | f"{track.other_language[0]} " 127 | f"{track.commercial_name} {track.other_channel_s[0]} " 128 | f"@ {track.other_bit_rate[0]}\n" 129 | ) 130 | else: 131 | media_info += ( 132 | f"AUDiO...........: {'#'+str(idx+1) if len(self._mediainfo.audio_tracks) > 1 else ''} " 133 | f"{track.commercial_name} {track.other_channel_s[0]} " 134 | f"@ {track.other_bit_rate[0]}\n" 135 | ) 136 | 137 | if len(self._mediainfo.text_tracks): 138 | media_info += "SUBTiTLES.......: {}\n".format( 139 | " | ".join( 140 | [ 141 | f"{track.other_language[0]} {track.format} {track.title if track.title else ''}" 142 | if track.other_language and len(track.other_language) > 1 143 | else f"{track.format} {track.title if track.title else ''}" 144 | for track in self._mediainfo.text_tracks 145 | ] 146 | ) 147 | ) 148 | 149 | for track in self._mediainfo.menu_tracks: 150 | if track.chapters_pos_end and track.chapters_pos_begin: 151 | media_info += f"CHAPTERS........: {int(track.chapters_pos_end) - int(track.chapters_pos_begin)}\n" 152 | 153 | if self.encoder: 154 | media_info += f"ENCODER.........: {self.encoder} @ {self.team}" 155 | return media_info 156 | 157 | @property 158 | def description(self): 159 | return ( 160 | "[quote][color=Red][size=4][b]" 161 | "资源及相关素材未经CHD许可 严禁提取发布或二压使用,请注意礼节!" 162 | "[/b][/color][/quote][/size]\n" 163 | "{}\n\n" 164 | "[img]https://www.z4a.net/images/2019/09/13/info.png[/img]" 165 | "[quote]{}[/quote]\n\n" 166 | "[img]https://www.z4a.net/images/2019/09/13/screens.png[/img]" 167 | "\n{}\n" 168 | "[quote][color=red][b]郑重声明:" 169 | "本站提供的所有影视作品均是在网上搜集任何涉及商业盈利目的均不得使用," 170 | "否则产生的一切后果将由您自己承担!本站将不对本站的任何内容负任何法律责任!" 171 | "该下载内容仅做宽带测试使用,请在下载后24小时内删除。请购买正版![/b][/color][/quote]".format( 172 | self.ptgen.format, 173 | self.media_info + "\n\n" + self.parsed_encoder_log, 174 | "\n".join([f"{uploaded}" for uploaded in self._screenshots]), 175 | ) 176 | ) 177 | 178 | @property 179 | def tags(self): 180 | tags = super(CHDBitsEncode, self).tags 181 | tags["official"] = True 182 | return tags 183 | 184 | def _generate_nfo(self): 185 | logger.info("正在生成nfo文件...") 186 | p = Path(self.folder) 187 | if p.is_file(): 188 | with open(f"{p.parent.joinpath(p.stem)}.nfo", "wb") as f: 189 | if self.team == "CHD": 190 | f.write(CHD.encode()) 191 | elif self.team == "CHDPAD": 192 | f.write(CHDPAD.encode()) 193 | else: 194 | f.write(NOTEAM.encode()) 195 | f.write(self.media_info.encode()) 196 | elif p.is_dir(): 197 | with open(p.joinpath(f"{p.name}.nfo"), "wb") as f: 198 | if self.team == "CHD": 199 | f.write(CHD.encode()) 200 | elif self.team == "CHDPAD": 201 | f.write(CHDPAD.encode()) 202 | else: 203 | f.write(NOTEAM.encode()) 204 | f.write(self.media_info.encode()) 205 | 206 | @property 207 | def easy_upload_torrent_info(self): 208 | torrent_info = super().easy_upload_torrent_info 209 | torrent_info["team"] = self.team.lower() 210 | return torrent_info 211 | -------------------------------------------------------------------------------- /src/differential/plugins/gazelle.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | 3 | from differential.base_plugin import Base 4 | 5 | 6 | class Gazelle(Base): 7 | @classmethod 8 | def get_aliases(cls): 9 | return "gz", 10 | 11 | @classmethod 12 | def get_help(cls): 13 | return "Gazelle插件,适用于未经过大规模结构改动的Gazelle站点" 14 | 15 | @classmethod 16 | def add_parser(cls, parser: argparse.ArgumentParser) -> argparse.ArgumentParser: 17 | super().add_parser(parser) 18 | return parser 19 | -------------------------------------------------------------------------------- /src/differential/plugins/greatposterwall.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | 3 | from differential.plugins.nexusphp import NexusPHP 4 | 5 | 6 | class GreatPosterWall(NexusPHP): 7 | 8 | @classmethod 9 | def get_aliases(cls): 10 | return 'gpw', 11 | 12 | @classmethod 13 | def get_help(cls): 14 | return 'GreatPosterWall插件,适用于GreatPosterWall' 15 | 16 | @classmethod 17 | def add_parser(cls, parser: argparse.ArgumentParser) -> argparse.ArgumentParser: 18 | return super().add_parser(parser) 19 | 20 | def __init__(self, **kwargs): 21 | super().__init__(upload_url="https://greatposterwall.com/upload.php", **kwargs) 22 | -------------------------------------------------------------------------------- /src/differential/plugins/hdbits.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | 3 | from differential.base_plugin import Base 4 | from differential.utils.mediainfo import get_full_mediainfo 5 | 6 | 7 | 8 | class HDBits(Base): 9 | @classmethod 10 | def get_aliases(cls): 11 | return "HDB","hdb", 12 | 13 | @classmethod 14 | def get_help(cls): 15 | return "HDBits插件,适用于HDBits" 16 | 17 | @classmethod 18 | def add_parser(cls, parser: argparse.ArgumentParser) -> argparse.ArgumentParser: 19 | return super().add_parser(parser) 20 | 21 | def __init__(self, **kwargs): 22 | super().__init__(upload_url="https://hdbits.org/upload", **kwargs) 23 | 24 | @property 25 | def media_info(self): 26 | if self.is_bdmv: 27 | return '' 28 | else: 29 | return get_full_mediainfo(self._mediainfo) 30 | 31 | 32 | @property 33 | def description(self): 34 | description = '' 35 | if self.is_bdmv: 36 | description += "[quote]{}[/quote]\n\n".format(self._bdinfo) 37 | # TODO: missing encoder log 38 | description += "\n".join([f"{uploaded}" for uploaded in self._screenshots]) 39 | return description -------------------------------------------------------------------------------- /src/differential/plugins/league_official.py: -------------------------------------------------------------------------------- 1 | import string 2 | import argparse 3 | import tempfile 4 | from pathlib import Path 5 | from typing import Optional 6 | 7 | from PIL import Image 8 | from loguru import logger 9 | 10 | from differential.plugins.lemonhd import LemonHD 11 | from differential.utils.mediainfo import get_track_attr, get_track_attrs 12 | 13 | 14 | GROUP_QUOTES = { 15 | 'LeagueTV': """ 16 | [quote][font=方正粗黑宋简体][size=5][color=Red][b]LeagueTV[/b][/color]高清电视录制小组出品! 17 | [color=blue]本小组接受求片 18 | 内地 & 港台电视台 剧集、 电影、 纪录片等 19 | 有需求的会员请移步[url=https://lemonhd.org/forums.php?action=viewtopic&forumid=8&topicid=4255]LeagueTV 求片区[/url]跟帖[/size][/font][/quote] 20 | 21 | """, 22 | 'LeagueWEB': """ 23 | [quote][font=方正粗黑宋简体][size=5][color=Red][b]LeagueWEB[/b][/color]小组出品! 24 | [color=blue]本小组接受求片 25 | 内地网络视频平台的 剧集、电影、纪录片等 26 | 有需求的会员请移步[url=https://lemonhd.org/forums.php?action=viewtopic&forumid=8&topicid=4557]LeagueWEB应求专用贴[/url]跟帖[/size][/font][/quote] 27 | 28 | """, 29 | 'LeagueNF': """ 30 | [quote][font=方正粗黑宋简体][size=5][color=Red][b]LeagueNF[/b][/color]小组出品! 31 | [color=blue]本小组接受求片 32 | Netflix流媒体平台 剧集、电影、纪录片、动漫等 33 | 有需求的会员请移步[url=https://lemonhd.org/forums.php?action=viewtopic&forumid=8&topicid=4622]LeagueNF小组应求专用贴[/url]跟帖[/size][/font][/quote] 34 | 35 | """ 36 | } 37 | 38 | class LeagueOfficial(LemonHD): 39 | 40 | @classmethod 41 | def get_help(cls): 42 | return 'LeagueOfficial插件,适用于LeagueTV/LeagueWeb/LeagueNF官组电影及电视剧上传' 43 | 44 | @classmethod 45 | def add_parser(cls, parser: argparse.ArgumentParser) -> argparse.ArgumentParser: 46 | super().add_parser(parser) 47 | parser.add_argument('--source-name', type=str, help='资源来源,HDTV/WEB-DL等', default=argparse.SUPPRESS) 48 | parser.add_argument('--uploader', type=str, help="发布者者名字,默认Anonymous", default=argparse.SUPPRESS) 49 | parser.add_argument('--team', type=str, help="官组名,LeagueTV/LeagueWeb/LeagueNF等等", default=argparse.SUPPRESS) 50 | parser.add_argument('--combine-screenshots', type=bool, help='是否合并所有截图为一张图,默认开启', default=argparse.SUPPRESS) 51 | return parser 52 | 53 | def __init__(self, source_name: str, team: str, uploader: str = "Anonymous", combine_screenshots: bool = True, **kwargs): 54 | super().__init__(**kwargs) 55 | self.team = team 56 | self.uploader = uploader 57 | self.source_name = source_name 58 | self.combine_screenshots = combine_screenshots 59 | 60 | def _make_screenshots(self) -> Optional[str]: 61 | screenshots_dir = super()._make_screenshots() 62 | if not self.combine_screenshots: 63 | return screenshots_dir 64 | 65 | logger.info("正在合并图片...") 66 | images = [Image.open(i) for i in sorted(Path(screenshots_dir).glob("*.png"))] 67 | 68 | width, height = images[0].size 69 | new_width = 2 * width 70 | new_height = (self.screenshot_count // 2 + self.screenshot_count % 2) * height 71 | 72 | new_im = Image.new('RGBA', (new_width, new_height)) 73 | for idx, im in enumerate(images): 74 | x = (idx % 2) * width 75 | y = (idx // 2) * height 76 | new_im.paste(im, (x, y)) 77 | 78 | temp_dir = tempfile.mkdtemp() 79 | screenshot_path = f'{temp_dir}/{self.main_file.stem}.thumb.png' 80 | new_im.save(screenshot_path, format='png') 81 | return temp_dir 82 | 83 | @property 84 | def subtitle(self): 85 | if not self.douban: 86 | return "" 87 | subtitle = f"{'/'.join(self.douban.this_title + self.douban.aka)} " 88 | if self.douban.cast: 89 | subtitle += f"[主演: {'/'.join([c.get('name').strip(string.ascii_letters+string.whitespace) for c in self.douban.cast('cast')[:3]])}]" 90 | return subtitle 91 | 92 | @property 93 | def media_info(self): 94 | media_info = "" 95 | for track in self._mediainfo.general_tracks: 96 | media_info += f"File Name............: {track.file_name}\n" 97 | media_info += f"File Size............: {get_track_attr(track, 'file_size', True)}\n" 98 | media_info += f"Duration.............: {get_track_attr(track, 'duration', True)}\n" 99 | media_info += f"Bit Rate.............: {get_track_attr(track, 'overall_bit_rate', True)}\n" 100 | for track in self._mediainfo.video_tracks: 101 | media_info += f"Video Codec..........: {get_track_attr(track, 'format', True)} {get_track_attr(track, 'format profile', True)}\n" 102 | media_info += f"Frame Rate...........: {get_track_attr(track, 'frame rate', True)}\n" 103 | media_info += f"Resolution...........: {get_track_attr(track, 'width', True, False)} x {get_track_attr(track, 'height', True, False)}\n" 104 | media_info += f"Display Ratio........: {get_track_attr(track, 'display_aspect_ratio', True)}\n" 105 | media_info += f"Scan Type............: {get_track_attr(track, 'scan type', True)}\n" 106 | media_info += f"Bite Depth...........: {get_track_attr(track, 'bit depth', True)}\n" 107 | 108 | for idx, track in enumerate(self._mediainfo.audio_tracks): 109 | media_info += f"Audio #{idx}.............: {get_track_attrs(track, ['bit rate', 'bit rate mode','channel_s', 'format'])}\n" 110 | 111 | for idx, track in enumerate(self._mediainfo.text_tracks): 112 | media_info += f"Subtitle #{idx}..........: {get_track_attrs(track, ['format', 'title', 'language'])}\n" 113 | 114 | if self.source_name: 115 | media_info += f"Source...............: {self.source_name}\n" 116 | media_info += f"Uploader.............: {self.uploader} @ {self.team}" 117 | return media_info 118 | 119 | @property 120 | def description(self): 121 | return ( 122 | "{}" 123 | "{}\n\n" 124 | "[img]https://imgbox.leaguehd.com/images/2021/01/04/info_01.png[/img]\n" 125 | "[quote][size=3][color=Navy][b]★★★ ★★ General Information ★★★ ★★ [/color][/size][/b]\n" 126 | "[font=Courier New]{}[/font][/quote]\n\n" 127 | "[img]https://imgbox.leaguehd.com/images/2021/01/04/screens_01.png[/img]\n" 128 | "{}\n".format( 129 | GROUP_QUOTES.get(self.team, ''), 130 | self.ptgen.format, 131 | self.media_info, 132 | "\n".join([f"{uploaded}" for uploaded in self._screenshots]), 133 | ) 134 | ) 135 | -------------------------------------------------------------------------------- /src/differential/plugins/lemonhd.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | 3 | from differential.plugins.nexusphp import NexusPHP 4 | 5 | 6 | class LemonHD(NexusPHP): 7 | 8 | @classmethod 9 | def get_help(cls): 10 | return 'LemonHD插件,适用于LemnonHD电影及电视剧上传' 11 | 12 | @classmethod 13 | def add_parser(cls, parser: argparse.ArgumentParser) -> argparse.ArgumentParser: 14 | super().add_parser(parser) 15 | parser.add_argument('--upload-type', type=str, help="上传类型,默认为电影(movies),其他类型参见柠檬上传URL", 16 | default=argparse.SUPPRESS) 17 | 18 | def __init__(self, upload_type='movie', **kwargs): 19 | super().__init__(upload_url="https://lemonhd.org/upload_{}.php".format(upload_type), **kwargs) 20 | -------------------------------------------------------------------------------- /src/differential/plugins/nexusphp.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | 3 | from differential.base_plugin import Base 4 | 5 | 6 | class NexusPHP(Base): 7 | @classmethod 8 | def get_aliases(cls): 9 | return "nexus", "ne" 10 | 11 | @classmethod 12 | def get_help(cls): 13 | return "NexusPHP插件,适用于未经过大规模结构改动的NexusPHP站点" 14 | 15 | @classmethod 16 | def add_parser(cls, parser: argparse.ArgumentParser) -> argparse.ArgumentParser: 17 | super().add_parser(parser) 18 | return parser 19 | -------------------------------------------------------------------------------- /src/differential/plugins/pterclub.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | 3 | from differential.plugins.nexusphp import NexusPHP 4 | 5 | 6 | class PTerClub(NexusPHP): 7 | 8 | @classmethod 9 | def get_aliases(cls): 10 | return 'pter', 11 | 12 | @classmethod 13 | def get_help(cls): 14 | return 'PTerClub插件,适用于PTerClub' 15 | 16 | @classmethod 17 | def add_parser(cls, parser: argparse.ArgumentParser) -> argparse.ArgumentParser: 18 | return super().add_parser(parser) 19 | 20 | def __init__(self, **kwargs): 21 | super().__init__(upload_url="https://pterclub.com/upload.php", **kwargs) 22 | -------------------------------------------------------------------------------- /src/differential/plugins/ptp.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | 3 | from differential.base_plugin import Base 4 | 5 | 6 | class PassThePopcorn(Base): 7 | @classmethod 8 | def get_aliases(cls): 9 | return "PTP","ptp", 10 | 11 | @classmethod 12 | def get_help(cls): 13 | return "PTP插件,适用于PTP" 14 | 15 | @classmethod 16 | def add_parser(cls, parser: argparse.ArgumentParser) -> argparse.ArgumentParser: 17 | return super().add_parser(parser) 18 | 19 | def __init__(self, **kwargs): 20 | super().__init__(upload_url="https://passthepopcorn.me/upload.php", **kwargs) 21 | -------------------------------------------------------------------------------- /src/differential/plugins/unit3d.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | 3 | from differential.base_plugin import Base 4 | 5 | 6 | class Unit3D(Base): 7 | @classmethod 8 | def get_aliases(cls): 9 | return "u3d", 10 | 11 | @classmethod 12 | def get_help(cls): 13 | return "Unit3D插件,适用于未经过大规模结构改动的Unit3D站点" 14 | 15 | @classmethod 16 | def add_parser(cls, parser: argparse.ArgumentParser) -> argparse.ArgumentParser: 17 | super().add_parser(parser) 18 | return parser 19 | -------------------------------------------------------------------------------- /src/differential/tools/BDinfoCli.0.7.3/BDInfo.exe: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeiShi1313/Differential/62c5a224e4cff3ad50e42e4f2cb5bc0a50030322/src/differential/tools/BDinfoCli.0.7.3/BDInfo.exe -------------------------------------------------------------------------------- /src/differential/tools/BDinfoCli.0.7.3/DiscUtils.Common.dll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeiShi1313/Differential/62c5a224e4cff3ad50e42e4f2cb5bc0a50030322/src/differential/tools/BDinfoCli.0.7.3/DiscUtils.Common.dll -------------------------------------------------------------------------------- /src/differential/tools/BDinfoCli.0.7.3/DiscUtils.dll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeiShi1313/Differential/62c5a224e4cff3ad50e42e4f2cb5bc0a50030322/src/differential/tools/BDinfoCli.0.7.3/DiscUtils.dll -------------------------------------------------------------------------------- /src/differential/tools/BDinfoCli.0.7.3/Microsoft.WindowsAPICodePack.Shell.dll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeiShi1313/Differential/62c5a224e4cff3ad50e42e4f2cb5bc0a50030322/src/differential/tools/BDinfoCli.0.7.3/Microsoft.WindowsAPICodePack.Shell.dll -------------------------------------------------------------------------------- /src/differential/tools/BDinfoCli.0.7.3/Microsoft.WindowsAPICodePack.ShellExtensions.dll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeiShi1313/Differential/62c5a224e4cff3ad50e42e4f2cb5bc0a50030322/src/differential/tools/BDinfoCli.0.7.3/Microsoft.WindowsAPICodePack.ShellExtensions.dll -------------------------------------------------------------------------------- /src/differential/tools/BDinfoCli.0.7.3/Microsoft.WindowsAPICodePack.dll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeiShi1313/Differential/62c5a224e4cff3ad50e42e4f2cb5bc0a50030322/src/differential/tools/BDinfoCli.0.7.3/Microsoft.WindowsAPICodePack.dll -------------------------------------------------------------------------------- /src/differential/tools/BDinfoCli.0.7.3/ZedGraph.dll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeiShi1313/Differential/62c5a224e4cff3ad50e42e4f2cb5bc0a50030322/src/differential/tools/BDinfoCli.0.7.3/ZedGraph.dll -------------------------------------------------------------------------------- /src/differential/tools/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeiShi1313/Differential/62c5a224e4cff3ad50e42e4f2cb5bc0a50030322/src/differential/tools/__init__.py -------------------------------------------------------------------------------- /src/differential/torrent.py: -------------------------------------------------------------------------------- 1 | from abc import abstractmethod 2 | 3 | 4 | class TorrnetBase: 5 | 6 | @property 7 | @abstractmethod 8 | def title(self): 9 | raise NotImplementedError() 10 | 11 | @property 12 | @abstractmethod 13 | def subtitle(self): 14 | raise NotImplementedError() 15 | 16 | @property 17 | @abstractmethod 18 | def media_info(self): 19 | raise NotImplementedError() 20 | 21 | @property 22 | @abstractmethod 23 | def media_infos(self): 24 | raise NotImplementedError() 25 | 26 | @property 27 | @abstractmethod 28 | def description(self): 29 | raise NotImplementedError() 30 | 31 | @property 32 | @abstractmethod 33 | def original_description(self): 34 | raise NotImplementedError() 35 | 36 | @property 37 | @abstractmethod 38 | def douban_url(self): 39 | raise NotImplementedError() 40 | 41 | @property 42 | @abstractmethod 43 | def douban_info(self): 44 | raise NotImplementedError() 45 | 46 | @property 47 | @abstractmethod 48 | def imdb_url(self): 49 | raise NotImplementedError() 50 | 51 | @property 52 | @abstractmethod 53 | def screenshots(self): 54 | raise NotImplementedError() 55 | 56 | @property 57 | @abstractmethod 58 | def poster(self): 59 | raise NotImplementedError() 60 | 61 | @property 62 | @abstractmethod 63 | def year(self): 64 | raise NotImplementedError() 65 | 66 | @property 67 | @abstractmethod 68 | def category(self): 69 | raise NotImplementedError() 70 | 71 | @property 72 | @abstractmethod 73 | def video_type(self): 74 | raise NotImplementedError() 75 | 76 | @property 77 | @abstractmethod 78 | def format(self): 79 | raise NotImplementedError() 80 | 81 | @property 82 | @abstractmethod 83 | def source(self): 84 | raise NotImplementedError() 85 | 86 | @property 87 | @abstractmethod 88 | def video_codec(self): 89 | raise NotImplementedError() 90 | 91 | @property 92 | @abstractmethod 93 | def audio_codec(self): 94 | raise NotImplementedError() 95 | 96 | @property 97 | @abstractmethod 98 | def resolution(self): 99 | raise NotImplementedError() 100 | 101 | @property 102 | @abstractmethod 103 | def area(self): 104 | raise NotImplementedError() 105 | 106 | @property 107 | @abstractmethod 108 | def movie_name(self): 109 | raise NotImplementedError() 110 | 111 | @property 112 | @abstractmethod 113 | def movie_aka_name(self): 114 | raise NotImplementedError() 115 | 116 | @property 117 | @abstractmethod 118 | def size(self): 119 | raise NotImplementedError() 120 | 121 | @property 122 | @abstractmethod 123 | def tags(self): 124 | raise NotImplementedError() 125 | 126 | @property 127 | @abstractmethod 128 | def other_tags(self): 129 | raise NotImplementedError() 130 | 131 | @property 132 | @abstractmethod 133 | def comparisons(self): 134 | raise NotImplementedError() 135 | -------------------------------------------------------------------------------- /src/differential/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeiShi1313/Differential/62c5a224e4cff3ad50e42e4f2cb5bc0a50030322/src/differential/utils/__init__.py -------------------------------------------------------------------------------- /src/differential/utils/binary.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | import sys 4 | import shutil 5 | import platform 6 | import subprocess 7 | from pathlib import Path 8 | from typing import Optional 9 | 10 | from loguru import logger 11 | 12 | 13 | def find_binary(name: str, alternative_names: list = None) -> Optional[Path]: 14 | if alternative_names is None: 15 | alternative_names = [] 16 | 17 | if Path(name).is_file(): 18 | return name 19 | 20 | path = os.environ.get(f"{name.upper()}PATH") 21 | if path: 22 | pp = Path(path) 23 | if not pp.is_file(): 24 | logger.error(f"{pp}不是可执行文件!") 25 | sys.exit(1) 26 | return pp 27 | for n in [name] + alternative_names: 28 | if n in os.listdir(os.getcwd()): 29 | return Path(os.getcwd()).joinpath(n) 30 | _which = shutil.which(n) 31 | if _which: 32 | return Path(_which) 33 | if platform.system() == "Windows": 34 | if f"{n}.exe" in os.listdir(os.getcwd()): 35 | return Path(os.getcwd()).joinpath(f"{n}.exe") 36 | _which = shutil.which(f"{n}.exe") 37 | if _which: 38 | return Path(_which) 39 | 40 | logger.error( 41 | f"{name} not found in path, you can specify its binary location " 42 | f"by setting environment variable: {name.upper()}PATH or put it under the tool folder." 43 | ) 44 | return None 45 | 46 | def build_cmd(binary_name: str, args: str, abort: bool = False) -> str: 47 | executable = find_binary(binary_name) 48 | if executable is None: 49 | if abort: 50 | sys.exit(1) 51 | else: 52 | return "" 53 | cmd = f'"{executable}" {args}' 54 | logger.trace(cmd) 55 | return cmd 56 | 57 | def execute_with_output(binary_name: str, args: str, abort: bool = False) -> int: 58 | cmd = build_cmd(binary_name, args, abort) 59 | return_code = 0 60 | 61 | def _execute(): 62 | proc = subprocess.Popen(cmd, shell=True, bufsize=1, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, 63 | universal_newlines=True) 64 | for stdout in iter(proc.stdout.readline, ""): 65 | yield stdout 66 | proc.stdout.close() 67 | return_code = proc.wait() 68 | prev = '' 69 | for out in iter(_execute()): 70 | out = out.strip() 71 | if re.sub('(\d| )', '', out) != re.sub('(\d| )', '', prev): 72 | print(out) 73 | else: 74 | print(out, end='\r') 75 | sys.stdout.flush() 76 | prev = out 77 | if return_code != 0: 78 | logger.warning(f"{binary_name} exit with return code {return_code}") 79 | return return_code 80 | 81 | 82 | def execute(binary_name: str, args: str, abort: bool = False) -> str: 83 | cmd = build_cmd(binary_name, args, abort) 84 | proc = subprocess.run( 85 | cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) 86 | logger.trace(proc) 87 | ret = "\n".join([proc.stdout.decode(), proc.stderr.decode()]) 88 | if proc.returncode != 0: 89 | logger.warning(f"{binary_name} exit with return code {proc.returncode}:\n{ret}") 90 | return ret 91 | 92 | 93 | def ffmpeg(path: Path, extra_args: str = "") -> str: 94 | if platform.system() != "Windows": 95 | path = str(path.absolute()).replace('"', '\\"') 96 | else: 97 | path = path.absolute() 98 | return execute("ffmpeg", f'-i "{path}" {extra_args}') 99 | 100 | 101 | def ffprobe(path: Path) -> str: 102 | if platform.system() != "Windows": 103 | path = str(path.absolute()).replace('"', '\\"') 104 | else: 105 | path = path.absolute() 106 | return execute("ffprobe", f'-i "{path}"') 107 | -------------------------------------------------------------------------------- /src/differential/utils/browser.py: -------------------------------------------------------------------------------- 1 | import requests 2 | import webbrowser 3 | 4 | from loguru import logger 5 | from differential.constants import URL_SHORTENER_PATH 6 | 7 | 8 | def open_link(link: str, use_short_url: bool = False): 9 | if use_short_url: 10 | req = requests.post(f"{URL_SHORTENER_PATH}/new", {"url": link}) 11 | if req.ok: 12 | link = f"{URL_SHORTENER_PATH}/dft/{req.text}" 13 | 14 | try: 15 | browser = webbrowser.get() 16 | except webbrowser.Error: 17 | browser = None 18 | 19 | if browser is None or isinstance(browser, webbrowser.GenericBrowser): 20 | logger.info(f"未找到浏览器,请直接复制以下链接:{link}") 21 | else: 22 | browser.open(link, new=1) 23 | logger.info(f"如果浏览器未打开,请直接复制以下链接:{link}") 24 | -------------------------------------------------------------------------------- /src/differential/utils/config.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | from configparser import RawConfigParser 3 | 4 | from loguru import logger 5 | 6 | from differential.constants import ImageHosting, BOOLEAN_ARGS, BOOLEAN_STATES 7 | 8 | 9 | def merge_config(args: argparse.Namespace, section: str = '') -> dict: 10 | merged = {} 11 | config = None 12 | if hasattr(args, 'config'): 13 | config = RawConfigParser() 14 | with open(args.config, 'r', encoding='utf-8') as f: 15 | config.read_file(f) 16 | 17 | if config: 18 | # First use the args in the general section 19 | for arg in config.defaults().keys(): 20 | merged[arg] = config.defaults()[arg] 21 | 22 | # Then use the args from config file matching the plugin name 23 | if args.plugin in config.sections(): 24 | for arg in config[args.plugin].keys(): 25 | merged[arg] = config[args.plugin][arg] 26 | 27 | if section: 28 | if section not in config.sections(): 29 | logger.warning(f"Section {section} not found in config! Skipping...") 30 | else: 31 | for arg in config[section].keys(): 32 | merged[arg] = config[section][arg] 33 | 34 | # Args from command line has the highest priority 35 | for arg in vars(args): 36 | merged[arg] = getattr(args, arg) 37 | 38 | # Handling non-str non-int args 39 | if 'image_hosting' in merged: 40 | merged['image_hosting'] = ImageHosting.parse(merged['image_hosting']) 41 | if any(arg in BOOLEAN_ARGS for arg in merged.keys()): 42 | for arg in BOOLEAN_ARGS: 43 | if arg in merged and not isinstance(merged[arg], bool): 44 | # Might be buggy to always assume not recognized args is False 45 | merged[arg] = BOOLEAN_STATES.get(merged[arg], False) 46 | 47 | # Parse int args 48 | for arg in merged: 49 | if isinstance(merged[arg], str) and merged[arg].isdigit(): 50 | merged[arg] = int(merged[arg]) 51 | return merged 52 | -------------------------------------------------------------------------------- /src/differential/utils/image/__init__.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import Generator 3 | from differential.utils.image.types import ImageUploaded 4 | from differential.utils.image.byr import byr_upload 5 | from differential.utils.image.hdbits import hdbits_upload 6 | from differential.utils.image.imgbox import imgbox_upload 7 | from differential.utils.image.smms import smms_upload 8 | from differential.utils.image.ptpimg import ptpimg_upload 9 | from differential.utils.image.imgurl import imgurl_upload 10 | from differential.utils.image.chevereto import chevereto_api_upload, chevereto_cookie_upload, chevereto_username_upload, chevereto_upload 11 | from differential.utils.image.cloudinary import cloudinary_upload 12 | 13 | 14 | 15 | def get_all_images(folder: str) -> Generator[Path, None, None]: 16 | image_types = ("png", "jpg", "jpeg", "gif", "webp") 17 | for t in image_types: 18 | for i in Path(folder).glob("*.{}".format(t)): 19 | yield i 20 | -------------------------------------------------------------------------------- /src/differential/utils/image/byr.py: -------------------------------------------------------------------------------- 1 | import re 2 | from pathlib import Path 3 | from typing import Optional, List 4 | 5 | import requests 6 | from loguru import logger 7 | 8 | from differential.constants import ImageHosting 9 | from differential.utils.image.types import ImageUploaded 10 | 11 | # TODO 12 | ''' 13 | curl 'https://byr.pt/uploadimage.php' \ 14 | -H 'accept: */*' \ 15 | -H 'accept-language: en-GB,en-US;q=0.9,en;q=0.8,zh-CN;q=0.7,zh-TW;q=0.6,zh-HK;q=0.5,zh;q=0.4' \ 16 | -H 'cache-control: no-cache' \ 17 | -H 'content-type: multipart/form-data; boundary=----WebKitFormBoundarygRHsEgLAfIfe4S9U' \ 18 | -b 'auth_token=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJuYmYiOjE3NDAzMjIxMjYuNTg3NTI0LCJleHAiOjE3NDAzMjMwMjYuNTg3NTcyLCJqdGkiOiJjNGJlYzJjOS05MTVlLTQwMDktYThjZi0yZWVjYzA3ZjMyMGMiLCJpYXQiOjE3NDAzMjIxMjYuNTg3NTI0LCJpc3MiOiJzZXNzaW9uIiwiYXVkIjoiYWU5NTNkMTQtYmM5NC00MGU3LWIyM2UtOTc2Y2Q4YTQyMmI0Iiwic3ViIjoiMzQ4MzU4In0.6ErGJ-w5W0i0HYXn9q7rnyw4lWpmaenKhnVsC2uEpvI; session_id=ae953d14-bc94-40e7-b23e-976cd8a422b4; refresh_token=415f22cb5b989416d3d428ccb1e276d341e5dfd8a3a496e664fafb9ab1d7c7e6' \ 19 | -H 'dnt: 1' \ 20 | -H 'origin: https://byr.pt' \ 21 | -H 'pragma: no-cache' \ 22 | -H 'priority: u=1, i' \ 23 | -H 'referer: https://byr.pt/upload.php' \ 24 | -H 'sec-ch-ua: "Chromium";v="133", "Not(A:Brand";v="99"' \ 25 | -H 'sec-ch-ua-mobile: ?0' \ 26 | -H 'sec-ch-ua-platform: "macOS"' \ 27 | -H 'sec-fetch-dest: empty' \ 28 | -H 'sec-fetch-mode: cors' \ 29 | -H 'sec-fetch-site: same-origin' \ 30 | -H 'user-agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36' \ 31 | --data-raw $'------WebKitFormBoundarygRHsEgLAfIfe4S9U\r\nContent-Disposition: form-data; name="file"; filename="00839uABgy1hqs8jueiqzj32553717rh.jpg"\r\nContent-Type: image/jpeg\r\n\r\n\r\n------WebKitFormBoundarygRHsEgLAfIfe4S9U\r\nContent-Disposition: form-data; name="type"\r\n\r\ntorrent\r\n------WebKitFormBoundarygRHsEgLAfIfe4S9U--\r\n' 32 | ''' 33 | 34 | def byr_upload(images: List[Path], cookie: Optional[str], url: Optional[str] = None) -> List[ImageUploaded]: 35 | if not cookie: 36 | logger.error("[Screenshots] 未设置byr_cookie") 37 | return [] 38 | if url and url.endswith("/"): 39 | url = url[:-1] 40 | 41 | uploaded = [] 42 | for img in images: 43 | if cached := ImageUploaded.from_pickle(img, ImageHosting.BYR): 44 | uploaded.append(cached) 45 | elif u := _byr_upload(img, cookie, url): 46 | uploaded.append(u) 47 | return uploaded 48 | 49 | def _byr_upload(img: Path, cookie: str, url: Optional[str] = None) -> Optional[ImageUploaded]: 50 | headers = {'cookie': cookie} 51 | data = {'type': 'torrent'} 52 | files = {'file': open(img, 'rb')} 53 | 54 | req = requests.post(f"{'https://byr.pt' if not url else url}/uploadimage.php", data=data, files=files, headers=headers) 55 | 56 | if not req.ok: 57 | logger.trace(req.content) 58 | logger.warning(f"上传图片失败: HTTP {req.status_code}, reason: {req.reason}") 59 | return None 60 | 61 | try: 62 | res = req.json() 63 | logger.trace(res) 64 | except json.decoder.JSONDecodeError: 65 | res = {} 66 | if location := res.get('location'): 67 | return ImageUploaded(hosting=ImageHosting.BYR, image=img, url=f"https://byr.pt{location}") 68 | logger.warning("[Screenshots] 上传图片失败: 未获取到图片地址") 69 | -------------------------------------------------------------------------------- /src/differential/utils/image/chevereto.py: -------------------------------------------------------------------------------- 1 | import re 2 | import json 3 | from pathlib import Path 4 | from typing import Optional, List 5 | 6 | import requests 7 | from loguru import logger 8 | 9 | from differential.constants import ImageHosting 10 | from differential.utils.image.types import ImageUploaded 11 | 12 | sessions = {} 13 | 14 | def chevereto_upload(images: List[Path], url: Optional[str], api_key: Optional[str], username: Optional[str], password: Optional[str]) -> List[ImageUploaded]: 15 | if not url: 16 | logger.error("Chevereto地址未提供,请设置chevereto_hosting_url") 17 | return [] 18 | 19 | if url.endswith("/"): 20 | url = url[:-1] 21 | 22 | uploaded = [] 23 | for img in images: 24 | if cached := ImageUploaded.from_pickle(img, ImageHosting.CHEVERETO): 25 | uploaded.append(cached) 26 | elif api_key: 27 | if u := chevereto_api_upload(img, url, api_key): 28 | uploaded.append(u) 29 | elif username and password: 30 | if u := chevereto_username_upload(img, url, username, password): 31 | uploaded.append(u) 32 | else: 33 | logger.error( "Chevereto的API或用户名或密码未设置,请检查chevereto-username/chevereto-password设置") 34 | return uploaded 35 | 36 | def chevereto_api_upload(img: Path, url: str, api_key: str) -> Optional[ImageUploaded]: 37 | data = {'key': api_key} 38 | files = {'source': open(img, 'rb')} 39 | req = requests.post(f'{url}/api/1/upload', data=data, files=files) 40 | 41 | try: 42 | res = req.json() 43 | logger.trace(res) 44 | except json.decoder.JSONDecodeError: 45 | res = {} 46 | if not req.ok: 47 | logger.trace(req.content) 48 | logger.warning( 49 | f"上传图片失败: HTTP {req.status_code}, reason: {req.reason} " 50 | f"{res['error'].get('message') if 'error' in res else ''}") 51 | return None 52 | if 'error' in res: 53 | logger.warning(f"上传图片失败: [{res['error'].get('code')}]{res['error'].get('message')}") 54 | return None 55 | if 'image' not in res or 'url' not in res['image']: 56 | logger.warning(f"图片直链获取失败") 57 | return None 58 | return ImageUploaded(hosting=ImageHosting.CHEVERETO, image=img, url=res['image']['url']) 59 | 60 | 61 | def chevereto_cookie_upload(img: Path, url: str, cookie: str, auth_token: str) -> Optional[ImageUploaded]: 62 | headers = {'cookie': cookie} 63 | data = {'type': 'file', 'action': 'upload', 'nsfw': 0, 'auth_token': auth_token} 64 | files = {'source': open(img, 'rb')} 65 | req = requests.post(f'{url}/json', data=data, files=files, headers=headers) 66 | 67 | try: 68 | res = req.json() 69 | logger.trace(res) 70 | except json.decoder.JSONDecodeError: 71 | res = {} 72 | if not req.ok: 73 | logger.trace(req.content) 74 | logger.warning( 75 | f"上传图片失败: HTTP {req.status_code}, reason: {req.reason} " 76 | f"{res['error'].get('message') if 'error' in res else ''}") 77 | return None 78 | if 'error' in res: 79 | logger.warning(f"上传图片失败: [{res['error'].get('code')}] {res['error'].get('context')} {res['error'].get('message')}") 80 | return None 81 | if 'status_code' in res and res.get('status_code') != 200: 82 | logger.warning(f"上传图片失败: [{res['status_code']}] {res.get('status_txt')}") 83 | return None 84 | if 'image' not in res or 'url' not in res['image']: 85 | logger.warning(f"图片直链获取失败") 86 | return None 87 | return ImageUploaded(hosting=ImageHosting.CHEVERETO, image=img, url=res['image']['url']) 88 | 89 | 90 | def with_session(func): 91 | def wrapper(img: Path, url: str, username: str, password: str): 92 | if (username, password) not in sessions: 93 | session = requests.Session() 94 | req = session.get(url) 95 | m = re.search(r'auth_token.*?\"(\w+)\"', req.text) 96 | if not m: 97 | logger.warning("未找到auth_token,请重试") 98 | return 99 | auth_token = m.groups()[0] 100 | data = {'auth_token': auth_token, 'login-subject': username, 'password': password, 'keep-login': 1} 101 | logger.info("正在登录Chevereto...") 102 | req = session.post(f"{url}/login", data=data) 103 | if not req.ok: 104 | logger.warning("Chevereto登录失败,请重试") 105 | return 106 | sessions[(username, password)] = (session, auth_token) 107 | else: 108 | session, auth_token = sessions.get((username, password)) 109 | return func(session, img, url, auth_token) 110 | return wrapper 111 | 112 | 113 | @with_session 114 | def chevereto_username_upload(session: requests.Session, img: Path, url: str, auth_token: str) -> Optional[ImageUploaded]: 115 | data = {'type': 'file', 'action': 'upload', 'nsfw': 0, 'auth_token': auth_token} 116 | files = {'source': open(img, 'rb')} 117 | req = session.post(f'{url}/json', data=data, files=files) 118 | 119 | try: 120 | res = req.json() 121 | logger.trace(res) 122 | except json.decoder.JSONDecodeError: 123 | res = {} 124 | if not req.ok: 125 | logger.trace(req.content) 126 | logger.warning( 127 | f"上传图片失败: HTTP {req.status_code}, reason: {req.reason} " 128 | f"{res['error'].get('message') if 'error' in res else ''}") 129 | return None 130 | if 'error' in res: 131 | logger.warning(f"上传图片失败: [{res['error'].get('code')}] {res['error'].get('context')} {res['error'].get('message')}") 132 | return None 133 | if 'status_code' in res and res.get('status_code') != 200: 134 | logger.warning(f"上传图片失败: [{res['status_code']}] {res.get('status_txt')}") 135 | return None 136 | if 'image' not in res or 'url' not in res['image']: 137 | logger.warning(f"图片直链获取失败") 138 | return None 139 | return ImageUploaded(hosting=ImageHosting.CHEVERETO, image=img, url=res['image']['url']) -------------------------------------------------------------------------------- /src/differential/utils/image/cloudinary.py: -------------------------------------------------------------------------------- 1 | import re 2 | import json 3 | import hashlib 4 | from time import time 5 | from pathlib import Path 6 | from typing import Optional, List 7 | from urllib.parse import urlencode 8 | 9 | import requests 10 | from loguru import logger 11 | 12 | from differential.constants import ImageHosting 13 | from differential.utils.image.types import ImageUploaded 14 | 15 | 16 | def cloudinary_upload(images: List[Path], folder_name: str, cloud_name: Optional[str], api_key: Optional[str], api_secret: Optional[str]): 17 | if not cloud_name or not api_key or not api_secret: 18 | logger.error( "Cloudinary的参数未设置,请检查cloudinary_cloud_name/cloudinary_api_key/cloudinary_api_secret设置") 19 | return [] 20 | 21 | uploaded = [] 22 | for img in images: 23 | if cached := ImageUploaded.from_pickle(img, ImageHosting.CLOUDINARY): 24 | uploaded.append(cached) 25 | elif u := _cloudinary_upload(img, folder_name, cloud_name, api_key, api_secret): 26 | uploaded.append(u) 27 | 28 | return uploaded 29 | 30 | 31 | def _cloudinary_upload(img: Path, folder_name: str, cloud_name: str, api_key: str, api_secret: str) -> Optional[ImageUploaded]: 32 | data = { 33 | 'folder': folder_name, 34 | 'timestamp': int(time()), 35 | 'use_filename': 'true', 36 | } 37 | serialized = '&'.join(f"{k}={v}" for k, v in data.items()) + api_secret 38 | data['signature'] = hashlib.sha1(serialized.encode('utf-8')).hexdigest() 39 | data['api_key'] = api_key 40 | files = { 41 | 'file': open(img, "rb"), 42 | } 43 | 44 | req = requests.post(f'https://api.cloudinary.com/v1_1/{cloud_name}/image/upload', data=data, files=files) 45 | try: 46 | res = req.json() 47 | logger.trace(res) 48 | except json.decoder.JSONDecodeError: 49 | res = {} 50 | 51 | if 'error' in res: 52 | logger.warning(f"[Screenshots] 上传图片失败: [{req.status_code}] {res['error'].get('message')}") 53 | return None 54 | if 'url' not in res or 'secure_url' not in res: 55 | logger.warning(f"[Screenshots] 图片直链获取失败") 56 | return None 57 | return ImageUploaded(hosting=ImageHosting.CLOUDINARY, image=img, url=res['secure_url'] if 'secure_url' in res else res['url']) -------------------------------------------------------------------------------- /src/differential/utils/image/hdbits.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | import random 4 | from pathlib import Path 5 | from typing import Optional, List, Tuple, Dict, Any 6 | from string import ascii_letters, digits 7 | 8 | import requests 9 | from loguru import logger 10 | from lxml.html import fromstring 11 | 12 | from differential.constants import ImageHosting 13 | from differential.utils.image.types import ImageUploaded 14 | 15 | 16 | def get_uploadid(cookie: str) -> str: 17 | req = requests.get("https://img.hdbits.org", headers={"cookie": cookie}) 18 | m = re.search(r"uploadid=([a-zA-Z0-9]{15})", req.text) 19 | if m: 20 | return m.groups()[0] 21 | return "" 22 | 23 | 24 | def hdbits_upload( 25 | images: List[Path], cookie: str, galleryname: str = None, thumb_size: str = "w300" 26 | ) -> List[ImageUploaded]: 27 | uploadid = get_uploadid(cookie) 28 | if not uploadid: 29 | logger.warning("获取uploadid失败") 30 | return [] 31 | headers = { 32 | "cookie": cookie, 33 | "user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/97.0.4692.71 Safari/537.36" 34 | } 35 | 36 | uploaded: List[Union[ImageUploaded, bool, None]] = [None] * len(images) 37 | for idx, img in enumerate(images): 38 | if cached := ImageUploaded.from_pickle(img, ImageHosting.HDB): 39 | uploaded[idx] = cached 40 | continue 41 | data = { 42 | "name": img.name, 43 | "thumbsize": thumb_size, 44 | "galleryoption": 1, 45 | "galleryname": galleryname if galleryname else uploadid, 46 | "existgallery": 1, 47 | } 48 | files = { 49 | "file": open(img, "rb"), 50 | } 51 | req = requests.post( 52 | f"https://img.hdbits.org/upload.php?uploadid={uploadid}", 53 | data=data, 54 | files=files, 55 | headers=headers, 56 | ) 57 | 58 | if not req.ok: 59 | logger.trace(req.content) 60 | logger.warning(f"[Screenshots] 上传图片失败: HTTP {req.status_code}, reason: {req.reason}") 61 | uploaded[idx] = False 62 | elif req.json().get("error"): 63 | logger.trace(req.content) 64 | logger.warning( 65 | f"[Screenshots] 上传图片失败: Code {req.json()['error'].get('code')}, message: {req.json()['error]'].get('message')}" 66 | ) 67 | uploaded[idx] = False 68 | logger.info(f"[Screenshots] 第{idx+1}张截图上传成功") 69 | uploaded[idx] = True 70 | 71 | if any(x is True for x in uploaded): 72 | urls, thumbs = _fetch_upload(uploadid, headers) 73 | if len(urls) != len([x for x in uploaded if x is True]): 74 | logger.warning(f"[Screenshots] 图片直链获取失败: \n{urls}\n{uploaded}") 75 | return None 76 | i = 0 77 | for idx, val in enumerate(uploaded): 78 | if val is True: 79 | uploaded[idx] = ImageUploaded( 80 | hosting=ImageHosting.HDB, 81 | image = images[idx], 82 | url=urls[i], 83 | thumb=thumbs[i].replace("i.hdbits.org", "t.hdbits.org").replace(".png", ".jpg"), 84 | ) 85 | i += 1 86 | 87 | return [u for u in uploaded if isinstance(u, ImageUploaded)] 88 | 89 | def _fetch_upload(uploadid: str, headers: Dict[str, Any]) -> Tuple[List[str], List[str]]: 90 | req = requests.get(f"https://img.hdbits.org/done/{uploadid}", headers=headers) 91 | if not req.ok: 92 | logger.trace(req.content) 93 | logger.warning(f"[Screenshots] 图片直链获取失败: HTTP {req.status_code}, reason: {req.reason}") 94 | return ([], []) 95 | root = fromstring(req.content) 96 | textareas = root.xpath("*//textarea") 97 | if not textareas: 98 | logger.warning(f"[Screenshots] 图片直链获取失败: {root}") 99 | return ([], []) 100 | urls = textareas[1].text.split("\n") 101 | thumbs = textareas[2].text.split("\n") 102 | if len(urls) != len(thumbs): 103 | logger.warning(f"[Screenshots] 图片直链获取失败: \n{urls}\n{thumbs}") 104 | return ([], []) 105 | return (urls, thumbs) -------------------------------------------------------------------------------- /src/differential/utils/image/imgbox.py: -------------------------------------------------------------------------------- 1 | import re 2 | from pathlib import Path 3 | from typing import Optional, List 4 | 5 | import requests 6 | from loguru import logger 7 | 8 | from differential.constants import ImageHosting 9 | from differential.utils.image.types import ImageUploaded 10 | 11 | 12 | def get_csrf_token(session) -> Optional[str]: 13 | headers = { 14 | "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/97.0.4692.99 Safari/537.36" 15 | } 16 | req = session.get("https://imgbox.com", headers=headers) 17 | if req.ok: 18 | if m := re.search(r"content=\"(.*?)\" name=\"csrf-token\"", req.text): 19 | return m.groups()[0] 20 | return None 21 | 22 | 23 | def get_token( 24 | session, csrf_token: str, gallery_title: Optional[str], allow_comment: bool = False 25 | ) -> dict: 26 | headers = { 27 | "X-CSRF-Token": csrf_token, 28 | "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/97.0.4692.99 Safari/537.36", 29 | } 30 | data = { 31 | "gallery": "true" if gallery_title is not None else "false", 32 | # TODO: need to test when gallery title is None 33 | "gallery_title": gallery_title, 34 | "comments_enabled": str(int(allow_comment)), 35 | } 36 | req = session.post( 37 | "https://imgbox.com/ajax/token/generate", data=data, headers=headers, json=True 38 | ) 39 | if req.ok and req.json().get("ok"): 40 | return req.json() 41 | return {} 42 | 43 | 44 | def login(session, username, password, csrf_token): 45 | headers = { 46 | "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/97.0.4692.99 Safari/537.36" 47 | } 48 | data = { 49 | "utf8": "✓", 50 | "authenticity_token": csrf_token, 51 | "user[login]": username, 52 | "user[password]": password, 53 | } 54 | logger.info("[Screenshots] 正在登录imgbox...") 55 | req = session.post("https://imgbox.com/login", data=data, headers=headers) 56 | if len(req.history) and req.history[-1].status_code == 302: 57 | logger.info("[Screenshots] 登录成功") 58 | return 59 | logger.warning("[Screenshots] 登录失败,使用匿名模式上传") 60 | 61 | 62 | def imgbox_upload( 63 | imgs: List[Path], 64 | usernmae: str = None, 65 | password: str = None, 66 | gallery_title: str = None, 67 | thumbnail_size: str = "300r", 68 | is_family_safe: bool = True, 69 | allow_comment: bool = False, 70 | ) -> List[ImageUploaded]: 71 | uploaded: List[Union[ImageUploaded, None]] = [None] * len(imgs) 72 | for idx, img in enumerate(imgs): 73 | if cached := ImageUploaded.from_pickle(img, ImageHosting.IMGBOX): 74 | uploaded[idx] = cached 75 | 76 | if any(x is None for x in uploaded): 77 | session = requests.Session() 78 | csrf_token = get_csrf_token(session) 79 | if not csrf_token: 80 | logger.warning("[Screenshots] 获取csrf token失败") 81 | return [] 82 | if usernmae and password: 83 | login(session, usernmae, password, csrf_token) 84 | token = get_token(session, csrf_token, gallery_title, allow_comment) 85 | if not token: 86 | logger.warning("[Screenshots] 获取token失败") 87 | return [] 88 | 89 | headers = { 90 | "X-CSRF-Token": csrf_token, 91 | "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/97.0.4692.99 Safari/537.36", 92 | } 93 | for idx, img in enumerate(imgs): 94 | if uploaded[idx]: 95 | continue 96 | data = { 97 | "token_id": str(token.get("token_id")), 98 | "token_secret": token.get("token_secret"), 99 | "content_type": str(int(is_family_safe)), 100 | "thumbnail_size": thumbnail_size, 101 | "gallery_id": token.get("gallery_id"), 102 | "gallery_secret": token.get("gallery_secret"), 103 | "comments_enabled": str(int(allow_comment)), 104 | } 105 | files = { 106 | "files[]": open(img, "rb"), 107 | } 108 | req = session.post( 109 | "https://imgbox.com/upload/process", 110 | data=data, 111 | files=files, 112 | headers=headers, 113 | ) 114 | 115 | if not req.ok: 116 | logger.trace(req.content) 117 | logger.warning( 118 | f"[Screenshots] 上传图片失败: HTTP {req.status_code}, reason: {req.reason}" 119 | ) 120 | continue 121 | elif req.json().get("error"): 122 | logger.trace(req.content) 123 | logger.warning( 124 | f"[Screenshots] 上传图片失败: Code {req.json()['error'].get('code')}, message: {req.json()['error]'].get('message')}" 125 | ) 126 | continue 127 | uploaded[idx] = ImageUploaded( 128 | hosting=ImageHosting.IMGBOX, 129 | image=imgs[idx], 130 | url=req.json().get("files", [{}])[0].get("original_url"), 131 | thumb=req.json().get("files", [{}])[0].get("thumbnail_url"), 132 | ) 133 | logger.info(f"[Screenshots] 第{idx+1}张截图上传成功") 134 | 135 | return [u for u in uploaded if isinstance(u, ImageUploaded)] 136 | -------------------------------------------------------------------------------- /src/differential/utils/image/imgurl.py: -------------------------------------------------------------------------------- 1 | import json 2 | from pathlib import Path 3 | from typing import Optional, List 4 | 5 | import requests 6 | from loguru import logger 7 | 8 | from differential.constants import ImageHosting 9 | from differential.utils.image.types import ImageUploaded 10 | 11 | 12 | def imgurl_upload(images: List[Path], url: Optional[str], api_key: Optional[str]) -> List[ImageUploaded]: 13 | if not url: 14 | logger.error("[Screenshots] 未设置imgurl地址") 15 | return [] 16 | if url.endswith("/"): 17 | url = url[:-1] 18 | 19 | if not api_key: 20 | logger.error("[Screenshots] 未设置imgurl API key") 21 | 22 | uploaded = [] 23 | for img in images: 24 | if cached := ImageUploaded.from_pickle(img, ImageHosting.IMGURL): 25 | uploaded.append(cached) 26 | elif u := _imgurl_upload(img, url, api_key): 27 | uploaded.append(u) 28 | 29 | return uploaded 30 | 31 | 32 | def _imgurl_upload(img: Path, url: str, api_key: str) -> Optional[ImageUploaded]: 33 | data = {'token': api_key} 34 | files = {'file': open(img, 'rb')} 35 | req = requests.post(f'{url}/api/upload', data=data, files=files) 36 | 37 | try: 38 | res = req.json() 39 | logger.trace(res) 40 | except json.decoder.JSONDecodeError: 41 | res = {} 42 | if not req.ok: 43 | logger.trace(req.content) 44 | logger.warning( 45 | f"[Screenshots] 上传图片失败: HTTP {req.status_code}, reason: {req.reason} " 46 | f"{res.get('msg') if 'msg' in res else ''}") 47 | return None 48 | if res.get('code') > 200: 49 | logger.warning(f"[Screenshots] 上传图片失败: [{res.get('code')}]{res.get('msg')}") 50 | return None 51 | return ImageUploaded(hosting=ImageHosting.IMGURL, image=img, url=res.get('url')) -------------------------------------------------------------------------------- /src/differential/utils/image/ptpimg.py: -------------------------------------------------------------------------------- 1 | import json 2 | from pathlib import Path 3 | from typing import Optional, List 4 | 5 | import requests 6 | from loguru import logger 7 | 8 | from differential.constants import ImageHosting 9 | from differential.utils.image.types import ImageUploaded 10 | 11 | 12 | def ptpimg_upload(imgs: List[Path], api_key: str) -> List[ImageUploaded]: 13 | uploaded = [] 14 | for img in imgs: 15 | if cached := ImageUploaded.from_pickle(img, ImageHosting.PTPIMG): 16 | uploaded.append(cached) 17 | elif u := _ptpimg_upload(img, api_key): 18 | uploaded.append(u) 19 | return uploaded 20 | 21 | 22 | def _ptpimg_upload(img: Path, api_key: str) -> Optional[ImageUploaded]: 23 | files = {'file-upload[0]': open(img, 'rb')} 24 | req = requests.post('https://ptpimg.me/upload.php', data={'api_key': api_key}, files=files) 25 | 26 | try: 27 | res = req.json() 28 | logger.trace(res) 29 | except json.decoder.JSONDecodeError: 30 | res = {} 31 | if not req.ok: 32 | logger.trace(req.content) 33 | logger.warning( 34 | f"[Screenshots] 上传图片失败: HTTP {req.status_code}, reason: {req.reason}") 35 | return None 36 | if len(res) < 1 or 'code' not in res[0] or 'ext' not in res[0]: 37 | logger.warning("[Screenshots] 图片直链获取失败") 38 | return None 39 | return ImageUploaded(hosting=ImageHosting.PTPIMG, image=img, url=f"https://ptpimg.me/{res[0].get('code')}.{res[0].get('ext')}") -------------------------------------------------------------------------------- /src/differential/utils/image/smms.py: -------------------------------------------------------------------------------- 1 | import json 2 | from pathlib import Path 3 | from typing import Optional, List 4 | 5 | import requests 6 | from loguru import logger 7 | 8 | from differential.constants import ImageHosting 9 | from differential.utils.image.types import ImageUploaded 10 | 11 | 12 | def smms_upload(images: List[Path], api_key: Optional[str]) -> List[ImageUploaded]: 13 | if not api_key: 14 | logger.error("[Screenshots] 未设置SMMS API key") 15 | return [] 16 | 17 | uploaded = [] 18 | for img in images: 19 | if cached := ImageUploaded.from_pickle(img, ImageHosting.SMMS): 20 | uploaded.append(cached) 21 | elif u := _smms_upload(img, api_key): 22 | uploaded.append(u) 23 | return uploaded 24 | 25 | def _smms_upload(img: Path, api_key: str) -> Optional[ImageUploaded]: 26 | headers = {'Authorization': api_key} 27 | files = {'smfile': open(img, 'rb'), 'format': 'json'} 28 | req = requests.post('https://sm.ms/api/v2/upload', headers=headers, files=files) 29 | 30 | try: 31 | res = req.json() 32 | logger.trace(res) 33 | except json.decoder.JSONDecodeError: 34 | res = {} 35 | if not req.ok: 36 | logger.trace(req.content) 37 | logger.warning( 38 | f"[Screenshots] 上传图片失败: HTTP {req.status_code}, reason: {req.reason} " 39 | f"{res.get('msg') if 'msg' in res else ''}") 40 | return None 41 | if not res.get('success') and res.get('code') != 'image_repeated': 42 | logger.warning(f"[Screenshots] 上传图片失败: [{res.get('code')}]{res.get('message')}") 43 | return None 44 | if res.get('code') == 'image_repeated': 45 | return res.get('images') 46 | if 'data' not in res or 'url' not in res['data']: 47 | logger.warning("[Screenshots] 图片直链获取失败") 48 | return None 49 | return ImageUploaded(hosting=ImageHosting.SMMS, image=img, url=res['data']['url']) -------------------------------------------------------------------------------- /src/differential/utils/image/types.py: -------------------------------------------------------------------------------- 1 | import pickle 2 | from pathlib import Path 3 | from typing import Optional 4 | from dataclasses import dataclass 5 | 6 | from differential.constants import ImageHosting 7 | 8 | @dataclass 9 | class ImageUploaded: 10 | hosting: ImageHosting 11 | image: Path 12 | url: str 13 | thumb: Optional[str] = None 14 | 15 | @classmethod 16 | def from_pickle(cls, image: Path, hosting: ImageHosting) -> Optional['ImageUploaded']: 17 | try: 18 | with open(image.parent.joinpath(f".{image.stem}.{hosting.value}"), 'rb') as f: 19 | return pickle.load(f) 20 | except FileNotFoundError: 21 | return None 22 | 23 | def __post_init__(self): 24 | with open(self.image.parent.joinpath(f".{self.image.stem}.{self.hosting.value}"), 'wb') as f: 25 | pickle.dump(self, f) 26 | 27 | def __str__(self): 28 | if self.thumb: 29 | return f"[url={self.url}][img]{self.thumb}[/img][/url]" 30 | return f"[img]{self.url}[/img]" 31 | -------------------------------------------------------------------------------- /src/differential/utils/mediainfo.py: -------------------------------------------------------------------------------- 1 | import re 2 | from pathlib import Path 3 | from decimal import Decimal 4 | from typing import Optional, List 5 | 6 | from loguru import logger 7 | from pymediainfo import Track, MediaInfo 8 | 9 | from differential.utils.binary import ffprobe 10 | 11 | 12 | def get_track_attr( 13 | track: Track, name: str, attr_only: bool = False, use_other: bool = True 14 | ) -> Optional[str]: 15 | alternative_name = None 16 | if name == "ID": 17 | alternative_name = "track_id" 18 | elif name == "Format/Info": 19 | alternative_name = "format_info" 20 | elif name == "Codec ID/Info": 21 | alternative_name = "codec_id_info" 22 | elif name == "Channel(s)": 23 | alternative_name = "channel_s" 24 | elif name == "Bits/(Pixel*Frame)": 25 | alternative_name = "bits__pixel_frame" 26 | 27 | attr = None 28 | if alternative_name: 29 | attr = getattr(track, alternative_name) 30 | if not attr and use_other: 31 | attrs = getattr(track, "other_" + name.replace(" ", "_").lower()) 32 | # Always get the first options 33 | if attrs and len(attrs): 34 | attr = attrs[0] 35 | if not attr: 36 | attr = getattr(track, name.replace(" ", "_").lower()) 37 | 38 | if attr: 39 | return attr if attr_only else "{}: {}".format(name, attr) 40 | return None 41 | 42 | 43 | def get_track_attrs(track: Track, names: List[str], join_str: str = " ") -> str: 44 | attrs = [] 45 | for name in names: 46 | attr = get_track_attr(track, name, True) 47 | if attr: 48 | attrs.append(attr) 49 | return join_str.join(attrs) 50 | 51 | 52 | def get_full_mediainfo(mediainfo: MediaInfo) -> str: 53 | track_format = { 54 | "general": [ 55 | "Unique ID", 56 | "Complete name", 57 | "Format", 58 | "Format version", 59 | "File Size", 60 | "Duration", 61 | "Overall bit rate", 62 | "Encoded date", 63 | "Writing application", 64 | "Writing library", 65 | "Attachments", 66 | ], 67 | "video": [ 68 | "ID", 69 | "Format", 70 | "Format/Info", 71 | "Format profile", 72 | "Codec ID", 73 | "Duration", 74 | "Bit rate", 75 | "Width", 76 | "Height", 77 | "Display aspect ratio", 78 | "Frame rate mode", 79 | "Frame rate", 80 | "Color space", 81 | "Chroma subsampling", 82 | "Bit depth", 83 | "Bits/(Pixel*Frame)", 84 | "Stream size", 85 | "Writing library", 86 | "Encoding settings", 87 | "Title", 88 | "Default", 89 | "Forced", 90 | "Color range", 91 | "Color primaries", 92 | "Transfer characteristics", 93 | "Matrix coefficients", 94 | "Mastering display color primaries", 95 | "Mastering display luminance", 96 | "Maximum Content Light Level", 97 | "Maximum Frame-Average Light Level", 98 | ], 99 | "audio": [ 100 | "ID", 101 | "Format", 102 | "Format/Info", 103 | "Commercial name", 104 | "Codec ID", 105 | "Duration", 106 | "Bit rate mode", 107 | "Bit rate", 108 | "Channel(s)", 109 | "Channel layout", 110 | "Sampling rate", 111 | "Frame rate", 112 | "Compression mode", 113 | "Stream size", 114 | "Title", 115 | "Language", 116 | "Service kind", 117 | "Default", 118 | "Forced", 119 | ], 120 | "text": [ 121 | "ID", 122 | "Format", 123 | "Muxing mode", 124 | "Codec ID", 125 | "Codec ID/Info", 126 | "Duration", 127 | "Bit rate", 128 | "Count of elements", 129 | "Stream size", 130 | "Title", 131 | "Language", 132 | "Default", 133 | "Forced", 134 | ], 135 | } 136 | media_info = "" 137 | for track_name in track_format.keys(): 138 | for idx, track in enumerate(getattr(mediainfo, "{}_tracks".format(track_name))): 139 | if len(getattr(mediainfo, "{}_tracks".format(track_name))) > 1: 140 | media_info += "{} #{}\n".format(track_name.capitalize(), idx + 1) 141 | else: 142 | media_info += "{}\n".format(track_name.capitalize()) 143 | 144 | media_info += ( 145 | "\n".join( 146 | filter( 147 | lambda a: a is not None, 148 | [ 149 | get_track_attr(track, name) 150 | for name in track_format[track_name] 151 | ], 152 | ) 153 | ) 154 | + "\n\n" 155 | ) 156 | # Special treatment with charters 157 | for track in mediainfo.menu_tracks: 158 | # Assuming there are always one menu tracks 159 | media_info += "Menu\n" 160 | for name in dir(track): 161 | # TODO: needs improvement 162 | if name[:2].isdigit(): 163 | media_info += "{} : {}\n".format( 164 | name[:-3].replace("_", ":") + "." + name[-3:], getattr(track, name) 165 | ) 166 | media_info += "\n" 167 | media_info.strip() 168 | return media_info 169 | 170 | def get_duration(main_file:Path, media_info: MediaInfo) -> Optional[Decimal]: 171 | for track in media_info.tracks: 172 | if track.track_type == "Video": 173 | return Decimal(track.duration) 174 | logger.error(f"未找到视频Track,请检查{main_file}是否为支持的文件") 175 | return None 176 | 177 | def get_resolution(main_file: Path, media_info: MediaInfo) -> Optional[str]: 178 | # 利用ffprobe获取视频基本信息 179 | ffprobe_out = ffprobe(main_file) 180 | m = re.search(r"Stream.*?Video.*?(\d{2,5})x(\d{2,5})", ffprobe_out) 181 | if not m: 182 | logger.debug(ffprobe_out) 183 | logger.warning("无法获取到视频的分辨率") 184 | return None 185 | 186 | # 获取视频分辨率以及长度等信息 187 | width, height = int(m.group(1)), int(m.group(2)) 188 | for track in media_info.tracks: 189 | if track.track_type == "Video": 190 | pixel_aspect_ratio = Decimal(track.pixel_aspect_ratio) 191 | break 192 | else: 193 | logger.error(f"未找到视频Track,请检查{main_file}是否为支持的文件") 194 | return None 195 | 196 | resolution = None 197 | if pixel_aspect_ratio <= 1: 198 | pheight = int(height * pixel_aspect_ratio) + ( 199 | int(height * pixel_aspect_ratio) % 2 200 | ) 201 | resolution = f"{width}x{pheight}" 202 | else: 203 | pwidth = int(width * pixel_aspect_ratio) + ( 204 | int(width * pixel_aspect_ratio) % 2 205 | ) 206 | resolution = f"{pwidth}x{height}" 207 | logger.trace( 208 | f"width: {width} height: {height}, " 209 | f"PAR: {pixel_aspect_ratio}, resolution: {resolution}" 210 | ) 211 | return resolution 212 | -------------------------------------------------------------------------------- /src/differential/utils/mediainfo_handler.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | import sys 4 | import platform 5 | import tempfile 6 | import shutil 7 | from pathlib import Path 8 | from loguru import logger 9 | from typing import Optional, List 10 | from pymediainfo import MediaInfo 11 | 12 | from differential import tools 13 | from differential.version import version 14 | from differential.utils.binary import execute_with_output 15 | from differential.utils.mediainfo import get_full_mediainfo, get_duration, get_resolution 16 | 17 | 18 | class MediaInfoHandler: 19 | """ 20 | Manages finding MediaInfo, determining if the target is a BDMV, 21 | scanning BDInfo if necessary, etc. 22 | """ 23 | mediainfo: MediaInfo 24 | 25 | def __init__( 26 | self, 27 | folder: Path, 28 | create_folder: bool, 29 | use_short_bdinfo: bool, 30 | scan_bdinfo: bool, 31 | ): 32 | self.folder = folder 33 | self.create_folder = create_folder 34 | self.use_short_bdinfo = use_short_bdinfo 35 | self.scan_bdinfo = scan_bdinfo 36 | 37 | self.is_bdmv = False 38 | self.bdinfo = None 39 | self.main_file = None 40 | 41 | def find_mediainfo(self): 42 | """ 43 | Main entry method to: 44 | 1) Possibly create folder if needed 45 | 2) Determine main file 46 | 3) Check for BDMV presence 47 | 4) Parse mediainfo 48 | 5) Possibly run BDInfo scanning 49 | Returns (MediaInfo, BDInfo, main_file, is_bdmv). 50 | """ 51 | logger.info(f"正在获取Mediainfo: {self.folder}") 52 | self._handle_single_or_folder() 53 | if not self.main_file: 54 | logger.error("未找到可分析的文件,请确认路径。") 55 | sys.exit(1) 56 | 57 | if self.main_file.suffix.lower() == ".iso": 58 | logger.error("请先挂载ISO文件再使用。") 59 | sys.exit(1) 60 | 61 | self.mediainfo = MediaInfo.parse(self.main_file) 62 | logger.info(f"[MediaInfo] 已获取: {self.main_file}") 63 | logger.trace(self.mediainfo.to_data()) 64 | 65 | # If BDMV found, handle BDInfo 66 | if self.is_bdmv: 67 | if self.scan_bdinfo: 68 | self.bdinfo = self._get_bdinfo() 69 | else: 70 | self.bdinfo = "[BDINFO HERE]" 71 | 72 | return self.main_file 73 | 74 | @property 75 | def media_info(self): 76 | if self.is_bdmv: 77 | return self.bdinfo 78 | else: 79 | return get_full_mediainfo(self.mediainfo) 80 | 81 | @property 82 | def resolution(self): 83 | return get_resolution(self.main_file, self.mediainfo) 84 | 85 | @property 86 | def duration(self): 87 | return get_duration(self.main_file, self.mediainfo) 88 | 89 | @property 90 | def tracks(self): 91 | return self.mediainfo.tracks 92 | 93 | def _handle_single_or_folder(self): 94 | """ 95 | If `folder` is actually a single file, handle create_folder logic. 96 | Otherwise, find the biggest file in the folder. 97 | Also detect if there's a BDMV structure present. 98 | """ 99 | if self.folder.is_file(): 100 | if self.create_folder: 101 | logger.info("目标是文件,正在创建文件夹...") 102 | new_dir = self.folder.parent.joinpath(self.folder.stem) 103 | if not new_dir.is_dir(): 104 | new_dir.mkdir(parents=True) 105 | shutil.move(str(self.folder), new_dir) 106 | self.folder = new_dir 107 | self.main_file = new_dir.joinpath(self.folder.name) 108 | else: 109 | self.main_file = self.folder 110 | else: 111 | logger.info("目标为文件夹,正在获取最大的文件...") 112 | biggest_size = -1 113 | biggest_file = None 114 | has_bdmv = False 115 | 116 | for f in self.folder.glob("**/*"): 117 | if f.is_file(): 118 | if f.suffix.lower() == ".bdmv": 119 | has_bdmv = True 120 | s = f.stat().st_size 121 | if s > biggest_size: 122 | biggest_size = s 123 | biggest_file = f 124 | 125 | self.main_file = biggest_file 126 | self.is_bdmv = has_bdmv 127 | 128 | def _get_bdinfo(self) -> str: 129 | """ 130 | Run or reuse BDInfo scanning for a BDMV structure. 131 | Return either the short summary or the full disc info. 132 | """ 133 | logger.info("[BDMV] 发现 BDMV 结构,准备扫描BDInfo...") 134 | 135 | # Check existing BDInfo in temp 136 | if cached := self._find_cached_bdinfo(): 137 | logger.info("[BDMV] 已发现之前的 BDInfo,跳过重复扫描") 138 | return cached 139 | 140 | # Otherwise, run BDInfo scanning 141 | temp_dir = tempfile.mkdtemp(prefix=f"Differential.bdinfo.{version}.", suffix=f".{self.folder.name}") 142 | self._run_bdinfo_scan(temp_dir) 143 | return self._collect_bdinfo_from_temp(temp_dir) 144 | 145 | def _find_cached_bdinfo(self) -> Optional[str]: 146 | """ 147 | Look in the temp dir for an existing BDInfo scan matching self.folder.name. 148 | """ 149 | for d in Path(tempfile.gettempdir()).glob(f"Differential.bdinfo.{version}.*.{self.folder.name}"): 150 | if d.is_dir(): 151 | if txt_files := list(d.glob("*.txt")): 152 | return self._extract_bdinfo_content(txt_files) 153 | return None 154 | 155 | def _run_bdinfo_scan(self, temp_dir: str) -> None: 156 | bdinfo_exe = os.path.join(os.path.dirname(tools.__file__), "BDinfoCli.0.7.3", "BDInfo.exe") 157 | for bdmv_path in self.folder.glob("**/BDMV"): 158 | logger.info(f"[BDInfo] 扫描: {bdmv_path.parent}") 159 | if platform.system() == "Windows": 160 | # path = bdmv_path.parent.replace('"', '\\"') 161 | args = f'-w "{bdmv_path.parent}" "{temp_dir}"' 162 | execute_with_output(bdinfo_exe, args, abort=True) 163 | else: 164 | path = bdmv_path.parent.replace('"', '\\"') 165 | args = f'"{bdinfo_exe}" -w "{path}" "{temp_dir}"' 166 | execute_with_output("mono", args, abort=True) 167 | 168 | def _collect_bdinfo_from_temp(self, temp_dir: str) -> str: 169 | txt_files = sorted(Path(temp_dir).glob("*.txt")) 170 | if not txt_files: 171 | logger.warning(f"[BDInfo] 未找到BDInfo信息:{temp_dir}") 172 | return "[BDINFO HERE]" 173 | return self._extract_bdinfo_content(txt_files) 174 | 175 | def _extract_bdinfo_content(self, txt_files: List[Path]) -> str: 176 | bdinfos = [] 177 | for txt in txt_files: 178 | content = txt.read_text(errors="ignore") 179 | if self.use_short_bdinfo: 180 | # Extract QUICK SUMMARY 181 | if m := re.search(r"(QUICK SUMMARY:\n+(.+?\n)+)\n\n", content): 182 | bdinfos.append(m.group(1)) 183 | else: 184 | # Extract DISC INFO 185 | pattern = ( 186 | r"(DISC INFO:\n+(.+?\n{1,2})+?)(?:CHAPTERS:\n|STREAM DIAGNOSTICS:\n|\[\/code\]\n<---- END FORUMS PASTE ---->)" 187 | ) 188 | if m := re.search(pattern, content): 189 | bdinfos.append(m.group(1)) 190 | return "\n\n".join(bdinfos) -------------------------------------------------------------------------------- /src/differential/utils/nfo.py: -------------------------------------------------------------------------------- 1 | 2 | from pathlib import Path 3 | 4 | 5 | def generate_nfo(folder: Path, media_info: str): 6 | logger.info("[NFO] 正在生成nfo文件...") 7 | if folder.is_file(): 8 | with open(f"{folder.resolve().parent.joinpath(folder.stem)}.nfo", "wb") as f: 9 | f.write(media_info.encode()) 10 | elif folder.is_dir(): 11 | with open(folder.joinpath(f"{folder.name}.nfo"), "wb") as f: 12 | f.write(media_info.encode()) -------------------------------------------------------------------------------- /src/differential/utils/parse.py: -------------------------------------------------------------------------------- 1 | import re 2 | from pathlib import Path 3 | 4 | 5 | def parse_encoder_log(encoder_log: str): 6 | log = "" 7 | if encoder_log and Path(encoder_log).is_file(): 8 | with open(encoder_log, "r") as f: 9 | log = f.read() 10 | m = re.search( 11 | r".*?(x264 \[info]: frame I:.*?)\n" 12 | r".*?(x264 \[info]: frame P:.*?)\n" 13 | r".*?(x264 \[info]: frame B:.*?)\n" 14 | r".*?(x264 \[info]: consecutive B-frames:.*?)\n", 15 | log, 16 | ) 17 | if m: 18 | return "\n".join(m.groups()) 19 | m = re.search( 20 | r".*?(x265 \[info]: frame I:.*?)\n" 21 | r".*?(x265 \[info]: frame P:.*?)\n" 22 | r".*?(x265 \[info]: frame B:.*?)\n" 23 | r".*?(x265 \[info]: Weighted P-Frames:.*?)\n" 24 | r".*?(x265 \[info]: Weighted B-Frames:.*?)\n" 25 | r".*?(x265 \[info]: consecutive B-frames:.*?)\n", 26 | log, 27 | ) 28 | if m: 29 | return "\n".join(m.groups()) 30 | return "" 31 | -------------------------------------------------------------------------------- /src/differential/utils/ptgen/bangumi.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass, field 2 | from typing import Optional, Any, Dict, List 3 | 4 | from differential.utils.ptgen.base import PTGenData, DataType 5 | 6 | @dataclass 7 | class BangumiData(PTGenData): 8 | """ 9 | Dataclass for data returned from bangumi.js 10 | """ 11 | alt: Optional[str] = None 12 | cover: Optional[str] = None # for backward compatibility 13 | poster: Optional[str] = None 14 | story: Optional[str] = None 15 | 16 | # staff: e.g. ["导演: XXX", "脚本: YYY", ...] 17 | staff: List[str] = field(default_factory=list) 18 | # info: e.g. ["中文名: XXX", "别名: YYY", ...] 19 | info: List[str] = field(default_factory=list) 20 | 21 | bangumi_votes: Optional[str] = None 22 | bangumi_rating_average: Optional[str] = None 23 | tags: List[str] = field(default_factory=list) 24 | cast: List[str] = field(default_factory=list) 25 | 26 | @staticmethod 27 | def from_dict(obj: Dict[str, Any]) -> 'BangumiData': 28 | """ 29 | Create a BangumiData instance from a raw dictionary 30 | (e.g., from the JS code `gen_bangumi(sid)`). 31 | """ 32 | base = PTGenData( 33 | site=obj['site'], 34 | sid=obj['sid'], 35 | success=obj.get('success', False), 36 | error=obj.get('error'), 37 | format=obj.get('format') 38 | ) 39 | 40 | bangumi = BangumiData(**base.__dict__) 41 | 42 | bangumi.alt = obj.get('alt') 43 | bangumi.cover = obj.get('cover') 44 | bangumi.poster = obj.get('poster') 45 | bangumi.story = obj.get('story') 46 | 47 | # Staff & Info 48 | bangumi.staff = obj.get('staff', []) 49 | bangumi.info = obj.get('info', []) 50 | 51 | # Ratings 52 | bangumi.bangumi_votes = obj.get('bangumi_votes') 53 | bangumi.bangumi_rating_average = obj.get('bangumi_rating_average') 54 | 55 | # Tags & Cast 56 | bangumi.tags = obj.get('tags', []) 57 | bangumi.cast = obj.get('cast', []) 58 | 59 | return bangumi 60 | 61 | def __str__(self) -> str: 62 | # Extract Chinese name from info if available 63 | for info_item in self.info: 64 | if info_item.startswith('中文名:'): 65 | return info_item.split(':', 1)[1].strip() 66 | 67 | # Fallback to alt if available 68 | if self.alt: 69 | return self.alt 70 | 71 | return super().__str__() -------------------------------------------------------------------------------- /src/differential/utils/ptgen/base.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | from dataclasses import dataclass 3 | from typing import Optional, Any, Dict 4 | 5 | 6 | class DataType(Enum): 7 | MOVIE = "Movie" 8 | TV_SERIES = "TV Series" 9 | GAME = "Game" 10 | 11 | @classmethod 12 | def from_str(cls, value: str) -> Optional['DataType']: 13 | value = value.lower().strip() 14 | if value in ["movie", "电影"]: 15 | return cls.MOVIE 16 | elif value in ["TVSeries", "tv series", "电视剧", "剧集"]: 17 | return cls.TV_SERIES 18 | return None 19 | 20 | @dataclass 21 | class PTGenData: 22 | """ 23 | Common base class for all ptgen data, 24 | """ 25 | site: str = "Unknown" 26 | sid: str = "" 27 | success: bool = False 28 | error: Optional[str] = None 29 | format: Optional[str] = None 30 | type_: Optional[DataType] = None 31 | 32 | def __str__(self) -> str: 33 | return f"{self.site} ({self.sid})" 34 | 35 | @property 36 | def subtitle(self) -> str: 37 | return "" 38 | 39 | @dataclass 40 | class Person: 41 | """Simple class for person-like fields (director, writer, actor).""" 42 | url: Optional[str] = None 43 | name: Optional[str] = None 44 | 45 | @classmethod 46 | def from_dict(cls, data: Dict[str, Any]) -> 'Person': 47 | return cls( 48 | url=data.get("url"), 49 | name=data.get("name") 50 | ) 51 | -------------------------------------------------------------------------------- /src/differential/utils/ptgen/douban.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass, field 2 | from typing import Optional, Any, Dict, List 3 | 4 | from differential.utils.ptgen.base import PTGenData, Person, DataType 5 | 6 | AREA_MAP = { 7 | "中国大陆": "CN", 8 | "中国香港": "HK", 9 | "中国台湾": "TW", 10 | "美国": "US", 11 | "日本": "JP", 12 | "韩国": "KR", 13 | "印度": "IN", 14 | "法国": "FR", 15 | "意大利": "IT", 16 | "德国": "GE", 17 | "西班牙": "ES", 18 | "葡萄牙": "PT", 19 | } 20 | 21 | @dataclass 22 | class DoubanData(PTGenData): 23 | imdb_id: Optional[str] = None 24 | imdb_link: Optional[str] = None 25 | imdb_rating_average: Optional[float] = None 26 | imdb_votes: Optional[int] = None 27 | imdb_rating: Optional[str] = None 28 | 29 | chinese_title: Optional[str] = None 30 | foreign_title: Optional[str] = None 31 | aka: List[str] = field(default_factory=list) 32 | trans_title: List[str] = field(default_factory=list) 33 | this_title: List[str] = field(default_factory=list) 34 | 35 | year: Optional[str] = None 36 | region: List[str] = field(default_factory=list) 37 | genre: List[str] = field(default_factory=list) 38 | language: List[str] = field(default_factory=list) 39 | episodes: Optional[str] = None 40 | duration: Optional[str] = None 41 | 42 | introduction: Optional[str] = None 43 | douban_rating_average: Optional[float] = None 44 | douban_votes: Optional[int] = None 45 | douban_rating: Optional[str] = None 46 | poster: Optional[str] = None 47 | 48 | director: List[Person] = field(default_factory=list) 49 | writer: List[Person] = field(default_factory=list) 50 | cast: List[Person] = field(default_factory=list) 51 | 52 | tags: List[str] = field(default_factory=list) 53 | awards: Optional[str] = None 54 | 55 | @staticmethod 56 | def from_dict(obj: Dict[str, Any]) -> 'DoubanData': 57 | base = PTGenData( 58 | site=obj['site'], 59 | sid=obj['sid'], 60 | success=obj.get('success', False), 61 | error=obj.get('error'), 62 | format=obj.get('format'), 63 | type_=DataType.TV_SERIES if obj.get('episodes', '').isdigit() else DataType.MOVIE 64 | ) 65 | douban = DoubanData(**base.__dict__) 66 | 67 | douban.imdb_id = obj.get('imdb_id') 68 | douban.imdb_link = obj.get('imdb_link') 69 | douban.imdb_rating_average = obj.get('imdb_rating_average') 70 | douban.imdb_votes = obj.get('imdb_votes') 71 | douban.imdb_rating = obj.get('imdb_rating') 72 | 73 | douban.chinese_title = obj.get('chinese_title') 74 | douban.foreign_title = obj.get('foreign_title') 75 | douban.aka = obj.get('aka', []) 76 | douban.trans_title = obj.get('trans_title', []) 77 | douban.this_title = obj.get('this_title', []) 78 | 79 | douban.year = obj.get('year') 80 | douban.region = obj.get('region', []) 81 | douban.genre = obj.get('genre', []) 82 | douban.language = obj.get('language', []) 83 | douban.episodes = obj.get('episodes') 84 | douban.duration = obj.get('duration') 85 | douban.introduction = obj.get('introduction') 86 | douban.douban_rating_average = obj.get('douban_rating_average') 87 | douban.douban_votes = obj.get('douban_votes') 88 | douban.douban_rating = obj.get('douban_rating') 89 | douban.poster = obj.get('poster') 90 | 91 | if 'director' in obj and isinstance(obj['director'], list): 92 | douban.director = [Person.from_dict(p) for p in obj['director']] 93 | if 'writer' in obj and isinstance(obj['writer'], list): 94 | douban.writer = [Person.from_dict(p) for p in obj['writer']] 95 | if 'cast' in obj and isinstance(obj['cast'], list): 96 | douban.cast = [Person.from_dict(p) for p in obj['cast']] 97 | 98 | douban.tags = obj.get('tags', []) 99 | douban.awards = obj.get('awards') 100 | 101 | return douban 102 | 103 | def __str__(self) -> str: 104 | if self.chinese_title: 105 | return self.chinese_title 106 | elif self.this_title: 107 | return self.this_title[0] 108 | elif self.trans_title: 109 | return self.trans_title[0] 110 | return super().__str__() 111 | 112 | @property 113 | def subtitle(self): 114 | if self.chinese_title: 115 | subtitle = f"{'/'.join([self.chinese_title] + self.aka)}" 116 | else: 117 | subtitle = f"{'/'.join(self.aka)}" 118 | if self.director: 119 | subtitle += ( 120 | f"【导演:{'/'.join([d.name for d in self.director])}】" 121 | ) 122 | if self.writer: 123 | subtitle += ( 124 | f"【编剧:{'/'.join([w.name for w in self.writer])}】" 125 | ) 126 | if self.cast: 127 | subtitle += ( 128 | f"【主演:{'/'.join([c.name for c in self.cast[:3]])}】" 129 | ) 130 | return subtitle 131 | 132 | @property 133 | def area(self): 134 | for area in AREA_MAP.keys(): 135 | if area in self.region: 136 | return AREA_MAP[area] 137 | return "" -------------------------------------------------------------------------------- /src/differential/utils/ptgen/epic.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass, field 2 | from typing import Optional, Any, Dict, List 3 | 4 | from differential.utils.ptgen.base import PTGenData, DataType 5 | 6 | @dataclass 7 | class EpicData(PTGenData): 8 | """ Dataclass for data returned from epic.js """ 9 | name: Optional[str] = None 10 | epic_link: Optional[str] = None 11 | desc: Optional[str] = None 12 | poster: Optional[str] = None 13 | screenshot: List[str] = field(default_factory=list) 14 | language: List[str] = field(default_factory=list) 15 | 16 | # Typically: "min_req" / "max_req" = { 'Windows': ['OS: ...','CPU: ...'], ... } 17 | min_req: Dict[str, List[str]] = field(default_factory=dict) 18 | max_req: Dict[str, List[str]] = field(default_factory=dict) 19 | 20 | # Possibly a list of rating label image URLs 21 | level: List[str] = field(default_factory=list) 22 | 23 | @staticmethod 24 | def from_dict(obj: Dict[str, Any]) -> 'EpicData': 25 | base = PTGenData( 26 | site=obj['site'], 27 | sid=obj['sid'], 28 | success=obj.get('success', False), 29 | error=obj.get('error'), 30 | format=obj.get('format'), 31 | type_=DataType.GAME 32 | ) 33 | epic = EpicData(**base.__dict__) 34 | 35 | epic.name = obj.get('name') 36 | epic.epic_link = obj.get('epic_link') 37 | epic.desc = obj.get('desc') 38 | epic.poster = obj.get('poster') 39 | epic.screenshot = obj.get('screenshot', []) 40 | epic.language = obj.get('language', []) 41 | epic.min_req = obj.get('min_req', {}) 42 | epic.max_req = obj.get('max_req', {}) 43 | epic.level = obj.get('level', []) 44 | 45 | return epic 46 | 47 | def __str__(self) -> str: 48 | if self.name: 49 | return self.name 50 | return super().__str__() -------------------------------------------------------------------------------- /src/differential/utils/ptgen/imdb.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass, field 2 | from typing import Optional, Any, Dict, List 3 | 4 | from differential.utils.ptgen.base import PTGenData, Person, DataType 5 | 6 | @dataclass 7 | class IMDBReleaseDate: 8 | """ For IMDB 'release_date' items: { 'country': ..., 'date': ... } """ 9 | country: Optional[str] = None 10 | date: Optional[str] = None 11 | 12 | @staticmethod 13 | def from_dict(obj: Dict[str, Any]) -> 'IMDBReleaseDate': 14 | return IMDBReleaseDate( 15 | country=obj.get('country'), 16 | date=obj.get('date') 17 | ) 18 | 19 | @dataclass 20 | class IMDBAka: 21 | """ For IMDB 'aka' items: { 'country': ..., 'title': ... } """ 22 | country: Optional[str] = None 23 | title: Optional[str] = None 24 | 25 | @staticmethod 26 | def from_dict(obj: Dict[str, Any]) -> 'IMDBAka': 27 | return IMDBAka( 28 | country=obj.get('country'), 29 | title=obj.get('title') 30 | ) 31 | 32 | 33 | @dataclass 34 | class IMDBData(PTGenData): 35 | """ Dataclass for data returned from imdb.js """ 36 | imdb_id: Optional[str] = None 37 | imdb_link: Optional[str] = None 38 | 39 | name: Optional[str] = None 40 | genre: List[str] = field(default_factory=list) 41 | contentRating: Optional[str] = None 42 | datePublished: Optional[str] = None 43 | description: Optional[str] = None 44 | duration: Optional[str] = None 45 | poster: Optional[str] = None 46 | year: Optional[str] = None 47 | 48 | # People 49 | actors: List[Person] = field(default_factory=list) 50 | directors: List[Person] = field(default_factory=list) 51 | creators: List[Person] = field(default_factory=list) 52 | 53 | keywords: List[str] = field(default_factory=list) 54 | 55 | # Ratings 56 | imdb_votes: Optional[int] = None 57 | imdb_rating_average: Optional[float] = None 58 | imdb_rating: Optional[str] = None 59 | 60 | # Additional info 61 | metascore: Optional[int] = None 62 | reviews: Optional[int] = None 63 | critic: Optional[int] = None 64 | popularity: Optional[int] = None 65 | details: Dict[str, List[str]] = field(default_factory=dict) 66 | 67 | # Release info 68 | release_date: List[IMDBReleaseDate] = field(default_factory=list) 69 | aka: List[IMDBAka] = field(default_factory=list) 70 | 71 | @staticmethod 72 | def from_dict(obj: Dict[str, Any]) -> 'IMDBData': 73 | base = PTGenData( 74 | site=obj['site'], 75 | sid=obj['sid'], 76 | success=obj.get('success', False), 77 | error=obj.get('error'), 78 | format=obj.get('format'), 79 | type_=DataType.from_str(obj.get('@type')) 80 | ) 81 | imdb = IMDBData(**base.__dict__) 82 | 83 | imdb.imdb_id = obj.get('imdb_id') 84 | imdb.imdb_link = obj.get('imdb_link') 85 | imdb.name = obj.get('name') 86 | imdb.genre = obj.get('genre', []) 87 | imdb.contentRating = obj.get('contentRating') 88 | imdb.datePublished = obj.get('datePublished') 89 | imdb.description = obj.get('description') 90 | imdb.duration = obj.get('duration') 91 | imdb.poster = obj.get('poster') 92 | imdb.year = obj.get('year') 93 | 94 | # People 95 | if 'actors' in obj and isinstance(obj['actors'], list): 96 | imdb.actors = [Person.from_dict(x) for x in obj['actors']] 97 | if 'directors' in obj and isinstance(obj['directors'], list): 98 | imdb.directors = [Person.from_dict(x) for x in obj['directors']] 99 | if 'creators' in obj and isinstance(obj['creators'], list): 100 | imdb.creators = [Person.from_dict(x) for x in obj['creators']] 101 | 102 | # Ratings 103 | imdb.keywords = obj.get('keywords', []) 104 | imdb.imdb_votes = obj.get('imdb_votes') 105 | imdb.imdb_rating_average = obj.get('imdb_rating_average') 106 | imdb.imdb_rating = obj.get('imdb_rating') 107 | imdb.metascore = obj.get('metascore') 108 | imdb.reviews = obj.get('reviews') 109 | imdb.critic = obj.get('critic') 110 | imdb.popularity = obj.get('popularity') 111 | imdb.details = obj.get('details', {}) 112 | 113 | # Release info 114 | if 'release_date' in obj and isinstance(obj['release_date'], list): 115 | imdb.release_date = [IMDBReleaseDate.from_dict(r) for r in obj['release_date']] 116 | if 'aka' in obj and isinstance(obj['aka'], list): 117 | imdb.aka = [IMDBAka.from_dict(a) for a in obj['aka']] 118 | 119 | return imdb 120 | 121 | def __str__(self) -> str: 122 | if self.name: 123 | return self.name 124 | return super().__str__() 125 | -------------------------------------------------------------------------------- /src/differential/utils/ptgen/indienova.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass, field 2 | from typing import Optional, Any, Dict, List 3 | 4 | from differential.utils.ptgen.base import PTGenData, DataType 5 | 6 | @dataclass 7 | class IndienovaData(PTGenData): 8 | """ 9 | Dataclass for data returned from indienova.js 10 | """ 11 | cover: Optional[str] = None 12 | poster: Optional[str] = None 13 | chinese_title: Optional[str] = None 14 | another_title: Optional[str] = None 15 | english_title: Optional[str] = None 16 | release_date: Optional[str] = None 17 | links: Dict[str, str] = field(default_factory=dict) # e.g., {'Steam': 'http://...', ...} 18 | intro: Optional[str] = None 19 | intro_detail: List[str] = field(default_factory=list) # e.g., ['类型: 冒险/动作', '视角: 第一人称', ...] 20 | descr: Optional[str] = None # Detailed description; fallback to intro if none 21 | rate: Optional[str] = None # "评分" field 22 | dev: List[str] = field(default_factory=list) # List of developers 23 | pub: List[str] = field(default_factory=list) # List of publishers 24 | screenshot: List[str] = field(default_factory=list) # List of screenshot URLs 25 | cat: List[str] = field(default_factory=list) # Tags 26 | level: List[str] = field(default_factory=list) # Game ratings / ESRB images 27 | price: List[str] = field(default_factory=list) # Price info 28 | 29 | @staticmethod 30 | def from_dict(obj: Dict[str, Any]) -> 'IndienovaData': 31 | """ 32 | Create an IndienovaData instance from a raw dictionary 33 | (typically from the JS function gen_indienova). 34 | """ 35 | base = PTGenData( 36 | site=obj['site'], 37 | sid=obj['sid'], 38 | success=obj.get('success', False), 39 | error=obj.get('error'), 40 | format=obj.get('format'), 41 | type_=DataType.GAME 42 | ) 43 | indienova = IndienovaData(**base.__dict__) 44 | 45 | indienova.cover = obj.get('cover') 46 | indienova.poster = obj.get('poster') 47 | indienova.chinese_title = obj.get('chinese_title') 48 | indienova.another_title = obj.get('another_title') 49 | indienova.english_title = obj.get('english_title') 50 | indienova.release_date = obj.get('release_date') 51 | indienova.links = obj.get('links', {}) 52 | indienova.intro = obj.get('intro') 53 | indienova.intro_detail = obj.get('intro_detail', []) 54 | indienova.descr = obj.get('descr') 55 | indienova.rate = obj.get('rate') 56 | indienova.dev = obj.get('dev', []) 57 | indienova.pub = obj.get('pub', []) 58 | indienova.screenshot = obj.get('screenshot', []) 59 | indienova.cat = obj.get('cat', []) 60 | indienova.level = obj.get('level', []) 61 | indienova.price = obj.get('price', []) 62 | 63 | return indienova 64 | 65 | def __str__(self) -> str: 66 | if self.chinese_title: 67 | return self.chinese_title 68 | elif self.english_title: 69 | return self.english_title 70 | elif self.another_title: 71 | return self.another_title 72 | return super().__str__() 73 | -------------------------------------------------------------------------------- /src/differential/utils/ptgen/parser.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict 2 | 3 | from differential.utils.ptgen.base import PTGenData 4 | from differential.utils.ptgen.douban import DoubanData 5 | from differential.utils.ptgen.imdb import IMDBData 6 | from differential.utils.ptgen.indienova import IndienovaData 7 | from differential.utils.ptgen.steam import SteamData 8 | from differential.utils.ptgen.epic import EpicData 9 | from differential.utils.ptgen.bangumi import BangumiData 10 | 11 | def parse_ptgen(data: Dict[str, Any]) -> PTGenData: 12 | site = data.get('site') 13 | if site == 'douban': 14 | return DoubanData.from_dict(data) 15 | elif site == 'imdb': 16 | return IMDBData.from_dict(data) 17 | elif site == 'steam': 18 | return SteamData.from_dict(data) 19 | elif site == 'epic': 20 | return EpicData.from_dict(data) 21 | elif site == 'bangumi': 22 | return BangumiData.from_dict(data) 23 | elif site == 'indienova': 24 | return IndienovaData.from_dict(data) 25 | else: 26 | # Fallback: just parse it into BaseData or raise error 27 | return PTGenData( 28 | site=data.get('site', 'unknown'), 29 | sid=data.get('sid', ''), 30 | success=data.get('success', False), 31 | error=data.get('error'), 32 | format=data.get('format', 'PTGen获取失败,请自行获取相关内容') 33 | ) -------------------------------------------------------------------------------- /src/differential/utils/ptgen/steam.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass, field 2 | from typing import Optional, Any, Dict, List 3 | 4 | from differential.utils.ptgen.base import PTGenData, DataType 5 | 6 | @dataclass 7 | class SteamData(PTGenData): 8 | """ Dataclass for data returned from steam.js """ 9 | steam_id: Optional[str] = None 10 | poster: Optional[str] = None 11 | name: Optional[str] = None 12 | detail: Optional[str] = None 13 | tags: List[str] = field(default_factory=list) 14 | review: List[str] = field(default_factory=list) 15 | linkbar: Optional[str] = None 16 | language: List[str] = field(default_factory=list) 17 | descr: Optional[str] = None 18 | screenshot: List[str] = field(default_factory=list) 19 | sysreq: List[str] = field(default_factory=list) 20 | name_chs: Optional[str] = None 21 | 22 | @staticmethod 23 | def from_dict(obj: Dict[str, Any]) -> 'SteamData': 24 | base = PTGenData( 25 | site=obj['site'], 26 | sid=obj['sid'], 27 | success=obj.get('success', False), 28 | error=obj.get('error'), 29 | format=obj.get('format'), 30 | type_=DataType.GAME 31 | ) 32 | steam = SteamData(**base.__dict__) 33 | 34 | steam.steam_id = obj.get('steam_id') 35 | steam.poster = obj.get('poster') 36 | steam.name = obj.get('name') 37 | steam.detail = obj.get('detail') 38 | steam.tags = obj.get('tags', []) 39 | steam.review = obj.get('review', []) 40 | steam.linkbar = obj.get('linkbar') 41 | steam.language = obj.get('language', []) 42 | steam.descr = obj.get('descr') 43 | steam.screenshot = obj.get('screenshot', []) 44 | steam.sysreq = obj.get('sysreq', []) 45 | steam.name_chs = obj.get('name_chs') 46 | 47 | return steam 48 | 49 | def __str__(self) -> str: 50 | if self.name_chs: 51 | return self.name_chs 52 | elif self.name: 53 | return self.name 54 | return super().__str__() -------------------------------------------------------------------------------- /src/differential/utils/ptgen_handler.py: -------------------------------------------------------------------------------- 1 | import requests 2 | from loguru import logger 3 | from typing import Optional, Union 4 | 5 | from differential.utils.ptgen.base import PTGenData 6 | from differential.utils.ptgen.imdb import IMDBData 7 | from differential.utils.ptgen.douban import DoubanData 8 | from differential.utils.ptgen.parser import parse_ptgen 9 | 10 | class PTGenHandler: 11 | """ 12 | Handles fetching information from PTGen (and optionally IMDB). 13 | """ 14 | 15 | def __init__(self, url: str, ptgen_url: str, second_ptgen_url: str, ptgen_retry: int): 16 | self.url = url 17 | self.ptgen_url = ptgen_url 18 | self.second_ptgen_url = second_ptgen_url 19 | self.ptgen_retry = ptgen_retry 20 | 21 | self._ptgen: Optional[PTGenData] = None 22 | self._douban: Optional[DoubanData] = None 23 | self._imdb: Optional[IMDBData] = None 24 | 25 | def fetch_ptgen_info(self): 26 | """ 27 | Public method to fetch PTGen (and optional IMDB) data, 28 | with retry logic switching between ptgen_url and second_ptgen_url. 29 | """ 30 | attempts_left = 2 * self.ptgen_retry 31 | while attempts_left > 0: 32 | use_second = attempts_left <= self.ptgen_retry 33 | self._ptgen = self._request_ptgen_info(use_second=use_second) 34 | if self._ptgen.success: 35 | return (self._ptgen, self._douban, self._imdb) 36 | attempts_left -= 1 37 | 38 | return (self._ptgen, self._douban, self._imdb) 39 | 40 | def _request_ptgen_info(self, use_second: bool = False) -> PTGenData: 41 | ptgen_url = self.second_ptgen_url if use_second else self.ptgen_url 42 | logger.debug(f"[PTGen] 正在从 {ptgen_url} 获取 {self.url}") 43 | params = {"url": self.url} 44 | 45 | try: 46 | resp = requests.get(ptgen_url, params=params, timeout=15) 47 | if not resp.ok: 48 | logger.trace(resp.content) 49 | logger.warning(f"[PTGen] HTTP {resp.status_code} - {resp.reason}") 50 | return PTGenData() 51 | 52 | ptgen = parse_ptgen(resp.json()) 53 | if not ptgen.success: 54 | logger.trace(resp.json()) 55 | logger.warning(f"[PTGen] 获取失败: {ptgen.error}") 56 | return ptgen 57 | 58 | if ptgen.site != "imdb": 59 | if ptgen.site == 'douban': 60 | self._douban = ptgen 61 | if hasattr(ptgen, "imdb_link"): 62 | self._imdb = self._try_fetch_imdb(ptgen.imdb_link) 63 | else: 64 | self._imdb = ptgen 65 | 66 | logger.info(f"[PTGen] 获取成功: {ptgen}") 67 | return ptgen 68 | except requests.RequestException as e: 69 | logger.warning(f"[PTGen] 请求异常: {e}") 70 | return PTGenData() 71 | 72 | def _try_fetch_imdb(self, imdb_link: str) -> IMDBData: 73 | """ 74 | Attempt to fetch IMDB info from PTGen for the provided IMDB link. 75 | Returns a dict or empty if failed. 76 | """ 77 | try: 78 | req = requests.get(self.ptgen_url, params={"url": imdb_link}, timeout=15) 79 | if req.ok and req.json().get("success"): 80 | return IMDBData.from_dict(req.json()) 81 | except Exception as e: 82 | logger.warning(f"[IMDB] 请求异常: {e}") 83 | return IMDBData() -------------------------------------------------------------------------------- /src/differential/utils/screenshot_handler.py: -------------------------------------------------------------------------------- 1 | import tempfile 2 | from PIL import Image 3 | from loguru import logger 4 | from pathlib import Path 5 | from decimal import Decimal 6 | 7 | from differential.version import version 8 | from differential.utils.binary import execute 9 | from differential.constants import ImageHosting 10 | from differential.utils.image import ( 11 | get_all_images, 12 | byr_upload, 13 | hdbits_upload, 14 | imgbox_upload, 15 | ptpimg_upload, 16 | smms_upload, 17 | imgurl_upload, 18 | chevereto_upload, 19 | cloudinary_upload, 20 | ) 21 | 22 | class ScreenshotHandler: 23 | """ 24 | Manages creating (ffmpeg) and uploading screenshots 25 | to a given image host. 26 | """ 27 | 28 | def __init__( 29 | self, 30 | folder: Path, 31 | screenshot_count: int = 0, 32 | screenshot_path: str = None, 33 | optimize_screenshot: bool = True, 34 | image_hosting: ImageHosting = ImageHosting.PTPIMG, 35 | chevereto_hosting_url: str = "", 36 | imgurl_hosting_url: str = "", 37 | ptpimg_api_key: str = None, 38 | hdbits_cookie: str = None, 39 | hdbits_thumb_size: str = "w300", 40 | chevereto_api_key: str = None, 41 | chevereto_username: str = None, 42 | chevereto_password: str = None, 43 | cloudinary_cloud_name: str = None, 44 | cloudinary_api_key: str = None, 45 | cloudinary_api_secret: str = None, 46 | imgurl_api_key: str = None, 47 | smms_api_key: str = None, 48 | byr_cookie: str = None, 49 | byr_alternative_url: str = None, 50 | imgbox_username: str = None, 51 | imgbox_password: str = None, 52 | imgbox_thumbnail_size: str = "300r", 53 | imgbox_family_safe: bool = True, 54 | ): 55 | self.folder = folder 56 | self.screenshot_count = screenshot_count 57 | self.screenshot_path = screenshot_path 58 | self.optimize_screenshot = optimize_screenshot 59 | self.image_hosting = image_hosting 60 | self.chevereto_hosting_url = chevereto_hosting_url 61 | self.imgurl_hosting_url = imgurl_hosting_url 62 | self.ptpimg_api_key = ptpimg_api_key 63 | self.hdbits_cookie = hdbits_cookie 64 | self.hdbits_thumb_size = hdbits_thumb_size 65 | self.chevereto_username = chevereto_username 66 | self.chevereto_password = chevereto_password 67 | self.chevereto_api_key = chevereto_api_key 68 | self.cloudinary_cloud_name = cloudinary_cloud_name 69 | self.cloudinary_api_key = cloudinary_api_key 70 | self.cloudinary_api_secret = cloudinary_api_secret 71 | self.imgurl_api_key = imgurl_api_key 72 | self.smms_api_key = smms_api_key 73 | self.byr_cookie = byr_cookie 74 | self.byr_alternative_url = byr_alternative_url 75 | self.imgbox_username = imgbox_username 76 | self.imgbox_password = imgbox_password 77 | self.imgbox_thumbnail_size = imgbox_thumbnail_size 78 | self.imgbox_family_safe = imgbox_family_safe 79 | 80 | self.screenshots: list = [] 81 | 82 | def collect_screenshots(self, main_file: Path, resolution: str, duration: Decimal) -> list: 83 | """ 84 | If screenshot_path is given, use images from that folder. 85 | Otherwise, generate screenshots from main_file. 86 | Then upload them. 87 | Returns a list of ImageUploaded objects. 88 | """ 89 | if self.screenshot_count <= 0: 90 | return 91 | 92 | if self.screenshot_path: 93 | logger.info("[Screenshots] 使用提供的截图文件夹...") 94 | self.screenshots = self._upload_screenshots(self.screenshot_path) 95 | else: 96 | logger.info("[Screenshots] 生成并上传截图...") 97 | temp_dir = self._generate_screenshots(main_file, resolution, duration) 98 | if not temp_dir: 99 | return 100 | self.screenshots = self._upload_screenshots(temp_dir) 101 | 102 | def _generate_screenshots(self, main_file: Path, resolution: str, duration: Decimal) -> str: 103 | if not resolution or not duration: 104 | logger.warning("[Screenshots] 文件无法提取分辨率或时长,无法生成截图") 105 | return None 106 | 107 | for f in Path(tempfile.gettempdir()).glob( 108 | f"Differential.screenshots.{version}.*.{self.folder.name}" 109 | ): 110 | if f.is_dir(): 111 | if 0 < self.screenshot_count == len(list(f.glob("*.png"))): 112 | logger.info("[Screenshots] 发现已生成的{}张截图,跳过截图...".format(self.screenshot_count)) 113 | return f.absolute() 114 | 115 | tmp_dir = tempfile.mkdtemp(prefix=f"Differential.screenshots.{version}.", suffix=f".{self.folder.name}") 116 | for i in range(1, self.screenshot_count + 1): 117 | logger.info(f"正在生成第{i}张截图...") 118 | timestamp_ms = int(i * duration / (self.screenshot_count + 1)) 119 | output_path = Path(tmp_dir).joinpath(f"{main_file.stem}.thumb_{i:02d}.png") 120 | 121 | args = ( 122 | f'-y -ss {timestamp_ms}ms -skip_frame nokey ' 123 | f'-i "{main_file.absolute()}" ' 124 | f'-s {resolution} -vsync 0 -vframes 1 -c:v png "{output_path}"' 125 | ) 126 | execute("ffmpeg", args) 127 | 128 | if self.optimize_screenshot and output_path.exists(): 129 | try: 130 | img = Image.open(output_path) 131 | img.save(output_path, format="PNG", optimize=True) 132 | except Exception as e: 133 | logger.error(f"Screenshot optimization failed: {e}") 134 | 135 | return tmp_dir 136 | 137 | def _upload_screenshots(self, img_dir: str) -> list: 138 | """ 139 | Upload screenshots from the given directory to the chosen image host. 140 | Returns a list of ImageUploaded objects. 141 | """ 142 | images = sorted(get_all_images(img_dir)) 143 | if not images: 144 | logger.warning("[Screenshots] 未找到可用图片.") 145 | return [] 146 | 147 | uploaded = [] 148 | if self.image_hosting == ImageHosting.HDB: 149 | uploaded = hdbits_upload( 150 | images, 151 | self.hdbits_cookie, 152 | self.folder.name, 153 | self.hdbits_thumb_size, 154 | ) 155 | elif self.image_hosting == ImageHosting.IMGBOX: 156 | uploaded = imgbox_upload( 157 | images, 158 | self.imgbox_username, 159 | self.imgbox_password, 160 | self.folder.name, 161 | self.imgbox_thumbnail_size, 162 | self.imgbox_family_safe, 163 | False, 164 | ) 165 | elif self.image_hosting == ImageHosting.PTPIMG: 166 | uploaded = ptpimg_upload(images, self.ptpimg_api_key) 167 | elif self.image_hosting == ImageHosting.CHEVERETO: 168 | uploaded = chevereto_upload(images, self.chevereto_hosting_url, self.chevereto_api_key, self.chevereto_username, self.chevereto_password) 169 | elif self.image_hosting == ImageHosting.CLOUDINARY: 170 | uploaded = cloudinary_upload(images, self.folder.stem, self.cloudinary_cloud_name, self.cloudinary_api_key, self.cloudinary_api_secret) 171 | elif self.image_hosting == ImageHosting.IMGURL: 172 | uploaded = imgurl_upload(images, self.imgurl_hosting_url, self.imgurl_api_key) 173 | elif self.image_hosting == ImageHosting.SMMS: 174 | uploaded = smms_upload(images, self.smms_api_key) 175 | elif self.image_hosting == ImageHosting.BYR: 176 | uploaded = byr_upload( images, self.byr_cookie, self.byr_alternative_url) 177 | else: 178 | logger.error(f"不支持的图片上传方式: {self.image_hosting}") 179 | 180 | return uploaded -------------------------------------------------------------------------------- /src/differential/utils/torrent.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import List, Optional 3 | 4 | import bencodepy 5 | from torf import Torrent 6 | from loguru import logger 7 | 8 | from differential.version import version 9 | 10 | 11 | def remake_torrent(path: Path, tracker: str, old_torrent: str) -> Optional[bytes]: 12 | if not Path(old_torrent).is_file(): 13 | return None 14 | try: 15 | with open(old_torrent, 'rb') as f: 16 | t = f.read() 17 | torrent = bencodepy.decode(t) 18 | except: 19 | import traceback 20 | traceback.print_exc() 21 | return None 22 | 23 | _name = torrent.get(b'info', {}).get(b'name').decode() 24 | if _name != path.name: 25 | logger.warning(f"洗种的基础种子很可能不匹配!基础种子文件名为:{_name},而将要制种的文件名为:{path.name}") 26 | 27 | new_torrent = {} 28 | if tracker: 29 | new_torrent[b'announce'] = tracker 30 | new_torrent[b'created by'] = f"Differential {version}" 31 | new_torrent[b'comment'] = f"Generate by Differential {version} made by XGCM" 32 | new_torrent[b'info'] = {b'private': 1} 33 | for k, v in torrent[b'info'].items(): 34 | if k in (b'length', b'files', b'name', b'piece length', b'pieces'): 35 | new_torrent[b'info'][k] = v 36 | return bencodepy.encode(new_torrent) 37 | 38 | 39 | def make_torrent_progress(torrent, filepath, pieces_done, pieces_total): 40 | logger.info(f'制种进度: {pieces_done/pieces_total*100:3.0f} %') 41 | 42 | def make_torrent(path: Path, tracker: str, prefix: str = None, reuse_torrent: bool = True, from_torrent: str = None): 43 | torrent_name = path.resolve().parent.joinpath((f"[{prefix}]." if prefix else '') + f"{path.name if path.is_dir() else path.stem}.torrent") 44 | if from_torrent and Path(from_torrent).is_file(): 45 | logger.info(f"正在基于{from_torrent}制作种子...") 46 | torrent = remake_torrent(path, tracker, from_torrent) 47 | if torrent: 48 | with open(torrent_name, 'wb') as f: 49 | f.write(torrent) 50 | logger.info(f"种子制作完成:{torrent_name.absolute()}") 51 | return 52 | if reuse_torrent: 53 | for f in path.resolve().parent.glob(f'*{path.name if path.is_dir() else path.stem}.torrent'): 54 | logger.info(f"正在基于{f.name}制作种子...") 55 | torrent = remake_torrent(path, tracker, f) 56 | if torrent: 57 | with open(torrent_name, 'wb') as f: 58 | f.write(torrent) 59 | logger.info(f"种子制作完成:{torrent_name.absolute()}") 60 | return 61 | 62 | logger.info("正在生成种子...") 63 | t = Torrent(path=path, trackers=[tracker], 64 | created_by=f"Differential {version}", 65 | comment=f"Generate by Differential {version} made by XGCM") 66 | t.private = True 67 | t.generate(callback=make_torrent_progress, interval=1) 68 | t.write(torrent_name, overwrite=True) 69 | logger.info(f"种子制作完成:{torrent_name.absolute()}") 70 | -------------------------------------------------------------------------------- /src/differential/utils/uploader/__init__.py: -------------------------------------------------------------------------------- 1 | from differential.utils.uploader.easy_upload import EasyUpload 2 | from differential.utils.uploader.auto_feed import AutoFeed 3 | -------------------------------------------------------------------------------- /src/differential/utils/uploader/auto_feed.py: -------------------------------------------------------------------------------- 1 | import base64 2 | from urllib.parse import quote 3 | from functools import reduce 4 | from differential.torrent import TorrnetBase 5 | from differential.utils.mediainfo import get_full_mediainfo 6 | 7 | 8 | class AutoFeed(TorrnetBase): 9 | 10 | def __init__(self, plugin: TorrnetBase, separator: str = "separator#"): 11 | self.plugin = plugin 12 | self.separator = separator 13 | 14 | def __getattribute__(self, name): 15 | try: 16 | return super().__getattribute__(name) 17 | except NotImplementedError: 18 | if not name.startswith("__") and name in dir(TorrnetBase): 19 | return getattr(self.plugin, name) 20 | else: 21 | raise AttributeError(name) 22 | 23 | @property 24 | def category(self): 25 | category = self.plugin.category 26 | category_map = { 27 | "movie": "电影", 28 | "tvPack": "剧集", 29 | "documentary": "记录", 30 | "concert": "音乐", 31 | # "_": "综艺", 32 | # "_": "动漫", 33 | # "_": "游戏", 34 | # "_": "书籍", 35 | # "_": "MV", 36 | # "_": "学习", 37 | # "_": "软件", 38 | # "_": "体育" 39 | } 40 | if category in category_map: 41 | return category_map[category] 42 | return '' 43 | 44 | @property 45 | def video_codec(self): 46 | return self.plugin.video_codec.upper() 47 | 48 | @property 49 | def audio_codec(self): 50 | return self.plugin.audio_codec.upper() 51 | 52 | @property 53 | def resolution(self): 54 | resolution = self.plugin.resolution 55 | if resolution == "4320p": 56 | return "8K" 57 | elif resolution == "2160p": 58 | return "4K" 59 | elif resolution == "480p": 60 | return "SD" 61 | return resolution 62 | 63 | @property 64 | def area(self): 65 | area = self.plugin.area 66 | area_map = { 67 | "CN": "大陆", 68 | "HK": "港台", 69 | "TW": "港台", 70 | "US": "欧美", 71 | "JP": "日本", 72 | "KR": "韩国", 73 | "IN": "印度", 74 | "FR": "欧美", 75 | "IT": "欧美", 76 | "GE": "欧美", 77 | "ES": "欧美", 78 | "PT": "欧美", 79 | } 80 | if area in area_map: 81 | return area_map[area] 82 | return '' 83 | 84 | @property 85 | def _raw_info(self) -> dict: 86 | return { 87 | # 填充类信息 88 | "name": self.title, # 主标题 89 | "small_descr": self.subtitle, # 副标题 90 | "url": self.imdb_url, # imdb链接 91 | "dburl": self.douban_url, # 豆瓣链接 92 | "descr": self.description, # 简介 93 | "log_info": "", # 音乐特有 94 | "tracklist": "", # 音乐特有 95 | "music_type": "", # 音乐特有 96 | "music_media": "", # 音乐特有 97 | "edition_info": "", # 音乐特有 98 | "music_name": "", # 音乐特有 99 | "music_author": "", # 音乐特有 100 | "animate_info": "", # 动漫特有|针对北邮人北洋U2的命名方式 101 | "anidb": "", # 动漫特有 102 | "torrentName": "", # 动漫辅助 103 | "images": self.screenshots if self.screenshots else '', # 截图 104 | "torrent_name": "", # 用于转发内站 105 | "torrent_url": "", # 用于转发内站 106 | # 选择类信息 107 | "type": self.category, # type:可取值——电影/纪录/体育/剧集/动画/综艺…… 108 | "source_sel": self.area, # 来源(地区):可取值——欧美/大陆/港台/日本/韩国/印度…… 109 | "standard_sel": self.resolution, # 分辨率:可取值——4K/1080p/1080i/720p/SD 110 | "audiocodec_sel": self.audio_codec, # 音频:可取值——AAC/AC3/DTS…… 111 | "codec_sel": self.video_codec, # 编码:可取值——H264/H265…… 112 | "medium_sel": self.video_type, # 媒介:可取值——web-dl/remux/encode…… 113 | # 其他 114 | "origin_site": "", # 记录源站点用于跳转后识别 115 | "origin_url": "", # 记录源站点用于跳转后识别 116 | "golden_torrent": "false", # 主要用于皮转柠檬, 转过去之后会变成字符串 117 | "mediainfo_cmct": "", # 适用于春天的info 118 | "imgs_cmct": "", # 适用于春天的截图 119 | "full_mediainfo": "", # 完整的mediainfo有的站点有长短两种,如:铂金家、猫、春天 120 | "subtitles": "", # 针对皮转海豹,字幕 121 | "youtube_url": "", # 用于发布iTS 122 | "ptp_poster": "", # 用于发布iTS 123 | "comparisons": "", # 用于海豹 124 | "version_info": "", # 用于海豹 125 | "multi_mediainfo": "", # 用于海豹 126 | "labels": 0, 127 | } 128 | 129 | @property 130 | def info(self) -> str: 131 | l = list(reduce(lambda k, v: k + v, self._raw_info.items())) 132 | return self.separator + base64.b64encode( 133 | quote("#linkstr#".join([str(i) for i in l])).encode("utf-8") 134 | ).decode("utf-8") -------------------------------------------------------------------------------- /src/differential/utils/uploader/easy_upload.py: -------------------------------------------------------------------------------- 1 | 2 | from differential.torrent import TorrnetBase 3 | 4 | 5 | class EasyUpload(TorrnetBase): 6 | 7 | def __init__(self, plugin: TorrnetBase): 8 | self.plugin = plugin 9 | 10 | def __getattribute__(self, name): 11 | try: 12 | return super().__getattribute__(name) 13 | except NotImplementedError: 14 | if not name.startswith('__') and name in dir(TorrnetBase): 15 | return getattr(self.plugin, name) 16 | else: 17 | raise AttributeError(name) 18 | 19 | @property 20 | def area(self): 21 | # map EU coutries to EU 22 | area = self.plugin.area 23 | area_map = { 24 | "FR": "EU", 25 | "IT": "EU", 26 | "GE": "EU", 27 | "ES": "EU", 28 | "PT": "EU", 29 | } 30 | if area in area_map: 31 | return area_map[area] 32 | return area 33 | 34 | @property 35 | def torrent_info(self) -> dict: 36 | return { 37 | "title": self.title, 38 | "subtitle": self.subtitle, 39 | "description": self.description, 40 | "originalDescription": self.original_description, 41 | "doubanUrl": self.douban_url, 42 | "doubanInfo": self.douban_info, 43 | "imdbUrl": self.imdb_url, 44 | "mediaInfo": self.media_info, 45 | "mediaInfos": self.media_infos, 46 | "screenshots": self.screenshots, 47 | "poster": self.poster, 48 | "year": self.year, 49 | "category": self.category, 50 | "videoType": self.video_type, 51 | "format": self.format, 52 | "source": self.source, 53 | "videoCodec": self.video_codec, 54 | "audioCodec": self.audio_codec, 55 | "resolution": self.resolution, 56 | "area": self.area, 57 | "movieAkaName": self.movie_aka_name, 58 | "movieName": self.movie_name, 59 | "size": self.size, 60 | "tags": self.tags, 61 | "otherTags": self.other_tags, 62 | "comparisons": self.comparisons, 63 | "isForbidden": False, 64 | "sourceSiteType": "NexusPHP", 65 | } 66 | -------------------------------------------------------------------------------- /src/differential/version.py: -------------------------------------------------------------------------------- 1 | version = '0.6.5.dev3+g7ed438a.d20250316' -------------------------------------------------------------------------------- /usage.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeiShi1313/Differential/62c5a224e4cff3ad50e42e4f2cb5bc0a50030322/usage.gif --------------------------------------------------------------------------------