├── .gitattributes ├── .gitignore ├── LICENSE ├── README.md ├── icons ├── Identifier.png ├── Infuse.png ├── bangumi.jpg ├── danmu.png ├── discord.png └── logo.jpg ├── package.json ├── package.v2.json ├── plugins.v2 ├── IdentifierHelper │ └── __init__.py ├── bangumi │ ├── README.md │ ├── __init__.py │ ├── bangumi_db.py │ └── bangumi_db_oper.py ├── bdremuxer │ ├── README.md │ ├── __init__.py │ └── requirements.txt ├── danmu │ ├── README.md │ ├── __init__.py │ ├── danmu_generator.py │ ├── dist │ │ ├── assets │ │ │ ├── __federation_expose_Config-BdvrOUFg.js │ │ │ ├── __federation_expose_Config-mmMv5D16.css │ │ │ ├── __federation_expose_Page-CyDIESC3.css │ │ │ ├── __federation_expose_Page-DF3RUHu3.js │ │ │ ├── __federation_fn_import-JrT3xvdd.js │ │ │ ├── __federation_shared_vuetify │ │ │ │ └── styles-CZ3C7oSZ.css │ │ │ ├── _plugin-vue_export-helper-pcqpp-6-.js │ │ │ ├── date-BMtbN87Q.js │ │ │ ├── index-DNuvSYik.js │ │ │ ├── index-cr18jDRr.css │ │ │ └── remoteEntry.js │ │ └── index.html │ ├── doc │ │ ├── api.png │ │ ├── api2.png │ │ └── page.png │ ├── index.html │ ├── package.json │ ├── src │ │ ├── App.vue │ │ ├── components │ │ │ ├── Config.vue │ │ │ └── Page.vue │ │ └── main.js │ ├── vite.config.js │ └── yarn.lock ├── discord │ ├── README.md │ ├── __init__.py │ ├── cogs │ │ └── moviepilot_cog.py │ ├── discord_bot.py │ ├── gpt.py │ ├── requirements.txt │ └── tokenes.py └── test │ └── __init__.py └── plugins ├── bangumi ├── README.md ├── __init__.py ├── bangumi_db.py └── bangumi_db_oper.py ├── bdremuxer ├── README.md ├── __init__.py └── requirements.txt ├── danmu ├── README.md ├── __init__.py └── danmu_generator.py ├── discord ├── README.md ├── __init__.py ├── cogs │ └── moviepilot_cog.py ├── discord_bot.py ├── gpt.py ├── requirements.txt └── tokenes.py ├── rmcdata ├── README.md └── __init__.py └── test └── __init__.py /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | test.py 6 | update_version.py 7 | bdextractor.py 8 | # C extensions 9 | *.so 10 | 11 | # Distribution / packaging 12 | .Python 13 | build/ 14 | develop-eggs/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 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 | cover/ 54 | 55 | # Translations 56 | *.mo 57 | *.pot 58 | 59 | # Django stuff: 60 | *.log 61 | local_settings.py 62 | db.sqlite3 63 | db.sqlite3-journal 64 | 65 | # Flask stuff: 66 | instance/ 67 | .webassets-cache 68 | 69 | # Scrapy stuff: 70 | .scrapy 71 | 72 | # Sphinx documentation 73 | docs/_build/ 74 | 75 | # PyBuilder 76 | .pybuilder/ 77 | target/ 78 | 79 | # Jupyter Notebook 80 | .ipynb_checkpoints 81 | 82 | # IPython 83 | profile_default/ 84 | ipython_config.py 85 | 86 | # pyenv 87 | # For a library or package, you might want to ignore these files since the code is 88 | # intended to run in multiple environments; otherwise, check them in: 89 | # .python-version 90 | 91 | # pipenv 92 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 93 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 94 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 95 | # install all needed dependencies. 96 | #Pipfile.lock 97 | 98 | # poetry 99 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 100 | # This is especially recommended for binary packages to ensure reproducibility, and is more 101 | # commonly ignored for libraries. 102 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 103 | #poetry.lock 104 | 105 | # pdm 106 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 107 | #pdm.lock 108 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 109 | # in version control. 110 | # https://pdm.fming.dev/#use-with-ide 111 | .pdm.toml 112 | 113 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 114 | __pypackages__/ 115 | 116 | # Celery stuff 117 | celerybeat-schedule 118 | celerybeat.pid 119 | 120 | # SageMath parsed files 121 | *.sage.py 122 | 123 | # Environments 124 | .env 125 | .venv 126 | env/ 127 | venv/ 128 | ENV/ 129 | env.bak/ 130 | venv.bak/ 131 | 132 | # Spyder project settings 133 | .spyderproject 134 | .spyproject 135 | 136 | # Rope project settings 137 | .ropeproject 138 | 139 | # mkdocs documentation 140 | /site 141 | 142 | # mypy 143 | .mypy_cache/ 144 | .dmypy.json 145 | dmypy.json 146 | 147 | # Pyre type checker 148 | .pyre/ 149 | 150 | # pytype static type analyzer 151 | .pytype/ 152 | 153 | # Cython debug symbols 154 | cython_debug/ 155 | 156 | # PyCharm 157 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 158 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 159 | # and can be added to the global gitignore or merged into this file. For a more nuclear 160 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 161 | #.idea/ 162 | test.test 163 | json.py 164 | 165 | ignore/ 166 | test_danmu.py 167 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MoviePilot-Plugins 2 | MoviePilot官方插件市场:https://raw.githubusercontent.com/jxxghp/MoviePilot-Plugins/main/ 3 | 4 | ### 使用 5 | 6 | 添加 https://raw.githubusercontent.com/HankunYu/MoviePilot-Plugins/main/ 到 PLUGIN_MARKET 7 | 8 | 请别使用版本0.x开头的插件,还在开发中... 9 | 10 | --- 11 | 12 | ### [Discord 消息推送插件](plugins/discord/README.md) 13 | 14 | 通过discord webhook推送MoviePilot的消息到discord频道中。 15 | 使用discord bot发送命令,加入OpenAI token后,可以使用GPT-3.5进行对话。 16 | 17 |
18 | 19 | ### [Infuse nfo 简介修复](plugins/rmcdata/README.md) 20 | 21 | 通过去除nfo中的CDATA标签修复infuse无法读取nfo内容导致媒体简介空白的问题。 22 | 23 |
24 | 25 | ### [Bangumi 同步](plugins/bangumi/README.md) 26 | 27 | 同步MoviePilot的观看记录到 Bangumi 收藏中。 28 | 自动订阅/下载 Bangumi 收藏的"想看"条目。 29 | 更改本地动漫 NFO文件评分为Bangumi评分。 30 | 更改订阅页面的评分为Bangumi评分。 31 | 32 | 需要申请Bangumi API Key,申请地址:https://next.bgm.tv/demo/access-token 33 | 34 |
35 | 36 | ### [弹幕刮削](plugins.v2/danmu/README.md) 37 | 38 | 自动刮削新入库文件,可以全局文件刮削。 39 | 使用弹弹Play弹幕库刮削弹幕到本地转为ass文件。 40 | .danmu为刮削出来的纯弹幕,.withDanmu为原生字幕与弹幕合并后的文件。方便不支持双字幕的播放器使用。 41 | -------------------------------------------------------------------------------- /icons/Identifier.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HankunYu/MoviePilot-Plugins/b129f043eddcec0f9028a0eea86f0ef5db4d2cc1/icons/Identifier.png -------------------------------------------------------------------------------- /icons/Infuse.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HankunYu/MoviePilot-Plugins/b129f043eddcec0f9028a0eea86f0ef5db4d2cc1/icons/Infuse.png -------------------------------------------------------------------------------- /icons/bangumi.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HankunYu/MoviePilot-Plugins/b129f043eddcec0f9028a0eea86f0ef5db4d2cc1/icons/bangumi.jpg -------------------------------------------------------------------------------- /icons/danmu.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HankunYu/MoviePilot-Plugins/b129f043eddcec0f9028a0eea86f0ef5db4d2cc1/icons/danmu.png -------------------------------------------------------------------------------- /icons/discord.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HankunYu/MoviePilot-Plugins/b129f043eddcec0f9028a0eea86f0ef5db4d2cc1/icons/discord.png -------------------------------------------------------------------------------- /icons/logo.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HankunYu/MoviePilot-Plugins/b129f043eddcec0f9028a0eea86f0ef5db4d2cc1/icons/logo.jpg -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "Discord": { 3 | "name": "Discord 消息推送", 4 | "description": "添加discord消息推送", 5 | "version": "1.5.8", 6 | "icon": "https://raw.githubusercontent.com/HankunYu/MoviePilot-Plugins/main/icons/discord.png", 7 | "color": "#3B5E8E", 8 | "author": "hankun", 9 | "level": 1, 10 | "history":{ 11 | "v1.5.8": "回退到老版本,针对v2有另外的版本", 12 | "v1.5.0": "增加对v2的支持" 13 | }, 14 | "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAyNe4GKw15qXHWAYBhHDlUDxM7MfrdmstwRoTtn+PNK9uvpsZip1/R6azzJi4hHLsEOvVLyfjjaEVDyZrJVGTxC+Fj+6JFrq4LnwB0CHlbXAN+UkMk3uD/srRx3eOVSZuqoCDplsX98DUim5Vx/xzyoRSwbWqMJvYsIawaKO9yZyA7ErjmyNOk1jduy1FYLISzyICtz0ubYsohzUZxbcZgqb+qBeECv0SofSMM+luvIHiqTPPMRKmjD6WufGerwm7r9r26coG5CvYgOX+jOJITIpAqXkAqqXNNoVCuTRazxF/OJFjbTsJLO1WnyIr6qB6oQW/7XgLk0Ot5M1Jx2JW/QIDAQAB" 15 | 16 | }, 17 | "RmCdata": { 18 | "name": "Infuse nfo 简介修复", 19 | "description": "去除cdata标签以修复infuse简介显示", 20 | "version": "1.2.6", 21 | "icon": "https://raw.githubusercontent.com/HankunYu/MoviePilot-Plugins/main/icons/Infuse.png", 22 | "color": "#32699D", 23 | "author": "hankun", 24 | "level": 1, 25 | "v2": true 26 | }, 27 | "Bangumi": { 28 | "name": "Bangumi 同步", 29 | "description": "动漫资源引用Bangumi评分,同步订阅/库存番剧到Bangumi", 30 | "version": "1.0.15", 31 | "icon": "https://raw.githubusercontent.com/HankunYu/MoviePilot-Plugins/main/icons/bangumi.jpg", 32 | "color": "#5378A4", 33 | "author": "hankun", 34 | "level": 1 35 | }, 36 | "BDRemuxer": { 37 | "name": "BD Remuxer", 38 | "description": "使用ffmpeg自动提取BDMV文件夹中的视频流和音频流,合并为MKV文件。低性能设备不推荐使用", 39 | "version": "1.1.0", 40 | "icon": "", 41 | "color": "#5378A4", 42 | "author": "hankun", 43 | "level": 1 44 | }, 45 | "Danmu": { 46 | "name": "弹幕刮削", 47 | "description": "使用弹弹play平台生成弹幕的字幕文件,实现弹幕播放。", 48 | "version": "1.1.2", 49 | "icon": "https://raw.githubusercontent.com/HankunYu/MoviePilot-Plugins/main/icons/danmu.png", 50 | "color": "#3B5E8E", 51 | "author": "hankun", 52 | "level": 1, 53 | "history": { 54 | "v1.1.2": "修复nfo文件不存在时导致错误。", 55 | "v1.1.1": "修复错误导致弹幕生成失败,增加log信息显示匹配弹幕数量。", 56 | "v1.1.0": "优化弹幕生成算法,让生成的弹幕分布更均匀。", 57 | "v1.0.9": "移除定期全局刮削,如果有需要请使用 设置->服务 手动启动全局刮削。刮削过于频繁会导致IP被封。", 58 | "v1.0.8": "修复弹幕时长设为10以上时无法生成弹幕", 59 | "v1.0.7": "修复cron表达式修改后无法应用", 60 | "v1.0.6": "提取内嵌字幕时自动识别并选择中文字幕(如果可用的话", 61 | "v1.0.5": "添加在hash匹配失败后,通过nfo标题匹配弹幕池", 62 | "v1.0.4": "修复我傻逼打错变量名", 63 | "v1.0.3": "修复合并字幕不是utf8无法读取的问题以及线程排序会漏任务的问题。", 64 | "v1.0.2": "修复合并字幕命名错误。", 65 | "v1.0.1": "修复ffmpeg有时候解码错误。", 66 | "v1.0.0": "基本功能实现,暂时不支持srt字幕。" 67 | } 68 | }, 69 | "Test":{ 70 | "name": "测试", 71 | "description": "测试", 72 | "version": "1.0.0", 73 | "icon": "", 74 | "color": "#3B5E8E", 75 | "author": "hankun", 76 | "level": 99, 77 | "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAyNe4GKw15qXHWAYBhHDlUDxM7MfrdmstwRoTtn+PNK9uvpsZip1/R6azzJi4hHLsEOvVLyfjjaEVDyZrJVGTxC+Fj+6JFrq4LnwB0CHlbXAN+UkMk3uD/srRx3eOVSZuqoCDplsX98DUim5Vx/xzyoRSwbWqMJvYsIawaKO9yZyA7ErjmyNOk1jduy1FYLISzyICtz0ubYsohzUZxbcZgqb+qBeECv0SofSMM+luvIHiqTPPMRKmjD6WufGerwm7r9r26coG5CvYgOX+jOJITIpAqXkAqqXNNoVCuTRazxF/OJFjbTsJLO1WnyIr6qB6oQW/7XgLk0Ot5M1Jx2JW/QIDAQAB" 78 | } 79 | } -------------------------------------------------------------------------------- /package.v2.json: -------------------------------------------------------------------------------- 1 | { 2 | "Discord": { 3 | "name": "Discord 消息推送", 4 | "description": "添加discord消息推送", 5 | "version": "1.5.9", 6 | "icon": "https://raw.githubusercontent.com/HankunYu/MoviePilot-Plugins/main/icons/discord.png", 7 | "color": "#3B5E8E", 8 | "author": "hankun", 9 | "level": 1, 10 | "history":{ 11 | "v1.5.8": "增加对v2的支持,OpenAI暂时无法使用" 12 | }, 13 | "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAyNe4GKw15qXHWAYBhHDlUDxM7MfrdmstwRoTtn+PNK9uvpsZip1/R6azzJi4hHLsEOvVLyfjjaEVDyZrJVGTxC+Fj+6JFrq4LnwB0CHlbXAN+UkMk3uD/srRx3eOVSZuqoCDplsX98DUim5Vx/xzyoRSwbWqMJvYsIawaKO9yZyA7ErjmyNOk1jduy1FYLISzyICtz0ubYsohzUZxbcZgqb+qBeECv0SofSMM+luvIHiqTPPMRKmjD6WufGerwm7r9r26coG5CvYgOX+jOJITIpAqXkAqqXNNoVCuTRazxF/OJFjbTsJLO1WnyIr6qB6oQW/7XgLk0Ot5M1Jx2JW/QIDAQAB" 14 | 15 | }, 16 | "Bangumi": { 17 | "name": "Bangumi 同步", 18 | "description": "动漫资源引用Bangumi评分,同步订阅/库存番剧到Bangumi", 19 | "version": "1.0.15", 20 | "icon": "https://raw.githubusercontent.com/HankunYu/MoviePilot-Plugins/main/icons/bangumi.jpg", 21 | "color": "#5378A4", 22 | "author": "hankun", 23 | "level": 99, 24 | "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAyNe4GKw15qXHWAYBhHDlUDxM7MfrdmstwRoTtn+PNK9uvpsZip1/R6azzJi4hHLsEOvVLyfjjaEVDyZrJVGTxC+Fj+6JFrq4LnwB0CHlbXAN+UkMk3uD/srRx3eOVSZuqoCDplsX98DUim5Vx/xzyoRSwbWqMJvYsIawaKO9yZyA7ErjmyNOk1jduy1FYLISzyICtz0ubYsohzUZxbcZgqb+qBeECv0SofSMM+luvIHiqTPPMRKmjD6WufGerwm7r9r26coG5CvYgOX+jOJITIpAqXkAqqXNNoVCuTRazxF/OJFjbTsJLO1WnyIr6qB6oQW/7XgLk0Ot5M1Jx2JW/QIDAQAB" 25 | 26 | }, 27 | "BDRemuxer": { 28 | "name": "BD Remuxer", 29 | "description": "使用ffmpeg自动提取BDMV文件夹中的视频流和音频流,合并为MKV文件。低性能设备不推荐使用", 30 | "version": "1.1.0", 31 | "icon": "", 32 | "color": "#5378A4", 33 | "author": "hankun", 34 | "level": 1 35 | }, 36 | "Danmu": { 37 | "name": "弹幕刮削", 38 | "description": "使用弹弹play平台生成弹幕的字幕文件,实现弹幕播放。", 39 | "version": "1.4.0", 40 | "icon": "https://raw.githubusercontent.com/HankunYu/MoviePilot-Plugins/main/icons/danmu.png", 41 | "color": "#3B5E8E", 42 | "author": "hankun", 43 | "level": 1, 44 | "history": { 45 | "v1.4.0": "修复文件路径无法访问的问题,新增无弹幕或者弹幕数量过少时定时重试", 46 | "v1.3.0": "新增联邦组件支持,增加UI手动刮削,需求MP v2.4.5+", 47 | "v1.2.0": "针对新番(90天内发布的媒体资源)使用较短时间缓存以获取最新弹幕数据", 48 | "v1.1.15": "增加根据路径刮削弹幕功能。增加单文件路径刮削。能力有限无法在同一个页面实现按钮功能……", 49 | "v1.1.13": "先检查当前目录下有没有.id结尾的文件 如果有,则获取文件名作为弹幕ID(如190270001 则使用19027.id)。如果需要请访问 https://dandanapi.hankun.online/docs 尝试获取epsoide id。", 50 | "v1.1.11": "修复弹幕获取过滤错误", 51 | "v1.1.10": "使用TMDB ID作为预备匹配方案,当无法匹配文件hash时尝试使用TMDB ID。", 52 | "v1.1.8": "增加了中转服务器,恢复了对弹弹的访问以及缓存。修复透明度问题。增加了弹幕过滤选项。" 53 | } 54 | }, 55 | "Test":{ 56 | "name": "测试", 57 | "description": "测试", 58 | "version": "1.3.9", 59 | "icon": "", 60 | "color": "#3B5E8E", 61 | "author": "hankun", 62 | "level": 99, 63 | "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAyNe4GKw15qXHWAYBhHDlUDxM7MfrdmstwRoTtn+PNK9uvpsZip1/R6azzJi4hHLsEOvVLyfjjaEVDyZrJVGTxC+Fj+6JFrq4LnwB0CHlbXAN+UkMk3uD/srRx3eOVSZuqoCDplsX98DUim5Vx/xzyoRSwbWqMJvYsIawaKO9yZyA7ErjmyNOk1jduy1FYLISzyICtz0ubYsohzUZxbcZgqb+qBeECv0SofSMM+luvIHiqTPPMRKmjD6WufGerwm7r9r26coG5CvYgOX+jOJITIpAqXkAqqXNNoVCuTRazxF/OJFjbTsJLO1WnyIr6qB6oQW/7XgLk0Ot5M1Jx2JW/QIDAQAB" 64 | }, 65 | "IdentifierHelper":{ 66 | "name": "自定义识别词助手", 67 | "description": "帮助管理自定义识别词", 68 | "version": "1.0.0", 69 | "icon": "https://raw.githubusercontent.com/HankunYu/MoviePilot-Plugins/main/icons/Identifier.png", 70 | "color": "#3B5E8E", 71 | "author": "hankun", 72 | "level": 99, 73 | "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAyNe4GKw15qXHWAYBhHDlUDxM7MfrdmstwRoTtn+PNK9uvpsZip1/R6azzJi4hHLsEOvVLyfjjaEVDyZrJVGTxC+Fj+6JFrq4LnwB0CHlbXAN+UkMk3uD/srRx3eOVSZuqoCDplsX98DUim5Vx/xzyoRSwbWqMJvYsIawaKO9yZyA7ErjmyNOk1jduy1FYLISzyICtz0ubYsohzUZxbcZgqb+qBeECv0SofSMM+luvIHiqTPPMRKmjD6WufGerwm7r9r26coG5CvYgOX+jOJITIpAqXkAqqXNNoVCuTRazxF/OJFjbTsJLO1WnyIr6qB6oQW/7XgLk0Ot5M1Jx2JW/QIDAQAB" 74 | } 75 | } -------------------------------------------------------------------------------- /plugins.v2/IdentifierHelper/__init__.py: -------------------------------------------------------------------------------- 1 | from typing import List, Tuple, Dict, Any 2 | 3 | import datetime, re 4 | from apscheduler.schedulers.background import BackgroundScheduler 5 | from apscheduler.triggers.cron import CronTrigger 6 | 7 | from app.core.config import settings 8 | from app.utils.http import RequestUtils 9 | from app.log import logger 10 | 11 | from app.plugins import _PluginBase 12 | from ...db.systemconfig_oper import SystemConfigOper 13 | from ...schemas.types import SystemConfigKey 14 | from app.utils.common import retry 15 | 16 | 17 | class IdentifierHelper(_PluginBase): 18 | # 插件名称 19 | plugin_name = "自定义识别词助手" 20 | # 插件描述 21 | plugin_desc = "帮助管理自定义识别词" 22 | # 插件图标 23 | plugin_icon = "https://raw.githubusercontent.com/HankunYu/MoviePilot-Plugins/main/icons/Identifier.png" 24 | # 插件版本 25 | plugin_version = "1.0.0" 26 | # 插件作者 27 | plugin_author = "hankun" 28 | # 作者主页 29 | author_url = "https://github.com/hankunyu" 30 | # 插件配置项ID前缀 31 | plugin_config_prefix = "identifierHelper_" 32 | # 加载顺序 33 | plugin_order = 10 34 | # 可使用的用户级别 35 | auth_level = 1 36 | # 条目的类型 37 | _type = ['屏蔽', '替换', '集偏移', '替换和集偏移'] 38 | _entries = [] 39 | _catgroies = [] 40 | 41 | def init_plugin(self, config: dict = None): 42 | if not config: 43 | return 44 | 45 | # config操作 46 | self.__update_config() 47 | 48 | 49 | 50 | def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: 51 | return [ 52 | { 53 | "component": "VRow", 54 | "content": [ 55 | { 56 | "component": "VCol", 57 | "props": { 58 | "cols": 12, 59 | }, 60 | "content": [ 61 | { 62 | "component": "VAlert", 63 | "props": { 64 | "type": "warning", 65 | "variant": "tonal", 66 | "text": "注意备份" 67 | } 68 | } 69 | ] 70 | } 71 | ] 72 | }, 73 | "component": "VTabs", 74 | "props": { 75 | "model": "_tabs", 76 | "height": 72, 77 | "fixed-tabs": True, 78 | "style": { 79 | "margin-top": "8px", 80 | "margin-bottom": "10px", 81 | } 82 | }, 83 | ], { 84 | 85 | } 86 | 87 | def __update_config(self): 88 | self.update_config({ 89 | }) 90 | 91 | def stop_service(self): 92 | pass 93 | 94 | def get_page(self) -> List[dict]: 95 | pass 96 | 97 | def get_state(self) -> bool: 98 | return False 99 | 100 | @staticmethod 101 | def get_command() -> List[Dict[str, Any]]: 102 | pass 103 | 104 | def get_api(self) -> List[Dict[str, Any]]: 105 | pass 106 | 107 | def add_entry(self, catgory, entry_type, content): 108 | # 创建一个字典表示条目 109 | entry = { 110 | "Catgory": catgory, 111 | "Type": entry_type, 112 | "Content": content 113 | } 114 | # 将条目添加到列表中 115 | self._entries.append(entry) 116 | 117 | def extract_identifier(self): 118 | text = SystemConfigOper.get(SystemConfigKey.CustomIdentifiers) 119 | 120 | # 初始化结果 121 | self._categories = [] 122 | current_category = '未分类' 123 | self._categories.append(current_category) 124 | 125 | # 按行分割文本 126 | lines = text.strip().split('\n') 127 | 128 | # 遍历每一行 129 | for line in lines: 130 | line = line.strip() # 去除行首尾空白 131 | 132 | # 匹配分类开始 133 | category_start_match = re.match(r'^##\s*Catgory\s*(.*)$', line) 134 | if category_start_match: 135 | current_category = category_start_match.group(1).strip() 136 | self._categories.append(current_category) 137 | continue 138 | 139 | # 匹配分类结束 140 | if line.startswith('## Catgory End'): 141 | current_category = '未分类' # 结束当前分类 142 | continue 143 | 144 | # 开始匹配条目 145 | # 匹配屏蔽词 146 | if re.match(r'^\S+$', line): 147 | self.add_entry(current_category, '屏蔽', line) 148 | continue 149 | 150 | # 匹配结合替换和集偏移量的格式 151 | complex_match = re.match(r'^(.*?)\s*=>\s*(.*)\s*&&\s*(.*?)\s*<>\s*(.*?)\s*>>\s*(.*)$', line) 152 | if complex_match: 153 | self.add_entry(current_category, '替换和集偏移', line) 154 | continue 155 | 156 | # 匹配被替换词 => 替换词 157 | simple_replace_match = re.match(r'^(.*?)\s*=>\s*(.*)$', line) 158 | if simple_replace_match: 159 | self.add_entry(current_category, '替换', line) 160 | continue 161 | 162 | # 匹配前定位词 <> 后定位词 >> 集偏移量(EP) 163 | offset_match = re.match(r'^(.*?)\s*<>\s*(.*?)\s*>>\s*(.*)$', line) 164 | if offset_match: 165 | self.add_entry(current_category, '集偏移', line) 166 | continue 167 | 168 | 169 | 170 | -------------------------------------------------------------------------------- /plugins.v2/bangumi/README.md: -------------------------------------------------------------------------------- 1 | ### 配置 2 | 3 | 需要 Bangumi API Key,申请地址:https://next.bgm.tv/demo/access-token 4 | 5 | 只会同步选择的媒体服务器中的媒体内容。 6 | 媒体库路径只用于更改本地NFO文件的评分。 7 | 8 | **自定义识别词** 9 | 只会使用识别词替换,请在前一个使用Bangumi标题,后一个使用TMDB标题。如有季数,请用中文添加。 10 | 如: 11 | ``` 12 | 银魂 => 银魂 第二季 13 | 银魂 => 银魂 第三季 14 | 银魂 => 银魂 第四季 15 | 银魂' => 银魂 第五季 16 | 银魂'延长战 => 银魂 第六季 17 | 银魂° => 银魂 第七季 18 | ``` 19 | --- 20 | ### 更新日志 21 | 22 | #### 1.0.9 23 | - 更新评分增加了对整个番剧评分的更新。 24 | 25 | #### 1.0.8 26 | - 修复MP更新后,脚本无法启动的问题。 27 | - 修复插件页面空白问题。 28 | 29 | #### 1.0.7 30 | - 修复新增想看条目缓慢,要等订阅/下载后才增加 31 | 32 | #### 1.0.6 33 | - 修复缓存时自定义词替换失效的问题 34 | - 提高识别的准确度 35 | 36 | #### 1.0.5 37 | 38 | - 修复了一些bug 39 | - 增加季数识别,当添加想看条目到下载时,根据自定义识别词识别季数并下载 40 | 41 | #### 1.0.4 42 | 43 | - 新增自定义识别词替换,用于识别 Bangumi 标题与 TMDB 标题的互相识别。请在自定义识别词页面自行增加 'Bangumi上的标题' => 'TMDB上的标题'。(如果发现重复下载想看上的项目,请添加识别词) 44 | - 新增脚本版本识别,防止多个脚本版本不一致卡死进程。目前是因为MP不会自动更新除了主脚本外的附带脚本。相信很快就会修复。 45 | - 优化缓存更新逻辑。 46 | -------------------------------------------------------------------------------- /plugins.v2/bangumi/bangumi_db.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from sqlalchemy import Column, Integer, String, Sequence 4 | from sqlalchemy.orm import Session 5 | 6 | from app.db import db_query 7 | from app.db import Base, db_update 8 | 9 | 10 | class BangumiInfo(Base): 11 | plugin_version = "1.0.9" 12 | """ 13 | Bangumi 数据表 14 | """ 15 | id = Column(Integer, Sequence('id'), primary_key=True, index=True) 16 | # 标题 17 | title = Column(String, index=True) 18 | # 原标题 19 | original_title = Column(String) 20 | # bangumi 项目 ID 21 | subject_id = Column(String, index=True) 22 | # 评分 23 | rating = Column(String) 24 | # 收藏状态 1:想看 2:看过 3:在看 4:搁置 5:抛弃 25 | status = Column(String, index=True) 26 | # 是否同步过 27 | synced = Column(String, index=True) 28 | # poster 29 | poster = Column(String) 30 | 31 | @staticmethod 32 | @db_query 33 | def get_by_title(db: Session, title: str): 34 | return db.query(BangumiInfo).filter(BangumiInfo.title == title).first() 35 | 36 | @staticmethod 37 | @db_update 38 | def empty(db: Session): 39 | db.query(BangumiInfo).delete() 40 | 41 | @staticmethod 42 | @db_query 43 | def exists_by_title(db: Session, title: str): 44 | return db.query(BangumiInfo).filter(BangumiInfo.title == title).first() 45 | 46 | @staticmethod 47 | @db_query 48 | def get_amount(db: Session): 49 | return db.query(BangumiInfo).count() 50 | 51 | @staticmethod 52 | @db_query 53 | def get_all(db: Session): 54 | return db.query(BangumiInfo).all() 55 | 56 | @staticmethod 57 | @db_query 58 | def get_all_bangumi(db: Session): 59 | return db.query(BangumiInfo).filter(BangumiInfo.subject_id != None).all() 60 | 61 | @staticmethod 62 | @db_update 63 | def update_info(db: Session, title: str, original_title: str ,subject_id: str, rating: str, status: str, synced: bool, poster: str): 64 | db.query(BangumiInfo).filter(BangumiInfo.title == title).update({ 65 | "original_title": original_title, 66 | "subject_id": subject_id, 67 | "rating": rating, 68 | "status": status, 69 | "synced": synced, 70 | "poster": poster, 71 | }) 72 | 73 | @staticmethod 74 | @db_query 75 | def get_wish(db: Session): 76 | return db.query(BangumiInfo).filter(BangumiInfo.status == 1).all() 77 | 78 | @staticmethod 79 | @db_query 80 | def get_watched(db: Session): 81 | return db.query(BangumiInfo).filter(BangumiInfo.status == 2).all() 82 | 83 | @staticmethod 84 | @db_query 85 | def get_watching(db: Session): 86 | return db.query(BangumiInfo).filter(BangumiInfo.status == 3).all() 87 | 88 | @staticmethod 89 | @db_query 90 | def get_dropped(db: Session): 91 | return db.query(BangumiInfo).filter(BangumiInfo.status == 4).all() 92 | 93 | @staticmethod 94 | @db_query 95 | def exists_by_subject_id(db: Session, subject_id: str): 96 | return db.query(BangumiInfo).filter(BangumiInfo.subject_id == subject_id).first() -------------------------------------------------------------------------------- /plugins.v2/bangumi/bangumi_db_oper.py: -------------------------------------------------------------------------------- 1 | import json 2 | from typing import Optional 3 | 4 | from sqlalchemy.orm import Session 5 | 6 | from app.db import DbOper 7 | from app.plugins.bangumi.bangumi_db import BangumiInfo 8 | 9 | 10 | class BangumiOper(DbOper): 11 | 12 | plugin_version = "1.0.9" 13 | """ 14 | 媒体服务器数据管理 15 | """ 16 | 17 | def __init__(self, db: Session = None): 18 | super().__init__(db) 19 | 20 | def add(self, **kwargs) -> bool: 21 | """ 22 | 新增媒体服务器数据 23 | """ 24 | item = BangumiInfo(**kwargs) 25 | if not item.get_by_title(self._db, kwargs.get("title")): 26 | item.create(self._db) 27 | return True 28 | return False 29 | 30 | def empty(self): 31 | """ 32 | 清空 Bangumi 数据 33 | """ 34 | BangumiInfo.empty(self._db) 35 | 36 | def exists(self, **kwargs) -> Optional[BangumiInfo]: 37 | """ 38 | 判断媒体服务器数据是否存在 39 | """ 40 | if kwargs.get("title"): 41 | item = BangumiInfo.exists_by_title(self._db, title=kwargs.get("title")) 42 | else: 43 | return None 44 | if not item: 45 | return None 46 | return item 47 | 48 | def get_subject_id(self, **kwargs) -> Optional[str]: 49 | """ 50 | 获取 Bangumi ID 51 | """ 52 | item = self.exists(**kwargs) 53 | if not item: 54 | return None 55 | return str(item.subject_id) 56 | 57 | def get_amount(self) -> int: 58 | """ 59 | 获取 Bangumi 数据量 60 | """ 61 | return BangumiInfo.get_amount(self._db) 62 | 63 | def get_all(self) -> list: 64 | """ 65 | 获取所有 Bangumi 数据 66 | """ 67 | return BangumiInfo.get_all(self._db) 68 | 69 | def update_info(self, **kwargs) -> bool: 70 | """ 71 | 更新 Bangumi 数据 72 | """ 73 | item = self.exists(title = kwargs.get("title")) 74 | if not item: 75 | return False 76 | item.update_info(self._db, **kwargs) 77 | return True 78 | 79 | def get_original_title(self, **kwargs) -> Optional[str]: 80 | """ 81 | 获取原标题 82 | """ 83 | item = self.exists(**kwargs) 84 | if not item: 85 | return None 86 | return str(item.original_title) 87 | 88 | def get_all_bangumi(self) -> list: 89 | """ 90 | 获取所有 Bangumi 数据 91 | """ 92 | return BangumiInfo.get_all_bangumi(self._db) 93 | 94 | def get_wish(self) -> list: 95 | """ 96 | 获取所有 Bangumi 上 想看 的条目 97 | """ 98 | return BangumiInfo.get_wish(self._db) 99 | 100 | def get_watched(self) -> list: 101 | """ 102 | 获取所有 Bangumi 上 看过 的条目 103 | """ 104 | return BangumiInfo.get_watched(self._db) 105 | 106 | def get_watching(self) -> list: 107 | """ 108 | 获取所有 Bangumi 上 在看 的条目 109 | """ 110 | return BangumiInfo.get_watching(self._db) 111 | 112 | def get_synced(self) -> list: 113 | """ 114 | 获取所有 Bangumi 上 已同步 的条目 115 | """ 116 | return BangumiInfo.get_synced(self._db) 117 | 118 | def get_exist_by_subject_id(self, subject_id: str) -> bool: 119 | """ 120 | 判断是否存在指定 Bangumi ID 的条目 121 | """ 122 | item = BangumiInfo.exists_by_subject_id(self._db, subject_id) 123 | if not item: 124 | return False 125 | return True 126 | -------------------------------------------------------------------------------- /plugins.v2/bdremuxer/README.md: -------------------------------------------------------------------------------- 1 | ### 注意 2 | 3 | 此插件吃性能,不建议在低性能设备上使用。 -------------------------------------------------------------------------------- /plugins.v2/bdremuxer/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | # MoviePilot library 3 | from app.log import logger 4 | from app.plugins import _PluginBase 5 | from app.core.event import eventmanager 6 | from app.schemas.types import EventType 7 | from app.utils.system import SystemUtils 8 | from typing import Any, List, Dict, Tuple 9 | import subprocess 10 | import os 11 | import shutil 12 | import threading 13 | try: 14 | from pyparsebluray import mpls 15 | except: 16 | subprocess.run(["pip3", "install", "pyparsebluray"]) 17 | subprocess.run(["pip3", "install", "ffmpeg-python"]) 18 | 19 | try: 20 | import ffmpeg 21 | except: 22 | logger.error("requirements 安装失败") 23 | 24 | class BDRemuxer(_PluginBase): 25 | # 插件名称 26 | plugin_name = "BDMV Remuxer" 27 | # 插件描述 28 | plugin_desc = "自动提取BDMV文件夹中的视频流和音频流,合并为MKV文件" 29 | # 插件图标 30 | plugin_icon = "" 31 | # 主题色 32 | plugin_color = "#3B5E8E" 33 | # 插件版本 34 | plugin_version = "1.1.0" 35 | # 插件作者 36 | plugin_author = "hankun" 37 | # 作者主页 38 | author_url = "https://github.com/hankunyu" 39 | # 插件配置项ID前缀 40 | plugin_config_prefix = "bdremuxer_" 41 | # 加载顺序 42 | plugin_order = 1 43 | # 可使用的用户级别 44 | auth_level = 1 45 | 46 | # 私有属性 47 | _enabled = False 48 | _delete = False 49 | _run_once = False 50 | _path = "" 51 | 52 | def init_plugin(self, config: dict = None): 53 | if config: 54 | self._enabled = config.get("enabled") 55 | self._delete = config.get("delete") 56 | self._run_once = config.get("run_once") 57 | self._path = config.get("path") 58 | if self._enabled: 59 | logger.info("BD Remuxer 插件初始化完成") 60 | if self._run_once: 61 | thread = threading.Thread(target=self.extract, args=(self._path,)) 62 | thread.start() 63 | self.update_config({ 64 | "enabled": self._enabled, 65 | "delete": self._delete, 66 | "run_once": False, 67 | "path": self._path 68 | }) 69 | 70 | def get_state(self) -> bool: 71 | return self._enabled 72 | 73 | 74 | 75 | @staticmethod 76 | def get_command() -> List[Dict[str, Any]]: 77 | pass 78 | 79 | def get_api(self) -> List[Dict[str, Any]]: 80 | pass 81 | 82 | # 插件配置页面 83 | def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: 84 | """ 85 | 拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构 86 | """ 87 | return [ 88 | { 89 | 'component': 'VForm', 90 | 'content': [ 91 | { 92 | 'component': 'VRow', 93 | 'content': [ 94 | { 95 | 'component': 'VCol', 96 | 'props': { 97 | 'cols': 12, 98 | 'md': 6 99 | }, 100 | 'content': [ 101 | { 102 | 'component': 'VSwitch', 103 | 'props': { 104 | 'model': 'enabled', 105 | 'label': '启用插件', 106 | } 107 | } 108 | ] 109 | }, 110 | { 111 | 'component': 'VCol', 112 | 'props': { 113 | 'cols': 12, 114 | 'md': 6 115 | }, 116 | 'content': [ 117 | { 118 | 'component': 'VSwitch', 119 | 'props': { 120 | 'model': 'delete', 121 | 'label': '删除原始文件', 122 | } 123 | } 124 | ] 125 | } 126 | ] 127 | }, 128 | { 129 | 'component': 'VRow', 130 | 'content': [ 131 | { 132 | 'component': 'VCol', 133 | 'props': { 134 | 'cols': 12 135 | }, 136 | 'content': [ 137 | { 138 | 'component': 'VTextarea', 139 | 'props': { 140 | 'model': 'path', 141 | 'label': '手动指定BDMV文件夹路径', 142 | 'rows': 1, 143 | 'placeholder': '路径指向BDMV父文件夹', 144 | } 145 | } 146 | ] 147 | }, 148 | { 149 | 'component': 'VCol', 150 | 'content': [ 151 | { 152 | 'component': 'VSwitch', 153 | 'props': { 154 | 'model': 'run_once', 155 | 'label': '提取指定目录BDMV', 156 | } 157 | } 158 | ] 159 | } 160 | ] 161 | }, 162 | { 163 | 'component': 'VRow', 164 | 'content': [ 165 | { 166 | 'component': 'VCol', 167 | 'content': [ 168 | { 169 | 'component': 'VAlert', 170 | 'props': { 171 | 'type': 'info', 172 | 'variant': 'flat', 173 | 'text': '自用插件,可能不稳定', 174 | } 175 | } 176 | ] 177 | } 178 | ] 179 | } 180 | ] 181 | } 182 | ], { 183 | "enabled": False, 184 | "delete": False, 185 | "path": "", 186 | "run_once": False, 187 | } 188 | 189 | def get_page(self) -> List[dict]: 190 | pass 191 | 192 | def extract(self,bd_path : str): 193 | logger.info('开始提取BDMV。') 194 | output_name = os.path.basename(bd_path) + ".mkv" 195 | output_name = os.path.join(bd_path, output_name) 196 | bd_path = bd_path + '/BDMV' 197 | if not os.path.exists(bd_path): 198 | logger.info('失败。输入路径不存在BDMV文件夹') 199 | return 200 | mpls_path = bd_path + '/PLAYLIST/' 201 | if not os.path.exists(mpls_path): 202 | logger.info('失败。找不到PLAYLIST文件夹') 203 | return 204 | file_paths = self.get_all_m2ts(mpls_path) 205 | if not file_paths: 206 | logger.info('失败。找不到m2ts文件') 207 | return 208 | 209 | filelist_string = '\n'.join([f"file '{file}'" for file in file_paths]) 210 | # 将filelist_string写入filelist.txt 211 | logger.info('搜索到需要提取的m2ts文件: ' + filelist_string) 212 | with open('/tmp/filelist.txt', 'w') as file: 213 | file.write(filelist_string) 214 | 215 | # 提取流程 216 | # 分析m2ts文件,提取视频流和音频流信息 217 | test_file = file_paths[0] 218 | probe = ffmpeg.probe(test_file) 219 | video_streams = [stream for stream in probe['streams'] if stream['codec_type'] == 'video'] 220 | audio_streams = [stream for stream in probe['streams'] if stream['codec_type'] == 'audio'] 221 | subtitle_streams = [stream for stream in probe['streams'] if stream['codec_type'] == 'subtitle'] 222 | 223 | # 选取第一个视频流作为流编码信息 224 | video_codec = video_streams[0]['codec_name'] 225 | # 获得每一条音频流的编码信息 226 | audio_codec = [] 227 | for audio_stream in audio_streams: 228 | if audio_stream['codec_name'] == 'pcm_bluray': 229 | audio_codec.append('pcm_s16le') 230 | else: 231 | audio_codec.append('copy') 232 | # print(audio_stream['codec_name']) 233 | 234 | # 获得每一条字幕流的编码信息 235 | subtitle_codec = [] 236 | for subtitle_stream in subtitle_streams: 237 | if subtitle_stream['codec_name'] == 'hdmv_pgs_subtitle': 238 | subtitle_codec.append('copy') 239 | else: 240 | subtitle_codec.append('copy') 241 | 242 | # 整理参数作为字典 243 | dict = { } 244 | for i in range(len(audio_codec)): 245 | dict[f'acodec:{i}'] = audio_codec[i] 246 | for i in range(len(subtitle_codec)): 247 | dict[f'scodec:{i}'] = subtitle_codec[i] 248 | # 使用ffmpeg合并m2ts文件 249 | try: 250 | ( 251 | ffmpeg 252 | .input( 253 | '/tmp/filelist.txt', 254 | format='concat', 255 | safe=0, 256 | ) 257 | .output( 258 | output_name, 259 | vcodec='copy', 260 | **dict, 261 | map='0', # 映射所有输入流 262 | map_metadata='0', # 复制输入流的元数据 263 | map_chapters='0', # 复制输入流的章节信息 264 | ) 265 | .run() 266 | ) 267 | except ffmpeg.Error as e: 268 | logger.error(e.stderr) 269 | logger.info('失败。') 270 | return 271 | # 删除原始文件 272 | if self._delete: 273 | shutil.rmtree(bd_path) 274 | logger.info('成功提取BDMV。并删除原始文件。') 275 | else: 276 | logger.info('成功提取BDMV。') 277 | 278 | 279 | def get_all_m2ts(self,mpls_path) -> list: 280 | """ 281 | Get all useful m2ts file paths from mpls file 282 | :param mpls_path: path to mpls 00000 file 283 | :return: list of m2ts file paths 284 | """ 285 | files = [] 286 | play_items = [] 287 | for file in os.listdir(mpls_path): 288 | if os.path.isfile(os.path.join(mpls_path, file)) and file.endswith('.mpls'): 289 | if file == '00000.mpls': continue # 跳过00000.mpls 290 | files.append(os.path.join(mpls_path, file)) 291 | files.sort() 292 | for file in files: 293 | with open(file, 'rb') as mpls_file: 294 | header = mpls.load_movie_playlist(mpls_file) 295 | mpls_file.seek(header.playlist_start_address, os.SEEK_SET) 296 | pls = mpls.load_playlist(mpls_file) 297 | for item in pls.play_items: 298 | if item.uo_mask_table == 0: 299 | stream_path = os.path.dirname(os.path.dirname(file)) + '/STREAM/' 300 | file_path = stream_path + item.clip_information_filename + '.m2ts' 301 | play_items.append(file_path) 302 | if play_items: 303 | return play_items 304 | return play_items 305 | 306 | @eventmanager.register(EventType.TransferComplete) 307 | def remuxer(self, event): 308 | if not self._enabled: 309 | return 310 | def __to_dict(_event): 311 | """ 312 | 递归将对象转换为字典 313 | """ 314 | if isinstance(_event, dict): 315 | for k, v in _event.items(): 316 | _event[k] = __to_dict(v) 317 | return _event 318 | elif isinstance(_event, list): 319 | for i in range(len(_event)): 320 | _event[i] = __to_dict(_event[i]) 321 | return _event 322 | elif isinstance(_event, tuple): 323 | return tuple(__to_dict(list(_event))) 324 | elif isinstance(_event, set): 325 | return set(__to_dict(list(_event))) 326 | elif hasattr(_event, 'to_dict'): 327 | return __to_dict(_event.to_dict()) 328 | elif hasattr(_event, '__dict__'): 329 | return __to_dict(_event.__dict__) 330 | elif isinstance(_event, (int, float, str, bool, type(None))): 331 | return _event 332 | else: 333 | return str(_event) 334 | 335 | raw_data = __to_dict(event.event_data) 336 | target_file = raw_data.get("transferinfo").get("file_list_new")[0] 337 | target_path = os.path.dirname(target_file) 338 | 339 | # 检查是否存在BDMV文件夹 340 | bd_path = os.path.dirname(target_path) 341 | if not os.path.exists(bd_path + '/BDMV'): 342 | logger.warn('失败。找不到BDMV文件夹: ' + bd_path) 343 | return 344 | # 提取流程 345 | thread = threading.Thread(target=self.extract, args=(bd_path,)) 346 | thread.start() 347 | 348 | 349 | 350 | 351 | def stop_service(self): 352 | """ 353 | 退出插件 354 | """ 355 | pass 356 | -------------------------------------------------------------------------------- /plugins.v2/bdremuxer/requirements.txt: -------------------------------------------------------------------------------- 1 | pyparsebluray~=0.1.4 2 | ffmpeg-python~=0.2.0 3 | -------------------------------------------------------------------------------- /plugins.v2/danmu/README.md: -------------------------------------------------------------------------------- 1 | # 弹幕刮削 2 | 3 | ![main page](doc/page.png) 4 | 5 | 此插件根据文件匹配弹弹平台上的弹幕,将弹幕文件转换为ass格式字幕,用于实现在不支持弹幕播放的设备上模拟弹幕. 6 | 7 | 8 | 9 | ### 文件匹配规则 10 | 首先会尝试使用文件hash直接匹配文件,如果没有匹配到怎会尝试使用TMDB ID来进行匹配. 11 |
以上匹配方式可以解决百分之八九十文件的识别问题. 12 |
如果匹配失败,提示找不到对应弹幕,有一个暂时的方法...可以尝试访问 https://dandanapi.hankun.online/docs 13 | ![](doc/api.png) 14 | 使用try it out,搜索对应文件 15 |
file_hash随便选一个值修改,只要保持长度32位,file_name更改为你想要找的文件然后点execute.在返回的值中找到animeId. 16 | ![](doc/api2.png) 17 | 在这个媒体文件的目录中添加一个空白文件,命名为xxxx.id (这个xxxx是你找到的对应的animeId) 18 | 之后插件会有限使用这个id来进行弹幕的搜索以及匹配. 19 | 20 | 21 | 22 | ### 更新日志 23 | 24 | - v1.4.0: 修复文件路径无法访问的问题,新增无弹幕或者弹幕数量过少时定时重试 25 | - v1.3.0: 新增联邦组件支持, 增加UI手动刮削, 需求MP v2.4.5+ 26 | - v1.2.0: 针对新番(90天内发布的媒体资源)使用较短时间缓存以获取最新弹幕数据 27 | - v1.1.15: 增加根据路径刮削弹幕功能。增加单文件路径刮削。能力有限无法在同一个页面实现按钮功能…… 28 | - v1.1.13: 先检查当前目录下有没有.id结尾的文件 如果有,则获取文件名作为弹幕ID(如190270001 则使用19027.id)。如果需要请访问 https://dandanapi.hankun.online/docs 尝试获取epsoide id。 29 | - v1.1.11: 修复弹幕获取过滤错误 30 | - v1.1.10: 使用TMDB ID作为预备匹配方案,当无法匹配文件hash时尝试使用TMDB ID。 31 | - v1.1.8: 增加了中转服务器,恢复了对弹弹的访问以及缓存。修复透明度问题。增加了弹幕过滤选项。 32 | -------------------------------------------------------------------------------- /plugins.v2/danmu/dist/assets/__federation_expose_Config-mmMv5D16.css: -------------------------------------------------------------------------------- 1 | 2 | .plugin-config[data-v-2b6e1a1e] { 3 | max-width: 80rem; 4 | margin: 0 auto; 5 | padding: 0.5rem; 6 | } 7 | .bg-primary-lighten-5[data-v-2b6e1a1e] { 8 | background-color: rgba(var(--v-theme-primary), 0.07); 9 | } 10 | .border[data-v-2b6e1a1e] { 11 | border: thin solid rgba(var(--v-border-color), var(--v-border-opacity)); 12 | } 13 | .config-card[data-v-2b6e1a1e] { 14 | background-image: linear-gradient(to right, rgba(var(--v-theme-surface), 0.98), rgba(var(--v-theme-surface), 0.95)), 15 | repeating-linear-gradient(45deg, rgba(var(--v-theme-primary), 0.03), rgba(var(--v-theme-primary), 0.03) 10px, transparent 10px, transparent 20px); 16 | background-attachment: fixed; 17 | box-shadow: 0 1px 2px rgba(var(--v-border-color), 0.05) !important; 18 | transition: all 0.3s ease; 19 | } 20 | .config-card[data-v-2b6e1a1e]:hover { 21 | box-shadow: 0 3px 6px rgba(var(--v-border-color), 0.1) !important; 22 | } 23 | .setting-item[data-v-2b6e1a1e] { 24 | border-radius: 8px; 25 | transition: all 0.2s ease; 26 | padding: 0.5rem; 27 | margin-bottom: 4px; 28 | } 29 | .setting-item[data-v-2b6e1a1e]:hover { 30 | background-color: rgba(var(--v-theme-primary), 0.03); 31 | } 32 | .small-switch[data-v-2b6e1a1e] { 33 | transform: scale(0.8); 34 | margin-right: -8px; 35 | } 36 | .text-subtitle-2[data-v-2b6e1a1e] { 37 | font-size: 14px !important; 38 | font-weight: 500; 39 | margin-bottom: 2px; 40 | } 41 | -------------------------------------------------------------------------------- /plugins.v2/danmu/dist/assets/__federation_expose_Page-CyDIESC3.css: -------------------------------------------------------------------------------- 1 | 2 | .plugin-page[data-v-0853f3af] { 3 | max-width: 80rem; 4 | margin: 0 auto; 5 | padding: 0.5rem; 6 | } 7 | .bg-primary-lighten-5[data-v-0853f3af] { 8 | background-color: rgba(var(--v-theme-primary), 0.07); 9 | } 10 | .border[data-v-0853f3af] { 11 | border: thin solid rgba(var(--v-border-color), var(--v-border-opacity)); 12 | } 13 | .status-card[data-v-0853f3af] { 14 | background-image: linear-gradient(to right, rgba(var(--v-theme-surface), 0.98), rgba(var(--v-theme-surface), 0.95)), 15 | repeating-linear-gradient(45deg, rgba(var(--v-theme-primary), 0.03), rgba(var(--v-theme-primary), 0.03) 10px, transparent 10px, transparent 20px); 16 | background-attachment: fixed; 17 | box-shadow: 0 1px 2px rgba(var(--v-border-color), 0.05) !important; 18 | transition: all 0.3s ease; 19 | } 20 | .status-card[data-v-0853f3af]:hover { 21 | box-shadow: 0 3px 6px rgba(var(--v-border-color), 0.1) !important; 22 | } 23 | .status-item[data-v-0853f3af] { 24 | border-radius: 8px; 25 | transition: all 0.2s ease; 26 | padding: 0.5rem; 27 | margin-bottom: 4px; 28 | } 29 | .status-item[data-v-0853f3af]:hover { 30 | background-color: rgba(var(--v-theme-primary), 0.03); 31 | } 32 | .text-subtitle-2[data-v-0853f3af] { 33 | font-size: 14px !important; 34 | font-weight: 500; 35 | margin-bottom: 2px; 36 | } 37 | .directory-content[data-v-0853f3af] { 38 | max-height: 600px; 39 | overflow-y: auto; 40 | } 41 | .directory-item[data-v-0853f3af] { 42 | border-radius: 4px; 43 | transition: all 0.2s ease; 44 | cursor: pointer; 45 | } 46 | .directory-item[data-v-0853f3af]:hover { 47 | background-color: rgba(var(--v-theme-primary), 0.03); 48 | } 49 | .back-item[data-v-0853f3af] { 50 | border-radius: 4px; 51 | transition: all 0.2s ease; 52 | cursor: pointer; 53 | border: 1px dashed rgba(var(--v-theme-primary), 0.3); 54 | } 55 | .back-item[data-v-0853f3af]:hover { 56 | background-color: rgba(var(--v-theme-primary), 0.05); 57 | border-color: rgba(var(--v-theme-primary), 0.5); 58 | } 59 | .media-item[data-v-0853f3af] { 60 | border-radius: 4px; 61 | transition: all 0.2s ease; 62 | } 63 | .media-item[data-v-0853f3af]:hover { 64 | background-color: rgba(var(--v-theme-primary), 0.03); 65 | } 66 | .cursor-pointer[data-v-0853f3af] { 67 | cursor: pointer; 68 | } 69 | -------------------------------------------------------------------------------- /plugins.v2/danmu/dist/assets/__federation_fn_import-JrT3xvdd.js: -------------------------------------------------------------------------------- 1 | const buildIdentifier = "[0-9A-Za-z-]+"; 2 | const build = `(?:\\+(${buildIdentifier}(?:\\.${buildIdentifier})*))`; 3 | const numericIdentifier = "0|[1-9]\\d*"; 4 | const numericIdentifierLoose = "[0-9]+"; 5 | const nonNumericIdentifier = "\\d*[a-zA-Z-][a-zA-Z0-9-]*"; 6 | const preReleaseIdentifierLoose = `(?:${numericIdentifierLoose}|${nonNumericIdentifier})`; 7 | const preReleaseLoose = `(?:-?(${preReleaseIdentifierLoose}(?:\\.${preReleaseIdentifierLoose})*))`; 8 | const preReleaseIdentifier = `(?:${numericIdentifier}|${nonNumericIdentifier})`; 9 | const preRelease = `(?:-(${preReleaseIdentifier}(?:\\.${preReleaseIdentifier})*))`; 10 | const xRangeIdentifier = `${numericIdentifier}|x|X|\\*`; 11 | const xRangePlain = `[v=\\s]*(${xRangeIdentifier})(?:\\.(${xRangeIdentifier})(?:\\.(${xRangeIdentifier})(?:${preRelease})?${build}?)?)?`; 12 | const hyphenRange = `^\\s*(${xRangePlain})\\s+-\\s+(${xRangePlain})\\s*$`; 13 | const mainVersionLoose = `(${numericIdentifierLoose})\\.(${numericIdentifierLoose})\\.(${numericIdentifierLoose})`; 14 | const loosePlain = `[v=\\s]*${mainVersionLoose}${preReleaseLoose}?${build}?`; 15 | const gtlt = "((?:<|>)?=?)"; 16 | const comparatorTrim = `(\\s*)${gtlt}\\s*(${loosePlain}|${xRangePlain})`; 17 | const loneTilde = "(?:~>?)"; 18 | const tildeTrim = `(\\s*)${loneTilde}\\s+`; 19 | const loneCaret = "(?:\\^)"; 20 | const caretTrim = `(\\s*)${loneCaret}\\s+`; 21 | const star = "(<|>)?=?\\s*\\*"; 22 | const caret = `^${loneCaret}${xRangePlain}$`; 23 | const mainVersion = `(${numericIdentifier})\\.(${numericIdentifier})\\.(${numericIdentifier})`; 24 | const fullPlain = `v?${mainVersion}${preRelease}?${build}?`; 25 | const tilde = `^${loneTilde}${xRangePlain}$`; 26 | const xRange = `^${gtlt}\\s*${xRangePlain}$`; 27 | const comparator = `^${gtlt}\\s*(${fullPlain})$|^$`; 28 | const gte0 = "^\\s*>=\\s*0.0.0\\s*$"; 29 | function parseRegex(source) { 30 | return new RegExp(source); 31 | } 32 | function isXVersion(version) { 33 | return !version || version.toLowerCase() === "x" || version === "*"; 34 | } 35 | function pipe(...fns) { 36 | return (x) => { 37 | return fns.reduce((v, f) => f(v), x); 38 | }; 39 | } 40 | function extractComparator(comparatorString) { 41 | return comparatorString.match(parseRegex(comparator)); 42 | } 43 | function combineVersion(major, minor, patch, preRelease2) { 44 | const mainVersion2 = `${major}.${minor}.${patch}`; 45 | if (preRelease2) { 46 | return `${mainVersion2}-${preRelease2}`; 47 | } 48 | return mainVersion2; 49 | } 50 | function parseHyphen(range) { 51 | return range.replace( 52 | parseRegex(hyphenRange), 53 | (_range, from, fromMajor, fromMinor, fromPatch, _fromPreRelease, _fromBuild, to, toMajor, toMinor, toPatch, toPreRelease) => { 54 | if (isXVersion(fromMajor)) { 55 | from = ""; 56 | } else if (isXVersion(fromMinor)) { 57 | from = `>=${fromMajor}.0.0`; 58 | } else if (isXVersion(fromPatch)) { 59 | from = `>=${fromMajor}.${fromMinor}.0`; 60 | } else { 61 | from = `>=${from}`; 62 | } 63 | if (isXVersion(toMajor)) { 64 | to = ""; 65 | } else if (isXVersion(toMinor)) { 66 | to = `<${+toMajor + 1}.0.0-0`; 67 | } else if (isXVersion(toPatch)) { 68 | to = `<${toMajor}.${+toMinor + 1}.0-0`; 69 | } else if (toPreRelease) { 70 | to = `<=${toMajor}.${toMinor}.${toPatch}-${toPreRelease}`; 71 | } else { 72 | to = `<=${to}`; 73 | } 74 | return `${from} ${to}`.trim(); 75 | } 76 | ); 77 | } 78 | function parseComparatorTrim(range) { 79 | return range.replace(parseRegex(comparatorTrim), "$1$2$3"); 80 | } 81 | function parseTildeTrim(range) { 82 | return range.replace(parseRegex(tildeTrim), "$1~"); 83 | } 84 | function parseCaretTrim(range) { 85 | return range.replace(parseRegex(caretTrim), "$1^"); 86 | } 87 | function parseCarets(range) { 88 | return range.trim().split(/\s+/).map((rangeVersion) => { 89 | return rangeVersion.replace( 90 | parseRegex(caret), 91 | (_, major, minor, patch, preRelease2) => { 92 | if (isXVersion(major)) { 93 | return ""; 94 | } else if (isXVersion(minor)) { 95 | return `>=${major}.0.0 <${+major + 1}.0.0-0`; 96 | } else if (isXVersion(patch)) { 97 | if (major === "0") { 98 | return `>=${major}.${minor}.0 <${major}.${+minor + 1}.0-0`; 99 | } else { 100 | return `>=${major}.${minor}.0 <${+major + 1}.0.0-0`; 101 | } 102 | } else if (preRelease2) { 103 | if (major === "0") { 104 | if (minor === "0") { 105 | return `>=${major}.${minor}.${patch}-${preRelease2} <${major}.${minor}.${+patch + 1}-0`; 106 | } else { 107 | return `>=${major}.${minor}.${patch}-${preRelease2} <${major}.${+minor + 1}.0-0`; 108 | } 109 | } else { 110 | return `>=${major}.${minor}.${patch}-${preRelease2} <${+major + 1}.0.0-0`; 111 | } 112 | } else { 113 | if (major === "0") { 114 | if (minor === "0") { 115 | return `>=${major}.${minor}.${patch} <${major}.${minor}.${+patch + 1}-0`; 116 | } else { 117 | return `>=${major}.${minor}.${patch} <${major}.${+minor + 1}.0-0`; 118 | } 119 | } 120 | return `>=${major}.${minor}.${patch} <${+major + 1}.0.0-0`; 121 | } 122 | } 123 | ); 124 | }).join(" "); 125 | } 126 | function parseTildes(range) { 127 | return range.trim().split(/\s+/).map((rangeVersion) => { 128 | return rangeVersion.replace( 129 | parseRegex(tilde), 130 | (_, major, minor, patch, preRelease2) => { 131 | if (isXVersion(major)) { 132 | return ""; 133 | } else if (isXVersion(minor)) { 134 | return `>=${major}.0.0 <${+major + 1}.0.0-0`; 135 | } else if (isXVersion(patch)) { 136 | return `>=${major}.${minor}.0 <${major}.${+minor + 1}.0-0`; 137 | } else if (preRelease2) { 138 | return `>=${major}.${minor}.${patch}-${preRelease2} <${major}.${+minor + 1}.0-0`; 139 | } 140 | return `>=${major}.${minor}.${patch} <${major}.${+minor + 1}.0-0`; 141 | } 142 | ); 143 | }).join(" "); 144 | } 145 | function parseXRanges(range) { 146 | return range.split(/\s+/).map((rangeVersion) => { 147 | return rangeVersion.trim().replace( 148 | parseRegex(xRange), 149 | (ret, gtlt2, major, minor, patch, preRelease2) => { 150 | const isXMajor = isXVersion(major); 151 | const isXMinor = isXMajor || isXVersion(minor); 152 | const isXPatch = isXMinor || isXVersion(patch); 153 | if (gtlt2 === "=" && isXPatch) { 154 | gtlt2 = ""; 155 | } 156 | preRelease2 = ""; 157 | if (isXMajor) { 158 | if (gtlt2 === ">" || gtlt2 === "<") { 159 | return "<0.0.0-0"; 160 | } else { 161 | return "*"; 162 | } 163 | } else if (gtlt2 && isXPatch) { 164 | if (isXMinor) { 165 | minor = 0; 166 | } 167 | patch = 0; 168 | if (gtlt2 === ">") { 169 | gtlt2 = ">="; 170 | if (isXMinor) { 171 | major = +major + 1; 172 | minor = 0; 173 | patch = 0; 174 | } else { 175 | minor = +minor + 1; 176 | patch = 0; 177 | } 178 | } else if (gtlt2 === "<=") { 179 | gtlt2 = "<"; 180 | if (isXMinor) { 181 | major = +major + 1; 182 | } else { 183 | minor = +minor + 1; 184 | } 185 | } 186 | if (gtlt2 === "<") { 187 | preRelease2 = "-0"; 188 | } 189 | return `${gtlt2 + major}.${minor}.${patch}${preRelease2}`; 190 | } else if (isXMinor) { 191 | return `>=${major}.0.0${preRelease2} <${+major + 1}.0.0-0`; 192 | } else if (isXPatch) { 193 | return `>=${major}.${minor}.0${preRelease2} <${major}.${+minor + 1}.0-0`; 194 | } 195 | return ret; 196 | } 197 | ); 198 | }).join(" "); 199 | } 200 | function parseStar(range) { 201 | return range.trim().replace(parseRegex(star), ""); 202 | } 203 | function parseGTE0(comparatorString) { 204 | return comparatorString.trim().replace(parseRegex(gte0), ""); 205 | } 206 | function compareAtom(rangeAtom, versionAtom) { 207 | rangeAtom = +rangeAtom || rangeAtom; 208 | versionAtom = +versionAtom || versionAtom; 209 | if (rangeAtom > versionAtom) { 210 | return 1; 211 | } 212 | if (rangeAtom === versionAtom) { 213 | return 0; 214 | } 215 | return -1; 216 | } 217 | function comparePreRelease(rangeAtom, versionAtom) { 218 | const { preRelease: rangePreRelease } = rangeAtom; 219 | const { preRelease: versionPreRelease } = versionAtom; 220 | if (rangePreRelease === void 0 && !!versionPreRelease) { 221 | return 1; 222 | } 223 | if (!!rangePreRelease && versionPreRelease === void 0) { 224 | return -1; 225 | } 226 | if (rangePreRelease === void 0 && versionPreRelease === void 0) { 227 | return 0; 228 | } 229 | for (let i = 0, n = rangePreRelease.length; i <= n; i++) { 230 | const rangeElement = rangePreRelease[i]; 231 | const versionElement = versionPreRelease[i]; 232 | if (rangeElement === versionElement) { 233 | continue; 234 | } 235 | if (rangeElement === void 0 && versionElement === void 0) { 236 | return 0; 237 | } 238 | if (!rangeElement) { 239 | return 1; 240 | } 241 | if (!versionElement) { 242 | return -1; 243 | } 244 | return compareAtom(rangeElement, versionElement); 245 | } 246 | return 0; 247 | } 248 | function compareVersion(rangeAtom, versionAtom) { 249 | return compareAtom(rangeAtom.major, versionAtom.major) || compareAtom(rangeAtom.minor, versionAtom.minor) || compareAtom(rangeAtom.patch, versionAtom.patch) || comparePreRelease(rangeAtom, versionAtom); 250 | } 251 | function eq(rangeAtom, versionAtom) { 252 | return rangeAtom.version === versionAtom.version; 253 | } 254 | function compare(rangeAtom, versionAtom) { 255 | switch (rangeAtom.operator) { 256 | case "": 257 | case "=": 258 | return eq(rangeAtom, versionAtom); 259 | case ">": 260 | return compareVersion(rangeAtom, versionAtom) < 0; 261 | case ">=": 262 | return eq(rangeAtom, versionAtom) || compareVersion(rangeAtom, versionAtom) < 0; 263 | case "<": 264 | return compareVersion(rangeAtom, versionAtom) > 0; 265 | case "<=": 266 | return eq(rangeAtom, versionAtom) || compareVersion(rangeAtom, versionAtom) > 0; 267 | case void 0: { 268 | return true; 269 | } 270 | default: 271 | return false; 272 | } 273 | } 274 | function parseComparatorString(range) { 275 | return pipe( 276 | parseCarets, 277 | parseTildes, 278 | parseXRanges, 279 | parseStar 280 | )(range); 281 | } 282 | function parseRange(range) { 283 | return pipe( 284 | parseHyphen, 285 | parseComparatorTrim, 286 | parseTildeTrim, 287 | parseCaretTrim 288 | )(range.trim()).split(/\s+/).join(" "); 289 | } 290 | function satisfy(version, range) { 291 | if (!version) { 292 | return false; 293 | } 294 | const parsedRange = parseRange(range); 295 | const parsedComparator = parsedRange.split(" ").map((rangeVersion) => parseComparatorString(rangeVersion)).join(" "); 296 | const comparators = parsedComparator.split(/\s+/).map((comparator2) => parseGTE0(comparator2)); 297 | const extractedVersion = extractComparator(version); 298 | if (!extractedVersion) { 299 | return false; 300 | } 301 | const [ 302 | , 303 | versionOperator, 304 | , 305 | versionMajor, 306 | versionMinor, 307 | versionPatch, 308 | versionPreRelease 309 | ] = extractedVersion; 310 | const versionAtom = { 311 | version: combineVersion( 312 | versionMajor, 313 | versionMinor, 314 | versionPatch, 315 | versionPreRelease 316 | ), 317 | major: versionMajor, 318 | minor: versionMinor, 319 | patch: versionPatch, 320 | preRelease: versionPreRelease == null ? void 0 : versionPreRelease.split(".") 321 | }; 322 | for (const comparator2 of comparators) { 323 | const extractedComparator = extractComparator(comparator2); 324 | if (!extractedComparator) { 325 | return false; 326 | } 327 | const [ 328 | , 329 | rangeOperator, 330 | , 331 | rangeMajor, 332 | rangeMinor, 333 | rangePatch, 334 | rangePreRelease 335 | ] = extractedComparator; 336 | const rangeAtom = { 337 | operator: rangeOperator, 338 | version: combineVersion( 339 | rangeMajor, 340 | rangeMinor, 341 | rangePatch, 342 | rangePreRelease 343 | ), 344 | major: rangeMajor, 345 | minor: rangeMinor, 346 | patch: rangePatch, 347 | preRelease: rangePreRelease == null ? void 0 : rangePreRelease.split(".") 348 | }; 349 | if (!compare(rangeAtom, versionAtom)) { 350 | return false; 351 | } 352 | } 353 | return true; 354 | } 355 | 356 | // eslint-disable-next-line no-undef 357 | const moduleMap = {}; 358 | const moduleCache = Object.create(null); 359 | async function importShared(name, shareScope = 'default') { 360 | return moduleCache[name] 361 | ? new Promise((r) => r(moduleCache[name])) 362 | : (await getSharedFromRuntime(name, shareScope)) || getSharedFromLocal(name) 363 | } 364 | async function getSharedFromRuntime(name, shareScope) { 365 | let module = null; 366 | if (globalThis?.__federation_shared__?.[shareScope]?.[name]) { 367 | const versionObj = globalThis.__federation_shared__[shareScope][name]; 368 | const requiredVersion = moduleMap[name]?.requiredVersion; 369 | const hasRequiredVersion = !!requiredVersion; 370 | if (hasRequiredVersion) { 371 | const versionKey = Object.keys(versionObj).find((version) => 372 | satisfy(version, requiredVersion) 373 | ); 374 | if (versionKey) { 375 | const versionValue = versionObj[versionKey]; 376 | module = await (await versionValue.get())(); 377 | } else { 378 | console.log( 379 | `provider support ${name}(${versionKey}) is not satisfied requiredVersion(\${moduleMap[name].requiredVersion})` 380 | ); 381 | } 382 | } else { 383 | const versionKey = Object.keys(versionObj)[0]; 384 | const versionValue = versionObj[versionKey]; 385 | module = await (await versionValue.get())(); 386 | } 387 | } 388 | if (module) { 389 | return flattenModule(module, name) 390 | } 391 | } 392 | async function getSharedFromLocal(name) { 393 | if (moduleMap[name]?.import) { 394 | let module = await (await moduleMap[name].get())(); 395 | return flattenModule(module, name) 396 | } else { 397 | console.error( 398 | `consumer config import=false,so cant use callback shared module` 399 | ); 400 | } 401 | } 402 | function flattenModule(module, name) { 403 | // use a shared module which export default a function will getting error 'TypeError: xxx is not a function' 404 | if (typeof module.default === 'function') { 405 | Object.keys(module).forEach((key) => { 406 | if (key !== 'default') { 407 | module.default[key] = module[key]; 408 | } 409 | }); 410 | moduleCache[name] = module.default; 411 | return module.default 412 | } 413 | if (module.default) module = Object.assign({}, module.default, module); 414 | moduleCache[name] = module; 415 | return module 416 | } 417 | 418 | export { importShared, getSharedFromLocal as importSharedLocal, getSharedFromRuntime as importSharedRuntime }; 419 | -------------------------------------------------------------------------------- /plugins.v2/danmu/dist/assets/_plugin-vue_export-helper-pcqpp-6-.js: -------------------------------------------------------------------------------- 1 | const _export_sfc = (sfc, props) => { 2 | const target = sfc.__vccOpts || sfc; 3 | for (const [key, val] of props) { 4 | target[key] = val; 5 | } 6 | return target; 7 | }; 8 | 9 | export { _export_sfc as _ }; 10 | -------------------------------------------------------------------------------- /plugins.v2/danmu/dist/assets/index-cr18jDRr.css: -------------------------------------------------------------------------------- 1 | 2 | .plugin-app { 3 | width: 100%; 4 | height: 100%; 5 | display: flex; 6 | flex-direction: column; 7 | } 8 | @supports not selector(:focus-visible) { 9 | } 10 | @supports not selector(:focus-visible) { 11 | } 12 | @supports selector(:focus-visible) { 13 | }@supports not selector(:focus-visible) { 14 | }@keyframes progress-circular-dash { 15 | 0% { 16 | stroke-dasharray: 1, 200; 17 | stroke-dashoffset: 0px; 18 | } 19 | 50% { 20 | stroke-dasharray: 100, 200; 21 | stroke-dashoffset: -15px; 22 | } 23 | 100% { 24 | stroke-dasharray: 100, 200; 25 | stroke-dashoffset: -124px; 26 | } 27 | } 28 | @keyframes progress-circular-rotate { 29 | 100% { 30 | transform: rotate(270deg); 31 | } 32 | }@media (forced-colors: active) { 33 | } 34 | 35 | @media (forced-colors: active) { 36 | } 37 | @media (forced-colors: active) { 38 | } 39 | 40 | @keyframes indeterminate-ltr { 41 | 0% { 42 | left: -90%; 43 | right: 100%; 44 | } 45 | 60% { 46 | left: -90%; 47 | right: 100%; 48 | } 49 | 100% { 50 | left: 100%; 51 | right: -35%; 52 | } 53 | } 54 | @keyframes indeterminate-rtl { 55 | 0% { 56 | left: 100%; 57 | right: -90%; 58 | } 59 | 60% { 60 | left: 100%; 61 | right: -90%; 62 | } 63 | 100% { 64 | left: -35%; 65 | right: 100%; 66 | } 67 | } 68 | @keyframes indeterminate-short-ltr { 69 | 0% { 70 | left: -200%; 71 | right: 100%; 72 | } 73 | 60% { 74 | left: 107%; 75 | right: -8%; 76 | } 77 | 100% { 78 | left: 107%; 79 | right: -8%; 80 | } 81 | } 82 | @keyframes indeterminate-short-rtl { 83 | 0% { 84 | left: 100%; 85 | right: -200%; 86 | } 87 | 60% { 88 | left: -8%; 89 | right: 107%; 90 | } 91 | 100% { 92 | left: -8%; 93 | right: 107%; 94 | } 95 | } 96 | @keyframes stream { 97 | to { 98 | transform: translateX(var(--v-progress-linear-stream-to)); 99 | } 100 | } 101 | @keyframes progress-linear-stripes { 102 | 0% { 103 | background-position-x: var(--v-progress-linear-height); 104 | } 105 | }@supports not selector(:focus-visible) { 106 | } 107 | @supports not selector(:focus-visible) { 108 | }@supports not selector(:focus-visible) { 109 | } 110 | @supports not selector(:focus-visible) { 111 | } 112 | @supports selector(:focus-visible) { 113 | }/* region BLOCK */ 114 | 115 | /* endregion */ 116 | /* region ELEMENTS */ 117 | 118 | /* endregion *//* region INPUT */ 119 | 120 | /* endregion */ 121 | /* region MODIFIERS */ 122 | 123 | /* endregion */ 124 | /* region ELEMENTS */ 125 | 126 | /* endregion */ 127 | /* region AFFIXES */ 128 | @media (hover: hover) { 129 | } 130 | @media (hover: none) { 131 | } 132 | 133 | /* endregion */ 134 | /* region LABEL */ 135 | 136 | /* endregion */ 137 | /* region OUTLINE */ 138 | @media (hover: hover) { 139 | } 140 | 141 | /* endregion */ 142 | /* region LOADER */ 143 | 144 | /* endregion */ 145 | /* region OVERLAY */ 146 | @media (hover: hover) { 147 | } 148 | @media (hover: hover) { 149 | } 150 | @media (hover: hover) { 151 | } 152 | 153 | /* endregion */ 154 | /* region MODIFIERS */ 155 | 156 | /* endregion */.bottom-sheet-transition-enter-from { 157 | transform: translateY(100%); 158 | } 159 | .bottom-sheet-transition-leave-to { 160 | transform: translateY(100%); 161 | } 162 | @media (min-width: 600px) { 163 | }@supports not selector(:focus-visible) { 164 | } 165 | @supports not selector(:focus-visible) { 166 | }@media (forced-colors: active) { 167 | } 168 | 169 | @media (hover: hover) { 170 | }@media (forced-colors: active) { 171 | } 172 | @media (forced-colors: active) { 173 | } 174 | @media (forced-colors: active) { 175 | }@media (min-width: 960px) { 176 | } 177 | @media (min-width: 1280px) { 178 | } 179 | @media (min-width: 1920px) { 180 | } 181 | @media (min-width: 2560px) { 182 | } 183 | 184 | .offset-1 { 185 | margin-inline-start: 8.3333333333%; 186 | } 187 | 188 | .offset-2 { 189 | margin-inline-start: 16.6666666667%; 190 | } 191 | 192 | .offset-3 { 193 | margin-inline-start: 25%; 194 | } 195 | 196 | .offset-4 { 197 | margin-inline-start: 33.3333333333%; 198 | } 199 | 200 | .offset-5 { 201 | margin-inline-start: 41.6666666667%; 202 | } 203 | 204 | .offset-6 { 205 | margin-inline-start: 50%; 206 | } 207 | 208 | .offset-7 { 209 | margin-inline-start: 58.3333333333%; 210 | } 211 | 212 | .offset-8 { 213 | margin-inline-start: 66.6666666667%; 214 | } 215 | 216 | .offset-9 { 217 | margin-inline-start: 75%; 218 | } 219 | 220 | .offset-10 { 221 | margin-inline-start: 83.3333333333%; 222 | } 223 | 224 | .offset-11 { 225 | margin-inline-start: 91.6666666667%; 226 | } 227 | 228 | @media (min-width: 600px) { 229 | .offset-sm-0 { 230 | margin-inline-start: 0; 231 | } 232 | .offset-sm-1 { 233 | margin-inline-start: 8.3333333333%; 234 | } 235 | .offset-sm-2 { 236 | margin-inline-start: 16.6666666667%; 237 | } 238 | .offset-sm-3 { 239 | margin-inline-start: 25%; 240 | } 241 | .offset-sm-4 { 242 | margin-inline-start: 33.3333333333%; 243 | } 244 | .offset-sm-5 { 245 | margin-inline-start: 41.6666666667%; 246 | } 247 | .offset-sm-6 { 248 | margin-inline-start: 50%; 249 | } 250 | .offset-sm-7 { 251 | margin-inline-start: 58.3333333333%; 252 | } 253 | .offset-sm-8 { 254 | margin-inline-start: 66.6666666667%; 255 | } 256 | .offset-sm-9 { 257 | margin-inline-start: 75%; 258 | } 259 | .offset-sm-10 { 260 | margin-inline-start: 83.3333333333%; 261 | } 262 | .offset-sm-11 { 263 | margin-inline-start: 91.6666666667%; 264 | } 265 | } 266 | @media (min-width: 960px) { 267 | .offset-md-0 { 268 | margin-inline-start: 0; 269 | } 270 | .offset-md-1 { 271 | margin-inline-start: 8.3333333333%; 272 | } 273 | .offset-md-2 { 274 | margin-inline-start: 16.6666666667%; 275 | } 276 | .offset-md-3 { 277 | margin-inline-start: 25%; 278 | } 279 | .offset-md-4 { 280 | margin-inline-start: 33.3333333333%; 281 | } 282 | .offset-md-5 { 283 | margin-inline-start: 41.6666666667%; 284 | } 285 | .offset-md-6 { 286 | margin-inline-start: 50%; 287 | } 288 | .offset-md-7 { 289 | margin-inline-start: 58.3333333333%; 290 | } 291 | .offset-md-8 { 292 | margin-inline-start: 66.6666666667%; 293 | } 294 | .offset-md-9 { 295 | margin-inline-start: 75%; 296 | } 297 | .offset-md-10 { 298 | margin-inline-start: 83.3333333333%; 299 | } 300 | .offset-md-11 { 301 | margin-inline-start: 91.6666666667%; 302 | } 303 | } 304 | @media (min-width: 1280px) { 305 | .offset-lg-0 { 306 | margin-inline-start: 0; 307 | } 308 | .offset-lg-1 { 309 | margin-inline-start: 8.3333333333%; 310 | } 311 | .offset-lg-2 { 312 | margin-inline-start: 16.6666666667%; 313 | } 314 | .offset-lg-3 { 315 | margin-inline-start: 25%; 316 | } 317 | .offset-lg-4 { 318 | margin-inline-start: 33.3333333333%; 319 | } 320 | .offset-lg-5 { 321 | margin-inline-start: 41.6666666667%; 322 | } 323 | .offset-lg-6 { 324 | margin-inline-start: 50%; 325 | } 326 | .offset-lg-7 { 327 | margin-inline-start: 58.3333333333%; 328 | } 329 | .offset-lg-8 { 330 | margin-inline-start: 66.6666666667%; 331 | } 332 | .offset-lg-9 { 333 | margin-inline-start: 75%; 334 | } 335 | .offset-lg-10 { 336 | margin-inline-start: 83.3333333333%; 337 | } 338 | .offset-lg-11 { 339 | margin-inline-start: 91.6666666667%; 340 | } 341 | } 342 | @media (min-width: 1920px) { 343 | .offset-xl-0 { 344 | margin-inline-start: 0; 345 | } 346 | .offset-xl-1 { 347 | margin-inline-start: 8.3333333333%; 348 | } 349 | .offset-xl-2 { 350 | margin-inline-start: 16.6666666667%; 351 | } 352 | .offset-xl-3 { 353 | margin-inline-start: 25%; 354 | } 355 | .offset-xl-4 { 356 | margin-inline-start: 33.3333333333%; 357 | } 358 | .offset-xl-5 { 359 | margin-inline-start: 41.6666666667%; 360 | } 361 | .offset-xl-6 { 362 | margin-inline-start: 50%; 363 | } 364 | .offset-xl-7 { 365 | margin-inline-start: 58.3333333333%; 366 | } 367 | .offset-xl-8 { 368 | margin-inline-start: 66.6666666667%; 369 | } 370 | .offset-xl-9 { 371 | margin-inline-start: 75%; 372 | } 373 | .offset-xl-10 { 374 | margin-inline-start: 83.3333333333%; 375 | } 376 | .offset-xl-11 { 377 | margin-inline-start: 91.6666666667%; 378 | } 379 | } 380 | @media (min-width: 2560px) { 381 | .offset-xxl-0 { 382 | margin-inline-start: 0; 383 | } 384 | .offset-xxl-1 { 385 | margin-inline-start: 8.3333333333%; 386 | } 387 | .offset-xxl-2 { 388 | margin-inline-start: 16.6666666667%; 389 | } 390 | .offset-xxl-3 { 391 | margin-inline-start: 25%; 392 | } 393 | .offset-xxl-4 { 394 | margin-inline-start: 33.3333333333%; 395 | } 396 | .offset-xxl-5 { 397 | margin-inline-start: 41.6666666667%; 398 | } 399 | .offset-xxl-6 { 400 | margin-inline-start: 50%; 401 | } 402 | .offset-xxl-7 { 403 | margin-inline-start: 58.3333333333%; 404 | } 405 | .offset-xxl-8 { 406 | margin-inline-start: 66.6666666667%; 407 | } 408 | .offset-xxl-9 { 409 | margin-inline-start: 75%; 410 | } 411 | .offset-xxl-10 { 412 | margin-inline-start: 83.3333333333%; 413 | } 414 | .offset-xxl-11 { 415 | margin-inline-start: 91.6666666667%; 416 | } 417 | }.date-picker-header-transition-enter-active, 418 | .date-picker-header-reverse-transition-enter-active { 419 | transition-duration: 0.3s; 420 | transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); 421 | } 422 | .date-picker-header-transition-leave-active, 423 | .date-picker-header-reverse-transition-leave-active { 424 | transition-duration: 0.3s; 425 | transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); 426 | } 427 | 428 | .date-picker-header-transition-enter-from { 429 | transform: translate(0, 100%); 430 | } 431 | .date-picker-header-transition-leave-to { 432 | opacity: 0; 433 | transform: translate(0, -100%); 434 | } 435 | 436 | .date-picker-header-reverse-transition-enter-from { 437 | transform: translate(0, -100%); 438 | } 439 | .date-picker-header-reverse-transition-leave-to { 440 | opacity: 0; 441 | transform: translate(0, 100%); 442 | }@supports not selector(:focus-visible) { 443 | } 444 | @supports not selector(:focus-visible) { 445 | }@keyframes loading { 446 | 100% { 447 | transform: translateX(100%); 448 | } 449 | }@supports not selector(:focus-visible) { 450 | } 451 | @supports not selector(:focus-visible) { 452 | }@media (forced-colors: active) { 453 | }@media (max-width: 1279.98px) { 454 | }/** Modifiers **/ -------------------------------------------------------------------------------- /plugins.v2/danmu/dist/assets/remoteEntry.js: -------------------------------------------------------------------------------- 1 | const currentImports = {}; 2 | const exportSet = new Set(['Module', '__esModule', 'default', '_export_sfc']); 3 | let moduleMap = { 4 | "./Page":()=>{ 5 | dynamicLoadingCss(["__federation_expose_Page-CyDIESC3.css"], false, './Page'); 6 | return __federation_import('./__federation_expose_Page-DF3RUHu3.js').then(module =>Object.keys(module).every(item => exportSet.has(item)) ? () => module.default : () => module)}, 7 | "./Config":()=>{ 8 | dynamicLoadingCss(["__federation_expose_Config-mmMv5D16.css"], false, './Config'); 9 | return __federation_import('./__federation_expose_Config-BdvrOUFg.js').then(module =>Object.keys(module).every(item => exportSet.has(item)) ? () => module.default : () => module)},}; 10 | const seen = {}; 11 | const dynamicLoadingCss = (cssFilePaths, dontAppendStylesToHead, exposeItemName) => { 12 | const metaUrl = import.meta.url; 13 | if (typeof metaUrl === 'undefined') { 14 | console.warn('The remote style takes effect only when the build.target option in the vite.config.ts file is higher than that of "es2020".'); 15 | return; 16 | } 17 | 18 | const curUrl = metaUrl.substring(0, metaUrl.lastIndexOf('remoteEntry.js')); 19 | const base = '/'; 20 | 'assets'; 21 | 22 | cssFilePaths.forEach(cssPath => { 23 | let href = ''; 24 | const baseUrl = base || curUrl; 25 | if (baseUrl) { 26 | const trimmer = { 27 | trailing: (path) => (path.endsWith('/') ? path.slice(0, -1) : path), 28 | leading: (path) => (path.startsWith('/') ? path.slice(1) : path) 29 | }; 30 | const isAbsoluteUrl = (url) => url.startsWith('http') || url.startsWith('//'); 31 | 32 | const cleanBaseUrl = trimmer.trailing(baseUrl); 33 | const cleanCssPath = trimmer.leading(cssPath); 34 | const cleanCurUrl = trimmer.trailing(curUrl); 35 | 36 | if (isAbsoluteUrl(baseUrl)) { 37 | href = [cleanBaseUrl, cleanCssPath].filter(Boolean).join('/'); 38 | } else { 39 | if (cleanCurUrl.includes(cleanBaseUrl)) { 40 | href = [cleanCurUrl, cleanCssPath].filter(Boolean).join('/'); 41 | } else { 42 | href = [cleanCurUrl + cleanBaseUrl, cleanCssPath].filter(Boolean).join('/'); 43 | } 44 | } 45 | } else { 46 | href = cssPath; 47 | } 48 | 49 | if (dontAppendStylesToHead) { 50 | const key = 'css__Logsclean__' + exposeItemName; 51 | window[key] = window[key] || []; 52 | window[key].push(href); 53 | return; 54 | } 55 | 56 | if (href in seen) return; 57 | seen[href] = true; 58 | 59 | const element = document.createElement('link'); 60 | element.rel = 'stylesheet'; 61 | element.href = href; 62 | document.head.appendChild(element); 63 | }); 64 | }; 65 | async function __federation_import(name) { 66 | currentImports[name] ??= import(name); 67 | return currentImports[name] 68 | } const get =(module) => { 69 | if(!moduleMap[module]) throw new Error('Can not find remote module ' + module) 70 | return moduleMap[module](); 71 | }; 72 | const init =(shareScope) => { 73 | globalThis.__federation_shared__= globalThis.__federation_shared__|| {}; 74 | Object.entries(shareScope).forEach(([key, value]) => { 75 | for (const [versionKey, versionValue] of Object.entries(value)) { 76 | const scope = versionValue.scope || 'default'; 77 | globalThis.__federation_shared__[scope] = globalThis.__federation_shared__[scope] || {}; 78 | const shared= globalThis.__federation_shared__[scope]; 79 | (shared[key] = shared[key]||{})[versionKey] = versionValue; 80 | } 81 | }); 82 | }; 83 | 84 | export { dynamicLoadingCss, get, init }; 85 | -------------------------------------------------------------------------------- /plugins.v2/danmu/dist/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 弹幕刮削 8 | 9 | 10 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 |
30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /plugins.v2/danmu/doc/api.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HankunYu/MoviePilot-Plugins/b129f043eddcec0f9028a0eea86f0ef5db4d2cc1/plugins.v2/danmu/doc/api.png -------------------------------------------------------------------------------- /plugins.v2/danmu/doc/api2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HankunYu/MoviePilot-Plugins/b129f043eddcec0f9028a0eea86f0ef5db4d2cc1/plugins.v2/danmu/doc/api2.png -------------------------------------------------------------------------------- /plugins.v2/danmu/doc/page.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HankunYu/MoviePilot-Plugins/b129f043eddcec0f9028a0eea86f0ef5db4d2cc1/plugins.v2/danmu/doc/page.png -------------------------------------------------------------------------------- /plugins.v2/danmu/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 弹幕刮削 8 | 9 | 10 | 17 | 18 | 19 | 20 |
21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /plugins.v2/danmu/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "moviepilot-plugin-component", 3 | "private": true, 4 | "version": "1.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "vite build", 9 | "preview": "vite preview" 10 | }, 11 | "dependencies": { 12 | "vue": "^3.3.4", 13 | "vuetify": "^3.4.0", 14 | "echarts": "^5.4.3", 15 | "vue-echarts": "^6.6.1", 16 | "@vueuse/core": "^10.6.0", 17 | "cron-parser": "^4.9.0", 18 | "cronstrue": "^2.48.0" 19 | }, 20 | "devDependencies": { 21 | "@originjs/vite-plugin-federation": "^1.3.5", 22 | "@vitejs/plugin-vue": "^4.4.0", 23 | "vite": "^5.0.0" 24 | } 25 | } -------------------------------------------------------------------------------- /plugins.v2/danmu/src/App.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 78 | 79 | -------------------------------------------------------------------------------- /plugins.v2/danmu/src/components/Page.vue: -------------------------------------------------------------------------------- 1 | 185 | 186 | 379 | 380 | 464 | -------------------------------------------------------------------------------- /plugins.v2/danmu/src/main.js: -------------------------------------------------------------------------------- 1 | import { createApp } from 'vue' 2 | import App from './App.vue' 3 | import { createVuetify } from 'vuetify' 4 | import * as components from 'vuetify/components' 5 | import * as directives from 'vuetify/directives' 6 | import 'vuetify/styles' 7 | 8 | // 创建Vuetify实例 9 | const vuetify = createVuetify({ 10 | components, 11 | directives, 12 | theme: { 13 | defaultTheme: 'dark' 14 | } 15 | }) 16 | 17 | // 创建应用 18 | const app = createApp(App) 19 | 20 | // 使用插件 21 | app.use(vuetify) 22 | 23 | // 挂载应用 24 | app.mount('#app') 25 | -------------------------------------------------------------------------------- /plugins.v2/danmu/vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import vue from '@vitejs/plugin-vue' 3 | import federation from '@originjs/vite-plugin-federation' 4 | 5 | export default defineConfig({ 6 | plugins: [ 7 | vue(), 8 | federation({ 9 | name: 'Logsclean', 10 | filename: 'remoteEntry.js', 11 | exposes: { 12 | './Page': './src/components/Page.vue', 13 | './Config': './src/components/Config.vue', 14 | }, 15 | shared: { 16 | vue: { 17 | requiredVersion: false, 18 | generate: false, 19 | }, 20 | vuetify: { 21 | requiredVersion: false, 22 | generate: false, 23 | singleton: true, 24 | }, 25 | 'vuetify/styles': { 26 | requiredVersion: false, 27 | generate: false, 28 | singleton: true, 29 | }, 30 | }, 31 | format: 'esm' 32 | }) 33 | ], 34 | build: { 35 | target: 'esnext', // 必须设置为esnext以支持顶层await 36 | minify: false, // 开发阶段建议关闭混淆 37 | cssCodeSplit: true, // 改为true以便能分离样式文件 38 | }, 39 | css: { 40 | preprocessorOptions: { 41 | scss: { 42 | additionalData: '/* 覆盖vuetify样式 */', 43 | } 44 | }, 45 | postcss: { 46 | plugins: [ 47 | { 48 | postcssPlugin: 'internal:charset-removal', 49 | AtRule: { 50 | charset: (atRule) => { 51 | if (atRule.name === 'charset') { 52 | atRule.remove(); 53 | } 54 | } 55 | } 56 | }, 57 | { 58 | postcssPlugin: 'vuetify-filter', 59 | Root(root) { 60 | // 过滤掉所有vuetify相关的CSS 61 | root.walkRules(rule => { 62 | if (rule.selector && ( 63 | rule.selector.includes('.v-') || 64 | rule.selector.includes('.mdi-'))) { 65 | rule.remove(); 66 | } 67 | }); 68 | } 69 | } 70 | ] 71 | } 72 | }, 73 | server: { 74 | port: 5001, // 使用不同于主应用的端口 75 | cors: true, // 启用CORS 76 | origin: 'http://localhost:5001' 77 | }, 78 | }) -------------------------------------------------------------------------------- /plugins.v2/discord/README.md: -------------------------------------------------------------------------------- 1 | ### 配置 2 | 3 | 目前只支持webhook的方式,需要在discord服务器中创建一个webhook,然后将webhook的url填入插件配置中。 4 | 站点地址可以自行填写,也可以留空。如果填写了站点地址,那么在discord中点击消息时会跳转到相应页面。 5 | 6 | 跟原版一样支持各类消息通知开关。 7 | 8 | 新增discord bot,需要自行注册一个bot并获取token然后添加到自己服务器上。 9 | 步骤可参考 https://hackmd.io/@lio2619/B1PB2yB2c 10 | 需要注意添加以下权限: 11 | - bot 12 | - messages.read 13 | - application.commands 14 | 嫌麻烦可以都勾上 15 | 16 | 目前指令支持: 17 | - 下载 /download 18 | - 搜索 /search 19 | - 订阅 /subscribe 20 | 21 | 直接@bot会使用GPT进行聊天 22 | 23 | ### 已知BUG 24 | 25 | ~~- 当MP因为更新重启后,Discord插件会无法载入,需要再手动重启一次MP。~~ 26 | - 未设置GPT回应对应用户/频道,导致bot一直跟webhook消息聊天。 27 | - GPT对话过长未处理,导致消息发送失败。 28 | ~~- 莫名其妙错误导致GPT对话无法使用(在长时间开启对话后,大约一个礼拜?)~~ 29 | 30 | ### Todo 31 | 32 | - [X] 添加discord bot支持 33 | - [X] 支持使用GPT聊天以及交互 34 | - [X] 支持从discord直接发送命令 -------------------------------------------------------------------------------- /plugins.v2/discord/cogs/moviepilot_cog.py: -------------------------------------------------------------------------------- 1 | import discord, sys, re 2 | from typing import Optional, List, Tuple 3 | from discord import app_commands 4 | from discord.ext import commands 5 | from app.log import logger 6 | from app.chain.download import DownloadChain 7 | from app.chain.search import SearchChain 8 | from app.chain.subscribe import SubscribeChain 9 | from app.core.context import MediaInfo, TorrentInfo, Context 10 | from app.core.metainfo import MetaInfo 11 | from app.plugins.discord.gpt import GPT 12 | import app.plugins.discord.tokenes as tokenes 13 | 14 | class MPCog(commands.Cog): 15 | on_conversion = False 16 | current_channel = None 17 | downloadchain = None 18 | searchchain = None 19 | subscribechain = None 20 | gpt = None 21 | 22 | def __init__(self, bot: commands.Bot): 23 | self.bot = bot 24 | self.downloadchain = DownloadChain() 25 | self.searchchain = SearchChain() 26 | self.subscribechain = SubscribeChain() 27 | self.gpt = GPT(token=tokenes.gpt_token) 28 | 29 | # 监听ready事件,bot准备好后打印登录信息 30 | @commands.Cog.listener() 31 | async def on_ready(self): 32 | logger.info(f'bot 登录成功 - {self.bot.user}') 33 | game = discord.Game("看电影中...") 34 | await self.bot.change_presence(status=discord.Status.idle, activity=game) 35 | slash = await self.bot.tree.sync() 36 | logger.info(f"已载入 {len(slash)} 个指令") 37 | 38 | # 监听mention事件,使用gpt生成回复 39 | @commands.Cog.listener() 40 | async def on_message(self,message): 41 | if message.author == self.bot.user: 42 | return 43 | 44 | if self.bot.user.mentioned_in(message) or self.on_conversion: 45 | msg = re.sub(r'<.*?>', '', message.content) 46 | self.on_conversion = True 47 | self.current_channel = message.channel 48 | reply = self.gpt.generate_reply(msg) 49 | if reply != None: 50 | await message.channel.send(reply) 51 | else: 52 | await message.channel.send("啊好像哪里出错了...这不应该,你再试试?不行就算了。") 53 | game = discord.Game("模仿ChatGPT中...") 54 | await self.bot.change_presence(status=discord.Status.online, activity=game) 55 | 56 | # slash command 57 | @app_commands.command(description="停止GPT对话") 58 | async def stop(self, interaction: discord.Interaction): 59 | game = discord.Game("看电影中...") 60 | self.on_conversion = False 61 | await self.bot.change_presence(status=discord.Status.idle, activity=game) 62 | await interaction.response.send_message("^^") 63 | 64 | @app_commands.command(description="清除对话记录") 65 | async def clear(self, interaction: discord.Interaction): 66 | self.gpt.clear_chat_history() 67 | await interaction.response.send_message("对话记录已经清除") 68 | 69 | @app_commands.command(description="下载电影,如果找到多个结果,返回结果列表,让用户选择下载") 70 | async def download(self, interaction: discord.Interaction, title: str): 71 | await interaction.response.send_message("正在搜索电影信息 " + title) 72 | 73 | game = discord.Game("努力搜索中...") 74 | await self.bot.change_presence(status=discord.Status.online, activity=game) 75 | # 搜索 76 | meta = MetaInfo(title=title) 77 | medias: Optional[List[MediaInfo]] = self.searchchain.search_medias(meta=meta) 78 | if not medias: 79 | await interaction.followup.send("无法识别到媒体信息 " + title) 80 | game = discord.Game("看电影中...") 81 | await self.bot.change_presence(status=discord.Status.online, activity=game) 82 | return 83 | # 如果找到多个结果,返回结果列表,让用户选择下载 84 | game = discord.Game("看电影中...") 85 | await self.bot.change_presence(status=discord.Status.idle, activity=game) 86 | if len(medias) > 0: 87 | for media in medias: 88 | fields = [] 89 | media_title = { 90 | "name": "标题", 91 | "value": media.title 92 | } 93 | release_date = { 94 | "name": "发布日期", 95 | "value": media.release_date 96 | } 97 | vote_average = { 98 | "name": "评分", 99 | "value": media.vote_average 100 | } 101 | fields.append(media_title) 102 | fields.append(release_date) 103 | fields.append(vote_average) 104 | embed = discord.Embed(title=media.title, 105 | description=media.tmdb_info["overview"], 106 | url=media.homepage) 107 | for field in fields: 108 | embed.add_field(name=field["name"], value=field["value"], inline=True) 109 | if media.backdrop_path: 110 | embed.set_image(url=media.backdrop_path) 111 | # 组合上下文 112 | context = Context(media_info=media, meta_info=meta) 113 | view = DownloadView(context) 114 | await interaction.followup.send(embed=embed, view=view) 115 | 116 | # 如果只找到一个结果,直接下载 117 | if len(medias) == 1: 118 | mediainfo = medias[0] 119 | exist_flag, no_exists = self.downloadchain.get_no_exists_info(meta=meta, mediainfo=mediainfo) 120 | if exist_flag: 121 | await interaction.followup.send(f'{mediainfo.title_year} 已存在') 122 | return 123 | contexts = self.searchchain.process(mediainfo = mediainfo, no_exists=no_exists) 124 | if len(contexts) == 0: 125 | await interaction.followup.send("没有找到资源 " + title) 126 | # 自动下载 127 | downloads, lefts = self.downloadchain.batch_download(contexts=contexts, no_exists=no_exists, 128 | username="Discord Bot") 129 | if downloads and not lefts: 130 | await interaction.followup.send(f'{mediainfo.title_year} 添加下载') 131 | else: 132 | await interaction.followup.send(f'{mediainfo.title_year} 下载未完整,开始订阅') 133 | self.subscribechain.add(title=mediainfo.title, 134 | year=mediainfo.year, 135 | mtype=mediainfo.type, 136 | tmdbid=mediainfo.tmdb_id, 137 | season=meta.begin_season, 138 | exist_ok=True, 139 | username="Discord Bot") 140 | 141 | @app_commands.command(description="订阅电影") 142 | async def subscribe(self, interaction: discord.Interaction, title: str): 143 | await interaction.response.send_message("正在搜索电影体信息 " + title) 144 | game = discord.Game("努力搜索中...") 145 | await self.bot.change_presence(status=discord.Status.online, activity=game) 146 | # 搜索 147 | meta = MetaInfo(title=title) 148 | medias: Optional[List[MediaInfo]] = self.searchchain.search_medias(meta=meta) 149 | if not medias: 150 | await interaction.followup.send("无法识别到媒体信息 " + title) 151 | game = discord.Game("看电影中...") 152 | await self.bot.change_presence(status=discord.Status.idle, activity=game) 153 | return 154 | await interaction.followup.send("无法识别到媒体信息 " + title) 155 | game = discord.Game("看电影中...") 156 | # 如果找到多个结果,返回结果列表,让用户选择下载 157 | if len(medias) > 0: 158 | for media in medias: 159 | fields = [] 160 | media_title = { 161 | "name": "标题", 162 | "value": media.title 163 | } 164 | release_date = { 165 | "name": "发布日期", 166 | "value": media.release_date 167 | } 168 | vote_average = { 169 | "name": "评分", 170 | "value": media.vote_average 171 | } 172 | fields.append(media_title) 173 | fields.append(release_date) 174 | fields.append(vote_average) 175 | embed = discord.Embed(title=media.title, 176 | description=media.tmdb_info["overview"], 177 | url=media.homepage) 178 | for field in fields: 179 | embed.add_field(name=field["name"], value=field["value"], inline=True) 180 | if media.backdrop_path: 181 | embed.set_image(url=media.backdrop_path) 182 | # 组合上下文 183 | context = Context(media_info=media, meta_info=meta) 184 | view = SubscribeView(context) 185 | await interaction.followup.send(embed=embed, view=view) 186 | 187 | 188 | # 如果只找到一个结果,直接订阅 189 | else: 190 | mediainfo = medias[0] 191 | await interaction.followup.send(f'{mediainfo.title_year} 添加订阅') 192 | self.subscribechain.add(title=mediainfo.title, 193 | year=mediainfo.year, 194 | mtype=mediainfo.type, 195 | tmdbid=mediainfo.tmdb_id, 196 | season=meta.begin_season, 197 | exist_ok=True, 198 | username="Discord Bot") 199 | 200 | 201 | @app_commands.command(description="搜索种子,选择下载") 202 | async def search(self, interaction: discord.Interaction, title: str): 203 | await interaction.response.send_message("正在搜索电影 " + title) 204 | game = discord.Game("努力搜索中...") 205 | await self.bot.change_presence(status=discord.Status.online, activity=game) 206 | # 搜索 207 | meta = MetaInfo(title=title) 208 | mediainfo = self.searchchain.recognize_media(meta=meta) 209 | if not mediainfo: 210 | await interaction.followup.send("无法识别到媒体信息 " + title) 211 | return 212 | exist_flag, no_exists = self.downloadchain.get_no_exists_info(meta=meta, mediainfo=mediainfo) 213 | if exist_flag: 214 | await interaction.followup.send(f'{mediainfo.title_year} 已存在') 215 | return 216 | 217 | contexts = self.searchchain.process(mediainfo = mediainfo, no_exists=no_exists) 218 | if len(contexts) == 0: 219 | await interaction.followup.send("没有找到资源 " + title) 220 | game = discord.Game("看电影中...") 221 | await self.bot.change_presence(status=discord.Status.idle, activity=game) 222 | else: 223 | for context in contexts: 224 | torrent = context.torrent_info 225 | embed = discord.Embed(title=torrent.title, 226 | description=torrent.description, 227 | url=torrent.page_url) 228 | fields = [] 229 | site_name = { 230 | "name": "站点", 231 | "value": torrent.site_name 232 | } 233 | torrent_size = { 234 | "name": "种子大小", 235 | "value": str(round(torrent.size / 1024 / 1024 / 1024, 2)) + " GB" 236 | } 237 | seeders = { 238 | "name": "做种数", 239 | "value": torrent.seeders 240 | } 241 | peers = { 242 | "name": "下载数", 243 | "value": torrent.peers 244 | } 245 | release_date = { 246 | "name": "发布日期", 247 | "value": torrent.pubdate 248 | } 249 | free = { 250 | "name": "促销", 251 | "value": torrent.volume_factor 252 | } 253 | fields.append(site_name) 254 | fields.append(torrent_size) 255 | fields.append(release_date) 256 | fields.append(seeders) 257 | fields.append(peers) 258 | fields.append(free) 259 | 260 | for field in fields: 261 | embed.add_field(name=field["name"], value=field["value"], inline=True) 262 | 263 | view = DownloadView(context) 264 | await interaction.followup.send(embed=embed, view=view) 265 | 266 | game = discord.Game("看电影中...") 267 | await self.bot.change_presence(status=discord.Status.idle, activity=game) 268 | 269 | class DownloadView(discord.ui.View): 270 | context = None 271 | downloadchain = None 272 | subscribechain = None 273 | searchchain = None 274 | def __init__(self, context: Context): 275 | super().__init__(timeout=180) 276 | self.context = context 277 | self.subscribechain = SubscribeChain() 278 | self.downloadchain = DownloadChain() 279 | self.searchchain = SearchChain() 280 | 281 | @discord.ui.button(label="下载", style = discord.ButtonStyle.blurple) 282 | async def download(self, button: discord.ui.Button, interaction: discord.Interaction): 283 | # 如果已经有种子信息,直接下载 284 | if(self.context.torrent_info != None): 285 | self.downloadchain.download_single(self.context) 286 | await interaction.response.send_message(f"添加下载任务 {self.context.torrent_info.title} 成功") 287 | # 如果没有种子信息,先搜索种子,再下载 288 | else: 289 | mediainfo = self.context.media_info 290 | meta = self.context.meta_info 291 | exist_flag, no_exists = self.downloadchain.get_no_exists_info(meta=meta, mediainfo=mediainfo) 292 | if exist_flag: 293 | await interaction.response.send_message(f'{mediainfo.title_year} 已存在') 294 | return 295 | contexts = self.searchchain.process(mediainfo = mediainfo, no_exists=no_exists) 296 | if len(contexts) == 0: 297 | await interaction.response.send_message("没有找到资源 " + mediainfo.title_year) 298 | return 299 | # 自动下载 300 | downloads, lefts = self.downloadchain.batch_download(contexts=contexts, no_exists=no_exists, 301 | username="Discord Bot") 302 | if downloads and not lefts: 303 | await interaction.response.send_message(f'{mediainfo.title_year} 添加下载') 304 | else: 305 | await interaction.response.send_message(f'{mediainfo.title_year} 下载未完整,开始订阅') 306 | self.subscribechain.add(title=mediainfo.title, 307 | year=mediainfo.year, 308 | mtype=mediainfo.type, 309 | tmdbid=mediainfo.tmdb_id, 310 | season=meta.begin_season, 311 | exist_ok=True, 312 | username="Discord Bot") 313 | 314 | class SubscribeView(discord.ui.View): 315 | context = None 316 | subscribechain = None 317 | def __init__(self, context: Context): 318 | super().__init__(timeout=180) 319 | self.context = context 320 | self.subscribechain = SubscribeChain() 321 | 322 | @discord.ui.button(label="订阅", style = discord.ButtonStyle.blurple) 323 | async def subscribe(self, button: discord.ui.Button, interaction: discord.Interaction): 324 | mediainfo = self.context.media_info 325 | meta = self.context.meta_info 326 | self.subscribechain.add(title=mediainfo.title, 327 | year=mediainfo.year, 328 | mtype=mediainfo.type, 329 | tmdbid=mediainfo.tmdb_id, 330 | season=meta.begin_season, 331 | exist_ok=True, 332 | username="Discord Bot") 333 | await interaction.response.send_message(f"已添加订阅 {mediainfo.title_year}") 334 | async def setup(bot : commands.Bot): 335 | await bot.add_cog(MPCog(bot)) -------------------------------------------------------------------------------- /plugins.v2/discord/discord_bot.py: -------------------------------------------------------------------------------- 1 | import discord, discord.webhook, asyncio, os, sys 2 | from discord.ext import commands 3 | import app.plugins.discord.tokenes as tokenes 4 | from app.log import logger 5 | 6 | on_conversion = False 7 | current_channel = None 8 | intents = discord.Intents.all() 9 | client = commands.Bot(command_prefix='$', intents=intents) 10 | 11 | # Load cogs 12 | async def load_extensions(): 13 | try: 14 | await client.load_extension(f"app.plugins.discord.cogs.moviepilot_cog") 15 | logger.info("Cog 加载完成") 16 | except Exception as e: 17 | logger.error(f"Cog 加载失败: {e}") 18 | 19 | # Unload cogs 20 | async def unload_extensions(): 21 | try: 22 | await client.unload_extension(f"app.plugins.discord.cogs.moviepilot_cog") 23 | logger.info("Cog 卸载完成") 24 | except Exception as e: 25 | logger.error(f"Cog 卸载失败: {e}") 26 | 27 | # Run bot 28 | async def run_bot(): 29 | if tokenes.is_bot_running: 30 | try: 31 | await load_extensions() 32 | except Exception as e: 33 | logger.error(f"Discord bot 已启动") 34 | else: 35 | try: 36 | logger.info("Discord bot 启动中...") 37 | tokenes.is_bot_running = True 38 | await load_extensions() 39 | await client.start(tokenes.bot_token) 40 | except Exception as e: 41 | logger.error(f"Discord bot 启动失败: {e}") 42 | tokenes.is_bot_running = False 43 | 44 | 45 | async def stop(): 46 | logger.info(f"is bot running: {tokenes.is_bot_running}") 47 | if tokenes.is_bot_running == True: 48 | logger.info("Discord bot 停止中...") 49 | try: 50 | await unload_extensions() 51 | tokenes.is_bot_running = False 52 | # client.clear() 53 | except Exception as e: 54 | logger.error(f"Discord bot 停止失败: {e}") 55 | else: 56 | logger.info("Discord bot 未运行") 57 | -------------------------------------------------------------------------------- /plugins.v2/discord/gpt.py: -------------------------------------------------------------------------------- 1 | 2 | from app.log import logger 3 | import openai 4 | class GPT(): 5 | openai_version_low = openai.__version__ == "0.27.10" 6 | logger.info(f"OpenAI 版本: {openai.__version__} {openai_version_low}") 7 | client = None 8 | gpt_token = None 9 | chat_start = [ 10 | {"role": "system", "content": "用傲娇的口吻和我说话,可以使用一些颜文字。"}, 11 | ] 12 | chat_history = chat_start 13 | def __init__(self, token = None): 14 | self.gpt_token = token 15 | if(self.gpt_token == None): 16 | logger.error(f"未设置OpenAI token") 17 | return 18 | if(self.openai_version_low): 19 | openai.api_key = self.gpt_token 20 | else: 21 | self.client = openai.OpenAI(api_key=self.gpt_token) 22 | logger.info("GPT 初始化完成") 23 | 24 | def clear_chat_history(self): 25 | self.chat_history = self.chat_start 26 | 27 | # 生成回复,并添加到chat_history 28 | def generate_reply(self,message): 29 | if(self.gpt_token == None): 30 | return f"未设置OpenAI token" 31 | # chat_history 添加用户输入 32 | self.chat_history.append({"role": "user", "content": message}) 33 | 34 | if self.openai_version_low: 35 | chat = openai.ChatCompletion.create( 36 | model="gpt-3.5-turbo", 37 | messages=self.chat_history) 38 | else: 39 | chat = self.client.chat.completions.create( 40 | model="gpt-3.5-turbo", 41 | messages = self.chat_history, 42 | ) 43 | # chat_history 添加助手回复 44 | self.chat_history.append({"role": "assistant", "content": chat.choices[0].message.content}) 45 | 46 | return chat.choices[0].message.content 47 | -------------------------------------------------------------------------------- /plugins.v2/discord/requirements.txt: -------------------------------------------------------------------------------- 1 | discord.py~=2.3.2 2 | openai~=1.9.0 -------------------------------------------------------------------------------- /plugins.v2/discord/tokenes.py: -------------------------------------------------------------------------------- 1 | 2 | bot_token = None 3 | gpt_token = None 4 | is_bot_running = False 5 | 6 | -------------------------------------------------------------------------------- /plugins.v2/test/__init__.py: -------------------------------------------------------------------------------- 1 | #import discord 2 | from enum import Enum 3 | import asyncio, threading 4 | 5 | # MoviePilot library 6 | from app.core.event import eventmanager, Event 7 | from app.log import logger 8 | from app.plugins import _PluginBase 9 | from app.core.event import eventmanager 10 | from app.schemas.types import EventType, NotificationType 11 | from typing import Any, List, Dict, Tuple 12 | from app.utils.http import RequestUtils 13 | from app.core.config import settings 14 | 15 | 16 | class Test(_PluginBase): 17 | # 插件名称 18 | plugin_name = "test" 19 | # 插件描述 20 | plugin_desc = "Test plugin" 21 | # 插件图标 22 | plugin_icon = "https://raw.githubusercontent.com/HankunYu/MoviePilot-Plugins/main/icons/discord.png" 23 | # 主题色 24 | plugin_color = "#3B5E8E" 25 | # 插件版本 26 | plugin_version = "1.3.9" 27 | # 插件作者 28 | plugin_author = "hankun" 29 | # 作者主页 30 | author_url = "https://github.com/hankunyu" 31 | # 插件配置项ID前缀 32 | plugin_config_prefix = "test_" 33 | # 加载顺序 34 | plugin_order = 1 35 | # 可使用的用户级别 36 | auth_level = 1 37 | 38 | 39 | _enabled = False 40 | def init_plugin(self, config: dict = None): 41 | if config: 42 | self._enabled = config.get("enabled") 43 | 44 | 45 | def get_state(self) -> bool: 46 | return self._enabled 47 | 48 | @staticmethod 49 | def get_command() -> List[Dict[str, Any]]: 50 | pass 51 | 52 | def get_api(self) -> List[Dict[str, Any]]: 53 | """ 54 | 获取插件API 55 | [{ 56 | "path": "/xx", 57 | "endpoint": self.xxx, 58 | "methods": ["GET", "POST"], 59 | "summary": "API说明" 60 | }] 61 | """ 62 | return [{ 63 | "path": "/test_button_click", 64 | "endpoint": self.test_button_click, 65 | "methods": ["GET"], 66 | "summary": "测试按钮点击", 67 | "description": "测试按钮点击事件" 68 | }] 69 | 70 | # 插件配置页面 71 | def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: 72 | """ 73 | 拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构 74 | """ 75 | return [ 76 | { 77 | 'component': 'VForm', 78 | 'content': [ 79 | { 80 | 'component': 'VRow', 81 | 'content': [ 82 | { 83 | 'component': 'VCol', 84 | 'props': { 85 | 'cols': 12, 86 | 'md': 6 87 | }, 88 | 'content': [ 89 | { 90 | 'component': 'VSwitch', 91 | 'props': { 92 | 'model': 'enabled', 93 | 'label': '启用插件', 94 | } 95 | } 96 | ] 97 | } 98 | ] 99 | } 100 | ] 101 | } 102 | ], { 103 | "enabled": False 104 | } 105 | 106 | def test_button_click(self): 107 | """ 108 | 测试按钮点击事件 109 | """ 110 | logger.info("测试按钮被点击了!") 111 | 112 | def get_page(self) -> List[dict]: 113 | """ 114 | 获取插件页面 115 | """ 116 | return [{ 117 | 'component': 'VForm', 118 | 'content': [ 119 | { 120 | 'component': 'VRow', 121 | 'content': [ 122 | { 123 | 'component': 'VCol', 124 | 'props': { 125 | 'cols': 12, 126 | 'md': 6 127 | }, 128 | 'content': [ 129 | { 130 | 'component': 'VBtn', 131 | 'props': { 132 | 'variant': 'tonal', 133 | 'text': '测试按钮' 134 | }, 135 | 'events': { 136 | 'click': { 137 | 'api': 'plugin/test/test_button_click', 138 | 'method': 'get', 139 | 'params': { 140 | 'apikey': settings.API_TOKEN 141 | } 142 | } 143 | } 144 | } 145 | ] 146 | } 147 | ] 148 | } 149 | ] 150 | }] 151 | 152 | 153 | def stop_service(self): 154 | """ 155 | 退出插件 156 | """ 157 | pass 158 | -------------------------------------------------------------------------------- /plugins/bangumi/README.md: -------------------------------------------------------------------------------- 1 | ### 配置 2 | 3 | 需要 Bangumi API Key,申请地址:https://next.bgm.tv/demo/access-token 4 | 5 | 只会同步选择的媒体服务器中的媒体内容。 6 | 媒体库路径只用于更改本地NFO文件的评分。 7 | 8 | **自定义识别词** 9 | 只会使用识别词替换,请在前一个使用Bangumi标题,后一个使用TMDB标题。如有季数,请用中文添加。 10 | 如: 11 | ``` 12 | 银魂 => 银魂 第二季 13 | 银魂 => 银魂 第三季 14 | 银魂 => 银魂 第四季 15 | 银魂' => 银魂 第五季 16 | 银魂'延长战 => 银魂 第六季 17 | 银魂° => 银魂 第七季 18 | ``` 19 | --- 20 | ### 更新日志 21 | 22 | #### 1.0.9 23 | - 更新评分增加了对整个番剧评分的更新。 24 | 25 | #### 1.0.8 26 | - 修复MP更新后,脚本无法启动的问题。 27 | - 修复插件页面空白问题。 28 | 29 | #### 1.0.7 30 | - 修复新增想看条目缓慢,要等订阅/下载后才增加 31 | 32 | #### 1.0.6 33 | - 修复缓存时自定义词替换失效的问题 34 | - 提高识别的准确度 35 | 36 | #### 1.0.5 37 | 38 | - 修复了一些bug 39 | - 增加季数识别,当添加想看条目到下载时,根据自定义识别词识别季数并下载 40 | 41 | #### 1.0.4 42 | 43 | - 新增自定义识别词替换,用于识别 Bangumi 标题与 TMDB 标题的互相识别。请在自定义识别词页面自行增加 'Bangumi上的标题' => 'TMDB上的标题'。(如果发现重复下载想看上的项目,请添加识别词) 44 | - 新增脚本版本识别,防止多个脚本版本不一致卡死进程。目前是因为MP不会自动更新除了主脚本外的附带脚本。相信很快就会修复。 45 | - 优化缓存更新逻辑。 46 | -------------------------------------------------------------------------------- /plugins/bangumi/bangumi_db.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from sqlalchemy import Column, Integer, String, Sequence 4 | from sqlalchemy.orm import Session 5 | 6 | from app.db import db_query 7 | from app.db import Base, db_update 8 | 9 | 10 | class BangumiInfo(Base): 11 | plugin_version = "1.0.9" 12 | """ 13 | Bangumi 数据表 14 | """ 15 | id = Column(Integer, Sequence('id'), primary_key=True, index=True) 16 | # 标题 17 | title = Column(String, index=True) 18 | # 原标题 19 | original_title = Column(String) 20 | # bangumi 项目 ID 21 | subject_id = Column(String, index=True) 22 | # 评分 23 | rating = Column(String) 24 | # 收藏状态 1:想看 2:看过 3:在看 4:搁置 5:抛弃 25 | status = Column(String, index=True) 26 | # 是否同步过 27 | synced = Column(String, index=True) 28 | # poster 29 | poster = Column(String) 30 | 31 | @staticmethod 32 | @db_query 33 | def get_by_title(db: Session, title: str): 34 | return db.query(BangumiInfo).filter(BangumiInfo.title == title).first() 35 | 36 | @staticmethod 37 | @db_update 38 | def empty(db: Session): 39 | db.query(BangumiInfo).delete() 40 | 41 | @staticmethod 42 | @db_query 43 | def exists_by_title(db: Session, title: str): 44 | return db.query(BangumiInfo).filter(BangumiInfo.title == title).first() 45 | 46 | @staticmethod 47 | @db_query 48 | def get_amount(db: Session): 49 | return db.query(BangumiInfo).count() 50 | 51 | @staticmethod 52 | @db_query 53 | def get_all(db: Session): 54 | return db.query(BangumiInfo).all() 55 | 56 | @staticmethod 57 | @db_query 58 | def get_all_bangumi(db: Session): 59 | return db.query(BangumiInfo).filter(BangumiInfo.subject_id != None).all() 60 | 61 | @staticmethod 62 | @db_update 63 | def update_info(db: Session, title: str, original_title: str ,subject_id: str, rating: str, status: str, synced: bool, poster: str): 64 | db.query(BangumiInfo).filter(BangumiInfo.title == title).update({ 65 | "original_title": original_title, 66 | "subject_id": subject_id, 67 | "rating": rating, 68 | "status": status, 69 | "synced": synced, 70 | "poster": poster, 71 | }) 72 | 73 | @staticmethod 74 | @db_query 75 | def get_wish(db: Session): 76 | return db.query(BangumiInfo).filter(BangumiInfo.status == 1).all() 77 | 78 | @staticmethod 79 | @db_query 80 | def get_watched(db: Session): 81 | return db.query(BangumiInfo).filter(BangumiInfo.status == 2).all() 82 | 83 | @staticmethod 84 | @db_query 85 | def get_watching(db: Session): 86 | return db.query(BangumiInfo).filter(BangumiInfo.status == 3).all() 87 | 88 | @staticmethod 89 | @db_query 90 | def get_dropped(db: Session): 91 | return db.query(BangumiInfo).filter(BangumiInfo.status == 4).all() 92 | 93 | @staticmethod 94 | @db_query 95 | def exists_by_subject_id(db: Session, subject_id: str): 96 | return db.query(BangumiInfo).filter(BangumiInfo.subject_id == subject_id).first() -------------------------------------------------------------------------------- /plugins/bangumi/bangumi_db_oper.py: -------------------------------------------------------------------------------- 1 | import json 2 | from typing import Optional 3 | 4 | from sqlalchemy.orm import Session 5 | 6 | from app.db import DbOper 7 | from plugins.bangumi.bangumi_db import BangumiInfo 8 | 9 | 10 | class BangumiOper(DbOper): 11 | 12 | plugin_version = "1.0.9" 13 | """ 14 | 媒体服务器数据管理 15 | """ 16 | 17 | def __init__(self, db: Session = None): 18 | super().__init__(db) 19 | 20 | def add(self, **kwargs) -> bool: 21 | """ 22 | 新增媒体服务器数据 23 | """ 24 | item = BangumiInfo(**kwargs) 25 | if not item.get_by_title(self._db, kwargs.get("title")): 26 | item.create(self._db) 27 | return True 28 | return False 29 | 30 | def empty(self): 31 | """ 32 | 清空 Bangumi 数据 33 | """ 34 | BangumiInfo.empty(self._db) 35 | 36 | def exists(self, **kwargs) -> Optional[BangumiInfo]: 37 | """ 38 | 判断媒体服务器数据是否存在 39 | """ 40 | if kwargs.get("title"): 41 | item = BangumiInfo.exists_by_title(self._db, title=kwargs.get("title")) 42 | else: 43 | return None 44 | if not item: 45 | return None 46 | return item 47 | 48 | def get_subject_id(self, **kwargs) -> Optional[str]: 49 | """ 50 | 获取 Bangumi ID 51 | """ 52 | item = self.exists(**kwargs) 53 | if not item: 54 | return None 55 | return str(item.subject_id) 56 | 57 | def get_amount(self) -> int: 58 | """ 59 | 获取 Bangumi 数据量 60 | """ 61 | return BangumiInfo.get_amount(self._db) 62 | 63 | def get_all(self) -> list: 64 | """ 65 | 获取所有 Bangumi 数据 66 | """ 67 | return BangumiInfo.get_all(self._db) 68 | 69 | def update_info(self, **kwargs) -> bool: 70 | """ 71 | 更新 Bangumi 数据 72 | """ 73 | item = self.exists(title = kwargs.get("title")) 74 | if not item: 75 | return False 76 | item.update_info(self._db, **kwargs) 77 | return True 78 | 79 | def get_original_title(self, **kwargs) -> Optional[str]: 80 | """ 81 | 获取原标题 82 | """ 83 | item = self.exists(**kwargs) 84 | if not item: 85 | return None 86 | return str(item.original_title) 87 | 88 | def get_all_bangumi(self) -> list: 89 | """ 90 | 获取所有 Bangumi 数据 91 | """ 92 | return BangumiInfo.get_all_bangumi(self._db) 93 | 94 | def get_wish(self) -> list: 95 | """ 96 | 获取所有 Bangumi 上 想看 的条目 97 | """ 98 | return BangumiInfo.get_wish(self._db) 99 | 100 | def get_watched(self) -> list: 101 | """ 102 | 获取所有 Bangumi 上 看过 的条目 103 | """ 104 | return BangumiInfo.get_watched(self._db) 105 | 106 | def get_watching(self) -> list: 107 | """ 108 | 获取所有 Bangumi 上 在看 的条目 109 | """ 110 | return BangumiInfo.get_watching(self._db) 111 | 112 | def get_synced(self) -> list: 113 | """ 114 | 获取所有 Bangumi 上 已同步 的条目 115 | """ 116 | return BangumiInfo.get_synced(self._db) 117 | 118 | def get_exist_by_subject_id(self, subject_id: str) -> bool: 119 | """ 120 | 判断是否存在指定 Bangumi ID 的条目 121 | """ 122 | item = BangumiInfo.exists_by_subject_id(self._db, subject_id) 123 | if not item: 124 | return False 125 | return True 126 | -------------------------------------------------------------------------------- /plugins/bdremuxer/README.md: -------------------------------------------------------------------------------- 1 | ### 注意 2 | 3 | 此插件吃性能,不建议在低性能设备上使用。 -------------------------------------------------------------------------------- /plugins/bdremuxer/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | # MoviePilot library 3 | from app.log import logger 4 | from app.plugins import _PluginBase 5 | from app.core.event import eventmanager 6 | from app.schemas.types import EventType 7 | from app.utils.system import SystemUtils 8 | from typing import Any, List, Dict, Tuple 9 | import subprocess 10 | import os 11 | import shutil 12 | import threading 13 | try: 14 | from pyparsebluray import mpls 15 | except: 16 | subprocess.run(["pip3", "install", "pyparsebluray"]) 17 | subprocess.run(["pip3", "install", "ffmpeg-python"]) 18 | 19 | try: 20 | import ffmpeg 21 | except: 22 | logger.error("requirements 安装失败") 23 | 24 | class BDRemuxer(_PluginBase): 25 | # 插件名称 26 | plugin_name = "BDMV Remuxer" 27 | # 插件描述 28 | plugin_desc = "自动提取BDMV文件夹中的视频流和音频流,合并为MKV文件" 29 | # 插件图标 30 | plugin_icon = "" 31 | # 主题色 32 | plugin_color = "#3B5E8E" 33 | # 插件版本 34 | plugin_version = "1.1.0" 35 | # 插件作者 36 | plugin_author = "hankun" 37 | # 作者主页 38 | author_url = "https://github.com/hankunyu" 39 | # 插件配置项ID前缀 40 | plugin_config_prefix = "bdremuxer_" 41 | # 加载顺序 42 | plugin_order = 1 43 | # 可使用的用户级别 44 | auth_level = 1 45 | 46 | # 私有属性 47 | _enabled = False 48 | _delete = False 49 | _run_once = False 50 | _path = "" 51 | 52 | def init_plugin(self, config: dict = None): 53 | if config: 54 | self._enabled = config.get("enabled") 55 | self._delete = config.get("delete") 56 | self._run_once = config.get("run_once") 57 | self._path = config.get("path") 58 | if self._enabled: 59 | logger.info("BD Remuxer 插件初始化完成") 60 | if self._run_once: 61 | thread = threading.Thread(target=self.extract, args=(self._path,)) 62 | thread.start() 63 | self.update_config({ 64 | "enabled": self._enabled, 65 | "delete": self._delete, 66 | "run_once": False, 67 | "path": self._path 68 | }) 69 | 70 | def get_state(self) -> bool: 71 | return self._enabled 72 | 73 | 74 | 75 | @staticmethod 76 | def get_command() -> List[Dict[str, Any]]: 77 | pass 78 | 79 | def get_api(self) -> List[Dict[str, Any]]: 80 | pass 81 | 82 | # 插件配置页面 83 | def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: 84 | """ 85 | 拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构 86 | """ 87 | return [ 88 | { 89 | 'component': 'VForm', 90 | 'content': [ 91 | { 92 | 'component': 'VRow', 93 | 'content': [ 94 | { 95 | 'component': 'VCol', 96 | 'props': { 97 | 'cols': 12, 98 | 'md': 6 99 | }, 100 | 'content': [ 101 | { 102 | 'component': 'VSwitch', 103 | 'props': { 104 | 'model': 'enabled', 105 | 'label': '启用插件', 106 | } 107 | } 108 | ] 109 | }, 110 | { 111 | 'component': 'VCol', 112 | 'props': { 113 | 'cols': 12, 114 | 'md': 6 115 | }, 116 | 'content': [ 117 | { 118 | 'component': 'VSwitch', 119 | 'props': { 120 | 'model': 'delete', 121 | 'label': '删除原始文件', 122 | } 123 | } 124 | ] 125 | } 126 | ] 127 | }, 128 | { 129 | 'component': 'VRow', 130 | 'content': [ 131 | { 132 | 'component': 'VCol', 133 | 'props': { 134 | 'cols': 12 135 | }, 136 | 'content': [ 137 | { 138 | 'component': 'VTextarea', 139 | 'props': { 140 | 'model': 'path', 141 | 'label': '手动指定BDMV文件夹路径', 142 | 'rows': 1, 143 | 'placeholder': '路径指向BDMV父文件夹', 144 | } 145 | } 146 | ] 147 | }, 148 | { 149 | 'component': 'VCol', 150 | 'content': [ 151 | { 152 | 'component': 'VSwitch', 153 | 'props': { 154 | 'model': 'run_once', 155 | 'label': '提取指定目录BDMV', 156 | } 157 | } 158 | ] 159 | } 160 | ] 161 | }, 162 | { 163 | 'component': 'VRow', 164 | 'content': [ 165 | { 166 | 'component': 'VCol', 167 | 'content': [ 168 | { 169 | 'component': 'VAlert', 170 | 'props': { 171 | 'type': 'info', 172 | 'variant': 'flat', 173 | 'text': '自用插件,可能不稳定', 174 | } 175 | } 176 | ] 177 | } 178 | ] 179 | } 180 | ] 181 | } 182 | ], { 183 | "enabled": False, 184 | "delete": False, 185 | "path": "", 186 | "run_once": False, 187 | } 188 | 189 | def get_page(self) -> List[dict]: 190 | pass 191 | 192 | def extract(self,bd_path : str): 193 | logger.info('开始提取BDMV。') 194 | output_name = os.path.basename(bd_path) + ".mkv" 195 | output_name = os.path.join(bd_path, output_name) 196 | bd_path = bd_path + '/BDMV' 197 | if not os.path.exists(bd_path): 198 | logger.info('失败。输入路径不存在BDMV文件夹') 199 | return 200 | mpls_path = bd_path + '/PLAYLIST/' 201 | if not os.path.exists(mpls_path): 202 | logger.info('失败。找不到PLAYLIST文件夹') 203 | return 204 | file_paths = self.get_all_m2ts(mpls_path) 205 | if not file_paths: 206 | logger.info('失败。找不到m2ts文件') 207 | return 208 | 209 | filelist_string = '\n'.join([f"file '{file}'" for file in file_paths]) 210 | # 将filelist_string写入filelist.txt 211 | logger.info('搜索到需要提取的m2ts文件: ' + filelist_string) 212 | with open('/tmp/filelist.txt', 'w') as file: 213 | file.write(filelist_string) 214 | 215 | # 提取流程 216 | # 分析m2ts文件,提取视频流和音频流信息 217 | test_file = file_paths[0] 218 | probe = ffmpeg.probe(test_file) 219 | video_streams = [stream for stream in probe['streams'] if stream['codec_type'] == 'video'] 220 | audio_streams = [stream for stream in probe['streams'] if stream['codec_type'] == 'audio'] 221 | subtitle_streams = [stream for stream in probe['streams'] if stream['codec_type'] == 'subtitle'] 222 | 223 | # 选取第一个视频流作为流编码信息 224 | video_codec = video_streams[0]['codec_name'] 225 | # 获得每一条音频流的编码信息 226 | audio_codec = [] 227 | for audio_stream in audio_streams: 228 | if audio_stream['codec_name'] == 'pcm_bluray': 229 | audio_codec.append('pcm_s16le') 230 | else: 231 | audio_codec.append('copy') 232 | # print(audio_stream['codec_name']) 233 | 234 | # 获得每一条字幕流的编码信息 235 | subtitle_codec = [] 236 | for subtitle_stream in subtitle_streams: 237 | if subtitle_stream['codec_name'] == 'hdmv_pgs_subtitle': 238 | subtitle_codec.append('copy') 239 | else: 240 | subtitle_codec.append('copy') 241 | 242 | # 整理参数作为字典 243 | dict = { } 244 | for i in range(len(audio_codec)): 245 | dict[f'acodec:{i}'] = audio_codec[i] 246 | for i in range(len(subtitle_codec)): 247 | dict[f'scodec:{i}'] = subtitle_codec[i] 248 | # 使用ffmpeg合并m2ts文件 249 | try: 250 | ( 251 | ffmpeg 252 | .input( 253 | '/tmp/filelist.txt', 254 | format='concat', 255 | safe=0, 256 | ) 257 | .output( 258 | output_name, 259 | vcodec='copy', 260 | **dict, 261 | map='0', # 映射所有输入流 262 | map_metadata='0', # 复制输入流的元数据 263 | map_chapters='0', # 复制输入流的章节信息 264 | ) 265 | .run() 266 | ) 267 | except ffmpeg.Error as e: 268 | logger.error(e.stderr) 269 | logger.info('失败。') 270 | return 271 | # 删除原始文件 272 | if self._delete: 273 | shutil.rmtree(bd_path) 274 | logger.info('成功提取BDMV。并删除原始文件。') 275 | else: 276 | logger.info('成功提取BDMV。') 277 | 278 | 279 | def get_all_m2ts(self,mpls_path) -> list: 280 | """ 281 | Get all useful m2ts file paths from mpls file 282 | :param mpls_path: path to mpls 00000 file 283 | :return: list of m2ts file paths 284 | """ 285 | files = [] 286 | play_items = [] 287 | for file in os.listdir(mpls_path): 288 | if os.path.isfile(os.path.join(mpls_path, file)) and file.endswith('.mpls'): 289 | if file == '00000.mpls': continue # 跳过00000.mpls 290 | files.append(os.path.join(mpls_path, file)) 291 | files.sort() 292 | for file in files: 293 | with open(file, 'rb') as mpls_file: 294 | header = mpls.load_movie_playlist(mpls_file) 295 | mpls_file.seek(header.playlist_start_address, os.SEEK_SET) 296 | pls = mpls.load_playlist(mpls_file) 297 | for item in pls.play_items: 298 | if item.uo_mask_table == 0: 299 | stream_path = os.path.dirname(os.path.dirname(file)) + '/STREAM/' 300 | file_path = stream_path + item.clip_information_filename + '.m2ts' 301 | play_items.append(file_path) 302 | if play_items: 303 | return play_items 304 | return play_items 305 | 306 | @eventmanager.register(EventType.TransferComplete) 307 | def remuxer(self, event): 308 | if not self._enabled: 309 | return 310 | def __to_dict(_event): 311 | """ 312 | 递归将对象转换为字典 313 | """ 314 | if isinstance(_event, dict): 315 | for k, v in _event.items(): 316 | _event[k] = __to_dict(v) 317 | return _event 318 | elif isinstance(_event, list): 319 | for i in range(len(_event)): 320 | _event[i] = __to_dict(_event[i]) 321 | return _event 322 | elif isinstance(_event, tuple): 323 | return tuple(__to_dict(list(_event))) 324 | elif isinstance(_event, set): 325 | return set(__to_dict(list(_event))) 326 | elif hasattr(_event, 'to_dict'): 327 | return __to_dict(_event.to_dict()) 328 | elif hasattr(_event, '__dict__'): 329 | return __to_dict(_event.__dict__) 330 | elif isinstance(_event, (int, float, str, bool, type(None))): 331 | return _event 332 | else: 333 | return str(_event) 334 | 335 | raw_data = __to_dict(event.event_data) 336 | target_file = raw_data.get("transferinfo").get("file_list_new")[0] 337 | target_path = os.path.dirname(target_file) 338 | 339 | # 检查是否存在BDMV文件夹 340 | bd_path = os.path.dirname(target_path) 341 | if not os.path.exists(bd_path + '/BDMV'): 342 | logger.warn('失败。找不到BDMV文件夹: ' + bd_path) 343 | return 344 | # 提取流程 345 | thread = threading.Thread(target=self.extract, args=(bd_path,)) 346 | thread.start() 347 | 348 | 349 | 350 | 351 | def stop_service(self): 352 | """ 353 | 退出插件 354 | """ 355 | pass 356 | -------------------------------------------------------------------------------- /plugins/bdremuxer/requirements.txt: -------------------------------------------------------------------------------- 1 | pyparsebluray~=0.1.4 2 | ffmpeg-python~=0.2.0 3 | -------------------------------------------------------------------------------- /plugins/danmu/README.md: -------------------------------------------------------------------------------- 1 | ### TODO 2 | 3 | - [ ] 支持srt字幕 -------------------------------------------------------------------------------- /plugins/danmu/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | # MoviePilot library 3 | from app.log import logger 4 | from app.plugins import _PluginBase 5 | from app.core.event import eventmanager 6 | from app.schemas.types import EventType 7 | from app.utils.system import SystemUtils 8 | from apscheduler.schedulers.background import BackgroundScheduler 9 | from apscheduler.triggers.cron import CronTrigger 10 | 11 | from typing import Any, List, Dict, Tuple 12 | import subprocess 13 | import os 14 | import threading 15 | from plugins.danmu import danmu_generator as generator 16 | 17 | 18 | class Danmu(_PluginBase): 19 | # 插件名称 20 | plugin_name = "弹幕刮削" 21 | # 插件描述 22 | plugin_desc = "使用弹弹play平台生成弹幕的字幕文件,实现弹幕播放。" 23 | # 插件图标 24 | plugin_icon = "https://raw.githubusercontent.com/HankunYu/MoviePilot-Plugins/main/icons/danmu.png" 25 | # 主题色 26 | plugin_color = "#3B5E8E" 27 | # 插件版本 28 | plugin_version = "1.1.2" 29 | # 插件作者 30 | plugin_author = "hankun" 31 | # 作者主页 32 | author_url = "https://github.com/hankunyu" 33 | # 插件配置项ID前缀 34 | plugin_config_prefix = "danmu_" 35 | # 加载顺序 36 | plugin_order = 1 37 | # 可使用的用户级别 38 | auth_level = 1 39 | 40 | # 私有属性 41 | _enabled = False 42 | _width = 1920 43 | _height = 1080 44 | # 搞字体太复杂 以后再说 45 | # _fontface = 'Arial' 46 | _fontsize = 50 47 | _alpha = 0.8 48 | _duration = 6 49 | _cron = '0 0 1 1 *' 50 | _path = '' 51 | 52 | def init_plugin(self, config: dict = None): 53 | if config: 54 | self._enabled = config.get("enabled") 55 | self._width = config.get("width") 56 | self._height = config.get("height") 57 | # self._fontface = config.get("fontface") 58 | self._fontsize = config.get("fontsize") 59 | self._alpha = config.get("alpha") 60 | self._duration = config.get("duration") 61 | self._path = config.get("path") 62 | # self._cron = config.get("cron") 63 | if self._enabled: 64 | logger.info("弹幕加载插件已启用") 65 | 66 | 67 | def get_state(self) -> bool: 68 | return self._enabled 69 | 70 | def get_service(self) -> List[Dict[str, Any]]: 71 | """ 72 | 注册插件公共服务 73 | [{ 74 | "id": "服务ID", 75 | "name": "服务名称", 76 | "trigger": "触发器:cron/interval/date/CronTrigger.from_crontab()", 77 | "func": self.xxx, 78 | "kwargs": {} # 定时器参数 79 | }] 80 | """ 81 | if self.get_state() and self._path and self._cron: 82 | return [{ 83 | "id": "Danmu", 84 | "name": "弹幕全局刮削服务", 85 | "trigger": CronTrigger.from_crontab(self._cron), 86 | "func": self.generate_danmu_global, 87 | "kwargs": {} 88 | }] 89 | return [] 90 | 91 | @staticmethod 92 | def get_command() -> List[Dict[str, Any]]: 93 | pass 94 | 95 | def get_api(self) -> List[Dict[str, Any]]: 96 | pass 97 | 98 | # 插件配置页面 99 | def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: 100 | """ 101 | 拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构 102 | """ 103 | return [ 104 | { 105 | 'component': 'VForm', 106 | 'content': [ 107 | { 108 | 'component': 'VRow', 109 | 'content': [ 110 | { 111 | 'component': 'VCol', 112 | 'props': { 113 | 'cols': 12, 114 | 'md': 6 115 | }, 116 | 'content': [ 117 | { 118 | 'component': 'VSwitch', 119 | 'props': { 120 | 'model': 'enabled', 121 | 'label': '启用插件', 122 | } 123 | } 124 | ] 125 | } 126 | ] 127 | }, 128 | { 129 | 'component': 'VRow', 130 | 'content': [ 131 | { 132 | 'component': 'VCol', 133 | 'props': { 134 | 'cols': 6, 135 | }, 136 | 'content': [ 137 | { 138 | 'component': 'VTextField', 139 | 'props': { 140 | 'model': 'width', 141 | 'label': '宽度,默认1920', 142 | 'type': 'number', 143 | 144 | } 145 | } 146 | ] 147 | }, 148 | { 149 | 'component': 'VCol', 150 | 'props': { 151 | 'cols': 6, 152 | }, 153 | 'content': [ 154 | { 155 | 'component': 'VTextField', 156 | 'props': { 157 | 'model': 'height', 158 | 'label': '高度,默认1080', 159 | 'type': 'number', 160 | 161 | } 162 | } 163 | ] 164 | } 165 | ] 166 | }, 167 | { 168 | 'component': 'VRow', 169 | 'content': [ 170 | { 171 | 'component': 'VCol', 172 | 'props': { 173 | 'cols': 6, 174 | }, 175 | 'content': [ 176 | { 177 | 'component': 'VTextField', 178 | 'props': { 179 | 'model': 'fontsize', 180 | 'label': '字体大小,默认50', 181 | 'type': 'number', 182 | 183 | } 184 | } 185 | ] 186 | }, 187 | { 188 | 'component': 'VCol', 189 | 'props': { 190 | 'cols': 6, 191 | }, 192 | 'content': [ 193 | { 194 | 'component': 'VTextField', 195 | 'props': { 196 | 'model': 'alpha', 197 | 'label': '弹幕透明度,默认0.8', 198 | 'type': 'number', 199 | 200 | } 201 | } 202 | ] 203 | } 204 | ] 205 | }, 206 | { 207 | 'component': 'VRow', 208 | 'content': [ 209 | { 210 | 'component': 'VCol', 211 | 'props': { 212 | 'cols': 6, 213 | }, 214 | 'content': [ 215 | { 216 | 'component': 'VTextField', 217 | 'props': { 218 | 'model': 'duration', 219 | 'label': '弹幕持续时间 默认6秒', 220 | 'type': 'number', 221 | 222 | } 223 | } 224 | ] 225 | }, 226 | { 227 | 'component': 'VCol', 228 | 'props': { 229 | 'cols': 6, 230 | }, 231 | 'content': [ 232 | { 233 | 'component': 'VTextField', 234 | 'props': { 235 | 'model': 'cron', 236 | 'label': '取消定期刮削,需要全局刮削请去 设置->服务 手动启动', 237 | 'type': 'text', 238 | 239 | } 240 | } 241 | ] 242 | } 243 | ] 244 | }, 245 | { 246 | 'component': 'VRow', 247 | 'content': [ 248 | { 249 | 'component': 'VCol', 250 | 'content': [ 251 | { 252 | 'component': 'VTextarea', 253 | 'props': { 254 | 'model': 'path', 255 | 'label': '刮削媒体库路径,一行一个', 256 | 'placeholder': '留空不启用', 257 | 'rows': 2, 258 | } 259 | } 260 | ] 261 | } 262 | ] 263 | }, 264 | { 265 | 'component': 'VRow', 266 | 'content': [ 267 | { 268 | 'component': 'VCol', 269 | 'content': [ 270 | { 271 | 'component': 'VAlert', 272 | 'props': { 273 | 'type': 'info', 274 | 'variant': 'flat', 275 | 'text': '此插件会根据情况生成两种弹幕字幕文件,均为ass格式。.danmu为刮削出来的纯弹幕,.withDanmu为原生字幕与弹幕合并后的文件。自动刮削新入库文件。如果没有外挂字幕只有内嵌字幕会自动提取内嵌字幕生成.withDanmu文件。弹幕来源为 弹弹play 提供的多站合并资源以及 https://github.com/m13253/danmaku2ass 提供的思路。第一次使用可以去 设置->服务 手动启动全局刮削。\n取消了定期全局刮削,为了降低服务器压力以及防止被ban IP。', 276 | } 277 | } 278 | ] 279 | } 280 | ] 281 | } 282 | ] 283 | } 284 | ], { 285 | "enabled": False, 286 | "width": 1920, 287 | "height": 1080, 288 | "fontsize": 50, 289 | "alpha": 0.8, 290 | "duration": 6, 291 | "cron": "0 0 1 1 *", 292 | } 293 | 294 | def get_page(self) -> List[dict]: 295 | pass 296 | 297 | def generate_danmu(self, file_path: str): 298 | generator.danmu_generator(file_path, self._width, self._height,'Arial', self._fontsize, self._alpha, self._duration) 299 | 300 | def generate_danmu_global(self): 301 | # 同时最多开启10个线程 302 | threading_list = [] 303 | max_thread = 10 304 | if self._path: 305 | logger.info("开始全局弹幕刮削") 306 | # 按行切割并去除前后空白 307 | paths = [path.strip() for path in self._path.split('\n') if path.strip()] 308 | for path in paths: 309 | logger.info(f"刮削路径:{path}") 310 | if os.path.exists(path): 311 | for root, dirs, files in os.walk(path): 312 | for file in files: 313 | if file.endswith('.mp4') or file.endswith('.mkv'): 314 | if len(threading_list) >= max_thread: 315 | threading_list[0].join() # 只等待第一个线程 316 | threading_list.pop(0) 317 | target_file = os.path.join(root, file) 318 | logger.info(f"开始生成弹幕文件:{target_file}") 319 | thread = threading.Thread(target=self.generate_danmu, args=(target_file,)) 320 | thread.start() 321 | threading_list.append(thread) 322 | 323 | for thread in threading_list: 324 | thread.join() 325 | logger.info("全局弹幕刮削完成") 326 | 327 | @eventmanager.register(EventType.TransferComplete) 328 | def generate_danmu_after_transfer(self, event): 329 | if not self._enabled: 330 | return 331 | def __to_dict(_event): 332 | """ 333 | 递归将对象转换为字典 334 | """ 335 | if isinstance(_event, dict): 336 | for k, v in _event.items(): 337 | _event[k] = __to_dict(v) 338 | return _event 339 | elif isinstance(_event, list): 340 | for i in range(len(_event)): 341 | _event[i] = __to_dict(_event[i]) 342 | return _event 343 | elif isinstance(_event, tuple): 344 | return tuple(__to_dict(list(_event))) 345 | elif isinstance(_event, set): 346 | return set(__to_dict(list(_event))) 347 | elif hasattr(_event, 'to_dict'): 348 | return __to_dict(_event.to_dict()) 349 | elif hasattr(_event, '__dict__'): 350 | return __to_dict(_event.__dict__) 351 | elif isinstance(_event, (int, float, str, bool, type(None))): 352 | return _event 353 | else: 354 | return str(_event) 355 | 356 | raw_data = __to_dict(event.event_data) 357 | target_file = raw_data.get("transferinfo").get("file_list_new")[0] 358 | logger.info(f"开始生成弹幕文件:{target_file}") 359 | thread = threading.Thread(target=self.generate_danmu, args=(target_file,)) 360 | thread.start() 361 | 362 | 363 | 364 | 365 | def stop_service(self): 366 | """ 367 | 退出插件 368 | """ 369 | pass 370 | -------------------------------------------------------------------------------- /plugins/danmu/danmu_generator.py: -------------------------------------------------------------------------------- 1 | import chardet 2 | import requests, os, re, hashlib 3 | import subprocess, json 4 | from app.log import logger 5 | 6 | def calculate_md5_of_first_16MB(file_path): 7 | # 创建一个新的MD5哈希对象 8 | md5 = hashlib.md5() 9 | 10 | # 16MB的大小(以字节为单位) 11 | size_16MB = 16 * 1024 * 1024 12 | 13 | # 打开文件,并读取前16MB数据 14 | with open(file_path, 'rb') as f: 15 | data = f.read(size_16MB) 16 | md5.update(data) 17 | 18 | # 返回MD5哈希值的十六进制表示 19 | return md5.hexdigest() 20 | 21 | def get_video_duration(file_path): 22 | # 构建ffmpeg命令 23 | cmd = ['ffmpeg', '-i', file_path] 24 | 25 | try: 26 | # 调用ffmpeg命令并捕获输出 27 | process = subprocess.Popen(cmd, stderr=subprocess.PIPE, stdout=subprocess.PIPE) 28 | stdout, stderr = process.communicate() 29 | 30 | # 尝试以utf-8解码 31 | try: 32 | # 尝试使用 'ignore' 模式忽略解码错误 33 | stderr = stderr.decode('utf-8', errors='ignore') 34 | except UnicodeDecodeError as e: 35 | logger.warning(f"utf-8 解码失败:{e}. 尝试使用替换模式。") 36 | stderr = stderr.decode('utf-8', errors='replace') 37 | 38 | # 使用正则表达式解析视频文件的时长 39 | duration_match = re.search(r"Duration: (\d+):(\d+):(\d+\.\d+)", stderr) 40 | if duration_match: 41 | hours = int(duration_match.group(1)) 42 | minutes = int(duration_match.group(2)) 43 | seconds = float(duration_match.group(3)) 44 | 45 | # 将时长转换为秒 46 | total_seconds = hours * 3600 + minutes * 60 + seconds 47 | return total_seconds 48 | else: 49 | logger.error(f"无法找到视频时长信息 - {file_path}") 50 | return None 51 | except FileNotFoundError: 52 | logger.error("ffmpeg 工具没有找到,请确保已安装并正确配置路径。") 53 | except Exception as e: 54 | logger.error(f"处理文件 {file_path} 时出错: {e}") 55 | 56 | return None 57 | 58 | def get_file_size(file_path): 59 | # 获取文件的大小,单位为字节 60 | file_size_in_bytes = os.path.getsize(file_path) 61 | return file_size_in_bytes 62 | 63 | 64 | def get_comment_ID(file_path): 65 | url = f'https://api.dandanplay.net/api/v2/match' 66 | headers = { 67 | 'Accept': 'application/json', 68 | "User-Agent": "Moviepilot/plugins 1.1.0" 69 | } 70 | duration = int(get_video_duration(file_path)) 71 | hash = calculate_md5_of_first_16MB(file_path) 72 | name = os.path.basename(file_path) 73 | try: 74 | size = get_file_size(file_path) 75 | duration = int(get_video_duration(file_path)) 76 | info = { 77 | "fileName": name, 78 | "fileHash": hash, 79 | "fileSize": size, 80 | "videoDuration": duration, 81 | "matchMode": "hashAndFileName" 82 | } 83 | except: 84 | logger.error('获取文件信息失败') 85 | info = { 86 | "fileName": name, 87 | "fileHash": hash, 88 | "fileSize": 0, 89 | "videoDuration": 0, 90 | "matchMode": "hashAndFileName" 91 | } 92 | response = requests.post(url, headers=headers, json=info) 93 | if response.status_code == 200: 94 | if response.json()['isMatched']: 95 | return response.json()['matches'][0]['episodeId'] 96 | else: 97 | if len(response.json()['matches']) == 0: 98 | logger.error('未找到弹幕可能匹配 - ' + file_path) 99 | return None 100 | logger.info('尝试从nfo文件获取标题 - ' + file_path) 101 | title = get_title_from_nfo(file_path) 102 | if not title: 103 | logger.info('未找到标题 跳过 - ' + file_path) 104 | return None 105 | # 尝试从标题匹配 106 | for match in response.json()['matches']: 107 | episodeTitle = match['episodeTitle'] 108 | # 去除 第x话 109 | # 稍微严格一点 不用contains 110 | episodeTitle = re.sub(r'第\d+话 ', '', episodeTitle) 111 | if episodeTitle == title: 112 | logger.info('匹配成功 - ' + file_path) 113 | return match['episodeId'] 114 | logger.info('未找到匹配,跳过 - ' + file_path) 115 | return None 116 | else: 117 | logger.error("获取弹幕ID失败 %s" % response.text) 118 | return None 119 | 120 | def get_title_from_nfo(file_path): 121 | # 尝试读取nfo文件 122 | logger.info('尝试读取nfo文件 - ' + file_path) 123 | nfo_file = os.path.splitext(file_path)[0] + '.nfo' 124 | try: 125 | with open(nfo_file) as f: 126 | nfo_content = f.read() 127 | try: 128 | title = re.search(r'(.*)', nfo_content).group(1) 129 | logger.info('从nfo文件中获取标题 - ' + title) 130 | return title 131 | except: 132 | logger.error('未找到标题信息') 133 | return None 134 | except: 135 | logger.error('未找到nfo文件') 136 | return None 137 | 138 | def get_comments(comment_id): 139 | url = f'https://api.dandanplay.net/api/v2/comment/{comment_id}?withRelated=true' 140 | headers = { 141 | 'Accept': 'application/json', 142 | "User-Agent": "Moviepilot/plugins 1.1.0" 143 | } 144 | 145 | response = requests.get(url, headers=headers) 146 | if response.status_code == 200: 147 | return response.json() 148 | else: 149 | logger.error("获取弹幕失败 %s" % response.text) 150 | return None 151 | 152 | def convert_timestamp(timestamp): 153 | timestamp = round(timestamp * 100.0) 154 | hour, minute = divmod(timestamp, 360000) 155 | minute, second = divmod(minute, 6000) 156 | second, centsecond = divmod(second, 100) 157 | return '%d:%02d:%02d.%02d' % (int(hour), int(minute), int(second), int(centsecond)) 158 | 159 | def write_ass_head(f, width, height, fontface, fontsize, alpha, styleid): 160 | f.write( 161 | '''[Script Info] 162 | ; Script generated by Hankun 163 | ; Super thanks to https://github.com/m13253/danmaku2ass and https://www.dandanplay.com/ 164 | Script Updated By: MoviePilot Danmu Plugin https://github.com/HankunYu/MoviePilot-Plugins 165 | ScriptType: v4.00+ 166 | PlayResX: %(width)d 167 | PlayResY: %(height)d 168 | Aspect Ratio: %(width)d:%(height)d 169 | Collisions: Normal 170 | WrapStyle: 2 171 | ScaledBorderAndShadow: yes 172 | YCbCr Matrix: TV.601 173 | 174 | [V4+ Styles] 175 | Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding 176 | Style: %(styleid)s, %(fontface)s, %(fontsize).0f, &H%(alpha)02XFFFFFF, &H%(alpha)02XFFFFFF, &H%(alpha)02X000000, &H%(alpha)02X000000, 0, 0, 0, 0, 100, 100, 0.00, 0.00, 1, %(outline).0f, 0, 7, 0, 0, 0, 0 177 | 178 | [Events] 179 | Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text 180 | ''' % {'width': width, 'height': height, 'fontface': fontface, 'fontsize': fontsize, 'alpha': 255 - round(alpha * 255), 'outline': max(fontsize / 25.0, 1), 'styleid': styleid} 181 | ) 182 | 183 | def convert_comments_to_ass(comments, output_file, width=1920, height=1080, fontface='Arial', fontsize=50, alpha=0.8, duration=6): 184 | styleid = 'Danmu' 185 | 186 | def find_non_overlapping_track(tracks, current_time, max_tracks): 187 | possible_track = 1 188 | last_time_remain = 100 189 | for track in range(1, max_tracks + 1): 190 | if track not in tracks or current_time >= tracks[track]: 191 | return track 192 | else: 193 | time_remain = tracks[track] - current_time 194 | if time_remain > last_time_remain: 195 | possible_track = track 196 | # 如果所有轨道都满了 返回离弹幕结束最快的轨道 197 | return possible_track 198 | 199 | with open(output_file, 'w', encoding='utf-8-sig') as f: 200 | write_ass_head(f, width, height, fontface, fontsize, alpha, styleid) 201 | 202 | max_tracks = height // fontsize # Maximum number of tracks that can fit in the screen height 203 | scrolling_tracks = {} 204 | top_tracks = {} 205 | bottom_tracks = {} 206 | logger.info(f"{output_file} - 共匹配到{len(comments)}条弹幕。") 207 | for comment in comments: 208 | p = comment['p'].split(',') 209 | timeline, pos, color, _ = float(p[0]), int(p[1]), int(p[2]), str(p[3]) 210 | text = comment['m'] 211 | 212 | start_time = convert_timestamp(timeline) 213 | end_time = convert_timestamp(timeline + float(duration)) # 默认显示时间为6秒 214 | 215 | # 同轨道滚动弹幕之间间隔时间 216 | gap = 1 217 | # 计算弹幕的宽度 218 | text_width = len(text) * fontsize * 0.6 # 粗略估算宽度 219 | # 计算弹幕尾部离开轨道起始点的时间 220 | velocity = (width + text_width) / float(duration) 221 | leave_time = text_width / velocity + gap 222 | 223 | color_hex = '&H{0:06X}'.format(color & 0xFFFFFF) 224 | styles = '' 225 | 226 | if pos == 1: # 滚动弹幕 227 | track_id = find_non_overlapping_track(scrolling_tracks, timeline, max_tracks) 228 | scrolling_tracks[track_id] = timeline + leave_time 229 | initial_y = (track_id - 1) * fontsize + 10 230 | styles = f'\\move({width}, {initial_y}, {-len(text)*fontsize}, {initial_y})' 231 | elif pos == 4: # 底部弹幕 232 | track_id = find_non_overlapping_track(bottom_tracks, timeline, max_tracks) 233 | bottom_tracks[track_id] = timeline + float(duration) 234 | styles = f'\\an2\\pos({width/2}, {height - 50 - (track_id - 1) * fontsize})' 235 | elif pos == 5: # 顶部弹幕 236 | track_id = find_non_overlapping_track(top_tracks, timeline, max_tracks) 237 | top_tracks[track_id] = timeline + float(duration) 238 | styles = f'\\an8\\pos({width/2}, {50 + (track_id - 1) * fontsize})' 239 | else: 240 | styles = '\\move({}, {}, {}, {})'.format(0, 0, width, 0) 241 | 242 | f.write(f'Dialogue: 0,{start_time},{end_time},{styleid},,0,0,0,,{{\\c{color_hex}{styles}}}{text}\n') 243 | logger.info('弹幕生成成功 - ' + output_file) 244 | 245 | def sort_comments(comment_data): 246 | # 提取 comments 列表 247 | comments = comment_data["comments"] 248 | 249 | def extract_time(comment): 250 | # 提取 p 字段中的时间部分 251 | p_field = comment['p'] 252 | time_str = p_field.split(',')[0] 253 | return float(time_str) 254 | 255 | # 按照从 p 字段提取的时间进行排序 256 | return sorted(comments, key=extract_time) 257 | 258 | def generate_danmu_ass(comment_ID, file_path, width=1920, height=1080, fontface='Arial', fontsize=50, alpha=0.8, duration=6): 259 | comments = sort_comments(get_comments(comment_ID)) 260 | output = os.path.splitext(file_path)[0] + '.danmu.ass' 261 | convert_comments_to_ass(comments, output, width, height, fontface, fontsize, alpha, duration) 262 | 263 | # sub1 为弹幕字幕,sub2 为原生字幕 264 | def combine_sub_ass(sub1, sub2) -> bool: 265 | if not sub1 or not sub2: 266 | return False 267 | 268 | # 读取两个字幕文件的内容 269 | with open(sub1, 'r', encoding='utf-8-sig') as f: 270 | sub1_content = f.read() 271 | with open(sub2, 'rb') as f: 272 | raw_data = f.read() 273 | result = chardet.detect(raw_data) 274 | file_encoding = result['encoding'] 275 | with open(sub2, 'r', encoding=file_encoding) as f: 276 | sub2_content = f.read() 277 | 278 | # 检查原生字幕格式 279 | # 如果字幕是ass 或者ssa 格式,ssa格式按照ass处理 280 | if os.path.splitext(sub2)[1].lower() == '.ass' or os.path.splitext(sub2)[1].lower() == '.ssa': 281 | sub1ResX = re.search(r"PlayResX:\s*(\d+)", sub1_content) 282 | sub2ResX = re.search(r"PlayResX:\s*(\d+)", sub2_content) 283 | 284 | # 计算两个字幕的分辨率的比例 (大致 285 | if not sub1ResX or not sub2ResX: 286 | fontSizeRatio = 1 287 | else: 288 | fontSizeRatio = int(sub1ResX.group(1)) / int(sub2ResX.group(1)) * 0.8 289 | 290 | # 提取原生字幕的样式格式 291 | format_match = re.search(r"Format:.+", sub2_content) 292 | if not format_match: 293 | return False 294 | 295 | format_line = format_match.group() 296 | 297 | style_lines = re.findall(r'Style:.*', sub2_content) 298 | for i, line in enumerate(style_lines): 299 | elements = line.split(',') 300 | if(len(elements) < 3): 301 | continue 302 | elements[2] = str(int(float(elements[2]) * fontSizeRatio)) # 修改字体大小 303 | style_lines[i] = ','.join(elements) 304 | 305 | # 拼接所有样式 306 | styles_content = '\n'.join(style_lines) 307 | 308 | # 提取原生字幕的内容 309 | events_start = sub2_content.find('[Events]') 310 | if events_start == -1: 311 | return False # 未找到[Events] 312 | 313 | events_content = sub2_content[events_start + len('[Events]'):].strip() 314 | 315 | # 获取合并后sub名字 316 | output = os.path.splitext(sub2)[0] + ".withDanmu.ass" 317 | with open(output, 'w', encoding='utf-8-sig') as f: 318 | # 写入弹幕字幕的内容 319 | f.write(sub1_content) 320 | f.write('\n') 321 | f.write('[V4+ Styles]\n') 322 | f.write(format_line) 323 | f.write('\n') 324 | f.write(styles_content) 325 | f.write('\n') 326 | f.write('[Events]\n') 327 | f.write(events_content) 328 | 329 | return True 330 | 331 | elif os.path.splitext(sub2)[1].lower() == '.srt': 332 | return False 333 | else: 334 | return False 335 | 336 | # 检查文件是否有自带的字幕 337 | def find_subtitle_file(file_path): 338 | # 遍历文件目录 339 | filename = os.path.basename(file_path) 340 | filename = os.path.splitext(filename)[0] 341 | for root, dirs, files in os.walk(os.path.dirname(file_path)): 342 | for file in files: 343 | # 检查文件是否以.srt .ass .ssa结尾,并且不包含'danmu'这个字符串 344 | if (file.endswith('.srt') or file.endswith('.ass') or file.endswith('.ssa')) and 'danmu' not in file and file.startswith(filename): 345 | sub2 = os.path.join(root, file) 346 | logger.info("找到字幕文件 - " + sub2) 347 | # 返回文件路径 348 | return sub2 349 | 350 | # 如果没有找到符合条件的文件,返回None 351 | logger.info("没找到字幕文件") 352 | return None 353 | 354 | # 获得视频文件中所有流信息,包括字幕流 355 | def get_video_streams(file_path): 356 | command = [ 357 | 'ffprobe', '-v', 'error', 358 | '-print_format', 'json', '-show_format', '-show_streams', file_path 359 | ] 360 | result = subprocess.run(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True) 361 | if result.returncode != 0: 362 | return {} 363 | else: 364 | return json.loads(result.stdout) 365 | 366 | # 提取视频文件中的指定字幕流 367 | def extract_subtitles(file_path, output_file, stream_index): 368 | command = [ 369 | 'ffmpeg', 370 | '-i', file_path, 371 | '-map', f'0:{stream_index}', 372 | '-c:s', 'ass', 373 | output_file 374 | ] 375 | result = subprocess.run(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True) 376 | return result.returncode == 0 377 | 378 | # 提取所有字幕流,并按照字幕名字命名 379 | def try_extract_sub(file_path): 380 | # 获取视频流信息 381 | streams_info = get_video_streams(file_path) 382 | 383 | for stream in streams_info.get('streams', []): 384 | if stream.get('codec_type') == 'subtitle': 385 | # 获取字幕流的索引 386 | stream_index = stream['index'] 387 | # 获取文件名字和扩展名 388 | base_name, _ = os.path.splitext(file_path) 389 | # 获取语言信息 390 | language = stream.get('tags', {}).get('language', 'unknown') 391 | # 如果不是中文则跳过 392 | if language not in ['zh', 'zho', 'chi', 'chs', 'cht', 'cn']: 393 | continue 394 | # 输出的字幕文件名:原文件名.字幕语言.ass 395 | output_file = f"{base_name}.{language}.ass" 396 | 397 | # 检查文件是否存在并删除它 398 | if os.path.exists(output_file): 399 | os.remove(output_file) 400 | 401 | if extract_subtitles(file_path, output_file, stream_index): 402 | logger.info(f'成功提取内嵌字幕 - {output_file}') 403 | break 404 | 405 | def danmu_generator(file_path, width=1920, height=1080, fontface='Arial', fontsize=50, alpha=0.8, duration=6): 406 | # 使用弹弹play api 获取弹幕 407 | comment_id = get_comment_ID(file_path) 408 | if(comment_id == None): 409 | logger.info("未找到对应弹幕 - " + file_path) 410 | return None 411 | generate_danmu_ass(comment_id,file_path,width, height, fontface, fontsize, alpha, duration) 412 | # 尝试搜索原生字幕 413 | sub2 = find_subtitle_file(file_path) 414 | # 尝试获取内嵌字幕 415 | if sub2 == None: 416 | try_extract_sub(file_path) 417 | sub2 = find_subtitle_file(file_path) 418 | # 生成内嵌字幕加弹幕文件 419 | if sub2 != None: 420 | combine_sub_ass(os.path.splitext(file_path)[0] + '.danmu.ass', sub2) 421 | else: 422 | logger.error('未找到原生字幕,跳过合并 - ' + file_path) 423 | 424 | -------------------------------------------------------------------------------- /plugins/discord/README.md: -------------------------------------------------------------------------------- 1 | ### 配置 2 | 3 | 目前只支持webhook的方式,需要在discord服务器中创建一个webhook,然后将webhook的url填入插件配置中。 4 | 站点地址可以自行填写,也可以留空。如果填写了站点地址,那么在discord中点击消息时会跳转到相应页面。 5 | 6 | 跟原版一样支持各类消息通知开关。 7 | 8 | 新增discord bot,需要自行注册一个bot并获取token然后添加到自己服务器上。 9 | 步骤可参考 https://hackmd.io/@lio2619/B1PB2yB2c 10 | 需要注意添加以下权限: 11 | - bot 12 | - messages.read 13 | - application.commands 14 | 嫌麻烦可以都勾上 15 | 16 | 目前指令支持: 17 | - 下载 /download 18 | - 搜索 /search 19 | - 订阅 /subscribe 20 | 21 | 直接@bot会使用GPT进行聊天 22 | 23 | ### 已知BUG 24 | 25 | ~~- 当MP因为更新重启后,Discord插件会无法载入,需要再手动重启一次MP。~~ 26 | - 未设置GPT回应对应用户/频道,导致bot一直跟webhook消息聊天。 27 | - GPT对话过长未处理,导致消息发送失败。 28 | ~~- 莫名其妙错误导致GPT对话无法使用(在长时间开启对话后,大约一个礼拜?)~~ 29 | 30 | ### Todo 31 | 32 | - [X] 添加discord bot支持 33 | - [X] 支持使用GPT聊天以及交互 34 | - [X] 支持从discord直接发送命令 -------------------------------------------------------------------------------- /plugins/discord/cogs/moviepilot_cog.py: -------------------------------------------------------------------------------- 1 | import discord, sys, re 2 | from typing import Optional, List, Tuple 3 | from discord import app_commands 4 | from discord.ext import commands 5 | from app.log import logger 6 | from app.chain.download import DownloadChain 7 | from app.chain.search import SearchChain 8 | from app.chain.subscribe import SubscribeChain 9 | from app.core.context import MediaInfo, TorrentInfo, Context 10 | from app.core.metainfo import MetaInfo 11 | from plugins.discord.gpt import GPT 12 | import plugins.discord.tokenes as tokenes 13 | 14 | class MPCog(commands.Cog): 15 | on_conversion = False 16 | current_channel = None 17 | downloadchain = None 18 | searchchain = None 19 | subscribechain = None 20 | gpt = None 21 | 22 | def __init__(self, bot: commands.Bot): 23 | self.bot = bot 24 | self.downloadchain = DownloadChain() 25 | self.searchchain = SearchChain() 26 | self.subscribechain = SubscribeChain() 27 | self.gpt = GPT(token=tokenes.gpt_token) 28 | 29 | # 监听ready事件,bot准备好后打印登录信息 30 | @commands.Cog.listener() 31 | async def on_ready(self): 32 | logger.info(f'bot 登录成功 - {self.bot.user}') 33 | game = discord.Game("看电影中...") 34 | await self.bot.change_presence(status=discord.Status.idle, activity=game) 35 | slash = await self.bot.tree.sync() 36 | logger.info(f"已载入 {len(slash)} 个指令") 37 | 38 | # 监听mention事件,使用gpt生成回复 39 | @commands.Cog.listener() 40 | async def on_message(self,message): 41 | if message.author == self.bot.user: 42 | return 43 | 44 | if self.bot.user.mentioned_in(message) or self.on_conversion: 45 | msg = re.sub(r'<.*?>', '', message.content) 46 | self.on_conversion = True 47 | self.current_channel = message.channel 48 | reply = self.gpt.generate_reply(msg) 49 | if reply != None: 50 | await message.channel.send(reply) 51 | else: 52 | await message.channel.send("啊好像哪里出错了...这不应该,你再试试?不行就算了。") 53 | game = discord.Game("模仿ChatGPT中...") 54 | await self.bot.change_presence(status=discord.Status.online, activity=game) 55 | 56 | # slash command 57 | @app_commands.command(description="停止GPT对话") 58 | async def stop(self, interaction: discord.Interaction): 59 | game = discord.Game("看电影中...") 60 | self.on_conversion = False 61 | await self.bot.change_presence(status=discord.Status.idle, activity=game) 62 | await interaction.response.send_message("^^") 63 | 64 | @app_commands.command(description="清除对话记录") 65 | async def clear(self, interaction: discord.Interaction): 66 | self.gpt.clear_chat_history() 67 | await interaction.response.send_message("对话记录已经清除") 68 | 69 | @app_commands.command(description="下载电影,如果找到多个结果,返回结果列表,让用户选择下载") 70 | async def download(self, interaction: discord.Interaction, title: str): 71 | await interaction.response.send_message("正在搜索电影信息 " + title) 72 | 73 | game = discord.Game("努力搜索中...") 74 | await self.bot.change_presence(status=discord.Status.online, activity=game) 75 | # 搜索 76 | meta = MetaInfo(title=title) 77 | medias: Optional[List[MediaInfo]] = self.searchchain.search_medias(meta=meta) 78 | if not medias: 79 | await interaction.followup.send("无法识别到媒体信息 " + title) 80 | game = discord.Game("看电影中...") 81 | await self.bot.change_presence(status=discord.Status.online, activity=game) 82 | return 83 | # 如果找到多个结果,返回结果列表,让用户选择下载 84 | game = discord.Game("看电影中...") 85 | await self.bot.change_presence(status=discord.Status.idle, activity=game) 86 | if len(medias) > 0: 87 | for media in medias: 88 | fields = [] 89 | media_title = { 90 | "name": "标题", 91 | "value": media.title 92 | } 93 | release_date = { 94 | "name": "发布日期", 95 | "value": media.release_date 96 | } 97 | vote_average = { 98 | "name": "评分", 99 | "value": media.vote_average 100 | } 101 | fields.append(media_title) 102 | fields.append(release_date) 103 | fields.append(vote_average) 104 | embed = discord.Embed(title=media.title, 105 | description=media.tmdb_info["overview"], 106 | url=media.homepage) 107 | for field in fields: 108 | embed.add_field(name=field["name"], value=field["value"], inline=True) 109 | if media.backdrop_path: 110 | embed.set_image(url=media.backdrop_path) 111 | # 组合上下文 112 | context = Context(media_info=media, meta_info=meta) 113 | view = DownloadView(context) 114 | await interaction.followup.send(embed=embed, view=view) 115 | 116 | # 如果只找到一个结果,直接下载 117 | if len(medias) == 1: 118 | mediainfo = medias[0] 119 | exist_flag, no_exists = self.downloadchain.get_no_exists_info(meta=meta, mediainfo=mediainfo) 120 | if exist_flag: 121 | await interaction.followup.send(f'{mediainfo.title_year} 已存在') 122 | return 123 | contexts = self.searchchain.process(mediainfo = mediainfo, no_exists=no_exists) 124 | if len(contexts) == 0: 125 | await interaction.followup.send("没有找到资源 " + title) 126 | # 自动下载 127 | downloads, lefts = self.downloadchain.batch_download(contexts=contexts, no_exists=no_exists, 128 | username="Discord Bot") 129 | if downloads and not lefts: 130 | await interaction.followup.send(f'{mediainfo.title_year} 添加下载') 131 | else: 132 | await interaction.followup.send(f'{mediainfo.title_year} 下载未完整,开始订阅') 133 | self.subscribechain.add(title=mediainfo.title, 134 | year=mediainfo.year, 135 | mtype=mediainfo.type, 136 | tmdbid=mediainfo.tmdb_id, 137 | season=meta.begin_season, 138 | exist_ok=True, 139 | username="Discord Bot") 140 | 141 | @app_commands.command(description="订阅电影") 142 | async def subscribe(self, interaction: discord.Interaction, title: str): 143 | await interaction.response.send_message("正在搜索电影体信息 " + title) 144 | game = discord.Game("努力搜索中...") 145 | await self.bot.change_presence(status=discord.Status.online, activity=game) 146 | # 搜索 147 | meta = MetaInfo(title=title) 148 | medias: Optional[List[MediaInfo]] = self.searchchain.search_medias(meta=meta) 149 | if not medias: 150 | await interaction.followup.send("无法识别到媒体信息 " + title) 151 | game = discord.Game("看电影中...") 152 | await self.bot.change_presence(status=discord.Status.idle, activity=game) 153 | return 154 | await interaction.followup.send("无法识别到媒体信息 " + title) 155 | game = discord.Game("看电影中...") 156 | # 如果找到多个结果,返回结果列表,让用户选择下载 157 | if len(medias) > 0: 158 | for media in medias: 159 | fields = [] 160 | media_title = { 161 | "name": "标题", 162 | "value": media.title 163 | } 164 | release_date = { 165 | "name": "发布日期", 166 | "value": media.release_date 167 | } 168 | vote_average = { 169 | "name": "评分", 170 | "value": media.vote_average 171 | } 172 | fields.append(media_title) 173 | fields.append(release_date) 174 | fields.append(vote_average) 175 | embed = discord.Embed(title=media.title, 176 | description=media.tmdb_info["overview"], 177 | url=media.homepage) 178 | for field in fields: 179 | embed.add_field(name=field["name"], value=field["value"], inline=True) 180 | if media.backdrop_path: 181 | embed.set_image(url=media.backdrop_path) 182 | # 组合上下文 183 | context = Context(media_info=media, meta_info=meta) 184 | view = SubscribeView(context) 185 | await interaction.followup.send(embed=embed, view=view) 186 | 187 | 188 | # 如果只找到一个结果,直接订阅 189 | else: 190 | mediainfo = medias[0] 191 | await interaction.followup.send(f'{mediainfo.title_year} 添加订阅') 192 | self.subscribechain.add(title=mediainfo.title, 193 | year=mediainfo.year, 194 | mtype=mediainfo.type, 195 | tmdbid=mediainfo.tmdb_id, 196 | season=meta.begin_season, 197 | exist_ok=True, 198 | username="Discord Bot") 199 | 200 | 201 | @app_commands.command(description="搜索种子,选择下载") 202 | async def search(self, interaction: discord.Interaction, title: str): 203 | await interaction.response.send_message("正在搜索电影 " + title) 204 | game = discord.Game("努力搜索中...") 205 | await self.bot.change_presence(status=discord.Status.online, activity=game) 206 | # 搜索 207 | meta = MetaInfo(title=title) 208 | mediainfo = self.searchchain.recognize_media(meta=meta) 209 | if not mediainfo: 210 | await interaction.followup.send("无法识别到媒体信息 " + title) 211 | return 212 | exist_flag, no_exists = self.downloadchain.get_no_exists_info(meta=meta, mediainfo=mediainfo) 213 | if exist_flag: 214 | await interaction.followup.send(f'{mediainfo.title_year} 已存在') 215 | return 216 | 217 | contexts = self.searchchain.process(mediainfo = mediainfo, no_exists=no_exists) 218 | if len(contexts) == 0: 219 | await interaction.followup.send("没有找到资源 " + title) 220 | game = discord.Game("看电影中...") 221 | await self.bot.change_presence(status=discord.Status.idle, activity=game) 222 | else: 223 | for context in contexts: 224 | torrent = context.torrent_info 225 | embed = discord.Embed(title=torrent.title, 226 | description=torrent.description, 227 | url=torrent.page_url) 228 | fields = [] 229 | site_name = { 230 | "name": "站点", 231 | "value": torrent.site_name 232 | } 233 | torrent_size = { 234 | "name": "种子大小", 235 | "value": str(round(torrent.size / 1024 / 1024 / 1024, 2)) + " GB" 236 | } 237 | seeders = { 238 | "name": "做种数", 239 | "value": torrent.seeders 240 | } 241 | peers = { 242 | "name": "下载数", 243 | "value": torrent.peers 244 | } 245 | release_date = { 246 | "name": "发布日期", 247 | "value": torrent.pubdate 248 | } 249 | free = { 250 | "name": "促销", 251 | "value": torrent.volume_factor 252 | } 253 | fields.append(site_name) 254 | fields.append(torrent_size) 255 | fields.append(release_date) 256 | fields.append(seeders) 257 | fields.append(peers) 258 | fields.append(free) 259 | 260 | for field in fields: 261 | embed.add_field(name=field["name"], value=field["value"], inline=True) 262 | 263 | view = DownloadView(context) 264 | await interaction.followup.send(embed=embed, view=view) 265 | 266 | game = discord.Game("看电影中...") 267 | await self.bot.change_presence(status=discord.Status.idle, activity=game) 268 | 269 | class DownloadView(discord.ui.View): 270 | context = None 271 | downloadchain = None 272 | subscribechain = None 273 | searchchain = None 274 | def __init__(self, context: Context): 275 | super().__init__(timeout=180) 276 | self.context = context 277 | self.subscribechain = SubscribeChain() 278 | self.downloadchain = DownloadChain() 279 | self.searchchain = SearchChain() 280 | 281 | @discord.ui.button(label="下载", style = discord.ButtonStyle.blurple) 282 | async def download(self, button: discord.ui.Button, interaction: discord.Interaction): 283 | # 如果已经有种子信息,直接下载 284 | if(self.context.torrent_info != None): 285 | self.downloadchain.download_single(self.context) 286 | await interaction.response.send_message(f"添加下载任务 {self.context.torrent_info.title} 成功") 287 | # 如果没有种子信息,先搜索种子,再下载 288 | else: 289 | mediainfo = self.context.media_info 290 | meta = self.context.meta_info 291 | exist_flag, no_exists = self.downloadchain.get_no_exists_info(meta=meta, mediainfo=mediainfo) 292 | if exist_flag: 293 | await interaction.response.send_message(f'{mediainfo.title_year} 已存在') 294 | return 295 | contexts = self.searchchain.process(mediainfo = mediainfo, no_exists=no_exists) 296 | if len(contexts) == 0: 297 | await interaction.response.send_message("没有找到资源 " + mediainfo.title_year) 298 | return 299 | # 自动下载 300 | downloads, lefts = self.downloadchain.batch_download(contexts=contexts, no_exists=no_exists, 301 | username="Discord Bot") 302 | if downloads and not lefts: 303 | await interaction.response.send_message(f'{mediainfo.title_year} 添加下载') 304 | else: 305 | await interaction.response.send_message(f'{mediainfo.title_year} 下载未完整,开始订阅') 306 | self.subscribechain.add(title=mediainfo.title, 307 | year=mediainfo.year, 308 | mtype=mediainfo.type, 309 | tmdbid=mediainfo.tmdb_id, 310 | season=meta.begin_season, 311 | exist_ok=True, 312 | username="Discord Bot") 313 | 314 | class SubscribeView(discord.ui.View): 315 | context = None 316 | subscribechain = None 317 | def __init__(self, context: Context): 318 | super().__init__(timeout=180) 319 | self.context = context 320 | self.subscribechain = SubscribeChain() 321 | 322 | @discord.ui.button(label="订阅", style = discord.ButtonStyle.blurple) 323 | async def subscribe(self, button: discord.ui.Button, interaction: discord.Interaction): 324 | mediainfo = self.context.media_info 325 | meta = self.context.meta_info 326 | self.subscribechain.add(title=mediainfo.title, 327 | year=mediainfo.year, 328 | mtype=mediainfo.type, 329 | tmdbid=mediainfo.tmdb_id, 330 | season=meta.begin_season, 331 | exist_ok=True, 332 | username="Discord Bot") 333 | await interaction.response.send_message(f"已添加订阅 {mediainfo.title_year}") 334 | async def setup(bot : commands.Bot): 335 | await bot.add_cog(MPCog(bot)) -------------------------------------------------------------------------------- /plugins/discord/discord_bot.py: -------------------------------------------------------------------------------- 1 | import discord, discord.webhook, asyncio, os, sys 2 | from discord.ext import commands 3 | import plugins.discord.tokenes as tokenes 4 | from app.log import logger 5 | 6 | on_conversion = False 7 | current_channel = None 8 | intents = discord.Intents.all() 9 | client = commands.Bot(command_prefix='$', intents=intents) 10 | 11 | # Load cogs 12 | async def load_extensions(): 13 | try: 14 | await client.load_extension(f"plugins.discord.cogs.moviepilot_cog") 15 | logger.info("Cog 加载完成") 16 | except Exception as e: 17 | logger.error(f"Cog 加载失败: {e}") 18 | 19 | # Unload cogs 20 | async def unload_extensions(): 21 | try: 22 | await client.unload_extension(f"plugins.discord.cogs.moviepilot_cog") 23 | logger.info("Cog 卸载完成") 24 | except Exception as e: 25 | logger.error(f"Cog 卸载失败: {e}") 26 | 27 | # Run bot 28 | async def run_bot(): 29 | if tokenes.is_bot_running: 30 | try: 31 | await load_extensions() 32 | except Exception as e: 33 | logger.error(f"Discord bot 已启动") 34 | else: 35 | try: 36 | logger.info("Discord bot 启动中...") 37 | tokenes.is_bot_running = True 38 | await load_extensions() 39 | await client.start(tokenes.bot_token) 40 | except Exception as e: 41 | logger.error(f"Discord bot 启动失败: {e}") 42 | tokenes.is_bot_running = False 43 | 44 | 45 | async def stop(): 46 | logger.info(f"is bot running: {tokenes.is_bot_running}") 47 | if tokenes.is_bot_running == True: 48 | logger.info("Discord bot 停止中...") 49 | try: 50 | await unload_extensions() 51 | tokenes.is_bot_running = False 52 | # client.clear() 53 | except Exception as e: 54 | logger.error(f"Discord bot 停止失败: {e}") 55 | else: 56 | logger.info("Discord bot 未运行") 57 | -------------------------------------------------------------------------------- /plugins/discord/gpt.py: -------------------------------------------------------------------------------- 1 | 2 | from app.log import logger 3 | import openai 4 | class GPT(): 5 | openai_version_low = openai.__version__ == "0.27.10" 6 | logger.info(f"OpenAI 版本: {openai.__version__} {openai_version_low}") 7 | client = None 8 | gpt_token = None 9 | chat_start = [ 10 | {"role": "system", "content": "用傲娇的口吻和我说话,可以使用一些颜文字。"}, 11 | ] 12 | chat_history = chat_start 13 | def __init__(self, token = None): 14 | self.gpt_token = token 15 | if(self.gpt_token == None): 16 | logger.error(f"未设置OpenAI token") 17 | return 18 | if(self.openai_version_low): 19 | openai.api_key = self.gpt_token 20 | else: 21 | self.client = openai.OpenAI(api_key=self.gpt_token) 22 | logger.info("GPT 初始化完成") 23 | 24 | def clear_chat_history(self): 25 | self.chat_history = self.chat_start 26 | 27 | # 生成回复,并添加到chat_history 28 | def generate_reply(self,message): 29 | if(self.gpt_token == None): 30 | return f"未设置OpenAI token" 31 | # chat_history 添加用户输入 32 | self.chat_history.append({"role": "user", "content": message}) 33 | 34 | if self.openai_version_low: 35 | chat = openai.ChatCompletion.create( 36 | model="gpt-3.5-turbo", 37 | messages=self.chat_history) 38 | else: 39 | chat = self.client.chat.completions.create( 40 | model="gpt-3.5-turbo", 41 | messages = self.chat_history, 42 | ) 43 | # chat_history 添加助手回复 44 | self.chat_history.append({"role": "assistant", "content": chat.choices[0].message.content}) 45 | 46 | return chat.choices[0].message.content 47 | -------------------------------------------------------------------------------- /plugins/discord/requirements.txt: -------------------------------------------------------------------------------- 1 | discord.py~=2.3.2 2 | openai~=1.9.0 -------------------------------------------------------------------------------- /plugins/discord/tokenes.py: -------------------------------------------------------------------------------- 1 | 2 | bot_token = None 3 | gpt_token = None 4 | is_bot_running = False 5 | 6 | -------------------------------------------------------------------------------- /plugins/rmcdata/README.md: -------------------------------------------------------------------------------- 1 | ## Infuse nfo 简介修复 2 | 3 | CDATA标签是XML中的一种特殊标签,用于包含不需要转义的文本内容。但是不清楚什么原因,infuse无法正确读取nfo中的CDATA标签,导致媒体简介空白。 4 | 5 | ### 配置 6 | 7 | 启用后将根据每次入库事件自动触发脚本,去除nfo中的CDATA标签。 8 | 也可以配置全媒体库nfo修复目录,开启运行一次后将对输入的路径下所有nfo文件进行CDATA标签去除。 9 | 10 | 11 | -------------------------------------------------------------------------------- /plugins/rmcdata/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | import threading 3 | 4 | # MoviePilot library 5 | from app.log import logger 6 | from app.plugins import _PluginBase 7 | from app.core.event import eventmanager 8 | from app.schemas.types import EventType 9 | from typing import Any, List, Dict, Tuple 10 | 11 | class RmCdata(_PluginBase): 12 | # 插件名称 13 | plugin_name = "Infuse nfo 简介修复" 14 | # 插件描述 15 | plugin_desc = "去除nfo文件中的cdata标签以修复Infuse简介内容显示" 16 | # 插件图标 17 | plugin_icon = "https://raw.githubusercontent.com/HankunYu/MoviePilot-Plugins/main/icons/Infuse.png" 18 | # 主题色 19 | plugin_color = "#32699D" 20 | # 插件版本 21 | plugin_version = "1.2.6" 22 | # 插件作者 23 | plugin_author = "hankun" 24 | # 作者主页 25 | author_url = "https://github.com/hankunyu" 26 | # 插件配置项ID前缀 27 | plugin_config_prefix = "rmcdata_" 28 | # 加载顺序 29 | plugin_order = 1 30 | # 可使用的用户级别 31 | auth_level = 1 32 | 33 | # 私有属性 34 | _enabled = False 35 | _rm_all = False 36 | _rm_empty = False 37 | _all_path = "" 38 | _is_running = False 39 | _threads = [] 40 | def init_plugin(self, config: dict = None): 41 | if config: 42 | self._enabled = config.get("enabled") 43 | self._rm_all = config.get("rm_all") 44 | self._all_path = config.get("all_path") 45 | self._rm_empty = config.get("rm_empty") 46 | 47 | if self._rm_all and not self._is_running: 48 | self._is_runing = True 49 | for path in self._all_path.split('\n'): 50 | if os.path.exists(path): 51 | thread = threading.Thread(target=self.process_all_nfo_files, args=(path,)) 52 | thread.start() 53 | self._threads.append(thread) 54 | # self.process_all_nfo_files(path) 55 | self._rm_all = False 56 | self.update_config({ 57 | "enabled": self._enabled, 58 | "rm_empty": self._rm_empty, 59 | "rm_all": False, 60 | "all_path": self._all_path, 61 | }) 62 | 63 | 64 | if self._enabled: 65 | logger.info(f"nfo 文件监控开始, version: {self.plugin_version}") 66 | 67 | def get_state(self) -> bool: 68 | return self._enabled 69 | 70 | @staticmethod 71 | def get_command() -> List[Dict[str, Any]]: 72 | pass 73 | 74 | def get_api(self) -> List[Dict[str, Any]]: 75 | pass 76 | 77 | # 插件配置页面 78 | def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: 79 | """ 80 | 拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构 81 | """ 82 | return [ 83 | { 84 | 'component': 'VForm', 85 | 'content': [ 86 | { 87 | 'component': 'VRow', 88 | 'content': [ 89 | { 90 | 'component': 'VCol', 91 | 'props': { 92 | 'cols': 12, 93 | 'md': 6 94 | }, 95 | 'content': [ 96 | { 97 | 'component': 'VSwitch', 98 | 'props': { 99 | 'model': 'enabled', 100 | 'label': '启用插件', 101 | } 102 | } 103 | ] 104 | }, 105 | { 106 | 'component': 'VCol', 107 | 'content': [ 108 | { 109 | 'component': 'VSwitch', 110 | 'props': { 111 | 'model': 'rm_empty', 112 | 'label': '自动删除内容为空的nfo文件', 113 | } 114 | } 115 | ] 116 | }, 117 | { 118 | 'component': 'VCol', 119 | 'content': [ 120 | { 121 | 'component': 'VSwitch', 122 | 'props': { 123 | 'model': 'rm_all', 124 | 'label': '运行一次全媒体库nfo修复', 125 | } 126 | } 127 | ] 128 | } 129 | ] 130 | }, 131 | { 132 | 'component': 'VRow', 133 | 'content': [ 134 | { 135 | 'component': 'VCol', 136 | 'props': { 137 | 'cols': 12 138 | }, 139 | 'content': [ 140 | { 141 | 'component': 'VTextarea', 142 | 'props': { 143 | 'model': 'all_path', 144 | 'label': '全媒体库nfo修复目录', 145 | 'rows': 5, 146 | 'placeholder': '每一行一个目录,需配置到媒体文件的上级目录' 147 | } 148 | } 149 | ] 150 | } 151 | ] 152 | }, 153 | { 154 | 'component': 'VRow', 155 | 'content': [ 156 | { 157 | 'component': 'VCol', 158 | 'props': { 159 | 'cols': 12, 160 | }, 161 | 'content': [ 162 | { 163 | 'component': 'VAlert', 164 | 'props': { 165 | 'type': 'info', 166 | 'variant': 'flat', 167 | 'text': '当自动删除内容为空的nfo文件启用时,会在每次nfo文件删除CDATA标签以及全媒体库nfo文件扫描完成后自动删除内容为空的nfo文件' 168 | } 169 | } 170 | ] 171 | } 172 | ] 173 | } 174 | ] 175 | } 176 | ], { 177 | "enabled": False, 178 | "rm_all": False, 179 | "rm_empty": False, 180 | "all_path": "", 181 | } 182 | 183 | def get_page(self) -> List[dict]: 184 | pass 185 | @staticmethod 186 | def replace_cdata_tags(file_path, rm_empty=False): 187 | logger.info(f'正在处理 {file_path}...') 188 | with open(file_path, 'r') as file: 189 | content = file.read() 190 | # 替换 CDATA 标签 191 | content = content.replace('', '') 192 | with open(file_path, 'w') as file: 193 | file.write(content) 194 | logger.info(f'{file_path} 处理完成') 195 | 196 | if rm_empty: 197 | RmCdata.delete_file_without_plot(file_path) 198 | 199 | @staticmethod 200 | def delete_file_without_plot(file_path): 201 | with open(file_path, "r") as file: 202 | text = file.read() 203 | start_tag = "" 204 | end_tag = "" 205 | 206 | start_index = text.find(start_tag) 207 | end_index = text.find(end_tag) 208 | 209 | if start_index == -1 or end_index == -1: 210 | logger.info(f'{file_path} 元数据没找到plot标签') 211 | return 212 | 213 | if end_index >= start_index + len(start_tag): 214 | content = text[start_index + len(start_tag):end_index] 215 | if content.strip(): 216 | pass 217 | else: 218 | logger.info(f'plot标签为空,删除 {file_path}...') 219 | os.remove(file_path) 220 | 221 | def process_all_nfo_files(self,directory): 222 | logger.info(f'正在处理 {directory} 下的所有 nfo 文件...') 223 | for root, dirs, files in os.walk(directory): 224 | for file in files: 225 | if file.endswith('.nfo'): 226 | file_path = os.path.join(root, file) 227 | self.replace_cdata_tags(file_path, self._rm_empty) 228 | 229 | logger.info(f'{directory} - 处理完成') 230 | self._threads.pop(0) 231 | logger.info(f'正在移除线程... 剩余 {len(self._threads)} 个线程') 232 | if len(self._threads) == 0: 233 | self._is_runing = False 234 | logger.info(f'任务完成') 235 | 236 | @eventmanager.register(EventType.TransferComplete) 237 | def rmcdata(self, event): 238 | 239 | if not self._enabled: 240 | return 241 | 242 | def __to_dict(_event): 243 | """ 244 | 递归将对象转换为字典 245 | """ 246 | if isinstance(_event, dict): 247 | for k, v in _event.items(): 248 | _event[k] = __to_dict(v) 249 | return _event 250 | elif isinstance(_event, list): 251 | for i in range(len(_event)): 252 | _event[i] = __to_dict(_event[i]) 253 | return _event 254 | elif isinstance(_event, tuple): 255 | return tuple(__to_dict(list(_event))) 256 | elif isinstance(_event, set): 257 | return set(__to_dict(list(_event))) 258 | elif hasattr(_event, 'to_dict'): 259 | return __to_dict(_event.to_dict()) 260 | elif hasattr(_event, '__dict__'): 261 | return __to_dict(_event.__dict__) 262 | elif isinstance(_event, (int, float, str, bool, type(None))): 263 | return _event 264 | else: 265 | return str(_event) 266 | 267 | 268 | raw_data = __to_dict(event.event_data) 269 | targets_file = raw_data.get("transferinfo").get("file_list_new") 270 | 271 | for media_name in targets_file: 272 | file_name, file_ext = os.path.splitext(media_name) 273 | nfo_file = file_name + ".nfo" 274 | if os.path.exists(nfo_file): 275 | logger.info(f'准备处理 {nfo_file}...') 276 | self.replace_cdata_tags(nfo_file, self._rm_empty) 277 | else: 278 | logger.error(f'{nfo_file} 不存在') 279 | 280 | 281 | 282 | 283 | def stop_service(self): 284 | """ 285 | 退出插件 286 | """ 287 | pass 288 | -------------------------------------------------------------------------------- /plugins/test/__init__.py: -------------------------------------------------------------------------------- 1 | #import discord 2 | from enum import Enum 3 | import asyncio, threading 4 | 5 | # MoviePilot library 6 | from app.core.event import eventmanager, Event 7 | from app.log import logger 8 | from app.plugins import _PluginBase 9 | from app.core.event import eventmanager 10 | from app.schemas.types import EventType, NotificationType 11 | from typing import Any, List, Dict, Tuple 12 | from app.utils.http import RequestUtils 13 | 14 | 15 | 16 | class Test(_PluginBase): 17 | # 插件名称 18 | plugin_name = "test" 19 | # 插件描述 20 | plugin_desc = "Test plugin" 21 | # 插件图标 22 | plugin_icon = "https://raw.githubusercontent.com/HankunYu/MoviePilot-Plugins/main/icons/discord.png" 23 | # 主题色 24 | plugin_color = "#3B5E8E" 25 | # 插件版本 26 | plugin_version = "1" 27 | # 插件作者 28 | plugin_author = "hankun" 29 | # 作者主页 30 | author_url = "https://github.com/hankunyu" 31 | # 插件配置项ID前缀 32 | plugin_config_prefix = "test_" 33 | # 加载顺序 34 | plugin_order = 1 35 | # 可使用的用户级别 36 | auth_level = 1 37 | 38 | 39 | _enabled = False 40 | def init_plugin(self, config: dict = None): 41 | if config: 42 | self._enabled = config.get("enabled") 43 | 44 | # 启动discord bot 45 | if(self._enabled): 46 | logger.info(f"Discord插件初始化完成 version: {self.plugin_version}") 47 | 48 | def get_state(self) -> bool: 49 | return self._enabled 50 | 51 | @staticmethod 52 | def get_command() -> List[Dict[str, Any]]: 53 | pass 54 | 55 | def get_api(self) -> List[Dict[str, Any]]: 56 | pass 57 | 58 | # 插件配置页面 59 | def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: 60 | """ 61 | 拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构 62 | """ 63 | return [ 64 | { 65 | 'component': 'VForm', 66 | 'content': [ 67 | { 68 | 'component': 'VRow', 69 | 'content': [ 70 | { 71 | 'component': 'VCol', 72 | 'props': { 73 | 'cols': 12, 74 | 'md': 6 75 | }, 76 | 'content': [ 77 | { 78 | 'component': 'VSwitch', 79 | 'props': { 80 | 'model': 'enabled', 81 | 'label': '启用插件', 82 | } 83 | } 84 | ] 85 | } 86 | ] 87 | } 88 | ] 89 | } 90 | ], { 91 | "enabled": False 92 | } 93 | 94 | def get_page(self) -> List[dict]: 95 | pass 96 | 97 | 98 | @eventmanager.register(EventType.NoticeMessage) 99 | def send(self, event: Event): 100 | msg_body = event.event_data 101 | text = msg_body.get("text") 102 | msg_type: NotificationType = msg_body.get("mtype") 103 | logger.info(f"event type: " + str(msg_type)) 104 | return 105 | 106 | 107 | def stop_service(self): 108 | """ 109 | 退出插件 110 | """ 111 | pass 112 | --------------------------------------------------------------------------------