├── .gitignore ├── LICENSE ├── README.md ├── icons ├── PlexAutoSkip_A.png ├── PlexAutoSkip_B.png ├── PlexAutoSkip_C.png ├── PlexEdition_A.png ├── Plex_A.png ├── TorrentClassifier.png ├── TorrentClassifier.webp ├── autodiagnosis.png ├── autodiagnosis.webp ├── auxiliaryauth.png ├── auxiliaryauth.webp ├── brush.jpg ├── brushmanager.png ├── brushmanager.webp ├── cancel.png ├── commands.png ├── customplugin.png ├── customplugin.webp ├── grid.webp ├── historycategory.png ├── historycategory.webp ├── historyclear.png ├── historyclear.webp ├── hitandrun.png ├── hitandrun.webp ├── mihosts.png ├── mihosts.webp ├── plexautolanguages.png ├── plexautolanguages.webp ├── plexedition.png ├── plexedition.webp ├── plexlocalization.png ├── plexlocalization.webp ├── plexlocalization_A.webp ├── plexmatch.png ├── plexmatch.webp ├── plexpersonmeta.png ├── plexpersonmeta.webp ├── plexspeedtest.png ├── plexspeedtest.webp ├── reload.png ├── reload_A.png ├── reorder.png ├── reorder.webp ├── seed.webp ├── servicemanager.png ├── servicemanager.webp ├── smartrename.png ├── smartrename.webp ├── subscribeassistant.png ├── systemnotification.png ├── systemnotification.webp ├── trafficassistant.png ├── trafficassistant.webp ├── weatherwidget.png ├── weatherwidget.webp ├── webdavbackup.png └── webdavbackup_A.png ├── images ├── 2024-04-19-00-46-10.png ├── 2024-04-24-02-45-38.png ├── 2024-05-02-03-16-42.png ├── 2024-05-03-09-27-11.png ├── 2024-05-08-02-51-16.png ├── 2024-05-08-02-51-29.png ├── 2024-05-09-03-56-17.png ├── 2024-05-09-23-29-47.png ├── 2024-05-09-23-30-39.png ├── 2024-05-17-22-16-48.png ├── 2024-05-17-22-17-21.png ├── 2024-05-24-00-29-38.png ├── 2024-05-27-00-56-02.png ├── 2024-05-27-00-56-38.png ├── 2024-05-30-18-35-36.png ├── 2024-06-02-20-39-05.png ├── 2024-06-13-17-06-24.png ├── 2024-06-15-00-25-20.png ├── 2024-06-15-00-25-31.png ├── 2024-06-15-00-28-07.png ├── 2024-06-25-02-57-20.png ├── 2024-06-25-02-57-53.png ├── 2024-06-28-02-25-01.png ├── 2024-07-04-01-57-02.png ├── 2024-07-04-02-11-17.png ├── 2024-07-07-03-30-56.png ├── 2024-07-07-03-34-15.png ├── 2024-07-07-03-35-26.png ├── 2024-07-07-03-36-08.png ├── 2024-07-07-03-36-49.png ├── 2024-07-07-03-37-10.png ├── 2024-07-13-00-43-36.png ├── 2024-07-13-00-44-06.png ├── 2024-07-13-00-47-11.png ├── 2024-07-13-03-00-07.png ├── 2024-07-13-03-02-10.png ├── 2024-07-20-01-19-39.png ├── 2024-07-25-00-23-35.png ├── 2024-07-25-00-24-34.png ├── 2024-07-25-00-32-08.png ├── 2024-07-25-00-35-04.png ├── 2024-08-04-02-35-31.png ├── 2024-08-14-20-26-08.png ├── 2024-08-14-20-27-09.png ├── 2024-12-28-01-11-11.png ├── 2024-12-28-01-15-35.png ├── 2024-12-28-01-19-13.png ├── 2024-12-28-01-19-30.png ├── 2024-12-28-01-19-51.png ├── 2024-12-28-01-21-45.png ├── 2024-12-28-01-21-56.png ├── 2024-12-28-01-22-59.png ├── 2024-12-28-01-23-07.png └── 2024-12-28-01-25-20.png ├── package.json ├── package.v2.json ├── plugins.v2 ├── autodiagnosis │ └── __init__.py ├── auxiliaryauth │ └── __init__.py ├── boss │ ├── __init__.py │ └── douban.py ├── brushflowlowfreq │ ├── README.md │ └── __init__.py ├── brushmanager │ └── __init__.py ├── commands │ └── __init__.py ├── hitandrun │ ├── __init__.py │ ├── entities.py │ ├── helper.py │ ├── hnrconfig.py │ └── rule.yaml ├── plexautolanguages │ ├── __init__.py │ ├── config │ │ ├── default.yaml │ │ └── user.yaml │ ├── core │ │ ├── __init__.py │ │ ├── alerts │ │ │ ├── __init__.py │ │ │ ├── activity.py │ │ │ ├── base.py │ │ │ ├── playing.py │ │ │ ├── status.py │ │ │ └── timeline.py │ │ ├── constants.py │ │ ├── exceptions.py │ │ ├── plex_alert_handler.py │ │ ├── plex_alert_listener.py │ │ ├── plex_server.py │ │ ├── plex_server_cache.py │ │ ├── track_changes.py │ │ └── utils │ │ │ ├── __init__.py │ │ │ ├── configuration.py │ │ │ ├── json_encoders.py │ │ │ └── logger.py │ ├── languageprovider.py │ └── requirements.txt ├── plexedition │ └── __init__.py ├── plexlocalization │ ├── __init__.py │ └── requirements.txt ├── plexmatch │ └── __init__.py ├── plexpersonmeta │ ├── README.md │ ├── __init__.py │ ├── helper.py │ ├── requirements.txt │ └── scrape.py ├── plexrefreshrecent │ └── __init__.py ├── pluginreorder │ └── __init__.py ├── servicemanager │ └── __init__.py ├── smartrename │ └── __init__.py ├── subscribeassistant │ └── __init__.py ├── torrentclassifier │ ├── __init__.py │ └── classifierconfig.py └── trafficassistant │ ├── __init__.py │ └── trafficconfig.py └── plugins ├── autodiagnosis └── __init__.py ├── brushflowlowfreq ├── README.md └── __init__.py ├── brushmanager └── __init__.py ├── customplugin ├── __init__.py ├── custom.py └── task.py ├── historycategory └── __init__.py ├── historyclear └── __init__.py ├── hitandrun ├── __init__.py ├── entities.py ├── helper.py ├── hnrconfig.py └── rule.yaml ├── mihosts └── __init__.py ├── plexautolanguages ├── __init__.py ├── config │ ├── default.yaml │ └── user.yaml ├── core │ ├── __init__.py │ ├── alerts │ │ ├── __init__.py │ │ ├── activity.py │ │ ├── base.py │ │ ├── playing.py │ │ ├── status.py │ │ └── timeline.py │ ├── constants.py │ ├── exceptions.py │ ├── plex_alert_handler.py │ ├── plex_alert_listener.py │ ├── plex_server.py │ ├── plex_server_cache.py │ ├── track_changes.py │ └── utils │ │ ├── __init__.py │ │ ├── configuration.py │ │ ├── json_encoders.py │ │ └── logger.py ├── languageprovider.py └── requirements.txt ├── plexautoskip ├── __init__.py ├── config │ ├── config.ini │ ├── custom.json │ └── logging.ini ├── requirements.txt ├── resources │ ├── __init__.py │ ├── binge.py │ ├── customEntries.py │ ├── log.py │ ├── mediaWrapper.py │ ├── server.py │ ├── settings.py │ ├── skipper.py │ └── sslAlertListener.py └── setup │ ├── config.ini.sample │ ├── custom.json.sample │ └── logging.ini.sample ├── plexedition └── __init__.py ├── plexlocalization ├── __init__.py └── requirements.txt ├── plexmatch └── __init__.py ├── plexpersonmeta ├── README.md ├── __init__.py ├── helper.py └── requirements.txt ├── plexrefreshrecent └── __init__.py ├── plexspeedtest ├── __init__.py ├── plex.tv_dns.xlsx └── requirements.txt ├── pluginreload └── __init__.py ├── pluginreorder └── __init__.py ├── systemnotification └── __init__.py ├── torrentclassifier ├── __init__.py └── classifierconfig.py ├── trafficassistant ├── __init__.py └── trafficconfig.py ├── weatherwidget └── __init__.py └── webdavbackup ├── __init__.py └── requirements.txt /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/#use-with-ide 110 | .pdm.toml 111 | 112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 113 | __pypackages__/ 114 | 115 | # Celery stuff 116 | celerybeat-schedule 117 | celerybeat.pid 118 | 119 | # SageMath parsed files 120 | *.sage.py 121 | 122 | # Environments 123 | .env 124 | .venv 125 | env/ 126 | venv/ 127 | ENV/ 128 | env.bak/ 129 | venv.bak/ 130 | 131 | # Spyder project settings 132 | .spyderproject 133 | .spyproject 134 | 135 | # Rope project settings 136 | .ropeproject 137 | 138 | # mkdocs documentation 139 | /site 140 | 141 | # mypy 142 | .mypy_cache/ 143 | .dmypy.json 144 | dmypy.json 145 | 146 | # Pyre type checker 147 | .pyre/ 148 | 149 | # pytype static type analyzer 150 | .pytype/ 151 | 152 | # Cython debug symbols 153 | cython_debug/ 154 | 155 | # PyCharm 156 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 157 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 158 | # and can be added to the global gitignore or merged into this file. For a more nuclear 159 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 160 | .idea/ 161 | .vscode/ 162 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MoviePilot-Plugins 2 | 3 | MoviePilot三方插件:https://github.com/InfinityPacer/MoviePilot-Plugins 4 | 5 | ## 安装说明 6 | 7 | MoviePilot环境变量添加本项目地址,具体参见 https://github.com/jxxghp/MoviePilot 8 | 9 | ## 插件集合 10 | 11 | ![](images/2024-12-28-01-25-20.png) 12 | 13 | ## 插件说明 14 | 15 | ### 1. [站点刷流(低频版)](https://github.com/InfinityPacer/MoviePilot-Plugins/blob/main/plugins.v2/brushflowlowfreq/README.md) 16 | 17 | - 在官方刷流插件的基础上,新增了若干项功能优化了部分细节逻辑,目前已逐步PR至官方插件。在此,再次感谢 [@jxxghp](https://github.com/jxxghp) 提供那么优秀的开源作品。 18 | - 详细配置说明以及刷流规则请参考 [README](https://github.com/InfinityPacer/MoviePilot-Plugins/blob/main/plugins/brushflowlowfreq/README.md) 19 | 20 | ![](images/2024-05-02-03-16-42.png) 21 | ![](images/2024-07-07-03-36-08.png) 22 | 23 | ### 2. 飞书机器人消息通知 24 | 25 | - 详细使用参考飞书官方文档,[自定义机器人使用指南](https://open.feishu.cn/document/client-docs/bot-v3/add-custom-bot) 26 | 27 | ![](images/2024-04-19-00-46-10.png) 28 | 29 | ### 3. 插件热加载 30 | 31 | - 直接在Docker中调试插件时,无需重启容器即可完成插件热加载 32 | 33 | ![](images/2024-07-07-03-36-49.png) 34 | 35 | ### 4. 刷流种子整理 36 | 37 | - 针对刷流种子进行整理入库操作,目前仅支持QB 38 | - 添加MP标签建议配合MP中的「监控默认下载器」选项 39 | - 移除刷流标签建议配合刷流插件中的「下载器监控」选项 40 | - 入库由MoviePliot的下载器监控或者目录监控完成,本插件仅提供种子操作如自动分类,添加MP标签等功能 41 | 42 | ![](images/2024-07-07-03-37-10.png) 43 | 44 | ### 5. Plex元数据刷新 45 | 46 | - 定时通知Plex刷新最近入库元数据 47 | - 部分电影/剧集的元数据更新滞后于其发布日期,Plex可能无法及时更新这些信息 48 | - 通过定期触发元数据刷新操作,确保用户始终可以访问最新元数据 49 | 50 | ![](images/2024-04-24-02-45-38.png) 51 | ![](images/2024-04-24-02-45-08.png) 52 | ![](images/2024-06-15-00-25-20.png) 53 | 54 | ### 6. WebDAV备份 55 | 56 | - 定时通过WebDAV备份数据库和配置文件。 57 | 58 | #### 感谢 59 | 60 | - 参考了 [thsrite/MoviePilot-Plugins](https://github.com/thsrite/MoviePilot-Plugins/) 项目,实现了插件的相关功能。 61 | - 特此感谢 [thsrite](https://github.com/thsrite) 等贡献者的卓越代码贡献。 62 | - 如有未能提及的作者,请告知我以便进行补充。 63 | 64 | ![](images/2024-07-07-03-30-56.png) 65 | 66 | ### 7. Plex中文本地化 67 | 68 | - 实现拼音排序、搜索及类型标签中文本地化功能。 69 | 70 | #### 感谢 71 | 72 | - 本插件基于 [plex_localization_zhcn](https://github.com/sqkkyzx/plex_localization_zhcn),[plex-localization-zh](https://github.com/x1ao4/plex-localization-zh) 项目,实现了插件的相关功能。 73 | - 特此感谢 [timmy0209](https://github.com/timmy0209)、[sqkkyzx](https://github.com/sqkkyzx)、[x1ao4](https://github.com/x1ao4)、[anooki-c](https://github.com/anooki-c) 等贡献者的卓越代码贡献。 74 | - 如有未能提及的作者,请告知我以便进行补充。 75 | 76 | ![](images/2024-05-09-03-56-17.png) 77 | ![](images/2024-06-15-00-28-07.png) 78 | ![](images/2024-06-15-00-25-31.png) 79 | 80 | ### 8. [PlexAutoSkip](https://github.com/InfinityPacer/PlexAutoSkip) 81 | 82 | - 实现自动跳过Plex中片头、片尾以及类似的内容。 83 | - 目前支持的Plex客户端,参考如下 84 | - Plex for iOS 85 | - Plex for Apple TV 86 | - 由于Plex调整,部分客户端仅部分版本支持,仅供参考 87 | - Plex Web 88 | - Plex for Windows 89 | - Plex for Mac 90 | - Plex for Linux 91 | - Plex for Roku 92 | - Plex for Android (TV) 93 | - Plex for Android (Mobile) 94 | - 相关配置请参考[说明](https://github.com/InfinityPacer/PlexAutoSkip/blob/master/README.md)以及[Wiki](https://github.com/InfinityPacer/PlexAutoSkip/wiki) 95 | 96 | #### 感谢 97 | 98 | - 本插件基于 [PlexAutoSkip](https://github.com/mdhiggins/PlexAutoSkip) 项目,实现了插件的相关功能。 99 | - 特此感谢 [mdhiggins](https://github.com/mdhiggins) 的卓越代码贡献。 100 | - 如有未能提及的作者,请告知我以便进行补充。 101 | 102 | ![](images/2024-05-03-09-27-11.png) 103 | ![](images/2024-06-28-02-25-01.png) 104 | 105 | ### 9. PlexEdition 106 | 107 | - 根据入库记录修改Edition为电影版本/资源类型/特效信息,字段来源于[MOVIE_RENAME_FORMAT](https://github.com/jxxghp/MoviePilot?tab=readme-ov-file#2-%E7%8E%AF%E5%A2%83%E5%8F%98%E9%87%8F--%E9%85%8D%E7%BD%AE%E6%96%87%E4%BB%B6)中的**edition**版本(资源类型+特效) 108 | 109 | #### 感谢 110 | 111 | - 灵感来自于项目 [plex-edition-manager](https://github.com/x1ao4/plex-edition-manager) ,特此感谢 [x1ao4](https://github.com/x1ao4)。 112 | - 如有未能提及的作者,请告知我以便进行补充。 113 | 114 | ![](images/2024-05-08-02-51-16.png) 115 | ![](images/2024-05-08-02-51-29.png) 116 | 117 | ### 10. 历史记录清理 118 | 119 | - 清理历史记录后将导致后续无法从历史记录中找到下载路径以及媒体库路径,请慎重使用 120 | - 清理历史记录前请先对/config/user.db文件进行备份,以便出现异常后能够还原 121 | - 目前仅支持一键清理历史记录,相关文件不会进行删除,请自行在文件系统中删除 122 | - 执行清理前插件会备份数据库至路径:/config/plugins/HistoryClear/Backup/.zip,如有需要,请自行还原 123 | 124 | ![](images/2024-05-09-23-30-39.png) 125 | ![](images/2024-05-09-23-29-47.png) 126 | 127 | ### 11. 天气 128 | 129 | - 支持在仪表盘中显示实时天气小部件,方便用户随时查看天气情况 130 | - 通过在和风天气官网获取对应链接精确定位城市,如「[秦淮区](https://www.qweather.com/weather/qinhuai-101190109.html)」的链接填写为「qinhuai-101190109」 131 | 132 | #### 感谢 133 | 134 | - 天气数据来源于[和风天气](https://www.qweather.com/),再次感谢[和风天气](https://www.qweather.com/)提供的服务 135 | 136 | ![](images/2024-07-13-00-43-36.png) 137 | ![](images/2024-07-13-00-44-06.png) 138 | 139 | ### 12. 插件自定义排序 140 | 141 | - 支持将插件按自定义顺序排序 142 | 143 | ![](images/2024-07-13-03-00-07.png) 144 | ![](images/2024-07-13-02-54-10.png) 145 | 146 | ### 13. 种子关键字分类整理 147 | 148 | - 通过匹配种子关键字进行自定义分类 149 | 150 | ```yaml 151 | ####### 配置说明 BEGIN ####### 152 | # 1. 本配置文件用于管理种子文件的自动分类和标签管理,采用数组形式以支持多种筛选和应用规则。 153 | # 2. 配置文件中的「torrent_source」定义了种子的来源筛选条件;「torrent_target」定义了应对匹配种子执行的操作。 154 | # 3. 每个配置条目以「-」开头,表示配置文件的数组元素。 155 | # 4. 「remove_tags」字段支持使用特殊值「@all」,代表移除所有标签。 156 | # 5. 「auto_category」启用时开启QBittorrent的「自动Torrent管理」,并忽略「change_directory」配置项。 157 | ####### 配置说明 END ####### 158 | 159 | - torrent_filter: 160 | # 种子来源部分定义:包括筛选种子的标题、分类和标签 161 | # 种子标题的过滤条件,支持使用正则表达式匹配 162 | torrent_title: '测试标题1' 163 | # 种子必须属于的分类 164 | torrent_category: '测试分类1' 165 | # 种子必须具有的标签,多个标签时,任一满足即可 166 | torrent_tags: 167 | - '测试标签1' 168 | torrent_target: 169 | # 目标种子部分定义:包括修改目标目录、修改分类、新增标签和移除标签的设置 170 | # 处理后种子的存储目录,auto_category 为 true 时不生效 171 | change_directory: '/path/to/movies' 172 | # 处理后的种子新分类 173 | change_category: '测试新分类1' 174 | # 添加到种子的新标签 175 | add_tags: 176 | - '测试新标签1' 177 | - '测试新标签2' 178 | # 移除的标签,使用 '@all' 清除所有标签 179 | remove_tags: 180 | - '@all' 181 | # 是否启用自动分类 182 | auto_category: true 183 | 184 | - torrent_filter: 185 | # 种子标题的过滤条件,支持使用正则表达式匹配 186 | torrent_title: '.*\.测试标题2' 187 | # 种子必须属于的分类 188 | torrent_category: '测试分类2' 189 | # 种子必须具有的标签,多个标签时,任一满足即可 190 | torrent_tags: 191 | - '测试标签2' 192 | - 'Rock' 193 | torrent_target: 194 | # 处理后种子的存储目录,auto_category 为 true 时不生效 195 | change_directory: '/path/to/music' 196 | # 处理后的种子新分类 197 | change_category: '测试新分类2' 198 | # 添加到种子的新标签 199 | add_tags: 200 | - '测试新标签2' 201 | # 移除的标签 202 | remove_tags: 203 | - '测试标签1' 204 | # 是否启用自动分类 205 | auto_category: false 206 | ``` 207 | 208 | ![](images/2024-05-24-00-29-38.png) 209 | 210 | ### 14. 历史记录分类刷新 211 | 212 | - 刷新历史记录分类可能会导致历史记录分类数据异常,请慎重使用 213 | - 刷新历史记录分类前请先对/config/user.db文件进行备份,以便出现异常后能够还原 214 | - 执行刷新前插件会备份数据库至路径:/config/plugins/HistoryCategory/Backup/.zip,如有需要,请自行还原 215 | - 主程序需升级1.9.1+版本 216 | 217 | ![](images/2024-05-27-00-56-38.png) 218 | ![](images/2024-05-27-00-56-02.png) 219 | 220 | ### 15. 自动诊断 221 | 222 | - 自动发起系统健康检查、网络连通性测试以及硬链接检查 223 | - 建议仅针对需要使用的模块开启系统健康检查以及网络连通性测试 224 | - 执行周期建议大于60分钟,最小不能低于10分钟 225 | 226 | ![](images/2024-06-13-17-06-24.png) 227 | 228 | ### 16. 系统通知 229 | 230 | - 通过通知渠道发送系统通知消息 231 | 232 | ![](images/2024-05-30-18-35-36.png) 233 | 234 | ### 17. 站点流量管理 235 | 236 | - 自动管理流量,保障站点分享率 237 | 238 | ![](images/2024-06-02-20-39-05.png) 239 | 240 | ### 18. [Plex演职人员刮削](https://github.com/InfinityPacer/MoviePilot-Plugins/blob/main/plugins/plexpersonmeta/README.md) 241 | 242 | - 实现刮削演职人员中文名称及角色 243 | - Plex 的 API 实现较为复杂,我在尝试为 `actor.tag.tagKey` 赋值时遇到了问题,如果您对此有所了解,请不吝赐教,可以通过新增一个 issue 与我联系,特此感谢 244 | - **警告**:由于 `tagKey` 的问题,当执行刮削后,可能会出现丢失在线元数据,无法在Plex中点击人物查看详情等问题 245 | 246 | #### 感谢 247 | 248 | - 本插件基于 [官方插件](https://github.com/jxxghp/MoviePilot-Plugins) 编写,并参考了 [PrettyServer](https://github.com/Bespertrijun/PrettyServer) 项目,实现了插件的相关功能。 249 | - 特此感谢 [jxxghp](https://github.com/jxxghp)、[Bespertrijun](https://github.com/Bespertrijun) 等贡献者的卓越代码贡献。 250 | - 如有未能提及的作者,请告知我以便进行补充。 251 | 252 | ![](images/2024-07-13-03-02-10.png) 253 | ![](images/2024-06-25-02-57-20.png) 254 | ![](images/2024-06-25-02-57-53.png) 255 | 256 | ### 19. PlexMatch 257 | 258 | - 实现入库时添加 .plexmatch 文件,提高识别准确率 259 | 260 | ![](images/2024-07-13-00-47-11.png) 261 | 262 | ### 21. 自定义插件 263 | 264 | - 实现编写自定义插件 265 | 266 | ![](images/2024-07-25-00-32-08.png) 267 | 268 | ### 21. 小米路由Hosts 269 | 270 | - 定时将本地Hosts同步至小米路由Hosts 271 | 272 | ![](images/2024-07-20-01-19-39.png) 273 | 274 | ### 22. Plex自动语言 275 | 276 | - 实现自动选择Plex电视节目的音轨和字幕语言。 277 | - 相关信息请参考 [Plex-Auto-Languages](https://github.com/RemiRigal/Plex-Auto-Languages) 278 | 279 | #### 感谢 280 | 281 | - 本插件基于 [Plex-Auto-Languages](https://github.com/RemiRigal/Plex-Auto-Languages) 项目,实现了插件的相关功能。 282 | - 特此感谢 [RemiRigal](https://github.com/RemiRigal) 的卓越代码贡献。 283 | - 如有未能提及的作者,请告知我以便进行补充。 284 | 285 | ![](images/2024-07-25-00-24-34.png) 286 | ![](images/2024-07-25-00-23-35.png) 287 | 288 | ### 23. H&R助手 289 | 290 | - 监听下载、订阅、刷流等行为,对H&R种子进行自动标签管理 291 | - 站点独立规则,参考 [rule.yaml](plugins/hitandrun/rule.yaml) 292 | - 插件仍在完善阶段,同时并未适配所有场景,如RSS订阅等 293 | - 插件并不能完全适配所有站点,请以实际使用情况为准 294 | - 插件可能导致H&R种子被错误识别,严重甚至导致站点封号,请慎重使用 295 | 296 | ![](images/2024-08-04-02-35-31.png) 297 | 298 | ### 24. Plex IP优选 299 | 300 | - 自动获取Plex相关域名,实现IP优选 301 | 302 | ![](images/2024-08-14-20-27-09.png) 303 | 304 | ------- 305 | **以下插件仅支持 MoviePilot v2** 306 | 307 | ### 25. 辅助认证 308 | 309 | - 支持使用第三方系统进行辅助认证 310 | 311 | ![](images/2024-12-28-01-15-35.png) 312 | 313 | ### 26. 命令管理 314 | 315 | - 实现微信、Telegram等客户端的命令管理 316 | 317 | ![](images/2024-12-28-01-19-30.png) 318 | 319 | ### 27. 智能重命名 320 | 321 | - 自定义适配多场景重命名 322 | - 相关细节,请查阅[自定义重命名](https://wiki.movie-pilot.org/zh/advanced) 323 | 324 | ![](images/2024-12-28-01-19-13.png) 325 | 326 | ### 28. 服务管理 327 | 328 | - 实现自定义服务管理 329 | - 启用本插件后,默认的系统服务将失效,仅以本插件设置为准 330 | - 系统服务正在运行时,请慎重启停用,否则可能导致死锁等一系列问题 331 | - 请勿随意调整服务频率,否则可能导致站点警告、封禁等后果,相关风险请自行评估与承担 332 | 333 | ![](images/2024-12-28-01-19-51.png) 334 | 335 | ### 29. 订阅助手 336 | 337 | - 实现多场景管理系统订阅与状态同步 338 | - 本插件仅支持 TMDB 数据源,相关订阅状态说明,请查阅 [#3330](https://github.com/jxxghp/MoviePilot/pull/3330) 339 | 340 | ![](images/2024-12-28-01-21-45.png) 341 | ![](images/2024-12-28-01-21-56.png) 342 | ![](images/2024-12-28-01-22-59.png) 343 | ![](images/2024-12-28-01-23-07.png) -------------------------------------------------------------------------------- /icons/PlexAutoSkip_A.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InfinityPacer/MoviePilot-Plugins/a60089999fafc7ffb7143dc09d2a627e4d839f59/icons/PlexAutoSkip_A.png -------------------------------------------------------------------------------- /icons/PlexAutoSkip_B.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InfinityPacer/MoviePilot-Plugins/a60089999fafc7ffb7143dc09d2a627e4d839f59/icons/PlexAutoSkip_B.png -------------------------------------------------------------------------------- /icons/PlexAutoSkip_C.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InfinityPacer/MoviePilot-Plugins/a60089999fafc7ffb7143dc09d2a627e4d839f59/icons/PlexAutoSkip_C.png -------------------------------------------------------------------------------- /icons/PlexEdition_A.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InfinityPacer/MoviePilot-Plugins/a60089999fafc7ffb7143dc09d2a627e4d839f59/icons/PlexEdition_A.png -------------------------------------------------------------------------------- /icons/Plex_A.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InfinityPacer/MoviePilot-Plugins/a60089999fafc7ffb7143dc09d2a627e4d839f59/icons/Plex_A.png -------------------------------------------------------------------------------- /icons/TorrentClassifier.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InfinityPacer/MoviePilot-Plugins/a60089999fafc7ffb7143dc09d2a627e4d839f59/icons/TorrentClassifier.png -------------------------------------------------------------------------------- /icons/TorrentClassifier.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InfinityPacer/MoviePilot-Plugins/a60089999fafc7ffb7143dc09d2a627e4d839f59/icons/TorrentClassifier.webp -------------------------------------------------------------------------------- /icons/autodiagnosis.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InfinityPacer/MoviePilot-Plugins/a60089999fafc7ffb7143dc09d2a627e4d839f59/icons/autodiagnosis.png -------------------------------------------------------------------------------- /icons/autodiagnosis.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InfinityPacer/MoviePilot-Plugins/a60089999fafc7ffb7143dc09d2a627e4d839f59/icons/autodiagnosis.webp -------------------------------------------------------------------------------- /icons/auxiliaryauth.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InfinityPacer/MoviePilot-Plugins/a60089999fafc7ffb7143dc09d2a627e4d839f59/icons/auxiliaryauth.png -------------------------------------------------------------------------------- /icons/auxiliaryauth.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InfinityPacer/MoviePilot-Plugins/a60089999fafc7ffb7143dc09d2a627e4d839f59/icons/auxiliaryauth.webp -------------------------------------------------------------------------------- /icons/brush.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InfinityPacer/MoviePilot-Plugins/a60089999fafc7ffb7143dc09d2a627e4d839f59/icons/brush.jpg -------------------------------------------------------------------------------- /icons/brushmanager.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InfinityPacer/MoviePilot-Plugins/a60089999fafc7ffb7143dc09d2a627e4d839f59/icons/brushmanager.png -------------------------------------------------------------------------------- /icons/brushmanager.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InfinityPacer/MoviePilot-Plugins/a60089999fafc7ffb7143dc09d2a627e4d839f59/icons/brushmanager.webp -------------------------------------------------------------------------------- /icons/cancel.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InfinityPacer/MoviePilot-Plugins/a60089999fafc7ffb7143dc09d2a627e4d839f59/icons/cancel.png -------------------------------------------------------------------------------- /icons/commands.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InfinityPacer/MoviePilot-Plugins/a60089999fafc7ffb7143dc09d2a627e4d839f59/icons/commands.png -------------------------------------------------------------------------------- /icons/customplugin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InfinityPacer/MoviePilot-Plugins/a60089999fafc7ffb7143dc09d2a627e4d839f59/icons/customplugin.png -------------------------------------------------------------------------------- /icons/customplugin.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InfinityPacer/MoviePilot-Plugins/a60089999fafc7ffb7143dc09d2a627e4d839f59/icons/customplugin.webp -------------------------------------------------------------------------------- /icons/grid.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InfinityPacer/MoviePilot-Plugins/a60089999fafc7ffb7143dc09d2a627e4d839f59/icons/grid.webp -------------------------------------------------------------------------------- /icons/historycategory.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InfinityPacer/MoviePilot-Plugins/a60089999fafc7ffb7143dc09d2a627e4d839f59/icons/historycategory.png -------------------------------------------------------------------------------- /icons/historycategory.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InfinityPacer/MoviePilot-Plugins/a60089999fafc7ffb7143dc09d2a627e4d839f59/icons/historycategory.webp -------------------------------------------------------------------------------- /icons/historyclear.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InfinityPacer/MoviePilot-Plugins/a60089999fafc7ffb7143dc09d2a627e4d839f59/icons/historyclear.png -------------------------------------------------------------------------------- /icons/historyclear.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InfinityPacer/MoviePilot-Plugins/a60089999fafc7ffb7143dc09d2a627e4d839f59/icons/historyclear.webp -------------------------------------------------------------------------------- /icons/hitandrun.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InfinityPacer/MoviePilot-Plugins/a60089999fafc7ffb7143dc09d2a627e4d839f59/icons/hitandrun.png -------------------------------------------------------------------------------- /icons/hitandrun.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InfinityPacer/MoviePilot-Plugins/a60089999fafc7ffb7143dc09d2a627e4d839f59/icons/hitandrun.webp -------------------------------------------------------------------------------- /icons/mihosts.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InfinityPacer/MoviePilot-Plugins/a60089999fafc7ffb7143dc09d2a627e4d839f59/icons/mihosts.png -------------------------------------------------------------------------------- /icons/mihosts.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InfinityPacer/MoviePilot-Plugins/a60089999fafc7ffb7143dc09d2a627e4d839f59/icons/mihosts.webp -------------------------------------------------------------------------------- /icons/plexautolanguages.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InfinityPacer/MoviePilot-Plugins/a60089999fafc7ffb7143dc09d2a627e4d839f59/icons/plexautolanguages.png -------------------------------------------------------------------------------- /icons/plexautolanguages.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InfinityPacer/MoviePilot-Plugins/a60089999fafc7ffb7143dc09d2a627e4d839f59/icons/plexautolanguages.webp -------------------------------------------------------------------------------- /icons/plexedition.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InfinityPacer/MoviePilot-Plugins/a60089999fafc7ffb7143dc09d2a627e4d839f59/icons/plexedition.png -------------------------------------------------------------------------------- /icons/plexedition.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InfinityPacer/MoviePilot-Plugins/a60089999fafc7ffb7143dc09d2a627e4d839f59/icons/plexedition.webp -------------------------------------------------------------------------------- /icons/plexlocalization.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InfinityPacer/MoviePilot-Plugins/a60089999fafc7ffb7143dc09d2a627e4d839f59/icons/plexlocalization.png -------------------------------------------------------------------------------- /icons/plexlocalization.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InfinityPacer/MoviePilot-Plugins/a60089999fafc7ffb7143dc09d2a627e4d839f59/icons/plexlocalization.webp -------------------------------------------------------------------------------- /icons/plexlocalization_A.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InfinityPacer/MoviePilot-Plugins/a60089999fafc7ffb7143dc09d2a627e4d839f59/icons/plexlocalization_A.webp -------------------------------------------------------------------------------- /icons/plexmatch.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InfinityPacer/MoviePilot-Plugins/a60089999fafc7ffb7143dc09d2a627e4d839f59/icons/plexmatch.png -------------------------------------------------------------------------------- /icons/plexmatch.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InfinityPacer/MoviePilot-Plugins/a60089999fafc7ffb7143dc09d2a627e4d839f59/icons/plexmatch.webp -------------------------------------------------------------------------------- /icons/plexpersonmeta.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InfinityPacer/MoviePilot-Plugins/a60089999fafc7ffb7143dc09d2a627e4d839f59/icons/plexpersonmeta.png -------------------------------------------------------------------------------- /icons/plexpersonmeta.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InfinityPacer/MoviePilot-Plugins/a60089999fafc7ffb7143dc09d2a627e4d839f59/icons/plexpersonmeta.webp -------------------------------------------------------------------------------- /icons/plexspeedtest.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InfinityPacer/MoviePilot-Plugins/a60089999fafc7ffb7143dc09d2a627e4d839f59/icons/plexspeedtest.png -------------------------------------------------------------------------------- /icons/plexspeedtest.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InfinityPacer/MoviePilot-Plugins/a60089999fafc7ffb7143dc09d2a627e4d839f59/icons/plexspeedtest.webp -------------------------------------------------------------------------------- /icons/reload.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InfinityPacer/MoviePilot-Plugins/a60089999fafc7ffb7143dc09d2a627e4d839f59/icons/reload.png -------------------------------------------------------------------------------- /icons/reload_A.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InfinityPacer/MoviePilot-Plugins/a60089999fafc7ffb7143dc09d2a627e4d839f59/icons/reload_A.png -------------------------------------------------------------------------------- /icons/reorder.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InfinityPacer/MoviePilot-Plugins/a60089999fafc7ffb7143dc09d2a627e4d839f59/icons/reorder.png -------------------------------------------------------------------------------- /icons/reorder.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InfinityPacer/MoviePilot-Plugins/a60089999fafc7ffb7143dc09d2a627e4d839f59/icons/reorder.webp -------------------------------------------------------------------------------- /icons/seed.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InfinityPacer/MoviePilot-Plugins/a60089999fafc7ffb7143dc09d2a627e4d839f59/icons/seed.webp -------------------------------------------------------------------------------- /icons/servicemanager.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InfinityPacer/MoviePilot-Plugins/a60089999fafc7ffb7143dc09d2a627e4d839f59/icons/servicemanager.png -------------------------------------------------------------------------------- /icons/servicemanager.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InfinityPacer/MoviePilot-Plugins/a60089999fafc7ffb7143dc09d2a627e4d839f59/icons/servicemanager.webp -------------------------------------------------------------------------------- /icons/smartrename.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InfinityPacer/MoviePilot-Plugins/a60089999fafc7ffb7143dc09d2a627e4d839f59/icons/smartrename.png -------------------------------------------------------------------------------- /icons/smartrename.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InfinityPacer/MoviePilot-Plugins/a60089999fafc7ffb7143dc09d2a627e4d839f59/icons/smartrename.webp -------------------------------------------------------------------------------- /icons/subscribeassistant.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InfinityPacer/MoviePilot-Plugins/a60089999fafc7ffb7143dc09d2a627e4d839f59/icons/subscribeassistant.png -------------------------------------------------------------------------------- /icons/systemnotification.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InfinityPacer/MoviePilot-Plugins/a60089999fafc7ffb7143dc09d2a627e4d839f59/icons/systemnotification.png -------------------------------------------------------------------------------- /icons/systemnotification.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InfinityPacer/MoviePilot-Plugins/a60089999fafc7ffb7143dc09d2a627e4d839f59/icons/systemnotification.webp -------------------------------------------------------------------------------- /icons/trafficassistant.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InfinityPacer/MoviePilot-Plugins/a60089999fafc7ffb7143dc09d2a627e4d839f59/icons/trafficassistant.png -------------------------------------------------------------------------------- /icons/trafficassistant.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InfinityPacer/MoviePilot-Plugins/a60089999fafc7ffb7143dc09d2a627e4d839f59/icons/trafficassistant.webp -------------------------------------------------------------------------------- /icons/weatherwidget.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InfinityPacer/MoviePilot-Plugins/a60089999fafc7ffb7143dc09d2a627e4d839f59/icons/weatherwidget.png -------------------------------------------------------------------------------- /icons/weatherwidget.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InfinityPacer/MoviePilot-Plugins/a60089999fafc7ffb7143dc09d2a627e4d839f59/icons/weatherwidget.webp -------------------------------------------------------------------------------- /icons/webdavbackup.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InfinityPacer/MoviePilot-Plugins/a60089999fafc7ffb7143dc09d2a627e4d839f59/icons/webdavbackup.png -------------------------------------------------------------------------------- /icons/webdavbackup_A.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InfinityPacer/MoviePilot-Plugins/a60089999fafc7ffb7143dc09d2a627e4d839f59/icons/webdavbackup_A.png -------------------------------------------------------------------------------- /images/2024-04-19-00-46-10.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InfinityPacer/MoviePilot-Plugins/a60089999fafc7ffb7143dc09d2a627e4d839f59/images/2024-04-19-00-46-10.png -------------------------------------------------------------------------------- /images/2024-04-24-02-45-38.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InfinityPacer/MoviePilot-Plugins/a60089999fafc7ffb7143dc09d2a627e4d839f59/images/2024-04-24-02-45-38.png -------------------------------------------------------------------------------- /images/2024-05-02-03-16-42.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InfinityPacer/MoviePilot-Plugins/a60089999fafc7ffb7143dc09d2a627e4d839f59/images/2024-05-02-03-16-42.png -------------------------------------------------------------------------------- /images/2024-05-03-09-27-11.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InfinityPacer/MoviePilot-Plugins/a60089999fafc7ffb7143dc09d2a627e4d839f59/images/2024-05-03-09-27-11.png -------------------------------------------------------------------------------- /images/2024-05-08-02-51-16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InfinityPacer/MoviePilot-Plugins/a60089999fafc7ffb7143dc09d2a627e4d839f59/images/2024-05-08-02-51-16.png -------------------------------------------------------------------------------- /images/2024-05-08-02-51-29.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InfinityPacer/MoviePilot-Plugins/a60089999fafc7ffb7143dc09d2a627e4d839f59/images/2024-05-08-02-51-29.png -------------------------------------------------------------------------------- /images/2024-05-09-03-56-17.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InfinityPacer/MoviePilot-Plugins/a60089999fafc7ffb7143dc09d2a627e4d839f59/images/2024-05-09-03-56-17.png -------------------------------------------------------------------------------- /images/2024-05-09-23-29-47.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InfinityPacer/MoviePilot-Plugins/a60089999fafc7ffb7143dc09d2a627e4d839f59/images/2024-05-09-23-29-47.png -------------------------------------------------------------------------------- /images/2024-05-09-23-30-39.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InfinityPacer/MoviePilot-Plugins/a60089999fafc7ffb7143dc09d2a627e4d839f59/images/2024-05-09-23-30-39.png -------------------------------------------------------------------------------- /images/2024-05-17-22-16-48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InfinityPacer/MoviePilot-Plugins/a60089999fafc7ffb7143dc09d2a627e4d839f59/images/2024-05-17-22-16-48.png -------------------------------------------------------------------------------- /images/2024-05-17-22-17-21.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InfinityPacer/MoviePilot-Plugins/a60089999fafc7ffb7143dc09d2a627e4d839f59/images/2024-05-17-22-17-21.png -------------------------------------------------------------------------------- /images/2024-05-24-00-29-38.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InfinityPacer/MoviePilot-Plugins/a60089999fafc7ffb7143dc09d2a627e4d839f59/images/2024-05-24-00-29-38.png -------------------------------------------------------------------------------- /images/2024-05-27-00-56-02.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InfinityPacer/MoviePilot-Plugins/a60089999fafc7ffb7143dc09d2a627e4d839f59/images/2024-05-27-00-56-02.png -------------------------------------------------------------------------------- /images/2024-05-27-00-56-38.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InfinityPacer/MoviePilot-Plugins/a60089999fafc7ffb7143dc09d2a627e4d839f59/images/2024-05-27-00-56-38.png -------------------------------------------------------------------------------- /images/2024-05-30-18-35-36.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InfinityPacer/MoviePilot-Plugins/a60089999fafc7ffb7143dc09d2a627e4d839f59/images/2024-05-30-18-35-36.png -------------------------------------------------------------------------------- /images/2024-06-02-20-39-05.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InfinityPacer/MoviePilot-Plugins/a60089999fafc7ffb7143dc09d2a627e4d839f59/images/2024-06-02-20-39-05.png -------------------------------------------------------------------------------- /images/2024-06-13-17-06-24.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InfinityPacer/MoviePilot-Plugins/a60089999fafc7ffb7143dc09d2a627e4d839f59/images/2024-06-13-17-06-24.png -------------------------------------------------------------------------------- /images/2024-06-15-00-25-20.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InfinityPacer/MoviePilot-Plugins/a60089999fafc7ffb7143dc09d2a627e4d839f59/images/2024-06-15-00-25-20.png -------------------------------------------------------------------------------- /images/2024-06-15-00-25-31.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InfinityPacer/MoviePilot-Plugins/a60089999fafc7ffb7143dc09d2a627e4d839f59/images/2024-06-15-00-25-31.png -------------------------------------------------------------------------------- /images/2024-06-15-00-28-07.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InfinityPacer/MoviePilot-Plugins/a60089999fafc7ffb7143dc09d2a627e4d839f59/images/2024-06-15-00-28-07.png -------------------------------------------------------------------------------- /images/2024-06-25-02-57-20.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InfinityPacer/MoviePilot-Plugins/a60089999fafc7ffb7143dc09d2a627e4d839f59/images/2024-06-25-02-57-20.png -------------------------------------------------------------------------------- /images/2024-06-25-02-57-53.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InfinityPacer/MoviePilot-Plugins/a60089999fafc7ffb7143dc09d2a627e4d839f59/images/2024-06-25-02-57-53.png -------------------------------------------------------------------------------- /images/2024-06-28-02-25-01.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InfinityPacer/MoviePilot-Plugins/a60089999fafc7ffb7143dc09d2a627e4d839f59/images/2024-06-28-02-25-01.png -------------------------------------------------------------------------------- /images/2024-07-04-01-57-02.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InfinityPacer/MoviePilot-Plugins/a60089999fafc7ffb7143dc09d2a627e4d839f59/images/2024-07-04-01-57-02.png -------------------------------------------------------------------------------- /images/2024-07-04-02-11-17.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InfinityPacer/MoviePilot-Plugins/a60089999fafc7ffb7143dc09d2a627e4d839f59/images/2024-07-04-02-11-17.png -------------------------------------------------------------------------------- /images/2024-07-07-03-30-56.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InfinityPacer/MoviePilot-Plugins/a60089999fafc7ffb7143dc09d2a627e4d839f59/images/2024-07-07-03-30-56.png -------------------------------------------------------------------------------- /images/2024-07-07-03-34-15.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InfinityPacer/MoviePilot-Plugins/a60089999fafc7ffb7143dc09d2a627e4d839f59/images/2024-07-07-03-34-15.png -------------------------------------------------------------------------------- /images/2024-07-07-03-35-26.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InfinityPacer/MoviePilot-Plugins/a60089999fafc7ffb7143dc09d2a627e4d839f59/images/2024-07-07-03-35-26.png -------------------------------------------------------------------------------- /images/2024-07-07-03-36-08.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InfinityPacer/MoviePilot-Plugins/a60089999fafc7ffb7143dc09d2a627e4d839f59/images/2024-07-07-03-36-08.png -------------------------------------------------------------------------------- /images/2024-07-07-03-36-49.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InfinityPacer/MoviePilot-Plugins/a60089999fafc7ffb7143dc09d2a627e4d839f59/images/2024-07-07-03-36-49.png -------------------------------------------------------------------------------- /images/2024-07-07-03-37-10.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InfinityPacer/MoviePilot-Plugins/a60089999fafc7ffb7143dc09d2a627e4d839f59/images/2024-07-07-03-37-10.png -------------------------------------------------------------------------------- /images/2024-07-13-00-43-36.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InfinityPacer/MoviePilot-Plugins/a60089999fafc7ffb7143dc09d2a627e4d839f59/images/2024-07-13-00-43-36.png -------------------------------------------------------------------------------- /images/2024-07-13-00-44-06.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InfinityPacer/MoviePilot-Plugins/a60089999fafc7ffb7143dc09d2a627e4d839f59/images/2024-07-13-00-44-06.png -------------------------------------------------------------------------------- /images/2024-07-13-00-47-11.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InfinityPacer/MoviePilot-Plugins/a60089999fafc7ffb7143dc09d2a627e4d839f59/images/2024-07-13-00-47-11.png -------------------------------------------------------------------------------- /images/2024-07-13-03-00-07.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InfinityPacer/MoviePilot-Plugins/a60089999fafc7ffb7143dc09d2a627e4d839f59/images/2024-07-13-03-00-07.png -------------------------------------------------------------------------------- /images/2024-07-13-03-02-10.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InfinityPacer/MoviePilot-Plugins/a60089999fafc7ffb7143dc09d2a627e4d839f59/images/2024-07-13-03-02-10.png -------------------------------------------------------------------------------- /images/2024-07-20-01-19-39.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InfinityPacer/MoviePilot-Plugins/a60089999fafc7ffb7143dc09d2a627e4d839f59/images/2024-07-20-01-19-39.png -------------------------------------------------------------------------------- /images/2024-07-25-00-23-35.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InfinityPacer/MoviePilot-Plugins/a60089999fafc7ffb7143dc09d2a627e4d839f59/images/2024-07-25-00-23-35.png -------------------------------------------------------------------------------- /images/2024-07-25-00-24-34.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InfinityPacer/MoviePilot-Plugins/a60089999fafc7ffb7143dc09d2a627e4d839f59/images/2024-07-25-00-24-34.png -------------------------------------------------------------------------------- /images/2024-07-25-00-32-08.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InfinityPacer/MoviePilot-Plugins/a60089999fafc7ffb7143dc09d2a627e4d839f59/images/2024-07-25-00-32-08.png -------------------------------------------------------------------------------- /images/2024-07-25-00-35-04.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InfinityPacer/MoviePilot-Plugins/a60089999fafc7ffb7143dc09d2a627e4d839f59/images/2024-07-25-00-35-04.png -------------------------------------------------------------------------------- /images/2024-08-04-02-35-31.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InfinityPacer/MoviePilot-Plugins/a60089999fafc7ffb7143dc09d2a627e4d839f59/images/2024-08-04-02-35-31.png -------------------------------------------------------------------------------- /images/2024-08-14-20-26-08.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InfinityPacer/MoviePilot-Plugins/a60089999fafc7ffb7143dc09d2a627e4d839f59/images/2024-08-14-20-26-08.png -------------------------------------------------------------------------------- /images/2024-08-14-20-27-09.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InfinityPacer/MoviePilot-Plugins/a60089999fafc7ffb7143dc09d2a627e4d839f59/images/2024-08-14-20-27-09.png -------------------------------------------------------------------------------- /images/2024-12-28-01-11-11.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InfinityPacer/MoviePilot-Plugins/a60089999fafc7ffb7143dc09d2a627e4d839f59/images/2024-12-28-01-11-11.png -------------------------------------------------------------------------------- /images/2024-12-28-01-15-35.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InfinityPacer/MoviePilot-Plugins/a60089999fafc7ffb7143dc09d2a627e4d839f59/images/2024-12-28-01-15-35.png -------------------------------------------------------------------------------- /images/2024-12-28-01-19-13.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InfinityPacer/MoviePilot-Plugins/a60089999fafc7ffb7143dc09d2a627e4d839f59/images/2024-12-28-01-19-13.png -------------------------------------------------------------------------------- /images/2024-12-28-01-19-30.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InfinityPacer/MoviePilot-Plugins/a60089999fafc7ffb7143dc09d2a627e4d839f59/images/2024-12-28-01-19-30.png -------------------------------------------------------------------------------- /images/2024-12-28-01-19-51.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InfinityPacer/MoviePilot-Plugins/a60089999fafc7ffb7143dc09d2a627e4d839f59/images/2024-12-28-01-19-51.png -------------------------------------------------------------------------------- /images/2024-12-28-01-21-45.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InfinityPacer/MoviePilot-Plugins/a60089999fafc7ffb7143dc09d2a627e4d839f59/images/2024-12-28-01-21-45.png -------------------------------------------------------------------------------- /images/2024-12-28-01-21-56.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InfinityPacer/MoviePilot-Plugins/a60089999fafc7ffb7143dc09d2a627e4d839f59/images/2024-12-28-01-21-56.png -------------------------------------------------------------------------------- /images/2024-12-28-01-22-59.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InfinityPacer/MoviePilot-Plugins/a60089999fafc7ffb7143dc09d2a627e4d839f59/images/2024-12-28-01-22-59.png -------------------------------------------------------------------------------- /images/2024-12-28-01-23-07.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InfinityPacer/MoviePilot-Plugins/a60089999fafc7ffb7143dc09d2a627e4d839f59/images/2024-12-28-01-23-07.png -------------------------------------------------------------------------------- /images/2024-12-28-01-25-20.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InfinityPacer/MoviePilot-Plugins/a60089999fafc7ffb7143dc09d2a627e4d839f59/images/2024-12-28-01-25-20.png -------------------------------------------------------------------------------- /plugins.v2/boss/__init__.py: -------------------------------------------------------------------------------- 1 | import threading 2 | from datetime import datetime, timedelta 3 | from typing import Any, Dict, List, Tuple 4 | 5 | import pytz 6 | from apscheduler.schedulers.background import BackgroundScheduler 7 | 8 | from app.core.config import settings 9 | from app.log import logger 10 | from app.plugins import _PluginBase 11 | from app.plugins.boss.douban import run_batch 12 | 13 | lock = threading.Lock() 14 | 15 | 16 | class BOSS(_PluginBase): 17 | # 插件名称 18 | plugin_name = "个人测试" 19 | # 插件描述 20 | plugin_desc = "测试插件,请勿使用。" 21 | # 插件图标 22 | plugin_icon = "https://raw.githubusercontent.com/InfinityPacer/MoviePilot-Plugins/main/icons/boss.png" 23 | # 插件版本 24 | plugin_version = "0.0.1" 25 | # 插件作者 26 | plugin_author = "InfinityPacer" 27 | # 作者主页 28 | author_url = "https://github.com/InfinityPacer" 29 | # 插件配置项ID前缀 30 | plugin_config_prefix = "boss_" 31 | # 加载顺序 32 | plugin_order = 1 33 | # 可使用的用户级别 34 | auth_level = 1 35 | 36 | # region 私有属性 37 | # 是否开启 38 | _enabled = False 39 | # 是否立即运行一次 40 | _onlyonce = False 41 | # 定时器 42 | _scheduler = None 43 | 44 | # endregion 45 | 46 | def init_plugin(self, config: dict = None): 47 | if not config: 48 | return 49 | 50 | self._onlyonce = config.get("onlyonce", False) 51 | 52 | self._scheduler = BackgroundScheduler(timezone=settings.TZ) 53 | self._scheduler.start() 54 | if self._onlyonce: 55 | logger.info("个人测试,立即运行一次") 56 | self._scheduler.add_job( 57 | func=run_batch, 58 | trigger="date", 59 | run_date=datetime.now(tz=pytz.timezone(settings.TZ)) + timedelta(seconds=3), 60 | name="订阅助手", 61 | ) 62 | self._onlyonce = False 63 | config["onlyonce"] = self._onlyonce 64 | self.update_config(config=config) 65 | 66 | def get_state(self) -> bool: 67 | return self._enabled 68 | 69 | @staticmethod 70 | def get_command() -> List[Dict[str, Any]]: 71 | """ 72 | 定义远程控制命令 73 | :return: 命令关键字、事件、描述、附带数据 74 | """ 75 | pass 76 | 77 | def get_api(self) -> List[Dict[str, Any]]: 78 | pass 79 | 80 | def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: 81 | """ 82 | 拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构 83 | """ 84 | return [ 85 | { 86 | 'component': 'VForm', 87 | 'content': [ 88 | { 89 | 'component': 'VRow', 90 | 'content': [ 91 | { 92 | 'component': 'VCol', 93 | 'props': { 94 | 'cols': 12, 95 | 'md': 4 96 | }, 97 | 'content': [ 98 | { 99 | 'component': 'VSwitch', 100 | 'props': { 101 | 'model': 'onlyonce', 102 | 'label': '立即运行一次', 103 | 'hint': '插件将立即运行一次', 104 | 'persistent-hint': True 105 | } 106 | } 107 | ] 108 | } 109 | ] 110 | } 111 | ] 112 | } 113 | ], { 114 | } 115 | 116 | def get_page(self) -> List[dict]: 117 | pass 118 | 119 | def get_service(self) -> List[Dict[str, Any]]: 120 | """ 121 | 注册插件公共服务 122 | [{ 123 | "id": "服务ID", 124 | "name": "服务名称", 125 | "trigger": "触发器:cron/interval/date/CronTrigger.from_crontab()", 126 | "func": self.xxx, 127 | "kwargs": {} # 定时器参数 128 | }] 129 | """ 130 | pass 131 | 132 | def stop_service(self): 133 | """ 134 | 退出插件 135 | """ 136 | pass 137 | -------------------------------------------------------------------------------- /plugins.v2/boss/douban.py: -------------------------------------------------------------------------------- 1 | import time 2 | from concurrent.futures import ThreadPoolExecutor, as_completed 3 | from typing import List, Any 4 | 5 | from app.chain.douban import DoubanChain 6 | from app.log import logger 7 | from app.schemas import MediaType 8 | 9 | 10 | # 计时装饰器 11 | def timing(func): 12 | def wrapper(*args, **kwargs): 13 | start_time = time.time() 14 | result = func(*args, **kwargs) 15 | end_time = time.time() 16 | logger.info(f"{func.__name__} 耗时: {end_time - start_time:.2f} 秒") 17 | return result 18 | 19 | return wrapper 20 | 21 | 22 | # 具体的接口方法 23 | @timing 24 | def movie_showing(page: int = 1, count: int = 30) -> Any: 25 | movies = DoubanChain().movie_showing(page=page, count=count) 26 | logger.info(f"movie_showing: {movies}") 27 | if movies: 28 | return [media.to_dict() for media in movies] 29 | return [] 30 | 31 | 32 | @timing 33 | def douban_movies(sort: str = "R", tags: str = "", page: int = 1, count: int = 30) -> Any: 34 | movies = DoubanChain().douban_discover(mtype=MediaType.MOVIE, 35 | sort=sort, tags=tags, page=page, count=count) 36 | logger.info(f"douban_movies: {movies}") 37 | if movies: 38 | return [media.to_dict() for media in movies] 39 | return [] 40 | 41 | 42 | @timing 43 | def douban_tvs(sort: str = "R", tags: str = "", page: int = 1, count: int = 30) -> Any: 44 | tvs = DoubanChain().douban_discover(mtype=MediaType.TV, 45 | sort=sort, tags=tags, page=page, count=count) 46 | logger.info(f"douban_tvs: {tvs}") 47 | if tvs: 48 | return [media.to_dict() for media in tvs] 49 | return [] 50 | 51 | 52 | @timing 53 | def movie_top250(page: int = 1, count: int = 30) -> Any: 54 | movies = DoubanChain().movie_top250(page=page, count=count) 55 | logger.info(f"movie_top250: {movies}") 56 | if movies: 57 | return [media.to_dict() for media in movies] 58 | return [] 59 | 60 | 61 | @timing 62 | def tv_weekly_chinese(page: int = 1, count: int = 30) -> Any: 63 | tvs = DoubanChain().tv_weekly_chinese(page=page, count=count) 64 | logger.info(f"tv_weekly_chinese: {tvs}") 65 | if tvs: 66 | return [media.to_dict() for media in tvs] 67 | return [] 68 | 69 | 70 | @timing 71 | def tv_weekly_global(page: int = 1, count: int = 30) -> Any: 72 | tvs = DoubanChain().tv_weekly_global(page=page, count=count) 73 | logger.info(f"tv_weekly_global: {tvs}") 74 | if tvs: 75 | return [media.to_dict() for media in tvs] 76 | return [] 77 | 78 | 79 | @timing 80 | def tv_animation(page: int = 1, count: int = 30) -> Any: 81 | tvs = DoubanChain().tv_animation(page=page, count=count) 82 | logger.info(f"tv_animation: {tvs}") 83 | if tvs: 84 | return [media.to_dict() for media in tvs] 85 | return [] 86 | 87 | 88 | @timing 89 | def movie_hot(page: int = 1, count: int = 30) -> Any: 90 | movies = DoubanChain().movie_hot(page=page, count=count) 91 | logger.info(f"movie_hot: {movies}") 92 | if movies: 93 | return [media.to_dict() for media in movies] 94 | return [] 95 | 96 | 97 | @timing 98 | def tv_hot(page: int = 1, count: int = 30) -> Any: 99 | tvs = DoubanChain().tv_hot(page=page, count=count) 100 | logger.info(f"tv_hot: {tvs}") 101 | if tvs: 102 | return [media.to_dict() for media in tvs] 103 | return [] 104 | 105 | 106 | # 批量调用方法,使用多线程并发执行 107 | def batch_call_methods(methods: List[Any], *args, **kwargs): 108 | with ThreadPoolExecutor() as executor: 109 | # 提交任务 110 | future_to_method = {executor.submit(method, *args, **kwargs): method for method in methods} 111 | 112 | for future in as_completed(future_to_method): 113 | method = future_to_method[future] 114 | try: 115 | result = future.result() # 获取方法执行结果 116 | logger.info(f"{method.__name__} 执行成功") 117 | except Exception as exc: 118 | logger.info(f"{method.__name__} 执行出错: {exc}") 119 | 120 | 121 | # 使用多线程批量调用 122 | def run_batch(): 123 | methods = [ 124 | movie_showing, 125 | douban_movies, 126 | douban_tvs, 127 | movie_top250, 128 | tv_weekly_chinese, 129 | tv_weekly_global, 130 | tv_animation, 131 | movie_hot, 132 | tv_hot 133 | ] 134 | # 在此处传递通用参数 135 | batch_call_methods(methods, page=1, count=30) 136 | -------------------------------------------------------------------------------- /plugins.v2/hitandrun/entities.py: -------------------------------------------------------------------------------- 1 | import json 2 | import time 3 | from datetime import datetime 4 | from enum import Enum 5 | from typing import Optional 6 | 7 | import pytz 8 | from pydantic import BaseModel, Field 9 | 10 | from app.core.config import settings 11 | from app.core.context import TorrentInfo 12 | 13 | 14 | class HNRStatus(Enum): 15 | PENDING = "Pending" # 待确认,等待进行做种或上传下载比的验证 16 | IN_PROGRESS = "In Progress" # 进行中,用户正在努力满足做种或分享率要求 17 | COMPLIANT = "Compliant" # 已满足,用户已成功满足所有做种和分享率要求 18 | UNRESTRICTED = "Unrestricted" # 无限制,用户没有任何做种或分享率限制 19 | NEEDS_SEEDING = "Needs Seeding" # 需要做种,用户需要增加做种时间来避免受到惩罚 20 | OVERDUE = "Overdue" # 已过期,用户已超过做种期限但未满足要求 21 | WARNED = "Warned" # 已警告,用户因未达到做种要求而收到警告 22 | BANNED = "Banned" # 已封禁,用户因严重违反做种规则被封禁 23 | 24 | def to_chinese(self): 25 | descriptions = { 26 | "Pending": "待确认", 27 | "In Progress": "进行中", 28 | "Compliant": "已满足", 29 | "Unrestricted": "无限制", 30 | "Needs Seeding": "需要做种", 31 | "Overdue": "已过期", 32 | "Warned": "已警告", 33 | "Banned": "已封禁" 34 | } 35 | return descriptions[self.value] 36 | 37 | 38 | class TaskType(Enum): 39 | BRUSH = "Brush" # 刷流 40 | NORMAL = "Normal" # 普通 41 | AUTO_SUBSCRIBE = "Auto Subscribe" # 自动订阅 42 | RSS_SUBSCRIBE = "RSS Subscribe" # RSS订阅 43 | 44 | def to_chinese(self): 45 | descriptions = { 46 | "Brush": "刷流", 47 | "Normal": "普通", 48 | "Auto Subscribe": "自动订阅", 49 | "RSS Subscribe": "RSS订阅" 50 | } 51 | return descriptions[self.value] 52 | 53 | 54 | class TorrentHistory(BaseModel): 55 | site: Optional[int] = None # 站点ID 56 | site_name: Optional[str] = None # 站点名称 57 | title: Optional[str] = None # 种子名称 58 | description: Optional[str] = None # 种子副标题 59 | enclosure: Optional[str] = None # 种子链接 60 | page_url: Optional[str] = None # 详情页面 61 | size: float = 0 # 种子大小 62 | pubdate: Optional[str] = None # 发布时间 63 | hit_and_run: bool = False # HR 64 | time: Optional[float] = Field(default_factory=time.time) 65 | hash: Optional[str] = None # 种子Hash 66 | task_type: TaskType = TaskType.NORMAL # 任务类型 67 | 68 | @classmethod 69 | def from_torrent_info(cls, torrent_info: TorrentInfo): 70 | """通过TorrentInfo实例化""" 71 | # 使用字典解包初始化TorrentTask 72 | return cls(**torrent_info.__dict__) 73 | 74 | # 模型配置 75 | class Config: 76 | extra = "ignore" 77 | arbitrary_types_allowed = True 78 | 79 | def to_dict(self, **kwargs): 80 | """ 81 | 返回字典 82 | """ 83 | json_str = self.json(**kwargs) 84 | instance = json.loads(json_str) 85 | return instance 86 | 87 | @classmethod 88 | def from_dict(cls, data: dict): 89 | """ 90 | 实例化 91 | """ 92 | config_json = json.dumps(data) 93 | return cls.parse_raw(config_json) 94 | 95 | 96 | class TorrentTask(TorrentHistory): 97 | hr_status: Optional[HNRStatus] = HNRStatus.PENDING # H&R状态 98 | hr_duration: Optional[float] = None # H&R时间(小时) 99 | hr_ratio: Optional[float] = None # H&R分享率 100 | hr_deadline_days: Optional[float] = None # H&R满足要求的期限(天数) 101 | ratio: Optional[float] = 0.0 # 分享率 102 | downloaded: Optional[float] = 0.0 # 下载量 103 | uploaded: Optional[float] = 0.0 # 上传量 104 | seeding_time: Optional[float] = 0.0 # 做种时间(秒) 105 | deleted: Optional[bool] = False # 是否已删除 106 | time: Optional[float] = Field(default_factory=time.time) # 任务创建时间 107 | deleted_time: Optional[float] = None # 种子删除时间 108 | hr_met_time: Optional[float] = None # 满足H&R要求的时间 109 | 110 | @property 111 | def identifier(self) -> str: 112 | """ 113 | 获取种子标识符 114 | """ 115 | parts = [self.title, self.description] 116 | return " | ".join(part.strip() for part in parts if part and part.strip()) 117 | 118 | @property 119 | def deadline_time(self) -> float: 120 | """ 121 | 获取截止时间的 Unix 时间戳 122 | """ 123 | deadline_time = self.time + self.hr_deadline_days * 86400 124 | return deadline_time 125 | 126 | def formatted_deadline(self) -> str: 127 | """ 128 | 获取格式化的截止时间 129 | """ 130 | deadline_time_local = datetime.fromtimestamp(self.deadline_time, pytz.timezone(settings.TZ)) 131 | return deadline_time_local.strftime('%Y-%m-%d %H:%M') 132 | 133 | def remain_time(self, additional_seed_time: Optional[float] = 0.0) -> Optional[float]: 134 | """ 135 | 剩余时间(小时) 136 | """ 137 | if self.hr_status in [HNRStatus.COMPLIANT, HNRStatus.UNRESTRICTED]: 138 | return None 139 | # 计算所需做种总时间(小时) 140 | required_seeding_hours = (self.hr_duration or 0) + (additional_seed_time or 0) 141 | # 计算已做种时间(小时) 142 | seeding_hours = self.seeding_time / 3600 if self.seeding_time else 0 143 | # 计算剩余做种时间 144 | remain_hours = required_seeding_hours - seeding_hours 145 | # 确保剩余时间不小于0 146 | return max(remain_hours, 0) 147 | 148 | @staticmethod 149 | def format_to_chinese(value): 150 | return value.to_chinese() if hasattr(value, "to_chinese") else value 151 | -------------------------------------------------------------------------------- /plugins.v2/hitandrun/hnrconfig.py: -------------------------------------------------------------------------------- 1 | import json 2 | from enum import Enum 3 | from typing import Optional, List, Dict 4 | 5 | from pydantic import BaseModel, root_validator, validator 6 | from ruamel.yaml import YAML, YAMLError 7 | 8 | from app.log import logger 9 | 10 | 11 | class NotifyMode(Enum): 12 | NONE = "none" # 不发送 13 | ON_ERROR = "on_error" # 仅异常时发送 14 | ALWAYS = "always" # 发送所有通知 15 | 16 | 17 | class BaseConfig(BaseModel): 18 | """ 19 | 基础配置类,定义所有配置项的结构 20 | """ 21 | hr_duration: Optional[float] = None # H&R时间(小时) 22 | additional_seed_time: Optional[float] = None # 附加做种时间(小时) 23 | hr_ratio: Optional[float] = None # H&R分享率 24 | hr_active: Optional[bool] = False # H&R激活 25 | hr_deadline_days: Optional[float] = None # H&R满足要求的期限(天数) 26 | 27 | # 模型配置 28 | class Config: 29 | extra = "ignore" 30 | arbitrary_types_allowed = True 31 | 32 | @staticmethod 33 | def json_dumps(v, *, default): 34 | return json.dumps(v, ensure_ascii=False, default=default) 35 | 36 | def to_dict(self, **kwargs): 37 | """ 38 | 返回字典 39 | """ 40 | config_json = self.json(**kwargs) 41 | config_mapping = json.loads(config_json) 42 | return config_mapping 43 | 44 | @property 45 | def hr_seed_time(self) -> Optional[float]: 46 | """ 47 | H&R做种时间(小时) 48 | """ 49 | return (self.hr_duration or 0.0) + (self.additional_seed_time or 0.0) 50 | 51 | 52 | class SiteConfig(BaseConfig): 53 | """ 54 | 站点配置类,继承自基础配置类,添加站点特有的标识属性 55 | """ 56 | site_name: Optional[str] = None # 站点名称 57 | 58 | 59 | class HNRConfig(BaseConfig): 60 | """ 61 | 全局配置类,继承自基础配置类,添加全局特有的配置项 62 | """ 63 | enabled: Optional[bool] = False # 启用插件 64 | check_period: int = 5 # 检查周期 65 | sites: List[int] = [] # 站点列表 66 | site_infos: Dict = {} # 站点信息字典 67 | onlyonce: Optional[bool] = False # 立即运行一次 68 | notify: NotifyMode = NotifyMode.ALWAYS # 发送通知的模式 69 | brush_plugin: Optional[str] = None # 站点刷流插件 70 | auto_monitor: Optional[bool] = False # 自动监控(实验性功能) 71 | downloader: Optional[str] = None # 下载器 72 | hit_and_run_tag: Optional[str] = None # 种子标签 73 | auto_cleanup_days: float = 7 # 自动清理已删除或满足H&R要求的任务 74 | enable_site_config: Optional[bool] = False # 启用站点独立配置 75 | site_config_str: Optional[str] = None # 站点独立配置的字符串 76 | site_configs: Dict[str, SiteConfig] = {} # 站点独立配置(根据配置字符串解析后的字典) 77 | 78 | @root_validator(pre=True, allow_reuse=True) 79 | def __check_enums(cls, values): 80 | """校验枚举值""" 81 | # 处理 notify 字段 82 | notify_value = values.get("notify") 83 | all_values = {member.value for member in NotifyMode} 84 | if notify_value not in all_values: 85 | values["notify"] = NotifyMode.ALWAYS 86 | return values 87 | 88 | @validator("*", pre=True, allow_reuse=True) 89 | def __empty_string_to_float(cls, v, values, field): 90 | """ 91 | 校验空字符 92 | """ 93 | if field.type_ is float and not v: 94 | return 0.0 95 | return v 96 | 97 | @validator("auto_cleanup_days", pre=True, allow_reuse=True) 98 | def set_default_auto_cleanup_days(cls, v): 99 | """ 100 | 当 auto_cleanup_days 为 None 时,设置为 7 101 | """ 102 | if v is None: 103 | return 7 104 | return v 105 | 106 | def __init__(self, **data): 107 | super().__init__(**data) 108 | self.__post_init__() 109 | 110 | def __post_init__(self): 111 | """ 112 | 初始化完成 113 | """ 114 | self.__process_site_configs() 115 | 116 | def __process_site_configs(self): 117 | """ 118 | 校验并解析站点独立配置 119 | """ 120 | if self.enable_site_config: 121 | if self.site_config_str: 122 | self.site_configs = self.__parse_yaml_config(self.site_config_str) 123 | if self.site_configs: 124 | for site_name, site_config in self.site_configs.items(): 125 | self.site_configs[site_name] = self.__merge_site_config(site_config=site_config) 126 | else: 127 | logger.error("YAML解析失败,站点独立配置已禁用") 128 | self.enable_site_config = False 129 | else: 130 | logger.warning("已启用站点独立配置,但未提供配置字符串,站点独立配置已禁用") 131 | self.enable_site_config = False 132 | 133 | @staticmethod 134 | def __parse_yaml_config(yaml_str: str) -> Optional[Dict[str, SiteConfig]]: 135 | """ 136 | 解析YAML字符串为站点配置字典 137 | """ 138 | yaml = YAML(typ="safe") 139 | try: 140 | data = yaml.load(yaml_str) 141 | site_configs = {} 142 | for item in data: 143 | site_name = item.get("site_name") 144 | if site_name: 145 | try: 146 | site_configs[site_name] = SiteConfig(**item) 147 | except Exception as e: 148 | logger.error(f"站点 {site_name} 无效,忽略该站点配置,{e}") 149 | return site_configs 150 | except YAMLError as e: 151 | logger.error(f"无法获取站点独立配置信息,YAML解析错误: {e}") 152 | return None 153 | 154 | def __merge_site_config(self, site_config: SiteConfig) -> SiteConfig: 155 | """ 156 | 合并站点配置 157 | """ 158 | for field_name, field_info in SiteConfig.__fields__.items(): 159 | # 获取当前 site_config 对象中的字段值 160 | current_value = getattr(site_config, field_name, None) 161 | # 如果当前字段值为 None,则尝试从 HNRConfig 实例中获取同名字段的默认值 162 | if current_value is None: 163 | # 尝试从 HNRConfig 实例获取默认值,如果不存在则使用 Pydantic 字段的默认值 164 | default_value = getattr(self, field_name, field_info.default) 165 | # 设置 site_config 对象的字段值 166 | setattr(site_config, field_name, default_value) 167 | 168 | return site_config 169 | 170 | def get_site_config(self, site_name: str) -> SiteConfig: 171 | """ 172 | 根据站点名称返回合并后的配置 173 | """ 174 | site_config = self.site_configs.get(site_name) 175 | if site_config: 176 | return site_config 177 | else: 178 | # 使用 __fields__ 获取所有字段并从实例中获取对应值 179 | base_config_attrs = {field: getattr(self, field) for field in self.__fields__} 180 | return SiteConfig(**base_config_attrs, site_name=site_name) 181 | -------------------------------------------------------------------------------- /plugins.v2/hitandrun/rule.yaml: -------------------------------------------------------------------------------- 1 | ####### 配置说明 BEGIN ####### 2 | # 1. 此配置文件专门用于设定各站点的特定配置,包括做种时间、H&R激活状态等。 3 | # 2. 配置项通过数组形式组织,每个站点的配置作为数组的一个元素,以‘-’标记开头。 4 | # 3. 如果某站点的具体配置项与全局配置相同,则无需单独设置该项,默认采用全局配置。 5 | ####### 配置说明 END ####### 6 | 7 | - # 站点名称,用于标识适用于哪个站点 8 | site_name: '彩虹岛' 9 | # H&R时间(小时),站点默认的H&R时间,做种时间达到H&R时间后移除标签 10 | hr_duration: 120.0 11 | # 附加做种时间(小时),在H&R时间上额外增加的做种时长 12 | # additional_seed_time: 24.0 13 | # 分享率,做种时期望达到的分享比例,达到目标分享率后移除标签 14 | hr_ratio: 99.0 15 | # H&R激活,站点是否已启用全站H&R,开启后所有种子均视为H&R种子 16 | hr_active: false 17 | # H&R满足要求的期限(天数),需在此天数内满足H&R要求 18 | hr_deadline_days: 20 19 | 20 | - # 站点名称,用于标识适用于哪个站点 21 | site_name: '皇后' 22 | # H&R时间(小时),站点默认的H&R时间,做种时间达到H&R时间后移除标签 23 | hr_duration: 36.0 24 | # 附加做种时间(小时),在H&R时间上额外增加的做种时长 25 | # additional_seed_time: 24.0 (与全局配置保持一致,无需单独设置,注释处理) 26 | # 分享率,做种时期望达到的分享比例,达到目标分享率后移除标签 27 | # hr_ratio: 99.0(与全局配置保持一致,无需单独设置,注释处理) 28 | # H&R激活,站点是否已启用全站H&R,开启后所有种子均视为H&R种子 29 | hr_active: true 30 | # H&R满足要求的期限(天数),需在此天数内满足H&R要求 31 | hr_deadline_days: 30 32 | 33 | - # 站点名称,用于标识适用于哪个站点 34 | site_name: '观众' 35 | # H&R时间(小时),站点默认的H&R时间,做种时间达到H&R时间后移除标签 36 | hr_duration: 48.0 37 | # 附加做种时间(小时),在H&R时间上额外增加的做种时长 38 | # additional_seed_time: 24.0 (与全局配置保持一致,无需单独设置,注释处理) 39 | # 分享率,做种时期望达到的分享比例,达到目标分享率后移除标签 40 | # hr_ratio: 99.0(与全局配置保持一致,无需单独设置,注释处理) 41 | # H&R激活,站点是否已启用全站H&R,开启后所有种子均视为H&R种子 42 | hr_active: false 43 | # H&R满足要求的期限(天数),需在此天数内满足H&R要求 44 | hr_deadline_days: 14 45 | 46 | - # 站点名称,用于标识适用于哪个站点 47 | site_name: '听听歌' 48 | # H&R时间(小时),站点默认的H&R时间,做种时间达到H&R时间后移除标签 49 | hr_duration: 60.0 50 | # 附加做种时间(小时),在H&R时间上额外增加的做种时长 51 | # additional_seed_time: 24.0 (与全局配置保持一致,无需单独设置,注释处理) 52 | # 分享率,做种时期望达到的分享比例,达到目标分享率后移除标签 53 | # hr_ratio: 99.0(与全局配置保持一致,无需单独设置,注释处理) 54 | # H&R激活,站点是否已启用全站H&R,开启后所有种子均视为H&R种子 55 | hr_active: false 56 | # H&R满足要求的期限(天数),需在此天数内满足H&R要求 57 | hr_deadline_days: 14 58 | 59 | - # 站点名称,用于标识适用于哪个站点 60 | site_name: '家园' 61 | # H&R时间(小时),站点默认的H&R时间,做种时间达到H&R时间后移除标签 62 | hr_duration: 336.0 63 | # 附加做种时间(小时),在H&R时间上额外增加的做种时长 64 | # additional_seed_time: 24.0 (与全局配置保持一致,无需单独设置,注释处理) 65 | # 分享率,做种时期望达到的分享比例,达到目标分享率后移除标签 66 | # hr_ratio: 99.0(与全局配置保持一致,无需单独设置,注释处理) 67 | # H&R激活,站点是否已启用全站H&R,开启后所有种子均视为H&R种子 68 | hr_active: false 69 | # H&R满足要求的期限(天数),需在此天数内满足H&R要求 70 | hr_deadline_days: 60 71 | 72 | - # 站点名称,用于标识适用于哪个站点 73 | site_name: '我堡' 74 | # H&R时间(小时),站点默认的H&R时间,做种时间达到H&R时间后移除标签 75 | hr_duration: 48.0 76 | # 附加做种时间(小时),在H&R时间上额外增加的做种时长 77 | # additional_seed_time: 24.0 (与全局配置保持一致,无需单独设置,注释处理) 78 | # 分享率,做种时期望达到的分享比例,达到目标分享率后移除标签 79 | # hr_ratio: 99.0(与全局配置保持一致,无需单独设置,注释处理) 80 | # H&R激活,站点是否已启用全站H&R,开启后所有种子均视为H&R种子 81 | hr_active: false 82 | # H&R满足要求的期限(天数),需在此天数内满足H&R要求 83 | hr_deadline_days: 14 84 | 85 | - # 站点名称,用于标识适用于哪个站点 86 | site_name: '高清杜比' 87 | # H&R时间(小时),站点默认的H&R时间,做种时间达到H&R时间后移除标签 88 | hr_duration: 48.0 89 | # 附加做种时间(小时),在H&R时间上额外增加的做种时长 90 | # additional_seed_time: 24.0 (与全局配置保持一致,无需单独设置,注释处理) 91 | # 分享率,做种时期望达到的分享比例,达到目标分享率后移除标签 92 | hr_ratio: 1.6 93 | # H&R激活,站点是否已启用全站H&R,开启后所有种子均视为H&R种子 94 | hr_active: false 95 | # H&R满足要求的期限(天数),需在此天数内满足H&R要求 96 | hr_deadline_days: 30 97 | 98 | - # 站点名称,用于标识适用于哪个站点 99 | site_name: '憨憨' 100 | # H&R时间(小时),站点默认的H&R时间,做种时间达到H&R时间后移除标签 101 | hr_duration: 72.0 102 | # 附加做种时间(小时),在H&R时间上额外增加的做种时长 103 | # additional_seed_time: 24.0 (与全局配置保持一致,无需单独设置,注释处理) 104 | # 分享率,做种时期望达到的分享比例,达到目标分享率后移除标签 105 | hr_ratio: 99 106 | # H&R激活,站点是否已启用全站H&R,开启后所有种子均视为H&R种子 107 | hr_active: false 108 | # H&R满足要求的期限(天数),需在此天数内满足H&R要求 109 | hr_deadline_days: 20 -------------------------------------------------------------------------------- /plugins.v2/plexautolanguages/config/default.yaml: -------------------------------------------------------------------------------- 1 | plexautolanguages: 2 | # 更新整个剧集的语言或仅更新当前季 3 | # 可接受的值: 4 | # - show(默认) 5 | # - season 6 | update_level: "show" 7 | 8 | # 更新剧集/季的所有剧集或仅更新接下来的剧集 9 | # 可接受的值: 10 | # - all(默认) 11 | # - next 12 | update_strategy: "all" 13 | 14 | # 播放文件是否触发语言更新,默认为 'true' 15 | trigger_on_play: true 16 | 17 | # 扫描新文件时是否触发语言更新,默认为 'true' 18 | # 新增的剧集将根据最近观看的剧集更新语言,若该剧从未被观看则根据第一集更新语言 19 | trigger_on_scan: true 20 | 21 | # 浏览 Plex 库是否触发语言更新,默认为 'false' 22 | # 仅 Plex 网络客户端和 Plex for Windows 应用程序支持此功能 23 | # 仅当您希望在更新剧集的默认轨道时执行更改,即使未播放该剧集时,才将此参数设置为 'true' 24 | # 将此参数设置为 'true' 可能会导致更高的资源使用 25 | trigger_on_activity: false 26 | 27 | # 每当 Plex 服务器扫描其库时是否刷新缓存库,默认为 'true' 28 | # 禁用此参数将阻止 PlexAutoLanguages 检测已存在剧集的更新文件 29 | # 如果您的电视剧库很大(10k+ 剧集),建议禁用此参数 30 | refresh_library_on_scan: false 31 | 32 | # PlexAutoLanguages 将忽略具有以下任何 Plex 标签的剧集 33 | ignore_labels: 34 | - PAL_IGNORE 35 | 36 | # Plex 配置 37 | plex: 38 | # 一个有效的 Plex URL(必填) 39 | url: "http://plex:32400" 40 | # 一个有效的 Plex 令牌(必填) 41 | token: "MY_PLEX_TOKEN" 42 | 43 | scheduler: 44 | # 是否启用调度程序,默认为 'true' 45 | # 调度程序将对所有最近播放的电视剧进行更深入的分析 46 | enable: false 47 | # 调度程序开始任务的时间,格式为 'HH:MM',默认为 '02:00' 48 | schedule_time: "04:30" 49 | 50 | notifications: 51 | # 是否通过 Apprise 启用通知,默认为 'false' 52 | # 每当进行语言更改时发送通知 53 | enable: false 54 | # Apprise 配置的数组,详见 Apprise 文档:https://github.com/caronc/apprise 55 | # 数组 'users' 可以指定以将通知 URL 与特定用户关联 56 | # 如果不存在,默认为所有用户 57 | # 数组 'events' 可以指定以仅获取特定事件的通知 58 | # 有效的事件值:"play_or_activity" "new_episode" "updated_episode" "scheduler" 59 | # 如果不存在,默认为所有事件 60 | apprise_configs: 61 | # 此 URL 将在所有事件期间通知所有更改 62 | - "discord://webhook_id/webhook_token" 63 | # 这些 URL 将仅在用户 "MyUser1" 和 "MyUser2" 的语言更改时通知 64 | - urls: 65 | - "gotify://hostname/token" 66 | - "pover://user@token" 67 | users: 68 | - "MyUser1" 69 | - "MyUser2" 70 | # 此 URL 将仅在用户 "MyUser3" 的播放或活动事件期间通知语言更改 71 | - urls: 72 | - "tgram://bottoken/ChatID" 73 | users: 74 | - "MyUser3" 75 | events: 76 | - "play_or_activity" 77 | # 此 URL 将仅在调度程序任务期间通知语言更改 78 | - urls: 79 | - "gotify://hostname/token" 80 | events: 81 | - "scheduler" 82 | - "..." 83 | 84 | # 是否启用调试模式,默认为 'false' 85 | # 启用调试模式将显著增加输出日志的数量 86 | debug: true 87 | -------------------------------------------------------------------------------- /plugins.v2/plexautolanguages/config/user.yaml: -------------------------------------------------------------------------------- 1 | plexautolanguages: 2 | # 更新整个剧集的语言或仅更新当前季 3 | # 可接受的值: 4 | # - show(默认) 5 | # - season 6 | update_level: "show" 7 | 8 | # 更新剧集/季的所有剧集或仅更新接下来的剧集 9 | # 可接受的值: 10 | # - all(默认) 11 | # - next 12 | update_strategy: "all" 13 | 14 | # 播放文件是否触发语言更新,默认为 'true' 15 | trigger_on_play: true 16 | 17 | # 扫描新文件时是否触发语言更新,默认为 'true' 18 | # 新增的剧集将根据最近观看的剧集更新语言,若该剧从未被观看则根据第一集更新语言 19 | trigger_on_scan: true 20 | 21 | # 浏览 Plex 库是否触发语言更新,默认为 'false' 22 | # 仅 Plex 网络客户端和 Plex for Windows 应用程序支持此功能 23 | # 仅当您希望在更新剧集的默认轨道时执行更改,即使未播放该剧集时,才将此参数设置为 'true' 24 | # 将此参数设置为 'true' 可能会导致更高的资源使用 25 | trigger_on_activity: false 26 | 27 | # 每当 Plex 服务器扫描其库时是否刷新缓存库,默认为 'true' 28 | # 禁用此参数将阻止 PlexAutoLanguages 检测已存在剧集的更新文件 29 | # 如果您的电视剧库很大(10k+ 剧集),建议禁用此参数 30 | refresh_library_on_scan: false 31 | 32 | # PlexAutoLanguages 将忽略具有以下任何 Plex 标签的剧集 33 | ignore_labels: 34 | - PAL_IGNORE 35 | 36 | # Plex 配置 37 | plex: 38 | # 一个有效的 Plex URL(必填) 39 | url: "http://plex:32400" 40 | # 一个有效的 Plex 令牌(必填) 41 | token: "MY_PLEX_TOKEN" 42 | 43 | scheduler: 44 | # 是否启用调度程序,默认为 'true' 45 | # 调度程序将对所有最近播放的电视剧进行更深入的分析 46 | enable: false 47 | # 调度程序开始任务的时间,格式为 'HH:MM',默认为 '02:00' 48 | schedule_time: "04:30" 49 | 50 | notifications: 51 | # 是否通过 Apprise 启用通知,默认为 'false' 52 | # 每当进行语言更改时发送通知 53 | enable: false 54 | # Apprise 配置的数组,详见 Apprise 文档:https://github.com/caronc/apprise 55 | # 数组 'users' 可以指定以将通知 URL 与特定用户关联 56 | # 如果不存在,默认为所有用户 57 | # 数组 'events' 可以指定以仅获取特定事件的通知 58 | # 有效的事件值:"play_or_activity" "new_episode" "updated_episode" "scheduler" 59 | # 如果不存在,默认为所有事件 60 | apprise_configs: 61 | # 此 URL 将在所有事件期间通知所有更改 62 | - "discord://webhook_id/webhook_token" 63 | # 这些 URL 将仅在用户 "MyUser1" 和 "MyUser2" 的语言更改时通知 64 | - urls: 65 | - "gotify://hostname/token" 66 | - "pover://user@token" 67 | users: 68 | - "MyUser1" 69 | - "MyUser2" 70 | # 此 URL 将仅在用户 "MyUser3" 的播放或活动事件期间通知语言更改 71 | - urls: 72 | - "tgram://bottoken/ChatID" 73 | users: 74 | - "MyUser3" 75 | events: 76 | - "play_or_activity" 77 | # 此 URL 将仅在调度程序任务期间通知语言更改 78 | - urls: 79 | - "gotify://hostname/token" 80 | events: 81 | - "scheduler" 82 | - "..." 83 | 84 | # 是否启用调试模式,默认为 'false' 85 | # 启用调试模式将显著增加输出日志的数量 86 | debug: true 87 | -------------------------------------------------------------------------------- /plugins.v2/plexautolanguages/core/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InfinityPacer/MoviePilot-Plugins/a60089999fafc7ffb7143dc09d2a627e4d839f59/plugins.v2/plexautolanguages/core/__init__.py -------------------------------------------------------------------------------- /plugins.v2/plexautolanguages/core/alerts/__init__.py: -------------------------------------------------------------------------------- 1 | from .base import PlexAlert # noqa: F401 2 | from .activity import PlexActivity # noqa: F401 3 | from .playing import PlexPlaying # noqa: F401 4 | from .timeline import PlexTimeline # noqa: F401 5 | from .status import PlexStatus # noqa: F401 6 | -------------------------------------------------------------------------------- /plugins.v2/plexautolanguages/core/alerts/activity.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from typing import TYPE_CHECKING 3 | from datetime import datetime, timedelta 4 | from plexapi.video import Episode 5 | 6 | from app.plugins.plexautolanguages.core.alerts.base import PlexAlert 7 | from app.plugins.plexautolanguages.core.utils.logger import get_logger 8 | from app.plugins.plexautolanguages.core.constants import EventType 9 | 10 | if TYPE_CHECKING: 11 | from app.plugins.plexautolanguages.core.plex_server import PlexServer 12 | 13 | 14 | logger = get_logger() 15 | 16 | 17 | class PlexActivity(PlexAlert): 18 | 19 | TYPE = "activity" 20 | 21 | TYPE_LIBRARY_REFRESH_ITEM = "library.refresh.items" 22 | TYPE_LIBRARY_UPDATE_SECTION = "library.update.section" 23 | TYPE_PROVIDER_SUBSCRIPTIONS_PROCESS = "provider.subscriptions.process" 24 | TYPE_MEDIA_GENERATE_BIF = "media.generate.bif" 25 | 26 | def is_type(self, activity_type: str): 27 | return self.type == activity_type 28 | 29 | @property 30 | def event(self): 31 | return self._message.get("event", None) 32 | 33 | @property 34 | def type(self): 35 | return self._message.get("Activity", {}).get("type", None) 36 | 37 | @property 38 | def item_key(self): 39 | return self._message.get("Activity", {}).get("Context", {}).get("key", None) 40 | 41 | @property 42 | def user_id(self): 43 | return self._message.get("Activity", {}).get("userID", None) 44 | 45 | def process(self, plex: PlexServer): 46 | if self.event != "ended": 47 | return 48 | if not self.is_type(self.TYPE_LIBRARY_REFRESH_ITEM): 49 | return 50 | 51 | # Switch to the user's Plex instance 52 | user_plex = plex.get_plex_instance_of_user(self.user_id) 53 | if user_plex is None: 54 | return 55 | 56 | # Skip if not an Episode 57 | item = user_plex.fetch_item(self.item_key) 58 | if item is None or not isinstance(item, Episode): 59 | return 60 | 61 | # Skip if the show should be ignored 62 | if plex.should_ignore_show(item.show()): 63 | logger.debug(f"[Activity] Ignoring episode {item} due to Plex show labels") 64 | return 65 | 66 | # Skip if this item has already been seen in the last 3 seconds 67 | activity_key = (self.user_id, self.item_key) 68 | if activity_key in plex.cache.recent_activities and \ 69 | plex.cache.recent_activities[activity_key] > datetime.now() - timedelta(seconds=3): 70 | return 71 | plex.cache.recent_activities[activity_key] = datetime.now() 72 | 73 | # Change tracks if needed 74 | item.reload() 75 | user = plex.get_user_by_id(self.user_id) 76 | if user is None: 77 | return 78 | logger.debug(f"[Activity] User: {user.name} | Episode: {item}") 79 | plex.change_tracks(user.name, item, EventType.PLAY_OR_ACTIVITY) 80 | -------------------------------------------------------------------------------- /plugins.v2/plexautolanguages/core/alerts/base.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from typing import TYPE_CHECKING 3 | 4 | if TYPE_CHECKING: 5 | from app.plugins.plexautolanguages.core.plex_server import PlexServer 6 | 7 | 8 | class PlexAlert(): 9 | 10 | TYPE = None 11 | 12 | def __init__(self, message: dict): 13 | self._message = message 14 | 15 | @property 16 | def message(self): 17 | return self._message 18 | 19 | def process(self, plex: PlexServer): 20 | raise NotImplementedError 21 | -------------------------------------------------------------------------------- /plugins.v2/plexautolanguages/core/alerts/playing.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from typing import TYPE_CHECKING 3 | from plexapi.video import Episode 4 | 5 | from app.plugins.plexautolanguages.core.alerts.base import PlexAlert 6 | from app.plugins.plexautolanguages.core.utils.logger import get_logger 7 | from app.plugins.plexautolanguages.core.constants import EventType 8 | 9 | if TYPE_CHECKING: 10 | from app.plugins.plexautolanguages.core.plex_server import PlexServer 11 | 12 | 13 | logger = get_logger() 14 | 15 | 16 | class PlexPlaying(PlexAlert): 17 | 18 | TYPE = "playing" 19 | 20 | @property 21 | def client_identifier(self): 22 | return self._message.get("clientIdentifier", None) 23 | 24 | @property 25 | def item_key(self): 26 | return self._message.get("key", None) 27 | 28 | @property 29 | def session_key(self): 30 | return self._message.get("sessionKey", None) 31 | 32 | @property 33 | def session_state(self): 34 | return self._message.get("state", None) 35 | 36 | def process(self, plex: PlexServer): 37 | # Get User id and user's Plex instance 38 | if self.client_identifier not in plex.cache.user_clients: 39 | user_id, username = plex.get_user_from_client_identifier(self.client_identifier) 40 | if user_id is None: 41 | return 42 | plex.cache.user_clients[self.client_identifier] = (user_id, username) 43 | else: 44 | user_id, username = plex.cache.user_clients[self.client_identifier] 45 | user_plex = plex.get_plex_instance_of_user(user_id) 46 | if user_plex is None: 47 | return 48 | 49 | # Skip if not an Episode 50 | item = user_plex.fetch_item(self.item_key) 51 | if item is None or not isinstance(item, Episode): 52 | return 53 | 54 | # Skip if the show should be ignored 55 | if plex.should_ignore_show(item.show()): 56 | logger.debug(f"[Play Session] Ignoring episode {item} due to Plex show labels") 57 | return 58 | 59 | # Skip is the session state is unchanged 60 | if self.session_key in plex.cache.session_states and plex.cache.session_states[self.session_key] == self.session_state: 61 | return 62 | logger.debug(f"[Play Session] " 63 | f"Session: {self.session_key} | State: '{self.session_state}' | User id: {user_id} | Episode: {item}") 64 | plex.cache.session_states[self.session_key] = self.session_state 65 | 66 | # Reset cache if the session is stopped 67 | if self.session_state == "stopped": 68 | logger.debug(f"[Play Session] End of session {self.session_key} for user {user_id}") 69 | del plex.cache.session_states[self.session_key] 70 | del plex.cache.user_clients[self.client_identifier] 71 | 72 | # Skip if selected streams are unchanged 73 | item.reload() 74 | audio_stream, subtitle_stream = plex.get_selected_streams(item) 75 | pair_id = ( 76 | audio_stream.id if audio_stream is not None else None, 77 | subtitle_stream.id if subtitle_stream is not None else None 78 | ) 79 | if item.key in plex.cache.default_streams and plex.cache.default_streams[item.key] == pair_id: 80 | return 81 | plex.cache.default_streams.setdefault(item.key, pair_id) 82 | 83 | # Change tracks if needed 84 | plex.change_tracks(username, item, EventType.PLAY_OR_ACTIVITY) 85 | -------------------------------------------------------------------------------- /plugins.v2/plexautolanguages/core/alerts/status.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from typing import TYPE_CHECKING 3 | 4 | from app.plugins.plexautolanguages.core.alerts.base import PlexAlert 5 | from app.plugins.plexautolanguages.core.utils.logger import get_logger 6 | from app.plugins.plexautolanguages.core.constants import EventType 7 | 8 | if TYPE_CHECKING: 9 | from app.plugins.plexautolanguages.core.plex_server import PlexServer 10 | 11 | 12 | logger = get_logger() 13 | 14 | 15 | class PlexStatus(PlexAlert): 16 | 17 | TYPE = "status" 18 | 19 | @property 20 | def title(self): 21 | return self._message.get("title", None) 22 | 23 | def process(self, plex: PlexServer): 24 | if self.title != "Library scan complete": 25 | return 26 | logger.debug("[Status] The Plex server scanned the library") 27 | 28 | if plex.config.get("refresh_library_on_scan"): 29 | added, updated = plex.cache.refresh_library_cache() 30 | else: 31 | added = plex.get_recently_added_episodes(minutes=5) 32 | updated = [] 33 | 34 | # Process recently added episodes 35 | if len(added) > 0: 36 | logger.debug(f"[Status] Found {len(added)} newly added episode(s)") 37 | for item in added: 38 | # Check if the item should be ignored 39 | if plex.should_ignore_show(item.show()): 40 | continue 41 | 42 | # Check if the item has already been processed 43 | if not plex.cache.should_process_recently_added(item.key, item.addedAt): 44 | continue 45 | 46 | # Change tracks for all users 47 | logger.info(f"[Status] Processing newly added episode {plex.get_episode_short_name(item)}") 48 | plex.process_new_or_updated_episode(item.key, EventType.NEW_EPISODE, True) 49 | 50 | # Process updated episodes 51 | if len(updated) > 0: 52 | logger.debug(f"[Status] Found {len(updated)} updated episode(s)") 53 | for item in updated: 54 | # Check if the item should be ignored 55 | if plex.should_ignore_show(item.show()): 56 | continue 57 | 58 | # Check if the item has already been processed 59 | if not plex.cache.should_process_recently_updated(item.key): 60 | continue 61 | 62 | # Change tracks for all users 63 | logger.info(f"[Status] Processing updated episode {plex.get_episode_short_name(item)}") 64 | plex.process_new_or_updated_episode(item.key, EventType.UPDATED_EPISODE, False) 65 | -------------------------------------------------------------------------------- /plugins.v2/plexautolanguages/core/alerts/timeline.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from typing import TYPE_CHECKING 3 | from datetime import datetime, timedelta 4 | from plexapi.video import Episode 5 | 6 | from app.plugins.plexautolanguages.core.alerts.base import PlexAlert 7 | from app.plugins.plexautolanguages.core.utils.logger import get_logger 8 | from app.plugins.plexautolanguages.core.constants import EventType 9 | 10 | if TYPE_CHECKING: 11 | from app.plugins.plexautolanguages.core.plex_server import PlexServer 12 | 13 | 14 | logger = get_logger() 15 | 16 | 17 | class PlexTimeline(PlexAlert): 18 | 19 | TYPE = "timeline" 20 | 21 | @property 22 | def has_metadata_state(self): 23 | return "metadataState" in self._message 24 | 25 | @property 26 | def has_media_state(self): 27 | return "mediaState" in self._message 28 | 29 | @property 30 | def item_id(self): 31 | return int(self._message.get("itemID", None)) 32 | 33 | @property 34 | def identifier(self): 35 | return self._message.get("identifier", None) 36 | 37 | @property 38 | def state(self): 39 | return self._message.get("state", None) 40 | 41 | @property 42 | def entry_type(self): 43 | return self._message.get("type", None) 44 | 45 | def process(self, plex: PlexServer): 46 | if self.has_metadata_state or self.has_media_state: 47 | return 48 | if self.identifier != "com.plexapp.plugins.library" or self.state != 5 or self.entry_type == -1: 49 | return 50 | 51 | # Skip if not an Episode 52 | item = plex.fetch_item(self.item_id) 53 | if item is None or not isinstance(item, Episode): 54 | return 55 | 56 | # Skip if the show should be ignored 57 | if plex.should_ignore_show(item.show()): 58 | logger.debug(f"[Timeline] Ignoring episode {item} due to Plex show labels") 59 | return 60 | 61 | # Check if the item has been added recently 62 | if item.addedAt < datetime.now() - timedelta(minutes=5): 63 | return 64 | 65 | # Check if the item has already been processed 66 | if not plex.cache.should_process_recently_added(item.key, item.addedAt): 67 | return 68 | 69 | # Change tracks for all users 70 | logger.info(f"[Timeline] Processing newly added episode {plex.get_episode_short_name(item)}") 71 | plex.process_new_or_updated_episode(self.item_id, EventType.NEW_EPISODE, True) 72 | -------------------------------------------------------------------------------- /plugins.v2/plexautolanguages/core/constants.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | 4 | class EventType(Enum): 5 | 6 | PLAY_OR_ACTIVITY = 0 7 | NEW_EPISODE = 1 8 | UPDATED_EPISODE = 2 9 | SCHEDULER = 3 10 | -------------------------------------------------------------------------------- /plugins.v2/plexautolanguages/core/exceptions.py: -------------------------------------------------------------------------------- 1 | 2 | class InvalidConfiguration(Exception): 3 | pass 4 | 5 | 6 | class UserNotFound(Exception): 7 | pass 8 | -------------------------------------------------------------------------------- /plugins.v2/plexautolanguages/core/plex_alert_handler.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from queue import Queue, Empty 4 | from threading import Thread, Event 5 | from time import sleep 6 | from typing import TYPE_CHECKING 7 | 8 | from requests.exceptions import ReadTimeout 9 | from urllib3.exceptions import ReadTimeoutError 10 | 11 | from app.plugins.plexautolanguages.core.alerts import PlexActivity, PlexTimeline, PlexPlaying, PlexStatus 12 | from app.plugins.plexautolanguages.core.utils.logger import get_logger 13 | 14 | if TYPE_CHECKING: 15 | from app.plugins.plexautolanguages.core.plex_server import PlexServer 16 | 17 | logger = get_logger() 18 | 19 | 20 | class PlexAlertHandler: 21 | 22 | def __init__(self, plex: PlexServer, trigger_on_play: bool, trigger_on_scan: bool, trigger_on_activity: bool): 23 | self._plex = plex 24 | self._trigger_on_play = trigger_on_play 25 | self._trigger_on_scan = trigger_on_scan 26 | self._trigger_on_activity = trigger_on_activity 27 | self._alerts_queue = Queue() 28 | self._stop_event = Event() 29 | self._processor_thread = Thread(target=self._process_alerts) 30 | self._processor_thread.daemon = True 31 | self._processor_thread.start() 32 | 33 | def stop(self): 34 | self._stop_event.set() 35 | self._processor_thread.join() 36 | 37 | def __call__(self, message: dict): 38 | alert_class = None 39 | alert_field = None 40 | if self._trigger_on_play and message["type"] == "playing": 41 | alert_class = PlexPlaying 42 | alert_field = "PlaySessionStateNotification" 43 | elif self._trigger_on_activity and message["type"] == "activity": 44 | alert_class = PlexActivity 45 | alert_field = "ActivityNotification" 46 | elif self._trigger_on_scan and message["type"] == "timeline": 47 | alert_class = PlexTimeline 48 | alert_field = "TimelineEntry" 49 | elif self._trigger_on_scan and message["type"] == "status": 50 | alert_class = PlexStatus 51 | alert_field = "StatusNotification" 52 | 53 | if alert_class is None or alert_field is None or alert_field not in message: 54 | return 55 | 56 | for alert_message in message[alert_field]: 57 | alert = alert_class(alert_message) 58 | self._alerts_queue.put(alert) 59 | 60 | def _process_alerts(self): 61 | logger.debug("Starting alert processing thread") 62 | retry_counter = 0 63 | while not self._stop_event.is_set(): 64 | try: 65 | if retry_counter == 0: 66 | alert = self._alerts_queue.get(True, 1) 67 | try: 68 | alert.process(self._plex) 69 | retry_counter = 0 70 | except (ReadTimeout, ReadTimeoutError): 71 | retry_counter += 1 72 | logger.warning( 73 | f"ReadTimeout while processing {alert.TYPE} alert, retrying (attempt {retry_counter})...") 74 | logger.debug(alert.message) 75 | sleep(1) 76 | except Exception: 77 | logger.exception(f"Unable to process {alert.TYPE}") 78 | logger.debug(alert.message) 79 | retry_counter = 0 80 | except Empty: 81 | pass 82 | logger.debug("Stopping alert processing thread") 83 | -------------------------------------------------------------------------------- /plugins.v2/plexautolanguages/core/plex_alert_listener.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from typing import Callable 3 | from websocket import WebSocketApp 4 | from plexapi.alert import AlertListener 5 | from plexapi.server import PlexServer as BasePlexServer 6 | 7 | from app.plugins.plexautolanguages.core.utils.logger import get_logger 8 | 9 | 10 | logger = get_logger() 11 | 12 | 13 | class PlexAlertListener(AlertListener): 14 | 15 | def __init__(self, server: BasePlexServer, callback: Callable = None, callbackError: Callable = None): 16 | super().__init__(server, callback, callbackError) 17 | 18 | def run(self): 19 | url = self._server.url(self.key, includeToken=True).replace("http", "ws") 20 | self._ws = WebSocketApp(url, on_message=self._onMessage, on_error=self._onError) 21 | self._ws.run_forever(skip_utf8_validation=True) 22 | -------------------------------------------------------------------------------- /plugins.v2/plexautolanguages/core/plex_server_cache.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | import os 3 | import json 4 | import copy 5 | from typing import TYPE_CHECKING 6 | from datetime import datetime, timedelta 7 | from dateutil.parser import isoparse 8 | 9 | from app.plugins.plexautolanguages.core.utils.logger import get_logger 10 | from app.plugins.plexautolanguages.core.utils.json_encoders import DateTimeEncoder 11 | 12 | if TYPE_CHECKING: 13 | from app.plugins.plexautolanguages.core.plex_server import PlexServer 14 | 15 | 16 | logger = get_logger() 17 | 18 | 19 | class PlexServerCache(): 20 | 21 | def __init__(self, plex: PlexServer): 22 | self._is_refreshing = False 23 | self._encoder = DateTimeEncoder() 24 | self._plex = plex 25 | self._cache_file_path = self._get_cache_file_path() 26 | self._last_refresh = datetime.fromtimestamp(0) 27 | # Alerts cache 28 | self.session_states = {} # session_key: session_state 29 | self.default_streams = {} # item_key: (audio_stream_id, substitle_stream_id) 30 | self.user_clients = {} # client_identifier: user_id 31 | self.newly_added = {} # episode_id: added_at 32 | self.newly_updated = {} # episode_id: updated_at 33 | self.recent_activities = {} # (user_id, item_id): timestamp 34 | # Users cache 35 | self._instance_users = [] 36 | self._instance_user_tokens = {} 37 | self._instance_users_valid_until = datetime.fromtimestamp(0) 38 | # Library cache 39 | self.episode_parts = {} 40 | # Initialization 41 | if not self._load(): 42 | logger.info("Scanning all episodes from the Plex library, this action should only take a few seconds " 43 | "but can take several minutes for larger libraries") 44 | self.refresh_library_cache() 45 | logger.info(f"Scanned {len(self.episode_parts)} episodes from the library") 46 | 47 | def should_process_recently_added(self, episode_id: str, added_at: datetime): 48 | if episode_id in self.newly_added and self.newly_added[episode_id] == added_at: 49 | return False 50 | self.newly_added[episode_id] = added_at 51 | return True 52 | 53 | def should_process_recently_updated(self, episode_id: str): 54 | if episode_id in self.newly_updated and self.newly_updated[episode_id] >= self._last_refresh: 55 | return False 56 | self.newly_updated[episode_id] = datetime.now() 57 | return True 58 | 59 | def refresh_library_cache(self): 60 | if self._is_refreshing: 61 | logger.debug("[Cache] The library cache is already being refreshed") 62 | return [], [] 63 | self._is_refreshing = True 64 | logger.debug("[Cache] Refreshing library cache") 65 | added = [] 66 | updated = [] 67 | new_episode_parts = {} 68 | for episode in self._plex.episodes(): 69 | part_list = new_episode_parts.setdefault(episode.key, []) 70 | for part in episode.iterParts(): 71 | part_list.append(part.key) 72 | if episode.key in self.episode_parts and set(self.episode_parts[episode.key]) != set(part_list): 73 | updated.append(episode) 74 | elif episode.key not in self.episode_parts: 75 | added.append(episode) 76 | self.episode_parts = new_episode_parts 77 | logger.debug("[Cache] Done refreshing library cache") 78 | self._last_refresh = datetime.now() 79 | self.save() 80 | self._is_refreshing = False 81 | return added, updated 82 | 83 | def get_instance_users(self, check_validity=True): 84 | if check_validity and datetime.now() > self._instance_users_valid_until: 85 | return None 86 | return copy.deepcopy(self._instance_users) 87 | 88 | def set_instance_users(self, instance_users): 89 | self._instance_users = copy.deepcopy(instance_users) 90 | self._instance_users_valid_until = datetime.now() + timedelta(hours=12) 91 | for user in self._instance_users: 92 | if str(user.id) in self._instance_user_tokens: 93 | continue 94 | self._instance_user_tokens[str(user.id)] = user.get_token(self._plex.unique_id) 95 | 96 | def get_instance_user_token(self, user_id): 97 | return self._instance_user_tokens.get(str(user_id), None) 98 | 99 | def set_instance_user_token(self, user_id, token): 100 | self._instance_user_tokens[str(user_id)] = token 101 | 102 | def _get_cache_file_path(self): 103 | data_dir = self._plex.config.get("data_dir") 104 | cache_dir = os.path.join(data_dir, "cache") 105 | if not os.path.exists(cache_dir): 106 | os.makedirs(cache_dir) 107 | return os.path.join(cache_dir, self._plex.unique_id) 108 | 109 | def _load(self): 110 | if not os.path.exists(self._cache_file_path) or not os.path.isfile(self._cache_file_path): 111 | return False 112 | logger.debug("[Cache] Loading server cache from file") 113 | try: 114 | with open(self._cache_file_path, "r", encoding="utf-8") as stream: 115 | cache = json.load(stream) 116 | except json.JSONDecodeError: 117 | logger.warning("[Cache] The cache is corrupted, clearing the cache before trying again") 118 | if os.path.exists(self._cache_file_path) and os.path.isfile(self._cache_file_path): 119 | os.remove(self._cache_file_path) 120 | return False 121 | self.newly_updated = cache.get("newly_updated", self.newly_updated) 122 | self.newly_updated = {key: isoparse(value) for key, value in self.newly_updated.items()} 123 | self.newly_added = cache.get("newly_added", self.newly_added) 124 | self.newly_added = {key: isoparse(value) for key, value in self.newly_added.items()} 125 | self.episode_parts = cache.get("episode_parts", ) 126 | self._last_refresh = isoparse(cache.get("last_refresh", self._last_refresh)) 127 | return True 128 | 129 | def save(self): 130 | logger.debug("[Cache] Saving server cache to file") 131 | cache = { 132 | "newly_updated": self.newly_updated, 133 | "newly_added": self.newly_added, 134 | "episode_parts": self.episode_parts, 135 | "last_refresh": self._last_refresh 136 | } 137 | with open(self._cache_file_path, "w", encoding="utf-8") as stream: 138 | stream.write(self._encoder.encode(cache)) 139 | -------------------------------------------------------------------------------- /plugins.v2/plexautolanguages/core/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InfinityPacer/MoviePilot-Plugins/a60089999fafc7ffb7143dc09d2a627e4d839f59/plugins.v2/plexautolanguages/core/utils/__init__.py -------------------------------------------------------------------------------- /plugins.v2/plexautolanguages/core/utils/configuration.py: -------------------------------------------------------------------------------- 1 | import os 2 | import pathlib 3 | import re 4 | from collections.abc import Mapping 5 | 6 | from ruamel.yaml import YAML 7 | 8 | from app.core.config import settings 9 | from app.plugins.plexautolanguages.core.exceptions import InvalidConfiguration 10 | from app.plugins.plexautolanguages.core.utils.logger import get_logger 11 | 12 | logger = get_logger() 13 | yaml = YAML(typ="safe") 14 | 15 | 16 | def deep_dict_update(original, update): 17 | for key, value in update.items(): 18 | if isinstance(value, Mapping): 19 | original[key] = deep_dict_update(original.get(key, {}), value) 20 | else: 21 | original[key] = value 22 | return original 23 | 24 | 25 | def env_dict_update(original, var_name: str = ""): 26 | for key, value in original.items(): 27 | new_var_name = (f"{var_name}_{key}" if var_name != "" else key).upper() 28 | if isinstance(value, Mapping): 29 | original[key] = env_dict_update(original[key], new_var_name) 30 | elif new_var_name in os.environ: 31 | original[key] = yaml.load(os.environ.get(new_var_name)) 32 | logger.info(f"Setting value of parameter {new_var_name} from environment variable") 33 | return original 34 | 35 | 36 | def is_docker(): 37 | return False 38 | 39 | 40 | def get_data_directory(app_name: str): 41 | config_path = pathlib.Path(settings.CONFIG_PATH) 42 | data_path = config_path / "plugins" / app_name / "data" 43 | return data_path 44 | 45 | 46 | class Configuration: 47 | 48 | def __init__(self, default_config_path: pathlib.Path, user_config_path: pathlib.Path): 49 | if not default_config_path.exists(): 50 | logger.error("default config is not exists") 51 | return 52 | with open(default_config_path, "r", encoding="utf-8") as stream: 53 | self._config = yaml.load(stream).get("plexautolanguages", {}) 54 | if user_config_path.exists(): 55 | logger.info(f"Parsing config file '{user_config_path}'") 56 | self._override_from_config_file(user_config_path) 57 | self._override_from_env() 58 | self._override_plex_token_from_secret() 59 | self._postprocess_config() 60 | self._validate_config() 61 | self._add_system_config() 62 | 63 | def get(self, parameter_path: str): 64 | return self._get(self._config, parameter_path) 65 | 66 | def _get(self, config: dict, parameter_path: str): 67 | separator = "." 68 | if separator in parameter_path: 69 | splitted = parameter_path.split(separator) 70 | return self._get(config[splitted[0]], separator.join(splitted[1:])) 71 | return config[parameter_path] 72 | 73 | def _override_from_config_file(self, user_config_path: pathlib.Path): 74 | with open(user_config_path, "r", encoding="utf-8") as stream: 75 | user_config = yaml.load(stream).get("plexautolanguages", {}) 76 | self._config = deep_dict_update(self._config, user_config) 77 | 78 | def _override_from_env(self): 79 | self._config = env_dict_update(self._config) 80 | 81 | def _override_plex_token_from_secret(self): 82 | plex_token_file_path = os.environ.get("PLEX_TOKEN_FILE", "/run/secrets/plex_token") 83 | if not os.path.exists(plex_token_file_path): 84 | return 85 | logger.info("Getting PLEX_TOKEN from Docker secret") 86 | with open(plex_token_file_path, "r", encoding="utf-8") as stream: 87 | plex_token = stream.readline().strip() 88 | self._config["plex"]["token"] = plex_token 89 | 90 | def _postprocess_config(self): 91 | ignore_labels_config = self.get("ignore_labels") 92 | if isinstance(ignore_labels_config, str): 93 | self._config["ignore_labels"] = ignore_labels_config.split(",") 94 | 95 | def _validate_config(self): 96 | if self.get("plex.url") == "": 97 | logger.error("A Plex URL is required") 98 | raise InvalidConfiguration 99 | if self.get("plex.token") == "": 100 | logger.error("A Plex Token is required") 101 | raise InvalidConfiguration 102 | if self.get("update_level") not in ["show", "season"]: 103 | logger.error("The 'update_level' parameter must be either 'show' or 'season'") 104 | raise InvalidConfiguration 105 | if self.get("update_strategy") not in ["all", "next"]: 106 | logger.error("The 'update_strategy' parameter must be either 'all' or 'next'") 107 | raise InvalidConfiguration 108 | if not isinstance(self.get("ignore_labels"), list): 109 | logger.error("The 'ignore_labels' parameter must be a list or a string-based comma separated list") 110 | raise InvalidConfiguration 111 | if self.get("scheduler.enable") and not re.match(r"^\d{2}:\d{2}$", self.get("scheduler.schedule_time")): 112 | logger.error("A valid 'schedule_time' parameter with the format 'HH:MM' is required (ex: 02:30)") 113 | raise InvalidConfiguration 114 | logger.info("The provided configuration has been successfully validated") 115 | 116 | def _add_system_config(self): 117 | self._config["docker"] = is_docker() 118 | self._config["data_dir"] = get_data_directory("PlexAutoLanguages") 119 | if not os.path.exists(self._config["data_dir"]): 120 | os.makedirs(self._config["data_dir"]) 121 | -------------------------------------------------------------------------------- /plugins.v2/plexautolanguages/core/utils/json_encoders.py: -------------------------------------------------------------------------------- 1 | import json 2 | from datetime import datetime, date, time 3 | 4 | 5 | class DateTimeEncoder(json.JSONEncoder): 6 | 7 | def default(self, o): 8 | if isinstance(o, (datetime, date, time)): 9 | return o.isoformat() 10 | return super().default(o) 11 | -------------------------------------------------------------------------------- /plugins.v2/plexautolanguages/core/utils/logger.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from pathlib import Path 3 | 4 | from app.log import logger 5 | 6 | 7 | class CustomFormatter: 8 | grey = "\x1b[38;21m" 9 | blue = "\x1b[38;5;39m" 10 | yellow = "\x1b[38;5;226m" 11 | red = "\x1b[38;5;196m" 12 | bold_red = "\x1b[31;1m" 13 | reset = "\x1b[0m" 14 | fmt = "%(asctime)s [%(levelname)s] %(message)s" 15 | 16 | FORMATS = { 17 | logging.DEBUG: grey + fmt + reset, 18 | logging.INFO: blue + fmt + reset, 19 | logging.WARNING: yellow + fmt + reset, 20 | logging.ERROR: red + fmt + reset, 21 | logging.CRITICAL: bold_red + fmt + reset 22 | } 23 | 24 | def format(self, record): 25 | log_fmt = self.FORMATS.get(record.levelno) 26 | formatter = logging.Formatter(log_fmt) 27 | return formatter.format(record) 28 | 29 | 30 | def get_logger(plugin_name: str = None): 31 | """ 32 | 获取模块的logger 33 | """ 34 | return logger 35 | # if plugin_name: 36 | # loggers = getattr(logger, '_loggers', None) 37 | # if loggers: 38 | # logfile = Path("plugins") / f"{plugin_name}.log" 39 | # _logger = loggers.get(logfile) 40 | # if _logger: 41 | # return _logger 42 | # return logging.getLogger(__name__) 43 | -------------------------------------------------------------------------------- /plugins.v2/plexautolanguages/languageprovider.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from pathlib import Path 3 | from time import sleep 4 | 5 | from websocket import WebSocketConnectionClosedException 6 | 7 | from app.plugins.plexautolanguages.core.plex_server import PlexServer 8 | from app.plugins.plexautolanguages.core.utils.configuration import Configuration 9 | 10 | 11 | class LanguageProvider: 12 | 13 | def __init__(self, default_config_path: Path, user_config_path: Path, logger: logging.Logger): 14 | self.alive = False 15 | self.must_stop = False 16 | self.stop_signal = False 17 | self.plex_alert_listener = None 18 | self.logger = logger 19 | 20 | # Configuration 21 | self.config = Configuration(default_config_path, user_config_path) 22 | 23 | # Notifications 24 | self.notifier = None 25 | # if self.config.get("notifications.enable"): 26 | # self.notifier = Notifier(self.config.get("notifications.apprise_configs")) 27 | 28 | # Scheduler 29 | self.scheduler = None 30 | # if self.config.get("scheduler.enable"): 31 | # self.scheduler = Scheduler(self.config.get("scheduler.schedule_time"), self.scheduler_callback) 32 | 33 | # Plex 34 | self.plex = None 35 | 36 | def init(self): 37 | try: 38 | self.plex = PlexServer(self.config.get("plex.url"), 39 | self.config.get("plex.token"), 40 | self.notifier, 41 | self.config) 42 | except Exception as e: 43 | self.logger.error(e) 44 | 45 | def is_ready(self): 46 | return self.alive 47 | 48 | def is_healthy(self): 49 | return self.alive and self.plex.is_alive 50 | 51 | def stop(self, *_): 52 | self.logger.info("Received SIGINT or SIGTERM, stopping gracefully") 53 | self.must_stop = True 54 | self.stop_signal = True 55 | 56 | def start(self): 57 | if self.scheduler: 58 | self.scheduler.start() 59 | 60 | while not self.stop_signal: 61 | self.must_stop = False 62 | self.init() 63 | if self.plex is None: 64 | break 65 | self.plex.start_alert_listener(self.alert_listener_error_callback) 66 | self.alive = True 67 | count = 0 68 | while not self.must_stop: 69 | sleep(1) 70 | count += 1 71 | if count % 60 == 0 and not self.plex.is_alive: 72 | self.logger.warning("Lost connection to the Plex server") 73 | self.must_stop = True 74 | self.alive = False 75 | self.plex.save_cache() 76 | self.plex.stop() 77 | if not self.stop_signal: 78 | sleep(1) 79 | self.logger.info("Trying to restore the connection to the Plex server...") 80 | 81 | if self.scheduler: 82 | self.scheduler.shutdown() 83 | self.scheduler.join() 84 | 85 | def alert_listener_error_callback(self, error: Exception): 86 | if isinstance(error, WebSocketConnectionClosedException): 87 | self.logger.warning("The Plex server closed the websocket connection") 88 | elif isinstance(error, UnicodeDecodeError): 89 | self.logger.debug("Ignoring a websocket payload that could not be decoded") 90 | return 91 | else: 92 | self.logger.error("Alert listener had an unexpected error") 93 | self.logger.error(error, exc_info=True) 94 | self.must_stop = True 95 | 96 | def scheduler_callback(self): 97 | if self.plex is None or not self.plex.is_alive: 98 | return 99 | self.logger.info("Starting scheduler task") 100 | self.plex.start_deep_analysis() 101 | -------------------------------------------------------------------------------- /plugins.v2/plexautolanguages/requirements.txt: -------------------------------------------------------------------------------- 1 | websocket-client~=1.8 2 | python-dateutil~=2.8.2 3 | -------------------------------------------------------------------------------- /plugins.v2/plexlocalization/requirements.txt: -------------------------------------------------------------------------------- 1 | pypinyin~=0.51.0 -------------------------------------------------------------------------------- /plugins.v2/plexpersonmeta/README.md: -------------------------------------------------------------------------------- 1 | # Plex演职人员刮削 2 | 3 | 实现刮削演职人员中文名称及角色 4 | 5 | - **技术难点**:Plex 的 API 实现较为复杂,特别是在处理关联演职人员的 `tagKey`,我在尝试为 `actor.tag.tagKey` 赋值时遇到了问题。如果您对此有所了解,请不吝赐教,欢迎通过在项目的 GitHub 页面新增一个 issue 与我联系,我将非常感谢您的反馈和帮助。 6 | 7 | - **操作警告**:在刮削演职人员信息后,可能会出现一些问题,例如丢失在线元数据,或者在 Plex 中无法通过点击演职人员的名字来查看其详细信息。请在操作前备份相关数据,以防不测。 8 | 9 | 在进行任何操作前,请确保您已经做好了完整的数据备份,并理解所有相关的技术细节和潜在风险。如果您有更多关于 Plex API 的技术问题或需求,欢迎与我联系或查阅更多资料。 10 | 11 | #### 保留在线元数据功能注意事项 12 | 13 | - **2024.7.7 由于未知原因,部分媒体库演员数据被清理,因此该方案搁置,相关脚本下线,请勿开启该功能,如已使用该功能,建议尽快恢复数据库备份** 14 | 15 | #### 感谢 16 | 17 | - 本插件基于 [官方插件](https://github.com/jxxghp/MoviePilot-Plugins) 编写,并参考了 [PrettyServer](https://github.com/Bespertrijun/PrettyServer) 项目,实现了插件的相关功能。 18 | - 特此感谢 [jxxghp](https://github.com/jxxghp)、[Bespertrijun](https://github.com/Bespertrijun) 等贡献者的卓越代码贡献。 19 | - 如有未能提及的作者,请告知我以便进行补充。 20 | 21 | ![](../../images/2024-07-13-03-02-10.png) 22 | ![](../../images/2024-06-25-02-57-20.png) 23 | ![](../../images/2024-06-25-02-57-53.png) 24 | 25 | -------------------------------------------------------------------------------- /plugins.v2/plexpersonmeta/helper.py: -------------------------------------------------------------------------------- 1 | """ 2 | helper.py 3 | 4 | 这个模块定义了用于存储媒体项目信息的 `RatingInfo` 数据类以及缓存、限流等装饰器 5 | """ 6 | import functools 7 | from dataclasses import dataclass 8 | from typing import Optional 9 | 10 | from app.core.cache import cache_backend 11 | from app.log import logger 12 | 13 | 14 | @dataclass 15 | class RatingInfo: 16 | """ 17 | 媒体项目信息的数据类 18 | """ 19 | key: Optional[str] = None # 媒体项目的唯一标识 20 | type: Optional[str] = None # 媒体项目的类型(例如:电影、电视剧) 21 | title: Optional[str] = None # 媒体项目的标题 22 | search_title: Optional[str] = None # 用于搜索的标题 23 | tmdbid: Optional[int] = None # TMDB 的唯一标识,可选 24 | 25 | 26 | def cache_with_logging(region, source): 27 | """ 28 | 装饰器,用于在函数执行时处理缓存逻辑和日志记录。 29 | :param region: 缓存区,用于存储和检索缓存数据 30 | :param source: 数据来源,用于日志记录(例如:PERSON 或 MEDIA) 31 | :return: 装饰器函数 32 | """ 33 | 34 | def decorator(func): 35 | 36 | @functools.wraps(func) 37 | def wrapped_func(*args, **kwargs): 38 | key = cache_backend.get_cache_key(func, args, kwargs) 39 | exists_cache = cache_backend.exists(key=key, region=region) 40 | if exists_cache: 41 | value = cache_backend.get(key=key, region=region) 42 | if value is not None: 43 | if source == "PERSON": 44 | logger.info(f"从缓存中获取到 {source} 人物信息") 45 | else: 46 | logger.info(f"从缓存中获取到 {source} 媒体信息: {kwargs.get('title', 'Unknown Title')}") 47 | return value 48 | return None 49 | 50 | # 执行被装饰的函数 51 | result = func(*args, **kwargs) 52 | 53 | if result is None: 54 | # 如果结果为 None,说明触发限流或网络等异常,缓存5分钟,以免高频次调用 55 | cache_backend.set(key, "None", ttl=60 * 5, region=region, maxsize=100000) 56 | else: 57 | # 结果不为 None,使用默认 TTL 缓存 58 | cache_backend.set(key, result, ttl=60 * 60 * 24 * 3, region=region, maxsize=100000) 59 | 60 | return result 61 | 62 | return wrapped_func 63 | 64 | return decorator 65 | -------------------------------------------------------------------------------- /plugins.v2/plexpersonmeta/requirements.txt: -------------------------------------------------------------------------------- 1 | pypinyin~=0.51.0 -------------------------------------------------------------------------------- /plugins.v2/torrentclassifier/classifierconfig.py: -------------------------------------------------------------------------------- 1 | # 该模块定义了用于管理基于YAML配置文件的种子文件分类的类。 2 | from dataclasses import dataclass, field 3 | from typing import List, Optional 4 | 5 | 6 | @dataclass 7 | class TorrentFilter: 8 | """数据类,用于存储种子来源的筛选标准,包括标题、分类和标签。""" 9 | torrent_title: Optional[str] = None # 用于匹配种子标题的正则表达式 10 | torrent_category: Optional[str] = None # 种子必须属于的分类 11 | torrent_tags: Optional[List[str]] = field(default_factory=list) # 种子必须具有的标签,多个标签时,任一满足即可 12 | 13 | def __post_init__(self): 14 | # 移除列表中的空字符串 15 | self.torrent_tags = [tag for tag in self.torrent_tags if tag.strip()] 16 | 17 | 18 | @dataclass 19 | class TorrentTarget: 20 | """数据类,用于存储匹配来源标准的种子的处理设置。""" 21 | change_directory: Optional[str] = None # 匹配种子移动到的目录(如果启用auto_category,则忽略此设置) 22 | change_category: Optional[str] = None # 为种子指定的新分类 23 | add_tags: Optional[List[str]] = field(default_factory=list) # 需要添加到种子的标签 24 | remove_tags: Optional[List[str]] = field(default_factory=list) # 需要从种子移除的标签,'@all' 表示移除所有标签 25 | auto_category: Optional[bool] = False # 是否启用自动分类管理 26 | 27 | def __post_init__(self): 28 | # 移除列表中的空字符串 29 | self.add_tags = [tag for tag in self.add_tags if tag.strip()] 30 | self.remove_tags = [tag for tag in self.remove_tags if tag.strip()] 31 | 32 | 33 | @dataclass 34 | class ClassifierConfig: 35 | """整合种子来源和目标种子设置的数据类。""" 36 | torrent_filter: TorrentFilter # 种子来源配置 37 | torrent_target: TorrentTarget # 目标种子处理配置 38 | -------------------------------------------------------------------------------- /plugins.v2/trafficassistant/trafficconfig.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass, field 2 | from typing import List, Optional 3 | 4 | 5 | @dataclass 6 | class BaseConfig: 7 | """ 8 | 基础配置类,定义所有配置项的结构。 9 | """ 10 | ratio_upper_limit: Optional[float] = None # 分享率的上限 11 | ratio_lower_limit: Optional[float] = None # 分享率的下限 12 | 13 | remove_from_subscription_if_below: Optional[bool] = False # 分享率低于下限时,是否从订阅站点中移除 14 | remove_from_search_if_below: Optional[bool] = False # 分享率低于下限时,是否从搜索站点中移除 15 | enable_auto_brush_if_below: Optional[bool] = False # 分享率低于下限时,是否开启自动刷流 16 | send_alert_if_below: Optional[bool] = False # 分享率低于下限时,是否发送预警消息 17 | 18 | add_to_subscription_if_above: Optional[bool] = False # 分享率高于上限时,是否增加到订阅站点 19 | add_to_search_if_above: Optional[bool] = False # 分享率高于上限时,是否增加到搜索站点 20 | disable_auto_brush_if_above: Optional[bool] = False # 分享率高于上限时,是否关闭自动刷流 21 | 22 | def __post_init__(self): 23 | # 将输入转换为适当的类型 24 | self.ratio_upper_limit = convert_type(self.ratio_upper_limit, float) 25 | self.ratio_lower_limit = convert_type(self.ratio_lower_limit, float) 26 | 27 | 28 | @dataclass 29 | class TrafficConfig(BaseConfig): 30 | """ 31 | 全局配置类,继承自基础配置类,添加全局特有的配置项。 32 | """ 33 | enabled: Optional[bool] = False # 启用插件 34 | sites: List[int] = field(default_factory=list) # 站点列表 35 | site_infos: dict = None # 站点信息字典 36 | onlyonce: Optional[bool] = False # 立即运行一次 37 | notify: Optional[bool] = False # 发送通知 38 | cron: Optional[str] = None # 执行周期 39 | brush_plugin: Optional[str] = None # 站点刷流插件 40 | statistic_plugin: Optional[str] = "SiteStatistic" # 站点数据统计插件 41 | 42 | 43 | @dataclass 44 | class SiteConfig(BaseConfig): 45 | """ 46 | 站点配置类,继承自基础配置类,添加站点特有的标识属性。 47 | """ 48 | site_name: Optional[str] = None # 站点名称 49 | 50 | 51 | def convert_type(value, target_type): 52 | """ 53 | 将给定值转换为指定的目标类型。如果转换失败,则返回该类型的自然默认值。 54 | """ 55 | try: 56 | if target_type == float: 57 | return float(value) 58 | if target_type == int: 59 | return int(value) 60 | # 可以根据需要添加其他类型 61 | except (ValueError, TypeError): 62 | # 如果转换失败,则返回类型的自然默认值 63 | if target_type == float: 64 | return 0.0 65 | if target_type == int: 66 | return 0 67 | return None # 未指定类型的默认值 68 | 69 | 70 | def merge_configs(global_config: TrafficConfig, site_config: Optional[SiteConfig]) -> BaseConfig: 71 | """ 72 | 根据全局配置和站点配置合并得到最终的配置对象。 73 | 站点配置将覆盖全局配置中的相应项。 74 | """ 75 | final_config = vars(global_config).copy() # 复制全局配置 76 | if site_config: 77 | # 遍历站点配置,覆盖全局配置 78 | for key, value in vars(site_config).items(): 79 | if value is not None: 80 | final_config[key] = value 81 | return BaseConfig(**final_config) # 返回新的 BaseConfig 实例 82 | -------------------------------------------------------------------------------- /plugins/customplugin/custom.py: -------------------------------------------------------------------------------- 1 | # 演示代码示例,继承UserTaskBase,并实现start以及stop 2 | # 导入logger用于日志记录 3 | from app.log import logger 4 | # 导入UserTaskBase作为基类 5 | from app.plugins.customplugin.task import UserTaskBase 6 | 7 | 8 | # 定义一个继承自UserTaskBase的类HelloWorld 9 | class HelloWorld(UserTaskBase): 10 | def start(self): 11 | """ 12 | 开始任务时调用此方法 13 | """ 14 | # 记录任务开始的信息 15 | logger.info("Hello World. Start.") 16 | 17 | def stop(self): 18 | """ 19 | 停止任务时调用此方法 20 | """ 21 | # 记录任务停止的信息 22 | logger.info("Hello World. Stop.") -------------------------------------------------------------------------------- /plugins/customplugin/task.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | 3 | 4 | class UserTaskBase(ABC): 5 | 6 | @abstractmethod 7 | def start(self): 8 | """ 9 | 启动用户任务的方法,必须由用户定义的类实现 10 | """ 11 | pass 12 | 13 | @abstractmethod 14 | def stop(self): 15 | """ 16 | 停止用户任务的方法,必须由用户定义的类实现 17 | """ 18 | pass 19 | -------------------------------------------------------------------------------- /plugins/hitandrun/entities.py: -------------------------------------------------------------------------------- 1 | import json 2 | import time 3 | from datetime import datetime 4 | from enum import Enum 5 | from typing import Optional 6 | 7 | import pytz 8 | from pydantic import BaseModel, Field 9 | 10 | from app.core.config import settings 11 | from app.core.context import TorrentInfo 12 | 13 | 14 | class HNRStatus(Enum): 15 | PENDING = "Pending" # 待确认,等待进行做种或上传下载比的验证 16 | IN_PROGRESS = "In Progress" # 进行中,用户正在努力满足做种或分享率要求 17 | COMPLIANT = "Compliant" # 已满足,用户已成功满足所有做种和分享率要求 18 | UNRESTRICTED = "Unrestricted" # 无限制,用户没有任何做种或分享率限制 19 | NEEDS_SEEDING = "Needs Seeding" # 需要做种,用户需要增加做种时间来避免受到惩罚 20 | OVERDUE = "Overdue" # 已过期,用户已超过做种期限但未满足要求 21 | WARNED = "Warned" # 已警告,用户因未达到做种要求而收到警告 22 | BANNED = "Banned" # 已封禁,用户因严重违反做种规则被封禁 23 | 24 | def to_chinese(self): 25 | descriptions = { 26 | "Pending": "待确认", 27 | "In Progress": "进行中", 28 | "Compliant": "已满足", 29 | "Unrestricted": "无限制", 30 | "Needs Seeding": "需要做种", 31 | "Overdue": "已过期", 32 | "Warned": "已警告", 33 | "Banned": "已封禁" 34 | } 35 | return descriptions[self.value] 36 | 37 | 38 | class TaskType(Enum): 39 | BRUSH = "Brush" # 刷流 40 | NORMAL = "Normal" # 普通 41 | AUTO_SUBSCRIBE = "Auto Subscribe" # 自动订阅 42 | RSS_SUBSCRIBE = "RSS Subscribe" # RSS订阅 43 | 44 | def to_chinese(self): 45 | descriptions = { 46 | "Brush": "刷流", 47 | "Normal": "普通", 48 | "Auto Subscribe": "自动订阅", 49 | "RSS Subscribe": "RSS订阅" 50 | } 51 | return descriptions[self.value] 52 | 53 | 54 | class TorrentHistory(BaseModel): 55 | site: Optional[int] = None # 站点ID 56 | site_name: Optional[str] = None # 站点名称 57 | title: Optional[str] = None # 种子名称 58 | description: Optional[str] = None # 种子副标题 59 | enclosure: Optional[str] = None # 种子链接 60 | page_url: Optional[str] = None # 详情页面 61 | size: float = 0 # 种子大小 62 | pubdate: Optional[str] = None # 发布时间 63 | hit_and_run: bool = False # HR 64 | time: Optional[float] = Field(default_factory=time.time) 65 | hash: Optional[str] = None # 种子Hash 66 | task_type: TaskType = TaskType.NORMAL # 任务类型 67 | 68 | @classmethod 69 | def from_torrent_info(cls, torrent_info: TorrentInfo): 70 | """通过TorrentInfo实例化""" 71 | # 使用字典解包初始化TorrentTask 72 | return cls(**torrent_info.__dict__) 73 | 74 | # 模型配置 75 | class Config: 76 | extra = "ignore" 77 | arbitrary_types_allowed = True 78 | 79 | def to_dict(self, **kwargs): 80 | """ 81 | 返回字典 82 | """ 83 | json_str = self.json(**kwargs) 84 | instance = json.loads(json_str) 85 | return instance 86 | 87 | @classmethod 88 | def from_dict(cls, data: dict): 89 | """ 90 | 实例化 91 | """ 92 | config_json = json.dumps(data) 93 | return cls.parse_raw(config_json) 94 | 95 | 96 | class TorrentTask(TorrentHistory): 97 | hr_status: Optional[HNRStatus] = HNRStatus.PENDING # H&R状态 98 | hr_duration: Optional[float] = None # H&R时间(小时) 99 | hr_ratio: Optional[float] = None # H&R分享率 100 | hr_deadline_days: Optional[float] = None # H&R满足要求的期限(天数) 101 | ratio: Optional[float] = 0.0 # 分享率 102 | downloaded: Optional[float] = 0.0 # 下载量 103 | uploaded: Optional[float] = 0.0 # 上传量 104 | seeding_time: Optional[float] = 0.0 # 做种时间(秒) 105 | deleted: Optional[bool] = False # 是否已删除 106 | time: Optional[float] = Field(default_factory=time.time) # 任务创建时间 107 | deleted_time: Optional[float] = None # 种子删除时间 108 | hr_met_time: Optional[float] = None # 满足H&R要求的时间 109 | 110 | @property 111 | def identifier(self) -> str: 112 | """ 113 | 获取种子标识符 114 | """ 115 | parts = [self.title, self.description] 116 | return " | ".join(part.strip() for part in parts if part and part.strip()) 117 | 118 | @property 119 | def deadline_time(self) -> float: 120 | """ 121 | 获取截止时间的 Unix 时间戳 122 | """ 123 | deadline_time = self.time + self.hr_deadline_days * 86400 124 | return deadline_time 125 | 126 | def formatted_deadline(self) -> str: 127 | """ 128 | 获取格式化的截止时间 129 | """ 130 | deadline_time_local = datetime.fromtimestamp(self.deadline_time, pytz.timezone(settings.TZ)) 131 | return deadline_time_local.strftime('%Y-%m-%d %H:%M') 132 | 133 | def remain_time(self, additional_seed_time: Optional[float] = 0.0) -> Optional[float]: 134 | """ 135 | 剩余时间(小时) 136 | """ 137 | if self.hr_status in [HNRStatus.COMPLIANT, HNRStatus.UNRESTRICTED]: 138 | return None 139 | # 计算所需做种总时间(小时) 140 | required_seeding_hours = (self.hr_duration or 0) + (additional_seed_time or 0) 141 | # 计算已做种时间(小时) 142 | seeding_hours = self.seeding_time / 3600 if self.seeding_time else 0 143 | # 计算剩余做种时间 144 | remain_hours = required_seeding_hours - seeding_hours 145 | # 确保剩余时间不小于0 146 | return max(remain_hours, 0) 147 | 148 | @staticmethod 149 | def format_to_chinese(value): 150 | return value.to_chinese() if hasattr(value, "to_chinese") else value 151 | -------------------------------------------------------------------------------- /plugins/hitandrun/hnrconfig.py: -------------------------------------------------------------------------------- 1 | import json 2 | from enum import Enum 3 | from typing import Optional, List, Dict 4 | 5 | from pydantic import BaseModel, root_validator, validator 6 | from ruamel.yaml import YAML, YAMLError 7 | 8 | from app.log import logger 9 | 10 | 11 | class NotifyMode(Enum): 12 | NONE = "none" # 不发送 13 | ON_ERROR = "on_error" # 仅异常时发送 14 | ALWAYS = "always" # 发送所有通知 15 | 16 | 17 | class BaseConfig(BaseModel): 18 | """ 19 | 基础配置类,定义所有配置项的结构 20 | """ 21 | hr_duration: Optional[float] = None # H&R时间(小时) 22 | additional_seed_time: Optional[float] = None # 附加做种时间(小时) 23 | hr_ratio: Optional[float] = None # H&R分享率 24 | hr_active: Optional[bool] = False # H&R激活 25 | hr_deadline_days: Optional[float] = None # H&R满足要求的期限(天数) 26 | 27 | # 模型配置 28 | class Config: 29 | extra = "ignore" 30 | arbitrary_types_allowed = True 31 | 32 | @staticmethod 33 | def json_dumps(v, *, default): 34 | return json.dumps(v, ensure_ascii=False, default=default) 35 | 36 | def to_dict(self, **kwargs): 37 | """ 38 | 返回字典 39 | """ 40 | config_json = self.json(**kwargs) 41 | config_mapping = json.loads(config_json) 42 | return config_mapping 43 | 44 | @property 45 | def hr_seed_time(self) -> Optional[float]: 46 | """ 47 | H&R做种时间(小时) 48 | """ 49 | return (self.hr_duration or 0.0) + (self.additional_seed_time or 0.0) 50 | 51 | 52 | class SiteConfig(BaseConfig): 53 | """ 54 | 站点配置类,继承自基础配置类,添加站点特有的标识属性 55 | """ 56 | site_name: Optional[str] = None # 站点名称 57 | 58 | 59 | class HNRConfig(BaseConfig): 60 | """ 61 | 全局配置类,继承自基础配置类,添加全局特有的配置项 62 | """ 63 | enabled: Optional[bool] = False # 启用插件 64 | check_period: int = 5 # 检查周期 65 | sites: List[int] = [] # 站点列表 66 | site_infos: Dict = {} # 站点信息字典 67 | onlyonce: Optional[bool] = False # 立即运行一次 68 | notify: NotifyMode = NotifyMode.ALWAYS # 发送通知的模式 69 | brush_plugin: Optional[str] = None # 站点刷流插件 70 | auto_monitor: Optional[bool] = False # 自动监控(实验性功能) 71 | downloader: Optional[str] = None # 下载器 72 | hit_and_run_tag: Optional[str] = None # 种子标签 73 | auto_cleanup_days: float = 7 # 自动清理已删除或满足H&R要求的任务 74 | enable_site_config: Optional[bool] = False # 启用站点独立配置 75 | site_config_str: Optional[str] = None # 站点独立配置的字符串 76 | site_configs: Dict[str, SiteConfig] = {} # 站点独立配置(根据配置字符串解析后的字典) 77 | 78 | @root_validator(pre=True, allow_reuse=True) 79 | def __check_enums(cls, values): 80 | """校验枚举值""" 81 | # 处理 notify 字段 82 | notify_value = values.get("notify") 83 | all_values = {member.value for member in NotifyMode} 84 | if notify_value not in all_values: 85 | values["notify"] = NotifyMode.ALWAYS 86 | return values 87 | 88 | @validator("*", pre=True, allow_reuse=True) 89 | def __empty_string_to_float(cls, v, values, field): 90 | """ 91 | 校验空字符 92 | """ 93 | if field.type_ is float and not v: 94 | return 0.0 95 | return v 96 | 97 | @validator("auto_cleanup_days", pre=True, allow_reuse=True) 98 | def set_default_auto_cleanup_days(cls, v): 99 | """ 100 | 当 auto_cleanup_days 为 None 时,设置为 7 101 | """ 102 | if v is None: 103 | return 7 104 | return v 105 | 106 | def __init__(self, **data): 107 | super().__init__(**data) 108 | self.__post_init__() 109 | 110 | def __post_init__(self): 111 | """ 112 | 初始化完成 113 | """ 114 | self.__process_site_configs() 115 | 116 | def __process_site_configs(self): 117 | """ 118 | 校验并解析站点独立配置 119 | """ 120 | if self.enable_site_config: 121 | if self.site_config_str: 122 | self.site_configs = self.__parse_yaml_config(self.site_config_str) 123 | if self.site_configs: 124 | for site_name, site_config in self.site_configs.items(): 125 | self.site_configs[site_name] = self.__merge_site_config(site_config=site_config) 126 | else: 127 | logger.error("YAML解析失败,站点独立配置已禁用") 128 | self.enable_site_config = False 129 | else: 130 | logger.warning("已启用站点独立配置,但未提供配置字符串,站点独立配置已禁用") 131 | self.enable_site_config = False 132 | 133 | @staticmethod 134 | def __parse_yaml_config(yaml_str: str) -> Optional[Dict[str, SiteConfig]]: 135 | """ 136 | 解析YAML字符串为站点配置字典 137 | """ 138 | yaml = YAML(typ="safe") 139 | try: 140 | data = yaml.load(yaml_str) 141 | site_configs = {} 142 | for item in data: 143 | site_name = item.get("site_name") 144 | if site_name: 145 | try: 146 | site_configs[site_name] = SiteConfig(**item) 147 | except Exception as e: 148 | logger.error(f"站点 {site_name} 无效,忽略该站点配置,{e}") 149 | return site_configs 150 | except YAMLError as e: 151 | logger.error(f"无法获取站点独立配置信息,YAML解析错误: {e}") 152 | return None 153 | 154 | def __merge_site_config(self, site_config: SiteConfig) -> SiteConfig: 155 | """ 156 | 合并站点配置 157 | """ 158 | for field_name, field_info in SiteConfig.__fields__.items(): 159 | # 获取当前 site_config 对象中的字段值 160 | current_value = getattr(site_config, field_name, None) 161 | # 如果当前字段值为 None,则尝试从 HNRConfig 实例中获取同名字段的默认值 162 | if current_value is None: 163 | # 尝试从 HNRConfig 实例获取默认值,如果不存在则使用 Pydantic 字段的默认值 164 | default_value = getattr(self, field_name, field_info.default) 165 | # 设置 site_config 对象的字段值 166 | setattr(site_config, field_name, default_value) 167 | 168 | return site_config 169 | 170 | def get_site_config(self, site_name: str) -> SiteConfig: 171 | """ 172 | 根据站点名称返回合并后的配置 173 | """ 174 | site_config = self.site_configs.get(site_name) 175 | if site_config: 176 | return site_config 177 | else: 178 | # 使用 __fields__ 获取所有字段并从实例中获取对应值 179 | base_config_attrs = {field: getattr(self, field) for field in self.__fields__} 180 | return SiteConfig(**base_config_attrs, site_name=site_name) 181 | -------------------------------------------------------------------------------- /plugins/hitandrun/rule.yaml: -------------------------------------------------------------------------------- 1 | ####### 配置说明 BEGIN ####### 2 | # 1. 此配置文件专门用于设定各站点的特定配置,包括做种时间、H&R激活状态等。 3 | # 2. 配置项通过数组形式组织,每个站点的配置作为数组的一个元素,以‘-’标记开头。 4 | # 3. 如果某站点的具体配置项与全局配置相同,则无需单独设置该项,默认采用全局配置。 5 | ####### 配置说明 END ####### 6 | 7 | - # 站点名称,用于标识适用于哪个站点 8 | site_name: '彩虹岛' 9 | # H&R时间(小时),站点默认的H&R时间,做种时间达到H&R时间后移除标签 10 | hr_duration: 120.0 11 | # 附加做种时间(小时),在H&R时间上额外增加的做种时长 12 | # additional_seed_time: 24.0 13 | # 分享率,做种时期望达到的分享比例,达到目标分享率后移除标签 14 | hr_ratio: 99.0 15 | # H&R激活,站点是否已启用全站H&R,开启后所有种子均视为H&R种子 16 | hr_active: false 17 | # H&R满足要求的期限(天数),需在此天数内满足H&R要求 18 | hr_deadline_days: 20 19 | 20 | - # 站点名称,用于标识适用于哪个站点 21 | site_name: '皇后' 22 | # H&R时间(小时),站点默认的H&R时间,做种时间达到H&R时间后移除标签 23 | hr_duration: 36.0 24 | # 附加做种时间(小时),在H&R时间上额外增加的做种时长 25 | # additional_seed_time: 24.0 (与全局配置保持一致,无需单独设置,注释处理) 26 | # 分享率,做种时期望达到的分享比例,达到目标分享率后移除标签 27 | # hr_ratio: 99.0(与全局配置保持一致,无需单独设置,注释处理) 28 | # H&R激活,站点是否已启用全站H&R,开启后所有种子均视为H&R种子 29 | hr_active: true 30 | # H&R满足要求的期限(天数),需在此天数内满足H&R要求 31 | hr_deadline_days: 30 32 | 33 | - # 站点名称,用于标识适用于哪个站点 34 | site_name: '观众' 35 | # H&R时间(小时),站点默认的H&R时间,做种时间达到H&R时间后移除标签 36 | hr_duration: 48.0 37 | # 附加做种时间(小时),在H&R时间上额外增加的做种时长 38 | # additional_seed_time: 24.0 (与全局配置保持一致,无需单独设置,注释处理) 39 | # 分享率,做种时期望达到的分享比例,达到目标分享率后移除标签 40 | # hr_ratio: 99.0(与全局配置保持一致,无需单独设置,注释处理) 41 | # H&R激活,站点是否已启用全站H&R,开启后所有种子均视为H&R种子 42 | hr_active: false 43 | # H&R满足要求的期限(天数),需在此天数内满足H&R要求 44 | hr_deadline_days: 14 45 | 46 | - # 站点名称,用于标识适用于哪个站点 47 | site_name: '听听歌' 48 | # H&R时间(小时),站点默认的H&R时间,做种时间达到H&R时间后移除标签 49 | hr_duration: 60.0 50 | # 附加做种时间(小时),在H&R时间上额外增加的做种时长 51 | # additional_seed_time: 24.0 (与全局配置保持一致,无需单独设置,注释处理) 52 | # 分享率,做种时期望达到的分享比例,达到目标分享率后移除标签 53 | # hr_ratio: 99.0(与全局配置保持一致,无需单独设置,注释处理) 54 | # H&R激活,站点是否已启用全站H&R,开启后所有种子均视为H&R种子 55 | hr_active: false 56 | # H&R满足要求的期限(天数),需在此天数内满足H&R要求 57 | hr_deadline_days: 14 58 | 59 | - # 站点名称,用于标识适用于哪个站点 60 | site_name: '家园' 61 | # H&R时间(小时),站点默认的H&R时间,做种时间达到H&R时间后移除标签 62 | hr_duration: 336.0 63 | # 附加做种时间(小时),在H&R时间上额外增加的做种时长 64 | # additional_seed_time: 24.0 (与全局配置保持一致,无需单独设置,注释处理) 65 | # 分享率,做种时期望达到的分享比例,达到目标分享率后移除标签 66 | # hr_ratio: 99.0(与全局配置保持一致,无需单独设置,注释处理) 67 | # H&R激活,站点是否已启用全站H&R,开启后所有种子均视为H&R种子 68 | hr_active: false 69 | # H&R满足要求的期限(天数),需在此天数内满足H&R要求 70 | hr_deadline_days: 60 71 | 72 | - # 站点名称,用于标识适用于哪个站点 73 | site_name: '我堡' 74 | # H&R时间(小时),站点默认的H&R时间,做种时间达到H&R时间后移除标签 75 | hr_duration: 48.0 76 | # 附加做种时间(小时),在H&R时间上额外增加的做种时长 77 | # additional_seed_time: 24.0 (与全局配置保持一致,无需单独设置,注释处理) 78 | # 分享率,做种时期望达到的分享比例,达到目标分享率后移除标签 79 | # hr_ratio: 99.0(与全局配置保持一致,无需单独设置,注释处理) 80 | # H&R激活,站点是否已启用全站H&R,开启后所有种子均视为H&R种子 81 | hr_active: false 82 | # H&R满足要求的期限(天数),需在此天数内满足H&R要求 83 | hr_deadline_days: 14 84 | 85 | - # 站点名称,用于标识适用于哪个站点 86 | site_name: '高清杜比' 87 | # H&R时间(小时),站点默认的H&R时间,做种时间达到H&R时间后移除标签 88 | hr_duration: 48.0 89 | # 附加做种时间(小时),在H&R时间上额外增加的做种时长 90 | # additional_seed_time: 24.0 (与全局配置保持一致,无需单独设置,注释处理) 91 | # 分享率,做种时期望达到的分享比例,达到目标分享率后移除标签 92 | hr_ratio: 1.6 93 | # H&R激活,站点是否已启用全站H&R,开启后所有种子均视为H&R种子 94 | hr_active: false 95 | # H&R满足要求的期限(天数),需在此天数内满足H&R要求 96 | hr_deadline_days: 30 97 | 98 | - # 站点名称,用于标识适用于哪个站点 99 | site_name: '憨憨' 100 | # H&R时间(小时),站点默认的H&R时间,做种时间达到H&R时间后移除标签 101 | hr_duration: 72.0 102 | # 附加做种时间(小时),在H&R时间上额外增加的做种时长 103 | # additional_seed_time: 24.0 (与全局配置保持一致,无需单独设置,注释处理) 104 | # 分享率,做种时期望达到的分享比例,达到目标分享率后移除标签 105 | hr_ratio: 99 106 | # H&R激活,站点是否已启用全站H&R,开启后所有种子均视为H&R种子 107 | hr_active: false 108 | # H&R满足要求的期限(天数),需在此天数内满足H&R要求 109 | hr_deadline_days: 20 -------------------------------------------------------------------------------- /plugins/plexautolanguages/config/default.yaml: -------------------------------------------------------------------------------- 1 | plexautolanguages: 2 | # 更新整个剧集的语言或仅更新当前季 3 | # 可接受的值: 4 | # - show(默认) 5 | # - season 6 | update_level: "show" 7 | 8 | # 更新剧集/季的所有剧集或仅更新接下来的剧集 9 | # 可接受的值: 10 | # - all(默认) 11 | # - next 12 | update_strategy: "all" 13 | 14 | # 播放文件是否触发语言更新,默认为 'true' 15 | trigger_on_play: true 16 | 17 | # 扫描新文件时是否触发语言更新,默认为 'true' 18 | # 新增的剧集将根据最近观看的剧集更新语言,若该剧从未被观看则根据第一集更新语言 19 | trigger_on_scan: true 20 | 21 | # 浏览 Plex 库是否触发语言更新,默认为 'false' 22 | # 仅 Plex 网络客户端和 Plex for Windows 应用程序支持此功能 23 | # 仅当您希望在更新剧集的默认轨道时执行更改,即使未播放该剧集时,才将此参数设置为 'true' 24 | # 将此参数设置为 'true' 可能会导致更高的资源使用 25 | trigger_on_activity: false 26 | 27 | # 每当 Plex 服务器扫描其库时是否刷新缓存库,默认为 'true' 28 | # 禁用此参数将阻止 PlexAutoLanguages 检测已存在剧集的更新文件 29 | # 如果您的电视剧库很大(10k+ 剧集),建议禁用此参数 30 | refresh_library_on_scan: false 31 | 32 | # PlexAutoLanguages 将忽略具有以下任何 Plex 标签的剧集 33 | ignore_labels: 34 | - PAL_IGNORE 35 | 36 | # Plex 配置 37 | plex: 38 | # 一个有效的 Plex URL(必填) 39 | url: "http://plex:32400" 40 | # 一个有效的 Plex 令牌(必填) 41 | token: "MY_PLEX_TOKEN" 42 | 43 | scheduler: 44 | # 是否启用调度程序,默认为 'true' 45 | # 调度程序将对所有最近播放的电视剧进行更深入的分析 46 | enable: false 47 | # 调度程序开始任务的时间,格式为 'HH:MM',默认为 '02:00' 48 | schedule_time: "04:30" 49 | 50 | notifications: 51 | # 是否通过 Apprise 启用通知,默认为 'false' 52 | # 每当进行语言更改时发送通知 53 | enable: false 54 | # Apprise 配置的数组,详见 Apprise 文档:https://github.com/caronc/apprise 55 | # 数组 'users' 可以指定以将通知 URL 与特定用户关联 56 | # 如果不存在,默认为所有用户 57 | # 数组 'events' 可以指定以仅获取特定事件的通知 58 | # 有效的事件值:"play_or_activity" "new_episode" "updated_episode" "scheduler" 59 | # 如果不存在,默认为所有事件 60 | apprise_configs: 61 | # 此 URL 将在所有事件期间通知所有更改 62 | - "discord://webhook_id/webhook_token" 63 | # 这些 URL 将仅在用户 "MyUser1" 和 "MyUser2" 的语言更改时通知 64 | - urls: 65 | - "gotify://hostname/token" 66 | - "pover://user@token" 67 | users: 68 | - "MyUser1" 69 | - "MyUser2" 70 | # 此 URL 将仅在用户 "MyUser3" 的播放或活动事件期间通知语言更改 71 | - urls: 72 | - "tgram://bottoken/ChatID" 73 | users: 74 | - "MyUser3" 75 | events: 76 | - "play_or_activity" 77 | # 此 URL 将仅在调度程序任务期间通知语言更改 78 | - urls: 79 | - "gotify://hostname/token" 80 | events: 81 | - "scheduler" 82 | - "..." 83 | 84 | # 是否启用调试模式,默认为 'false' 85 | # 启用调试模式将显著增加输出日志的数量 86 | debug: true 87 | -------------------------------------------------------------------------------- /plugins/plexautolanguages/config/user.yaml: -------------------------------------------------------------------------------- 1 | plexautolanguages: 2 | # 更新整个剧集的语言或仅更新当前季 3 | # 可接受的值: 4 | # - show(默认) 5 | # - season 6 | update_level: "show" 7 | 8 | # 更新剧集/季的所有剧集或仅更新接下来的剧集 9 | # 可接受的值: 10 | # - all(默认) 11 | # - next 12 | update_strategy: "all" 13 | 14 | # 播放文件是否触发语言更新,默认为 'true' 15 | trigger_on_play: true 16 | 17 | # 扫描新文件时是否触发语言更新,默认为 'true' 18 | # 新增的剧集将根据最近观看的剧集更新语言,若该剧从未被观看则根据第一集更新语言 19 | trigger_on_scan: true 20 | 21 | # 浏览 Plex 库是否触发语言更新,默认为 'false' 22 | # 仅 Plex 网络客户端和 Plex for Windows 应用程序支持此功能 23 | # 仅当您希望在更新剧集的默认轨道时执行更改,即使未播放该剧集时,才将此参数设置为 'true' 24 | # 将此参数设置为 'true' 可能会导致更高的资源使用 25 | trigger_on_activity: false 26 | 27 | # 每当 Plex 服务器扫描其库时是否刷新缓存库,默认为 'true' 28 | # 禁用此参数将阻止 PlexAutoLanguages 检测已存在剧集的更新文件 29 | # 如果您的电视剧库很大(10k+ 剧集),建议禁用此参数 30 | refresh_library_on_scan: false 31 | 32 | # PlexAutoLanguages 将忽略具有以下任何 Plex 标签的剧集 33 | ignore_labels: 34 | - PAL_IGNORE 35 | 36 | # Plex 配置 37 | plex: 38 | # 一个有效的 Plex URL(必填) 39 | url: "http://plex:32400" 40 | # 一个有效的 Plex 令牌(必填) 41 | token: "MY_PLEX_TOKEN" 42 | 43 | scheduler: 44 | # 是否启用调度程序,默认为 'true' 45 | # 调度程序将对所有最近播放的电视剧进行更深入的分析 46 | enable: false 47 | # 调度程序开始任务的时间,格式为 'HH:MM',默认为 '02:00' 48 | schedule_time: "04:30" 49 | 50 | notifications: 51 | # 是否通过 Apprise 启用通知,默认为 'false' 52 | # 每当进行语言更改时发送通知 53 | enable: false 54 | # Apprise 配置的数组,详见 Apprise 文档:https://github.com/caronc/apprise 55 | # 数组 'users' 可以指定以将通知 URL 与特定用户关联 56 | # 如果不存在,默认为所有用户 57 | # 数组 'events' 可以指定以仅获取特定事件的通知 58 | # 有效的事件值:"play_or_activity" "new_episode" "updated_episode" "scheduler" 59 | # 如果不存在,默认为所有事件 60 | apprise_configs: 61 | # 此 URL 将在所有事件期间通知所有更改 62 | - "discord://webhook_id/webhook_token" 63 | # 这些 URL 将仅在用户 "MyUser1" 和 "MyUser2" 的语言更改时通知 64 | - urls: 65 | - "gotify://hostname/token" 66 | - "pover://user@token" 67 | users: 68 | - "MyUser1" 69 | - "MyUser2" 70 | # 此 URL 将仅在用户 "MyUser3" 的播放或活动事件期间通知语言更改 71 | - urls: 72 | - "tgram://bottoken/ChatID" 73 | users: 74 | - "MyUser3" 75 | events: 76 | - "play_or_activity" 77 | # 此 URL 将仅在调度程序任务期间通知语言更改 78 | - urls: 79 | - "gotify://hostname/token" 80 | events: 81 | - "scheduler" 82 | - "..." 83 | 84 | # 是否启用调试模式,默认为 'false' 85 | # 启用调试模式将显著增加输出日志的数量 86 | debug: true 87 | -------------------------------------------------------------------------------- /plugins/plexautolanguages/core/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InfinityPacer/MoviePilot-Plugins/a60089999fafc7ffb7143dc09d2a627e4d839f59/plugins/plexautolanguages/core/__init__.py -------------------------------------------------------------------------------- /plugins/plexautolanguages/core/alerts/__init__.py: -------------------------------------------------------------------------------- 1 | from .base import PlexAlert # noqa: F401 2 | from .activity import PlexActivity # noqa: F401 3 | from .playing import PlexPlaying # noqa: F401 4 | from .timeline import PlexTimeline # noqa: F401 5 | from .status import PlexStatus # noqa: F401 6 | -------------------------------------------------------------------------------- /plugins/plexautolanguages/core/alerts/activity.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from typing import TYPE_CHECKING 3 | from datetime import datetime, timedelta 4 | from plexapi.video import Episode 5 | 6 | from app.plugins.plexautolanguages.core.alerts.base import PlexAlert 7 | from app.plugins.plexautolanguages.core.utils.logger import get_logger 8 | from app.plugins.plexautolanguages.core.constants import EventType 9 | 10 | if TYPE_CHECKING: 11 | from app.plugins.plexautolanguages.core.plex_server import PlexServer 12 | 13 | 14 | logger = get_logger() 15 | 16 | 17 | class PlexActivity(PlexAlert): 18 | 19 | TYPE = "activity" 20 | 21 | TYPE_LIBRARY_REFRESH_ITEM = "library.refresh.items" 22 | TYPE_LIBRARY_UPDATE_SECTION = "library.update.section" 23 | TYPE_PROVIDER_SUBSCRIPTIONS_PROCESS = "provider.subscriptions.process" 24 | TYPE_MEDIA_GENERATE_BIF = "media.generate.bif" 25 | 26 | def is_type(self, activity_type: str): 27 | return self.type == activity_type 28 | 29 | @property 30 | def event(self): 31 | return self._message.get("event", None) 32 | 33 | @property 34 | def type(self): 35 | return self._message.get("Activity", {}).get("type", None) 36 | 37 | @property 38 | def item_key(self): 39 | return self._message.get("Activity", {}).get("Context", {}).get("key", None) 40 | 41 | @property 42 | def user_id(self): 43 | return self._message.get("Activity", {}).get("userID", None) 44 | 45 | def process(self, plex: PlexServer): 46 | if self.event != "ended": 47 | return 48 | if not self.is_type(self.TYPE_LIBRARY_REFRESH_ITEM): 49 | return 50 | 51 | # Switch to the user's Plex instance 52 | user_plex = plex.get_plex_instance_of_user(self.user_id) 53 | if user_plex is None: 54 | return 55 | 56 | # Skip if not an Episode 57 | item = user_plex.fetch_item(self.item_key) 58 | if item is None or not isinstance(item, Episode): 59 | return 60 | 61 | # Skip if the show should be ignored 62 | if plex.should_ignore_show(item.show()): 63 | logger.debug(f"[Activity] Ignoring episode {item} due to Plex show labels") 64 | return 65 | 66 | # Skip if this item has already been seen in the last 3 seconds 67 | activity_key = (self.user_id, self.item_key) 68 | if activity_key in plex.cache.recent_activities and \ 69 | plex.cache.recent_activities[activity_key] > datetime.now() - timedelta(seconds=3): 70 | return 71 | plex.cache.recent_activities[activity_key] = datetime.now() 72 | 73 | # Change tracks if needed 74 | item.reload() 75 | user = plex.get_user_by_id(self.user_id) 76 | if user is None: 77 | return 78 | logger.debug(f"[Activity] User: {user.name} | Episode: {item}") 79 | plex.change_tracks(user.name, item, EventType.PLAY_OR_ACTIVITY) 80 | -------------------------------------------------------------------------------- /plugins/plexautolanguages/core/alerts/base.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from typing import TYPE_CHECKING 3 | 4 | if TYPE_CHECKING: 5 | from app.plugins.plexautolanguages.core.plex_server import PlexServer 6 | 7 | 8 | class PlexAlert(): 9 | 10 | TYPE = None 11 | 12 | def __init__(self, message: dict): 13 | self._message = message 14 | 15 | @property 16 | def message(self): 17 | return self._message 18 | 19 | def process(self, plex: PlexServer): 20 | raise NotImplementedError 21 | -------------------------------------------------------------------------------- /plugins/plexautolanguages/core/alerts/playing.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from typing import TYPE_CHECKING 3 | from plexapi.video import Episode 4 | 5 | from app.plugins.plexautolanguages.core.alerts.base import PlexAlert 6 | from app.plugins.plexautolanguages.core.utils.logger import get_logger 7 | from app.plugins.plexautolanguages.core.constants import EventType 8 | 9 | if TYPE_CHECKING: 10 | from app.plugins.plexautolanguages.core.plex_server import PlexServer 11 | 12 | 13 | logger = get_logger() 14 | 15 | 16 | class PlexPlaying(PlexAlert): 17 | 18 | TYPE = "playing" 19 | 20 | @property 21 | def client_identifier(self): 22 | return self._message.get("clientIdentifier", None) 23 | 24 | @property 25 | def item_key(self): 26 | return self._message.get("key", None) 27 | 28 | @property 29 | def session_key(self): 30 | return self._message.get("sessionKey", None) 31 | 32 | @property 33 | def session_state(self): 34 | return self._message.get("state", None) 35 | 36 | def process(self, plex: PlexServer): 37 | # Get User id and user's Plex instance 38 | if self.client_identifier not in plex.cache.user_clients: 39 | user_id, username = plex.get_user_from_client_identifier(self.client_identifier) 40 | if user_id is None: 41 | return 42 | plex.cache.user_clients[self.client_identifier] = (user_id, username) 43 | else: 44 | user_id, username = plex.cache.user_clients[self.client_identifier] 45 | user_plex = plex.get_plex_instance_of_user(user_id) 46 | if user_plex is None: 47 | return 48 | 49 | # Skip if not an Episode 50 | item = user_plex.fetch_item(self.item_key) 51 | if item is None or not isinstance(item, Episode): 52 | return 53 | 54 | # Skip if the show should be ignored 55 | if plex.should_ignore_show(item.show()): 56 | logger.debug(f"[Play Session] Ignoring episode {item} due to Plex show labels") 57 | return 58 | 59 | # Skip is the session state is unchanged 60 | if self.session_key in plex.cache.session_states and plex.cache.session_states[self.session_key] == self.session_state: 61 | return 62 | logger.debug(f"[Play Session] " 63 | f"Session: {self.session_key} | State: '{self.session_state}' | User id: {user_id} | Episode: {item}") 64 | plex.cache.session_states[self.session_key] = self.session_state 65 | 66 | # Reset cache if the session is stopped 67 | if self.session_state == "stopped": 68 | logger.debug(f"[Play Session] End of session {self.session_key} for user {user_id}") 69 | del plex.cache.session_states[self.session_key] 70 | del plex.cache.user_clients[self.client_identifier] 71 | 72 | # Skip if selected streams are unchanged 73 | item.reload() 74 | audio_stream, subtitle_stream = plex.get_selected_streams(item) 75 | pair_id = ( 76 | audio_stream.id if audio_stream is not None else None, 77 | subtitle_stream.id if subtitle_stream is not None else None 78 | ) 79 | if item.key in plex.cache.default_streams and plex.cache.default_streams[item.key] == pair_id: 80 | return 81 | plex.cache.default_streams.setdefault(item.key, pair_id) 82 | 83 | # Change tracks if needed 84 | plex.change_tracks(username, item, EventType.PLAY_OR_ACTIVITY) 85 | -------------------------------------------------------------------------------- /plugins/plexautolanguages/core/alerts/status.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from typing import TYPE_CHECKING 3 | 4 | from app.plugins.plexautolanguages.core.alerts.base import PlexAlert 5 | from app.plugins.plexautolanguages.core.utils.logger import get_logger 6 | from app.plugins.plexautolanguages.core.constants import EventType 7 | 8 | if TYPE_CHECKING: 9 | from app.plugins.plexautolanguages.core.plex_server import PlexServer 10 | 11 | 12 | logger = get_logger() 13 | 14 | 15 | class PlexStatus(PlexAlert): 16 | 17 | TYPE = "status" 18 | 19 | @property 20 | def title(self): 21 | return self._message.get("title", None) 22 | 23 | def process(self, plex: PlexServer): 24 | if self.title != "Library scan complete": 25 | return 26 | logger.debug("[Status] The Plex server scanned the library") 27 | 28 | if plex.config.get("refresh_library_on_scan"): 29 | added, updated = plex.cache.refresh_library_cache() 30 | else: 31 | added = plex.get_recently_added_episodes(minutes=5) 32 | updated = [] 33 | 34 | # Process recently added episodes 35 | if len(added) > 0: 36 | logger.debug(f"[Status] Found {len(added)} newly added episode(s)") 37 | for item in added: 38 | # Check if the item should be ignored 39 | if plex.should_ignore_show(item.show()): 40 | continue 41 | 42 | # Check if the item has already been processed 43 | if not plex.cache.should_process_recently_added(item.key, item.addedAt): 44 | continue 45 | 46 | # Change tracks for all users 47 | logger.info(f"[Status] Processing newly added episode {plex.get_episode_short_name(item)}") 48 | plex.process_new_or_updated_episode(item.key, EventType.NEW_EPISODE, True) 49 | 50 | # Process updated episodes 51 | if len(updated) > 0: 52 | logger.debug(f"[Status] Found {len(updated)} updated episode(s)") 53 | for item in updated: 54 | # Check if the item should be ignored 55 | if plex.should_ignore_show(item.show()): 56 | continue 57 | 58 | # Check if the item has already been processed 59 | if not plex.cache.should_process_recently_updated(item.key): 60 | continue 61 | 62 | # Change tracks for all users 63 | logger.info(f"[Status] Processing updated episode {plex.get_episode_short_name(item)}") 64 | plex.process_new_or_updated_episode(item.key, EventType.UPDATED_EPISODE, False) 65 | -------------------------------------------------------------------------------- /plugins/plexautolanguages/core/alerts/timeline.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from typing import TYPE_CHECKING 3 | from datetime import datetime, timedelta 4 | from plexapi.video import Episode 5 | 6 | from app.plugins.plexautolanguages.core.alerts.base import PlexAlert 7 | from app.plugins.plexautolanguages.core.utils.logger import get_logger 8 | from app.plugins.plexautolanguages.core.constants import EventType 9 | 10 | if TYPE_CHECKING: 11 | from app.plugins.plexautolanguages.core.plex_server import PlexServer 12 | 13 | 14 | logger = get_logger() 15 | 16 | 17 | class PlexTimeline(PlexAlert): 18 | 19 | TYPE = "timeline" 20 | 21 | @property 22 | def has_metadata_state(self): 23 | return "metadataState" in self._message 24 | 25 | @property 26 | def has_media_state(self): 27 | return "mediaState" in self._message 28 | 29 | @property 30 | def item_id(self): 31 | return int(self._message.get("itemID", None)) 32 | 33 | @property 34 | def identifier(self): 35 | return self._message.get("identifier", None) 36 | 37 | @property 38 | def state(self): 39 | return self._message.get("state", None) 40 | 41 | @property 42 | def entry_type(self): 43 | return self._message.get("type", None) 44 | 45 | def process(self, plex: PlexServer): 46 | if self.has_metadata_state or self.has_media_state: 47 | return 48 | if self.identifier != "com.plexapp.plugins.library" or self.state != 5 or self.entry_type == -1: 49 | return 50 | 51 | # Skip if not an Episode 52 | item = plex.fetch_item(self.item_id) 53 | if item is None or not isinstance(item, Episode): 54 | return 55 | 56 | # Skip if the show should be ignored 57 | if plex.should_ignore_show(item.show()): 58 | logger.debug(f"[Timeline] Ignoring episode {item} due to Plex show labels") 59 | return 60 | 61 | # Check if the item has been added recently 62 | if item.addedAt < datetime.now() - timedelta(minutes=5): 63 | return 64 | 65 | # Check if the item has already been processed 66 | if not plex.cache.should_process_recently_added(item.key, item.addedAt): 67 | return 68 | 69 | # Change tracks for all users 70 | logger.info(f"[Timeline] Processing newly added episode {plex.get_episode_short_name(item)}") 71 | plex.process_new_or_updated_episode(self.item_id, EventType.NEW_EPISODE, True) 72 | -------------------------------------------------------------------------------- /plugins/plexautolanguages/core/constants.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | 4 | class EventType(Enum): 5 | 6 | PLAY_OR_ACTIVITY = 0 7 | NEW_EPISODE = 1 8 | UPDATED_EPISODE = 2 9 | SCHEDULER = 3 10 | -------------------------------------------------------------------------------- /plugins/plexautolanguages/core/exceptions.py: -------------------------------------------------------------------------------- 1 | 2 | class InvalidConfiguration(Exception): 3 | pass 4 | 5 | 6 | class UserNotFound(Exception): 7 | pass 8 | -------------------------------------------------------------------------------- /plugins/plexautolanguages/core/plex_alert_handler.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from queue import Queue, Empty 4 | from threading import Thread, Event 5 | from time import sleep 6 | from typing import TYPE_CHECKING 7 | 8 | from requests.exceptions import ReadTimeout 9 | from urllib3.exceptions import ReadTimeoutError 10 | 11 | from app.plugins.plexautolanguages.core.alerts import PlexActivity, PlexTimeline, PlexPlaying, PlexStatus 12 | from app.plugins.plexautolanguages.core.utils.logger import get_logger 13 | 14 | if TYPE_CHECKING: 15 | from app.plugins.plexautolanguages.core.plex_server import PlexServer 16 | 17 | logger = get_logger() 18 | 19 | 20 | class PlexAlertHandler: 21 | 22 | def __init__(self, plex: PlexServer, trigger_on_play: bool, trigger_on_scan: bool, trigger_on_activity: bool): 23 | self._plex = plex 24 | self._trigger_on_play = trigger_on_play 25 | self._trigger_on_scan = trigger_on_scan 26 | self._trigger_on_activity = trigger_on_activity 27 | self._alerts_queue = Queue() 28 | self._stop_event = Event() 29 | self._processor_thread = Thread(target=self._process_alerts) 30 | self._processor_thread.daemon = True 31 | self._processor_thread.start() 32 | 33 | def stop(self): 34 | self._stop_event.set() 35 | self._processor_thread.join() 36 | 37 | def __call__(self, message: dict): 38 | alert_class = None 39 | alert_field = None 40 | if self._trigger_on_play and message["type"] == "playing": 41 | alert_class = PlexPlaying 42 | alert_field = "PlaySessionStateNotification" 43 | elif self._trigger_on_activity and message["type"] == "activity": 44 | alert_class = PlexActivity 45 | alert_field = "ActivityNotification" 46 | elif self._trigger_on_scan and message["type"] == "timeline": 47 | alert_class = PlexTimeline 48 | alert_field = "TimelineEntry" 49 | elif self._trigger_on_scan and message["type"] == "status": 50 | alert_class = PlexStatus 51 | alert_field = "StatusNotification" 52 | 53 | if alert_class is None or alert_field is None or alert_field not in message: 54 | return 55 | 56 | for alert_message in message[alert_field]: 57 | alert = alert_class(alert_message) 58 | self._alerts_queue.put(alert) 59 | 60 | def _process_alerts(self): 61 | logger.debug("Starting alert processing thread") 62 | retry_counter = 0 63 | while not self._stop_event.is_set(): 64 | try: 65 | if retry_counter == 0: 66 | alert = self._alerts_queue.get(True, 1) 67 | try: 68 | alert.process(self._plex) 69 | retry_counter = 0 70 | except (ReadTimeout, ReadTimeoutError): 71 | retry_counter += 1 72 | logger.warning( 73 | f"ReadTimeout while processing {alert.TYPE} alert, retrying (attempt {retry_counter})...") 74 | logger.debug(alert.message) 75 | sleep(1) 76 | except Exception: 77 | logger.exception(f"Unable to process {alert.TYPE}") 78 | logger.debug(alert.message) 79 | retry_counter = 0 80 | except Empty: 81 | pass 82 | logger.debug("Stopping alert processing thread") 83 | -------------------------------------------------------------------------------- /plugins/plexautolanguages/core/plex_alert_listener.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from typing import Callable 3 | from websocket import WebSocketApp 4 | from plexapi.alert import AlertListener 5 | from plexapi.server import PlexServer as BasePlexServer 6 | 7 | from app.plugins.plexautolanguages.core.utils.logger import get_logger 8 | 9 | 10 | logger = get_logger() 11 | 12 | 13 | class PlexAlertListener(AlertListener): 14 | 15 | def __init__(self, server: BasePlexServer, callback: Callable = None, callbackError: Callable = None): 16 | super().__init__(server, callback, callbackError) 17 | 18 | def run(self): 19 | url = self._server.url(self.key, includeToken=True).replace("http", "ws") 20 | self._ws = WebSocketApp(url, on_message=self._onMessage, on_error=self._onError) 21 | self._ws.run_forever(skip_utf8_validation=True) 22 | -------------------------------------------------------------------------------- /plugins/plexautolanguages/core/plex_server_cache.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | import os 3 | import json 4 | import copy 5 | from typing import TYPE_CHECKING 6 | from datetime import datetime, timedelta 7 | from dateutil.parser import isoparse 8 | 9 | from app.plugins.plexautolanguages.core.utils.logger import get_logger 10 | from app.plugins.plexautolanguages.core.utils.json_encoders import DateTimeEncoder 11 | 12 | if TYPE_CHECKING: 13 | from app.plugins.plexautolanguages.core.plex_server import PlexServer 14 | 15 | 16 | logger = get_logger() 17 | 18 | 19 | class PlexServerCache(): 20 | 21 | def __init__(self, plex: PlexServer): 22 | self._is_refreshing = False 23 | self._encoder = DateTimeEncoder() 24 | self._plex = plex 25 | self._cache_file_path = self._get_cache_file_path() 26 | self._last_refresh = datetime.fromtimestamp(0) 27 | # Alerts cache 28 | self.session_states = {} # session_key: session_state 29 | self.default_streams = {} # item_key: (audio_stream_id, substitle_stream_id) 30 | self.user_clients = {} # client_identifier: user_id 31 | self.newly_added = {} # episode_id: added_at 32 | self.newly_updated = {} # episode_id: updated_at 33 | self.recent_activities = {} # (user_id, item_id): timestamp 34 | # Users cache 35 | self._instance_users = [] 36 | self._instance_user_tokens = {} 37 | self._instance_users_valid_until = datetime.fromtimestamp(0) 38 | # Library cache 39 | self.episode_parts = {} 40 | # Initialization 41 | if not self._load(): 42 | logger.info("Scanning all episodes from the Plex library, this action should only take a few seconds " 43 | "but can take several minutes for larger libraries") 44 | self.refresh_library_cache() 45 | logger.info(f"Scanned {len(self.episode_parts)} episodes from the library") 46 | 47 | def should_process_recently_added(self, episode_id: str, added_at: datetime): 48 | if episode_id in self.newly_added and self.newly_added[episode_id] == added_at: 49 | return False 50 | self.newly_added[episode_id] = added_at 51 | return True 52 | 53 | def should_process_recently_updated(self, episode_id: str): 54 | if episode_id in self.newly_updated and self.newly_updated[episode_id] >= self._last_refresh: 55 | return False 56 | self.newly_updated[episode_id] = datetime.now() 57 | return True 58 | 59 | def refresh_library_cache(self): 60 | if self._is_refreshing: 61 | logger.debug("[Cache] The library cache is already being refreshed") 62 | return [], [] 63 | self._is_refreshing = True 64 | logger.debug("[Cache] Refreshing library cache") 65 | added = [] 66 | updated = [] 67 | new_episode_parts = {} 68 | for episode in self._plex.episodes(): 69 | part_list = new_episode_parts.setdefault(episode.key, []) 70 | for part in episode.iterParts(): 71 | part_list.append(part.key) 72 | if episode.key in self.episode_parts and set(self.episode_parts[episode.key]) != set(part_list): 73 | updated.append(episode) 74 | elif episode.key not in self.episode_parts: 75 | added.append(episode) 76 | self.episode_parts = new_episode_parts 77 | logger.debug("[Cache] Done refreshing library cache") 78 | self._last_refresh = datetime.now() 79 | self.save() 80 | self._is_refreshing = False 81 | return added, updated 82 | 83 | def get_instance_users(self, check_validity=True): 84 | if check_validity and datetime.now() > self._instance_users_valid_until: 85 | return None 86 | return copy.deepcopy(self._instance_users) 87 | 88 | def set_instance_users(self, instance_users): 89 | self._instance_users = copy.deepcopy(instance_users) 90 | self._instance_users_valid_until = datetime.now() + timedelta(hours=12) 91 | for user in self._instance_users: 92 | if str(user.id) in self._instance_user_tokens: 93 | continue 94 | self._instance_user_tokens[str(user.id)] = user.get_token(self._plex.unique_id) 95 | 96 | def get_instance_user_token(self, user_id): 97 | return self._instance_user_tokens.get(str(user_id), None) 98 | 99 | def set_instance_user_token(self, user_id, token): 100 | self._instance_user_tokens[str(user_id)] = token 101 | 102 | def _get_cache_file_path(self): 103 | data_dir = self._plex.config.get("data_dir") 104 | cache_dir = os.path.join(data_dir, "cache") 105 | if not os.path.exists(cache_dir): 106 | os.makedirs(cache_dir) 107 | return os.path.join(cache_dir, self._plex.unique_id) 108 | 109 | def _load(self): 110 | if not os.path.exists(self._cache_file_path) or not os.path.isfile(self._cache_file_path): 111 | return False 112 | logger.debug("[Cache] Loading server cache from file") 113 | try: 114 | with open(self._cache_file_path, "r", encoding="utf-8") as stream: 115 | cache = json.load(stream) 116 | except json.JSONDecodeError: 117 | logger.warning("[Cache] The cache is corrupted, clearing the cache before trying again") 118 | if os.path.exists(self._cache_file_path) and os.path.isfile(self._cache_file_path): 119 | os.remove(self._cache_file_path) 120 | return False 121 | self.newly_updated = cache.get("newly_updated", self.newly_updated) 122 | self.newly_updated = {key: isoparse(value) for key, value in self.newly_updated.items()} 123 | self.newly_added = cache.get("newly_added", self.newly_added) 124 | self.newly_added = {key: isoparse(value) for key, value in self.newly_added.items()} 125 | self.episode_parts = cache.get("episode_parts", ) 126 | self._last_refresh = isoparse(cache.get("last_refresh", self._last_refresh)) 127 | return True 128 | 129 | def save(self): 130 | logger.debug("[Cache] Saving server cache to file") 131 | cache = { 132 | "newly_updated": self.newly_updated, 133 | "newly_added": self.newly_added, 134 | "episode_parts": self.episode_parts, 135 | "last_refresh": self._last_refresh 136 | } 137 | with open(self._cache_file_path, "w", encoding="utf-8") as stream: 138 | stream.write(self._encoder.encode(cache)) 139 | -------------------------------------------------------------------------------- /plugins/plexautolanguages/core/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InfinityPacer/MoviePilot-Plugins/a60089999fafc7ffb7143dc09d2a627e4d839f59/plugins/plexautolanguages/core/utils/__init__.py -------------------------------------------------------------------------------- /plugins/plexautolanguages/core/utils/configuration.py: -------------------------------------------------------------------------------- 1 | import os 2 | import pathlib 3 | import re 4 | from collections.abc import Mapping 5 | 6 | from ruamel.yaml import YAML 7 | 8 | from app.core.config import settings 9 | from app.plugins.plexautolanguages.core.exceptions import InvalidConfiguration 10 | from app.plugins.plexautolanguages.core.utils.logger import get_logger 11 | 12 | logger = get_logger() 13 | yaml = YAML(typ="safe") 14 | 15 | 16 | def deep_dict_update(original, update): 17 | for key, value in update.items(): 18 | if isinstance(value, Mapping): 19 | original[key] = deep_dict_update(original.get(key, {}), value) 20 | else: 21 | original[key] = value 22 | return original 23 | 24 | 25 | def env_dict_update(original, var_name: str = ""): 26 | for key, value in original.items(): 27 | new_var_name = (f"{var_name}_{key}" if var_name != "" else key).upper() 28 | if isinstance(value, Mapping): 29 | original[key] = env_dict_update(original[key], new_var_name) 30 | elif new_var_name in os.environ: 31 | original[key] = yaml.load(os.environ.get(new_var_name)) 32 | logger.info(f"Setting value of parameter {new_var_name} from environment variable") 33 | return original 34 | 35 | 36 | def is_docker(): 37 | return False 38 | 39 | 40 | def get_data_directory(app_name: str): 41 | config_path = pathlib.Path(settings.CONFIG_PATH) 42 | data_path = config_path / "plugins" / app_name / "data" 43 | return data_path 44 | 45 | 46 | class Configuration: 47 | 48 | def __init__(self, default_config_path: pathlib.Path, user_config_path: pathlib.Path): 49 | if not default_config_path.exists(): 50 | logger.error("default config is not exists") 51 | return 52 | with open(default_config_path, "r", encoding="utf-8") as stream: 53 | self._config = yaml.load(stream).get("plexautolanguages", {}) 54 | if user_config_path.exists(): 55 | logger.info(f"Parsing config file '{user_config_path}'") 56 | self._override_from_config_file(user_config_path) 57 | self._override_from_env() 58 | self._override_plex_token_from_secret() 59 | self._postprocess_config() 60 | self._validate_config() 61 | self._add_system_config() 62 | 63 | def get(self, parameter_path: str): 64 | return self._get(self._config, parameter_path) 65 | 66 | def _get(self, config: dict, parameter_path: str): 67 | separator = "." 68 | if separator in parameter_path: 69 | splitted = parameter_path.split(separator) 70 | return self._get(config[splitted[0]], separator.join(splitted[1:])) 71 | return config[parameter_path] 72 | 73 | def _override_from_config_file(self, user_config_path: pathlib.Path): 74 | with open(user_config_path, "r", encoding="utf-8") as stream: 75 | user_config = yaml.load(stream).get("plexautolanguages", {}) 76 | self._config = deep_dict_update(self._config, user_config) 77 | 78 | def _override_from_env(self): 79 | self._config = env_dict_update(self._config) 80 | 81 | def _override_plex_token_from_secret(self): 82 | plex_token_file_path = os.environ.get("PLEX_TOKEN_FILE", "/run/secrets/plex_token") 83 | if not os.path.exists(plex_token_file_path): 84 | return 85 | logger.info("Getting PLEX_TOKEN from Docker secret") 86 | with open(plex_token_file_path, "r", encoding="utf-8") as stream: 87 | plex_token = stream.readline().strip() 88 | self._config["plex"]["token"] = plex_token 89 | 90 | def _postprocess_config(self): 91 | ignore_labels_config = self.get("ignore_labels") 92 | if isinstance(ignore_labels_config, str): 93 | self._config["ignore_labels"] = ignore_labels_config.split(",") 94 | 95 | def _validate_config(self): 96 | if self.get("plex.url") == "": 97 | logger.error("A Plex URL is required") 98 | raise InvalidConfiguration 99 | if self.get("plex.token") == "": 100 | logger.error("A Plex Token is required") 101 | raise InvalidConfiguration 102 | if self.get("update_level") not in ["show", "season"]: 103 | logger.error("The 'update_level' parameter must be either 'show' or 'season'") 104 | raise InvalidConfiguration 105 | if self.get("update_strategy") not in ["all", "next"]: 106 | logger.error("The 'update_strategy' parameter must be either 'all' or 'next'") 107 | raise InvalidConfiguration 108 | if not isinstance(self.get("ignore_labels"), list): 109 | logger.error("The 'ignore_labels' parameter must be a list or a string-based comma separated list") 110 | raise InvalidConfiguration 111 | if self.get("scheduler.enable") and not re.match(r"^\d{2}:\d{2}$", self.get("scheduler.schedule_time")): 112 | logger.error("A valid 'schedule_time' parameter with the format 'HH:MM' is required (ex: 02:30)") 113 | raise InvalidConfiguration 114 | logger.info("The provided configuration has been successfully validated") 115 | 116 | def _add_system_config(self): 117 | self._config["docker"] = is_docker() 118 | self._config["data_dir"] = get_data_directory("PlexAutoLanguages") 119 | if not os.path.exists(self._config["data_dir"]): 120 | os.makedirs(self._config["data_dir"]) 121 | -------------------------------------------------------------------------------- /plugins/plexautolanguages/core/utils/json_encoders.py: -------------------------------------------------------------------------------- 1 | import json 2 | from datetime import datetime, date, time 3 | 4 | 5 | class DateTimeEncoder(json.JSONEncoder): 6 | 7 | def default(self, o): 8 | if isinstance(o, (datetime, date, time)): 9 | return o.isoformat() 10 | return super().default(o) 11 | -------------------------------------------------------------------------------- /plugins/plexautolanguages/core/utils/logger.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from pathlib import Path 3 | 4 | from app.log import logger 5 | 6 | 7 | class CustomFormatter: 8 | grey = "\x1b[38;21m" 9 | blue = "\x1b[38;5;39m" 10 | yellow = "\x1b[38;5;226m" 11 | red = "\x1b[38;5;196m" 12 | bold_red = "\x1b[31;1m" 13 | reset = "\x1b[0m" 14 | fmt = "%(asctime)s [%(levelname)s] %(message)s" 15 | 16 | FORMATS = { 17 | logging.DEBUG: grey + fmt + reset, 18 | logging.INFO: blue + fmt + reset, 19 | logging.WARNING: yellow + fmt + reset, 20 | logging.ERROR: red + fmt + reset, 21 | logging.CRITICAL: bold_red + fmt + reset 22 | } 23 | 24 | def format(self, record): 25 | log_fmt = self.FORMATS.get(record.levelno) 26 | formatter = logging.Formatter(log_fmt) 27 | return formatter.format(record) 28 | 29 | 30 | def get_logger(plugin_name: str = None): 31 | """ 32 | 获取模块的logger 33 | """ 34 | return logger 35 | # if plugin_name: 36 | # loggers = getattr(logger, '_loggers', None) 37 | # if loggers: 38 | # logfile = Path("plugins") / f"{plugin_name}.log" 39 | # _logger = loggers.get(logfile) 40 | # if _logger: 41 | # return _logger 42 | # return logging.getLogger(__name__) 43 | -------------------------------------------------------------------------------- /plugins/plexautolanguages/languageprovider.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from pathlib import Path 3 | from time import sleep 4 | 5 | from websocket import WebSocketConnectionClosedException 6 | 7 | from app.plugins.plexautolanguages.core.plex_server import PlexServer 8 | from app.plugins.plexautolanguages.core.utils.configuration import Configuration 9 | 10 | 11 | class LanguageProvider: 12 | 13 | def __init__(self, default_config_path: Path, user_config_path: Path, logger: logging.Logger): 14 | self.alive = False 15 | self.must_stop = False 16 | self.stop_signal = False 17 | self.plex_alert_listener = None 18 | self.logger = logger 19 | 20 | # Configuration 21 | self.config = Configuration(default_config_path, user_config_path) 22 | 23 | # Notifications 24 | self.notifier = None 25 | # if self.config.get("notifications.enable"): 26 | # self.notifier = Notifier(self.config.get("notifications.apprise_configs")) 27 | 28 | # Scheduler 29 | self.scheduler = None 30 | # if self.config.get("scheduler.enable"): 31 | # self.scheduler = Scheduler(self.config.get("scheduler.schedule_time"), self.scheduler_callback) 32 | 33 | # Plex 34 | self.plex = None 35 | 36 | def init(self): 37 | try: 38 | self.plex = PlexServer(self.config.get("plex.url"), 39 | self.config.get("plex.token"), 40 | self.notifier, 41 | self.config) 42 | except Exception as e: 43 | self.logger.error(e) 44 | 45 | def is_ready(self): 46 | return self.alive 47 | 48 | def is_healthy(self): 49 | return self.alive and self.plex.is_alive 50 | 51 | def stop(self, *_): 52 | self.logger.info("Received SIGINT or SIGTERM, stopping gracefully") 53 | self.must_stop = True 54 | self.stop_signal = True 55 | 56 | def start(self): 57 | if self.scheduler: 58 | self.scheduler.start() 59 | 60 | while not self.stop_signal: 61 | self.must_stop = False 62 | self.init() 63 | if self.plex is None: 64 | break 65 | self.plex.start_alert_listener(self.alert_listener_error_callback) 66 | self.alive = True 67 | count = 0 68 | while not self.must_stop: 69 | sleep(1) 70 | count += 1 71 | if count % 60 == 0 and not self.plex.is_alive: 72 | self.logger.warning("Lost connection to the Plex server") 73 | self.must_stop = True 74 | self.alive = False 75 | self.plex.save_cache() 76 | self.plex.stop() 77 | if not self.stop_signal: 78 | sleep(1) 79 | self.logger.info("Trying to restore the connection to the Plex server...") 80 | 81 | if self.scheduler: 82 | self.scheduler.shutdown() 83 | self.scheduler.join() 84 | 85 | def alert_listener_error_callback(self, error: Exception): 86 | if isinstance(error, WebSocketConnectionClosedException): 87 | self.logger.warning("The Plex server closed the websocket connection") 88 | elif isinstance(error, UnicodeDecodeError): 89 | self.logger.debug("Ignoring a websocket payload that could not be decoded") 90 | return 91 | else: 92 | self.logger.error("Alert listener had an unexpected error") 93 | self.logger.error(error, exc_info=True) 94 | self.must_stop = True 95 | 96 | def scheduler_callback(self): 97 | if self.plex is None or not self.plex.is_alive: 98 | return 99 | self.logger.info("Starting scheduler task") 100 | self.plex.start_deep_analysis() 101 | -------------------------------------------------------------------------------- /plugins/plexautolanguages/requirements.txt: -------------------------------------------------------------------------------- 1 | websocket-client~=1.8 2 | python-dateutil~=2.8.2 3 | -------------------------------------------------------------------------------- /plugins/plexautoskip/config/config.ini: -------------------------------------------------------------------------------- 1 | [Plex.tv] 2 | username = 3 | password = 4 | token = 98xARgsN1yiQUDz1xr-4 5 | servername = 6 | 7 | [Server] 8 | address = 192.168.50.99 9 | ssl = False 10 | port = 32400 11 | 12 | [Security] 13 | ignore-certs = False 14 | 15 | [Skip] 16 | mode = skip 17 | tags = intro, commercial, advertisement, credits 18 | types = movie, episode 19 | ignored-libraries = 20 | last-chapter = 0.0 21 | unwatched = True 22 | first-episode-series = Watched 23 | first-episode-season = Always 24 | first-safe-tags = 25 | last-episode-series = Watched 26 | last-episode-season = Always 27 | last-safe-tags = 28 | next = False 29 | 30 | [Binge] 31 | ignore-skip-for = 0 32 | safe-tags = 33 | same-show-only = False 34 | skip-next-max = 0 35 | 36 | [Offsets] 37 | start = 3000 38 | end = 1000 39 | tags = intro 40 | command = 500 41 | 42 | [Volume] 43 | low = 0 44 | high = 100 45 | 46 | -------------------------------------------------------------------------------- /plugins/plexautoskip/config/custom.json: -------------------------------------------------------------------------------- 1 | { 2 | "markers": {}, 3 | "offsets": {}, 4 | "tags": {}, 5 | "allowed": { 6 | "users": [], 7 | "clients": [], 8 | "keys": [], 9 | "skip-next": [] 10 | }, 11 | "blocked": { 12 | "users": [], 13 | "clients": [], 14 | "keys": [], 15 | "skip-next": [] 16 | }, 17 | "clients": {}, 18 | "mode": {} 19 | } -------------------------------------------------------------------------------- /plugins/plexautoskip/config/logging.ini: -------------------------------------------------------------------------------- 1 | [loggers] 2 | keys = root 3 | 4 | [handlers] 5 | keys = consoleHandler, fileHandler 6 | 7 | [formatters] 8 | keys = simpleFormatter, minimalFormatter 9 | 10 | [logger_root] 11 | level = DEBUG 12 | handlers = consoleHandler, fileHandler 13 | 14 | [handler_consoleHandler] 15 | class = StreamHandler 16 | level = INFO 17 | formatter = minimalFormatter 18 | args = (sys.stdout,) 19 | 20 | [handler_fileHandler] 21 | class = handlers.RotatingFileHandler 22 | level = INFO 23 | formatter = simpleFormatter 24 | args = ('%(logfilename)s', 'a', 100000, 3, 'utf-8') 25 | 26 | [formatter_simpleFormatter] 27 | format = %(asctime)s - %(name)s - %(levelname)s - %(message)s 28 | datefmt = %Y-%m-%d %H:%M:%S 29 | 30 | [formatter_minimalFormatter] 31 | format = %(levelname)s - %(message)s 32 | datefmt = 33 | 34 | -------------------------------------------------------------------------------- /plugins/plexautoskip/requirements.txt: -------------------------------------------------------------------------------- 1 | websocket-client~=1.8 -------------------------------------------------------------------------------- /plugins/plexautoskip/resources/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InfinityPacer/MoviePilot-Plugins/a60089999fafc7ffb7143dc09d2a627e4d839f59/plugins/plexautoskip/resources/__init__.py -------------------------------------------------------------------------------- /plugins/plexautoskip/resources/binge.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from datetime import datetime 3 | from app.plugins.plexautoskip.resources.mediaWrapper import MediaWrapper, GRANDPARENTRATINGKEY 4 | from app.plugins.plexautoskip.resources.log import getLogger 5 | from app.plugins.plexautoskip.resources.settings import Settings 6 | from plexapi.playqueue import PlayQueue 7 | from typing import Dict, List 8 | 9 | 10 | class BingeSession(): 11 | EPISODETYPE = "episode" 12 | WATCHED_PERCENTAGE = 0.5 # Consider replacing with server watched percentage setting when available in PlexAPI future update 13 | 14 | class BingeSessionException(Exception): 15 | pass 16 | 17 | def __init__(self, mediaWrapper: MediaWrapper, blockCount: int, maxCount: int, safeTags: List[str], sameShowOnly: bool) -> None: 18 | if mediaWrapper.media.type != self.EPISODETYPE: 19 | raise self.BingeSessionException 20 | 21 | self.blockCount: int = blockCount 22 | 23 | try: 24 | pq: PlayQueue = PlayQueue.get(mediaWrapper.server, mediaWrapper.playQueueID) 25 | if pq.items[-1] == mediaWrapper.media: 26 | self.blockCount = 0 27 | if sameShowOnly and hasattr(mediaWrapper.media, GRANDPARENTRATINGKEY) and any([x for x in pq.items if hasattr(x, GRANDPARENTRATINGKEY) and x.grandparentRatingKey != mediaWrapper.media.grandparentRatingKey]): 28 | self.blockCount = 0 29 | except IndexError: 30 | self.blockCount = 0 31 | 32 | self.current: MediaWrapper = mediaWrapper 33 | self.count: int = 1 34 | self.maxCount: int = maxCount 35 | self._maxCount: int = maxCount 36 | self.safeTags: List[str] = safeTags 37 | self.lastUpdate: datetime = datetime.now() 38 | self.sameShowOnly: bool = sameShowOnly 39 | 40 | self.__updateMediaWrapper__() 41 | 42 | @property 43 | def clientIdentifier(self) -> str: 44 | return self.current.clientIdentifier 45 | 46 | @property 47 | def sinceLastUpdate(self) -> float: 48 | return (datetime.now() - self.lastUpdate).total_seconds() 49 | 50 | def __updateMediaWrapper__(self) -> None: 51 | if self.block: 52 | self.current.tags = [t for t in self.current.tags if t in self.safeTags] 53 | self.current.customMarkers = [c for c in self.current.customMarkers if c.type in self.safeTags] 54 | self.current.updateMarkers() 55 | 56 | def update(self, mediaWrapper: MediaWrapper) -> bool: 57 | if self.clientIdentifier == mediaWrapper.clientIdentifier and self.current.plexsession.user == mediaWrapper.plexsession.user: 58 | if self.sameShowOnly and hasattr(self.current.media, GRANDPARENTRATINGKEY) and hasattr(mediaWrapper.media, GRANDPARENTRATINGKEY) and self.current.media.grandparentRatingKey != mediaWrapper.media.grandparentRatingKey: 59 | return False 60 | if mediaWrapper.media != self.current.media or (mediaWrapper.media == self.current.media and self.current.ended and not mediaWrapper.ended): 61 | if self.current.media.duration and (self.current.viewOffset / self.current.media.duration) > self.WATCHED_PERCENTAGE: 62 | if self.blockSkipNext: 63 | self.maxCount += self._maxCount + 1 64 | self.count += 1 65 | self.current = mediaWrapper 66 | self.__updateMediaWrapper__() 67 | self.lastUpdate = datetime.now() 68 | return True 69 | return False 70 | 71 | @property 72 | def block(self) -> bool: 73 | return self.count <= self.blockCount 74 | 75 | @property 76 | def blockSkipNext(self) -> bool: 77 | if not self.maxCount: 78 | return False 79 | return self.count > self.maxCount 80 | 81 | @property 82 | def remaining(self) -> int: 83 | r = self.blockCount - self.count 84 | return r if r > 0 else 0 85 | 86 | def __repr__(self) -> str: 87 | return "%s-%s" % (self.clientIdentifier, self.current.playQueueID) 88 | 89 | 90 | class BingeSessions(): 91 | TIMEOUT = 300 92 | IGNORED_CAP = 200 93 | 94 | def __init__(self, settings: Settings, logger: logging.Logger = None) -> None: 95 | self.log = logger or getLogger(__name__) 96 | self.settings: Settings = settings 97 | self.sessions: Dict[BingeSession] = {} 98 | self.ignored: List[str] = [] 99 | 100 | def update(self, mediaWrapper: MediaWrapper) -> None: 101 | if mediaWrapper.ended: 102 | return 103 | 104 | if mediaWrapper.playQueueID in self.ignored: 105 | return 106 | 107 | if mediaWrapper.clientIdentifier in self.sessions: 108 | oldCount = self.sessions[mediaWrapper.clientIdentifier].count 109 | if self.sessions[mediaWrapper.clientIdentifier].update(mediaWrapper): 110 | if oldCount != self.sessions[mediaWrapper.clientIdentifier].count: 111 | self.log.debug("Updating binge watcher (%s) with %s, remaining %d total %d" % ("active" if self.sessions[mediaWrapper.clientIdentifier].block else "inactive", mediaWrapper, self.sessions[mediaWrapper.clientIdentifier].remaining, self.sessions[mediaWrapper.clientIdentifier].count)) 112 | return 113 | else: 114 | self.log.debug("Binge watcher %s is no longer relavant, player is playing alternative content, deleting" % (self.sessions[mediaWrapper.clientIdentifier])) 115 | del self.sessions[mediaWrapper.clientIdentifier] 116 | 117 | try: 118 | self.sessions[mediaWrapper.clientIdentifier] = BingeSession(mediaWrapper, self.settings.binge, self.settings.skipnextmax, self.settings.bingesafetags, self.settings.bingesameshowonly) 119 | self.log.debug("Creating binge watcher (%s) for %s, remaining %d total %d" % ("active" if self.sessions[mediaWrapper.clientIdentifier].block else "inactive", mediaWrapper, self.sessions[mediaWrapper.clientIdentifier].remaining, self.sessions[mediaWrapper.clientIdentifier].count)) 120 | except BingeSession.BingeSessionException: 121 | self.ignored.append(mediaWrapper.playQueueID) 122 | self.ignored = self.ignored[-self.IGNORED_CAP:] 123 | 124 | def blockSkipNext(self, mediaWrapper: MediaWrapper) -> bool: 125 | if not self.settings.skipnextmax: 126 | return False 127 | 128 | session: BingeSession = self.sessions.get(mediaWrapper.clientIdentifier) 129 | if session: 130 | return session.blockSkipNext 131 | return False 132 | 133 | def clean(self) -> None: 134 | for session in list(self.sessions.values()): 135 | if session.sinceLastUpdate > self.TIMEOUT: 136 | self.log.debug("Binge watcher %s hasn't been updated in %d seconds, removing" % (session, self.TIMEOUT)) 137 | del self.sessions[session.clientIdentifier] 138 | -------------------------------------------------------------------------------- /plugins/plexautoskip/resources/log.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import shutil 4 | from logging.config import fileConfig 5 | from logging.handlers import BaseRotatingHandler 6 | from configparser import RawConfigParser 7 | 8 | 9 | defaults = { 10 | 'loggers': { 11 | 'keys': 'root', 12 | }, 13 | 'handlers': { 14 | 'keys': 'consoleHandler, fileHandler', 15 | }, 16 | 'formatters': { 17 | 'keys': 'simpleFormatter, minimalFormatter', 18 | }, 19 | 'logger_root': { 20 | 'level': 'DEBUG', 21 | 'handlers': 'consoleHandler, fileHandler', 22 | }, 23 | 'handler_consoleHandler': { 24 | 'class': 'StreamHandler', 25 | 'level': 'INFO', 26 | 'formatter': 'minimalFormatter', 27 | 'args': '(sys.stdout,)', 28 | }, 29 | 'handler_fileHandler': { 30 | 'class': 'handlers.RotatingFileHandler', 31 | 'level': 'INFO', 32 | 'formatter': 'simpleFormatter', 33 | 'args': "('%(logfilename)s', 'a', 100000, 3, 'utf-8')", 34 | }, 35 | 'formatter_simpleFormatter': { 36 | 'format': '%(asctime)s - %(name)s - %(levelname)s - %(message)s', 37 | 'datefmt': '%Y-%m-%d %H:%M:%S', 38 | }, 39 | 'formatter_minimalFormatter': { 40 | 'format': '%(levelname)s - %(message)s', 41 | 'datefmt': '' 42 | } 43 | } 44 | 45 | CONFIG_DEFAULT = "logging.ini" 46 | CONFIG_DIRECTORY = "./config" 47 | RESOURCE_DIRECTORY = "./resources" 48 | RELATIVE_TO_ROOT = "../" 49 | LOG_NAME = "pas.log" 50 | 51 | 52 | def checkLoggingConfig(configfile: str) -> None: 53 | write = True 54 | config = RawConfigParser() 55 | if os.path.exists(configfile): 56 | config.read(configfile) 57 | write = False 58 | for s in defaults: 59 | if not config.has_section(s): 60 | config.add_section(s) 61 | write = True 62 | for k in defaults[s]: 63 | if not config.has_option(s, k): 64 | config.set(s, k, str(defaults[s][k])) 65 | 66 | # Remove sysLogHandler if you're on Windows 67 | if 'sysLogHandler' in config.get('handlers', 'keys'): 68 | config.set('handlers', 'keys', config.get('handlers', 'keys').replace('sysLogHandler', '')) 69 | write = True 70 | while config.get('handlers', 'keys').endswith(",") or config.get('handlers', 'keys').endswith(" "): 71 | config.set('handlers', 'keys', config.get('handlers', 'keys')[:-1]) 72 | write = True 73 | if write: 74 | fp = open(configfile, "w") 75 | config.write(fp) 76 | fp.close() 77 | 78 | 79 | def getLogger(name: str = None, custompath: str = None) -> logging.Logger: 80 | if custompath: 81 | custompath = os.path.realpath(custompath) 82 | if not os.path.isdir(custompath): 83 | custompath = os.path.dirname(custompath) 84 | rootpath = os.path.abspath(custompath) 85 | resourcepath = os.path.normpath(os.path.join(rootpath, RESOURCE_DIRECTORY)) 86 | configpath = os.path.normpath(os.path.join(rootpath, CONFIG_DIRECTORY)) 87 | else: 88 | rootpath = os.path.abspath(os.path.join(os.path.dirname(os.path.realpath(__file__)), RELATIVE_TO_ROOT)) 89 | resourcepath = os.path.normpath(os.path.join(rootpath, RESOURCE_DIRECTORY)) 90 | configpath = os.path.normpath(os.path.join(rootpath, CONFIG_DIRECTORY)) 91 | 92 | logpath = configpath 93 | if not os.path.isdir(logpath): 94 | os.makedirs(logpath) 95 | 96 | if not os.path.isdir(configpath): 97 | os.makedirs(configpath) 98 | 99 | configfile = os.path.abspath(os.path.join(configpath, CONFIG_DEFAULT)).replace("\\", "\\\\") 100 | checkLoggingConfig(configfile) 101 | 102 | logfile = os.path.abspath(os.path.join(logpath, LOG_NAME)).replace("\\", "\\\\") 103 | fileConfig(configfile, defaults={'logfilename': logfile}) 104 | 105 | logger = logging.getLogger(name) 106 | rotatingFileHandlers = [x for x in logger.handlers if isinstance(x, BaseRotatingHandler)] 107 | for rh in rotatingFileHandlers: 108 | rh.rotator = rotator 109 | 110 | return logging.getLogger(name) 111 | 112 | 113 | def rotator(source: str, dest: str) -> None: 114 | if os.path.exists(source): 115 | try: 116 | os.rename(source, dest) 117 | except: 118 | try: 119 | shutil.copyfile(source, dest) 120 | open(source, 'w').close() 121 | except Exception as e: 122 | print("Error rotating logfiles: %s." % (e)) 123 | -------------------------------------------------------------------------------- /plugins/plexautoskip/resources/server.py: -------------------------------------------------------------------------------- 1 | from plexapi import VERSION as PLEXAPIVERSION 2 | from plexapi.server import PlexServer 3 | from plexapi.myplex import MyPlexAccount 4 | from app.plugins.plexautoskip.resources.log import getLogger 5 | from app.plugins.plexautoskip.resources.settings import Settings 6 | from typing import Tuple, Dict 7 | from ssl import CERT_NONE 8 | from pkg_resources import parse_version 9 | import requests 10 | import logging 11 | 12 | MINVERSION = "4.12" 13 | 14 | 15 | def getPlexServer(settings: Settings, logger: logging.Logger = None) -> Tuple[PlexServer, dict]: 16 | log = logger or getLogger(__name__) 17 | 18 | if not settings.username and not settings.address: 19 | log.error("No plex server settings specified, please update your configuration file") 20 | return None, None 21 | 22 | if parse_version(PLEXAPIVERSION) < parse_version(MINVERSION): 23 | log.error("PlexAutoSkip requires version %s please update to %s or greater, current version is %s" % (MINVERSION, MINVERSION, PLEXAPIVERSION)) 24 | return None, None 25 | 26 | plex: PlexServer = None 27 | sslopt: Dict = None 28 | session: requests.Session = None 29 | 30 | if settings.ignore_certs: 31 | sslopt = {"cert_reqs": CERT_NONE} 32 | session = requests.Session() 33 | session.verify = False 34 | requests.packages.urllib3.disable_warnings() 35 | 36 | log.info("Connecting to Plex server...") 37 | if settings.username and settings.servername: 38 | try: 39 | account = None 40 | if settings.token: 41 | try: 42 | account = MyPlexAccount(username=settings.username, token=settings.token, session=session) 43 | except: 44 | log.debug("Unable to connect using token, falling back to password") 45 | account = None 46 | if settings.password and not account: 47 | try: 48 | account = MyPlexAccount(username=settings.username, password=settings.password, session=session) 49 | except: 50 | log.debug("Unable to connect using username/password") 51 | account = None 52 | if account: 53 | plex = account.resource(settings.servername).connect() 54 | if plex: 55 | log.info("Connected to Plex server %s using plex.tv account" % (plex.friendlyName)) 56 | except: 57 | log.exception("Error connecting to plex.tv account") 58 | 59 | if not plex and settings.address and settings.port and settings.token: 60 | protocol = "https://" if settings.ssl else "http://" 61 | try: 62 | plex = PlexServer(protocol + settings.address + ':' + str(settings.port), settings.token, session=session) 63 | log.info("Connected to Plex server %s using server settings" % (plex.friendlyName)) 64 | except: 65 | log.exception("Error connecting to Plex server") 66 | elif plex and settings.address and settings.token: 67 | log.debug("Connected to server using plex.tv account, ignoring manual server settings") 68 | 69 | return plex, sslopt 70 | -------------------------------------------------------------------------------- /plugins/plexautoskip/resources/sslAlertListener.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from plexapi.alert import AlertListener 3 | from plexapi.server import PlexServer 4 | 5 | 6 | class SSLAlertListener(AlertListener): 7 | """ Override class for PlexAPI AlertListener to allow SSL options to be passed to WebSocket 8 | 9 | Parameters: 10 | server (:class:`~plexapi.server.PlexServer`): PlexServer this listener is connected to. 11 | callback (func): Callback function to call on received messages. The callback function 12 | will be sent a single argument 'data' which will contain a dictionary of data 13 | received from the server. :samp:`def my_callback(data): ...` 14 | callbackError (func): Callback function to call on errors. The callback function 15 | will be sent a single argument 'error' which will contain the Error object. 16 | :samp:`def my_callback(error): ...` 17 | sslopt (dict): ssl socket optional dict. 18 | :samp:`{"cert_reqs": ssl.CERT_NONE}` 19 | """ 20 | def __init__(self, server: PlexServer, callback=None, callbackError=None, sslopt=None, logger=None) -> None: 21 | self.log = logger or logging.getLogger(__name__) 22 | try: 23 | super(SSLAlertListener, self).__init__(server, callback, callbackError) 24 | except TypeError: 25 | self.log.error("AlertListener error detected, you may need to update your version of PlexAPI python package, attempting backwards compatibility") 26 | super(SSLAlertListener, self).__init__(server, callback) 27 | self._sslopt = sslopt 28 | 29 | def run(self) -> None: 30 | try: 31 | import websocket 32 | except ImportError: 33 | return 34 | # create the websocket connection 35 | url = self._server.url(self.key, includeToken=True).replace('http', 'ws') 36 | self._ws = websocket.WebSocketApp(url, on_message=self._onMessage, on_error=self._onError) 37 | self._ws.run_forever(sslopt=self._sslopt) 38 | -------------------------------------------------------------------------------- /plugins/plexautoskip/setup/config.ini.sample: -------------------------------------------------------------------------------- 1 | [Plex.tv] 2 | username = 3 | password = 4 | token = 5 | servername = 6 | 7 | [Server] 8 | address = 9 | ssl = True 10 | port = 32400 11 | 12 | [Security] 13 | ignore-certs = False 14 | 15 | [Skip] 16 | mode = skip 17 | tags = intro, commercial, advertisement, credits 18 | types = movie, episode 19 | ignored-libraries = 20 | last-chapter = 0.0 21 | unwatched = True 22 | first-episode-series = Watched 23 | first-episode-season = Always 24 | first-safe-tags = 25 | last-episode-series = Watched 26 | last-episode-season = Always 27 | last-safe-tags = 28 | next = False 29 | 30 | [Binge] 31 | ignore-skip-for = 0 32 | safe-tags = 33 | same-show-only = False 34 | skip-next-max = 0 35 | 36 | [Offsets] 37 | start = 3000 38 | end = 1000 39 | tags = intro 40 | command = 500 41 | 42 | [Volume] 43 | low = 0 44 | high = 100 45 | -------------------------------------------------------------------------------- /plugins/plexautoskip/setup/custom.json.sample: -------------------------------------------------------------------------------- 1 | { 2 | "markers": { 3 | "9999999": 4 | [ 5 | { 6 | "start": 0, 7 | "end": 20000 8 | } 9 | ] 10 | }, 11 | "offsets": { 12 | }, 13 | "tags": { 14 | }, 15 | "allowed": { 16 | "users": [ 17 | ], 18 | "clients": [ 19 | ], 20 | "keys": [ 21 | ], 22 | "skip-next": [ 23 | ] 24 | }, 25 | "blocked": { 26 | "users": [ 27 | ], 28 | "clients": [ 29 | ], 30 | "keys": [ 31 | ], 32 | "skip-next": [ 33 | ] 34 | }, 35 | "clients": { 36 | }, 37 | "mode": { 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /plugins/plexautoskip/setup/logging.ini.sample: -------------------------------------------------------------------------------- 1 | [loggers] 2 | keys = root 3 | 4 | [handlers] 5 | keys = consoleHandler, fileHandler 6 | 7 | [formatters] 8 | keys = simpleFormatter, minimalFormatter 9 | 10 | [logger_root] 11 | level = DEBUG 12 | handlers = consoleHandler, fileHandler 13 | 14 | [handler_consoleHandler] 15 | class = StreamHandler 16 | level = INFO 17 | formatter = minimalFormatter 18 | args = (sys.stdout,) 19 | 20 | [handler_fileHandler] 21 | class = handlers.RotatingFileHandler 22 | level = INFO 23 | formatter = simpleFormatter 24 | args = ('%(logfilename)s', 'a', 100000, 3, 'utf-8') 25 | 26 | [formatter_simpleFormatter] 27 | format = %(asctime)s - %(name)s - %(levelname)s - %(message)s 28 | datefmt = %Y-%m-%d %H:%M:%S 29 | 30 | [formatter_minimalFormatter] 31 | format = %(levelname)s - %(message)s 32 | datefmt = 33 | 34 | -------------------------------------------------------------------------------- /plugins/plexlocalization/requirements.txt: -------------------------------------------------------------------------------- 1 | pypinyin~=0.51.0 -------------------------------------------------------------------------------- /plugins/plexpersonmeta/README.md: -------------------------------------------------------------------------------- 1 | # Plex演职人员刮削 2 | 3 | 实现刮削演职人员中文名称及角色 4 | 5 | - **技术难点**:Plex 的 API 实现较为复杂,特别是在处理关联演职人员的 `tagKey`,我在尝试为 `actor.tag.tagKey` 赋值时遇到了问题。如果您对此有所了解,请不吝赐教,欢迎通过在项目的 GitHub 页面新增一个 issue 与我联系,我将非常感谢您的反馈和帮助。 6 | 7 | - **操作警告**:在刮削演职人员信息后,可能会出现一些问题,例如丢失在线元数据,或者在 Plex 中无法通过点击演职人员的名字来查看其详细信息。请在操作前备份相关数据,以防不测。 8 | 9 | 在进行任何操作前,请确保您已经做好了完整的数据备份,并理解所有相关的技术细节和潜在风险。如果您有更多关于 Plex API 的技术问题或需求,欢迎与我联系或查阅更多资料。 10 | 11 | #### 保留在线元数据功能注意事项 12 | 13 | - **2024.7.7 由于未知原因,部分媒体库演员数据被清理,因此该方案搁置,相关脚本下线,请勿开启该功能,如已使用该功能,建议尽快恢复数据库备份** 14 | 15 | #### 感谢 16 | 17 | - 本插件基于 [官方插件](https://github.com/jxxghp/MoviePilot-Plugins) 编写,并参考了 [PrettyServer](https://github.com/Bespertrijun/PrettyServer) 项目,实现了插件的相关功能。 18 | - 特此感谢 [jxxghp](https://github.com/jxxghp)、[Bespertrijun](https://github.com/Bespertrijun) 等贡献者的卓越代码贡献。 19 | - 如有未能提及的作者,请告知我以便进行补充。 20 | 21 | ![](../../images/2024-07-13-03-02-10.png) 22 | ![](../../images/2024-06-25-02-57-20.png) 23 | ![](../../images/2024-06-25-02-57-53.png) 24 | 25 | -------------------------------------------------------------------------------- /plugins/plexpersonmeta/helper.py: -------------------------------------------------------------------------------- 1 | """ 2 | helper.py 3 | 4 | 这个模块定义了用于存储媒体项目信息的 `RatingInfo` 数据类以及缓存、限流等装饰器 5 | """ 6 | import functools 7 | import time 8 | from dataclasses import dataclass 9 | from typing import Optional, Callable, Any 10 | 11 | from cachetools import TTLCache 12 | from cachetools.keys import hashkey 13 | 14 | from app.log import logger 15 | from app.schemas import APIRateLimitException 16 | 17 | 18 | @dataclass 19 | class RatingInfo: 20 | """ 21 | 媒体项目信息的数据类 22 | """ 23 | key: Optional[str] = None # 媒体项目的唯一标识 24 | type: Optional[str] = None # 媒体项目的类型(例如:电影、电视剧) 25 | title: Optional[str] = None # 媒体项目的标题 26 | search_title: Optional[str] = None # 用于搜索的标题 27 | tmdbid: Optional[int] = None # TMDB 的唯一标识,可选 28 | 29 | 30 | def cache_with_logging(cache, source): 31 | """ 32 | 装饰器,用于在函数执行时处理缓存逻辑和日志记录。 33 | :param cache: 缓存对象,用于存储和检索缓存数据 34 | :param source: 数据来源,用于日志记录(例如:PERSON 或 MEDIA) 35 | :return: 装饰器函数 36 | """ 37 | 38 | def decorator(func): 39 | @functools.wraps(func) 40 | def wrapped_func(*args, **kwargs): 41 | key = hashkey(*args, **kwargs) 42 | if key in cache: 43 | if source == "PERSON": 44 | logger.info(f"从缓存中获取到 {source} 人物信息") 45 | else: 46 | logger.info(f"从缓存中获取到 {source} 媒体信息: {kwargs.get('title', 'Unknown Title')}") 47 | return cache[key] 48 | 49 | # 执行被装饰的函数 50 | result = func(*args, **kwargs) 51 | 52 | if result is None: 53 | # 如果结果为 None,说明触发限流或网络等异常,缓存5分钟,以免高频次调用 54 | cache.set(key, result, ttl=60 * 5) 55 | else: 56 | # 结果不为 None,使用默认 TTL 缓存 57 | cache.set(key, result) 58 | 59 | return result 60 | 61 | return wrapped_func 62 | 63 | return decorator 64 | 65 | 66 | class DynamicTTLCache(TTLCache): 67 | """ 68 | 动态 TTL 缓存类,支持在缓存项上设置自定义的 TTL(时间到期) 69 | """ 70 | 71 | def __init__(self, maxsize, default_ttl): 72 | """ 73 | 初始化 DynamicTTLCache 实例 74 | :param maxsize: 缓存的最大容量 75 | :param default_ttl: 默认的缓存时间(秒) 76 | """ 77 | super().__init__(maxsize, default_ttl) 78 | self.default_ttl = default_ttl 79 | 80 | def set(self, key, value, ttl=None): 81 | """ 82 | 设置缓存项 83 | :param key: 缓存键 84 | :param value: 缓存值 85 | :param ttl: 缓存时间(秒),如果未指定则使用默认 TTL 86 | """ 87 | if ttl is None: 88 | ttl = self.default_ttl 89 | expiration = self.timer() + ttl 90 | super().__setitem__(key, (value, expiration)) 91 | 92 | def __getitem__(self, key): 93 | """ 94 | 获取缓存项 95 | :param key: 缓存键 96 | :return: 缓存值 97 | :raises KeyError: 如果缓存项已过期或不存在 98 | """ 99 | value, expire = super().__getitem__(key) 100 | if expire < self.timer(): 101 | super().__delitem__(key) # 删除过期缓存项 102 | raise KeyError(key) 103 | return value 104 | 105 | def __contains__(self, key): 106 | """ 107 | 检查缓存中是否包含指定的键 108 | :param key: 缓存键 109 | :return: True 如果包含键且未过期,否则 False 110 | """ 111 | try: 112 | self.__getitem__(key) 113 | except KeyError: 114 | return False 115 | return True 116 | 117 | 118 | # 创建自定义缓存对象 119 | tmdb_person_cache = DynamicTTLCache(maxsize=100000, default_ttl=60 * 60 * 24 * 3) # 缓存TMDB人物信息,默认 TTL 为 3 天 120 | tmdb_media_cache = DynamicTTLCache(maxsize=100000, default_ttl=60 * 60 * 24 * 3) # 缓存TMDB媒体信息,默认 TTL 为 3 天 121 | douban_media_cache = DynamicTTLCache(maxsize=100000, default_ttl=60 * 60 * 24 * 3) # 缓存豆瓣媒体信息,默认 TTL 为 3 天 122 | 123 | 124 | class RateLimiter: 125 | """ 126 | 限流器类,用于处理调用的限流逻辑。 127 | 通过增加等待时间逐步减少调用的频率,以避免触发限流。 128 | """ 129 | 130 | def __init__(self, base_wait: int = 600, backoff_factor: int = 2): 131 | """ 132 | 初始化 RateLimiter 实例。 133 | :param base_wait: 基础等待时间(秒),默认值为 600 秒(10 分钟)。 134 | :param backoff_factor: 等待时间的递增倍数,默认值为 2。 135 | """ 136 | self.next_allowed_time = 0 137 | self.current_wait = base_wait 138 | self.base_wait = base_wait 139 | self.backoff_factor = backoff_factor 140 | 141 | def can_call(self) -> bool: 142 | """ 143 | 检查是否可以进行下一次调用 144 | :return: 如果当前时间超过下一次允许调用的时间,返回 True;否则返回 False 145 | """ 146 | current_time = time.time() 147 | if current_time >= self.next_allowed_time: 148 | return True 149 | logger.warning(f"限流期间,跳过调用:将在 {self.next_allowed_time - current_time:.2f} 秒后允许继续调用") 150 | return False 151 | 152 | def reset(self): 153 | """ 154 | 重置等待时间 155 | 当调用成功时调用此方法,重置当前等待时间为基础等待时间 156 | """ 157 | if self.next_allowed_time != 0 or self.current_wait > self.base_wait: 158 | logger.info(f"调用成功,重置限流等待时长,并允许立即调用") 159 | self.next_allowed_time = 0 160 | self.current_wait = self.base_wait 161 | 162 | def trigger_limit(self): 163 | """ 164 | 触发限流 165 | 当触发限流异常时调用此方法,增加下一次允许调用的时间并更新当前等待时间 166 | """ 167 | current_time = time.time() 168 | self.next_allowed_time = current_time + self.current_wait 169 | logger.warning(f"触发限流:将在 {self.current_wait} 秒后允许继续调用") 170 | self.current_wait *= self.backoff_factor 171 | 172 | 173 | def rate_limit_handler(base_wait: int = 600, backoff_factor: int = 2) -> Callable: 174 | """ 175 | 装饰器,用于处理限流逻辑 176 | :param base_wait: 基础等待时间(秒),默认值为 600 秒(10 分钟) 177 | :param backoff_factor: 等待时间的递增倍数,默认值为 2 178 | :return: 装饰器函数 179 | """ 180 | rate_limiter = RateLimiter(base_wait, backoff_factor) 181 | 182 | def decorator(func: Callable) -> Callable: 183 | @functools.wraps(func) 184 | def wrapper(*args, **kwargs) -> Optional[Any]: 185 | if not rate_limiter.can_call(): 186 | return None 187 | 188 | try: 189 | result = func(*args, **kwargs) 190 | rate_limiter.reset() # 调用成功,重置等待时间 191 | return result 192 | except APIRateLimitException as e: 193 | rate_limiter.trigger_limit() 194 | logger.error(f"触发限流:{str(e)}") 195 | return None 196 | 197 | return wrapper 198 | 199 | return decorator 200 | -------------------------------------------------------------------------------- /plugins/plexpersonmeta/requirements.txt: -------------------------------------------------------------------------------- 1 | pypinyin~=0.51.0 -------------------------------------------------------------------------------- /plugins/plexspeedtest/plex.tv_dns.xlsx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InfinityPacer/MoviePilot-Plugins/a60089999fafc7ffb7143dc09d2a627e4d839f59/plugins/plexspeedtest/plex.tv_dns.xlsx -------------------------------------------------------------------------------- /plugins/plexspeedtest/requirements.txt: -------------------------------------------------------------------------------- 1 | openpyxl~=3.1.5 -------------------------------------------------------------------------------- /plugins/pluginreload/__init__.py: -------------------------------------------------------------------------------- 1 | from typing import Any, List, Dict, Tuple 2 | 3 | from app.core.plugin import PluginManager 4 | from app.db.systemconfig_oper import SystemConfigOper 5 | from app.log import logger 6 | from app.plugins import _PluginBase 7 | from app.scheduler import Scheduler 8 | from app.schemas.types import SystemConfigKey 9 | 10 | 11 | class PluginReload(_PluginBase): 12 | # 插件名称 13 | plugin_name = "插件热重载" 14 | # 插件描述 15 | plugin_desc = "支持插件热重载,用于Docker调试。" 16 | # 插件图标 17 | plugin_icon = "https://raw.githubusercontent.com/InfinityPacer/MoviePilot-Plugins/main/icons/reload.png" 18 | # 插件版本 19 | plugin_version = "1.5" 20 | # 插件作者 21 | plugin_author = "InfinityPacer" 22 | # 作者主页 23 | author_url = "https://github.com/InfinityPacer" 24 | # 插件配置项ID前缀 25 | plugin_config_prefix = "pluginreload_" 26 | # 加载顺序 27 | plugin_order = 99 28 | # 可使用的用户级别 29 | auth_level = 1 30 | 31 | # 私有属性 32 | _plugin_id = None 33 | _previous_state = False 34 | 35 | def init_plugin(self, config: dict = None): 36 | if config: 37 | self._previous_state = config.get("previous_state", None) 38 | self._plugin_id = config.get("plugin_id", None) 39 | if not self._plugin_id: 40 | self.__update_config() 41 | return 42 | 43 | self.__update_config() 44 | self.__reload(plugin_id=self._plugin_id) 45 | 46 | def get_state(self): 47 | pass 48 | 49 | @staticmethod 50 | def get_command() -> List[Dict[str, Any]]: 51 | pass 52 | 53 | def get_api(self) -> List[Dict[str, Any]]: 54 | pass 55 | 56 | def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: 57 | """ 58 | 拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构 59 | """ 60 | plugin_options = self.__get_local_plugin_options() 61 | 62 | return [ 63 | { 64 | 'component': 'VForm', 65 | 'content': [ 66 | { 67 | 'component': 'VRow', 68 | 'content': [ 69 | { 70 | 'component': 'VCol', 71 | 'props': { 72 | 'cols': 12, 73 | 'md': 8 74 | }, 75 | 'content': [ 76 | { 77 | 'component': 'VAutocomplete', 78 | 'props': { 79 | 'model': 'plugin_id', 80 | 'label': '插件重载', 81 | 'items': plugin_options, 82 | "clearable": True 83 | } 84 | } 85 | ] 86 | }, 87 | { 88 | 'component': 'VCol', 89 | 'props': { 90 | 'cols': 12, 91 | 'md': 4 92 | }, 93 | 'content': [ 94 | { 95 | 'component': 'VSwitch', 96 | 'props': { 97 | 'model': 'previous_state', 98 | 'label': '记住上一次', 99 | } 100 | } 101 | ] 102 | } 103 | ] 104 | }, 105 | { 106 | 'component': 'VRow', 107 | 'content': [ 108 | { 109 | 'component': 'VCol', 110 | 'props': { 111 | 'cols': 12, 112 | }, 113 | 'content': [ 114 | { 115 | 'component': 'VAlert', 116 | 'props': { 117 | 'type': 'info', 118 | 'variant': 'tonal', 119 | 'text': '注意:请选择已安装的本地插件,保存后对应插件将会在内存中重新加载' 120 | } 121 | } 122 | ] 123 | } 124 | ] 125 | } 126 | ] 127 | } 128 | ], { 129 | "plugin_id": "", 130 | "previous_state": True, 131 | } 132 | 133 | def get_page(self) -> List[dict]: 134 | pass 135 | 136 | def stop_service(self): 137 | """ 138 | 退出插件 139 | """ 140 | pass 141 | 142 | @staticmethod 143 | def __reload(plugin_id: str): 144 | logger.info(f"准备热加载插件: {plugin_id}") 145 | 146 | # 加载插件到内存 147 | try: 148 | PluginManager().reload_plugin(plugin_id) 149 | logger.info(f"成功热加载插件: {plugin_id} 到内存") 150 | except Exception as e: 151 | logger.error(f"失败热加载插件: {plugin_id} 到内存. 错误信息: {e}") 152 | return 153 | 154 | # 注册插件服务 155 | try: 156 | Scheduler().update_plugin_job(plugin_id) 157 | logger.info(f"成功热加载插件到插件服务: {plugin_id}") 158 | except Exception as e: 159 | logger.error(f"失败热加载插件到插件服务: {plugin_id}. 错误信息: {e}") 160 | return 161 | 162 | logger.info(f"已完成插件热加载: {plugin_id}") 163 | 164 | def __update_config(self): 165 | """ 166 | 更新配置 167 | """ 168 | config_mapping = {} 169 | if self._plugin_id != "PluginReload": 170 | config_mapping['previous_state'] = self._previous_state 171 | if self._previous_state: 172 | config_mapping["plugin_id"] = self._plugin_id 173 | 174 | self.update_config(config_mapping) 175 | 176 | @staticmethod 177 | def __get_local_plugin_options() -> List[Dict[str, Any]]: 178 | """获取本地插件实例选项""" 179 | plugin_manager = PluginManager() 180 | 181 | # 从系统配置获取用户已安装的插件 ID 列表 182 | installed_plugins = SystemConfigOper().get(SystemConfigKey.UserInstalledPlugins) or [] 183 | 184 | plugins = getattr(plugin_manager, '_plugins', {}) 185 | 186 | # 过滤并获取已安装的插件 187 | local_plugins = [] 188 | for plugin_id in installed_plugins: 189 | if plugin_id in plugins: 190 | plugin = plugins[plugin_id] 191 | plugin_info = (plugin.plugin_name, plugin.plugin_version, plugin.plugin_order) 192 | else: 193 | # 对于找不到的插件,创建一个默认插件元组 194 | plugin_info = (f"{plugin_id}", f"{1.0}", 1000) 195 | 196 | local_plugins.append((plugin_id, plugin_info)) 197 | 198 | # 根据插件顺序排序 199 | local_plugins = sorted(local_plugins, key=lambda x: x[1][2]) # 使用元组的顺序字段排序 200 | 201 | # 构建插件选项卡列表 202 | plugin_options = [] 203 | for index, (plugin_id, (plugin_name, plugin_version, plugin_order)) in enumerate(local_plugins, start=1): 204 | plugin_options.append({ 205 | "title": f"{index}. {plugin_name} v{plugin_version}", # 使用解构的方式获取名称和版本 206 | "value": plugin_id 207 | }) 208 | 209 | return plugin_options 210 | -------------------------------------------------------------------------------- /plugins/torrentclassifier/classifierconfig.py: -------------------------------------------------------------------------------- 1 | # 该模块定义了用于管理基于YAML配置文件的种子文件分类的类。 2 | from dataclasses import dataclass, field 3 | from typing import List, Optional 4 | 5 | 6 | @dataclass 7 | class TorrentFilter: 8 | """数据类,用于存储种子来源的筛选标准,包括标题、分类和标签。""" 9 | torrent_title: Optional[str] = None # 用于匹配种子标题的正则表达式 10 | torrent_category: Optional[str] = None # 种子必须属于的分类 11 | torrent_tags: Optional[List[str]] = field(default_factory=list) # 种子必须具有的标签,多个标签时,任一满足即可 12 | 13 | def __post_init__(self): 14 | # 移除列表中的空字符串 15 | self.torrent_tags = [tag for tag in self.torrent_tags if tag.strip()] 16 | 17 | 18 | @dataclass 19 | class TorrentTarget: 20 | """数据类,用于存储匹配来源标准的种子的处理设置。""" 21 | change_directory: Optional[str] = None # 匹配种子移动到的目录(如果启用auto_category,则忽略此设置) 22 | change_category: Optional[str] = None # 为种子指定的新分类 23 | add_tags: Optional[List[str]] = field(default_factory=list) # 需要添加到种子的标签 24 | remove_tags: Optional[List[str]] = field(default_factory=list) # 需要从种子移除的标签,'@all' 表示移除所有标签 25 | auto_category: Optional[bool] = False # 是否启用自动分类管理 26 | 27 | def __post_init__(self): 28 | # 移除列表中的空字符串 29 | self.add_tags = [tag for tag in self.add_tags if tag.strip()] 30 | self.remove_tags = [tag for tag in self.remove_tags if tag.strip()] 31 | 32 | 33 | @dataclass 34 | class ClassifierConfig: 35 | """整合种子来源和目标种子设置的数据类。""" 36 | torrent_filter: TorrentFilter # 种子来源配置 37 | torrent_target: TorrentTarget # 目标种子处理配置 38 | -------------------------------------------------------------------------------- /plugins/trafficassistant/trafficconfig.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass, field 2 | from typing import Optional, List 3 | 4 | 5 | @dataclass 6 | class BaseConfig: 7 | """ 8 | 基础配置类,定义所有配置项的结构。 9 | """ 10 | ratio_upper_limit: Optional[float] = None # 分享率的上限 11 | ratio_lower_limit: Optional[float] = None # 分享率的下限 12 | 13 | remove_from_subscription_if_below: Optional[bool] = False # 分享率低于下限时,是否从订阅站点中移除 14 | remove_from_search_if_below: Optional[bool] = False # 分享率低于下限时,是否从搜索站点中移除 15 | enable_auto_brush_if_below: Optional[bool] = False # 分享率低于下限时,是否开启自动刷流 16 | send_alert_if_below: Optional[bool] = False # 分享率低于下限时,是否发送预警消息 17 | 18 | add_to_subscription_if_above: Optional[bool] = False # 分享率高于上限时,是否增加到订阅站点 19 | add_to_search_if_above: Optional[bool] = False # 分享率高于上限时,是否增加到搜索站点 20 | disable_auto_brush_if_above: Optional[bool] = False # 分享率高于上限时,是否关闭自动刷流 21 | 22 | def __post_init__(self): 23 | # 将输入转换为适当的类型 24 | self.ratio_upper_limit = convert_type(self.ratio_upper_limit, float) 25 | self.ratio_lower_limit = convert_type(self.ratio_lower_limit, float) 26 | 27 | 28 | @dataclass 29 | class TrafficConfig(BaseConfig): 30 | """ 31 | 全局配置类,继承自基础配置类,添加全局特有的配置项。 32 | """ 33 | enabled: Optional[bool] = False # 启用插件 34 | sites: List[int] = field(default_factory=list) # 站点列表 35 | site_infos: dict = None # 站点信息字典 36 | onlyonce: Optional[bool] = False # 立即运行一次 37 | notify: Optional[bool] = False # 发送通知 38 | cron: Optional[str] = None # 执行周期 39 | brush_plugin: Optional[str] = None # 站点刷流插件 40 | statistic_plugin: Optional[str] = "SiteStatistic" # 站点数据统计插件 41 | 42 | 43 | @dataclass 44 | class SiteConfig(BaseConfig): 45 | """ 46 | 站点配置类,继承自基础配置类,添加站点特有的标识属性。 47 | """ 48 | site_name: Optional[str] = None # 站点名称 49 | 50 | 51 | def convert_type(value, target_type): 52 | """ 53 | 将给定值转换为指定的目标类型。如果转换失败,则返回该类型的自然默认值。 54 | """ 55 | try: 56 | if target_type == float: 57 | return float(value) 58 | if target_type == int: 59 | return int(value) 60 | # 可以根据需要添加其他类型 61 | except (ValueError, TypeError): 62 | # 如果转换失败,则返回类型的自然默认值 63 | if target_type == float: 64 | return 0.0 65 | if target_type == int: 66 | return 0 67 | return None # 未指定类型的默认值 68 | 69 | 70 | def merge_configs(global_config: TrafficConfig, site_config: Optional[SiteConfig]) -> BaseConfig: 71 | """ 72 | 根据全局配置和站点配置合并得到最终的配置对象。 73 | 站点配置将覆盖全局配置中的相应项。 74 | """ 75 | final_config = vars(global_config).copy() # 复制全局配置 76 | if site_config: 77 | # 遍历站点配置,覆盖全局配置 78 | for key, value in vars(site_config).items(): 79 | if value is not None: 80 | final_config[key] = value 81 | return BaseConfig(**final_config) # 返回新的 BaseConfig 实例 82 | -------------------------------------------------------------------------------- /plugins/webdavbackup/requirements.txt: -------------------------------------------------------------------------------- 1 | webdavclient3~=3.14.6 --------------------------------------------------------------------------------