Repository: jxxghp/MoviePilot Branch: v2 Commit: afcdefbbf332 Files: 471 Total size: 3.8 MB Directory structure: gitextract_jejzfg73/ ├── .dockerignore ├── .github/ │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.yml │ │ ├── config.yml │ │ ├── feature_request.yml │ │ └── rfc.yml │ └── workflows/ │ ├── beta.yml │ ├── build.yml │ ├── issues.yml │ └── pylint.yml ├── .gitignore ├── .pylintrc ├── LICENSE ├── README.md ├── app/ │ ├── __init__.py │ ├── agent/ │ │ ├── __init__.py │ │ ├── callback/ │ │ │ └── __init__.py │ │ ├── memory/ │ │ │ └── __init__.py │ │ ├── prompt/ │ │ │ ├── Agent Prompt.txt │ │ │ └── __init__.py │ │ └── tools/ │ │ ├── __init__.py │ │ ├── base.py │ │ ├── factory.py │ │ ├── impl/ │ │ │ ├── __init__.py │ │ │ ├── _torrent_search_utils.py │ │ │ ├── add_download.py │ │ │ ├── add_subscribe.py │ │ │ ├── delete_download.py │ │ │ ├── delete_subscribe.py │ │ │ ├── execute_command.py │ │ │ ├── get_recommendations.py │ │ │ ├── get_search_results.py │ │ │ ├── list_directory.py │ │ │ ├── query_directory_settings.py │ │ │ ├── query_download_tasks.py │ │ │ ├── query_downloaders.py │ │ │ ├── query_episode_schedule.py │ │ │ ├── query_library_exists.py │ │ │ ├── query_library_latest.py │ │ │ ├── query_media_detail.py │ │ │ ├── query_popular_subscribes.py │ │ │ ├── query_rule_groups.py │ │ │ ├── query_schedulers.py │ │ │ ├── query_site_userdata.py │ │ │ ├── query_sites.py │ │ │ ├── query_subscribe_history.py │ │ │ ├── query_subscribe_shares.py │ │ │ ├── query_subscribes.py │ │ │ ├── query_transfer_history.py │ │ │ ├── query_workflows.py │ │ │ ├── recognize_media.py │ │ │ ├── run_scheduler.py │ │ │ ├── run_workflow.py │ │ │ ├── scrape_metadata.py │ │ │ ├── search_media.py │ │ │ ├── search_person.py │ │ │ ├── search_person_credits.py │ │ │ ├── search_subscribe.py │ │ │ ├── search_torrents.py │ │ │ ├── search_web.py │ │ │ ├── send_message.py │ │ │ ├── test_site.py │ │ │ ├── transfer_file.py │ │ │ ├── update_site.py │ │ │ ├── update_site_cookie.py │ │ │ └── update_subscribe.py │ │ └── manager.py │ ├── api/ │ │ ├── __init__.py │ │ ├── apiv1.py │ │ ├── endpoints/ │ │ │ ├── __init__.py │ │ │ ├── bangumi.py │ │ │ ├── dashboard.py │ │ │ ├── discover.py │ │ │ ├── douban.py │ │ │ ├── download.py │ │ │ ├── history.py │ │ │ ├── login.py │ │ │ ├── mcp.py │ │ │ ├── media.py │ │ │ ├── mediaserver.py │ │ │ ├── message.py │ │ │ ├── mfa.py │ │ │ ├── plugin.py │ │ │ ├── recommend.py │ │ │ ├── search.py │ │ │ ├── site.py │ │ │ ├── storage.py │ │ │ ├── subscribe.py │ │ │ ├── system.py │ │ │ ├── tmdb.py │ │ │ ├── torrent.py │ │ │ ├── transfer.py │ │ │ ├── user.py │ │ │ ├── webhook.py │ │ │ └── workflow.py │ │ ├── servarr.py │ │ └── servcookie.py │ ├── chain/ │ │ ├── __init__.py │ │ ├── ai_recommend.py │ │ ├── bangumi.py │ │ ├── dashboard.py │ │ ├── douban.py │ │ ├── download.py │ │ ├── media.py │ │ ├── mediaserver.py │ │ ├── message.py │ │ ├── recommend.py │ │ ├── search.py │ │ ├── site.py │ │ ├── storage.py │ │ ├── subscribe.py │ │ ├── system.py │ │ ├── tmdb.py │ │ ├── torrents.py │ │ ├── transfer.py │ │ ├── tvdb.py │ │ ├── user.py │ │ ├── webhook.py │ │ └── workflow.py │ ├── command.py │ ├── core/ │ │ ├── __init__.py │ │ ├── cache.py │ │ ├── config.py │ │ ├── context.py │ │ ├── event.py │ │ ├── meta/ │ │ │ ├── __init__.py │ │ │ ├── customization.py │ │ │ ├── metaanime.py │ │ │ ├── metabase.py │ │ │ ├── metavideo.py │ │ │ ├── releasegroup.py │ │ │ ├── streamingplatform.py │ │ │ └── words.py │ │ ├── metainfo.py │ │ ├── module.py │ │ ├── plugin.py │ │ └── security.py │ ├── db/ │ │ ├── __init__.py │ │ ├── downloadhistory_oper.py │ │ ├── init.py │ │ ├── mediaserver_oper.py │ │ ├── message_oper.py │ │ ├── models/ │ │ │ ├── __init__.py │ │ │ ├── downloadhistory.py │ │ │ ├── mediaserver.py │ │ │ ├── message.py │ │ │ ├── passkey.py │ │ │ ├── plugindata.py │ │ │ ├── site.py │ │ │ ├── siteicon.py │ │ │ ├── sitestatistic.py │ │ │ ├── siteuserdata.py │ │ │ ├── subscribe.py │ │ │ ├── subscribehistory.py │ │ │ ├── systemconfig.py │ │ │ ├── transferhistory.py │ │ │ ├── user.py │ │ │ ├── userconfig.py │ │ │ └── workflow.py │ │ ├── plugindata_oper.py │ │ ├── site_oper.py │ │ ├── subscribe_oper.py │ │ ├── systemconfig_oper.py │ │ ├── transferhistory_oper.py │ │ ├── user_oper.py │ │ ├── userconfig_oper.py │ │ └── workflow_oper.py │ ├── factory.py │ ├── helper/ │ │ ├── __init__.py │ │ ├── browser.py │ │ ├── cloudflare.py │ │ ├── cookie.py │ │ ├── cookiecloud.py │ │ ├── directory.py │ │ ├── display.py │ │ ├── doh.py │ │ ├── downloader.py │ │ ├── format.py │ │ ├── image.py │ │ ├── llm.py │ │ ├── mediaserver.py │ │ ├── message.py │ │ ├── module.py │ │ ├── nfo.py │ │ ├── notification.py │ │ ├── ocr.py │ │ ├── passkey.py │ │ ├── plugin.py │ │ ├── progress.py │ │ ├── redis.py │ │ ├── resource.py │ │ ├── rss.py │ │ ├── rule.py │ │ ├── service.py │ │ ├── storage.py │ │ ├── subscribe.py │ │ ├── system.py │ │ ├── thread.py │ │ ├── torrent.py │ │ ├── twofa.py │ │ └── workflow.py │ ├── log.py │ ├── main.py │ ├── modules/ │ │ ├── __init__.py │ │ ├── bangumi/ │ │ │ ├── __init__.py │ │ │ └── bangumi.py │ │ ├── discord/ │ │ │ ├── __init__.py │ │ │ └── discord.py │ │ ├── douban/ │ │ │ ├── __init__.py │ │ │ ├── apiv2.py │ │ │ ├── douban_cache.py │ │ │ └── scraper.py │ │ ├── emby/ │ │ │ ├── __init__.py │ │ │ └── emby.py │ │ ├── fanart/ │ │ │ └── __init__.py │ │ ├── filemanager/ │ │ │ ├── __init__.py │ │ │ ├── storages/ │ │ │ │ ├── __init__.py │ │ │ │ ├── alipan.py │ │ │ │ ├── alist.py │ │ │ │ ├── local.py │ │ │ │ ├── rclone.py │ │ │ │ ├── smb.py │ │ │ │ └── u115.py │ │ │ └── transhandler.py │ │ ├── filter/ │ │ │ ├── RuleParser.py │ │ │ └── __init__.py │ │ ├── indexer/ │ │ │ ├── __init__.py │ │ │ ├── parser/ │ │ │ │ ├── __init__.py │ │ │ │ ├── bitpt.py │ │ │ │ ├── discuz.py │ │ │ │ ├── file_list.py │ │ │ │ ├── gazelle.py │ │ │ │ ├── hddolby.py │ │ │ │ ├── ipt_project.py │ │ │ │ ├── mtorrent.py │ │ │ │ ├── nexus_audiences.py │ │ │ │ ├── nexus_hhanclub.py │ │ │ │ ├── nexus_php.py │ │ │ │ ├── nexus_project.py │ │ │ │ ├── nexus_rabbit.py │ │ │ │ ├── rousi.py │ │ │ │ ├── small_horse.py │ │ │ │ ├── tnode.py │ │ │ │ ├── torrent_leech.py │ │ │ │ ├── unit3d.py │ │ │ │ ├── yema.py │ │ │ │ └── zhixing.py │ │ │ └── spider/ │ │ │ ├── __init__.py │ │ │ ├── haidan.py │ │ │ ├── hddolby.py │ │ │ ├── mtorrent.py │ │ │ ├── rousi.py │ │ │ ├── tnode.py │ │ │ ├── torrentleech.py │ │ │ └── yema.py │ │ ├── jellyfin/ │ │ │ ├── __init__.py │ │ │ └── jellyfin.py │ │ ├── plex/ │ │ │ ├── __init__.py │ │ │ └── plex.py │ │ ├── postgresql/ │ │ │ └── __init__.py │ │ ├── qbittorrent/ │ │ │ ├── __init__.py │ │ │ └── qbittorrent.py │ │ ├── qqbot/ │ │ │ ├── __init__.py │ │ │ ├── api.py │ │ │ ├── gateway.py │ │ │ └── qqbot.py │ │ ├── redis/ │ │ │ └── __init__.py │ │ ├── rtorrent/ │ │ │ ├── __init__.py │ │ │ └── rtorrent.py │ │ ├── slack/ │ │ │ ├── __init__.py │ │ │ └── slack.py │ │ ├── subtitle/ │ │ │ └── __init__.py │ │ ├── synologychat/ │ │ │ ├── __init__.py │ │ │ └── synologychat.py │ │ ├── telegram/ │ │ │ ├── __init__.py │ │ │ └── telegram.py │ │ ├── themoviedb/ │ │ │ ├── __init__.py │ │ │ ├── category.py │ │ │ ├── scraper.py │ │ │ ├── tmdb_cache.py │ │ │ ├── tmdbapi.py │ │ │ └── tmdbv3api/ │ │ │ ├── __init__.py │ │ │ ├── as_obj.py │ │ │ ├── exceptions.py │ │ │ ├── objs/ │ │ │ │ ├── __init__.py │ │ │ │ ├── account.py │ │ │ │ ├── auth.py │ │ │ │ ├── certification.py │ │ │ │ ├── change.py │ │ │ │ ├── collection.py │ │ │ │ ├── company.py │ │ │ │ ├── configuration.py │ │ │ │ ├── credit.py │ │ │ │ ├── discover.py │ │ │ │ ├── episode.py │ │ │ │ ├── find.py │ │ │ │ ├── genre.py │ │ │ │ ├── group.py │ │ │ │ ├── keyword.py │ │ │ │ ├── list.py │ │ │ │ ├── movie.py │ │ │ │ ├── network.py │ │ │ │ ├── person.py │ │ │ │ ├── provider.py │ │ │ │ ├── review.py │ │ │ │ ├── search.py │ │ │ │ ├── season.py │ │ │ │ ├── trending.py │ │ │ │ └── tv.py │ │ │ └── tmdb.py │ │ ├── thetvdb/ │ │ │ ├── __init__.py │ │ │ └── tvdb_v4_official.py │ │ ├── transmission/ │ │ │ ├── __init__.py │ │ │ └── transmission.py │ │ ├── trimemedia/ │ │ │ ├── __init__.py │ │ │ ├── api.py │ │ │ └── trimemedia.py │ │ ├── ugreen/ │ │ │ ├── __init__.py │ │ │ ├── api.py │ │ │ └── ugreen.py │ │ ├── vocechat/ │ │ │ ├── __init__.py │ │ │ └── vocechat.py │ │ ├── webpush/ │ │ │ └── __init__.py │ │ └── wechat/ │ │ ├── WXBizMsgCrypt3.py │ │ ├── __init__.py │ │ ├── wechat.py │ │ └── wechatbot.py │ ├── monitor.py │ ├── plugins/ │ │ └── __init__.py │ ├── scheduler.py │ ├── schemas/ │ │ ├── __init__.py │ │ ├── agent.py │ │ ├── category.py │ │ ├── context.py │ │ ├── dashboard.py │ │ ├── download.py │ │ ├── event.py │ │ ├── exception.py │ │ ├── file.py │ │ ├── history.py │ │ ├── mcp.py │ │ ├── mediaserver.py │ │ ├── message.py │ │ ├── monitoring.py │ │ ├── plugin.py │ │ ├── response.py │ │ ├── rule.py │ │ ├── servarr.py │ │ ├── servcookie.py │ │ ├── site.py │ │ ├── subscribe.py │ │ ├── system.py │ │ ├── tmdb.py │ │ ├── token.py │ │ ├── transfer.py │ │ ├── types.py │ │ ├── user.py │ │ └── workflow.py │ ├── startup/ │ │ ├── __init__.py │ │ ├── agent_initializer.py │ │ ├── command_initializer.py │ │ ├── lifecycle.py │ │ ├── modules_initializer.py │ │ ├── monitor_initializer.py │ │ ├── plugins_initializer.py │ │ ├── routers_initializer.py │ │ ├── scheduler_initializer.py │ │ └── workflow_initializer.py │ ├── utils/ │ │ ├── __init__.py │ │ ├── common.py │ │ ├── crypto.py │ │ ├── debounce.py │ │ ├── dom.py │ │ ├── gc.py │ │ ├── http.py │ │ ├── ip.py │ │ ├── limit.py │ │ ├── mixins.py │ │ ├── object.py │ │ ├── otp.py │ │ ├── security.py │ │ ├── singleton.py │ │ ├── site.py │ │ ├── string.py │ │ ├── structures.py │ │ ├── system.py │ │ ├── timer.py │ │ ├── tokens.py │ │ ├── ugreen_crypto.py │ │ ├── url.py │ │ └── web.py │ └── workflow/ │ ├── __init__.py │ └── actions/ │ ├── __init__.py │ ├── add_download.py │ ├── add_subscribe.py │ ├── fetch_downloads.py │ ├── fetch_medias.py │ ├── fetch_rss.py │ ├── fetch_torrents.py │ ├── filter_medias.py │ ├── filter_torrents.py │ ├── invoke_plugin.py │ ├── note.py │ ├── scan_file.py │ ├── scrape_file.py │ ├── send_event.py │ ├── send_message.py │ └── transfer_file.py ├── config/ │ └── category.yaml ├── database/ │ ├── env.py │ ├── gen.py │ ├── script.py.mako │ └── versions/ │ ├── 0fb94bf69b38_2_0_2.py │ ├── 262735d025da_2_0_1.py │ ├── 279a949d81b6_2_1_1.py │ ├── 294b007932ef_2_0_0.py │ ├── 3891a5e722a1_2_1_7.py │ ├── 3df653756eec_2_1_6.py │ ├── 41ef1dd7467c_2_2_2.py │ ├── 4666ce24a443_2_1_8.py │ ├── 486e56a62dcb_2_1_5.py │ ├── 4b544f5d3b07_2_1_3.py │ ├── 55390f1f77c1_2_0_9.py │ ├── 58edfac72c32_2_2_3.py │ ├── 5b3355c964bb_2_2_0.py │ ├── 610bb05ddeef_2_1_2.py │ ├── 89d24811e894_2_1_4.py │ ├── a295e41830a6_2_0_6.py │ ├── a73f2dbf5c09_2_0_4.py │ ├── a946dae52526_2_2_1.py │ ├── bf28a012734c_2_0_8.py │ ├── ca5461f314f2_2_1_0.py │ ├── d58298a0879f_2_1_9.py │ ├── e2dbe1421fa4_2_0_3.py │ ├── eaf9cbc49027_2_0_7.py │ └── ecf3c693fdf3_2_0_5.py ├── docker/ │ ├── Dockerfile │ ├── cert.sh │ ├── docker_http_proxy.conf │ ├── entrypoint.sh │ ├── nginx.common.conf │ ├── nginx.template.conf │ └── update.sh ├── docs/ │ ├── development-setup.md │ ├── mcp-api.md │ └── postgresql-setup.md ├── frozen.spec ├── requirements.in ├── requirements.txt ├── safety.policy.yml ├── setup.py ├── skills/ │ └── moviepilot-cli/ │ ├── SKILL.md │ └── scripts/ │ └── mp-cli.js ├── tests/ │ ├── __init__.py │ ├── cases/ │ │ ├── __init__.py │ │ ├── files.py │ │ ├── groups.py │ │ └── meta.py │ ├── manual/ │ │ └── ugreen_media_cli.py │ ├── run.py │ ├── test_bluray.py │ ├── test_mediascrape.py │ ├── test_metainfo.py │ ├── test_object.py │ ├── test_release_group.py │ ├── test_string.py │ ├── test_telegram.py │ ├── test_transfer_history_retransfer.py │ ├── test_ugreen_api.py │ ├── test_ugreen_crypto.py │ └── test_ugreen_mediaserver.py └── version.py ================================================ FILE CONTENTS ================================================ ================================================ FILE: .dockerignore ================================================ # Git .github .git .gitignore # Documentation docs/ README.md LICENSE # Development files .pylintrc *.pyc __pycache__/ *.pyo *.pyd .Python *.so .pytest_cache/ .coverage htmlcov/ .tox/ .nox/ .hypothesis/ .mypy_cache/ .dmypy.json dmypy.json # Virtual environments venv/ env/ ENV/ env.bak/ venv.bak/ # IDE .vscode/ .idea/ *.swp *.swo *~ # OS .DS_Store .DS_Store? ._* .Spotlight-V100 .Trashes ehthumbs.db Thumbs.db # Logs *.log logs/ # Temporary files *.tmp *.temp tmp/ temp/ # Database *.db *.sqlite *.sqlite3 # Test files tests/ test_* *_test.py # Build artifacts build/ dist/ *.egg-info/ # Docker Dockerfile* docker-compose* .dockerignore # Other app.ico frozen.spec ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.yml ================================================ name: 问题反馈 description: File a bug report title: "[错误报告]: 请在此处简单描述你的问题" labels: ["bug"] body: - type: markdown attributes: value: | 请确认以下信息: 1. 请按此模板提交issues,不按模板提交的问题将直接关闭。 2. 如果你的问题可以直接在以往 issue 或者 Telegram频道 中找到,那么你的 issue 将会被直接关闭。 3. **$\color{red}{提交问题务必描述清楚、附上日志}$**,描述不清导致无法理解和分析的问题会被直接关闭。 4. 此仓库为后端仓库,如果是前端 WebUI 问题请在[前端仓库](https://github.com/jxxghp/MoviePilot-Frontend)提 issue。 5. **$\color{red}{不要通过issues来寻求解决你的环境问题、配置安装类问题、咨询类问题}$**,否则直接关闭并加入用户 $\color{red}{黑名单}$ !实在没有精力陪一波又一波的伸手党玩。 - type: checkboxes id: ensure attributes: label: 确认 description: 在提交 issue 之前,请确认你已经阅读并确认以下内容 options: - label: 我的版本是最新版本,我的版本号与 [version](https://github.com/jxxghp/MoviePilot/releases/latest) 相同。 required: true - label: 我已经 [issue](https://github.com/jxxghp/MoviePilot/issues) 中搜索过,确认我的问题没有被提出过。 required: true - label: 我已经 [Telegram频道](https://t.me/moviepilot_channel) 中搜索过,确认我的问题没有被提出过。 required: true - label: 我已经修改标题,将标题中的 描述 替换为我遇到的问题。 required: true - type: input id: version attributes: label: 当前程序版本 description: 遇到问题时程序所在的版本号 validations: required: true - type: dropdown id: environment attributes: label: 运行环境 description: 当前程序运行环境 options: - Docker - Windows validations: required: true - type: dropdown id: type attributes: label: 问题类型 description: 你在以下哪个部分碰到了问题 options: - 主程序运行问题 - 插件问题 - 其他问题 validations: required: true - type: textarea id: what-happened attributes: label: 问题描述 description: 请详细描述你碰到的问题 placeholder: "问题描述" validations: required: true - type: textarea id: logs attributes: label: 发生问题时系统日志和配置文件 description: 问题出现时,程序运行日志请复制到这里。 render: bash ================================================ FILE: .github/ISSUE_TEMPLATE/config.yml ================================================ blank_issues_enabled: false contact_links: - name: 项目讨论 url: https://github.com/jxxghp/MoviePilot/discussions/new/choose about: discussion - name: Telegram 频道 url: https://t.me/moviepilot_channel about: 更新日志 - name: Telegram 交流群 url: https://t.me/moviepilot_official about: 交流互助 ================================================ FILE: .github/ISSUE_TEMPLATE/feature_request.yml ================================================ name: 功能改进 description: Feature Request title: "[Feature Request]: " labels: ["feature request"] body: - type: markdown attributes: value: | 请说明你希望添加的功能。 - type: input id: version attributes: label: 当前程序版本 description: 目前使用的程序版本 validations: required: true - type: dropdown id: environment attributes: label: 运行环境 description: 当前程序运行环境 options: - Docker - Windows validations: required: true - type: dropdown id: type attributes: label: 功能改进类型 description: 你需要在下面哪个方面改进功能 options: - 主程序 - 插件 - 其他 validations: required: true - type: textarea id: feature-request attributes: label: 功能改进 description: 请详细描述需要改进或者添加的功能。 placeholder: "功能改进" validations: required: true - type: textarea id: references attributes: label: 参考资料 description: 可以列举一些参考资料,但是不要引用同类但商业化软件的任何内容。 placeholder: "参考资料" ================================================ FILE: .github/ISSUE_TEMPLATE/rfc.yml ================================================ name: 功能提案 description: Request for Comments title: "[RFC]" labels: ["RFC"] body: - type: markdown attributes: value: | 一份提案(RFC)定位为 **「在某功能/重构的具体开发前,用于开发者间 review 技术设计/方案的文档」**, 目的是让协作的开发者间清晰的知道「要做什么」和「具体会怎么做」,以及所有的开发者都能公开透明的参与讨论; 以便评估和讨论产生的影响 (遗漏的考虑、向后兼容性、与现有功能的冲突), 因此提案侧重在对解决问题的 **方案、设计、步骤** 的描述上。 如果仅希望讨论是否添加或改进某功能本身,请使用 -> [Issue: 功能改进](https://github.com/jxxghp/MoviePilot/issues/new?assignees=&labels=feature+request&projects=&template=feature_request.yml&title=%5BFeature+Request%5D%3A+) - type: textarea id: background attributes: label: 背景 or 问题 description: 简单描述遇到的什么问题或需要改动什么。可以引用其他 issue、讨论、文档等。 validations: required: true - type: textarea id: goal attributes: label: "目标 & 方案简述" description: 简单描述提案此提案实现后,**预期的目标效果**,以及简单大致描述会采取的方案/步骤,可能会/不会产生什么影响。 validations: required: true - type: textarea id: design attributes: label: "方案设计 & 实现步骤" description: | 详细描述你设计的具体方案,可以考虑拆分列表或要点,一步步描述具体打算如何实现的步骤和相关细节。 这部份不需要一次性写完整,即使在创建完此提案 issue 后,依旧可以再次编辑修改。 validations: required: false - type: textarea id: alternative attributes: label: "替代方案 & 对比" description: | [可选] 为来实现目标效果,还考虑过什么其他方案,有什么对比? validations: required: false ================================================ FILE: .github/workflows/beta.yml ================================================ name: MoviePilot Builder Beta on: workflow_dispatch: jobs: Docker-build: runs-on: ubuntu-latest name: Build Docker Image steps: - name: Checkout uses: actions/checkout@v4 - name: Release version id: release_version run: | app_version=$(cat version.py |sed -ne "s/APP_VERSION\s=\s'v\(.*\)'/\1/gp") echo "app_version=$app_version" >> $GITHUB_ENV - name: Docker Meta id: meta uses: docker/metadata-action@v5 with: images: | ${{ secrets.DOCKER_USERNAME }}/moviepilot-v2 ghcr.io/${{ github.repository }} tags: | type=raw,value=beta - name: Set Up QEMU uses: docker/setup-qemu-action@v3 - name: Set Up Buildx uses: docker/setup-buildx-action@v3 - name: Login DockerHub uses: docker/login-action@v3 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} - name: Login GitHub Container Registry uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build Image uses: docker/build-push-action@v5 with: context: . file: docker/Dockerfile platforms: | linux/amd64 linux/arm64/v8 push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha, scope=${{ github.workflow }}-docker cache-to: type=gha, scope=${{ github.workflow }}-docker ================================================ FILE: .github/workflows/build.yml ================================================ name: MoviePilot Builder v2 on: workflow_dispatch: push: branches: - v2 paths: - 'version.py' jobs: Docker-build: runs-on: ubuntu-latest name: Build Docker Image steps: - name: Checkout uses: actions/checkout@v4 - name: Release version id: release_version run: | app_version=$(cat version.py |sed -ne "s/APP_VERSION\s=\s'v\(.*\)'/\1/gp") echo "app_version=$app_version" >> $GITHUB_ENV - name: Docker Meta id: meta uses: docker/metadata-action@v5 with: images: | ${{ secrets.DOCKER_USERNAME }}/moviepilot-v2 ${{ secrets.DOCKER_USERNAME }}/moviepilot ghcr.io/${{ github.repository }} tags: | type=raw,value=${{ env.app_version }} type=raw,value=latest - name: Set Up QEMU uses: docker/setup-qemu-action@v3 - name: Set Up Buildx uses: docker/setup-buildx-action@v3 - name: Login DockerHub uses: docker/login-action@v3 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} - name: Login GitHub Container Registry uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build Image uses: docker/build-push-action@v5 with: context: . file: docker/Dockerfile platforms: | linux/amd64 linux/arm64/v8 push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha, scope=${{ github.workflow }}-docker cache-to: type=gha, scope=${{ github.workflow }}-docker - name: Get existing release body id: get_release_body continue-on-error: true run: | release_body=$(curl -s -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \ "https://api.github.com/repos/${{ github.repository }}/releases/tags/v${{ env.app_version }}" | \ jq -r '.body // ""') echo "RELEASE_BODY<> $GITHUB_ENV echo "$release_body" >> $GITHUB_ENV echo "EOF" >> $GITHUB_ENV - name: Delete Release uses: dev-drprasad/delete-tag-and-release@v1.1 continue-on-error: true with: tag_name: v${{ env.app_version }} delete_release: true github_token: ${{ secrets.GITHUB_TOKEN }} - name: Generate Release uses: softprops/action-gh-release@v2 with: tag_name: v${{ env.app_version }} name: v${{ env.app_version }} body: ${{ env.RELEASE_BODY }} draft: false prerelease: false make_latest: true env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} ================================================ FILE: .github/workflows/issues.yml ================================================ name: Close inactive issues on: workflow_dispatch: schedule: # Github Action 只支持 UTC 时间。 # '0 18 * * *' 对应 UTC 时间的 18:00,也就是中国时区 (UTC+8) 的第二天凌晨 02:00。 - cron: "0 18 * * *" jobs: close-issues: runs-on: ubuntu-latest permissions: issues: write pull-requests: write steps: - uses: actions/stale@v5 with: # 标记 stale 标签时间 days-before-issue-stale: 30 # 关闭 issues 标签时间 days-before-issue-close: 14 # 自定义标签名 stale-issue-label: "stale" stale-issue-message: "此问题已过时,因为它已打开 30 天且没有任何活动。" close-issue-message: "此问题已关闭,因为它在标记为 stale 后,已处于无更新状态 14 天。" # 忽略所有的 Pull Request,只处理 Issue days-before-pr-stale: -1 days-before-pr-close: -1 # 排除带有RFC标签的issue exempt-issue-labels: "RFC" operations-per-run: 500 repo-token: ${{ secrets.GITHUB_TOKEN }} ================================================ FILE: .github/workflows/pylint.yml ================================================ name: Pylint Code Quality Check on: # 允许手动触发 workflow_dispatch: jobs: pylint: runs-on: ubuntu-latest name: Pylint Code Quality Check steps: - name: Checkout code uses: actions/checkout@v4 - name: Set up Python uses: actions/setup-python@v5 with: python-version: '3.12' cache: 'pip' - name: Cache pip dependencies uses: actions/cache@v4 with: path: ~/.cache/pip key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt', '**/requirements.in') }} restore-keys: | ${{ runner.os }}-pip- - name: Install dependencies run: | python -m pip install --upgrade pip setuptools wheel pip install pylint # 安装项目依赖 if [ -f requirements.txt ]; then echo "📦 安装 requirements.txt 中的依赖..." pip install -r requirements.txt elif [ -f requirements.in ]; then echo "📦 安装 requirements.in 中的依赖..." pip install -r requirements.in else echo "⚠️ 未找到依赖文件,仅安装 pylint" fi - name: Verify pylint config run: | # 检查项目中的pylint配置文件是否存在 if [ -f .pylintrc ]; then echo "✅ 找到项目配置文件: .pylintrc" echo "配置文件内容预览:" head -10 .pylintrc else echo "❌ 未找到 .pylintrc 配置文件" exit 1 fi - name: Run pylint run: | # 运行pylint,检查主要的Python文件 echo "🚀 运行 Pylint 错误检查..." # 检查主要目录 - 只关注错误,如果有错误则退出 echo "📂 检查 app/ 目录..." pylint app/ --output-format=colorized --reports=yes --score=yes # 检查根目录的Python文件 echo "📂 检查根目录 Python 文件..." for file in $(find . -name "*.py" -not -path "./.*" -not -path "./.venv/*" -not -path "./build/*" -not -path "./dist/*" -not -path "./tests/*" -not -path "./docs/*" -not -path "./__pycache__/*" -maxdepth 1); do echo "检查文件: $file" pylint "$file" --output-format=colorized || exit 1 done # 生成详细报告 echo "📊 生成 Pylint 详细报告..." pylint app/ --output-format=json > pylint-report.json || true # 显示评分(仅供参考) echo "📈 Pylint 评分(仅供参考):" pylint app/ --score=yes --reports=no | tail -2 || true - name: Upload pylint report uses: actions/upload-artifact@v4 if: always() with: name: pylint-report path: pylint-report.json - name: Summary run: | echo "🎉 Pylint 检查完成!" echo "✅ 没有发现语法错误或严重问题" echo "📊 详细报告已保存为构建工件" ================================================ FILE: .gitignore ================================================ .idea/ *.c *.so *.pyd build/ cython_cache/ dist/ nginx/ test.py safety_report.txt app/helper/sites.py app/helper/*.so app/helper/*.pyd app/helper/*.bin app/plugins/** !app/plugins/__init__.py config/cookies/** config/user.db* config/sites/** config/logs/ config/temp/ config/cache/ *.pyc *.log .vscode venv # Pylint pylint-report.json .pylint.d/ # AI .claude/ ================================================ FILE: .pylintrc ================================================ [MASTER] # 指定Python路径 init-hook='import sys; sys.path.append(".")' # 忽略的文件和目录 ignore=.git,__pycache__,.venv,build,dist,tests,docs # 并行作业数量 jobs=0 [MESSAGES CONTROL] # 只关注错误级别的问题,禁用警告、约定和重构建议 # E = Error (错误) - 会导致构建失败 # W = Warning (警告) - 仅显示,不会失败 # R = Refactor (重构建议) - 仅显示,不会失败 # C = Convention (约定) - 仅显示,不会失败 # I = Information (信息) - 仅显示,不会失败 # 禁用大部分警告、约定和重构建议,只保留错误和重要警告 disable=all enable=error, syntax-error, undefined-variable, used-before-assignment, unreachable, return-outside-function, yield-outside-function, continue-in-finally, nonlocal-without-binding, undefined-loop-variable, redefined-builtin, not-callable, assignment-from-no-return, no-value-for-parameter, too-many-function-args, unexpected-keyword-arg, redundant-keyword-arg, import-error, relative-beyond-top-level [REPORTS] # 设置报告格式 output-format=colorized reports=yes score=yes [FORMAT] # 最大行长度 max-line-length=120 # 缩进大小 indent-string=' ' [DESIGN] # 最大参数数量 max-args=10 # 最大本地变量数量 max-locals=20 # 最大分支数量 max-branches=15 # 最大语句数量 max-statements=50 # 最大父类数量 max-parents=7 # 最大属性数量 max-attributes=10 # 最小公共方法数量 min-public-methods=1 # 最大公共方法数量 max-public-methods=25 [SIMILARITIES] # 最小相似行数 min-similarity-lines=6 # 忽略注释 ignore-comments=yes # 忽略文档字符串 ignore-docstrings=yes # 忽略导入 ignore-imports=yes [TYPECHECK] # 生成缺失成员提示的类列表 generated-members=requests.packages.urllib3 ================================================ FILE: LICENSE ================================================ GNU GENERAL PUBLIC LICENSE Version 3, 29 June 2007 Copyright (C) 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Preamble The GNU General Public License is a free, copyleft license for software and other kinds of works. The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use the GNU General Public License for most of our software; it applies also to any other work released this way by its authors. You can apply it to your programs, too. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights. Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it: responsibilities to respect the freedom of others. For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients the same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights. Developers that use the GNU GPL protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License giving you legal permission to copy, distribute and/or modify it. For the developers' and authors' protection, the GPL clearly explains that there is no warranty for this free software. For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems will not be attributed erroneously to authors of previous versions. Some devices are designed to deny users access to install or run modified versions of the software inside them, although the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the software. The systematic pattern of such abuse occurs in the area of products for individuals to use, which is precisely where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those products. If such problems arise substantially in other domains, we stand ready to extend this provision to those domains in future versions of the GPL, as needed to protect the freedom of users. Finally, every program is threatened constantly by software patents. States should not allow patents to restrict development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger that patents applied to a free program could make it effectively proprietary. To prevent this, the GPL assures that patents cannot be used to render the program non-free. The precise terms and conditions for copying, distribution and modification follow. TERMS AND CONDITIONS 0. Definitions. "This License" refers to version 3 of the GNU General Public License. "Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. "The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals or organizations. To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a work "based on" the earlier work. A "covered work" means either the unmodified Program or a work based on the Program. To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. An interactive user interface displays "Appropriate Legal Notices" to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. 1. Source Code. The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means any non-source form of a work. A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language. The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A "Major Component", in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it. The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work. The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source. The Corresponding Source for a work in source code form is that same work. 2. Basic Permissions. All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you. Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary. 3. Protecting Users' Legal Rights From Anti-Circumvention Law. No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures. When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures. 4. Conveying Verbatim Copies. You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program. You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee. 5. Conveying Modified Source Versions. You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions: a) The work must carry prominent notices stating that you modified it, and giving a relevant date. b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to "keep intact all notices". c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it. d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so. A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an "aggregate" if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate. 6. Conveying Non-Source Forms. You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways: a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange. b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge. c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b. d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements. e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d. A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work. A "User Product" is either (1) a "consumer product", which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, "normally used" refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product. "Installation Information" for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made. If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM). The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying. 7. Additional Terms. "Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions. When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission. Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms: a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or d) Limiting the use for publicity purposes of names of licensors or authors of the material; or e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors. All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying. If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms. Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way. 8. Termination. You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11). However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10. 9. Acceptance Not Required for Having Copies. You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so. 10. Automatic Licensing of Downstream Recipients. Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License. An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts. You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. 11. Patents. A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's "contributor version". A contributor's "essential patent claims" are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent sublicenses in a manner consistent with the requirements of this License. Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version. In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To "grant" such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party. If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. "Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid. If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it. A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. 12. No Surrender of Others' Freedom. If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. 13. Use with the GNU Affero General Public License. Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the special requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply to the combination as such. 14. Revised Versions of this License. The Free Software Foundation may publish revised and/or new versions of the GNU General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU General Public License, you may choose any version ever published by the Free Software Foundation. If the Program specifies that a proxy can decide which future versions of the GNU General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version. 15. Disclaimer of Warranty. THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 16. Limitation of Liability. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. 17. Interpretation of Sections 15 and 16. If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee. END OF TERMS AND CONDITIONS How to Apply These Terms to Your New Programs If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. Copyright (C) This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . Also add information on how to contact you by electronic and paper mail. If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode: Copyright (C) This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details. The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, your program's commands might be different; for a GUI interface, you would use an "about box". You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, and how to apply and follow the GNU GPL, see . The GNU General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read . ================================================ FILE: README.md ================================================ # MoviePilot ![GitHub Repo stars](https://img.shields.io/github/stars/jxxghp/MoviePilot?style=for-the-badge) ![GitHub forks](https://img.shields.io/github/forks/jxxghp/MoviePilot?style=for-the-badge) ![GitHub contributors](https://img.shields.io/github/contributors/jxxghp/MoviePilot?style=for-the-badge) ![GitHub repo size](https://img.shields.io/github/repo-size/jxxghp/MoviePilot?style=for-the-badge) ![GitHub issues](https://img.shields.io/github/issues/jxxghp/MoviePilot?style=for-the-badge) ![Docker Pulls](https://img.shields.io/docker/pulls/jxxghp/moviepilot?style=for-the-badge) ![Docker Pulls V2](https://img.shields.io/docker/pulls/jxxghp/moviepilot-v2?style=for-the-badge) ![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20Linux%20%7C%20Synology-blue?style=for-the-badge) 基于 [NAStool](https://github.com/NAStool/nas-tools) 部分代码重新设计,聚焦自动化核心需求,减少问题同时更易于扩展和维护。 # 仅用于学习交流使用,请勿在任何国内平台宣传该项目! 发布频道:https://t.me/moviepilot_channel ## 主要特性 - 前后端分离,基于FastApi + Vue3。 - 聚焦核心需求,简化功能和设置,部分设置项可直接使用默认值。 - 重新设计了用户界面,更加美观易用。 ## 安装使用 官方Wiki:https://wiki.movie-pilot.org ### 为 AI Agent 添加 Skills ```shell npx skills add https://github.com/jxxghp/MoviePilot ``` ## 参与开发 API文档:https://api.movie-pilot.org MCP工具API文档:详见 [docs/mcp-api.md](docs/mcp-api.md) 本地运行需要 `Python 3.12`、`Node JS v20.12.1` - 克隆主项目 [MoviePilot](https://github.com/jxxghp/MoviePilot) ```shell git clone https://github.com/jxxghp/MoviePilot ``` - 克隆资源项目 [MoviePilot-Resources](https://github.com/jxxghp/MoviePilot-Resources) ,将 `resources` 目录下对应平台及版本的库 `.so`/`.pyd`/`.bin` 文件复制到 `app/helper` 目录 ```shell git clone https://github.com/jxxghp/MoviePilot-Resources ``` - 安装后端依赖,运行 `main.py` 启动后端服务,默认监听端口:`3001`,API文档地址:`http://localhost:3001/docs` ```shell cd MoviePilot pip install -r requirements.txt python3 -m app.main ``` - 克隆前端项目 [MoviePilot-Frontend](https://github.com/jxxghp/MoviePilot-Frontend) ```shell git clone https://github.com/jxxghp/MoviePilot-Frontend ``` - 安装前端依赖,运行前端项目,访问:`http://localhost:5173` ```shell yarn yarn dev ``` - 参考 [插件开发指引](https://wiki.movie-pilot.org/zh/plugindev) 在 `app/plugins` 目录下开发插件代码 ## 相关项目 - [MoviePilot-Frontend](https://github.com/jxxghp/MoviePilot-Frontend) - [MoviePilot-Resources](https://github.com/jxxghp/MoviePilot-Resources) - [MoviePilot-Plugins](https://github.com/jxxghp/MoviePilot-Plugins) - [MoviePilot-Server](https://github.com/jxxghp/MoviePilot-Server) - [MoviePilot-Wiki](https://github.com/jxxghp/MoviePilot-Wiki) ## 免责申明 - 本软件仅供学习交流使用,任何人不得将本软件用于商业用途,任何人不得将本软件用于违法犯罪活动,软件对用户行为不知情,一切责任由使用者承担。 - 本软件代码开源,基于开源代码进行修改,人为去除相关限制导致软件被分发、传播并造成责任事件的,需由代码修改发布者承担全部责任,不建议对用户认证机制进行规避或修改并公开发布。 - 本项目不接受捐赠,没有在任何地方发布捐赠信息页面,软件本身不收费也不提供任何收费相关服务,请仔细辨别避免误导。 ## 贡献者 ================================================ FILE: app/__init__.py ================================================ ================================================ FILE: app/agent/__init__.py ================================================ import asyncio from typing import Dict, List, Any, Union import json import tiktoken from langchain.agents import AgentExecutor from langchain.prompts import ChatPromptTemplate, MessagesPlaceholder from langchain_community.callbacks import get_openai_callback from langchain_core.chat_history import InMemoryChatMessageHistory from langchain_core.messages import HumanMessage, AIMessage, ToolCall, ToolMessage, SystemMessage, trim_messages from langchain_core.runnables import RunnablePassthrough, RunnableLambda from langchain_core.runnables.history import RunnableWithMessageHistory from langchain.agents.format_scratchpad.openai_tools import format_to_openai_tool_messages from langchain.agents.output_parsers.openai_tools import OpenAIToolsAgentOutputParser from app.agent.callback import StreamingCallbackHandler from app.agent.memory import conversation_manager from app.agent.prompt import prompt_manager from app.agent.tools.factory import MoviePilotToolFactory from app.chain import ChainBase from app.core.config import settings from app.helper.llm import LLMHelper from app.helper.message import MessageHelper from app.log import logger from app.schemas import Notification class AgentChain(ChainBase): pass class MoviePilotAgent: """ MoviePilot AI智能体 """ def __init__(self, session_id: str, user_id: str = None, channel: str = None, source: str = None, username: str = None): self.session_id = session_id self.user_id = user_id self.channel = channel # 消息渠道 self.source = source # 消息来源 self.username = username # 用户名 # 消息助手 self.message_helper = MessageHelper() # 回调处理器 self.callback_handler = StreamingCallbackHandler( session_id=session_id ) # LLM模型 self.llm = self._initialize_llm() # 工具 self.tools = self._initialize_tools() # 提示词模板 self.prompt = self._initialize_prompt() # Agent执行器 self.agent_executor = self._create_agent_executor() def _initialize_llm(self): """ 初始化LLM模型 """ return LLMHelper.get_llm(streaming=True, callbacks=[self.callback_handler]) def _initialize_tools(self) -> List: """ 初始化工具列表 """ return MoviePilotToolFactory.create_tools( session_id=self.session_id, user_id=self.user_id, channel=self.channel, source=self.source, username=self.username, callback_handler=self.callback_handler ) @staticmethod def _initialize_session_store() -> Dict[str, InMemoryChatMessageHistory]: """ 初始化内存存储 """ return {} def get_session_history(self, session_id: str) -> InMemoryChatMessageHistory: """ 获取会话历史 """ chat_history = InMemoryChatMessageHistory() messages: List[dict] = conversation_manager.get_recent_messages_for_agent( session_id=session_id, user_id=self.user_id ) if messages: for msg in messages: if msg.get("role") == "user": chat_history.add_message(HumanMessage(content=msg.get("content", ""))) elif msg.get("role") == "agent": chat_history.add_message(AIMessage(content=msg.get("content", ""))) elif msg.get("role") == "tool_call": metadata = msg.get("metadata", {}) chat_history.add_message( AIMessage( content=msg.get("content", ""), tool_calls=[ ToolCall( id=metadata.get("call_id"), name=metadata.get("tool_name"), args=metadata.get("parameters"), ) ] ) ) elif msg.get("role") == "tool_result": metadata = msg.get("metadata", {}) chat_history.add_message(ToolMessage( content=msg.get("content", ""), tool_call_id=metadata.get("call_id", "unknown") )) elif msg.get("role") == "system": chat_history.add_message(SystemMessage(content=msg.get("content", ""))) return chat_history @staticmethod def _initialize_prompt() -> ChatPromptTemplate: """ 初始化提示词模板 """ try: prompt_template = ChatPromptTemplate.from_messages([ ("system", "{system_prompt}"), MessagesPlaceholder(variable_name="chat_history"), ("user", "{input}"), MessagesPlaceholder(variable_name="agent_scratchpad"), ]) logger.info("LangChain提示词模板初始化成功") return prompt_template except Exception as e: logger.error(f"初始化提示词失败: {e}") raise e @staticmethod def _token_counter(messages: List[Union[HumanMessage, AIMessage, ToolMessage, SystemMessage]]) -> int: """ 通用的Token计数器 """ try: # 尝试从模型获取编码集,如果失败则回退到 cl100k_base (大多数现代模型使用的编码) try: encoding = tiktoken.encoding_for_model(settings.LLM_MODEL) except KeyError: encoding = tiktoken.get_encoding("cl100k_base") num_tokens = 0 for message in messages: # 基础开销 (每个消息大约 3 个 token) num_tokens += 3 # 1. 处理文本内容 (content) if isinstance(message.content, str): num_tokens += len(encoding.encode(message.content)) elif isinstance(message.content, list): for part in message.content: if isinstance(part, dict) and part.get("type") == "text": num_tokens += len(encoding.encode(part.get("text", ""))) # 2. 处理工具调用 (仅 AIMessage 包含 tool_calls) if getattr(message, "tool_calls", None): for tool_call in message.tool_calls: # 函数名 num_tokens += len(encoding.encode(tool_call.get("name", ""))) # 参数 (转为 JSON 估算) args_str = json.dumps(tool_call.get("args", {}), ensure_ascii=False) num_tokens += len(encoding.encode(args_str)) # 额外的结构开销 (ID 等) num_tokens += 3 # 3. 处理角色权重 num_tokens += 1 # 加上回复的起始 Token (大约 3 个 token) num_tokens += 3 return num_tokens except Exception as e: logger.error(f"Token计数失败: {e}") # 发生错误时返回一个保守的估算值 return len(str(messages)) // 4 def _create_agent_executor(self) -> RunnableWithMessageHistory: """ 创建Agent执行器 """ try: # 消息裁剪器,防止上下文超出限制 base_trimmer = trim_messages( max_tokens=settings.LLM_MAX_CONTEXT_TOKENS * 1000 * 0.8, strategy="last", token_counter=self._token_counter, include_system=True, allow_partial=False, start_on="human", ) # 包装trimmer,在裁剪后验证工具调用的完整性 def validated_trimmer(messages): # 如果输入是 PromptValue,转换为消息列表 if hasattr(messages, "to_messages"): messages = messages.to_messages() trimmed = base_trimmer.invoke(messages) # 二次校验:确保不出现 broken tool chains # 1. AIMessage with tool_calls 必须紧跟着对应的 ToolMessage # 2. ToolMessage 必须有对应的 AIMessage 前置 safe_messages = [] i = 0 while i < len(trimmed): msg = trimmed[i] if isinstance(msg, AIMessage) and getattr(msg, "tool_calls", None): # 检查工具调用序列是否完整 tool_calls = msg.tool_calls is_valid_sequence = True tool_results = [] # 向后查找对应的 ToolMessage temp_i = i + 1 for tool_call in tool_calls: if temp_i >= len(trimmed): is_valid_sequence = False break next_msg = trimmed[temp_i] if isinstance(next_msg, ToolMessage) and next_msg.tool_call_id == tool_call.get("id"): tool_results.append(next_msg) temp_i += 1 else: is_valid_sequence = False break if is_valid_sequence: # 序列完整,保留消息 safe_messages.append(msg) safe_messages.extend(tool_results) i = temp_i # 跳过已处理的工具结果 else: # 序列不完整,丢弃该 AIMessage(后续的孤立 ToolMessage 会在下一次循环被当做 orphaned 处理掉) logger.warning(f"移除无效的工具调用链: {len(tool_calls)} calls, incomplete results") i += 1 continue if isinstance(msg, ToolMessage): # 如果在这里遇到 ToolMessage,说明它没有被上面的逻辑消费,则是孤立的(或者顺序错乱) logger.warning("移除孤立的 ToolMessage") i += 1 continue # 其他类型的消息直接保留 safe_messages.append(msg) i += 1 if len(safe_messages) < len(messages): logger.info(f"LangChain消息上下文已裁剪: {len(messages)} -> {len(safe_messages)}") return safe_messages # 创建Agent执行链 agent = ( RunnablePassthrough.assign( agent_scratchpad=lambda x: format_to_openai_tool_messages( x["intermediate_steps"] ) ) | self.prompt | RunnableLambda(validated_trimmer) | self.llm.bind_tools(self.tools) | OpenAIToolsAgentOutputParser() ) executor = AgentExecutor( agent=agent, tools=self.tools, verbose=settings.LLM_VERBOSE, max_iterations=settings.LLM_MAX_ITERATIONS, return_intermediate_steps=True, handle_parsing_errors=True, early_stopping_method="force" ) return RunnableWithMessageHistory( executor, self.get_session_history, input_messages_key="input", history_messages_key="chat_history" ) except Exception as e: logger.error(f"创建Agent执行器失败: {e}") raise e async def _summarize_history(self): """ 总结提炼之前的对话和工具执行情况,并把会话总结变成新的系统提示词取代之前的对话 """ try: # 获取当前历史记录 chat_history = self.get_session_history(self.session_id) messages = chat_history.messages if not messages: return logger.info(f"会话 {self.session_id} 历史消息长度已超过 90%,开始总结并重置上下文...") # 将消息转换为摘要所需的文本格式 history_text = "" for msg in messages: if isinstance(msg, HumanMessage): history_text += f"用户: {msg.content}\n" elif isinstance(msg, AIMessage): history_text += f"智能体: {msg.content}\n" if getattr(msg, "tool_calls", None): for tool_call in msg.tool_calls: history_text += f"智能体调用工具: {tool_call.get('name')},参数: {tool_call.get('args')}\n" elif isinstance(msg, ToolMessage): history_text += f"工具响应: {msg.content}\n" elif isinstance(msg, SystemMessage): history_text += f"系统: {msg.content}\n" # 摘要提示词 summary_prompt = ( "Please provide a comprehensive and highly informational summary of the preceding conversation and tool executions. " "Your goal is to condense the history while retaining all critical details for future reference. " "Ensure you include:\n" "1. User's core intents, specific requests, and any mentioned preferences.\n" "2. Names of movies, TV shows, or other key entities discussed.\n" "3. A concise log of tool calls made and their specific results/outcomes.\n" "4. The current status of any tasks and any pending actions.\n" "5. Any important context that would be necessary for the agent to continue the conversation seamlessly.\n" "The summary should be dense with information and serve as the primary context for the next stage of the interaction." ) # 调用 LLM 进行总结 (非流式) summary_llm = LLMHelper.get_llm(streaming=False) response = await summary_llm.ainvoke([ SystemMessage(content=summary_prompt), HumanMessage(content=f"Here is the conversation history to summarize:\n{history_text}") ]) summary_content = str(response.content) if not summary_content: logger.warning("总结生成失败,跳过重置逻辑。") return # 清空原有的会话记录并插入新的系统总结 await conversation_manager.clear_memory(self.session_id, self.user_id) await conversation_manager.add_conversation( session_id=self.session_id, user_id=self.user_id, role="system", content=f"\n{summary_content}\n" ) logger.info(f"会话 {self.session_id} 历史摘要替换完成。") except Exception as e: logger.error(f"执行会话总结出错: {str(e)}") async def process_message(self, message: str) -> str: """ 处理用户消息 """ try: # 检查上下文长度是否超过 90% history = self.get_session_history(self.session_id) if self._token_counter(history.messages) > settings.LLM_MAX_CONTEXT_TOKENS * 1000 * 0.9: await self._summarize_history() # 添加用户消息到记忆 await conversation_manager.add_conversation( self.session_id, user_id=self.user_id, role="user", content=message ) # 构建输入上下文 input_context = { "system_prompt": prompt_manager.get_agent_prompt(channel=self.channel), "input": message } # 执行Agent logger.info(f"Agent执行推理: session_id={self.session_id}, input={message}") result = await self._execute_agent(input_context) # 获取Agent回复 agent_message = await self.callback_handler.get_message() # 发送Agent回复给用户(通过原渠道) if agent_message: # 发送回复 await self.send_agent_message(agent_message) # 添加Agent回复到记忆 await conversation_manager.add_conversation( session_id=self.session_id, user_id=self.user_id, role="agent", content=agent_message ) else: agent_message = result.get("output") or "很抱歉,智能体出错了,未能生成回复内容。" await self.send_agent_message(agent_message) return agent_message except Exception as e: error_message = f"处理消息时发生错误: {str(e)}" logger.error(error_message) # 发送错误消息给用户(通过原渠道) await self.send_agent_message(error_message) return error_message async def _execute_agent(self, input_context: Dict[str, Any]) -> Dict[str, Any]: """ 执行LangChain Agent """ try: with get_openai_callback() as cb: result = await self.agent_executor.ainvoke( input_context, config={"configurable": {"session_id": self.session_id}}, callbacks=[self.callback_handler] ) logger.info(f"LLM调用消耗: \n{cb}") if cb.total_tokens > 0: result["token_usage"] = { "prompt_tokens": cb.prompt_tokens, "completion_tokens": cb.completion_tokens, "total_tokens": cb.total_tokens } return result except asyncio.CancelledError: logger.info(f"Agent执行被取消: session_id={self.session_id}") return { "output": "任务已取消", "intermediate_steps": [], "token_usage": {} } except Exception as e: logger.error(f"Agent执行失败: {e}") return { "output": str(e), "intermediate_steps": [], "token_usage": {} } async def send_agent_message(self, message: str, title: str = "MoviePilot助手"): """ 通过原渠道发送消息给用户 """ await AgentChain().async_post_message( Notification( channel=self.channel, source=self.source, userid=self.user_id, username=self.username, title=title, text=message ) ) async def cleanup(self): """ 清理智能体资源 """ logger.info(f"MoviePilot智能体已清理: session_id={self.session_id}") class AgentManager: """ AI智能体管理器 """ def __init__(self): self.active_agents: Dict[str, MoviePilotAgent] = {} @staticmethod async def initialize(): """ 初始化管理器 """ await conversation_manager.initialize() async def close(self): """ 关闭管理器 """ await conversation_manager.close() # 清理所有活跃的智能体 for agent in self.active_agents.values(): await agent.cleanup() self.active_agents.clear() async def process_message(self, session_id: str, user_id: str, message: str, channel: str = None, source: str = None, username: str = None) -> str: """ 处理用户消息 """ # 获取或创建Agent实例 if session_id not in self.active_agents: logger.info(f"创建新的AI智能体实例,session_id: {session_id}, user_id: {user_id}") agent = MoviePilotAgent( session_id=session_id, user_id=user_id, channel=channel, source=source, username=username ) self.active_agents[session_id] = agent else: agent = self.active_agents[session_id] agent.user_id = user_id # 确保user_id是最新的 # 更新渠道信息 if channel: agent.channel = channel if source: agent.source = source if username: agent.username = username # 处理消息 return await agent.process_message(message) async def clear_session(self, session_id: str, user_id: str): """ 清空会话 """ if session_id in self.active_agents: agent = self.active_agents[session_id] await agent.cleanup() del self.active_agents[session_id] await conversation_manager.clear_memory(session_id, user_id) logger.info(f"会话 {session_id} 的记忆已清空") # 全局智能体管理器实例 agent_manager = AgentManager() ================================================ FILE: app/agent/callback/__init__.py ================================================ import threading from langchain_core.callbacks import AsyncCallbackHandler from app.log import logger class StreamingCallbackHandler(AsyncCallbackHandler): """ 流式输出回调处理器 """ def __init__(self, session_id: str): self._lock = threading.Lock() self.session_id = session_id self.current_message = "" async def get_message(self): """ 获取当前消息内容,获取后清空 """ with self._lock: if not self.current_message: return "" msg = self.current_message logger.info(f"Agent消息: {msg}") self.current_message = "" return msg async def on_llm_new_token(self, token: str, **kwargs): """ 处理新的token """ if not token: return with self._lock: # 缓存当前消息 self.current_message += token ================================================ FILE: app/agent/memory/__init__.py ================================================ """对话记忆管理器""" import asyncio import json from datetime import datetime, timedelta from typing import Dict, List, Optional, Any from app.core.config import settings from app.helper.redis import AsyncRedisHelper from app.log import logger from app.schemas.agent import ConversationMemory class ConversationMemoryManager: """ 对话记忆管理器 """ def __init__(self): # 内存中的会话记忆缓存 self.memory_cache: Dict[str, ConversationMemory] = {} # 使用现有的Redis助手 self.redis_helper = AsyncRedisHelper() # 内存缓存清理任务(Redis通过TTL自动过期) self.cleanup_task: Optional[asyncio.Task] = None async def initialize(self): """ 初始化记忆管理器 """ try: # 启动内存缓存清理任务(Redis通过TTL自动过期) self.cleanup_task = asyncio.create_task(self._cleanup_expired_memories()) logger.info("对话记忆管理器初始化完成") except Exception as e: logger.warning(f"Redis连接失败,将使用内存存储: {e}") async def close(self): """ 关闭记忆管理器 """ if self.cleanup_task: self.cleanup_task.cancel() try: await self.cleanup_task except asyncio.CancelledError: pass await self.redis_helper.close() logger.info("对话记忆管理器已关闭") @staticmethod def _get_memory_key(session_id: str, user_id: str): """ 计算内存Key """ return f"{user_id}:{session_id}" if user_id else session_id @staticmethod def _get_redis_key(session_id: str, user_id: str): """ 计算Redis Key """ return f"agent_memory:{user_id}:{session_id}" if user_id else f"agent_memory:{session_id}" def _get_memory(self, session_id: str, user_id: str): """ 获取内存中的记忆 """ cache_key = self._get_memory_key(session_id, user_id) return self.memory_cache.get(cache_key) async def _get_redis(self, session_id: str, user_id: str) -> Optional[ConversationMemory]: """ 从Redis获取记忆 """ if settings.CACHE_BACKEND_TYPE == "redis": try: redis_key = self._get_redis_key(session_id, user_id) memory_data = await self.redis_helper.get(redis_key, region="AI_AGENT") if memory_data: memory_dict = json.loads(memory_data) if isinstance(memory_data, str) else memory_data memory = ConversationMemory(**memory_dict) return memory except Exception as e: logger.warning(f"从Redis加载记忆失败: {e}") return None async def get_conversation(self, session_id: str, user_id: str) -> ConversationMemory: """ 获取会话记忆 """ # 首先检查缓存 conversion = self._get_memory(session_id, user_id) if conversion: return conversion # 尝试从Redis加载 memory = await self._get_redis(session_id, user_id) if memory: # 加载到内存缓存 self._save_memory(memory) return memory # 创建新的记忆 memory = ConversationMemory(session_id=session_id, user_id=user_id) await self._save_conversation(memory) return memory async def set_title(self, session_id: str, user_id: str, title: str): """ 设置会话标题 """ memory = await self.get_conversation(session_id=session_id, user_id=user_id) memory.title = title memory.updated_at = datetime.now() await self._save_conversation(memory) async def get_title(self, session_id: str, user_id: str) -> Optional[str]: """ 获取会话标题 """ memory = await self.get_conversation(session_id=session_id, user_id=user_id) return memory.title async def list_sessions(self, user_id: str, limit: int = 100) -> List[Dict[str, Any]]: """ 列出历史会话摘要(按更新时间倒序) - 当启用Redis时:遍历 `agent_memory:*` 键并读取摘要 - 当未启用Redis时:基于内存缓存返回 """ sessions: List[ConversationMemory] = [] # 从Redis遍历 if settings.CACHE_BACKEND_TYPE == "redis": try: # 使用Redis助手的items方法遍历所有键 async for key, value in self.redis_helper.items(region="AI_AGENT"): if key.startswith("agent_memory:"): try: # 解析键名获取user_id和session_id key_parts = key.split(":") if len(key_parts) >= 3: key_user_id = key_parts[2] if len(key_parts) > 3 else None if not user_id or key_user_id == user_id: data = value if isinstance(value, dict) else json.loads(value) memory = ConversationMemory(**data) sessions.append(memory) except Exception as err: logger.warning(f"解析Redis记忆数据失败: {err}") continue except Exception as e: logger.warning(f"遍历Redis会话失败: {e}") # 合并内存缓存(确保包含近期的会话) for cache_key, memory in self.memory_cache.items(): # 如果指定了user_id,只返回该用户的会话 if not user_id or memory.user_id == user_id: sessions.append(memory) # 去重(以 session_id 为键,取最近updated) uniq: Dict[str, ConversationMemory] = {} for mem in sessions: existed = uniq.get(mem.session_id) if (not existed) or (mem.updated_at > existed.updated_at): uniq[mem.session_id] = mem # 排序并裁剪 sorted_list = sorted(uniq.values(), key=lambda m: m.updated_at, reverse=True)[:limit] return [ { "session_id": m.session_id, "title": m.title or "新会话", "message_count": len(m.messages), "created_at": m.created_at.isoformat(), "updated_at": m.updated_at.isoformat(), } for m in sorted_list ] async def add_conversation( self, session_id: str, user_id: str, role: str, content: str, metadata: Optional[Dict[str, Any]] = None ): """ 添加消息到记忆 """ memory = await self.get_conversation(session_id=session_id, user_id=user_id) message = { "role": role, "content": content, "timestamp": datetime.now().isoformat(), "metadata": metadata or {} } memory.messages.append(message) memory.updated_at = datetime.now() # 限制消息数量,避免记忆过大 max_messages = settings.LLM_MAX_MEMORY_MESSAGES if len(memory.messages) > max_messages: # 保留最近的消息,但保留第一条系统消息 system_messages = [msg for msg in memory.messages if msg["role"] == "system"] recent_messages = memory.messages[-(max_messages - len(system_messages)):] memory.messages = system_messages + recent_messages await self._save_conversation(memory) logger.debug(f"消息已添加到记忆: session_id={session_id}, user_id={user_id}, role={role}") def get_recent_messages_for_agent( self, session_id: str, user_id: str ) -> List[Dict[str, Any]]: """ 为Agent获取最近的消息(仅内存缓存) 如果消息Token数量超过模型最大上下文长度的阀值,会自动进行摘要裁剪 """ cache_key = self._get_memory_key(session_id, user_id) memory = self.memory_cache.get(cache_key) if not memory: return [] # 获取所有消息 return memory.messages[:-1] async def get_recent_messages( self, session_id: str, user_id: str, limit: int = 10, role_filter: Optional[list] = None ) -> List[Dict[str, Any]]: """ 获取最近的消息 """ memory = await self.get_conversation(session_id=session_id, user_id=user_id) messages = memory.messages if role_filter: messages = [msg for msg in messages if msg["role"] in role_filter] return messages[-limit:] if messages else [] async def get_context(self, session_id: str, user_id: str) -> Dict[str, Any]: """ 获取会话上下文 """ memory = await self.get_conversation(session_id=session_id, user_id=user_id) return memory.context async def clear_memory(self, session_id: str, user_id: str): """ 清空会话记忆 """ cache_key = f"{user_id}:{session_id}" if user_id else session_id if cache_key in self.memory_cache: del self.memory_cache[cache_key] if settings.CACHE_BACKEND_TYPE == "redis": redis_key = self._get_redis_key(session_id, user_id) await self.redis_helper.delete(redis_key, region="AI_AGENT") logger.info(f"会话记忆已清空: session_id={session_id}, user_id={user_id}") def _save_memory(self, memory: ConversationMemory): """ 保存记忆到内存 """ cache_key = self._get_memory_key(memory.session_id, memory.user_id) self.memory_cache[cache_key] = memory async def _save_redis(self, memory: ConversationMemory): """ 保存记忆到Redis """ if settings.CACHE_BACKEND_TYPE == "redis": try: memory_dict = memory.model_dump() redis_key = self._get_redis_key(memory.session_id, memory.user_id) ttl = int(timedelta(days=settings.LLM_REDIS_MEMORY_RETENTION_DAYS).total_seconds()) await self.redis_helper.set( redis_key, memory_dict, ttl=ttl, region="AI_AGENT" ) except Exception as e: logger.warning(f"保存记忆到Redis失败: {e}") async def _save_conversation(self, memory: ConversationMemory): """ 保存记忆到存储 Redis中的记忆会自动通过TTL机制过期,无需手动清理 """ # 更新内存缓存 self._save_memory(memory) # 保存到Redis,设置TTL自动过期 await self._save_redis(memory) async def _cleanup_expired_memories(self): """ 清理内存中过期记忆的后台任务 注意:Redis中的记忆通过TTL机制自动过期,这里只清理内存缓存 """ while True: try: # 每小时清理一次 await asyncio.sleep(3600) current_time = datetime.now() expired_sessions = [] # 只检查内存缓存中的过期记忆 # Redis中的记忆会通过TTL自动过期,无需手动处理 for cache_key, memory in self.memory_cache.items(): if (current_time - memory.updated_at).days > settings.LLM_MEMORY_RETENTION_DAYS: expired_sessions.append(cache_key) # 只清理内存缓存,不删除Redis中的键(Redis会自动过期) for cache_key in expired_sessions: if cache_key in self.memory_cache: del self.memory_cache[cache_key] if expired_sessions: logger.info(f"清理了{len(expired_sessions)}个过期内存会话记忆") except asyncio.CancelledError: break except Exception as e: logger.error(f"清理记忆时发生错误: {e}") conversation_manager = ConversationMemoryManager() ================================================ FILE: app/agent/prompt/Agent Prompt.txt ================================================ You are an AI media assistant powered by MoviePilot, specialized in managing home media ecosystems. Your expertise covers searching for movies/TV shows, managing subscriptions, overseeing downloads, and organizing media libraries. All your responses must be in **Chinese (中文)**. You act as a proactive agent. Your goal is to fully resolve the user's media-related requests autonomously. Do not end your turn until the task is complete or you are blocked and require user feedback. Core Capabilities: 1. Media Search & Recognition - Identify movies, TV shows, and anime across various metadata providers. - Recognize media info from fuzzy filenames or incomplete titles. 2. Subscription Management - Create complex rules for automated downloading of new episodes. - Monitor trending movies/shows for automated suggestions. 3. Download Control - Intelligent torrent searching across private/public trackers. - Filter resources by quality (4K/1080p), codec (H265/H264), and release groups. 4. System Status & Organization - Monitor download progress and server health. - Manage file transfers, renaming, and library cleanup. - Use Markdown for structured data like movie lists, download statuses, or technical details. - Avoid wrapping the entire response in a single code block. Use `inline code` for titles or parameters and ```code blocks``` for structured logs or data only when necessary. - ALWAYS use backticks for media titles (e.g., `Interstellar`), file paths, or specific parameters. - Optimize your writing for clarity and readability, using bold text for key information. - Provide comprehensive details for media (year, rating, resolution) to help users make informed decisions. - Do not stop for approval for read-only operations. Only stop for critical actions like starting a download or deleting a subscription. Important Notes: - User-Centric: Your tone should be helpful, professional, and media-savvy. - No Coding Hallucinations: You are NOT a coding assistant. Do not offer code snippets, IDE tips, or programming help. Focus entirely on the MoviePilot media ecosystem. - Contextual Memory: Remember if the user preferred a specific version previously and prioritize similar results in future searches. Definition: Provide a brief progress narrative (1-3 sentences) explaining what you have searched, what you found, and what you are about to execute. - **Immediate Execution**: If you state an intention to perform an action (e.g., "I'll search for the movie"), execute the corresponding tool call in the same turn. - Use natural tenses: "I've found...", "I'm checking...", "I will now add...". - Skip redundant updates if no significant progress has been made since the last message. At the end of your session/turn, provide a concise summary of your actions. - Highlight key results: "Subscribed to `Stranger Things`", "Added `Avatar` 4K to download queue". - Use bullet points for multiple actions. - Do not repeat the internal execution steps; focus on the outcome for the user. 1. Media Discovery: Start by identifying the exact media metadata (TMDB ID, Season/Episode) using search tools. 2. Context Checking: Verify current status (Is it already in the library? Is it already subscribed?). 3. Action Execution: Perform the requested task (Subscribe, Search Torrents, etc.) with a brief status update. 4. Final Confirmation: Summarize the final state and wait for the next user command. - Parallel Execution: You MUST call independent tools in parallel. For example, search for torrents on multiple sites or check both subscription and download status at once. - Information Depth: If a search returns ambiguous results, use `query_media_detail` or `recognize_media` to resolve the ambiguity before proceeding. - Proactive Fallback: If `search_media` fails, try `search_web` or fuzzy search with `recognize_media`. Do not ask the user for help unless all automated search methods are exhausted. 1. Download Safety: You MUST present a list of found torrents (including size, seeds, and quality) and obtain the user's explicit consent before initiating any download. 2. Subscription Logic: When adding a subscription, always check for the best matching quality profile based on user history or the default settings. 3. Library Awareness: Always check if the user already has the content in their library to avoid duplicate downloads. 4. Error Handling: If a site is down or a tool returns an error, explain the situation in plain Chinese (e.g., "站点响应超时") and suggest an alternative (e.g., "尝试从其他站点进行搜索"). Specific markdown rules: {markdown_spec} ================================================ FILE: app/agent/prompt/__init__.py ================================================ """提示词管理器""" from pathlib import Path from typing import Dict from app.log import logger from app.schemas import ChannelCapability, ChannelCapabilities, MessageChannel, ChannelCapabilityManager class PromptManager: """ 提示词管理器 """ def __init__(self, prompts_dir: str = None): if prompts_dir is None: self.prompts_dir = Path(__file__).parent else: self.prompts_dir = Path(prompts_dir) self.prompts_cache: Dict[str, str] = {} def load_prompt(self, prompt_name: str) -> str: """ 加载指定的提示词 """ if prompt_name in self.prompts_cache: return self.prompts_cache[prompt_name] prompt_file = self.prompts_dir / prompt_name try: with open(prompt_file, 'r', encoding='utf-8') as f: content = f.read().strip() # 缓存提示词 self.prompts_cache[prompt_name] = content logger.info(f"提示词加载成功: {prompt_name},长度:{len(content)} 字符") return content except FileNotFoundError: logger.error(f"提示词文件不存在: {prompt_file}") raise except Exception as e: logger.error(f"加载提示词失败: {prompt_name}, 错误: {e}") raise def get_agent_prompt(self, channel: str = None) -> str: """ 获取智能体提示词 :param channel: 消息渠道(Telegram、微信、Slack等) :return: 提示词内容 """ # 基础提示词 base_prompt = self.load_prompt("Agent Prompt.txt") # 识别渠道 msg_channel = next((c for c in MessageChannel if c.value.lower() == channel.lower()), None) if channel else None if msg_channel: # 获取渠道能力说明 caps = ChannelCapabilityManager.get_capabilities(msg_channel) if caps: base_prompt = base_prompt.replace( "{markdown_spec}", self._generate_formatting_instructions(caps) ) return base_prompt @staticmethod def _generate_formatting_instructions(caps: ChannelCapabilities) -> str: """ 根据渠道能力动态生成格式指令 """ instructions = [] if ChannelCapability.RICH_TEXT not in caps.capabilities: instructions.append("- Formatting: Use **Plain Text ONLY**. The channel does NOT support Markdown.") instructions.append( "- No Markdown Symbols: NEVER use `**`, `*`, `__`, or `[` blocks. Use natural text to emphasize (e.g., using ALL CAPS or separators).") instructions.append( "- Lists: Use plain text symbols like `>` or `*` at the start of lines, followed by manual line breaks.") instructions.append("- Links: Paste URLs directly as text.") return "\n".join(instructions) def clear_cache(self): """ 清空缓存 """ self.prompts_cache.clear() logger.info("提示词缓存已清空") prompt_manager = PromptManager() ================================================ FILE: app/agent/tools/__init__.py ================================================ ================================================ FILE: app/agent/tools/base.py ================================================ import json import uuid from abc import ABCMeta, abstractmethod from typing import Any, Optional from langchain.tools import BaseTool from pydantic import PrivateAttr from app.agent import StreamingCallbackHandler, conversation_manager from app.chain import ChainBase from app.log import logger from app.schemas import Notification class ToolChain(ChainBase): pass class MoviePilotTool(BaseTool, metaclass=ABCMeta): """ MoviePilot专用工具基类 """ _session_id: str = PrivateAttr() _user_id: str = PrivateAttr() _channel: str = PrivateAttr(default=None) _source: str = PrivateAttr(default=None) _username: str = PrivateAttr(default=None) _callback_handler: StreamingCallbackHandler = PrivateAttr(default=None) def __init__(self, session_id: str, user_id: str, **kwargs): super().__init__(**kwargs) self._session_id = session_id self._user_id = user_id def _run(self, *args: Any, **kwargs: Any) -> Any: pass async def _arun(self, **kwargs) -> str: """ 异步运行工具 """ # 获取工具调用前的agent消息 agent_message = await self._callback_handler.get_message() # 生成唯一的工具调用ID call_id = f"call_{str(uuid.uuid4())[:16]}" # 记忆工具调用 await conversation_manager.add_conversation( session_id=self._session_id, user_id=self._user_id, role="tool_call", content=agent_message, metadata={ "call_id": call_id, "tool_name": self.name, "parameters": kwargs } ) # 获取执行工具说明,优先使用工具自定义的提示消息,如果没有则使用 explanation tool_message = self.get_tool_message(**kwargs) if not tool_message: explanation = kwargs.get("explanation") if explanation: tool_message = explanation # 合并agent消息和工具执行消息,一起发送 messages = [] if agent_message: messages.append(agent_message) if tool_message: messages.append(f"⚙️ => {tool_message}") # 发送合并后的消息 if messages: merged_message = "\n\n".join(messages) await self.send_tool_message(merged_message, title="MoviePilot助手") logger.debug(f'Executing tool {self.name} with args: {kwargs}') # 执行工具,捕获异常确保结果总是被存储到记忆中 try: result = await self.run(**kwargs) logger.debug(f'Tool {self.name} executed with result: {result}') except Exception as e: # 记录异常详情 error_message = f"工具执行异常 ({type(e).__name__}): {str(e)}" logger.error(f'Tool {self.name} execution failed: {e}', exc_info=True) result = error_message # 记忆工具调用结果 if isinstance(result, str): formated_result = result elif isinstance(result, (int, float)): formated_result = str(result) else: formated_result = json.dumps(result, ensure_ascii=False, indent=2) await conversation_manager.add_conversation( session_id=self._session_id, user_id=self._user_id, role="tool_result", content=formated_result, metadata={ "call_id": call_id, "tool_name": self.name, } ) return result def get_tool_message(self, **kwargs) -> Optional[str]: """ 获取工具执行时的友好提示消息 子类可以重写此方法,根据实际参数生成个性化的提示消息。 如果返回 None 或空字符串,将回退使用 explanation 参数。 Args: **kwargs: 工具的所有参数(包括 explanation) Returns: str: 友好的提示消息,如果返回 None 或空字符串则使用 explanation """ return None @abstractmethod async def run(self, **kwargs) -> str: raise NotImplementedError def set_message_attr(self, channel: str, source: str, username: str): """ 设置消息属性 """ self._channel = channel self._source = source self._username = username def set_callback_handler(self, callback_handler: StreamingCallbackHandler): """ 设置回调处理器 """ self._callback_handler = callback_handler async def send_tool_message(self, message: str, title: str = ""): """ 发送工具消息 """ await ToolChain().async_post_message( Notification( channel=self._channel, source=self._source, userid=self._user_id, username=self._username, title=title, text=message ) ) ================================================ FILE: app/agent/tools/factory.py ================================================ from typing import List, Callable from app.agent.tools.impl.add_download import AddDownloadTool from app.agent.tools.impl.add_subscribe import AddSubscribeTool from app.agent.tools.impl.update_subscribe import UpdateSubscribeTool from app.agent.tools.impl.search_subscribe import SearchSubscribeTool from app.agent.tools.impl.get_recommendations import GetRecommendationsTool from app.agent.tools.impl.query_downloaders import QueryDownloadersTool from app.agent.tools.impl.query_download_tasks import QueryDownloadTasksTool from app.agent.tools.impl.query_library_exists import QueryLibraryExistsTool from app.agent.tools.impl.query_library_latest import QueryLibraryLatestTool from app.agent.tools.impl.query_sites import QuerySitesTool from app.agent.tools.impl.update_site import UpdateSiteTool from app.agent.tools.impl.query_site_userdata import QuerySiteUserdataTool from app.agent.tools.impl.test_site import TestSiteTool from app.agent.tools.impl.query_subscribes import QuerySubscribesTool from app.agent.tools.impl.query_subscribe_shares import QuerySubscribeSharesTool from app.agent.tools.impl.query_rule_groups import QueryRuleGroupsTool from app.agent.tools.impl.query_popular_subscribes import QueryPopularSubscribesTool from app.agent.tools.impl.query_subscribe_history import QuerySubscribeHistoryTool from app.agent.tools.impl.delete_subscribe import DeleteSubscribeTool from app.agent.tools.impl.search_media import SearchMediaTool from app.agent.tools.impl.search_person import SearchPersonTool from app.agent.tools.impl.search_person_credits import SearchPersonCreditsTool from app.agent.tools.impl.recognize_media import RecognizeMediaTool from app.agent.tools.impl.scrape_metadata import ScrapeMetadataTool from app.agent.tools.impl.query_episode_schedule import QueryEpisodeScheduleTool from app.agent.tools.impl.query_media_detail import QueryMediaDetailTool from app.agent.tools.impl.search_torrents import SearchTorrentsTool from app.agent.tools.impl.get_search_results import GetSearchResultsTool from app.agent.tools.impl.search_web import SearchWebTool from app.agent.tools.impl.send_message import SendMessageTool from app.agent.tools.impl.query_schedulers import QuerySchedulersTool from app.agent.tools.impl.run_scheduler import RunSchedulerTool from app.agent.tools.impl.query_workflows import QueryWorkflowsTool from app.agent.tools.impl.run_workflow import RunWorkflowTool from app.agent.tools.impl.update_site_cookie import UpdateSiteCookieTool from app.agent.tools.impl.delete_download import DeleteDownloadTool from app.agent.tools.impl.query_directory_settings import QueryDirectorySettingsTool from app.agent.tools.impl.list_directory import ListDirectoryTool from app.agent.tools.impl.query_transfer_history import QueryTransferHistoryTool from app.agent.tools.impl.transfer_file import TransferFileTool from app.agent.tools.impl.execute_command import ExecuteCommandTool from app.core.plugin import PluginManager from app.log import logger from .base import MoviePilotTool class MoviePilotToolFactory: """ MoviePilot工具工厂 """ @staticmethod def create_tools(session_id: str, user_id: str, channel: str = None, source: str = None, username: str = None, callback_handler: Callable = None) -> List[MoviePilotTool]: """ 创建MoviePilot工具列表 """ tools = [] tool_definitions = [ SearchMediaTool, SearchPersonTool, SearchPersonCreditsTool, RecognizeMediaTool, ScrapeMetadataTool, QueryEpisodeScheduleTool, QueryMediaDetailTool, AddSubscribeTool, UpdateSubscribeTool, SearchSubscribeTool, SearchTorrentsTool, GetSearchResultsTool, SearchWebTool, AddDownloadTool, QuerySubscribesTool, QuerySubscribeSharesTool, QueryPopularSubscribesTool, QueryRuleGroupsTool, QuerySubscribeHistoryTool, DeleteSubscribeTool, QueryDownloadTasksTool, DeleteDownloadTool, QueryDownloadersTool, QuerySitesTool, UpdateSiteTool, QuerySiteUserdataTool, TestSiteTool, UpdateSiteCookieTool, GetRecommendationsTool, QueryLibraryExistsTool, QueryLibraryLatestTool, QueryDirectorySettingsTool, ListDirectoryTool, QueryTransferHistoryTool, TransferFileTool, SendMessageTool, QuerySchedulersTool, RunSchedulerTool, QueryWorkflowsTool, RunWorkflowTool, ExecuteCommandTool ] # 创建内置工具 for ToolClass in tool_definitions: tool = ToolClass( session_id=session_id, user_id=user_id ) tool.set_message_attr(channel=channel, source=source, username=username) tool.set_callback_handler(callback_handler=callback_handler) tools.append(tool) # 加载插件提供的工具 plugin_tools_count = 0 plugin_tools_info = PluginManager().get_plugin_agent_tools() for plugin_info in plugin_tools_info: plugin_id = plugin_info.get("plugin_id") plugin_name = plugin_info.get("plugin_name") tool_classes = plugin_info.get("tools", []) for ToolClass in tool_classes: try: # 验证工具类是否继承自 MoviePilotTool if not issubclass(ToolClass, MoviePilotTool): logger.warning(f"插件 {plugin_name}({plugin_id}) 提供的工具类 {ToolClass.__name__} 未继承自 MoviePilotTool,已跳过") continue # 创建工具实例 tool = ToolClass( session_id=session_id, user_id=user_id ) tool.set_message_attr(channel=channel, source=source, username=username) tool.set_callback_handler(callback_handler=callback_handler) tools.append(tool) plugin_tools_count += 1 logger.debug(f"成功加载插件 {plugin_name}({plugin_id}) 的工具: {ToolClass.__name__}") except Exception as e: logger.error(f"加载插件 {plugin_name}({plugin_id}) 的工具 {ToolClass.__name__} 失败: {str(e)}") builtin_tools_count = len(tool_definitions) if plugin_tools_count > 0: logger.info(f"成功创建 {len(tools)} 个MoviePilot工具(内置工具: {builtin_tools_count} 个,插件工具: {plugin_tools_count} 个)") else: logger.info(f"成功创建 {len(tools)} 个MoviePilot工具") return tools ================================================ FILE: app/agent/tools/impl/__init__.py ================================================ ================================================ FILE: app/agent/tools/impl/_torrent_search_utils.py ================================================ """种子搜索工具辅助函数""" import re from typing import List, Optional from app.core.context import Context from app.utils.crypto import HashUtils from app.utils.string import StringUtils SEARCH_RESULT_CACHE_FILE = "__search_result__" TORRENT_RESULT_LIMIT = 50 def build_torrent_ref(context: Optional[Context]) -> str: """生成用于下载校验的短引用""" if not context or not context.torrent_info: return "" return HashUtils.sha1(context.torrent_info.enclosure or "")[:7] def sort_season_options(options: List[str]) -> List[str]: """按前端逻辑排序季集选项""" if len(options) <= 1: return options parsed_options = [] for index, option in enumerate(options): match = re.match(r"^S(\d+)(?:-S(\d+))?\s*(?:E(\d+)(?:-E(\d+))?)?$", option or "") if not match: parsed_options.append({ "original": option, "season_num": 0, "episode_num": 0, "max_episode_num": 0, "is_whole_season": False, "index": index, }) continue episode_num = int(match.group(3)) if match.group(3) else 0 max_episode_num = int(match.group(4)) if match.group(4) else episode_num parsed_options.append({ "original": option, "season_num": int(match.group(1)), "episode_num": episode_num, "max_episode_num": max_episode_num, "is_whole_season": not match.group(3), "index": index, }) whole_seasons = [item for item in parsed_options if item["is_whole_season"]] episodes = [item for item in parsed_options if not item["is_whole_season"]] whole_seasons.sort(key=lambda item: (-item["season_num"], item["index"])) episodes.sort( key=lambda item: ( -item["season_num"], -(item["max_episode_num"] or item["episode_num"]), -item["episode_num"], item["index"], ) ) return [item["original"] for item in whole_seasons + episodes] def append_option(options: List[str], value: Optional[str]) -> None: """按前端逻辑收集去重后的筛选项""" if value and value not in options: options.append(value) def build_filter_options(items: List[Context]) -> dict: """从搜索结果中构建筛选项汇总""" filter_options = { "site": [], "season": [], "freeState": [], "edition": [], "resolution": [], "videoCode": [], "releaseGroup": [], } for item in items: torrent_info = item.torrent_info meta_info = item.meta_info append_option(filter_options["site"], getattr(torrent_info, "site_name", None)) append_option(filter_options["season"], getattr(meta_info, "season_episode", None)) append_option(filter_options["freeState"], getattr(torrent_info, "volume_factor", None)) append_option(filter_options["edition"], getattr(meta_info, "edition", None)) append_option(filter_options["resolution"], getattr(meta_info, "resource_pix", None)) append_option(filter_options["videoCode"], getattr(meta_info, "video_encode", None)) append_option(filter_options["releaseGroup"], getattr(meta_info, "resource_team", None)) filter_options["season"] = sort_season_options(filter_options["season"]) return filter_options def match_filter(filter_values: Optional[List[str]], value: Optional[str]) -> bool: """匹配前端同款多选筛选规则""" return not filter_values or bool(value and value in filter_values) def filter_contexts(items: List[Context], site: Optional[List[str]] = None, season: Optional[List[str]] = None, free_state: Optional[List[str]] = None, video_code: Optional[List[str]] = None, edition: Optional[List[str]] = None, resolution: Optional[List[str]] = None, release_group: Optional[List[str]] = None) -> List[Context]: """按前端同款维度筛选结果""" filtered_items = [] for item in items: torrent_info = item.torrent_info meta_info = item.meta_info if ( match_filter(site, getattr(torrent_info, "site_name", None)) and match_filter(free_state, getattr(torrent_info, "volume_factor", None)) and match_filter(season, getattr(meta_info, "season_episode", None)) and match_filter(release_group, getattr(meta_info, "resource_team", None)) and match_filter(video_code, getattr(meta_info, "video_encode", None)) and match_filter(resolution, getattr(meta_info, "resource_pix", None)) and match_filter(edition, getattr(meta_info, "edition", None)) ): filtered_items.append(item) return filtered_items def simplify_search_result(context: Context, index: int) -> dict: """精简单条搜索结果""" simplified = {} torrent_info = context.torrent_info meta_info = context.meta_info media_info = context.media_info if torrent_info: simplified["torrent_info"] = { "title": torrent_info.title, "size": StringUtils.format_size(torrent_info.size), "seeders": torrent_info.seeders, "peers": torrent_info.peers, "site_name": torrent_info.site_name, "torrent_url": f"{build_torrent_ref(context)}:{index}", "page_url": torrent_info.page_url, "volume_factor": torrent_info.volume_factor, "freedate_diff": torrent_info.freedate_diff, "pubdate": torrent_info.pubdate, } if media_info: simplified["media_info"] = { "title": media_info.title, "en_title": media_info.en_title, "year": media_info.year, "type": media_info.type.value if media_info.type else None, "season": media_info.season, "tmdb_id": media_info.tmdb_id, } if meta_info: simplified["meta_info"] = { "name": meta_info.name, "cn_name": meta_info.cn_name, "en_name": meta_info.en_name, "year": meta_info.year, "type": meta_info.type.value if meta_info.type else None, "begin_season": meta_info.begin_season, "season_episode": meta_info.season_episode, "resource_team": meta_info.resource_team, "video_encode": meta_info.video_encode, "edition": meta_info.edition, "resource_pix": meta_info.resource_pix, } return simplified ================================================ FILE: app/agent/tools/impl/add_download.py ================================================ """添加下载工具""" import re from pathlib import Path from typing import List, Optional, Type from pydantic import BaseModel, Field from app.agent.tools.base import MoviePilotTool, ToolChain from app.chain.search import SearchChain from app.chain.download import DownloadChain from app.core.config import settings from app.core.context import Context from app.core.metainfo import MetaInfo from app.db.site_oper import SiteOper from app.helper.directory import DirectoryHelper from app.log import logger from app.schemas import TorrentInfo, FileURI from app.utils.crypto import HashUtils class AddDownloadInput(BaseModel): """添加下载工具的输入参数模型""" explanation: str = Field(..., description="Clear explanation of why this tool is being used in the current context") torrent_url: List[str] = Field( ..., description="One or more torrent_url values. Values matching the hash:id pattern from get_search_results are treated as internal references; other values must be direct torrent URLs or magnet links." ) downloader: Optional[str] = Field(None, description="Name of the downloader to use (optional, uses default if not specified)") save_path: Optional[str] = Field(None, description="Directory path where the downloaded files should be saved. Using `:` for remote storage. e.g. rclone:/MP, smb:/server/share/Movies. (optional, uses default path if not specified)") labels: Optional[str] = Field(None, description="Comma-separated list of labels/tags to assign to the download (optional, e.g., 'movie,hd,bluray')") class AddDownloadTool(MoviePilotTool): name: str = "add_download" description: str = "Add torrent download task to the configured downloader (qBittorrent, Transmission, etc.) using hash:id references from get_search_results or direct torrent URLs / magnet links." args_schema: Type[BaseModel] = AddDownloadInput def get_tool_message(self, **kwargs) -> Optional[str]: """根据下载参数生成友好的提示消息""" torrent_urls = self._normalize_torrent_urls(kwargs.get("torrent_url")) downloader = kwargs.get("downloader") if torrent_urls: if len(torrent_urls) == 1: if self._is_torrent_ref(torrent_urls[0]): message = f"正在添加下载任务: 资源 {torrent_urls[0]}" else: message = "正在添加下载任务: 直链或磁力链接" else: message = f"正在批量添加下载任务: 共 {len(torrent_urls)} 个资源" else: message = "正在添加下载任务" if downloader: message += f" [下载器: {downloader}]" return message @staticmethod def _build_torrent_ref(context: Context) -> str: """生成用于校验缓存项的短引用""" if not context or not context.torrent_info: return "" return HashUtils.sha1(context.torrent_info.enclosure or "")[:7] @staticmethod def _is_torrent_ref(torrent_ref: Optional[str]) -> bool: """判断是否为内部搜索结果引用""" if not torrent_ref: return False return bool(re.fullmatch(r"[0-9a-f]{7}:\d+", str(torrent_ref).strip())) @staticmethod def _is_direct_download_url(torrent_url: Optional[str]) -> bool: """判断是否为允许直传下载器的下载内容""" if not torrent_url: return False value = str(torrent_url).strip() return value.startswith("http://") or value.startswith("https://") or value.startswith("magnet:") @classmethod def _resolve_cached_context(cls, torrent_ref: str) -> Optional[Context]: """从最近一次搜索缓存中解析种子上下文,仅支持 hash:id 格式""" ref = str(torrent_ref).strip() if ":" not in ref: return None try: ref_hash, ref_index = ref.split(":", 1) index = int(ref_index) except (TypeError, ValueError): return None if index < 1: return None results = SearchChain().last_search_results() or [] if index > len(results): return None context = results[index - 1] if not ref_hash or cls._build_torrent_ref(context) != ref_hash: return None return context @staticmethod def _merge_labels_with_system_tag(labels: Optional[str]) -> Optional[str]: """合并用户标签与系统默认标签,确保任务可被系统管理""" system_tag = (settings.TORRENT_TAG or "").strip() user_labels = [item.strip() for item in (labels or "").split(",") if item.strip()] if system_tag and system_tag not in user_labels: user_labels.append(system_tag) return ",".join(user_labels) if user_labels else None @staticmethod def _format_failed_result(failed_messages: List[str]) -> str: """统一格式化失败结果""" return ", ".join([message for message in failed_messages if message]) @staticmethod def _build_failure_message(torrent_ref: str, error_msg: Optional[str] = None) -> str: """构造失败提示""" normalized_error = (error_msg or "").strip() prefix = "添加种子任务失败:" if normalized_error.startswith(prefix): normalized_error = normalized_error[len(prefix):].lstrip() if AddDownloadTool._is_direct_download_url(normalized_error): normalized_error = "" if normalized_error: return f"{torrent_ref} {normalized_error}" if AddDownloadTool._is_torrent_ref(torrent_ref): return torrent_ref return "" @classmethod def _normalize_torrent_urls(cls, torrent_url: Optional[List[str] | str]) -> List[str]: """统一规范 torrent_url 输入,保留所有非空值""" if torrent_url is None: return [] if isinstance(torrent_url, str): candidates = torrent_url.split(",") else: candidates = torrent_url return [str(item).strip() for item in candidates if item and str(item).strip()] @staticmethod def _resolve_direct_download_dir(save_path: Optional[str]) -> Optional[Path]: """解析直接下载使用的目录,优先使用 save_path,其次使用默认下载目录""" if save_path: return Path(save_path) download_dirs = DirectoryHelper().get_download_dirs() if not download_dirs: return None dir_conf = download_dirs[0] if not dir_conf.download_path: return None return Path(FileURI(storage=dir_conf.storage or "local", path=dir_conf.download_path).uri) async def run(self, torrent_url: Optional[List[str]] = None, downloader: Optional[str] = None, save_path: Optional[str] = None, labels: Optional[str] = None, **kwargs) -> str: logger.info( f"执行工具: {self.name}, 参数: torrent_url={torrent_url}, downloader={downloader}, save_path={save_path}, labels={labels}") try: torrent_inputs = self._normalize_torrent_urls(torrent_url) if not torrent_inputs: return "错误:torrent_url 不能为空。" download_chain = DownloadChain() merged_labels = self._merge_labels_with_system_tag(labels) success_count = 0 failed_messages = [] for torrent_input in torrent_inputs: if self._is_torrent_ref(torrent_input): cached_context = self._resolve_cached_context(torrent_input) if not cached_context or not cached_context.torrent_info: failed_messages.append(f"{torrent_input} 引用无效,请重新使用 get_search_results 查看搜索结果") continue cached_torrent = cached_context.torrent_info site_name = cached_torrent.site_name torrent_title = cached_torrent.title or torrent_input torrent_description = cached_torrent.description enclosure = cached_torrent.enclosure if not site_name: failed_messages.append(f"{torrent_input} 缺少站点名称") continue siteinfo = await SiteOper().async_get_by_name(site_name) if not siteinfo: failed_messages.append(f"{torrent_input} 未找到站点信息 {site_name}") continue torrent_info = TorrentInfo( title=torrent_title, description=torrent_description, enclosure=enclosure, site_name=site_name, site_ua=siteinfo.ua, site_cookie=siteinfo.cookie, site_proxy=siteinfo.proxy, site_order=siteinfo.pri, site_downloader=siteinfo.downloader ) meta_info = MetaInfo(title=torrent_title, subtitle=torrent_description) media_info = cached_context.media_info if cached_context.media_info else None if not media_info: media_info = await ToolChain().async_recognize_media(meta=meta_info) if not media_info: failed_messages.append(f"{torrent_input} 无法识别媒体信息") continue context = Context( torrent_info=torrent_info, meta_info=meta_info, media_info=media_info ) else: if not self._is_direct_download_url(torrent_input): failed_messages.append( f"{torrent_input} 不是有效的下载内容,非 hash:id 时仅支持 http://、https:// 或 magnet: 开头" ) continue download_dir = self._resolve_direct_download_dir(save_path) if not download_dir: failed_messages.append(f"{torrent_input} 缺少保存路径,且系统未配置可用下载目录") continue result = download_chain.download( content=torrent_input, download_dir=download_dir, cookie=None, label=merged_labels, downloader=downloader ) if result: _, did, _, error_msg = result else: did, error_msg = None, "未找到下载器" if did: success_count += 1 else: failed_messages.append(self._build_failure_message(torrent_input, error_msg)) continue did, error_msg = download_chain.download_single( context=context, downloader=downloader, save_path=save_path, label=merged_labels, return_detail=True ) if did: success_count += 1 else: failed_messages.append(self._build_failure_message(torrent_input, error_msg)) if success_count and not failed_messages: return "任务添加成功" if success_count: return f"部分任务添加失败:{self._format_failed_result(failed_messages)}" return f"任务添加失败:{self._format_failed_result(failed_messages)}" except Exception as e: logger.error(f"添加下载任务失败: {e}", exc_info=True) return f"添加下载任务时发生错误: {str(e)}" ================================================ FILE: app/agent/tools/impl/add_subscribe.py ================================================ """添加订阅工具""" from typing import Optional, Type, List from pydantic import BaseModel, Field from app.agent.tools.base import MoviePilotTool from app.chain.subscribe import SubscribeChain from app.log import logger from app.schemas.types import MediaType class AddSubscribeInput(BaseModel): """添加订阅工具的输入参数模型""" explanation: str = Field(..., description="Clear explanation of why this tool is being used in the current context") title: str = Field(..., description="The title of the media to subscribe to (e.g., 'The Matrix', 'Breaking Bad')") year: str = Field(..., description="Release year of the media (required for accurate identification)") media_type: str = Field(..., description="Allowed values: movie, tv") season: Optional[int] = Field(None, description="Season number for TV shows (optional, if not specified will subscribe to all seasons)") tmdb_id: Optional[int] = Field(None, description="TMDB database ID for precise media identification (optional, can be obtained from search_media tool)") douban_id: Optional[str] = Field(None, description="Douban ID for precise media identification (optional, alternative to tmdb_id)") start_episode: Optional[int] = Field(None, description="Starting episode number for TV shows (optional, defaults to 1 if not specified)") total_episode: Optional[int] = Field(None, description="Total number of episodes for TV shows (optional, will be auto-detected from TMDB if not specified)") quality: Optional[str] = Field(None, description="Quality filter as regular expression (optional, e.g., 'BluRay|WEB-DL|HDTV')") resolution: Optional[str] = Field(None, description="Resolution filter as regular expression (optional, e.g., '1080p|720p|2160p')") effect: Optional[str] = Field(None, description="Effect filter as regular expression (optional, e.g., 'HDR|DV|SDR')") filter_groups: Optional[List[str]] = Field(None, description="List of filter rule group names to apply (optional, can be obtained from query_rule_groups tool)") sites: Optional[List[int]] = Field(None, description="List of site IDs to search from (optional, can be obtained from query_sites tool)") class AddSubscribeTool(MoviePilotTool): name: str = "add_subscribe" description: str = "Add media subscription to create automated download rules for movies and TV shows. The system will automatically search and download new episodes or releases based on the subscription criteria. Supports advanced filtering options like quality, resolution, and effect filters using regular expressions." args_schema: Type[BaseModel] = AddSubscribeInput def get_tool_message(self, **kwargs) -> Optional[str]: """根据订阅参数生成友好的提示消息""" title = kwargs.get("title", "") year = kwargs.get("year", "") media_type = kwargs.get("media_type", "") season = kwargs.get("season") message = f"正在添加订阅: {title}" if year: message += f" ({year})" if media_type: message += f" [{media_type}]" if season: message += f" 第{season}季" return message async def run(self, title: str, year: str, media_type: str, season: Optional[int] = None, tmdb_id: Optional[int] = None, douban_id: Optional[str] = None, start_episode: Optional[int] = None, total_episode: Optional[int] = None, quality: Optional[str] = None, resolution: Optional[str] = None, effect: Optional[str] = None, filter_groups: Optional[List[str]] = None, sites: Optional[List[int]] = None, **kwargs) -> str: logger.info( f"执行工具: {self.name}, 参数: title={title}, year={year}, media_type={media_type}, " f"season={season}, tmdb_id={tmdb_id}, douban_id={douban_id}, start_episode={start_episode}, " f"total_episode={total_episode}, quality={quality}, resolution={resolution}, " f"effect={effect}, filter_groups={filter_groups}, sites={sites}") try: subscribe_chain = SubscribeChain() media_type_enum = MediaType.from_agent(media_type) if not media_type_enum: return f"错误:无效的媒体类型 '{media_type}',支持的类型:'movie', 'tv'" # 构建额外的订阅参数 subscribe_kwargs = {} if start_episode is not None: subscribe_kwargs['start_episode'] = start_episode if total_episode is not None: subscribe_kwargs['total_episode'] = total_episode if quality: subscribe_kwargs['quality'] = quality if resolution: subscribe_kwargs['resolution'] = resolution if effect: subscribe_kwargs['effect'] = effect if filter_groups: subscribe_kwargs['filter_groups'] = filter_groups if sites: subscribe_kwargs['sites'] = sites sid, message = await subscribe_chain.async_add( mtype=media_type_enum, title=title, year=year, tmdbid=tmdb_id, doubanid=douban_id, season=season, username=self._user_id, **subscribe_kwargs ) if sid: if message and "已存在" in message: return f"订阅已存在:{title} ({year})。如需修改参数请先删除旧订阅。" result_msg = f"成功添加订阅:{title} ({year})" if subscribe_kwargs: params = [] if start_episode is not None: params.append(f"开始集数: {start_episode}") if total_episode is not None: params.append(f"总集数: {total_episode}") if quality: params.append(f"质量过滤: {quality}") if resolution: params.append(f"分辨率过滤: {resolution}") if effect: params.append(f"特效过滤: {effect}") if filter_groups: params.append(f"规则组: {', '.join(filter_groups)}") if sites: params.append(f"站点: {', '.join(map(str, sites))}") if params: result_msg += f"\n配置参数: {', '.join(params)}" return result_msg else: return f"添加订阅失败:{message}" except Exception as e: logger.error(f"添加订阅失败: {e}", exc_info=True) return f"添加订阅时发生错误: {str(e)}" ================================================ FILE: app/agent/tools/impl/delete_download.py ================================================ """删除下载任务工具""" from typing import Optional, Type from pydantic import BaseModel, Field from app.agent.tools.base import MoviePilotTool from app.chain.download import DownloadChain from app.log import logger class DeleteDownloadInput(BaseModel): """删除下载任务工具的输入参数模型""" explanation: str = Field(..., description="Clear explanation of why this tool is being used in the current context") hash: str = Field(..., description="Task hash (can be obtained from query_download_tasks tool)") downloader: Optional[str] = Field(None, description="Name of specific downloader (optional, if not provided will search all downloaders)") delete_files: Optional[bool] = Field(False, description="Whether to delete downloaded files along with the task (default: False, only removes the task from downloader)") class DeleteDownloadTool(MoviePilotTool): name: str = "delete_download" description: str = "Delete a download task from the downloader by task hash only. Optionally specify the downloader name and whether to delete downloaded files." args_schema: Type[BaseModel] = DeleteDownloadInput def get_tool_message(self, **kwargs) -> Optional[str]: """根据删除参数生成友好的提示消息""" hash_value = kwargs.get("hash", "") downloader = kwargs.get("downloader") delete_files = kwargs.get("delete_files", False) message = f"正在删除下载任务: {hash_value}" if downloader: message += f" [下载器: {downloader}]" if delete_files: message += " (包含文件)" return message async def run(self, hash: str, downloader: Optional[str] = None, delete_files: Optional[bool] = False, **kwargs) -> str: logger.info(f"执行工具: {self.name}, 参数: hash={hash}, downloader={downloader}, delete_files={delete_files}") try: download_chain = DownloadChain() # 仅支持通过hash删除任务 if len(hash) != 40 or not all(c in '0123456789abcdefABCDEF' for c in hash): return "参数错误:hash 格式无效,请先使用 query_download_tasks 工具获取正确的 hash。" # 删除下载任务 # remove_torrents 支持 delete_file 参数,可以控制是否删除文件 result = download_chain.remove_torrents(hashs=[hash], downloader=downloader, delete_file=delete_files) if result: files_info = "(包含文件)" if delete_files else "(不包含文件)" return f"成功删除下载任务:{hash} {files_info}" else: return f"删除下载任务失败:{hash},请检查任务是否存在或下载器是否可用" except Exception as e: logger.error(f"删除下载任务失败: {e}", exc_info=True) return f"删除下载任务时发生错误: {str(e)}" ================================================ FILE: app/agent/tools/impl/delete_subscribe.py ================================================ """删除订阅工具""" from typing import Optional, Type from pydantic import BaseModel, Field from app.agent.tools.base import MoviePilotTool from app.core.event import eventmanager from app.db.subscribe_oper import SubscribeOper from app.helper.subscribe import SubscribeHelper from app.log import logger from app.schemas.types import EventType class DeleteSubscribeInput(BaseModel): """删除订阅工具的输入参数模型""" explanation: str = Field(..., description="Clear explanation of why this tool is being used in the current context") subscribe_id: int = Field(..., description="The ID of the subscription to delete (can be obtained from query_subscribes tool)") class DeleteSubscribeTool(MoviePilotTool): name: str = "delete_subscribe" description: str = "Delete a media subscription by its ID. This will remove the subscription and stop automatic downloads for that media." args_schema: Type[BaseModel] = DeleteSubscribeInput def get_tool_message(self, **kwargs) -> Optional[str]: """根据删除参数生成友好的提示消息""" subscribe_id = kwargs.get("subscribe_id") return f"正在删除订阅 (ID: {subscribe_id})" async def run(self, subscribe_id: int, **kwargs) -> str: logger.info(f"执行工具: {self.name}, 参数: subscribe_id={subscribe_id}") try: subscribe_oper = SubscribeOper() # 获取订阅信息 subscribe = await subscribe_oper.async_get(subscribe_id) if not subscribe: return f"订阅 ID {subscribe_id} 不存在" # 在删除之前获取订阅信息(用于事件) subscribe_info = subscribe.to_dict() # 删除订阅 subscribe_oper.delete(subscribe_id) # 发送事件 await eventmanager.async_send_event(EventType.SubscribeDeleted, { "subscribe_id": subscribe_id, "subscribe_info": subscribe_info }) # 统计订阅 SubscribeHelper().sub_done_async({ "tmdbid": subscribe.tmdbid, "doubanid": subscribe.doubanid }) return f"成功删除订阅:{subscribe.name} ({subscribe.year})" except Exception as e: logger.error(f"删除订阅失败: {e}", exc_info=True) return f"删除订阅时发生错误: {str(e)}" ================================================ FILE: app/agent/tools/impl/execute_command.py ================================================ """执行Shell命令工具""" import asyncio from typing import Optional, Type from pydantic import BaseModel, Field from app.agent.tools.base import MoviePilotTool from app.log import logger class ExecuteCommandInput(BaseModel): """执行Shell命令工具的输入参数模型""" explanation: str = Field(..., description="Clear explanation of why this command is being executed") command: str = Field(..., description="The shell command to execute") timeout: Optional[int] = Field(60, description="Max execution time in seconds (default: 60)") class ExecuteCommandTool(MoviePilotTool): name: str = "execute_command" description: str = "Safely execute shell commands on the server. Useful for system maintenance, checking status, or running custom scripts. Includes timeout and output limits." args_schema: Type[BaseModel] = ExecuteCommandInput def get_tool_message(self, **kwargs) -> Optional[str]: """根据命令生成友好的提示消息""" command = kwargs.get("command", "") return f"正在执行系统命令: {command}" async def run(self, command: str, timeout: Optional[int] = 60, **kwargs) -> str: logger.info(f"执行工具: {self.name}, 参数: command={command}, timeout={timeout}") # 简单安全过滤 forbidden_keywords = ["rm -rf /", ":(){ :|:& };:", "dd if=/dev/zero", "mkfs", "reboot", "shutdown"] for keyword in forbidden_keywords: if keyword in command: return f"错误:命令包含禁止使用的关键字 '{keyword}'" try: # 执行命令 process = await asyncio.create_subprocess_shell( command, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE ) try: # 等待完成,带超时 stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=timeout) # 处理输出 stdout_str = stdout.decode('utf-8', errors='replace').strip() stderr_str = stderr.decode('utf-8', errors='replace').strip() exit_code = process.returncode result = f"命令执行完成 (退出码: {exit_code})" if stdout_str: result += f"\n\n标准输出:\n{stdout_str}" if stderr_str: result += f"\n\n错误输出:\n{stderr_str}" # 如果没有输出 if not stdout_str and not stderr_str: result += "\n\n(无输出内容)" # 限制输出长度,防止上下文过长 if len(result) > 3000: result = result[:3000] + "\n\n...(输出内容过长,已截断)" return result except asyncio.TimeoutError: # 超时处理 try: process.kill() except ProcessLookupError: pass return f"命令执行超时 (限制: {timeout}秒)" except Exception as e: logger.error(f"执行命令失败: {e}", exc_info=True) return f"执行命令时发生错误: {str(e)}" ================================================ FILE: app/agent/tools/impl/get_recommendations.py ================================================ """获取推荐工具""" import json from typing import Optional, Type from pydantic import BaseModel, Field from app.agent.tools.base import MoviePilotTool from app.chain.recommend import RecommendChain from app.log import logger from app.schemas.types import MediaType, media_type_to_agent class GetRecommendationsInput(BaseModel): """获取推荐工具的输入参数模型""" explanation: str = Field(..., description="Clear explanation of why this tool is being used in the current context") source: Optional[str] = Field("tmdb_trending", description="Recommendation source: " "'tmdb_trending' for TMDB trending content, " "'tmdb_movies' for TMDB popular movies, " "'tmdb_tvs' for TMDB popular TV shows, " "'douban_hot' for Douban popular content, " "'douban_movie_hot' for Douban hot movies, " "'douban_tv_hot' for Douban hot TV shows, " "'douban_movie_showing' for Douban movies currently showing, " "'douban_movies' for Douban latest movies, " "'douban_tvs' for Douban latest TV shows, " "'douban_movie_top250' for Douban movie TOP250, " "'douban_tv_weekly_chinese' for Douban Chinese TV weekly chart, " "'douban_tv_weekly_global' for Douban global TV weekly chart, " "'douban_tv_animation' for Douban popular animation, " "'bangumi_calendar' for Bangumi anime calendar") media_type: Optional[str] = Field("all", description="Allowed values: movie, tv, all") limit: Optional[int] = Field(20, description="Maximum number of recommendations to return (default: 20, maximum: 100)") class GetRecommendationsTool(MoviePilotTool): name: str = "get_recommendations" description: str = "Get trending and popular media recommendations from various sources. Returns curated lists of popular movies, TV shows, and anime based on different criteria like trending, ratings, or calendar schedules." args_schema: Type[BaseModel] = GetRecommendationsInput def get_tool_message(self, **kwargs) -> Optional[str]: """根据推荐参数生成友好的提示消息""" source = kwargs.get("source", "tmdb_trending") media_type = kwargs.get("media_type", "all") limit = kwargs.get("limit", 20) source_map = { "tmdb_trending": "TMDB流行趋势", "tmdb_movies": "TMDB热门电影", "tmdb_tvs": "TMDB热门电视剧", "douban_hot": "豆瓣热门", "douban_movie_hot": "豆瓣热门电影", "douban_tv_hot": "豆瓣热门电视剧", "douban_movie_showing": "豆瓣正在热映", "douban_movies": "豆瓣最新电影", "douban_tvs": "豆瓣最新电视剧", "douban_movie_top250": "豆瓣电影TOP250", "douban_tv_weekly_chinese": "豆瓣国产剧集榜", "douban_tv_weekly_global": "豆瓣全球剧集榜", "douban_tv_animation": "豆瓣热门动漫", "bangumi_calendar": "番组计划" } source_desc = source_map.get(source, source) message = f"正在获取推荐: {source_desc}" if media_type != "all": message += f" [{media_type}]" message += f" (限制: {limit}条)" return message async def run(self, source: Optional[str] = "tmdb_trending", media_type: Optional[str] = "all", limit: Optional[int] = 20, **kwargs) -> str: logger.info(f"执行工具: {self.name}, 参数: source={source}, media_type={media_type}, limit={limit}") try: if media_type != "all": media_type_enum = MediaType.from_agent(media_type) if not media_type_enum: return f"错误:无效的媒体类型 '{media_type}',支持的类型:'movie', 'tv', 'all'" media_type = media_type_enum.to_agent() # 归一化为 "movie"/"tv" recommend_chain = RecommendChain() results = [] if source == "tmdb_trending": # async_tmdb_trending 只接受 page 参数,返回固定数量的结果 # 如果需要限制数量,需要在返回后截取 results = await recommend_chain.async_tmdb_trending(page=1) if limit and limit > 0: results = results[:limit] elif source == "tmdb_movies": # async_tmdb_movies 接受 page 参数,返回固定数量的结果 results = await recommend_chain.async_tmdb_movies(page=1) if limit and limit > 0: results = results[:limit] elif source == "tmdb_tvs": # async_tmdb_tvs 接受 page 参数,返回固定数量的结果 results = await recommend_chain.async_tmdb_tvs(page=1) if limit and limit > 0: results = results[:limit] elif source == "douban_hot": if media_type == "movie": results = await recommend_chain.async_douban_movie_hot(page=1, count=limit) elif media_type == "tv": results = await recommend_chain.async_douban_tv_hot(page=1, count=limit) else: # all results.extend(await recommend_chain.async_douban_movie_hot(page=1, count=limit)) results.extend(await recommend_chain.async_douban_tv_hot(page=1, count=limit)) elif source == "douban_movie_hot": results = await recommend_chain.async_douban_movie_hot(page=1, count=limit) elif source == "douban_tv_hot": results = await recommend_chain.async_douban_tv_hot(page=1, count=limit) elif source == "douban_movie_showing": results = await recommend_chain.async_douban_movie_showing(page=1, count=limit) elif source == "douban_movies": results = await recommend_chain.async_douban_movies(page=1, count=limit) elif source == "douban_tvs": results = await recommend_chain.async_douban_tvs(page=1, count=limit) elif source == "douban_movie_top250": results = await recommend_chain.async_douban_movie_top250(page=1, count=limit) elif source == "douban_tv_weekly_chinese": results = await recommend_chain.async_douban_tv_weekly_chinese(page=1, count=limit) elif source == "douban_tv_weekly_global": results = await recommend_chain.async_douban_tv_weekly_global(page=1, count=limit) elif source == "douban_tv_animation": results = await recommend_chain.async_douban_tv_animation(page=1, count=limit) elif source == "bangumi_calendar": results = await recommend_chain.async_bangumi_calendar(page=1, count=limit) else: # 不支持的推荐来源 supported_sources = [ "tmdb_trending", "tmdb_movies", "tmdb_tvs", "douban_hot", "douban_movie_hot", "douban_tv_hot", "douban_movie_showing", "douban_movies", "douban_tvs", "douban_movie_top250", "douban_tv_weekly_chinese", "douban_tv_weekly_global", "douban_tv_animation", "bangumi_calendar" ] return f"不支持的推荐来源: {source}。支持的来源包括: {', '.join(supported_sources)}" if results: # 限制最多20条结果 total_count = len(results) limited_results = results[:20] # 精简字段,只保留关键信息 simplified_results = [] for r in limited_results: # r 应该是字典格式(to_dict的结果),但为了安全起见进行检查 if not isinstance(r, dict): logger.warning(f"推荐结果格式异常,跳过: {type(r)}") continue simplified = { "title": r.get("title"), "en_title": r.get("en_title"), "year": r.get("year"), "type": media_type_to_agent(r.get("type")), "season": r.get("season"), "tmdb_id": r.get("tmdb_id"), "imdb_id": r.get("imdb_id"), "douban_id": r.get("douban_id"), "vote_average": r.get("vote_average"), "poster_path": r.get("poster_path"), "detail_link": r.get("detail_link") } simplified_results.append(simplified) result_json = json.dumps(simplified_results, ensure_ascii=False, indent=2) # 如果结果被裁剪,添加提示信息 if total_count > 20: return f"注意:推荐结果共找到 {total_count} 条,为节省上下文空间,仅显示前 20 条结果。\n\n{result_json}" return result_json return "未找到推荐内容。" except Exception as e: logger.error(f"获取推荐失败: {e}", exc_info=True) return f"获取推荐时发生错误: {str(e)}" ================================================ FILE: app/agent/tools/impl/get_search_results.py ================================================ """获取搜索结果工具""" import json import re from typing import List, Optional, Type from pydantic import BaseModel, Field from app.agent.tools.base import MoviePilotTool from app.chain.search import SearchChain from app.log import logger from ._torrent_search_utils import ( TORRENT_RESULT_LIMIT, build_filter_options, filter_contexts, simplify_search_result, ) class GetSearchResultsInput(BaseModel): """获取搜索结果工具的输入参数模型""" explanation: str = Field(..., description="Clear explanation of why this tool is being used in the current context") site: Optional[List[str]] = Field(None, description="Site name filters") season: Optional[List[str]] = Field(None, description="Season or episode filters") free_state: Optional[List[str]] = Field(None, description="Promotion state filters") video_code: Optional[List[str]] = Field(None, description="Video codec filters") edition: Optional[List[str]] = Field(None, description="Edition filters") resolution: Optional[List[str]] = Field(None, description="Resolution filters") release_group: Optional[List[str]] = Field(None, description="Release group filters") title_pattern: Optional[str] = Field(None, description="Regular expression pattern to filter torrent titles (e.g., '4K|2160p|UHD', '1080p.*BluRay')") show_filter_options: Optional[bool] = Field(False, description="Whether to return only optional filter options for re-checking available conditions") class GetSearchResultsTool(MoviePilotTool): name: str = "get_search_results" description: str = "Get cached torrent search results from search_torrents with optional filters. Returns at most the first 50 matches." args_schema: Type[BaseModel] = GetSearchResultsInput def get_tool_message(self, **kwargs) -> Optional[str]: return "正在获取搜索结果" async def run(self, site: Optional[List[str]] = None, season: Optional[List[str]] = None, free_state: Optional[List[str]] = None, video_code: Optional[List[str]] = None, edition: Optional[List[str]] = None, resolution: Optional[List[str]] = None, release_group: Optional[List[str]] = None, title_pattern: Optional[str] = None, show_filter_options: bool = False, **kwargs) -> str: logger.info( f"执行工具: {self.name}, 参数: site={site}, season={season}, free_state={free_state}, video_code={video_code}, edition={edition}, resolution={resolution}, release_group={release_group}, title_pattern={title_pattern}, show_filter_options={show_filter_options}") try: items = await SearchChain().async_last_search_results() or [] if not items: return "没有可用的搜索结果,请先使用 search_torrents 搜索" if show_filter_options: payload = { "total_count": len(items), "filter_options": build_filter_options(items), } return json.dumps(payload, ensure_ascii=False, indent=2) regex_pattern = None if title_pattern: try: regex_pattern = re.compile(title_pattern, re.IGNORECASE) except re.error as e: logger.warning(f"正则表达式编译失败: {title_pattern}, 错误: {e}") return f"正则表达式格式错误: {str(e)}" filtered_items = filter_contexts( items=items, site=site, season=season, free_state=free_state, video_code=video_code, edition=edition, resolution=resolution, release_group=release_group, ) if regex_pattern: filtered_items = [ item for item in filtered_items if item.torrent_info and item.torrent_info.title and regex_pattern.search(item.torrent_info.title) ] if not filtered_items: return "没有符合筛选条件的搜索结果,请调整筛选条件" total_count = len(filtered_items) filtered_ids = {id(item) for item in filtered_items} matched_indices = [index for index, item in enumerate(items, start=1) if id(item) in filtered_ids] limited_items = filtered_items[:TORRENT_RESULT_LIMIT] limited_indices = matched_indices[:TORRENT_RESULT_LIMIT] results = [ simplify_search_result(item, index) for item, index in zip(limited_items, limited_indices) ] payload = { "total_count": total_count, "results": results, } if total_count > TORRENT_RESULT_LIMIT: payload["message"] = f"搜索结果共找到 {total_count} 条,仅显示前 {TORRENT_RESULT_LIMIT} 条结果。" return json.dumps(payload, ensure_ascii=False, indent=2) except Exception as e: error_message = f"获取搜索结果失败: {str(e)}" logger.error(f"获取搜索结果失败: {e}", exc_info=True) return error_message ================================================ FILE: app/agent/tools/impl/list_directory.py ================================================ """查询文件系统目录内容工具""" import json from datetime import datetime from pathlib import Path from typing import Optional, Type from pydantic import BaseModel, Field from app.agent.tools.base import MoviePilotTool from app.chain.storage import StorageChain from app.log import logger from app.schemas.file import FileItem from app.utils.string import StringUtils class ListDirectoryInput(BaseModel): """查询文件系统目录内容工具的输入参数模型""" explanation: str = Field(..., description="Clear explanation of why this tool is being used in the current context") path: str = Field(..., description="Directory path to list contents (e.g., '/home/user/downloads' or 'C:/Downloads')") storage: Optional[str] = Field("local", description="Storage type (default: 'local' for local file system, can be 'smb', 'alist', etc.)") sort_by: Optional[str] = Field("name", description="Sort order: 'name' for alphabetical sorting, 'time' for modification time sorting (default: 'name')") class ListDirectoryTool(MoviePilotTool): name: str = "list_directory" description: str = "List actual files and folders in a file system directory (NOT configuration). Shows files and subdirectories with their names, types, sizes, and modification times. Returns up to 20 items and the total count if there are more items. Use 'query_directory_settings' to query directory configuration settings." args_schema: Type[BaseModel] = ListDirectoryInput def get_tool_message(self, **kwargs) -> Optional[str]: """根据目录参数生成友好的提示消息""" path = kwargs.get("path", "") storage = kwargs.get("storage", "local") message = f"正在查询目录: {path}" if storage != "local": message += f" [存储: {storage}]" return message async def run(self, path: str, storage: Optional[str] = "local", sort_by: Optional[str] = "name", **kwargs) -> str: logger.info(f"执行工具: {self.name}, 参数: path={path}, storage={storage}, sort_by={sort_by}") try: # 规范化路径 if not path: return "错误:路径不能为空" # 确保路径格式正确 if storage == "local": # 本地路径处理 if not path.startswith("/") and not (len(path) > 1 and path[1] == ":"): # 相对路径,尝试转换为绝对路径 path = str(Path(path).resolve()) else: # 远程存储路径,确保以/开头 if not path.startswith("/"): path = "/" + path # 创建FileItem fileitem = FileItem( storage=storage or "local", path=path, type="dir" ) # 查询目录内容 storage_chain = StorageChain() file_list = storage_chain.list_files(fileitem, recursion=False) if file_list is None: return f"无法访问目录:{path},请检查路径是否正确或存储是否可用" if not file_list: return f"目录 {path} 为空" # 排序 if sort_by == "time": file_list.sort(key=lambda x: x.modify_time or 0, reverse=True) else: # 默认按名称排序(目录优先,然后按名称) file_list.sort(key=lambda x: ( 0 if x.type == "dir" else 1, StringUtils.natural_sort_key(x.name or "") )) # 限制返回数量 total_count = len(file_list) limited_list = file_list[:20] # 转换为字典格式 simplified_items = [] for item in limited_list: # 格式化文件大小 size_str = None if item.size: size_str = StringUtils.str_filesize(item.size) # 格式化修改时间 modify_time_str = None if item.modify_time: try: modify_time_str = datetime.fromtimestamp(item.modify_time).strftime("%Y-%m-%d %H:%M:%S") except (ValueError, OSError): modify_time_str = str(item.modify_time) simplified = { "name": item.name, "type": item.type, "path": item.path, "size": size_str, "modify_time": modify_time_str } # 如果是文件,添加扩展名 if item.type == "file" and item.extension: simplified["extension"] = item.extension simplified_items.append(simplified) result_json = json.dumps(simplified_items, ensure_ascii=False, indent=2) # 如果结果被裁剪,添加提示信息 if total_count > 20: return f"注意:目录中共有 {total_count} 个项目,为节省上下文空间,仅显示前 20 个项目。\n\n{result_json}" else: return result_json except Exception as e: logger.error(f"查询目录内容失败: {e}", exc_info=True) return f"查询目录内容时发生错误: {str(e)}" ================================================ FILE: app/agent/tools/impl/query_directory_settings.py ================================================ """查询系统目录设置工具""" import json from typing import Optional, Type from pydantic import BaseModel, Field from app.agent.tools.base import MoviePilotTool from app.helper.directory import DirectoryHelper from app.log import logger class QueryDirectorySettingsInput(BaseModel): """查询系统目录设置工具的输入参数模型""" explanation: str = Field(..., description="Clear explanation of why this tool is being used in the current context") directory_type: Optional[str] = Field("all", description="Filter directories by type: 'download' for download directories, 'library' for media library directories, 'all' for all directories") storage_type: Optional[str] = Field("all", description="Filter directories by storage type: 'local' for local storage, 'remote' for remote storage, 'all' for all storage types") name: Optional[str] = Field(None, description="Filter directories by name (partial match, optional)") class QueryDirectorySettingsTool(MoviePilotTool): name: str = "query_directory_settings" description: str = "Query system directory configuration settings (NOT file listings). Returns configured directory paths, storage types, transfer modes, and other directory-related settings. Use 'list_directory' to list actual files and folders in a directory." args_schema: Type[BaseModel] = QueryDirectorySettingsInput def get_tool_message(self, **kwargs) -> Optional[str]: """根据查询参数生成友好的提示消息""" directory_type = kwargs.get("directory_type", "all") storage_type = kwargs.get("storage_type", "all") name = kwargs.get("name") parts = ["正在查询目录配置"] if directory_type != "all": type_map = {"download": "下载目录", "library": "媒体库目录"} parts.append(f"类型: {type_map.get(directory_type, directory_type)}") if storage_type != "all": storage_map = {"local": "本地存储", "remote": "远程存储"} parts.append(f"存储: {storage_map.get(storage_type, storage_type)}") if name: parts.append(f"名称: {name}") return " | ".join(parts) if len(parts) > 1 else parts[0] async def run(self, directory_type: Optional[str] = "all", storage_type: Optional[str] = "all", name: Optional[str] = None, **kwargs) -> str: logger.info(f"执行工具: {self.name}, 参数: directory_type={directory_type}, storage_type={storage_type}, name={name}") try: directory_helper = DirectoryHelper() # 根据目录类型获取目录列表 if directory_type == "download": dirs = directory_helper.get_download_dirs() elif directory_type == "library": dirs = directory_helper.get_library_dirs() else: dirs = directory_helper.get_dirs() # 按存储类型过滤 filtered_dirs = [] for d in dirs: # 按存储类型过滤 if storage_type == "local": # 对于下载目录,检查 storage;对于媒体库目录,检查 library_storage if directory_type == "download" and d.storage != "local": continue elif directory_type == "library" and d.library_storage != "local": continue elif directory_type == "all": # 检查是否有本地存储配置 if d.download_path and d.storage != "local": continue if d.library_path and d.library_storage != "local": continue elif storage_type == "remote": # 对于下载目录,检查 storage;对于媒体库目录,检查 library_storage if directory_type == "download" and d.storage == "local": continue elif directory_type == "library" and d.library_storage == "local": continue elif directory_type == "all": # 检查是否有远程存储配置 if d.download_path and d.storage == "local": continue if d.library_path and d.library_storage == "local": continue # 按名称过滤(部分匹配) if name and d.name and name.lower() not in d.name.lower(): continue filtered_dirs.append(d) if filtered_dirs: # 转换为字典格式,只保留关键信息 simplified_dirs = [] for d in filtered_dirs: simplified = { "name": d.name, "priority": d.priority, "storage": d.storage, "download_path": d.download_path, "library_path": d.library_path, "library_storage": d.library_storage, "media_type": d.media_type, "media_category": d.media_category, "monitor_type": d.monitor_type, "monitor_mode": d.monitor_mode, "transfer_type": d.transfer_type, "overwrite_mode": d.overwrite_mode, "renaming": d.renaming, "scraping": d.scraping, "notify": d.notify, "download_type_folder": d.download_type_folder, "download_category_folder": d.download_category_folder, "library_type_folder": d.library_type_folder, "library_category_folder": d.library_category_folder } simplified_dirs.append(simplified) result_json = json.dumps(simplified_dirs, ensure_ascii=False, indent=2) return result_json return "未找到相关目录配置" except Exception as e: logger.error(f"查询系统目录设置失败: {e}", exc_info=True) return f"查询系统目录设置时发生错误: {str(e)}" ================================================ FILE: app/agent/tools/impl/query_download_tasks.py ================================================ """查询下载工具""" import json from typing import Optional, Type, List, Union from pydantic import BaseModel, Field from app.agent.tools.base import MoviePilotTool from app.chain.download import DownloadChain from app.db.downloadhistory_oper import DownloadHistoryOper from app.log import logger from app.schemas import TransferTorrent, DownloadingTorrent from app.schemas.types import TorrentStatus, media_type_to_agent class QueryDownloadTasksInput(BaseModel): """查询下载工具的输入参数模型""" explanation: str = Field(..., description="Clear explanation of why this tool is being used in the current context") downloader: Optional[str] = Field(None, description="Name of specific downloader to query (optional, if not provided queries all configured downloaders)") status: Optional[str] = Field("all", description="Filter downloads by status: 'downloading' for active downloads, 'completed' for finished downloads, 'paused' for paused downloads, 'all' for all downloads") hash: Optional[str] = Field(None, description="Query specific download task by hash (optional, if provided will search for this specific task regardless of status)") title: Optional[str] = Field(None, description="Query download tasks by title/name (optional, supports partial match, searches all tasks if provided)") class QueryDownloadTasksTool(MoviePilotTool): name: str = "query_download_tasks" description: str = "Query download status and list download tasks. Can query all active downloads, or search for specific tasks by hash or title. Shows download progress, completion status, and task details from configured downloaders." args_schema: Type[BaseModel] = QueryDownloadTasksInput @staticmethod def _get_all_torrents(download_chain: DownloadChain, downloader: Optional[str] = None) -> List[Union[TransferTorrent, DownloadingTorrent]]: """ 查询所有状态的任务(包括下载中和已完成的任务) """ all_torrents = [] # 查询正在下载的任务 downloading_torrents = download_chain.list_torrents( downloader=downloader, status=TorrentStatus.DOWNLOADING ) or [] all_torrents.extend(downloading_torrents) # 查询已完成的任务(可转移状态) transfer_torrents = download_chain.list_torrents( downloader=downloader, status=TorrentStatus.TRANSFER ) or [] all_torrents.extend(transfer_torrents) return all_torrents @staticmethod def _format_progress(progress: Optional[float]) -> Optional[str]: """ 将下载进度格式化为保留一位小数的百分比字符串 """ try: if progress is None: return None return f"{float(progress):.1f}%" except (TypeError, ValueError): return None def get_tool_message(self, **kwargs) -> Optional[str]: """根据查询参数生成友好的提示消息""" downloader = kwargs.get("downloader") status = kwargs.get("status", "all") hash_value = kwargs.get("hash") title = kwargs.get("title") parts = ["正在查询下载任务"] if downloader: parts.append(f"下载器: {downloader}") if status != "all": status_map = {"downloading": "下载中", "completed": "已完成", "paused": "已暂停"} parts.append(f"状态: {status_map.get(status, status)}") if hash_value: parts.append(f"Hash: {hash_value[:8]}...") elif title: parts.append(f"标题: {title}") return " | ".join(parts) if len(parts) > 1 else parts[0] async def run(self, downloader: Optional[str] = None, status: Optional[str] = "all", hash: Optional[str] = None, title: Optional[str] = None, **kwargs) -> str: logger.info(f"执行工具: {self.name}, 参数: downloader={downloader}, status={status}, hash={hash}, title={title}") try: download_chain = DownloadChain() # 如果提供了hash,直接查询该hash的任务(不限制状态) if hash: torrents = download_chain.list_torrents(downloader=downloader, hashs=[hash]) or [] if not torrents: return f"未找到hash为 {hash} 的下载任务(该任务可能已完成、已删除或不存在)" # 转换为DownloadingTorrent格式 downloads = [] for torrent in torrents: # 获取下载历史信息 history = DownloadHistoryOper().get_by_hash(torrent.hash) if history: torrent.media = { "tmdbid": history.tmdbid, "type": history.type, "title": history.title, "season": history.seasons, "episode": history.episodes, "image": history.image, } torrent.userid = history.userid torrent.username = history.username downloads.append(torrent) filtered_downloads = downloads elif title: # 如果提供了title,查询所有任务并搜索匹配的标题 # 查询所有状态的任务 all_torrents = self._get_all_torrents(download_chain, downloader) filtered_downloads = [] title_lower = title.lower() for torrent in all_torrents: # 获取下载历史信息 history = DownloadHistoryOper().get_by_hash(torrent.hash) # 检查标题或名称是否匹配(包括下载历史中的标题) matched = False # 检查torrent的title和name字段 if (title_lower in (torrent.title or "").lower()) or \ (title_lower in (torrent.name or "").lower()): matched = True # 检查下载历史中的标题 if history and history.title: if title_lower in history.title.lower(): matched = True if matched: if history: torrent.media = { "tmdbid": history.tmdbid, "type": history.type, "title": history.title, "season": history.seasons, "episode": history.episodes, "image": history.image, } torrent.userid = history.userid torrent.username = history.username filtered_downloads.append(torrent) if not filtered_downloads: return f"未找到标题包含 '{title}' 的下载任务" else: # 根据status决定查询方式 if status == "downloading": # 如果status为下载中,使用downloading方法 downloads = download_chain.downloading(name=downloader) or [] filtered_downloads = [] for dl in downloads: if downloader and dl.downloader != downloader: continue filtered_downloads.append(dl) else: # 其他状态(completed、paused、all),使用list_torrents查询所有任务 # 查询所有状态的任务 all_torrents = self._get_all_torrents(download_chain, downloader) filtered_downloads = [] for torrent in all_torrents: if downloader and torrent.downloader != downloader: continue # 根据status过滤 if status == "completed": # 已完成的任务(state为seeding或completed) if torrent.state not in ["seeding", "completed"]: continue elif status == "paused": # 已暂停的任务 if torrent.state != "paused": continue # status == "all" 时不过滤 # 获取下载历史信息 history = DownloadHistoryOper().get_by_hash(torrent.hash) if history: torrent.media = { "tmdbid": history.tmdbid, "type": history.type, "title": history.title, "season": history.seasons, "episode": history.episodes, "image": history.image, } torrent.userid = history.userid torrent.username = history.username filtered_downloads.append(torrent) if filtered_downloads: # 限制最多20条结果 total_count = len(filtered_downloads) limited_downloads = filtered_downloads[:20] # 精简字段,只保留关键信息 simplified_downloads = [] for d in limited_downloads: simplified = { "downloader": d.downloader, "hash": d.hash, "title": d.title, "name": d.name, "year": d.year, "season_episode": d.season_episode, "size": d.size, "progress": self._format_progress(d.progress), "state": d.state, "upspeed": d.upspeed, "dlspeed": d.dlspeed, "left_time": d.left_time } # 精简 media 字段 if d.media: simplified["media"] = { "tmdbid": d.media.get("tmdbid"), "type": media_type_to_agent(d.media.get("type")), "title": d.media.get("title"), "season": d.media.get("season"), "episode": d.media.get("episode") } simplified_downloads.append(simplified) result_json = json.dumps(simplified_downloads, ensure_ascii=False, indent=2) # 如果结果被裁剪,添加提示信息 if total_count > 20: return f"注意:查询结果共找到 {total_count} 条,为节省上下文空间,仅显示前 20 条结果。\n\n{result_json}" # 如果查询的是特定hash或title,添加明确的状态信息 if hash: return f"找到hash为 {hash} 的下载任务:\n\n{result_json}" elif title: return f"找到 {total_count} 个标题包含 '{title}' 的下载任务:\n\n{result_json}" return result_json return "未找到相关下载任务" except Exception as e: logger.error(f"查询下载失败: {e}", exc_info=True) return f"查询下载时发生错误: {str(e)}" ================================================ FILE: app/agent/tools/impl/query_downloaders.py ================================================ """查询下载器工具""" import json from typing import Optional, Type from pydantic import BaseModel, Field from app.agent.tools.base import MoviePilotTool from app.db.systemconfig_oper import SystemConfigOper from app.log import logger from app.schemas.types import SystemConfigKey class QueryDownloadersInput(BaseModel): """查询下载器工具的输入参数模型""" explanation: str = Field(..., description="Clear explanation of why this tool is being used in the current context") class QueryDownloadersTool(MoviePilotTool): name: str = "query_downloaders" description: str = "Query downloader configuration and list all available downloaders. Shows downloader status, connection details, and configuration settings." args_schema: Type[BaseModel] = QueryDownloadersInput def get_tool_message(self, **kwargs) -> Optional[str]: """生成友好的提示消息""" return "正在查询下载器配置" async def run(self, **kwargs) -> str: logger.info(f"执行工具: {self.name}") try: system_config_oper = SystemConfigOper() downloaders_config = system_config_oper.get(SystemConfigKey.Downloaders) if downloaders_config: return json.dumps(downloaders_config, ensure_ascii=False, indent=2) return "未配置下载器。" except Exception as e: logger.error(f"查询下载器失败: {e}") return f"查询下载器时发生错误: {str(e)}" ================================================ FILE: app/agent/tools/impl/query_episode_schedule.py ================================================ """查询剧集上映时间工具""" import json from typing import Optional, Type from pydantic import BaseModel, Field from app.agent.tools.base import MoviePilotTool from app.chain.tmdb import TmdbChain from app.log import logger class QueryEpisodeScheduleInput(BaseModel): """查询剧集上映时间工具的输入参数模型""" explanation: str = Field(..., description="Clear explanation of why this tool is being used in the current context") tmdb_id: int = Field(..., description="TMDB ID of the TV series (can be obtained from search_media tool)") season: int = Field(..., description="Season number to query") episode_group: Optional[str] = Field(None, description="Episode group ID (optional)") class QueryEpisodeScheduleTool(MoviePilotTool): name: str = "query_episode_schedule" description: str = "Query TV series episode air dates and schedule. Returns non-duplicated schedule fields, including episode list, air-date statistics, and per-episode metadata. Filters out episodes without air dates." args_schema: Type[BaseModel] = QueryEpisodeScheduleInput def get_tool_message(self, **kwargs) -> Optional[str]: """根据查询参数生成友好的提示消息""" tmdb_id = kwargs.get("tmdb_id") season = kwargs.get("season") episode_group = kwargs.get("episode_group") message = f"正在查询剧集上映时间: TMDB ID {tmdb_id} 第{season}季" if episode_group: message += f" (剧集组: {episode_group})" return message async def run(self, tmdb_id: int, season: int, episode_group: Optional[str] = None, **kwargs) -> str: logger.info(f"执行工具: {self.name}, 参数: tmdb_id={tmdb_id}, season={season}, episode_group={episode_group}") try: # 获取集列表 tmdb_chain = TmdbChain() episodes = await tmdb_chain.async_tmdb_episodes( tmdbid=tmdb_id, season=season, episode_group=episode_group ) if not episodes: return json.dumps({ "success": False, "message": f"未找到 TMDB ID {tmdb_id} 第{season}季的集信息" }, ensure_ascii=False) # 过滤掉没有上映日期的集,并构建每集的详细信息 episode_list = [] for episode in episodes: air_date = episode.air_date # 过滤掉没有上映日期的数据 if not air_date: continue episode_info = { "episode_number": episode.episode_number, "name": episode.name, "air_date": air_date, "runtime": episode.runtime, "vote_average": episode.vote_average, "still_path": episode.still_path, "episode_type": episode.episode_type, "season_number": episode.season_number } episode_list.append(episode_info) if not episode_list: return json.dumps({ "success": False, "message": f"未找到 TMDB ID {tmdb_id} 第{season}季的播出时间信息(所有集都没有播出日期)" }, ensure_ascii=False) # 按播出日期排序 episode_list.sort(key=lambda x: (x["air_date"] or "", x["episode_number"] or 0)) result = { "season": season, "total_episodes": len(episodes), "episodes_with_air_date": len(episode_list), "episodes": episode_list } return json.dumps(result, ensure_ascii=False, indent=2) except Exception as e: error_message = f"查询剧集上映时间失败: {str(e)}" logger.error(f"查询剧集上映时间失败: {e}", exc_info=True) return json.dumps({ "success": False, "message": error_message, "tmdb_id": tmdb_id, "season": season }, ensure_ascii=False) ================================================ FILE: app/agent/tools/impl/query_library_exists.py ================================================ """查询媒体库工具""" import json from typing import Optional, Type from pydantic import BaseModel, Field from app.agent.tools.base import MoviePilotTool from app.chain.mediaserver import MediaServerChain from app.log import logger from app.schemas.types import MediaType, media_type_to_agent class QueryLibraryExistsInput(BaseModel): """查询媒体库工具的输入参数模型""" explanation: str = Field(..., description="Clear explanation of why this tool is being used in the current context") tmdb_id: Optional[int] = Field(None, description="TMDB ID (can be obtained from search_media tool). Either tmdb_id or douban_id must be provided.") douban_id: Optional[str] = Field(None, description="Douban ID (can be obtained from search_media tool). Either tmdb_id or douban_id must be provided.") media_type: Optional[str] = Field(None, description="Allowed values: movie, tv") class QueryLibraryExistsTool(MoviePilotTool): name: str = "query_library_exists" description: str = "Check whether a specific media resource already exists in the media library (Plex, Emby, Jellyfin) by media ID. Requires tmdb_id or douban_id (can be obtained from search_media tool) for accurate matching." args_schema: Type[BaseModel] = QueryLibraryExistsInput def get_tool_message(self, **kwargs) -> Optional[str]: """根据查询参数生成友好的提示消息""" tmdb_id = kwargs.get("tmdb_id") douban_id = kwargs.get("douban_id") media_type = kwargs.get("media_type") if tmdb_id: message = f"正在查询媒体库: TMDB={tmdb_id}" elif douban_id: message = f"正在查询媒体库: 豆瓣={douban_id}" else: message = "正在查询媒体库" if media_type: message += f" [{media_type}]" return message async def run(self, tmdb_id: Optional[int] = None, douban_id: Optional[str] = None, media_type: Optional[str] = None, **kwargs) -> str: logger.info(f"执行工具: {self.name}, 参数: tmdb_id={tmdb_id}, douban_id={douban_id}, media_type={media_type}") try: if not tmdb_id and not douban_id: return "参数错误:tmdb_id 和 douban_id 至少需要提供一个,请先使用 search_media 工具获取媒体 ID。" media_type_enum = None if media_type: media_type_enum = MediaType.from_agent(media_type) if not media_type_enum: return f"错误:无效的媒体类型 '{media_type}',支持的类型:'movie', 'tv'" media_chain = MediaServerChain() mediainfo = media_chain.recognize_media( tmdbid=tmdb_id, doubanid=douban_id, mtype=media_type_enum, ) if not mediainfo: media_id = f"TMDB={tmdb_id}" if tmdb_id else f"豆瓣={douban_id}" return f"未识别到媒体信息: {media_id}" # 2. 调用媒体服务器接口实时查询存在信息 existsinfo = media_chain.media_exists(mediainfo=mediainfo) if not existsinfo: return "媒体库中未找到相关媒体" # 3. 如果找到了,获取详细信息并组装结果 result_items = [] if existsinfo.itemid and existsinfo.server: iteminfo = media_chain.iteminfo(server=existsinfo.server, item_id=existsinfo.itemid) if iteminfo: # 使用 model_dump() 转换为字典格式 item_dict = iteminfo.model_dump(exclude_none=True) # 对于电视剧,补充已存在的季集详情及进度统计 if existsinfo.type == MediaType.TV: # 注入已存在集信息 (Dict[int, list]) item_dict["seasoninfo"] = existsinfo.seasons # 统计库中已存在的季集总数 if existsinfo.seasons: item_dict["existing_episodes_count"] = sum(len(e) for e in existsinfo.seasons.values()) item_dict["seasons_existing_count"] = {str(s): len(e) for s, e in existsinfo.seasons.items()} # 如果识别到了元数据,补充总计对比和进度概览 if mediainfo.seasons: item_dict["seasons_total_count"] = {str(s): len(e) for s, e in mediainfo.seasons.items()} # 进度概览,例如 "Season 1": "3/12" item_dict["seasons_progress"] = { f"第{s}季": f"{len(existsinfo.seasons.get(s, []))}/{len(mediainfo.seasons.get(s, []))} 集" for s in mediainfo.seasons.keys() if (s in existsinfo.seasons or s > 0) } result_items.append(item_dict) if result_items: return json.dumps(result_items, ensure_ascii=False) # 如果找到了但没有获取到 iteminfo,返回基本信息 result_dict = { "title": mediainfo.title, "year": mediainfo.year, "type": media_type_to_agent(existsinfo.type), "server": existsinfo.server, "server_type": existsinfo.server_type, "itemid": existsinfo.itemid, "seasons": existsinfo.seasons if existsinfo.seasons else {} } if existsinfo.type == MediaType.TV and existsinfo.seasons: result_dict["existing_episodes_count"] = sum(len(e) for e in existsinfo.seasons.values()) result_dict["seasons_existing_count"] = {str(s): len(e) for s, e in existsinfo.seasons.items()} if mediainfo.seasons: result_dict["seasons_total_count"] = {str(s): len(e) for s, e in mediainfo.seasons.items()} return json.dumps([result_dict], ensure_ascii=False) except Exception as e: logger.error(f"查询媒体库失败: {e}", exc_info=True) return f"查询媒体库时发生错误: {str(e)}" ================================================ FILE: app/agent/tools/impl/query_library_latest.py ================================================ """查询媒体服务器最近入库影片工具""" import json from typing import Optional, Type from pydantic import BaseModel, Field from app.agent.tools.base import MoviePilotTool from app.chain.mediaserver import MediaServerChain from app.helper.service import ServiceConfigHelper from app.log import logger class QueryLibraryLatestInput(BaseModel): """查询媒体服务器最近入库影片工具的输入参数模型""" explanation: str = Field(..., description="Clear explanation of why this tool is being used in the current context") server: Optional[str] = Field(None, description="Media server name (optional, if not specified queries all enabled media servers)") count: Optional[int] = Field(20, description="Number of items to return (default: 20)") class QueryLibraryLatestTool(MoviePilotTool): name: str = "query_library_latest" description: str = "Query the latest media items added to the media server (Plex, Emby, Jellyfin). Returns recently added movies and TV series with their titles, images, links, and other metadata." args_schema: Type[BaseModel] = QueryLibraryLatestInput def get_tool_message(self, **kwargs) -> Optional[str]: """根据查询参数生成友好的提示消息""" server = kwargs.get("server") count = kwargs.get("count", 20) parts = ["正在查询媒体服务器最近入库影片"] if server: parts.append(f"服务器: {server}") else: parts.append("所有服务器") parts.append(f"数量: {count}条") return " | ".join(parts) async def run(self, server: Optional[str] = None, count: Optional[int] = 20, **kwargs) -> str: logger.info(f"执行工具: {self.name}, 参数: server={server}, count={count}") try: media_chain = MediaServerChain() results = [] # 如果没有指定服务器,获取所有启用的媒体服务器 if not server: mediaservers = ServiceConfigHelper.get_mediaserver_configs() enabled_servers = [ms.name for ms in mediaservers if ms.enabled] if not enabled_servers: return "未找到启用的媒体服务器" # 遍历所有启用的服务器 for server_name in enabled_servers: latest_items = media_chain.latest(server=server_name, count=count, username=self._username) if latest_items: for item in latest_items: item_dict = item.model_dump(exclude_none=True) item_dict["server"] = server_name results.append(item_dict) else: # 查询指定服务器 latest_items = media_chain.latest(server=server, count=count, username=self._username) if latest_items: for item in latest_items: item_dict = item.model_dump(exclude_none=True) item_dict["server"] = server results.append(item_dict) if not results: server_info = f"服务器 {server}" if server else "所有服务器" return f"未找到 {server_info} 的最近入库影片" # 限制返回数量,避免结果过多 if len(results) > count: results = results[:count] return json.dumps(results, ensure_ascii=False, indent=2) except Exception as e: logger.error(f"查询媒体服务器最近入库影片失败: {e}", exc_info=True) return f"查询媒体服务器最近入库影片时发生错误: {str(e)}" ================================================ FILE: app/agent/tools/impl/query_media_detail.py ================================================ """查询媒体详情工具""" import json from typing import Optional, Type from pydantic import BaseModel, Field from app.agent.tools.base import MoviePilotTool from app.chain.media import MediaChain from app.log import logger from app.schemas.types import MediaType class QueryMediaDetailInput(BaseModel): """查询媒体详情工具的输入参数模型""" explanation: str = Field(..., description="Clear explanation of why this tool is being used in the current context") tmdb_id: Optional[int] = Field(None, description="TMDB ID of the media (movie or TV series, can be obtained from search_media tool)") douban_id: Optional[str] = Field(None, description="Douban ID of the media (alternative to tmdb_id)") media_type: str = Field(..., description="Allowed values: movie, tv") class QueryMediaDetailTool(MoviePilotTool): name: str = "query_media_detail" description: str = "Query supplementary media details from TMDB by ID and media_type. Accepts tmdb_id or douban_id (at least one required). media_type accepts 'movie' or 'tv'. Returns non-duplicated detail fields such as status, genres, directors, actors, and season info for TV series." args_schema: Type[BaseModel] = QueryMediaDetailInput def get_tool_message(self, **kwargs) -> Optional[str]: """根据查询参数生成友好的提示消息""" tmdb_id = kwargs.get("tmdb_id") douban_id = kwargs.get("douban_id") if tmdb_id: return f"正在查询媒体详情: TMDB ID {tmdb_id}" return f"正在查询媒体详情: 豆瓣 ID {douban_id}" async def run(self, media_type: str, tmdb_id: Optional[int] = None, douban_id: Optional[str] = None, **kwargs) -> str: logger.info(f"执行工具: {self.name}, 参数: tmdb_id={tmdb_id}, douban_id={douban_id}, media_type={media_type}") if tmdb_id is None and douban_id is None: return json.dumps({ "success": False, "message": "必须提供 tmdb_id 或 douban_id 之一" }, ensure_ascii=False) try: media_chain = MediaChain() media_type_enum = MediaType.from_agent(media_type) if not media_type_enum: return json.dumps({ "success": False, "message": f"无效的媒体类型 '{media_type}',支持的类型:'movie', 'tv'" }, ensure_ascii=False) mediainfo = await media_chain.async_recognize_media(tmdbid=tmdb_id, doubanid=douban_id, mtype=media_type_enum) if not mediainfo: id_info = f"TMDB ID {tmdb_id}" if tmdb_id else f"豆瓣 ID {douban_id}" return json.dumps({ "success": False, "message": f"未找到 {id_info} 的媒体信息" }, ensure_ascii=False) # 精简 genres - 只保留名称 genres = [g.get("name") for g in (mediainfo.genres or []) if g.get("name")] # 精简 directors - 只保留姓名和职位 directors = [ { "name": d.get("name"), "job": d.get("job") } for d in (mediainfo.directors or []) if d.get("name") ] # 精简 actors - 只保留姓名和角色 actors = [ { "name": a.get("name"), "character": a.get("character") } for a in (mediainfo.actors or []) if a.get("name") ] # 构建基础媒体详情信息 result = { "status": mediainfo.status, "genres": genres, "directors": directors, "actors": actors } # 如果是电视剧,添加电视剧特有信息 if mediainfo.type == MediaType.TV: # 精简 season_info - 只保留基础摘要 season_info = [ { "season_number": s.get("season_number"), "name": s.get("name"), "episode_count": s.get("episode_count"), "air_date": s.get("air_date") } for s in (mediainfo.season_info or []) if s.get("season_number") is not None ] result.update({ "number_of_seasons": mediainfo.number_of_seasons, "number_of_episodes": mediainfo.number_of_episodes, "first_air_date": mediainfo.first_air_date, "last_air_date": mediainfo.last_air_date, "season_info": season_info }) return json.dumps(result, ensure_ascii=False, indent=2) except Exception as e: error_message = f"查询媒体详情失败: {str(e)}" logger.error(f"查询媒体详情失败: {e}", exc_info=True) return json.dumps({ "success": False, "message": error_message, "tmdb_id": tmdb_id, "douban_id": douban_id }, ensure_ascii=False) ================================================ FILE: app/agent/tools/impl/query_popular_subscribes.py ================================================ """查询热门订阅工具""" import json from typing import Optional, Type import cn2an from pydantic import BaseModel, Field from app.agent.tools.base import MoviePilotTool from app.core.context import MediaInfo from app.helper.subscribe import SubscribeHelper from app.log import logger from app.schemas.types import MediaType, media_type_to_agent class QueryPopularSubscribesInput(BaseModel): """查询热门订阅工具的输入参数模型""" explanation: str = Field(..., description="Clear explanation of why this tool is being used in the current context") media_type: str = Field(..., description="Allowed values: movie, tv") page: Optional[int] = Field(1, description="Page number for pagination (default: 1)") count: Optional[int] = Field(30, description="Number of items per page (default: 30)") min_sub: Optional[int] = Field(None, description="Minimum number of subscribers filter (optional, e.g., 5)") genre_id: Optional[int] = Field(None, description="Filter by genre ID (optional)") min_rating: Optional[float] = Field(None, description="Minimum rating filter (optional, e.g., 7.5)") max_rating: Optional[float] = Field(None, description="Maximum rating filter (optional, e.g., 10.0)") sort_type: Optional[str] = Field(None, description="Sort type (optional, e.g., 'count', 'rating')") class QueryPopularSubscribesTool(MoviePilotTool): name: str = "query_popular_subscribes" description: str = "Query popular subscriptions based on user shared data. Shows media with the most subscribers, supports filtering by genre, rating, minimum subscribers, and pagination." args_schema: Type[BaseModel] = QueryPopularSubscribesInput def get_tool_message(self, **kwargs) -> Optional[str]: """根据查询参数生成友好的提示消息""" media_type = kwargs.get("media_type", "") page = kwargs.get("page", 1) min_sub = kwargs.get("min_sub") min_rating = kwargs.get("min_rating") max_rating = kwargs.get("max_rating") parts = [f"正在查询热门订阅 [{media_type}]"] if min_sub: parts.append(f"最少订阅: {min_sub}") if min_rating: parts.append(f"最低评分: {min_rating}") if max_rating: parts.append(f"最高评分: {max_rating}") if page > 1: parts.append(f"第{page}页") return " | ".join(parts) if len(parts) > 1 else parts[0] async def run(self, media_type: str, page: Optional[int] = 1, count: Optional[int] = 30, min_sub: Optional[int] = None, genre_id: Optional[int] = None, min_rating: Optional[float] = None, max_rating: Optional[float] = None, sort_type: Optional[str] = None, **kwargs) -> str: logger.info( f"执行工具: {self.name}, 参数: media_type={media_type}, page={page}, count={count}, min_sub={min_sub}, " f"genre_id={genre_id}, min_rating={min_rating}, max_rating={max_rating}, sort_type={sort_type}") try: if page is None or page < 1: page = 1 if count is None or count < 1: count = 30 media_type_enum = MediaType.from_agent(media_type) if not media_type_enum: return f"错误:无效的媒体类型 '{media_type}',支持的类型:'movie', 'tv'" subscribe_helper = SubscribeHelper() subscribes = await subscribe_helper.async_get_statistic( stype=media_type_enum.to_agent(), page=page, count=count, genre_id=genre_id, min_rating=min_rating, max_rating=max_rating, sort_type=sort_type ) if not subscribes: return "未找到热门订阅数据(可能订阅统计功能未启用)" # 转换为MediaInfo格式并过滤 ret_medias = [] for sub in subscribes: # 订阅人数 subscriber_count = sub.get("count", 0) # 如果设置了最小订阅人数,进行过滤 if min_sub and subscriber_count < min_sub: continue media = MediaInfo() raw_type = str(sub.get("type") or "").strip().lower() if raw_type in ["movie", "电影"]: media.type = MediaType.MOVIE elif raw_type in ["tv", "电视剧"]: media.type = MediaType.TV else: # 跳过无法识别类型的数据,避免单条脏数据导致整批失败 logger.warning(f"跳过未知媒体类型: {sub.get('type')}") continue media.tmdb_id = sub.get("tmdbid") # 处理标题 title = sub.get("name") season = sub.get("season") if season and int(season) > 1 and media.tmdb_id: # 小写数据转大写 season_str = cn2an.an2cn(season, "low") title = f"{title} 第{season_str}季" media.title = title media.year = sub.get("year") media.douban_id = sub.get("doubanid") media.bangumi_id = sub.get("bangumiid") media.tvdb_id = sub.get("tvdbid") media.imdb_id = sub.get("imdbid") media.season = sub.get("season") media.vote_average = sub.get("vote") media.poster_path = sub.get("poster") media.backdrop_path = sub.get("backdrop") media.popularity = subscriber_count ret_medias.append(media) if not ret_medias: return "未找到符合条件的热门订阅" # 转换为字典格式,只保留关键信息 simplified_medias = [] for media in ret_medias: media_dict = media.to_dict() simplified = { "type": media_type_to_agent(media_dict.get("type")), "title": media_dict.get("title"), "year": media_dict.get("year"), "tmdb_id": media_dict.get("tmdb_id"), "douban_id": media_dict.get("douban_id"), "bangumi_id": media_dict.get("bangumi_id"), "tvdb_id": media_dict.get("tvdb_id"), "imdb_id": media_dict.get("imdb_id"), "season": media_dict.get("season"), "vote_average": media_dict.get("vote_average"), "poster_path": media_dict.get("poster_path"), "backdrop_path": media_dict.get("backdrop_path"), "popularity": media_dict.get("popularity"), # 订阅人数 "subscriber_count": media_dict.get("popularity") # 明确标注为订阅人数 } simplified_medias.append(simplified) result_json = json.dumps(simplified_medias, ensure_ascii=False, indent=2) pagination_info = f"第 {page} 页,每页 {count} 条,共 {len(simplified_medias)} 条结果" return f"{pagination_info}\n\n{result_json}" except Exception as e: logger.error(f"查询热门订阅失败: {e}", exc_info=True) return f"查询热门订阅时发生错误: {str(e)}" ================================================ FILE: app/agent/tools/impl/query_rule_groups.py ================================================ """查询规则组工具""" import json from typing import Optional, Type from pydantic import BaseModel, Field from app.agent.tools.base import MoviePilotTool from app.helper.rule import RuleHelper from app.log import logger class QueryRuleGroupsInput(BaseModel): """查询规则组工具的输入参数模型""" explanation: str = Field(..., description="Clear explanation of why this tool is being used in the current context") class QueryRuleGroupsTool(MoviePilotTool): name: str = "query_rule_groups" description: str = "Query all filter rule groups available in the system. Rule groups are used to filter torrents when searching or subscribing. Returns rule group names, media types, and categories, but excludes rule_string to keep results concise." args_schema: Type[BaseModel] = QueryRuleGroupsInput def get_tool_message(self, **kwargs) -> Optional[str]: """根据查询参数生成友好的提示消息""" return "正在查询所有规则组" async def run(self, **kwargs) -> str: logger.info(f"执行工具: {self.name}") try: rule_helper = RuleHelper() rule_groups = rule_helper.get_rule_groups() if not rule_groups: return json.dumps({ "message": "未找到任何规则组", "rule_groups": [] }, ensure_ascii=False, indent=2) # 精简字段,过滤掉 rule_string 避免结果过大 simplified_groups = [] for group in rule_groups: simplified = { "name": group.name, "media_type": group.media_type, "category": group.category } simplified_groups.append(simplified) result = { "message": f"找到 {len(simplified_groups)} 个规则组", "rule_groups": simplified_groups } return json.dumps(result, ensure_ascii=False, indent=2) except Exception as e: error_message = f"查询规则组失败: {str(e)}" logger.error(f"查询规则组失败: {e}", exc_info=True) return json.dumps({ "success": False, "message": error_message, "rule_groups": [] }, ensure_ascii=False) ================================================ FILE: app/agent/tools/impl/query_schedulers.py ================================================ """查询定时服务工具""" import json from typing import Optional, Type from pydantic import BaseModel, Field from app.agent.tools.base import MoviePilotTool from app.log import logger from app.scheduler import Scheduler class QuerySchedulersInput(BaseModel): """查询定时服务工具的输入参数模型""" explanation: str = Field(..., description="Clear explanation of why this tool is being used in the current context") class QuerySchedulersTool(MoviePilotTool): name: str = "query_schedulers" description: str = "Query scheduled tasks and list all available scheduler jobs. Shows job status, next run time, and provider information." args_schema: Type[BaseModel] = QuerySchedulersInput def get_tool_message(self, **kwargs) -> Optional[str]: """生成友好的提示消息""" return "正在查询定时服务" async def run(self, **kwargs) -> str: logger.info(f"执行工具: {self.name}") try: scheduler = Scheduler() schedulers = scheduler.list() if schedulers: # 转换为字典列表以便JSON序列化 schedulers_list = [] for s in schedulers: schedulers_list.append({ "id": s.id, "name": s.name, "provider": s.provider, "status": s.status, "next_run": s.next_run }) result_json = json.dumps(schedulers_list, ensure_ascii=False, indent=2) # 限制最多30条结果 total_count = len(schedulers_list) if total_count > 30: limited_schedulers = schedulers_list[:30] limited_json = json.dumps(limited_schedulers, ensure_ascii=False, indent=2) return f"注意:查询结果共找到 {total_count} 条,为节省上下文空间,仅显示前 30 条结果。\n\n{limited_json}" return result_json return "未找到定时服务" except Exception as e: logger.error(f"查询定时服务失败: {e}", exc_info=True) return f"查询定时服务时发生错误: {str(e)}" ================================================ FILE: app/agent/tools/impl/query_site_userdata.py ================================================ """查询站点用户数据工具""" import json from typing import Optional, Type from pydantic import BaseModel, Field from app.agent.tools.base import MoviePilotTool from app.db import AsyncSessionFactory from app.db.models.site import Site from app.db.models.siteuserdata import SiteUserData from app.log import logger class QuerySiteUserdataInput(BaseModel): """查询站点用户数据工具的输入参数模型""" explanation: str = Field(..., description="Clear explanation of why this tool is being used in the current context") site_id: int = Field(..., description="The ID of the site to query user data for (can be obtained from query_sites tool)") workdate: Optional[str] = Field(None, description="Work date to query (optional, format: 'YYYY-MM-DD', if not specified returns latest data)") class QuerySiteUserdataTool(MoviePilotTool): name: str = "query_site_userdata" description: str = "Query user data for a specific site including username, user level, upload/download statistics, seeding information, bonus points, and other account details. Supports querying data for a specific date or latest data." args_schema: Type[BaseModel] = QuerySiteUserdataInput def get_tool_message(self, **kwargs) -> Optional[str]: """根据查询参数生成友好的提示消息""" site_id = kwargs.get("site_id") workdate = kwargs.get("workdate") message = f"正在查询站点 #{site_id} 的用户数据" if workdate: message += f" (日期: {workdate})" else: message += " (最新数据)" return message async def run(self, site_id: int, workdate: Optional[str] = None, **kwargs) -> str: logger.info(f"执行工具: {self.name}, 参数: site_id={site_id}, workdate={workdate}") try: # 获取数据库会话 async with AsyncSessionFactory() as db: # 获取站点 site = await Site.async_get(db, site_id) if not site: return json.dumps({ "success": False, "message": f"站点不存在: {site_id}" }, ensure_ascii=False) # 获取站点用户数据 user_data_list = await SiteUserData.async_get_by_domain( db, domain=site.domain, workdate=workdate ) if not user_data_list: return json.dumps({ "success": False, "message": f"站点 {site.name} ({site.domain}) 暂无用户数据", "site_id": site_id, "site_name": site.name, "site_domain": site.domain, "workdate": workdate }, ensure_ascii=False) # 格式化用户数据 result = { "success": True, "site_id": site_id, "site_name": site.name, "site_domain": site.domain, "workdate": workdate, "data_count": len(user_data_list), "user_data": [] } for user_data in user_data_list: # 格式化上传/下载量(转换为可读格式) upload_gb = user_data.upload / (1024 ** 3) if user_data.upload else 0 download_gb = user_data.download / (1024 ** 3) if user_data.download else 0 seeding_size_gb = user_data.seeding_size / (1024 ** 3) if user_data.seeding_size else 0 leeching_size_gb = user_data.leeching_size / (1024 ** 3) if user_data.leeching_size else 0 user_data_dict = { "domain": user_data.domain, "name": user_data.name, "username": user_data.username, "userid": user_data.userid, "user_level": user_data.user_level, "join_at": user_data.join_at, "bonus": user_data.bonus, "upload": user_data.upload, "upload_gb": round(upload_gb, 2), "download": user_data.download, "download_gb": round(download_gb, 2), "ratio": round(user_data.ratio, 2) if user_data.ratio else 0, "seeding": int(user_data.seeding) if user_data.seeding else 0, "leeching": int(user_data.leeching) if user_data.leeching else 0, "seeding_size": user_data.seeding_size, "seeding_size_gb": round(seeding_size_gb, 2), "leeching_size": user_data.leeching_size, "leeching_size_gb": round(leeching_size_gb, 2), "seeding_info": user_data.seeding_info if user_data.seeding_info else [], "message_unread": user_data.message_unread, "message_unread_contents": user_data.message_unread_contents if user_data.message_unread_contents else [], "err_msg": user_data.err_msg, "updated_day": user_data.updated_day, "updated_time": user_data.updated_time } result["user_data"].append(user_data_dict) # 如果有多条数据,只返回最新的(按更新时间排序) if len(result["user_data"]) > 1: result["user_data"].sort( key=lambda x: (x.get("updated_day", ""), x.get("updated_time", "")), reverse=True ) result["message"] = f"找到 {len(result['user_data'])} 条数据,显示最新的一条" result["user_data"] = [result["user_data"][0]] return json.dumps(result, ensure_ascii=False, indent=2) except Exception as e: error_message = f"查询站点用户数据失败: {str(e)}" logger.error(f"查询站点用户数据失败: {e}", exc_info=True) return json.dumps({ "success": False, "message": error_message, "site_id": site_id }, ensure_ascii=False) ================================================ FILE: app/agent/tools/impl/query_sites.py ================================================ """查询站点工具""" import json from typing import Optional, Type from pydantic import BaseModel, Field from app.agent.tools.base import MoviePilotTool from app.db.site_oper import SiteOper from app.log import logger class QuerySitesInput(BaseModel): """查询站点工具的输入参数模型""" explanation: str = Field(..., description="Clear explanation of why this tool is being used in the current context") status: Optional[str] = Field("all", description="Filter sites by status: 'active' for enabled sites, 'inactive' for disabled sites, 'all' for all sites") name: Optional[str] = Field(None, description="Filter sites by name (partial match, optional)") class QuerySitesTool(MoviePilotTool): name: str = "query_sites" description: str = "Query site status and list all configured sites. Shows site name, domain, status, priority, and basic configuration. Site priority (pri): smaller values have higher priority (e.g., pri=1 has higher priority than pri=10)." args_schema: Type[BaseModel] = QuerySitesInput def get_tool_message(self, **kwargs) -> Optional[str]: """根据查询参数生成友好的提示消息""" status = kwargs.get("status", "all") name = kwargs.get("name") parts = ["正在查询站点"] if status != "all": status_map = {"active": "已启用", "inactive": "已禁用"} parts.append(f"状态: {status_map.get(status, status)}") if name: parts.append(f"名称: {name}") return " | ".join(parts) if len(parts) > 1 else parts[0] async def run(self, status: Optional[str] = "all", name: Optional[str] = None, **kwargs) -> str: logger.info(f"执行工具: {self.name}, 参数: status={status}, name={name}") try: site_oper = SiteOper() # 获取所有站点(按优先级排序) sites = await site_oper.async_list() filtered_sites = [] for site in sites: # 按状态过滤 if status == "active" and not site.is_active: continue if status == "inactive" and site.is_active: continue # 按名称过滤(部分匹配) if name and name.lower() not in (site.name or "").lower(): continue filtered_sites.append(site) if filtered_sites: # 精简字段,只保留关键信息 simplified_sites = [] for s in filtered_sites: simplified = { "id": s.id, "name": s.name, "domain": s.domain, "url": s.url, "pri": s.pri, "is_active": s.is_active, "downloader": s.downloader, "proxy": s.proxy, "timeout": s.timeout } simplified_sites.append(simplified) result_json = json.dumps(simplified_sites, ensure_ascii=False, indent=2) return result_json return "未找到相关站点" except Exception as e: logger.error(f"查询站点失败: {e}", exc_info=True) return f"查询站点时发生错误: {str(e)}" ================================================ FILE: app/agent/tools/impl/query_subscribe_history.py ================================================ """查询订阅历史工具""" import json from typing import Optional, Type from pydantic import BaseModel, Field from app.agent.tools.base import MoviePilotTool from app.db import AsyncSessionFactory from app.db.models.subscribehistory import SubscribeHistory from app.log import logger from app.schemas.types import media_type_to_agent class QuerySubscribeHistoryInput(BaseModel): """查询订阅历史工具的输入参数模型""" explanation: str = Field(..., description="Clear explanation of why this tool is being used in the current context") media_type: Optional[str] = Field("all", description="Allowed values: movie, tv, all") name: Optional[str] = Field(None, description="Filter by media name (partial match, optional)") class QuerySubscribeHistoryTool(MoviePilotTool): name: str = "query_subscribe_history" description: str = "Query subscription history records. Shows completed subscriptions with their details including name, type, rating, completion date, and other subscription information. Supports filtering by media type and name. Returns up to 30 records." args_schema: Type[BaseModel] = QuerySubscribeHistoryInput def get_tool_message(self, **kwargs) -> Optional[str]: """根据查询参数生成友好的提示消息""" media_type = kwargs.get("media_type", "all") name = kwargs.get("name") parts = ["正在查询订阅历史"] if media_type != "all": parts.append(f"类型: {media_type}") if name: parts.append(f"名称: {name}") return " | ".join(parts) if len(parts) > 1 else parts[0] async def run(self, media_type: Optional[str] = "all", name: Optional[str] = None, **kwargs) -> str: logger.info(f"执行工具: {self.name}, 参数: media_type={media_type}, name={name}") try: if media_type not in ["all", "movie", "tv"]: return f"错误:无效的媒体类型 '{media_type}',支持的类型:'movie', 'tv', 'all'" # 获取数据库会话 async with AsyncSessionFactory() as db: # 根据类型查询 if media_type == "all": # 查询所有类型,需要分别查询电影和电视剧 movie_history = await SubscribeHistory.async_list_by_type(db, mtype="movie", page=1, count=100) tv_history = await SubscribeHistory.async_list_by_type(db, mtype="tv", page=1, count=100) all_history = list(movie_history) + list(tv_history) # 按日期排序 all_history.sort(key=lambda x: x.date or "", reverse=True) else: # 查询指定类型 all_history = await SubscribeHistory.async_list_by_type(db, mtype=media_type, page=1, count=100) # 按名称过滤 filtered_history = [] if name: name_lower = name.lower() for record in all_history: if record.name and name_lower in record.name.lower(): filtered_history.append(record) else: filtered_history = all_history if not filtered_history: return "未找到相关订阅历史记录" # 限制最多30条 total_count = len(filtered_history) limited_history = filtered_history[:30] # 转换为字典格式,只保留关键信息 simplified_records = [] for record in limited_history: simplified = { "id": record.id, "name": record.name, "year": record.year, "type": media_type_to_agent(record.type), "season": record.season, "tmdbid": record.tmdbid, "doubanid": record.doubanid, "bangumiid": record.bangumiid, "poster": record.poster, "vote": record.vote, "total_episode": record.total_episode, "date": record.date, "username": record.username } # 添加过滤规则信息(如果有) if record.filter: simplified["filter"] = record.filter if record.quality: simplified["quality"] = record.quality if record.resolution: simplified["resolution"] = record.resolution simplified_records.append(simplified) result_json = json.dumps(simplified_records, ensure_ascii=False, indent=2) # 如果结果被裁剪,添加提示信息 if total_count > 30: return f"注意:查询结果共找到 {total_count} 条,为节省上下文空间,仅显示前 30 条结果。\n\n{result_json}" return result_json except Exception as e: logger.error(f"查询订阅历史失败: {e}", exc_info=True) return f"查询订阅历史时发生错误: {str(e)}" ================================================ FILE: app/agent/tools/impl/query_subscribe_shares.py ================================================ """查询订阅分享工具""" import json from typing import Optional, Type from pydantic import BaseModel, Field from app.agent.tools.base import MoviePilotTool from app.helper.subscribe import SubscribeHelper from app.log import logger class QuerySubscribeSharesInput(BaseModel): """查询订阅分享工具的输入参数模型""" explanation: str = Field(..., description="Clear explanation of why this tool is being used in the current context") name: Optional[str] = Field(None, description="Filter shares by media name (partial match, optional)") page: Optional[int] = Field(1, description="Page number for pagination (default: 1)") count: Optional[int] = Field(30, description="Number of items per page (default: 30)") genre_id: Optional[int] = Field(None, description="Filter by genre ID (optional)") min_rating: Optional[float] = Field(None, description="Minimum rating filter (optional, e.g., 7.5)") max_rating: Optional[float] = Field(None, description="Maximum rating filter (optional, e.g., 10.0)") sort_type: Optional[str] = Field(None, description="Sort type (optional, e.g., 'count', 'rating')") class QuerySubscribeSharesTool(MoviePilotTool): name: str = "query_subscribe_shares" description: str = "Query shared subscriptions from other users. Shows popular subscriptions shared by the community with filtering and pagination support." args_schema: Type[BaseModel] = QuerySubscribeSharesInput def get_tool_message(self, **kwargs) -> Optional[str]: """根据查询参数生成友好的提示消息""" name = kwargs.get("name") page = kwargs.get("page", 1) min_rating = kwargs.get("min_rating") max_rating = kwargs.get("max_rating") parts = ["正在查询订阅分享"] if name: parts.append(f"名称: {name}") if min_rating: parts.append(f"最低评分: {min_rating}") if max_rating: parts.append(f"最高评分: {max_rating}") if page > 1: parts.append(f"第{page}页") return " | ".join(parts) if len(parts) > 1 else parts[0] async def run(self, name: Optional[str] = None, page: Optional[int] = 1, count: Optional[int] = 30, genre_id: Optional[int] = None, min_rating: Optional[float] = None, max_rating: Optional[float] = None, sort_type: Optional[str] = None, **kwargs) -> str: logger.info( f"执行工具: {self.name}, 参数: name={name}, page={page}, count={count}, genre_id={genre_id}, " f"min_rating={min_rating}, max_rating={max_rating}, sort_type={sort_type}") try: if page is None or page < 1: page = 1 if count is None or count < 1: count = 30 subscribe_helper = SubscribeHelper() shares = await subscribe_helper.async_get_shares( name=name, page=page, count=count, genre_id=genre_id, min_rating=min_rating, max_rating=max_rating, sort_type=sort_type ) if not shares: return "未找到订阅分享数据(可能订阅分享功能未启用)" # 简化字段,只保留关键信息 simplified_shares = [] for share in shares: simplified = { "id": share.get("id"), "name": share.get("name"), "year": share.get("year"), "type": share.get("type"), "season": share.get("season"), "tmdbid": share.get("tmdbid"), "doubanid": share.get("doubanid"), "bangumiid": share.get("bangumiid"), "poster": share.get("poster"), "vote": share.get("vote"), "share_title": share.get("share_title"), "share_comment": share.get("share_comment"), "share_user": share.get("share_user"), "fork_count": share.get("fork_count", 0) } # 截断过长的描述 if simplified.get("description") and len(simplified["description"]) > 200: simplified["description"] = simplified["description"][:200] + "..." simplified_shares.append(simplified) result_json = json.dumps(simplified_shares, ensure_ascii=False, indent=2) pagination_info = f"第 {page} 页,每页 {count} 条,共 {len(simplified_shares)} 条结果" return f"{pagination_info}\n\n{result_json}" except Exception as e: logger.error(f"查询订阅分享失败: {e}", exc_info=True) return f"查询订阅分享时发生错误: {str(e)}" ================================================ FILE: app/agent/tools/impl/query_subscribes.py ================================================ """查询订阅工具""" import json from typing import Optional, Type from pydantic import BaseModel, Field from app.agent.tools.base import MoviePilotTool from app.db.subscribe_oper import SubscribeOper from app.log import logger from app.schemas.types import MediaType, media_type_to_agent class QuerySubscribesInput(BaseModel): """查询订阅工具的输入参数模型""" explanation: str = Field(..., description="Clear explanation of why this tool is being used in the current context") status: Optional[str] = Field("all", description="Filter subscriptions by status: 'R' for enabled subscriptions, 'S' for paused ones, 'all' for all subscriptions") media_type: Optional[str] = Field("all", description="Allowed values: movie, tv, all") tmdb_id: Optional[int] = Field(None, description="Filter by TMDB ID to check if a specific media is already subscribed") douban_id: Optional[str] = Field(None, description="Filter by Douban ID to check if a specific media is already subscribed") class QuerySubscribesTool(MoviePilotTool): name: str = "query_subscribes" description: str = "Query subscription status and list all user subscriptions. Shows active subscriptions, their download status, and configuration details." args_schema: Type[BaseModel] = QuerySubscribesInput def get_tool_message(self, **kwargs) -> Optional[str]: """根据查询参数生成友好的提示消息""" status = kwargs.get("status", "all") media_type = kwargs.get("media_type", "all") parts = ["正在查询订阅"] # 根据状态过滤条件生成提示 if status != "all": status_map = {"R": "已启用", "S": "已暂停"} parts.append(f"状态: {status_map.get(status, status)}") # 根据媒体类型过滤条件生成提示 if media_type != "all": parts.append(f"类型: {media_type}") return " | ".join(parts) if len(parts) > 1 else parts[0] async def run(self, status: Optional[str] = "all", media_type: Optional[str] = "all", tmdb_id: Optional[int] = None, douban_id: Optional[str] = None, **kwargs) -> str: logger.info(f"执行工具: {self.name}, 参数: status={status}, media_type={media_type}, tmdb_id={tmdb_id}, douban_id={douban_id}") try: if media_type != "all" and not MediaType.from_agent(media_type): return f"错误:无效的媒体类型 '{media_type}',支持的类型:'movie', 'tv', 'all'" subscribe_oper = SubscribeOper() subscribes = await subscribe_oper.async_list() filtered_subscribes = [] for sub in subscribes: if status != "all" and sub.state != status: continue if media_type != "all" and sub.type != MediaType.from_agent(media_type).value: continue if tmdb_id is not None and sub.tmdbid != tmdb_id: continue if douban_id is not None and sub.doubanid != douban_id: continue filtered_subscribes.append(sub) if filtered_subscribes: # 限制最多50条结果 total_count = len(filtered_subscribes) limited_subscribes = filtered_subscribes[:50] # 精简字段,只保留关键信息 simplified_subscribes = [] for s in limited_subscribes: simplified = { "id": s.id, "name": s.name, "year": s.year, "type": media_type_to_agent(s.type), "season": s.season, "tmdbid": s.tmdbid, "doubanid": s.doubanid, "bangumiid": s.bangumiid, "poster": s.poster, "vote": s.vote, "state": s.state, "total_episode": s.total_episode, "lack_episode": s.lack_episode, "last_update": s.last_update, "username": s.username } simplified_subscribes.append(simplified) result_json = json.dumps(simplified_subscribes, ensure_ascii=False, indent=2) # 如果结果被裁剪,添加提示信息 if total_count > 50: return f"注意:查询结果共找到 {total_count} 条,为节省上下文空间,仅显示前 50 条结果。\n\n{result_json}" return result_json return "未找到相关订阅" except Exception as e: logger.error(f"查询订阅失败: {e}", exc_info=True) return f"查询订阅时发生错误: {str(e)}" ================================================ FILE: app/agent/tools/impl/query_transfer_history.py ================================================ """查询整理历史记录工具""" import json from typing import Optional, Type import jieba from pydantic import BaseModel, Field from app.agent.tools.base import MoviePilotTool from app.db import AsyncSessionFactory from app.db.models.transferhistory import TransferHistory from app.log import logger from app.schemas.types import media_type_to_agent class QueryTransferHistoryInput(BaseModel): """查询整理历史记录工具的输入参数模型""" explanation: str = Field(..., description="Clear explanation of why this tool is being used in the current context") title: Optional[str] = Field(None, description="Search by title (optional, supports partial match)") status: Optional[str] = Field("all", description="Filter by status: 'success' for successful transfers, 'failed' for failed transfers, 'all' for all records (default: 'all')") page: Optional[int] = Field(1, description="Page number for pagination (default: 1, each page contains 30 records)") class QueryTransferHistoryTool(MoviePilotTool): name: str = "query_transfer_history" description: str = "Query file transfer history records. Shows transfer status, source and destination paths, media information, and transfer details. Supports filtering by title and status." args_schema: Type[BaseModel] = QueryTransferHistoryInput def get_tool_message(self, **kwargs) -> Optional[str]: """根据查询参数生成友好的提示消息""" title = kwargs.get("title") status = kwargs.get("status", "all") page = kwargs.get("page", 1) parts = ["正在查询整理历史"] if title: parts.append(f"标题: {title}") if status != "all": status_map = {"success": "成功", "failed": "失败"} parts.append(f"状态: {status_map.get(status, status)}") if page > 1: parts.append(f"第{page}页") return " | ".join(parts) if len(parts) > 1 else parts[0] async def run(self, title: Optional[str] = None, status: Optional[str] = "all", page: Optional[int] = 1, **kwargs) -> str: logger.info(f"执行工具: {self.name}, 参数: title={title}, status={status}, page={page}") try: # 处理状态参数 status_bool = None if status == "success": status_bool = True elif status == "failed": status_bool = False # 处理页码参数 if page is None or page < 1: page = 1 # 每页记录数 count = 50 # 获取数据库会话 async with AsyncSessionFactory() as db: # 处理标题搜索 if title: # 使用 jieba 分词处理标题 words = jieba.cut(title, HMM=False) title_search = "%".join(words) # 查询记录 result = await TransferHistory.async_list_by_title( db, title=title_search, page=page, count=count, status=status_bool ) total = await TransferHistory.async_count_by_title( db, title=title_search, status=status_bool ) else: # 查询所有记录 result = await TransferHistory.async_list_by_page( db, page=page, count=count, status=status_bool ) total = await TransferHistory.async_count(db, status=status_bool) if not result: return "未找到相关整理历史记录" # 转换为字典格式,只保留关键信息 simplified_records = [] for record in result: simplified = { "id": record.id, "title": record.title, "year": record.year, "type": media_type_to_agent(record.type), "category": record.category, "seasons": record.seasons, "episodes": record.episodes, "src": record.src, "dest": record.dest, "mode": record.mode, "status": "成功" if record.status else "失败", "date": record.date, "downloader": record.downloader, "download_hash": record.download_hash } # 如果失败,添加错误信息 if not record.status and record.errmsg: simplified["errmsg"] = record.errmsg # 添加媒体ID信息(如果有) if record.tmdbid: simplified["tmdbid"] = record.tmdbid if record.imdbid: simplified["imdbid"] = record.imdbid if record.doubanid: simplified["doubanid"] = record.doubanid simplified_records.append(simplified) result_json = json.dumps(simplified_records, ensure_ascii=False, indent=2) # 计算总页数 total_pages = (total + count - 1) // count if total > 0 else 1 # 构建分页信息 pagination_info = f"第 {page}/{total_pages} 页,共 {total} 条记录(每页 {count} 条)" return f"{pagination_info}\n\n{result_json}" except Exception as e: logger.error(f"查询整理历史记录失败: {e}", exc_info=True) return f"查询整理历史记录时发生错误: {str(e)}" ================================================ FILE: app/agent/tools/impl/query_workflows.py ================================================ """查询工作流工具""" import json from typing import Optional, Type from pydantic import BaseModel, Field from app.agent.tools.base import MoviePilotTool from app.db import AsyncSessionFactory from app.db.workflow_oper import WorkflowOper from app.log import logger class QueryWorkflowsInput(BaseModel): """查询工作流工具的输入参数模型""" explanation: str = Field(..., description="Clear explanation of why this tool is being used in the current context") state: Optional[str] = Field("all", description="Filter workflows by state: 'W' for waiting, 'R' for running, 'P' for paused, 'S' for success, 'F' for failed, 'all' for all workflows (default: 'all')") name: Optional[str] = Field(None, description="Filter workflows by name (partial match, optional)") trigger_type: Optional[str] = Field("all", description="Filter workflows by trigger type: 'timer' for scheduled, 'event' for event-triggered, 'manual' for manual, 'all' for all types (default: 'all')") class QueryWorkflowsTool(MoviePilotTool): name: str = "query_workflows" description: str = "Query workflow list and status. Shows workflow name, description, trigger type, state, execution count, and other workflow details. Supports filtering by state, name, and trigger type." args_schema: Type[BaseModel] = QueryWorkflowsInput def get_tool_message(self, **kwargs) -> Optional[str]: """根据查询参数生成友好的提示消息""" state = kwargs.get("state", "all") name = kwargs.get("name") trigger_type = kwargs.get("trigger_type", "all") parts = ["正在查询工作流"] if state != "all": state_map = {"W": "等待", "R": "运行中", "P": "暂停", "S": "成功", "F": "失败"} parts.append(f"状态: {state_map.get(state, state)}") if trigger_type != "all": trigger_map = {"timer": "定时触发", "event": "事件触发", "manual": "手动触发"} parts.append(f"触发类型: {trigger_map.get(trigger_type, trigger_type)}") if name: parts.append(f"名称: {name}") return " | ".join(parts) if len(parts) > 1 else parts[0] async def run(self, state: Optional[str] = "all", name: Optional[str] = None, trigger_type: Optional[str] = "all", **kwargs) -> str: logger.info(f"执行工具: {self.name}, 参数: state={state}, name={name}, trigger_type={trigger_type}") try: # 获取数据库会话 async with AsyncSessionFactory() as db: workflow_oper = WorkflowOper(db) workflows = await workflow_oper.async_list() # 过滤工作流 filtered_workflows = [] for wf in workflows: # 按状态过滤 if state != "all" and wf.state != state: continue # 按触发类型过滤 if trigger_type != "all": if trigger_type == "timer" and wf.trigger_type not in ["timer", None]: continue elif trigger_type == "event" and wf.trigger_type != "event": continue elif trigger_type == "manual" and wf.trigger_type != "manual": continue # 按名称过滤(部分匹配) if name and wf.name and name.lower() not in wf.name.lower(): continue filtered_workflows.append(wf) if not filtered_workflows: return "未找到相关工作流" # 转换为字典格式,只保留关键信息 simplified_workflows = [] for wf in filtered_workflows: # 状态说明 state_map = { "W": "等待", "R": "运行中", "P": "暂停", "S": "成功", "F": "失败" } state_desc = state_map.get(wf.state, wf.state) # 触发类型说明 trigger_type_map = { "timer": "定时触发", "event": "事件触发", "manual": "手动触发" } trigger_type_desc = trigger_type_map.get(wf.trigger_type, wf.trigger_type or "定时触发") simplified = { "id": wf.id, "name": wf.name, "description": wf.description, "trigger_type": trigger_type_desc, "state": state_desc, "run_count": wf.run_count, "timer": wf.timer, "event_type": wf.event_type, "add_time": wf.add_time, "last_time": wf.last_time, "current_action": wf.current_action } # 如果有结果,添加结果信息 if wf.result: simplified["result"] = wf.result simplified_workflows.append(simplified) result_json = json.dumps(simplified_workflows, ensure_ascii=False, indent=2) return result_json except Exception as e: logger.error(f"查询工作流失败: {e}", exc_info=True) return f"查询工作流时发生错误: {str(e)}" ================================================ FILE: app/agent/tools/impl/recognize_media.py ================================================ """识别媒体信息工具""" import json from typing import Optional, Type from pydantic import BaseModel, Field from app.agent.tools.base import MoviePilotTool from app.chain.media import MediaChain from app.core.context import Context from app.core.metainfo import MetaInfo from app.log import logger from app.schemas.types import media_type_to_agent class RecognizeMediaInput(BaseModel): """识别媒体信息工具的输入参数模型""" explanation: str = Field(..., description="Clear explanation of why this tool is being used in the current context") title: Optional[str] = Field(None, description="The title of the torrent/media to recognize (required for torrent recognition)") subtitle: Optional[str] = Field(None, description="The subtitle or description of the torrent (optional, helps improve recognition accuracy)") path: Optional[str] = Field(None, description="The file path to recognize (required for file recognition, mutually exclusive with title)") class RecognizeMediaTool(MoviePilotTool): name: str = "recognize_media" description: str = "Extract/identify media information from torrent titles or file paths (NOT database search). Supports two modes: 1) Extract from torrent title and optional subtitle, 2) Extract from file path. Returns detailed media information. Use 'search_media' to search TMDB database, or 'scrape_metadata' to generate metadata files for existing files." args_schema: Type[BaseModel] = RecognizeMediaInput def get_tool_message(self, **kwargs) -> Optional[str]: """根据识别参数生成友好的提示消息""" title = kwargs.get("title") subtitle = kwargs.get("subtitle") path = kwargs.get("path") if path: message = f"正在识别文件媒体信息: {path}" elif title: message = f"正在识别种子媒体信息: {title}" if subtitle: message += f" ({subtitle})" else: message = "正在识别媒体信息" return message async def run(self, title: Optional[str] = None, subtitle: Optional[str] = None, path: Optional[str] = None, **kwargs) -> str: logger.info(f"执行工具: {self.name}, 参数: title={title}, subtitle={subtitle}, path={path}") try: media_chain = MediaChain() context = None # 根据提供的参数选择识别方式 if path: # 文件路径识别 if not path: return json.dumps({ "success": False, "message": "文件路径不能为空" }, ensure_ascii=False) context = await media_chain.async_recognize_by_path(path) if context: return self._format_context_result(context, "文件") else: return json.dumps({ "success": False, "message": f"无法识别文件媒体信息: {path}", "path": path }, ensure_ascii=False) elif title: # 种子标题识别 metainfo = MetaInfo(title, subtitle) mediainfo = await media_chain.async_recognize_by_meta(metainfo) if mediainfo: context = Context(meta_info=metainfo, media_info=mediainfo) return self._format_context_result(context, "种子") else: return json.dumps({ "success": False, "message": f"无法识别种子媒体信息: {title}", "title": title, "subtitle": subtitle }, ensure_ascii=False) else: return json.dumps({ "success": False, "message": "必须提供 title(标题)或 path(文件路径)参数之一" }, ensure_ascii=False) except Exception as e: error_message = f"识别媒体信息失败: {str(e)}" logger.error(f"识别媒体信息失败: {e}", exc_info=True) return json.dumps({ "success": False, "message": error_message }, ensure_ascii=False) def _format_context_result(self, context: Context, source_type: str) -> str: """格式化识别结果为JSON字符串""" if not context: return json.dumps({ "success": False, "message": "识别结果为空" }, ensure_ascii=False) context_dict = context.to_dict() media_info = context_dict.get("media_info") meta_info = context_dict.get("meta_info") # 构建简化的结果 result = { "success": True, "source_type": source_type, "media_info": None, "meta_info": None } # 处理媒体信息 if media_info: result["media_info"] = { "title": media_info.get("title"), "en_title": media_info.get("en_title"), "year": media_info.get("year"), "type": media_type_to_agent(media_info.get("type")), "season": media_info.get("season"), "tmdb_id": media_info.get("tmdb_id"), "imdb_id": media_info.get("imdb_id"), "douban_id": media_info.get("douban_id"), "bangumi_id": media_info.get("bangumi_id"), "overview": media_info.get("overview"), "vote_average": media_info.get("vote_average"), "poster_path": media_info.get("poster_path"), "backdrop_path": media_info.get("backdrop_path"), "detail_link": media_info.get("detail_link"), "title_year": media_info.get("title_year"), "source": media_info.get("source") } # 处理元数据信息 if meta_info: result["meta_info"] = { "name": meta_info.get("name"), "title": meta_info.get("title"), "year": meta_info.get("year"), "type": media_type_to_agent(meta_info.get("type")), "begin_season": meta_info.get("begin_season"), "end_season": meta_info.get("end_season"), "begin_episode": meta_info.get("begin_episode"), "end_episode": meta_info.get("end_episode"), "total_episode": meta_info.get("total_episode"), "part": meta_info.get("part"), "season_episode": meta_info.get("season_episode"), "episode_list": meta_info.get("episode_list"), "tmdbid": meta_info.get("tmdbid"), "doubanid": meta_info.get("doubanid") } return json.dumps(result, ensure_ascii=False, indent=2) ================================================ FILE: app/agent/tools/impl/run_scheduler.py ================================================ """运行定时服务工具""" from typing import Optional, Type from pydantic import BaseModel, Field from app.agent.tools.base import MoviePilotTool from app.log import logger from app.scheduler import Scheduler class RunSchedulerInput(BaseModel): """运行定时服务工具的输入参数模型""" explanation: str = Field(..., description="Clear explanation of why this tool is being used in the current context") job_id: str = Field(..., description="The ID of the scheduled job to run (can be obtained from query_schedulers tool)") class RunSchedulerTool(MoviePilotTool): name: str = "run_scheduler" description: str = "Manually trigger a scheduled task to run immediately. This will execute the specified scheduler job by its ID." args_schema: Type[BaseModel] = RunSchedulerInput def get_tool_message(self, **kwargs) -> Optional[str]: """根据运行参数生成友好的提示消息""" job_id = kwargs.get("job_id", "") return f"正在运行定时服务 (ID: {job_id})" async def run(self, job_id: str, **kwargs) -> str: logger.info(f"执行工具: {self.name}, 参数: job_id={job_id}") try: scheduler = Scheduler() # 检查定时服务是否存在 schedulers = scheduler.list() job_exists = False job_name = None for s in schedulers: if s.id == job_id: job_exists = True job_name = s.name break if not job_exists: return f"定时服务 ID {job_id} 不存在,请使用 query_schedulers 工具查询可用的定时服务" # 运行定时服务 scheduler.start(job_id) return f"成功触发定时服务:{job_name} (ID: {job_id})" except Exception as e: logger.error(f"运行定时服务失败: {e}", exc_info=True) return f"运行定时服务时发生错误: {str(e)}" ================================================ FILE: app/agent/tools/impl/run_workflow.py ================================================ """执行工作流工具""" from typing import Optional, Type from pydantic import BaseModel, Field from app.agent.tools.base import MoviePilotTool from app.chain.workflow import WorkflowChain from app.db import AsyncSessionFactory from app.db.workflow_oper import WorkflowOper from app.log import logger class RunWorkflowInput(BaseModel): """执行工作流工具的输入参数模型""" explanation: str = Field(..., description="Clear explanation of why this tool is being used in the current context") workflow_id: int = Field(..., description="Workflow ID (can be obtained from query_workflows tool)") from_begin: Optional[bool] = Field(True, description="Whether to run workflow from the beginning (default: True, if False will continue from last executed action)") class RunWorkflowTool(MoviePilotTool): name: str = "run_workflow" description: str = "Execute a specific workflow manually by workflow ID. Supports running from the beginning or continuing from the last executed action." args_schema: Type[BaseModel] = RunWorkflowInput def get_tool_message(self, **kwargs) -> Optional[str]: """根据工作流参数生成友好的提示消息""" workflow_id = kwargs.get("workflow_id") from_begin = kwargs.get("from_begin", True) message = f"正在执行工作流: {workflow_id}" if not from_begin: message += " (从上次位置继续)" else: message += " (从头开始)" return message async def run(self, workflow_id: int, from_begin: Optional[bool] = True, **kwargs) -> str: logger.info(f"执行工具: {self.name}, 参数: workflow_id={workflow_id}, from_begin={from_begin}") try: # 获取数据库会话 async with AsyncSessionFactory() as db: workflow_oper = WorkflowOper(db) workflow = await workflow_oper.async_get(workflow_id) if not workflow: return f"未找到工作流:{workflow_id},请使用 query_workflows 工具查询可用的工作流" # 执行工作流 workflow_chain = WorkflowChain() state, errmsg = workflow_chain.process(workflow.id, from_begin=from_begin) if not state: return f"执行工作流失败:{workflow.name} (ID: {workflow.id})\n错误原因:{errmsg}" else: return f"工作流执行成功:{workflow.name} (ID: {workflow.id})" except Exception as e: logger.error(f"执行工作流失败: {e}", exc_info=True) return f"执行工作流时发生错误: {str(e)}" ================================================ FILE: app/agent/tools/impl/scrape_metadata.py ================================================ """刮削媒体元数据工具""" import json from pathlib import Path from typing import Optional, Type from pydantic import BaseModel, Field from app.agent.tools.base import MoviePilotTool from app.chain.media import MediaChain from app.core.config import global_vars from app.core.metainfo import MetaInfoPath from app.log import logger from app.schemas import FileItem class ScrapeMetadataInput(BaseModel): """刮削媒体元数据工具的输入参数模型""" explanation: str = Field(..., description="Clear explanation of why this tool is being used in the current context") path: str = Field(..., description="Path to the file or directory to scrape metadata for (e.g., '/path/to/file.mkv' or '/path/to/directory')") storage: Optional[str] = Field("local", description="Storage type: 'local' for local storage, 'smb', 'alist', etc. for remote storage (default: 'local')") overwrite: Optional[bool] = Field(False, description="Whether to overwrite existing metadata files (default: False)") class ScrapeMetadataTool(MoviePilotTool): name: str = "scrape_metadata" description: str = "Generate metadata files (NFO files, posters, backgrounds, etc.) for existing media files or directories. Automatically recognizes media information from the file path and creates metadata files. Supports both local and remote storage. Use 'search_media' to search TMDB database, or 'recognize_media' to extract info from torrent titles/file paths without generating files." args_schema: Type[BaseModel] = ScrapeMetadataInput def get_tool_message(self, **kwargs) -> Optional[str]: """根据刮削参数生成友好的提示消息""" path = kwargs.get("path", "") storage = kwargs.get("storage", "local") overwrite = kwargs.get("overwrite", False) message = f"正在刮削媒体元数据: {path}" if storage != "local": message += f" [存储: {storage}]" if overwrite: message += " [覆盖模式]" return message async def run(self, path: str, storage: Optional[str] = "local", overwrite: Optional[bool] = False, **kwargs) -> str: logger.info(f"执行工具: {self.name}, 参数: path={path}, storage={storage}, overwrite={overwrite}") try: # 验证路径 if not path: return json.dumps({ "success": False, "message": "刮削路径不能为空" }, ensure_ascii=False) # 创建 FileItem fileitem = FileItem( storage=storage, path=path, type="file" if Path(path).suffix else "dir" ) # 检查本地存储路径是否存在 if storage == "local": scrape_path = Path(path) if not scrape_path.exists(): return json.dumps({ "success": False, "message": f"刮削路径不存在: {path}" }, ensure_ascii=False) # 识别媒体信息 media_chain = MediaChain() scrape_path = Path(path) meta = MetaInfoPath(scrape_path) mediainfo = await media_chain.async_recognize_by_meta(meta) if not mediainfo: return json.dumps({ "success": False, "message": f"刮削失败,无法识别媒体信息: {path}", "path": path }, ensure_ascii=False) # 在线程池中执行同步的刮削操作 await global_vars.loop.run_in_executor( None, lambda: media_chain.scrape_metadata( fileitem=fileitem, meta=meta, mediainfo=mediainfo, overwrite=overwrite ) ) return json.dumps({ "success": True, "message": f"{path} 刮削完成", "path": path, "media_info": { "title": mediainfo.title, "year": mediainfo.year, "type": mediainfo.type.value if mediainfo.type else None, "tmdb_id": mediainfo.tmdb_id, "season": mediainfo.season } }, ensure_ascii=False, indent=2) except Exception as e: error_message = f"刮削媒体元数据失败: {str(e)}" logger.error(f"刮削媒体元数据失败: {e}", exc_info=True) return json.dumps({ "success": False, "message": error_message, "path": path }, ensure_ascii=False) ================================================ FILE: app/agent/tools/impl/search_media.py ================================================ """搜索媒体工具""" import json from typing import Optional, Type from pydantic import BaseModel, Field from app.agent.tools.base import MoviePilotTool from app.chain.media import MediaChain from app.log import logger from app.schemas.types import MediaType, media_type_to_agent class SearchMediaInput(BaseModel): """搜索媒体工具的输入参数模型""" explanation: str = Field(..., description="Clear explanation of why this tool is being used in the current context") title: str = Field(..., description="The title of the media to search for (e.g., 'The Matrix', 'Breaking Bad')") year: Optional[str] = Field(None, description="Release year of the media (optional, helps narrow down results)") media_type: Optional[str] = Field(None, description="Allowed values: movie, tv") season: Optional[int] = Field(None, description="Season number for TV shows and anime (optional, only applicable for series)") class SearchMediaTool(MoviePilotTool): name: str = "search_media" description: str = "Search TMDB database for media resources (movies, TV shows, anime, etc.) by title, year, type, and other criteria. Returns detailed media information from TMDB. Use 'recognize_media' to extract info from torrent titles/file paths, or 'scrape_metadata' to generate metadata files." args_schema: Type[BaseModel] = SearchMediaInput def get_tool_message(self, **kwargs) -> Optional[str]: """根据搜索参数生成友好的提示消息""" title = kwargs.get("title", "") year = kwargs.get("year") media_type = kwargs.get("media_type") season = kwargs.get("season") message = f"正在搜索媒体: {title}" if year: message += f" ({year})" if media_type: message += f" [{media_type}]" if season: message += f" 第{season}季" return message async def run(self, title: str, year: Optional[str] = None, media_type: Optional[str] = None, season: Optional[int] = None, **kwargs) -> str: logger.info( f"执行工具: {self.name}, 参数: title={title}, year={year}, media_type={media_type}, season={season}") try: media_chain = MediaChain() # 使用 MediaChain.search 方法 meta, results = await media_chain.async_search(title=title) # 过滤结果 if results: media_type_enum = None if media_type: media_type_enum = MediaType.from_agent(media_type) if not media_type_enum: return f"错误:无效的媒体类型 '{media_type}',支持的类型:'movie', 'tv'" filtered_results = [] for result in results: if year and result.year != year: continue if media_type_enum and result.type != media_type_enum: continue if season is not None and result.season != season: continue filtered_results.append(result) if filtered_results: # 限制最多30条结果 total_count = len(filtered_results) limited_results = filtered_results[:30] # 精简字段,只保留关键信息 simplified_results = [] for r in limited_results: simplified = { "title": r.title, "en_title": r.en_title, "year": r.year, "type": media_type_to_agent(r.type), "season": r.season, "tmdb_id": r.tmdb_id, "imdb_id": r.imdb_id, "douban_id": r.douban_id, "overview": r.overview[:200] + "..." if r.overview and len(r.overview) > 200 else r.overview, "vote_average": r.vote_average, "poster_path": r.poster_path, "detail_link": r.detail_link } simplified_results.append(simplified) result_json = json.dumps(simplified_results, ensure_ascii=False, indent=2) # 如果结果被裁剪,添加提示信息 if total_count > 30: return f"注意:搜索结果共找到 {total_count} 条,为节省上下文空间,仅显示前 30 条结果。\n\n{result_json}" return result_json else: return f"未找到符合条件的媒体资源: {title}" else: return f"未找到相关媒体资源: {title}" except Exception as e: error_message = f"搜索媒体失败: {str(e)}" logger.error(f"搜索媒体失败: {e}", exc_info=True) return error_message ================================================ FILE: app/agent/tools/impl/search_person.py ================================================ """搜索人物工具""" import json from typing import Optional, Type from pydantic import BaseModel, Field from app.agent.tools.base import MoviePilotTool from app.chain.media import MediaChain from app.log import logger class SearchPersonInput(BaseModel): """搜索人物工具的输入参数模型""" explanation: str = Field(..., description="Clear explanation of why this tool is being used in the current context") name: str = Field(..., description="The name of the person to search for (e.g., 'Tom Hanks', '周杰伦')") class SearchPersonTool(MoviePilotTool): name: str = "search_person" description: str = "Search for person information including actors, directors, etc. Supports searching by name. Returns detailed person information from TMDB, Douban, or Bangumi database." args_schema: Type[BaseModel] = SearchPersonInput def get_tool_message(self, **kwargs) -> Optional[str]: """根据搜索参数生成友好的提示消息""" name = kwargs.get("name", "") return f"正在搜索人物: {name}" async def run(self, name: str, **kwargs) -> str: logger.info(f"执行工具: {self.name}, 参数: name={name}") try: media_chain = MediaChain() # 使用 MediaChain.async_search_persons 方法搜索人物 persons = await media_chain.async_search_persons(name=name) if persons: # 限制最多30条结果 total_count = len(persons) limited_persons = persons[:30] # 精简字段,只保留关键信息 simplified_results = [] for person in limited_persons: simplified = { "name": person.name, "id": person.id, "source": person.source, "profile_path": person.profile_path, "original_name": person.original_name, "known_for_department": person.known_for_department, "popularity": person.popularity, "biography": person.biography[:200] + "..." if person.biography and len(person.biography) > 200 else person.biography, "birthday": person.birthday, "deathday": person.deathday, "place_of_birth": person.place_of_birth, "gender": person.gender, "imdb_id": person.imdb_id, "also_known_as": person.also_known_as[:5] if person.also_known_as else [], # 限制别名数量 } # 添加豆瓣特有字段 if person.source == "douban": simplified["url"] = person.url simplified["avatar"] = person.avatar simplified["latin_name"] = person.latin_name simplified["roles"] = person.roles[:5] if person.roles else [] # 限制角色数量 # 添加Bangumi特有字段 if person.source == "bangumi": simplified["career"] = person.career simplified["relation"] = person.relation simplified_results.append(simplified) result_json = json.dumps(simplified_results, ensure_ascii=False, indent=2) # 如果结果被裁剪,添加提示信息 if total_count > 30: return f"注意:搜索结果共找到 {total_count} 条,为节省上下文空间,仅显示前 30 条结果。\n\n{result_json}" return result_json else: return f"未找到相关人物信息: {name}" except Exception as e: error_message = f"搜索人物失败: {str(e)}" logger.error(f"搜索人物失败: {e}", exc_info=True) return error_message ================================================ FILE: app/agent/tools/impl/search_person_credits.py ================================================ """搜索演员参演作品工具""" import json from typing import Optional, Type from pydantic import BaseModel, Field from app.agent.tools.base import MoviePilotTool from app.chain.douban import DoubanChain from app.chain.tmdb import TmdbChain from app.chain.bangumi import BangumiChain from app.log import logger class SearchPersonCreditsInput(BaseModel): """搜索演员参演作品工具的输入参数模型""" explanation: str = Field(..., description="Clear explanation of why this tool is being used in the current context") person_id: int = Field(..., description="The ID of the person/actor to search for credits (e.g., 31 for Tom Hanks in TMDB)") source: str = Field(..., description="The data source: 'tmdb' for TheMovieDB, 'douban' for Douban, 'bangumi' for Bangumi") page: Optional[int] = Field(1, description="Page number for pagination (default: 1)") class SearchPersonCreditsTool(MoviePilotTool): name: str = "search_person_credits" description: str = "Search for films and TV shows that a person/actor has appeared in (filmography). Supports searching by person ID from TMDB, Douban, or Bangumi database. Returns a list of media works the person has participated in." args_schema: Type[BaseModel] = SearchPersonCreditsInput def get_tool_message(self, **kwargs) -> Optional[str]: """根据搜索参数生成友好的提示消息""" person_id = kwargs.get("person_id", "") source = kwargs.get("source", "") return f"正在搜索人物参演作品: {source} ID {person_id}" async def run(self, person_id: int, source: str, page: Optional[int] = 1, **kwargs) -> str: logger.info(f"执行工具: {self.name}, 参数: person_id={person_id}, source={source}, page={page}") try: # 根据source选择相应的chain if source.lower() == "tmdb": tmdb_chain = TmdbChain() medias = await tmdb_chain.async_person_credits(person_id=person_id, page=page) elif source.lower() == "douban": douban_chain = DoubanChain() medias = await douban_chain.async_person_credits(person_id=person_id, page=page) elif source.lower() == "bangumi": bangumi_chain = BangumiChain() medias = await bangumi_chain.async_person_credits(person_id=person_id) else: return f"不支持的数据源: {source}。支持的数据源: tmdb, douban, bangumi" if medias: # 限制最多30条结果 total_count = len(medias) limited_medias = medias[:30] # 精简字段,只保留关键信息 simplified_results = [] for media in limited_medias: simplified = { "title": media.title, "en_title": media.en_title, "year": media.year, "type": media.type.value if media.type else None, "season": media.season, "tmdb_id": media.tmdb_id, "imdb_id": media.imdb_id, "douban_id": media.douban_id, "overview": media.overview[:200] + "..." if media.overview and len(media.overview) > 200 else media.overview, "vote_average": media.vote_average, "poster_path": media.poster_path, "backdrop_path": media.backdrop_path, "detail_link": media.detail_link } simplified_results.append(simplified) result_json = json.dumps(simplified_results, ensure_ascii=False, indent=2) # 如果结果被裁剪,添加提示信息 if total_count > 30: return f"注意:搜索结果共找到 {total_count} 条,为节省上下文空间,仅显示前 30 条结果。\n\n{result_json}" return result_json else: return f"未找到人物 ID {person_id} ({source}) 的参演作品" except Exception as e: error_message = f"搜索演员参演作品失败: {str(e)}" logger.error(f"搜索演员参演作品失败: {e}", exc_info=True) return error_message ================================================ FILE: app/agent/tools/impl/search_subscribe.py ================================================ """搜索订阅缺失剧集工具""" import json from typing import Optional, Type, List from pydantic import BaseModel, Field from app.agent.tools.base import MoviePilotTool from app.chain.subscribe import SubscribeChain from app.core.config import global_vars from app.db.subscribe_oper import SubscribeOper from app.log import logger from app.schemas.types import media_type_to_agent class SearchSubscribeInput(BaseModel): """搜索订阅缺失剧集工具的输入参数模型""" explanation: str = Field(..., description="Clear explanation of why this tool is being used in the current context") subscribe_id: int = Field(..., description="The ID of the subscription to search for missing episodes (can be obtained from query_subscribes tool)") manual: Optional[bool] = Field(False, description="Whether this is a manual search (default: False)") filter_groups: Optional[List[str]] = Field(None, description="List of filter rule group names to apply for this search (optional, can be obtained from query_rule_groups tool. If provided, will temporarily update the subscription's filter groups before searching)") class SearchSubscribeTool(MoviePilotTool): name: str = "search_subscribe" description: str = "Search for missing episodes/resources for a specific subscription. This tool will search torrent sites for the missing episodes of the subscription and automatically download matching resources. Use this when a user wants to search for missing episodes of a specific subscription." args_schema: Type[BaseModel] = SearchSubscribeInput def get_tool_message(self, **kwargs) -> Optional[str]: """根据搜索参数生成友好的提示消息""" subscribe_id = kwargs.get("subscribe_id") manual = kwargs.get("manual", False) message = f"正在搜索订阅 #{subscribe_id} 的缺失剧集" if manual: message += "(手动搜索)" return message async def run(self, subscribe_id: int, manual: Optional[bool] = False, filter_groups: Optional[List[str]] = None, **kwargs) -> str: logger.info( f"执行工具: {self.name}, 参数: subscribe_id={subscribe_id}, manual={manual}, filter_groups={filter_groups}") try: # 先验证订阅是否存在 subscribe_oper = SubscribeOper() subscribe = subscribe_oper.get(subscribe_id) if not subscribe: return json.dumps({ "success": False, "message": f"订阅不存在: {subscribe_id}" }, ensure_ascii=False) # 获取订阅信息用于返回 subscribe_info = { "id": subscribe.id, "name": subscribe.name, "year": subscribe.year, "type": media_type_to_agent(subscribe.type), "season": subscribe.season, "state": subscribe.state, "total_episode": subscribe.total_episode, "lack_episode": subscribe.lack_episode, "tmdbid": subscribe.tmdbid, "doubanid": subscribe.doubanid } # 检查订阅状态 if subscribe.state == "S": return json.dumps({ "success": False, "message": f"订阅 #{subscribe_id} ({subscribe.name}) 已暂停,无法搜索", "subscribe": subscribe_info }, ensure_ascii=False) # 如果提供了 filter_groups 参数,先更新订阅的规则组 if filter_groups is not None: subscribe_oper.update(subscribe_id, {"filter_groups": filter_groups}) logger.info(f"更新订阅 #{subscribe_id} 的规则组为: {filter_groups}") # 调用 SubscribeChain 的 search 方法 # search 方法是同步的,需要在异步环境中运行 subscribe_chain = SubscribeChain() # 在线程池中执行同步的搜索操作 # 当 sid 有值时,state 参数会被忽略,直接处理该订阅 await global_vars.loop.run_in_executor( None, lambda: subscribe_chain.search( sid=subscribe_id, state='R', # 默认状态,当 sid 有值时此参数会被忽略 manual=manual ) ) # 重新获取订阅信息以获取更新后的状态 updated_subscribe = subscribe_oper.get(subscribe_id) if updated_subscribe: subscribe_info.update({ "state": updated_subscribe.state, "lack_episode": updated_subscribe.lack_episode, "last_update": updated_subscribe.last_update, "filter_groups": updated_subscribe.filter_groups }) # 如果提供了规则组,会在返回信息中显示 result = { "success": True, "message": f"订阅 #{subscribe_id} ({subscribe.name}) 搜索完成", "subscribe": subscribe_info } if filter_groups is not None: result["message"] += f"(已应用规则组: {', '.join(filter_groups)})" return json.dumps(result, ensure_ascii=False, indent=2) except Exception as e: error_message = f"搜索订阅缺失剧集失败: {str(e)}" logger.error(f"搜索订阅缺失剧集失败: {e}", exc_info=True) return json.dumps({ "success": False, "message": error_message, "subscribe_id": subscribe_id }, ensure_ascii=False) ================================================ FILE: app/agent/tools/impl/search_torrents.py ================================================ """搜索种子工具""" import json from typing import List, Optional, Type from pydantic import BaseModel, Field from app.agent.tools.base import MoviePilotTool from app.chain.search import SearchChain from app.db.systemconfig_oper import SystemConfigOper from app.helper.sites import SitesHelper from app.log import logger from app.schemas.types import MediaType, SystemConfigKey from ._torrent_search_utils import ( SEARCH_RESULT_CACHE_FILE, build_filter_options, ) class SearchTorrentsInput(BaseModel): """搜索种子工具的输入参数模型""" explanation: str = Field(..., description="Clear explanation of why this tool is being used in the current context") tmdb_id: Optional[int] = Field(None, description="TMDB ID (can be obtained from search_media tool). Either tmdb_id or douban_id must be provided.") douban_id: Optional[str] = Field(None, description="Douban ID (can be obtained from search_media tool). Either tmdb_id or douban_id must be provided.") media_type: Optional[str] = Field(None, description="Allowed values: movie, tv") area: Optional[str] = Field(None, description="Search scope: 'title' (default) or 'imdbid'") sites: Optional[List[int]] = Field(None, description="Array of specific site IDs to search on (optional, if not provided searches all configured sites)") class SearchTorrentsTool(MoviePilotTool): name: str = "search_torrents" description: str = ("Search for torrent files by media ID across configured indexer sites, cache the matched results, " "and return available filter options for follow-up selection. " "Requires tmdb_id or douban_id (can be obtained from search_media tool) for accurate matching.") args_schema: Type[BaseModel] = SearchTorrentsInput def get_tool_message(self, **kwargs) -> Optional[str]: """根据搜索参数生成友好的提示消息""" tmdb_id = kwargs.get("tmdb_id") douban_id = kwargs.get("douban_id") media_type = kwargs.get("media_type") if tmdb_id: message = f"正在搜索种子: TMDB={tmdb_id}" elif douban_id: message = f"正在搜索种子: 豆瓣={douban_id}" else: message = "正在搜索种子" if media_type: message += f" [{media_type}]" return message async def run(self, tmdb_id: Optional[int] = None, douban_id: Optional[str] = None, media_type: Optional[str] = None, area: Optional[str] = None, sites: Optional[List[int]] = None, **kwargs) -> str: logger.info( f"执行工具: {self.name}, 参数: tmdb_id={tmdb_id}, douban_id={douban_id}, media_type={media_type}, area={area}, sites={sites}") if not tmdb_id and not douban_id: return "参数错误:tmdb_id 和 douban_id 至少需要提供一个,请先使用 search_media 工具获取媒体 ID。" try: search_chain = SearchChain() media_type_enum = None if media_type: media_type_enum = MediaType.from_agent(media_type) if not media_type_enum: return f"错误:无效的媒体类型 '{media_type}',支持的类型:'movie', 'tv'" filtered_torrents = await search_chain.async_search_by_id( tmdbid=tmdb_id, doubanid=douban_id, mtype=media_type_enum, area=area or "title", sites=sites, cache_local=False, ) # 获取站点信息 all_indexers = await SitesHelper().async_get_indexers() all_sites = [{"id": indexer.get("id"), "name": indexer.get("name")} for indexer in (all_indexers or [])] if sites: search_site_ids = sites else: configured_sites = SystemConfigOper().get(SystemConfigKey.IndexerSites) search_site_ids = configured_sites if configured_sites else [] if filtered_torrents: await search_chain.async_save_cache(filtered_torrents, SEARCH_RESULT_CACHE_FILE) result_json = json.dumps({ "total_count": len(filtered_torrents), "message": "搜索完成。请使用 get_search_results 工具获取搜索结果。", "all_sites": all_sites, "search_site_ids": search_site_ids, "filter_options": build_filter_options(filtered_torrents), }, ensure_ascii=False, indent=2) return result_json else: media_id = f"TMDB={tmdb_id}" if tmdb_id else f"豆瓣={douban_id}" result_json = json.dumps({ "message": f"未找到相关种子资源: {media_id}", "all_sites": all_sites, "search_site_ids": search_site_ids, }, ensure_ascii=False, indent=2) return result_json except Exception as e: error_message = f"搜索种子时发生错误: {str(e)}" logger.error(f"搜索种子失败: {e}", exc_info=True) return error_message ================================================ FILE: app/agent/tools/impl/search_web.py ================================================ import asyncio import json import re from typing import Optional, Type, List, Dict import httpx from ddgs import DDGS from pydantic import BaseModel, Field from app.agent.tools.base import MoviePilotTool from app.core.config import settings from app.log import logger # 搜索超时时间(秒) SEARCH_TIMEOUT = 20 class SearchWebInput(BaseModel): """搜索网络内容工具的输入参数模型""" explanation: str = Field(..., description="Clear explanation of why this tool is being used in the current context") query: str = Field(..., description="The search query string to search for on the web") max_results: Optional[int] = Field(5, description="Maximum number of search results to return (default: 5, max: 10)") class SearchWebTool(MoviePilotTool): name: str = "search_web" description: str = "Search the web for information when you need to find current information, facts, or references that you're uncertain about. Returns search results with titles, snippets, and URLs. Use this tool to get up-to-date information from the internet." args_schema: Type[BaseModel] = SearchWebInput def get_tool_message(self, **kwargs) -> Optional[str]: """根据搜索参数生成友好的提示消息""" query = kwargs.get("query", "") max_results = kwargs.get("max_results", 5) return f"正在搜索网络内容: {query} (最多返回 {max_results} 条结果)" async def run(self, query: str, max_results: Optional[int] = 5, **kwargs) -> str: """ 执行网络搜索 """ logger.info(f"执行工具: {self.name}, 参数: query={query}, max_results={max_results}") try: # 限制最大结果数 max_results = min(max(1, max_results or 5), 10) results = [] # 1. 优先使用 Tavily (如果配置了 API Key) if settings.TAVILY_API_KEY: logger.info("使用 Tavily 进行搜索...") results = await self._search_tavily(query, max_results) # 2. 如果没有结果或未配置 Tavily,使用 DuckDuckGo if not results: logger.info("使用 DuckDuckGo 进行搜索...") results = await self._search_duckduckgo(query, max_results) if not results: return f"未找到与 '{query}' 相关的搜索结果" # 格式化并裁剪结果 formatted_results = self._format_and_truncate_results(results, max_results) return json.dumps(formatted_results, ensure_ascii=False, indent=2) except Exception as e: error_message = f"搜索网络内容失败: {str(e)}" logger.error(f"搜索网络内容失败: {e}", exc_info=True) return error_message @staticmethod async def _search_tavily(query: str, max_results: int) -> List[Dict]: """使用 Tavily API 进行搜索""" try: async with httpx.AsyncClient(timeout=SEARCH_TIMEOUT) as client: response = await client.post( "https://api.tavily.com/search", json={ "api_key": settings.TAVILY_API_KEY, "query": query, "search_depth": "basic", "max_results": max_results, "include_answer": False, "include_images": False, "include_raw_content": False, } ) response.raise_for_status() data = response.json() results = [] for result in data.get("results", []): results.append({ 'title': result.get('title', ''), 'snippet': result.get('content', ''), 'url': result.get('url', ''), 'source': 'Tavily' }) return results except Exception as e: logger.warning(f"Tavily 搜索失败: {e}") return [] @staticmethod def _get_proxy_url(proxy_setting) -> Optional[str]: """从代理设置中提取代理URL""" if not proxy_setting: return None if isinstance(proxy_setting, dict): return proxy_setting.get('http') or proxy_setting.get('https') return proxy_setting async def _search_duckduckgo(self, query: str, max_results: int) -> List[Dict]: """使用 duckduckgo-search (DDGS) 进行搜索""" try: def sync_search(): results = [] ddgs_kwargs = { 'timeout': SEARCH_TIMEOUT } proxy_url = self._get_proxy_url(settings.PROXY) if proxy_url: ddgs_kwargs['proxy'] = proxy_url try: with DDGS(**ddgs_kwargs) as ddgs: ddgs_gen = ddgs.text( query, max_results=max_results ) if ddgs_gen: for result in ddgs_gen: results.append({ 'title': result.get('title', ''), 'snippet': result.get('body', ''), 'url': result.get('href', ''), 'source': 'DuckDuckGo' }) except Exception as err: logger.warning(f"DuckDuckGo search process failed: {err}") return results loop = asyncio.get_running_loop() return await loop.run_in_executor(None, sync_search) except Exception as e: logger.warning(f"DuckDuckGo 搜索失败: {e}") return [] @staticmethod def _format_and_truncate_results(results: List[Dict], max_results: int) -> Dict: """格式化并裁剪搜索结果""" formatted = { "total_results": len(results), "results": [] } for idx, result in enumerate(results[:max_results], 1): title = result.get("title", "")[:200] snippet = result.get("snippet", "") url = result.get("url", "") source = result.get("source", "Unknown") # 裁剪摘要 max_snippet_length = 500 # 增加到500字符,提供更多上下文 if len(snippet) > max_snippet_length: snippet = snippet[:max_snippet_length] + "..." # 清理文本 snippet = re.sub(r'\s+', ' ', snippet).strip() formatted["results"].append({ "rank": idx, "title": title, "snippet": snippet, "url": url, "source": source }) if len(results) > max_results: formatted["note"] = f"仅显示前 {max_results} 条结果。" return formatted ================================================ FILE: app/agent/tools/impl/send_message.py ================================================ """发送消息工具""" from typing import Optional, Type from pydantic import BaseModel, Field from app.agent.tools.base import MoviePilotTool from app.log import logger class SendMessageInput(BaseModel): """发送消息工具的输入参数模型""" explanation: str = Field(..., description="Clear explanation of why this tool is being used in the current context") message: str = Field(..., description="The message content to send to the user (should be clear and informative)") message_type: Optional[str] = Field("info", description="Type of message: 'info' for general information, 'success' for successful operations, 'warning' for warnings, 'error' for error messages") class SendMessageTool(MoviePilotTool): name: str = "send_message" description: str = "Send notification message to the user through configured notification channels (Telegram, Slack, WeChat, etc.). Used to inform users about operation results, errors, or important updates." args_schema: Type[BaseModel] = SendMessageInput def get_tool_message(self, **kwargs) -> Optional[str]: """根据消息参数生成友好的提示消息""" message = kwargs.get("message", "") message_type = kwargs.get("message_type", "info") type_map = {"info": "信息", "success": "成功", "warning": "警告", "error": "错误"} type_desc = type_map.get(message_type, message_type) # 截断过长的消息 if len(message) > 50: message = message[:50] + "..." return f"正在发送{type_desc}消息: {message}" async def run(self, message: str, message_type: Optional[str] = None, **kwargs) -> str: logger.info(f"执行工具: {self.name}, 参数: message={message}, message_type={message_type}") try: await self.send_tool_message(message, title=message_type) return "消息已发送" except Exception as e: logger.error(f"发送消息失败: {e}") return f"发送消息时发生错误: {str(e)}" ================================================ FILE: app/agent/tools/impl/test_site.py ================================================ """测试站点连通性工具""" from typing import Optional, Type from pydantic import BaseModel, Field from app.agent.tools.base import MoviePilotTool from app.chain.site import SiteChain from app.db.site_oper import SiteOper from app.log import logger class TestSiteInput(BaseModel): """测试站点连通性工具的输入参数模型""" explanation: str = Field(..., description="Clear explanation of why this tool is being used in the current context") site_identifier: int = Field(..., description="Site ID to test (can be obtained from query_sites tool)") class TestSiteTool(MoviePilotTool): name: str = "test_site" description: str = "Test site connectivity and availability. This will check if a site is accessible and can be logged in. Accepts site ID only." args_schema: Type[BaseModel] = TestSiteInput def get_tool_message(self, **kwargs) -> Optional[str]: """根据测试参数生成友好的提示消息""" site_identifier = kwargs.get("site_identifier") return f"正在测试站点连通性: {site_identifier}" async def run(self, site_identifier: int, **kwargs) -> str: logger.info(f"执行工具: {self.name}, 参数: site_identifier={site_identifier}") try: site_oper = SiteOper() site_chain = SiteChain() site = await site_oper.async_get(site_identifier) if not site: return f"未找到站点:{site_identifier},请使用 query_sites 工具查询可用的站点" # 测试站点连通性 status, message = site_chain.test(site.domain) if status: return f"站点连通性测试成功:{site.name} ({site.domain})\n{message}" else: return f"站点连通性测试失败:{site.name} ({site.domain})\n{message}" except Exception as e: logger.error(f"测试站点连通性失败: {e}", exc_info=True) return f"测试站点连通性时发生错误: {str(e)}" ================================================ FILE: app/agent/tools/impl/transfer_file.py ================================================ """整理文件或目录工具""" from pathlib import Path from typing import Optional, Type from pydantic import BaseModel, Field from app.agent.tools.base import MoviePilotTool from app.chain.transfer import TransferChain from app.log import logger from app.schemas import FileItem, MediaType class TransferFileInput(BaseModel): """整理文件或目录工具的输入参数模型""" explanation: str = Field(..., description="Clear explanation of why this tool is being used in the current context") file_path: str = Field(..., description="Path to the file or directory to transfer (e.g., '/path/to/file.mkv' or '/path/to/directory')") storage: Optional[str] = Field("local", description="Storage type of the source file (default: 'local', can be 'smb', 'alist', etc.)") target_path: Optional[str] = Field(None, description="Target path for the transferred file/directory (optional, uses default library path if not specified)") target_storage: Optional[str] = Field(None, description="Target storage type (optional, uses default storage if not specified)") media_type: Optional[str] = Field(None, description="Allowed values: movie, tv") tmdbid: Optional[int] = Field(None, description="TMDB ID for precise media identification (optional but recommended for accuracy)") doubanid: Optional[str] = Field(None, description="Douban ID for media identification (optional)") season: Optional[int] = Field(None, description="Season number for TV shows (optional)") transfer_type: Optional[str] = Field(None, description="Transfer mode: 'move' to move files, 'copy' to copy files, 'link' for hard link, 'softlink' for symbolic link (optional, uses default mode if not specified)") background: Optional[bool] = Field(False, description="Whether to run transfer in background (default: False, runs synchronously)") class TransferFileTool(MoviePilotTool): name: str = "transfer_file" description: str = "Transfer/organize a file or directory to the media library. Automatically recognizes media information and organizes files according to configured rules. Supports custom target paths, media identification, and transfer modes." args_schema: Type[BaseModel] = TransferFileInput def get_tool_message(self, **kwargs) -> Optional[str]: """根据整理参数生成友好的提示消息""" file_path = kwargs.get("file_path", "") media_type = kwargs.get("media_type") transfer_type = kwargs.get("transfer_type") background = kwargs.get("background", False) message = f"正在整理文件: {file_path}" if media_type: message += f" [{media_type}]" if transfer_type: transfer_map = {"move": "移动", "copy": "复制", "link": "硬链接", "softlink": "软链接"} message += f" 模式: {transfer_map.get(transfer_type, transfer_type)}" if background: message += " [后台运行]" return message async def run(self, file_path: str, storage: Optional[str] = "local", target_path: Optional[str] = None, target_storage: Optional[str] = None, media_type: Optional[str] = None, tmdbid: Optional[int] = None, doubanid: Optional[str] = None, season: Optional[int] = None, transfer_type: Optional[str] = None, background: Optional[bool] = False, **kwargs) -> str: logger.info( f"执行工具: {self.name}, 参数: file_path={file_path}, storage={storage}, target_path={target_path}, " f"target_storage={target_storage}, media_type={media_type}, tmdbid={tmdbid}, doubanid={doubanid}, " f"season={season}, transfer_type={transfer_type}, background={background}") try: if not file_path: return "错误:必须提供文件或目录路径" # 规范化路径 if storage == "local": # 本地路径处理 if not file_path.startswith("/") and not (len(file_path) > 1 and file_path[1] == ":"): # 相对路径,尝试转换为绝对路径 file_path = str(Path(file_path).resolve()) else: # 远程存储路径,确保以/开头 if not file_path.startswith("/"): file_path = "/" + file_path # 创建FileItem fileitem = FileItem( storage=storage or "local", path=file_path, type="dir" if file_path.endswith("/") else "file" ) # 处理目标路径 target_path_obj = None if target_path: target_path_obj = Path(target_path) # 处理媒体类型 media_type_enum = None if media_type: media_type_enum = MediaType.from_agent(media_type) if not media_type_enum: return f"错误:无效的媒体类型 '{media_type}',支持的类型:'movie', 'tv'" # 调用整理方法 transfer_chain = TransferChain() state, errormsg = transfer_chain.manual_transfer( fileitem=fileitem, target_storage=target_storage, target_path=target_path_obj, tmdbid=tmdbid, doubanid=doubanid, mtype=media_type_enum, season=season, transfer_type=transfer_type, background=background ) if not state: # 处理错误信息 if isinstance(errormsg, list): error_text = f"整理完成,{len(errormsg)} 个文件转移失败" if errormsg: error_text += f":\n" + "\n".join(str(e) for e in errormsg[:5]) # 只显示前5个错误 if len(errormsg) > 5: error_text += f"\n... 还有 {len(errormsg) - 5} 个错误" else: error_text = str(errormsg) return f"整理失败:{error_text}" else: if background: return f"整理任务已提交到后台运行:{file_path}" else: return f"整理成功:{file_path}" except Exception as e: logger.error(f"整理文件失败: {e}", exc_info=True) return f"整理文件时发生错误: {str(e)}" ================================================ FILE: app/agent/tools/impl/update_site.py ================================================ """更新站点工具""" import json from typing import Optional, Type from pydantic import BaseModel, Field from app.agent.tools.base import MoviePilotTool from app.core.event import eventmanager from app.db import AsyncSessionFactory from app.db.models.site import Site from app.log import logger from app.schemas.types import EventType from app.utils.string import StringUtils class UpdateSiteInput(BaseModel): """更新站点工具的输入参数模型""" explanation: str = Field(..., description="Clear explanation of why this tool is being used in the current context") site_id: int = Field(..., description="The ID of the site to update (can be obtained from query_sites tool)") name: Optional[str] = Field(None, description="Site name (optional)") url: Optional[str] = Field(None, description="Site URL (optional, will be automatically formatted)") pri: Optional[int] = Field(None, description="Site priority (optional, smaller value = higher priority, e.g., pri=1 has higher priority than pri=10)") rss: Optional[str] = Field(None, description="RSS feed URL (optional)") cookie: Optional[str] = Field(None, description="Site cookie (optional)") ua: Optional[str] = Field(None, description="User-Agent string (optional)") apikey: Optional[str] = Field(None, description="API key (optional)") token: Optional[str] = Field(None, description="API token (optional)") proxy: Optional[int] = Field(None, description="Whether to use proxy: 0 for no, 1 for yes (optional)") filter: Optional[str] = Field(None, description="Filter rule as regular expression (optional)") note: Optional[str] = Field(None, description="Site notes/remarks (optional)") timeout: Optional[int] = Field(None, description="Request timeout in seconds (optional, default: 15)") limit_interval: Optional[int] = Field(None, description="Rate limit interval in seconds (optional)") limit_count: Optional[int] = Field(None, description="Rate limit count per interval (optional)") limit_seconds: Optional[int] = Field(None, description="Rate limit seconds between requests (optional)") is_active: Optional[bool] = Field(None, description="Whether site is active: True for enabled, False for disabled (optional)") downloader: Optional[str] = Field(None, description="Downloader name for this site (optional)") class UpdateSiteTool(MoviePilotTool): name: str = "update_site" description: str = "Update site configuration including URL, priority, authentication credentials (cookie, UA, API key), proxy settings, rate limits, and other site properties. Supports updating multiple site attributes at once. Site priority (pri): smaller values have higher priority (e.g., pri=1 has higher priority than pri=10)." args_schema: Type[BaseModel] = UpdateSiteInput def get_tool_message(self, **kwargs) -> Optional[str]: """根据更新参数生成友好的提示消息""" site_id = kwargs.get("site_id") fields_updated = [] if kwargs.get("name"): fields_updated.append("名称") if kwargs.get("url"): fields_updated.append("URL") if kwargs.get("pri") is not None: fields_updated.append("优先级") if kwargs.get("cookie"): fields_updated.append("Cookie") if kwargs.get("ua"): fields_updated.append("User-Agent") if kwargs.get("proxy") is not None: fields_updated.append("代理设置") if kwargs.get("is_active") is not None: fields_updated.append("启用状态") if kwargs.get("downloader"): fields_updated.append("下载器") if fields_updated: return f"正在更新站点 #{site_id}: {', '.join(fields_updated)}" return f"正在更新站点 #{site_id}" async def run(self, site_id: int, name: Optional[str] = None, url: Optional[str] = None, pri: Optional[int] = None, rss: Optional[str] = None, cookie: Optional[str] = None, ua: Optional[str] = None, apikey: Optional[str] = None, token: Optional[str] = None, proxy: Optional[int] = None, filter: Optional[str] = None, note: Optional[str] = None, timeout: Optional[int] = None, limit_interval: Optional[int] = None, limit_count: Optional[int] = None, limit_seconds: Optional[int] = None, is_active: Optional[bool] = None, downloader: Optional[str] = None, **kwargs) -> str: logger.info(f"执行工具: {self.name}, 参数: site_id={site_id}") try: # 获取数据库会话 async with AsyncSessionFactory() as db: # 获取站点 site = await Site.async_get(db, site_id) if not site: return json.dumps({ "success": False, "message": f"站点不存在: {site_id}" }, ensure_ascii=False) # 构建更新字典 site_dict = {} # 基本信息 if name is not None: site_dict["name"] = name # URL处理(需要校正格式) if url is not None: _scheme, _netloc = StringUtils.get_url_netloc(url) site_dict["url"] = f"{_scheme}://{_netloc}/" if pri is not None: site_dict["pri"] = pri if rss is not None: site_dict["rss"] = rss # 认证信息 if cookie is not None: site_dict["cookie"] = cookie if ua is not None: site_dict["ua"] = ua if apikey is not None: site_dict["apikey"] = apikey if token is not None: site_dict["token"] = token # 配置选项 if proxy is not None: site_dict["proxy"] = proxy if filter is not None: site_dict["filter"] = filter if note is not None: site_dict["note"] = note if timeout is not None: site_dict["timeout"] = timeout # 流控设置 if limit_interval is not None: site_dict["limit_interval"] = limit_interval if limit_count is not None: site_dict["limit_count"] = limit_count if limit_seconds is not None: site_dict["limit_seconds"] = limit_seconds # 状态和下载器 if is_active is not None: site_dict["is_active"] = is_active if downloader is not None: site_dict["downloader"] = downloader # 如果没有要更新的字段 if not site_dict: return json.dumps({ "success": False, "message": "没有提供要更新的字段" }, ensure_ascii=False) # 更新站点 await site.async_update(db, site_dict) # 重新获取更新后的站点数据 updated_site = await Site.async_get(db, site_id) # 发送站点更新事件 await eventmanager.async_send_event(EventType.SiteUpdated, { "domain": updated_site.domain if updated_site else site.domain }) # 构建返回结果 result = { "success": True, "message": f"站点 #{site_id} 更新成功", "site_id": site_id, "updated_fields": list(site_dict.keys()) } if updated_site: result["site"] = { "id": updated_site.id, "name": updated_site.name, "domain": updated_site.domain, "url": updated_site.url, "pri": updated_site.pri, "is_active": updated_site.is_active, "downloader": updated_site.downloader, "proxy": updated_site.proxy, "timeout": updated_site.timeout } return json.dumps(result, ensure_ascii=False, indent=2) except Exception as e: error_message = f"更新站点失败: {str(e)}" logger.error(f"更新站点失败: {e}", exc_info=True) return json.dumps({ "success": False, "message": error_message, "site_id": site_id }, ensure_ascii=False) ================================================ FILE: app/agent/tools/impl/update_site_cookie.py ================================================ """更新站点Cookie和UA工具""" from typing import Optional, Type from pydantic import BaseModel, Field from app.agent.tools.base import MoviePilotTool from app.chain.site import SiteChain from app.db.site_oper import SiteOper from app.log import logger class UpdateSiteCookieInput(BaseModel): """更新站点Cookie和UA工具的输入参数模型""" explanation: str = Field(..., description="Clear explanation of why this tool is being used in the current context") site_identifier: int = Field(..., description="Site ID to update Cookie and User-Agent for (can be obtained from query_sites tool)") username: str = Field(..., description="Site login username") password: str = Field(..., description="Site login password") two_step_code: Optional[str] = Field(None, description="Two-step verification code or secret key (optional, required for sites with 2FA enabled)") class UpdateSiteCookieTool(MoviePilotTool): name: str = "update_site_cookie" description: str = "Update site Cookie and User-Agent by logging in with username and password. This tool can automatically obtain and update the site's authentication credentials. Supports two-step verification for sites that require it. Accepts site ID only." args_schema: Type[BaseModel] = UpdateSiteCookieInput def get_tool_message(self, **kwargs) -> Optional[str]: """根据更新参数生成友好的提示消息""" site_identifier = kwargs.get("site_identifier") username = kwargs.get("username", "") two_step_code = kwargs.get("two_step_code") message = f"正在更新站点Cookie: {site_identifier} (用户: {username})" if two_step_code: message += " [需要两步验证]" return message async def run(self, site_identifier: int, username: str, password: str, two_step_code: Optional[str] = None, **kwargs) -> str: logger.info(f"执行工具: {self.name}, 参数: site_identifier={site_identifier}, username={username}") try: site_oper = SiteOper() site_chain = SiteChain() site = await site_oper.async_get(site_identifier) if not site: return f"未找到站点:{site_identifier},请使用 query_sites 工具查询可用的站点" # 更新站点Cookie和UA status, message = site_chain.update_cookie( site_info=site, username=username, password=password, two_step_code=two_step_code ) if status: return f"站点【{site.name}】Cookie和UA更新成功\n{message}" else: return f"站点【{site.name}】Cookie和UA更新失败\n错误原因:{message}" except Exception as e: logger.error(f"更新站点Cookie和UA失败: {e}", exc_info=True) return f"更新站点Cookie和UA时发生错误: {str(e)}" ================================================ FILE: app/agent/tools/impl/update_subscribe.py ================================================ """更新订阅工具""" import json from typing import Optional, Type, List from pydantic import BaseModel, Field from app.agent.tools.base import MoviePilotTool from app.core.event import eventmanager from app.db import AsyncSessionFactory from app.db.models.subscribe import Subscribe from app.log import logger from app.schemas.types import EventType class UpdateSubscribeInput(BaseModel): """更新订阅工具的输入参数模型""" explanation: str = Field(..., description="Clear explanation of why this tool is being used in the current context") subscribe_id: int = Field(..., description="The ID of the subscription to update (can be obtained from query_subscribes tool)") name: Optional[str] = Field(None, description="Subscription name/title (optional)") year: Optional[str] = Field(None, description="Release year (optional)") season: Optional[int] = Field(None, description="Season number for TV shows (optional)") total_episode: Optional[int] = Field(None, description="Total number of episodes (optional)") lack_episode: Optional[int] = Field(None, description="Number of missing episodes (optional)") start_episode: Optional[int] = Field(None, description="Starting episode number (optional)") quality: Optional[str] = Field(None, description="Quality filter as regular expression (optional, e.g., 'BluRay|WEB-DL|HDTV')") resolution: Optional[str] = Field(None, description="Resolution filter as regular expression (optional, e.g., '1080p|720p|2160p')") effect: Optional[str] = Field(None, description="Effect filter as regular expression (optional, e.g., 'HDR|DV|SDR')") include: Optional[str] = Field(None, description="Include filter as regular expression (optional)") exclude: Optional[str] = Field(None, description="Exclude filter as regular expression (optional)") filter: Optional[str] = Field(None, description="Filter rule as regular expression (optional)") state: Optional[str] = Field(None, description="Subscription state: 'R' for enabled, 'P' for pending, 'S' for paused (optional)") sites: Optional[List[int]] = Field(None, description="List of site IDs to search from (optional)") downloader: Optional[str] = Field(None, description="Downloader name (optional)") save_path: Optional[str] = Field(None, description="Save path for downloaded files (optional)") best_version: Optional[int] = Field(None, description="Whether to upgrade to best version: 0 for no, 1 for yes (optional)") custom_words: Optional[str] = Field(None, description="Custom recognition words (optional)") media_category: Optional[str] = Field(None, description="Custom media category (optional)") episode_group: Optional[str] = Field(None, description="Episode group ID (optional)") class UpdateSubscribeTool(MoviePilotTool): name: str = "update_subscribe" description: str = "Update subscription properties including filters, episode counts, state, and other settings. Supports updating quality/resolution filters, episode tracking, subscription state, and download configuration." args_schema: Type[BaseModel] = UpdateSubscribeInput def get_tool_message(self, **kwargs) -> Optional[str]: """根据更新参数生成友好的提示消息""" subscribe_id = kwargs.get("subscribe_id") fields_updated = [] if kwargs.get("name"): fields_updated.append("名称") if kwargs.get("total_episode") is not None: fields_updated.append("总集数") if kwargs.get("lack_episode") is not None: fields_updated.append("缺失集数") if kwargs.get("quality"): fields_updated.append("质量过滤") if kwargs.get("resolution"): fields_updated.append("分辨率过滤") if kwargs.get("state"): state_map = {"R": "启用", "P": "禁用", "S": "暂停"} fields_updated.append(f"状态({state_map.get(kwargs.get('state'), kwargs.get('state'))})") if kwargs.get("sites"): fields_updated.append("站点") if kwargs.get("downloader"): fields_updated.append("下载器") if fields_updated: return f"正在更新订阅 #{subscribe_id}: {', '.join(fields_updated)}" return f"正在更新订阅 #{subscribe_id}" async def run(self, subscribe_id: int, name: Optional[str] = None, year: Optional[str] = None, season: Optional[int] = None, total_episode: Optional[int] = None, lack_episode: Optional[int] = None, start_episode: Optional[int] = None, quality: Optional[str] = None, resolution: Optional[str] = None, effect: Optional[str] = None, include: Optional[str] = None, exclude: Optional[str] = None, filter: Optional[str] = None, state: Optional[str] = None, sites: Optional[List[int]] = None, downloader: Optional[str] = None, save_path: Optional[str] = None, best_version: Optional[int] = None, custom_words: Optional[str] = None, media_category: Optional[str] = None, episode_group: Optional[str] = None, **kwargs) -> str: logger.info(f"执行工具: {self.name}, 参数: subscribe_id={subscribe_id}") try: # 获取数据库会话 async with AsyncSessionFactory() as db: # 获取订阅 subscribe = await Subscribe.async_get(db, subscribe_id) if not subscribe: return json.dumps({ "success": False, "message": f"订阅不存在: {subscribe_id}" }, ensure_ascii=False) # 保存旧数据用于事件 old_subscribe_dict = subscribe.to_dict() # 构建更新字典 subscribe_dict = {} # 基本信息 if name is not None: subscribe_dict["name"] = name if year is not None: subscribe_dict["year"] = year if season is not None: subscribe_dict["season"] = season # 集数相关 if total_episode is not None: subscribe_dict["total_episode"] = total_episode # 如果总集数增加,缺失集数也要相应增加 if total_episode > (subscribe.total_episode or 0): old_lack = subscribe.lack_episode or 0 subscribe_dict["lack_episode"] = old_lack + (total_episode - (subscribe.total_episode or 0)) # 标记为手动修改过总集数 subscribe_dict["manual_total_episode"] = 1 # 缺失集数处理(只有在没有提供总集数时才单独处理) # 注意:如果 lack_episode 为 0,不更新(避免更新为0) if lack_episode is not None and total_episode is None: if lack_episode > 0: subscribe_dict["lack_episode"] = lack_episode # 如果 lack_episode 为 0,不添加到更新字典中(保持原值或由总集数逻辑处理) if start_episode is not None: subscribe_dict["start_episode"] = start_episode # 过滤规则 if quality is not None: subscribe_dict["quality"] = quality if resolution is not None: subscribe_dict["resolution"] = resolution if effect is not None: subscribe_dict["effect"] = effect if include is not None: subscribe_dict["include"] = include if exclude is not None: subscribe_dict["exclude"] = exclude if filter is not None: subscribe_dict["filter"] = filter # 状态 if state is not None: valid_states = ["R", "P", "S", "N"] if state not in valid_states: return json.dumps({ "success": False, "message": f"无效的订阅状态: {state},有效状态: {', '.join(valid_states)}" }, ensure_ascii=False) subscribe_dict["state"] = state # 下载配置 if sites is not None: subscribe_dict["sites"] = sites if downloader is not None: subscribe_dict["downloader"] = downloader if save_path is not None: subscribe_dict["save_path"] = save_path if best_version is not None: subscribe_dict["best_version"] = best_version # 其他配置 if custom_words is not None: subscribe_dict["custom_words"] = custom_words if media_category is not None: subscribe_dict["media_category"] = media_category if episode_group is not None: subscribe_dict["episode_group"] = episode_group # 如果没有要更新的字段 if not subscribe_dict: return json.dumps({ "success": False, "message": "没有提供要更新的字段" }, ensure_ascii=False) # 更新订阅 await subscribe.async_update(db, subscribe_dict) # 重新获取更新后的订阅数据 updated_subscribe = await Subscribe.async_get(db, subscribe_id) # 发送订阅调整事件 await eventmanager.async_send_event(EventType.SubscribeModified, { "subscribe_id": subscribe_id, "old_subscribe_info": old_subscribe_dict, "subscribe_info": updated_subscribe.to_dict() if updated_subscribe else {}, }) # 构建返回结果 result = { "success": True, "message": f"订阅 #{subscribe_id} 更新成功", "subscribe_id": subscribe_id, "updated_fields": list(subscribe_dict.keys()) } if updated_subscribe: result["subscribe"] = { "id": updated_subscribe.id, "name": updated_subscribe.name, "year": updated_subscribe.year, "type": updated_subscribe.type, "season": updated_subscribe.season, "state": updated_subscribe.state, "total_episode": updated_subscribe.total_episode, "lack_episode": updated_subscribe.lack_episode, "start_episode": updated_subscribe.start_episode, "quality": updated_subscribe.quality, "resolution": updated_subscribe.resolution, "effect": updated_subscribe.effect } return json.dumps(result, ensure_ascii=False, indent=2) except Exception as e: error_message = f"更新订阅失败: {str(e)}" logger.error(f"更新订阅失败: {e}", exc_info=True) return json.dumps({ "success": False, "message": error_message, "subscribe_id": subscribe_id }, ensure_ascii=False) ================================================ FILE: app/agent/tools/manager.py ================================================ import json import uuid from typing import Any, Dict, List, Optional from app.agent.tools.factory import MoviePilotToolFactory from app.log import logger class ToolDefinition: """ 工具定义 """ def __init__(self, name: str, description: str, input_schema: Dict[str, Any]): self.name = name self.description = description self.input_schema = input_schema class MoviePilotToolsManager: """ MoviePilot工具管理器(用于HTTP API) """ def __init__(self, user_id: str = "api_user", session_id: str = uuid.uuid4()): """ 初始化工具管理器 Args: user_id: 用户ID session_id: 会话ID """ self.user_id = user_id self.session_id = session_id self.tools: List[Any] = [] self._load_tools() def _load_tools(self): """ 加载所有MoviePilot工具 """ try: # 创建工具实例 self.tools = MoviePilotToolFactory.create_tools( session_id=self.session_id, user_id=self.user_id, channel=None, source="api", username="API Client", callback_handler=None, ) logger.info(f"成功加载 {len(self.tools)} 个工具") except Exception as e: logger.error(f"加载工具失败: {e}", exc_info=True) self.tools = [] def list_tools(self) -> List[ToolDefinition]: """ 列出所有可用的工具 Returns: 工具定义列表 """ tools_list = [] for tool in self.tools: # 获取工具的输入参数模型 args_schema = getattr(tool, 'args_schema', None) if args_schema: # 将Pydantic模型转换为JSON Schema input_schema = self._convert_to_json_schema(args_schema) else: # 如果没有args_schema,使用基本信息 input_schema = { "type": "object", "properties": {}, "required": [] } tools_list.append(ToolDefinition( name=tool.name, description=tool.description or "", input_schema=input_schema )) return tools_list def get_tool(self, tool_name: str) -> Optional[Any]: """ 获取指定工具实例 Args: tool_name: 工具名称 Returns: 工具实例,如果未找到返回None """ for tool in self.tools: if tool.name == tool_name: return tool return None @staticmethod def _resolve_field_schema(field_info: Dict[str, Any]) -> Dict[str, Any]: """ 解析字段schema,兼容 Optional[T] 生成的 anyOf 结构 """ if field_info.get("type"): return field_info any_of = field_info.get("anyOf") if not any_of: return field_info for type_option in any_of: if type_option.get("type") and type_option["type"] != "null": merged = dict(type_option) if "description" not in merged and field_info.get("description"): merged["description"] = field_info["description"] if "default" not in merged and "default" in field_info: merged["default"] = field_info["default"] return merged return field_info @staticmethod def _normalize_scalar_value(field_type: Optional[str], value: Any, key: str) -> Any: """ 根据字段类型规范化单个值 """ if field_type == "integer" and isinstance(value, str): try: return int(value) except (ValueError, TypeError): logger.warning(f"无法将参数 {key}='{value}' 转换为整数,返回 None") return None if field_type == "number" and isinstance(value, str): try: return float(value) except (ValueError, TypeError): logger.warning(f"无法将参数 {key}='{value}' 转换为浮点数,返回 None") return None if field_type == "boolean": if isinstance(value, str): return value.lower() in ("true", "1", "yes", "on") if isinstance(value, (int, float)): return value != 0 if isinstance(value, bool): return value return True return value @staticmethod def _parse_array_string(value: str, key: str, item_type: str = "string") -> list: """ 将逗号分隔的字符串解析为列表,并根据 item_type 转换元素类型 """ trimmed = value.strip() if not trimmed: return [] return [ MoviePilotToolsManager._normalize_scalar_value(item_type, item.strip(), key) for item in trimmed.split(",") if item.strip() ] @staticmethod def _normalize_arguments(tool_instance: Any, arguments: Dict[str, Any]) -> Dict[str, Any]: """ 根据工具的参数schema规范化参数类型 Args: tool_instance: 工具实例 arguments: 原始参数 Returns: 规范化后的参数 """ # 获取工具的参数schema args_schema = getattr(tool_instance, 'args_schema', None) if not args_schema: return arguments # 获取schema中的字段定义 try: schema = args_schema.model_json_schema() properties = schema.get("properties", {}) except Exception as e: logger.warning(f"获取工具schema失败: {e}") return arguments # 规范化参数 normalized = {} for key, value in arguments.items(): if key not in properties: # 参数不在schema中,保持原样 normalized[key] = value continue field_info = MoviePilotToolsManager._resolve_field_schema(properties[key]) field_type = field_info.get("type") # 数组类型:将字符串解析为列表 if field_type == "array" and isinstance(value, str): item_type = field_info.get("items", {}).get("type", "string") normalized[key] = MoviePilotToolsManager._parse_array_string(value, key, item_type) continue # 根据类型进行转换 normalized[key] = MoviePilotToolsManager._normalize_scalar_value(field_type, value, key) return normalized async def call_tool(self, tool_name: str, arguments: Dict[str, Any]) -> str: """ 调用工具 Args: tool_name: 工具名称 arguments: 工具参数 Returns: 工具执行结果(字符串) """ tool_instance = self.get_tool(tool_name) if not tool_instance: error_msg = json.dumps({ "error": f"工具 '{tool_name}' 未找到" }, ensure_ascii=False) return error_msg try: # 规范化参数类型 normalized_arguments = self._normalize_arguments(tool_instance, arguments) # 调用工具的run方法 result = await tool_instance.run(**normalized_arguments) # 确保返回字符串 if isinstance(result, str): formated_result = result elif isinstance(result, int, float): formated_result = str(result) else: try: formated_result = json.dumps(result, ensure_ascii=False, indent=2) except Exception as e: logger.warning(f"结果转换为JSON失败: {e}, 使用字符串表示") formated_result = str(result) return formated_result except Exception as e: logger.error(f"调用工具 {tool_name} 时发生错误: {e}", exc_info=True) error_msg = json.dumps({ "error": f"调用工具 '{tool_name}' 时发生错误: {str(e)}" }, ensure_ascii=False) return error_msg @staticmethod def _convert_to_json_schema(args_schema: Any) -> Dict[str, Any]: """ 将Pydantic模型转换为JSON Schema Args: args_schema: Pydantic模型类 Returns: JSON Schema字典 """ # 获取Pydantic模型的字段信息 schema = args_schema.model_json_schema() # 构建JSON Schema properties = {} required = [] if "properties" in schema: for field_name, field_info in schema["properties"].items(): resolved_field_info = MoviePilotToolsManager._resolve_field_schema(field_info) # 转换字段类型 field_type = resolved_field_info.get("type", "string") field_description = resolved_field_info.get("description", "") # 处理可选字段 if field_name not in schema.get("required", []): # 可选字段 default_value = resolved_field_info.get("default") properties[field_name] = { "type": field_type, "description": field_description } if default_value is not None: properties[field_name]["default"] = default_value else: properties[field_name] = { "type": field_type, "description": field_description } required.append(field_name) # 处理枚举类型 if "enum" in resolved_field_info: properties[field_name]["enum"] = resolved_field_info["enum"] # 处理数组类型 if field_type == "array" and "items" in resolved_field_info: properties[field_name]["items"] = resolved_field_info["items"] return { "type": "object", "properties": properties, "required": required } moviepilot_tool_manager = MoviePilotToolsManager() ================================================ FILE: app/api/__init__.py ================================================ ================================================ FILE: app/api/apiv1.py ================================================ from fastapi import APIRouter from app.api.endpoints import login, user, webhook, message, site, subscribe, \ media, douban, search, plugin, tmdb, history, system, download, dashboard, \ transfer, mediaserver, bangumi, storage, discover, recommend, workflow, torrent, mcp, mfa api_router = APIRouter() api_router.include_router(login.router, prefix="/login", tags=["login"]) api_router.include_router(user.router, prefix="/user", tags=["user"]) api_router.include_router(mfa.router, prefix="/mfa", tags=["mfa"]) api_router.include_router(site.router, prefix="/site", tags=["site"]) api_router.include_router(message.router, prefix="/message", tags=["message"]) api_router.include_router(webhook.router, prefix="/webhook", tags=["webhook"]) api_router.include_router(subscribe.router, prefix="/subscribe", tags=["subscribe"]) api_router.include_router(media.router, prefix="/media", tags=["media"]) api_router.include_router(search.router, prefix="/search", tags=["search"]) api_router.include_router(douban.router, prefix="/douban", tags=["douban"]) api_router.include_router(tmdb.router, prefix="/tmdb", tags=["tmdb"]) api_router.include_router(history.router, prefix="/history", tags=["history"]) api_router.include_router(system.router, prefix="/system", tags=["system"]) api_router.include_router(plugin.router, prefix="/plugin", tags=["plugin"]) api_router.include_router(download.router, prefix="/download", tags=["download"]) api_router.include_router(dashboard.router, prefix="/dashboard", tags=["dashboard"]) api_router.include_router(storage.router, prefix="/storage", tags=["storage"]) api_router.include_router(transfer.router, prefix="/transfer", tags=["transfer"]) api_router.include_router(mediaserver.router, prefix="/mediaserver", tags=["mediaserver"]) api_router.include_router(bangumi.router, prefix="/bangumi", tags=["bangumi"]) api_router.include_router(discover.router, prefix="/discover", tags=["discover"]) api_router.include_router(recommend.router, prefix="/recommend", tags=["recommend"]) api_router.include_router(workflow.router, prefix="/workflow", tags=["workflow"]) api_router.include_router(torrent.router, prefix="/torrent", tags=["torrent"]) api_router.include_router(mcp.router, prefix="/mcp", tags=["mcp"]) ================================================ FILE: app/api/endpoints/__init__.py ================================================ ================================================ FILE: app/api/endpoints/bangumi.py ================================================ from typing import List, Any, Optional from fastapi import APIRouter, Depends from app import schemas from app.chain.bangumi import BangumiChain from app.core.context import MediaInfo from app.core.security import verify_token router = APIRouter() @router.get("/credits/{bangumiid}", summary="查询Bangumi演职员表", response_model=List[schemas.MediaPerson]) async def bangumi_credits(bangumiid: int, page: Optional[int] = 1, count: Optional[int] = 20, _: schemas.TokenPayload = Depends(verify_token)) -> Any: """ 查询Bangumi演职员表 """ persons = await BangumiChain().async_bangumi_credits(bangumiid) if persons: return persons[(page - 1) * count: page * count] return [] @router.get("/recommend/{bangumiid}", summary="查询Bangumi推荐", response_model=List[schemas.MediaInfo]) async def bangumi_recommend(bangumiid: int, page: Optional[int] = 1, count: Optional[int] = 20, _: schemas.TokenPayload = Depends(verify_token)) -> Any: """ 查询Bangumi推荐 """ medias = await BangumiChain().async_bangumi_recommend(bangumiid) if medias: return [media.to_dict() for media in medias[(page - 1) * count: page * count]] return [] @router.get("/person/{person_id}", summary="人物详情", response_model=schemas.MediaPerson) async def bangumi_person(person_id: int, _: schemas.TokenPayload = Depends(verify_token)) -> Any: """ 根据人物ID查询人物详情 """ return await BangumiChain().async_person_detail(person_id=person_id) @router.get("/person/credits/{person_id}", summary="人物参演作品", response_model=List[schemas.MediaInfo]) async def bangumi_person_credits(person_id: int, page: Optional[int] = 1, count: Optional[int] = 20, _: schemas.TokenPayload = Depends(verify_token)) -> Any: """ 根据人物ID查询人物参演作品 """ medias = await BangumiChain().async_person_credits(person_id=person_id) if medias: return [media.to_dict() for media in medias[(page - 1) * count: page * count]] return [] @router.get("/{bangumiid}", summary="查询Bangumi详情", response_model=schemas.MediaInfo) async def bangumi_info(bangumiid: int, _: schemas.TokenPayload = Depends(verify_token)) -> Any: """ 查询Bangumi详情 """ info = await BangumiChain().async_bangumi_info(bangumiid) if info: return MediaInfo(bangumi_info=info).to_dict() else: return schemas.MediaInfo() ================================================ FILE: app/api/endpoints/dashboard.py ================================================ from pathlib import Path from typing import Any, List, Optional, Annotated from fastapi import APIRouter, Depends from sqlalchemy.orm import Session from app import schemas from app.chain.dashboard import DashboardChain from app.chain.storage import StorageChain from app.core.security import verify_token, verify_apitoken from app.db import get_db from app.db.models.transferhistory import TransferHistory from app.helper.directory import DirectoryHelper from app.scheduler import Scheduler from app.utils.system import SystemUtils router = APIRouter() @router.get("/statistic", summary="媒体数量统计", response_model=schemas.Statistic) def statistic(name: Optional[str] = None, _: schemas.TokenPayload = Depends(verify_token)) -> Any: """ 查询媒体数量统计信息 """ media_statistics: Optional[List[schemas.Statistic]] = DashboardChain().media_statistic(name) if media_statistics: # 汇总各媒体库统计信息 ret_statistic = schemas.Statistic() has_episode_count = False for media_statistic in media_statistics: ret_statistic.movie_count += media_statistic.movie_count or 0 ret_statistic.tv_count += media_statistic.tv_count or 0 ret_statistic.user_count += media_statistic.user_count or 0 if media_statistic.episode_count is not None: ret_statistic.episode_count += media_statistic.episode_count or 0 has_episode_count = True if not has_episode_count: # 所有媒体服务都未提供剧集统计时,返回 None 供前端展示“未获取”。 ret_statistic.episode_count = None return ret_statistic else: return schemas.Statistic() @router.get("/statistic2", summary="媒体数量统计(API_TOKEN)", response_model=schemas.Statistic) def statistic2(_: Annotated[str, Depends(verify_apitoken)]) -> Any: """ 查询媒体数量统计信息 API_TOKEN认证(?token=xxx) """ return statistic() @router.get("/storage", summary="本地存储空间", response_model=schemas.Storage) def storage(_: schemas.TokenPayload = Depends(verify_token)) -> Any: """ 查询本地存储空间信息 """ total, available = 0, 0 dirs = DirectoryHelper().get_dirs() if not dirs: return schemas.Storage(total_storage=total, used_storage=total - available) storages = set([d.library_storage for d in dirs if d.library_storage]) for _storage in storages: _usage = StorageChain().storage_usage(_storage) if _usage: total += _usage.total available += _usage.available return schemas.Storage( total_storage=total, used_storage=total - available ) @router.get("/storage2", summary="本地存储空间(API_TOKEN)", response_model=schemas.Storage) def storage2(_: Annotated[str, Depends(verify_apitoken)]) -> Any: """ 查询本地存储空间信息 API_TOKEN认证(?token=xxx) """ return storage() @router.get("/processes", summary="进程信息", response_model=List[schemas.ProcessInfo]) def processes(_: schemas.TokenPayload = Depends(verify_token)) -> Any: """ 查询进程信息 """ return SystemUtils.processes() @router.get("/downloader", summary="下载器信息", response_model=schemas.DownloaderInfo) def downloader(name: Optional[str] = None, _: schemas.TokenPayload = Depends(verify_token)) -> Any: """ 查询下载器信息 """ # 下载目录空间 download_dirs = DirectoryHelper().get_local_download_dirs() _, free_space = SystemUtils.space_usage([Path(d.download_path) for d in download_dirs]) # 下载器信息 downloader_info = schemas.DownloaderInfo() transfer_infos = DashboardChain().downloader_info(name) if transfer_infos: for transfer_info in transfer_infos: downloader_info.download_speed += transfer_info.download_speed downloader_info.upload_speed += transfer_info.upload_speed downloader_info.download_size += transfer_info.download_size downloader_info.upload_size += transfer_info.upload_size downloader_info.free_space = free_space return downloader_info @router.get("/downloader2", summary="下载器信息(API_TOKEN)", response_model=schemas.DownloaderInfo) def downloader2(_: Annotated[str, Depends(verify_apitoken)]) -> Any: """ 查询下载器信息 API_TOKEN认证(?token=xxx) """ return downloader() @router.get("/schedule", summary="后台服务", response_model=List[schemas.ScheduleInfo]) async def schedule(_: schemas.TokenPayload = Depends(verify_token)) -> Any: """ 查询后台服务信息 """ return Scheduler().list() @router.get("/schedule2", summary="后台服务(API_TOKEN)", response_model=List[schemas.ScheduleInfo]) async def schedule2(_: Annotated[str, Depends(verify_apitoken)]) -> Any: """ 查询下载器信息 API_TOKEN认证(?token=xxx) """ return await schedule() @router.get("/transfer", summary="文件整理统计", response_model=List[int]) async def transfer(days: Optional[int] = 7, db: Session = Depends(get_db), _: schemas.TokenPayload = Depends(verify_token)) -> Any: """ 查询文件整理统计信息 """ transfer_stat = await TransferHistory.async_statistic(db, days) return [stat[1] for stat in transfer_stat] @router.get("/cpu", summary="获取当前CPU使用率", response_model=float) def cpu(_: schemas.TokenPayload = Depends(verify_token)) -> Any: """ 获取当前CPU使用率 """ return SystemUtils.cpu_usage() @router.get("/cpu2", summary="获取当前CPU使用率(API_TOKEN)", response_model=float) def cpu2(_: Annotated[str, Depends(verify_apitoken)]) -> Any: """ 获取当前CPU使用率 API_TOKEN认证(?token=xxx) """ return cpu() @router.get("/memory", summary="获取当前内存使用量和使用率", response_model=List[int]) def memory(_: schemas.TokenPayload = Depends(verify_token)) -> Any: """ 获取当前内存使用率 """ return SystemUtils.memory_usage() @router.get("/memory2", summary="获取当前内存使用量和使用率(API_TOKEN)", response_model=List[int]) def memory2(_: Annotated[str, Depends(verify_apitoken)]) -> Any: """ 获取当前内存使用率 API_TOKEN认证(?token=xxx) """ return memory() @router.get("/network", summary="获取当前网络流量", response_model=List[int]) def network(_: schemas.TokenPayload = Depends(verify_token)) -> Any: """ 获取当前网络流量(上行和下行流量,单位:bytes/s) """ return SystemUtils.network_usage() @router.get("/network2", summary="获取当前网络流量(API_TOKEN)", response_model=List[int]) def network2(_: Annotated[str, Depends(verify_apitoken)]) -> Any: """ 获取当前网络流量 API_TOKEN认证(?token=xxx) """ return network() ================================================ FILE: app/api/endpoints/discover.py ================================================ from typing import Any, List, Optional from fastapi import APIRouter, Depends from app import schemas from app.chain.bangumi import BangumiChain from app.chain.douban import DoubanChain from app.chain.tmdb import TmdbChain from app.core.event import eventmanager from app.core.security import verify_token from app.schemas import DiscoverSourceEventData from app.schemas.types import ChainEventType, MediaType router = APIRouter() @router.get("/source", summary="获取探索数据源", response_model=List[schemas.DiscoverMediaSource]) def source(_: schemas.TokenPayload = Depends(verify_token)) -> Any: """ 获取探索数据源 """ # 广播事件,请示额外的探索数据源支持 event_data = DiscoverSourceEventData() event = eventmanager.send_event(ChainEventType.DiscoverSource, event_data) # 使用事件返回的上下文数据 if event and event.event_data: event_data: DiscoverSourceEventData = event.event_data if event_data.extra_sources: return event_data.extra_sources return [] @router.get("/bangumi", summary="探索Bangumi", response_model=List[schemas.MediaInfo]) async def bangumi(type: Optional[int] = 2, cat: Optional[int] = None, sort: Optional[str] = 'rank', year: Optional[str] = None, page: Optional[int] = 1, count: Optional[int] = 30, _: schemas.TokenPayload = Depends(verify_token)) -> Any: """ 探索Bangumi """ medias = await BangumiChain().async_discover(type=type, cat=cat, sort=sort, year=year, limit=count, offset=(page - 1) * count) if medias: return [media.to_dict() for media in medias] return [] @router.get("/douban_movies", summary="探索豆瓣电影", response_model=List[schemas.MediaInfo]) async def douban_movies(sort: Optional[str] = "R", tags: Optional[str] = "", page: Optional[int] = 1, count: Optional[int] = 30, _: schemas.TokenPayload = Depends(verify_token)) -> Any: """ 浏览豆瓣电影信息 """ movies = await DoubanChain().async_douban_discover(mtype=MediaType.MOVIE, sort=sort, tags=tags, page=page, count=count) return [media.to_dict() for media in movies] if movies else [] @router.get("/douban_tvs", summary="探索豆瓣剧集", response_model=List[schemas.MediaInfo]) async def douban_tvs(sort: Optional[str] = "R", tags: Optional[str] = "", page: Optional[int] = 1, count: Optional[int] = 30, _: schemas.TokenPayload = Depends(verify_token)) -> Any: """ 浏览豆瓣剧集信息 """ tvs = await DoubanChain().async_douban_discover(mtype=MediaType.TV, sort=sort, tags=tags, page=page, count=count) return [media.to_dict() for media in tvs] if tvs else [] @router.get("/tmdb_movies", summary="探索TMDB电影", response_model=List[schemas.MediaInfo]) async def tmdb_movies(sort_by: Optional[str] = "popularity.desc", with_genres: Optional[str] = "", with_original_language: Optional[str] = "", with_keywords: Optional[str] = "", with_watch_providers: Optional[str] = "", vote_average: Optional[float] = 0.0, vote_count: Optional[int] = 0, release_date: Optional[str] = "", page: Optional[int] = 1, _: schemas.TokenPayload = Depends(verify_token)) -> Any: """ 浏览TMDB电影信息 """ movies = await TmdbChain().async_tmdb_discover(mtype=MediaType.MOVIE, sort_by=sort_by, with_genres=with_genres, with_original_language=with_original_language, with_keywords=with_keywords, with_watch_providers=with_watch_providers, vote_average=vote_average, vote_count=vote_count, release_date=release_date, page=page) return [movie.to_dict() for movie in movies] if movies else [] @router.get("/tmdb_tvs", summary="探索TMDB剧集", response_model=List[schemas.MediaInfo]) async def tmdb_tvs(sort_by: Optional[str] = "popularity.desc", with_genres: Optional[str] = "", with_original_language: Optional[str] = "", with_keywords: Optional[str] = "", with_watch_providers: Optional[str] = "", vote_average: Optional[float] = 0.0, vote_count: Optional[int] = 0, release_date: Optional[str] = "", page: Optional[int] = 1, _: schemas.TokenPayload = Depends(verify_token)) -> Any: """ 浏览TMDB剧集信息 """ tvs = await TmdbChain().async_tmdb_discover(mtype=MediaType.TV, sort_by=sort_by, with_genres=with_genres, with_original_language=with_original_language, with_keywords=with_keywords, with_watch_providers=with_watch_providers, vote_average=vote_average, vote_count=vote_count, release_date=release_date, page=page) return [tv.to_dict() for tv in tvs] if tvs else [] ================================================ FILE: app/api/endpoints/douban.py ================================================ from typing import Any, List, Optional from fastapi import APIRouter, Depends from app import schemas from app.chain.douban import DoubanChain from app.core.context import MediaInfo from app.core.security import verify_token from app.schemas import MediaType router = APIRouter() @router.get("/person/{person_id}", summary="人物详情", response_model=schemas.MediaPerson) async def douban_person(person_id: int, _: schemas.TokenPayload = Depends(verify_token)) -> Any: """ 根据人物ID查询人物详情 """ return await DoubanChain().async_person_detail(person_id=person_id) @router.get("/person/credits/{person_id}", summary="人物参演作品", response_model=List[schemas.MediaInfo]) async def douban_person_credits(person_id: int, page: Optional[int] = 1, _: schemas.TokenPayload = Depends(verify_token)) -> Any: """ 根据人物ID查询人物参演作品 """ medias = await DoubanChain().async_person_credits(person_id=person_id, page=page) if medias: return [media.to_dict() for media in medias] return [] @router.get("/credits/{doubanid}/{type_name}", summary="豆瓣演员阵容", response_model=List[schemas.MediaPerson]) async def douban_credits(doubanid: str, type_name: str, _: schemas.TokenPayload = Depends(verify_token)) -> Any: """ 根据豆瓣ID查询演员阵容,type_name: 电影/电视剧 """ mediatype = MediaType(type_name) if mediatype == MediaType.MOVIE: return await DoubanChain().async_movie_credits(doubanid=doubanid) elif mediatype == MediaType.TV: return await DoubanChain().async_tv_credits(doubanid=doubanid) return [] @router.get("/recommend/{doubanid}/{type_name}", summary="豆瓣推荐电影/电视剧", response_model=List[schemas.MediaInfo]) async def douban_recommend(doubanid: str, type_name: str, _: schemas.TokenPayload = Depends(verify_token)) -> Any: """ 根据豆瓣ID查询推荐电影/电视剧,type_name: 电影/电视剧 """ mediatype = MediaType(type_name) if mediatype == MediaType.MOVIE: medias = await DoubanChain().async_movie_recommend(doubanid=doubanid) elif mediatype == MediaType.TV: medias = await DoubanChain().async_tv_recommend(doubanid=doubanid) else: return [] if medias: return [media.to_dict() for media in medias] return [] @router.get("/{doubanid}", summary="查询豆瓣详情", response_model=schemas.MediaInfo) async def douban_info(doubanid: str, _: schemas.TokenPayload = Depends(verify_token)) -> Any: """ 根据豆瓣ID查询豆瓣媒体信息 """ doubaninfo = await DoubanChain().async_douban_info(doubanid=doubanid) if doubaninfo: return MediaInfo(douban_info=doubaninfo).to_dict() else: return schemas.MediaInfo() ================================================ FILE: app/api/endpoints/download.py ================================================ from typing import Any, List, Annotated, Optional from fastapi import APIRouter, Depends, Body from app import schemas from app.chain.download import DownloadChain from app.chain.media import MediaChain from app.core.config import settings from app.core.context import MediaInfo, Context, TorrentInfo from app.core.event import eventmanager from app.core.metainfo import MetaInfo from app.core.security import verify_token from app.db.models.user import User from app.db.systemconfig_oper import SystemConfigOper from app.db.user_oper import get_current_active_user from app.schemas.types import ChainEventType, SystemConfigKey router = APIRouter() @router.get("/", summary="正在下载", response_model=List[schemas.DownloadingTorrent]) def current( name: Optional[str] = None, _: schemas.TokenPayload = Depends(verify_token)) -> Any: """ 查询正在下载的任务 """ return DownloadChain().downloading(name) @router.post("/", summary="添加下载(含媒体信息)", response_model=schemas.Response) def download( media_in: schemas.MediaInfo, torrent_in: schemas.TorrentInfo, downloader: Annotated[str | None, Body()] = None, save_path: Annotated[str | None, Body()] = None, current_user: User = Depends(get_current_active_user)) -> Any: """ 添加下载任务(含媒体信息) """ # 元数据 metainfo = MetaInfo(title=torrent_in.title, subtitle=torrent_in.description) # 媒体信息 mediainfo = MediaInfo() mediainfo.from_dict(media_in.model_dump()) # 种子信息 torrentinfo = TorrentInfo() torrentinfo.from_dict(torrent_in.model_dump()) # 手动下载始终使用选择的下载器 torrentinfo.site_downloader = downloader # 上下文 context = Context( meta_info=metainfo, media_info=mediainfo, torrent_info=torrentinfo ) did = DownloadChain().download_single(context=context, username=current_user.name, save_path=save_path, source="Manual") if not did: return schemas.Response(success=False, message="任务添加失败") return schemas.Response(success=True, data={ "download_id": did }) @router.post("/add", summary="添加下载(不含媒体信息)", response_model=schemas.Response) def add( torrent_in: schemas.TorrentInfo, tmdbid: Annotated[int | None, Body()] = None, doubanid: Annotated[str | None, Body()] = None, downloader: Annotated[str | None, Body()] = None, # 保存路径, 支持:, 如rclone:/MP, smb:/server/share/Movies等 save_path: Annotated[str | None, Body()] = None, current_user: User = Depends(get_current_active_user)) -> Any: """ 添加下载任务(不含媒体信息) """ # 元数据 metainfo = MetaInfo(title=torrent_in.title, subtitle=torrent_in.description) # 媒体信息 mediainfo = MediaChain().select_recognize_source( log_name=torrent_in.title, log_context=torrent_in.title, native_fn=lambda: MediaChain().recognize_media(meta=metainfo, tmdbid=tmdbid, doubanid=doubanid), plugin_fn=lambda: MediaChain().recognize_help(title=torrent_in.title, org_meta=metainfo) ) if not mediainfo: return schemas.Response(success=False, message="无法识别媒体信息") # 种子信息 torrentinfo = TorrentInfo() torrentinfo.from_dict(torrent_in.model_dump()) # 上下文 context = Context( meta_info=metainfo, media_info=mediainfo, torrent_info=torrentinfo ) did = DownloadChain().download_single(context=context, username=current_user.name, downloader=downloader, save_path=save_path, source="Manual") if not did: return schemas.Response(success=False, message="任务添加失败") return schemas.Response(success=True, data={ "download_id": did }) @router.get("/start/{hashString}", summary="开始任务", response_model=schemas.Response) def start( hashString: str, name: Optional[str] = None, _: schemas.TokenPayload = Depends(verify_token)) -> Any: """ 开如下载任务 """ ret = DownloadChain().set_downloading(hashString, "start", name=name) return schemas.Response(success=True if ret else False) @router.get("/stop/{hashString}", summary="暂停任务", response_model=schemas.Response) def stop(hashString: str, name: Optional[str] = None, _: schemas.TokenPayload = Depends(verify_token)) -> Any: """ 暂停下载任务 """ ret = DownloadChain().set_downloading(hashString, "stop", name=name) return schemas.Response(success=True if ret else False) @router.get("/clients", summary="查询可用下载器", response_model=List[dict]) async def clients(_: schemas.TokenPayload = Depends(verify_token)) -> Any: """ 查询可用下载器 """ downloaders: List[dict] = SystemConfigOper().get(SystemConfigKey.Downloaders) if downloaders: return [{"name": d.get("name"), "type": d.get("type")} for d in downloaders if d.get("enabled")] return [] @router.delete("/{hashString}", summary="删除下载任务", response_model=schemas.Response) def delete(hashString: str, name: Optional[str] = None, _: schemas.TokenPayload = Depends(verify_token)) -> Any: """ 删除下载任务 """ ret = DownloadChain().remove_downloading(hashString, name=name) return schemas.Response(success=True if ret else False) ================================================ FILE: app/api/endpoints/history.py ================================================ from typing import List, Any, Optional import jieba from fastapi import APIRouter, Depends from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import Session from pathlib import Path from app import schemas from app.chain.storage import StorageChain from app.core.event import eventmanager from app.core.security import verify_token from app.db import get_async_db, get_db from app.db.models import User from app.db.models.downloadhistory import DownloadHistory, DownloadFiles from app.db.models.transferhistory import TransferHistory from app.db.user_oper import get_current_active_superuser_async, get_current_active_superuser from app.schemas.types import EventType router = APIRouter() @router.get("/download", summary="查询下载历史记录", response_model=List[schemas.DownloadHistory]) async def download_history(page: Optional[int] = 1, count: Optional[int] = 30, db: AsyncSession = Depends(get_async_db), _: schemas.TokenPayload = Depends(verify_token)) -> Any: """ 查询下载历史记录 """ return await DownloadHistory.async_list_by_page(db, page, count) @router.delete("/download", summary="删除下载历史记录", response_model=schemas.Response) async def delete_download_history(history_in: schemas.DownloadHistory, db: AsyncSession = Depends(get_async_db), _: schemas.TokenPayload = Depends(verify_token)) -> Any: """ 删除下载历史记录 """ await DownloadHistory.async_delete(db, history_in.id) return schemas.Response(success=True) @router.get("/transfer", summary="查询整理记录", response_model=schemas.Response) async def transfer_history(title: Optional[str] = None, page: Optional[int] = 1, count: Optional[int] = 30, status: Optional[bool] = None, db: AsyncSession = Depends(get_async_db), _: schemas.TokenPayload = Depends(verify_token)) -> Any: """ 查询整理记录 """ if title == "失败": title = None status = False elif title == "成功": title = None status = True if title: words = jieba.cut(title, HMM=False) title = "%".join(words) total = await TransferHistory.async_count_by_title(db, title=title, status=status) result = await TransferHistory.async_list_by_title(db, title=title, page=page, count=count, status=status) else: result = await TransferHistory.async_list_by_page(db, page=page, count=count, status=status) total = await TransferHistory.async_count(db, status=status) return schemas.Response(success=True, data={ "list": [item.to_dict() for item in result], "total": total, }) @router.delete("/transfer", summary="删除整理记录", response_model=schemas.Response) def delete_transfer_history(history_in: schemas.TransferHistory, deletesrc: Optional[bool] = False, deletedest: Optional[bool] = False, db: Session = Depends(get_db), _: User = Depends(get_current_active_superuser)) -> Any: """ 删除整理记录 """ history: TransferHistory = TransferHistory.get(db, history_in.id) if not history: return schemas.Response(success=False, message="记录不存在") # 册除媒体库文件 if deletedest and history.dest_fileitem: dest_fileitem = schemas.FileItem(**history.dest_fileitem) StorageChain().delete_media_file(dest_fileitem) # 删除源文件 if deletesrc and history.src_fileitem: src_fileitem = schemas.FileItem(**history.src_fileitem) state = StorageChain().delete_media_file(src_fileitem) if not state: return schemas.Response(success=False, message=f"{src_fileitem.path} 删除失败") # 删除下载记录中关联的文件 DownloadFiles.delete_by_fullpath(db, Path(src_fileitem.path).as_posix()) # 发送事件 eventmanager.send_event( EventType.DownloadFileDeleted, { "src": history.src, "hash": history.download_hash } ) # 删除记录 TransferHistory.delete(db, history_in.id) return schemas.Response(success=True) @router.get("/empty/transfer", summary="清空整理记录", response_model=schemas.Response) async def empty_transfer_history(db: AsyncSession = Depends(get_async_db), _: User = Depends(get_current_active_superuser_async)) -> Any: """ 清空整理记录 """ await TransferHistory.async_truncate(db) return schemas.Response(success=True) ================================================ FILE: app/api/endpoints/login.py ================================================ from datetime import timedelta from typing import Any, List, Annotated from fastapi import APIRouter, Depends, Form, HTTPException from fastapi.security import OAuth2PasswordRequestForm from app import schemas from app.chain.user import UserChain from app.core import security from app.core.config import settings from app.db.systemconfig_oper import SystemConfigOper from app.helper.sites import SitesHelper # noqa from app.helper.image import WallpaperHelper from app.schemas.types import SystemConfigKey router = APIRouter() @router.post("/access-token", summary="获取token", response_model=schemas.Token) def login_access_token( form_data: Annotated[OAuth2PasswordRequestForm, Depends()], otp_password: Annotated[str | None, Form()] = None ) -> Any: """ 获取认证Token """ success, user_or_message = UserChain().user_authenticate(username=form_data.username, password=form_data.password, mfa_code=otp_password) if not success: # 如果是需要MFA验证,返回特殊标识 if user_or_message == "MFA_REQUIRED": raise HTTPException( status_code=401, detail="需要双重验证,请提供验证码或使用通行密钥", headers={"X-MFA-Required": "true"} ) raise HTTPException(status_code=401, detail="用户名或密码错误") # 用户等级 level = SitesHelper().auth_level # 是否显示配置向导 show_wizard = not SystemConfigOper().get(SystemConfigKey.SetupWizardState) and not settings.ADVANCED_MODE return schemas.Token( access_token=security.create_access_token( userid=user_or_message.id, username=user_or_message.name, super_user=user_or_message.is_superuser, expires_delta=timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES), level=level ), token_type="bearer", super_user=user_or_message.is_superuser, user_id=user_or_message.id, user_name=user_or_message.name, avatar=user_or_message.avatar, level=level, permissions=user_or_message.permissions or {}, wizard=show_wizard ) @router.get("/wallpaper", summary="登录页面电影海报", response_model=schemas.Response) def wallpaper() -> Any: """ 获取登录页面电影海报 """ url = WallpaperHelper().get_wallpaper() if url: return schemas.Response( success=True, message=url ) return schemas.Response(success=False) @router.get("/wallpapers", summary="登录页面电影海报列表", response_model=List[str]) def wallpapers() -> Any: """ 获取登录页面电影海报 """ return WallpaperHelper().get_wallpapers() ================================================ FILE: app/api/endpoints/mcp.py ================================================ from typing import List, Any, Dict, Annotated, Union from fastapi import APIRouter, Depends, HTTPException, Request from fastapi.responses import JSONResponse, Response from app import schemas from app.agent.tools.manager import moviepilot_tool_manager from app.core.security import verify_apikey from app.log import logger # 导入版本号 try: from version import APP_VERSION except ImportError: APP_VERSION = "unknown" router = APIRouter() # MCP 协议版本 MCP_PROTOCOL_VERSIONS = ["2025-11-25", "2025-06-18", "2024-11-05"] MCP_PROTOCOL_VERSION = MCP_PROTOCOL_VERSIONS[0] # 默认使用最新版本 MCP_HIDDEN_TOOLS = {"execute_command", "search_web"} def list_exposed_tools(): """ 获取 MCP 可见工具列表 """ return [ tool for tool in moviepilot_tool_manager.list_tools() if tool.name not in MCP_HIDDEN_TOOLS ] def create_jsonrpc_response(request_id: Union[str, int, None], result: Any) -> Dict[str, Any]: """ 创建 JSON-RPC 成功响应 """ response = { "jsonrpc": "2.0", "id": request_id, "result": result } return response def create_jsonrpc_error(request_id: Union[str, int, None], code: int, message: str, data: Any = None) -> Dict[ str, Any]: """ 创建 JSON-RPC 错误响应 """ error = { "jsonrpc": "2.0", "id": request_id, "error": { "code": code, "message": message } } if data is not None: error["error"]["data"] = data return error @router.post("", summary="MCP JSON-RPC 端点", response_model=None) async def mcp_jsonrpc( request: Request, _: Annotated[str, Depends(verify_apikey)] = None ) -> Union[JSONResponse, Response]: """ MCP 标准 JSON-RPC 2.0 端点 处理所有 MCP 协议消息(初始化、工具列表、工具调用等) """ try: body = await request.json() except Exception as e: logger.error(f"解析请求体失败: {e}") return JSONResponse( status_code=400, content=create_jsonrpc_error(None, -32700, "Parse error", str(e)) ) # 验证 JSON-RPC 格式 if not isinstance(body, dict) or body.get("jsonrpc") != "2.0": return JSONResponse( status_code=400, content=create_jsonrpc_error(body.get("id"), -32600, "Invalid Request") ) method = body.get("method") params = body.get("params", {}) request_id = body.get("id") # 如果有 id,则为请求;没有 id 则为通知 is_notification = request_id is None try: # 处理初始化请求 if method == "initialize": result = await handle_initialize(params) return JSONResponse(content=create_jsonrpc_response(request_id, result)) # 处理已初始化通知 elif method == "notifications/initialized": if is_notification: return Response(status_code=204) else: return JSONResponse( status_code=400, content={"error": "initialized must be a notification"} ) # 处理工具列表请求 if method == "tools/list": result = await handle_tools_list() return JSONResponse(content=create_jsonrpc_response(request_id, result)) # 处理工具调用请求 elif method == "tools/call": result = await handle_tools_call(params) return JSONResponse(content=create_jsonrpc_response(request_id, result)) # 处理 ping 请求 elif method == "ping": return JSONResponse(content=create_jsonrpc_response(request_id, {})) # 未知方法 else: return JSONResponse( content=create_jsonrpc_error(request_id, -32601, f"Method not found: {method}") ) except ValueError as e: logger.warning(f"MCP 请求参数错误: {e}") return JSONResponse( status_code=400, content=create_jsonrpc_error(request_id, -32602, "Invalid params", str(e)) ) except Exception as e: logger.error(f"处理 MCP 请求失败: {e}", exc_info=True) return JSONResponse( status_code=500, content=create_jsonrpc_error(request_id, -32603, "Internal error", str(e)) ) async def handle_initialize(params: Dict[str, Any]) -> Dict[str, Any]: """ 处理初始化请求 """ protocol_version = params.get("protocolVersion") client_info = params.get("clientInfo", {}) logger.info(f"MCP 初始化请求: 客户端={client_info.get('name')}, 协议版本={protocol_version}") # 版本协商:选择客户端和服务器都支持的版本 negotiated_version = MCP_PROTOCOL_VERSION if protocol_version in MCP_PROTOCOL_VERSIONS: # 客户端版本在支持列表中,使用客户端版本 negotiated_version = protocol_version logger.info(f"使用客户端协议版本: {negotiated_version}") else: # 客户端版本不支持,使用服务器默认版本 logger.warning(f"协议版本不匹配: 客户端={protocol_version}, 使用服务器版本={negotiated_version}") return { "protocolVersion": negotiated_version, "capabilities": { "tools": { "listChanged": False # 暂不支持工具列表变更通知 }, "logging": {} }, "serverInfo": { "name": "MoviePilot", "version": APP_VERSION, "description": "MoviePilot MCP Server - 电影自动化管理工具", }, "instructions": "MoviePilot MCP 服务器,提供媒体管理、订阅、下载等工具。" } async def handle_tools_list() -> Dict[str, Any]: """ 处理工具列表请求 """ tools = list_exposed_tools() # 转换为 MCP 工具格式 mcp_tools = [] for tool in tools: mcp_tool = { "name": tool.name, "description": tool.description, "inputSchema": tool.input_schema } mcp_tools.append(mcp_tool) return { "tools": mcp_tools } async def handle_tools_call(params: Dict[str, Any]) -> Dict[str, Any]: """ 处理工具调用请求 """ tool_name = params.get("name") arguments = params.get("arguments", {}) if not tool_name: raise ValueError("Missing tool name") try: if tool_name in MCP_HIDDEN_TOOLS: raise ValueError(f"工具 '{tool_name}' 未找到") result_text = await moviepilot_tool_manager.call_tool(tool_name, arguments) return { "content": [ { "type": "text", "text": result_text } ] } except Exception as e: logger.error(f"工具调用失败: {tool_name}, 错误: {e}", exc_info=True) return { "content": [ { "type": "text", "text": f"错误: {str(e)}" } ], "isError": True } @router.delete("", summary="终止 MCP 会话", response_model=None) async def delete_mcp_session( _: Annotated[str, Depends(verify_apikey)] = None ) -> Union[JSONResponse, Response]: """ 终止 MCP 会话(无状态模式下仅返回成功) """ return Response(status_code=204) # ==================== 兼容的 RESTful API 端点 ==================== @router.get("/tools", summary="列出所有可用工具", response_model=List[Dict[str, Any]]) async def list_tools( _: Annotated[str, Depends(verify_apikey)] ) -> Any: """ 获取所有可用的工具列表 返回每个工具的名称、描述和参数定义 """ try: # 获取所有工具定义 tools = list_exposed_tools() # 转换为字典格式 tools_list = [] for tool in tools: tool_dict = { "name": tool.name, "description": tool.description, "inputSchema": tool.input_schema } tools_list.append(tool_dict) return tools_list except Exception as e: logger.error(f"获取工具列表失败: {e}", exc_info=True) raise HTTPException(status_code=500, detail=f"获取工具列表失败: {str(e)}") @router.post("/tools/call", summary="调用工具", response_model=schemas.ToolCallResponse) async def call_tool( request: schemas.ToolCallRequest, _: Annotated[str, Depends(verify_apikey)] = None ) -> Any: """ 调用指定的工具 Returns: 工具执行结果 """ try: if request.tool_name in MCP_HIDDEN_TOOLS: raise ValueError(f"工具 '{request.tool_name}' 未找到") result_text = await moviepilot_tool_manager.call_tool(request.tool_name, request.arguments) return schemas.ToolCallResponse( success=True, result=result_text ) except Exception as e: logger.error(f"调用工具 {request.tool_name} 失败: {e}", exc_info=True) return schemas.ToolCallResponse( success=False, error=f"调用工具失败: {str(e)}" ) @router.get("/tools/{tool_name}", summary="获取工具详情", response_model=Dict[str, Any]) async def get_tool_info( tool_name: str, _: Annotated[str, Depends(verify_apikey)] ) -> Any: """ 获取指定工具的详细信息 Returns: 工具的详细信息,包括名称、描述和参数定义 """ try: # 获取所有工具 tools = list_exposed_tools() # 查找指定工具 for tool in tools: if tool.name == tool_name: return { "name": tool.name, "description": tool.description, "inputSchema": tool.input_schema } raise HTTPException(status_code=404, detail=f"工具 '{tool_name}' 未找到") except HTTPException: raise except Exception as e: logger.error(f"获取工具信息失败: {e}", exc_info=True) raise HTTPException(status_code=500, detail=f"获取工具信息失败: {str(e)}") @router.get("/tools/{tool_name}/schema", summary="获取工具参数Schema", response_model=Dict[str, Any]) async def get_tool_schema( tool_name: str, _: Annotated[str, Depends(verify_apikey)] ) -> Any: """ 获取指定工具的参数Schema(JSON Schema格式) Returns: 工具的JSON Schema定义 """ try: # 获取所有工具 tools = list_exposed_tools() # 查找指定工具 for tool in tools: if tool.name == tool_name: return tool.input_schema raise HTTPException(status_code=404, detail=f"工具 '{tool_name}' 未找到") except HTTPException: raise except Exception as e: logger.error(f"获取工具Schema失败: {e}", exc_info=True) raise HTTPException(status_code=500, detail=f"获取工具Schema失败: {str(e)}") ================================================ FILE: app/api/endpoints/media.py ================================================ from pathlib import Path from typing import List, Any, Union, Annotated, Optional from fastapi import APIRouter, Depends from app import schemas from app.chain.media import MediaChain from app.chain.tmdb import TmdbChain from app.core.config import settings from app.core.context import Context from app.core.event import eventmanager from app.core.metainfo import MetaInfo, MetaInfoPath from app.core.security import verify_token, verify_apitoken from app.db.models import User from app.db.user_oper import get_current_active_user, get_current_active_superuser from app.schemas import MediaType, MediaRecognizeConvertEventData from app.schemas.category import CategoryConfig from app.schemas.types import ChainEventType router = APIRouter() @router.get("/recognize", summary="识别媒体信息(种子)", response_model=schemas.Context) async def recognize(title: str, subtitle: Optional[str] = None, _: schemas.TokenPayload = Depends(verify_token)) -> Any: """ 根据标题、副标题识别媒体信息 """ # 识别媒体信息 metainfo = MetaInfo(title, subtitle) mediainfo = await MediaChain().async_recognize_by_meta(metainfo) if mediainfo: return Context(meta_info=metainfo, media_info=mediainfo).to_dict() return schemas.Context() @router.get("/recognize2", summary="识别种子媒体信息(API_TOKEN)", response_model=schemas.Context) async def recognize2(_: Annotated[str, Depends(verify_apitoken)], title: str, subtitle: Optional[str] = None ) -> Any: """ 根据标题、副标题识别媒体信息 API_TOKEN认证(?token=xxx) """ # 识别媒体信息 return await recognize(title, subtitle) @router.get("/recognize_file", summary="识别媒体信息(文件)", response_model=schemas.Context) async def recognize_file(path: str, _: schemas.TokenPayload = Depends(verify_token)) -> Any: """ 根据文件路径识别媒体信息 """ # 识别媒体信息 context = await MediaChain().async_recognize_by_path(path) if context: return context.to_dict() return schemas.Context() @router.get("/recognize_file2", summary="识别文件媒体信息(API_TOKEN)", response_model=schemas.Context) async def recognize_file2(path: str, _: Annotated[str, Depends(verify_apitoken)]) -> Any: """ 根据文件路径识别媒体信息 API_TOKEN认证(?token=xxx) """ # 识别媒体信息 return await recognize_file(path) @router.get("/search", summary="搜索媒体/人物信息", response_model=List[dict]) async def search(title: str, type: Optional[str] = "media", page: int = 1, count: int = 8, _: schemas.TokenPayload = Depends(verify_token)) -> Any: """ 模糊搜索媒体/人物信息列表 media:媒体信息,person:人物信息 """ def __get_source(obj: Union[schemas.MediaInfo, schemas.MediaPerson, dict]): """ 获取对象属性 """ if isinstance(obj, dict): return obj.get("source") return obj.source media_chain = MediaChain() if type == "media": _, medias = await media_chain.async_search(title=title) result = [media.to_dict() for media in medias] if medias else [] elif type == "collection": collections = await media_chain.async_search_collections(name=title) result = [collection.to_dict() for collection in collections] if collections else [] else: # person persons = await media_chain.async_search_persons(name=title) result = [person.model_dump() for person in persons] if persons else [] if not result: return [] # 排序和分页 setting_order = settings.SEARCH_SOURCE.split(',') if settings.SEARCH_SOURCE else [] sort_order = {source: index for index, source in enumerate(setting_order)} sorted_result = sorted(result, key=lambda x: sort_order.get(__get_source(x), 4)) return sorted_result[(page - 1) * count:page * count] @router.post("/scrape/{storage}", summary="刮削媒体信息", response_model=schemas.Response) def scrape(fileitem: schemas.FileItem, storage: Optional[str] = "local", _: schemas.TokenPayload = Depends(verify_token)) -> Any: """ 刮削媒体信息 """ if not fileitem or not fileitem.path: return schemas.Response(success=False, message="刮削路径无效") chain = MediaChain() # 识别媒体信息 scrape_path = Path(fileitem.path) meta = MetaInfoPath(scrape_path) mediainfo = chain.recognize_by_meta(meta) if not mediainfo: return schemas.Response(success=False, message="刮削失败,无法识别媒体信息") if storage == "local": if not scrape_path.exists(): return schemas.Response(success=False, message="刮削路径不存在") # 手动刮削 (暂时使用同步版本,可以后续优化为异步) chain.scrape_metadata(fileitem=fileitem, meta=meta, mediainfo=mediainfo, overwrite=True) return schemas.Response(success=True, message=f"{fileitem.path} 刮削完成") @router.get("/category/config", summary="获取分类策略配置", response_model=schemas.Response) def get_category_config(_: User = Depends(get_current_active_user)): """ 获取分类策略配置 """ config = MediaChain().category_config() return schemas.Response(success=True, data=config.model_dump()) @router.post("/category/config", summary="保存分类策略配置", response_model=schemas.Response) def save_category_config(config: CategoryConfig, _: User = Depends(get_current_active_superuser)): """ 保存分类策略配置 """ if MediaChain().save_category_config(config): return schemas.Response(success=True, message="保存成功") else: return schemas.Response(success=False, message="保存失败") @router.get("/category", summary="查询自动分类配置", response_model=dict) async def category(_: schemas.TokenPayload = Depends(verify_token)) -> Any: """ 查询自动分类配置 """ return MediaChain().media_category() or {} @router.get("/group/seasons/{episode_group}", summary="查询剧集组季信息", response_model=List[schemas.MediaSeason]) async def group_seasons(episode_group: str, _: schemas.TokenPayload = Depends(verify_token)) -> Any: """ 查询剧集组季信息(themoviedb) """ return await TmdbChain().async_tmdb_group_seasons(group_id=episode_group) @router.get("/groups/{tmdbid}", summary="查询媒体剧集组", response_model=List[dict]) async def groups(tmdbid: int, _: schemas.TokenPayload = Depends(verify_token)) -> Any: """ 查询媒体剧集组列表(themoviedb) """ mediainfo = await MediaChain().async_recognize_media(tmdbid=tmdbid, mtype=MediaType.TV) if not mediainfo: return [] return mediainfo.episode_groups @router.get("/seasons", summary="查询媒体季信息", response_model=List[schemas.MediaSeason]) async def seasons(mediaid: Optional[str] = None, title: Optional[str] = None, year: str = None, season: int = None, _: schemas.TokenPayload = Depends(verify_token)) -> Any: """ 查询媒体季信息 """ if mediaid: if mediaid.startswith("tmdb:"): tmdbid = int(mediaid[5:]) seasons_info = await TmdbChain().async_tmdb_seasons(tmdbid=tmdbid) if seasons_info: if season is not None: return [sea for sea in seasons_info if sea.season_number == season] return seasons_info if title: meta = MetaInfo(title) if year: meta.year = year mediainfo = await MediaChain().async_recognize_media(meta, mtype=MediaType.TV) if mediainfo: if settings.RECOGNIZE_SOURCE == "themoviedb": seasons_info = await TmdbChain().async_tmdb_seasons(tmdbid=mediainfo.tmdb_id) if seasons_info: if season is not None: return [sea for sea in seasons_info if sea.season_number == season] return seasons_info else: sea = season if season is not None else 1 return [schemas.MediaSeason( season_number=sea, poster_path=mediainfo.poster_path, name=f"第 {sea} 季", air_date=mediainfo.release_date, overview=mediainfo.overview, vote_average=mediainfo.vote_average, episode_count=mediainfo.number_of_episodes )] return [] @router.get("/{mediaid}", summary="查询媒体详情", response_model=schemas.MediaInfo) async def detail(mediaid: str, type_name: str, title: Optional[str] = None, year: str = None, _: schemas.TokenPayload = Depends(verify_token)) -> Any: """ 根据媒体ID查询themoviedb或豆瓣媒体信息,type_name: 电影/电视剧 """ mtype = MediaType(type_name) mediainfo = None mediachain = MediaChain() if mediaid.startswith("tmdb:"): mediainfo = await mediachain.async_recognize_media(tmdbid=int(mediaid[5:]), mtype=mtype) elif mediaid.startswith("douban:"): mediainfo = await mediachain.async_recognize_media(doubanid=mediaid[7:], mtype=mtype) elif mediaid.startswith("bangumi:"): mediainfo = await mediachain.async_recognize_media(bangumiid=int(mediaid[8:]), mtype=mtype) else: # 广播事件解析媒体信息 event_data = MediaRecognizeConvertEventData( mediaid=mediaid, convert_type=settings.RECOGNIZE_SOURCE ) event = await eventmanager.async_send_event(ChainEventType.MediaRecognizeConvert, event_data) # 使用事件返回的上下文数据 if event and event.event_data and event.event_data.media_dict: event_data: MediaRecognizeConvertEventData = event.event_data new_id = event_data.media_dict.get("id") if event_data.convert_type == "themoviedb": mediainfo = await mediachain.async_recognize_media(tmdbid=new_id, mtype=mtype) elif event_data.convert_type == "douban": mediainfo = await mediachain.async_recognize_media(doubanid=new_id, mtype=mtype) elif title: # 使用名称识别兜底 meta = MetaInfo(title) if year: meta.year = year if mtype: meta.type = mtype mediainfo = await mediachain.async_recognize_media(meta=meta) # 识别 if mediainfo: await mediachain.async_obtain_images(mediainfo) return mediainfo.to_dict() return schemas.MediaInfo() ================================================ FILE: app/api/endpoints/mediaserver.py ================================================ from typing import Any, List, Dict, Optional from fastapi import APIRouter, Depends from sqlalchemy.ext.asyncio import AsyncSession from app import schemas from app.chain.download import DownloadChain from app.chain.mediaserver import MediaServerChain from app.core.context import MediaInfo from app.core.metainfo import MetaInfo from app.core.security import verify_token from app.db import get_async_db from app.db.mediaserver_oper import MediaServerOper from app.db.models import MediaServerItem from app.db.systemconfig_oper import SystemConfigOper from app.helper.mediaserver import MediaServerHelper from app.schemas import MediaType, NotExistMediaInfo from app.schemas.types import SystemConfigKey router = APIRouter() @router.get("/play/{itemid:path}", summary="在线播放") def play_item(itemid: str, _: schemas.TokenPayload = Depends(verify_token)) -> schemas.Response: """ 获取媒体服务器播放页面地址 """ if not itemid: return schemas.Response(success=False, message="参数错误") configs = MediaServerHelper().get_configs() if not configs: return schemas.Response(success=False, message="未配置媒体服务器") media_chain = MediaServerChain() for name in configs.keys(): item = media_chain.iteminfo(server=name, item_id=itemid) if item: play_url = media_chain.get_play_url(server=name, item_id=itemid) if play_url: return schemas.Response(success=True, data={ "url": play_url }) return schemas.Response(success=False, message="未找到播放地址") @router.get("/exists", summary="查询本地是否存在(数据库)", response_model=schemas.Response) async def exists_local(title: Optional[str] = None, year: Optional[str] = None, mtype: Optional[str] = None, tmdbid: Optional[int] = None, season: Optional[int] = None, db: AsyncSession = Depends(get_async_db), _: schemas.TokenPayload = Depends(verify_token)) -> Any: """ 判断本地是否存在 """ meta = MetaInfo(title) if season is None: season = meta.begin_season # 返回对象 ret_info = {} # 本地数据库是否存在 exist: MediaServerItem = await MediaServerOper(db).async_exists( title=meta.name, year=year, mtype=mtype, tmdbid=tmdbid, season=season ) if exist: ret_info = { "id": exist.item_id } return schemas.Response(success=True if exist else False, data={ "item": ret_info }) @router.post("/exists_remote", summary="查询已存在的剧集信息(媒体服务器)", response_model=Dict[int, list]) def exists(media_in: schemas.MediaInfo, _: schemas.TokenPayload = Depends(verify_token)) -> Any: """ 根据媒体信息查询媒体库已存在的剧集信息 """ # 转化为媒体信息对象 mediainfo = MediaInfo() mediainfo.from_dict(media_in.model_dump()) existsinfo: schemas.ExistMediaInfo = MediaServerChain().media_exists(mediainfo=mediainfo) if not existsinfo: return {} if media_in.season is not None: return { media_in.season: existsinfo.seasons.get(media_in.season) or [] } return existsinfo.seasons @router.post("/notexists", summary="查询媒体库缺失信息(媒体服务器)", response_model=List[schemas.NotExistMediaInfo]) def not_exists(media_in: schemas.MediaInfo, _: schemas.TokenPayload = Depends(verify_token)) -> Any: """ 根据媒体信息查询缺失电影/剧集 """ # 媒体信息 meta = MetaInfo(title=media_in.title) mtype = MediaType(media_in.type) if media_in.type else None if mtype: meta.type = mtype if media_in.season is not None: meta.begin_season = media_in.season meta.type = MediaType.TV if media_in.year: meta.year = media_in.year # 转化为媒体信息对象 mediainfo = MediaInfo() mediainfo.from_dict(media_in.model_dump()) exist_flag, no_exists = DownloadChain().get_no_exists_info(meta=meta, mediainfo=mediainfo) mediakey = mediainfo.tmdb_id or mediainfo.douban_id if mediainfo.type == MediaType.MOVIE: # 电影已存在时返回空列表,不存在时返回空对像列表 return [] if exist_flag else [NotExistMediaInfo()] elif no_exists and no_exists.get(mediakey): # 电视剧返回缺失的剧集 return list(no_exists.get(mediakey).values()) return [] @router.get("/latest", summary="最新入库条目", response_model=List[schemas.MediaServerPlayItem]) def latest(server: str, count: Optional[int] = 20, userinfo: schemas.TokenPayload = Depends(verify_token)) -> Any: """ 获取媒体服务器最新入库条目 """ return MediaServerChain().latest(server=server, count=count, username=userinfo.username) or [] @router.get("/playing", summary="正在播放条目", response_model=List[schemas.MediaServerPlayItem]) def playing(server: str, count: Optional[int] = 12, userinfo: schemas.TokenPayload = Depends(verify_token)) -> Any: """ 获取媒体服务器正在播放条目 """ return MediaServerChain().playing(server=server, count=count, username=userinfo.username) or [] @router.get("/library", summary="媒体库列表", response_model=List[schemas.MediaServerLibrary]) def library(server: str, hidden: Optional[bool] = False, userinfo: schemas.TokenPayload = Depends(verify_token)) -> Any: """ 获取媒体服务器媒体库列表 """ return MediaServerChain().librarys(server=server, username=userinfo.username, hidden=hidden) or [] @router.get("/clients", summary="查询可用媒体服务器", response_model=List[dict]) async def clients(_: schemas.TokenPayload = Depends(verify_token)) -> Any: """ 查询可用媒体服务器 """ mediaservers: List[dict] = SystemConfigOper().get(SystemConfigKey.MediaServers) if mediaservers: return [{"name": d.get("name"), "type": d.get("type")} for d in mediaservers if d.get("enabled")] return [] ================================================ FILE: app/api/endpoints/message.py ================================================ import json from typing import Union, Any, List, Optional from fastapi import APIRouter, BackgroundTasks, Depends, Request from pywebpush import WebPushException, webpush from sqlalchemy.ext.asyncio import AsyncSession from starlette.responses import PlainTextResponse from app import schemas from app.chain.message import MessageChain from app.core.config import settings, global_vars from app.core.security import verify_token, verify_apitoken from app.db import get_async_db from app.db.models import User from app.db.models.message import Message from app.db.user_oper import get_current_active_superuser from app.helper.service import ServiceConfigHelper from app.log import logger from app.modules.wechat.WXBizMsgCrypt3 import WXBizMsgCrypt from app.schemas.types import MessageChannel router = APIRouter() def start_message_chain(body: Any, form: Any, args: Any): """ 启动链式任务 """ MessageChain().process(body=body, form=form, args=args) @router.post("/", summary="接收用户消息", response_model=schemas.Response) async def user_message(background_tasks: BackgroundTasks, request: Request, _: schemas.TokenPayload = Depends(verify_apitoken)): """ 用户消息响应,配置请求中需要添加参数:token=API_TOKEN&source=消息配置名 """ body = await request.body() form = await request.form() args = request.query_params background_tasks.add_task(start_message_chain, body, form, args) return schemas.Response(success=True) @router.post("/web", summary="接收WEB消息", response_model=schemas.Response) def web_message(text: str, current_user: User = Depends(get_current_active_superuser)): """ WEB消息响应 """ MessageChain().handle_message( channel=MessageChannel.Web, source=current_user.name, userid=current_user.name, username=current_user.name, text=text ) return schemas.Response(success=True) @router.get("/web", summary="获取WEB消息", response_model=List[dict]) async def get_web_message(_: schemas.TokenPayload = Depends(verify_token), db: AsyncSession = Depends(get_async_db), page: Optional[int] = 1, count: Optional[int] = 20): """ 获取WEB消息列表 """ ret_messages = [] messages = await Message.async_list_by_page(db, page=page, count=count) for message in messages: try: ret_messages.append(message.to_dict()) except Exception as e: logger.error(f"获取WEB消息列表失败: {str(e)}") continue return ret_messages def wechat_verify(echostr: str, msg_signature: str, timestamp: Union[str, int], nonce: str, source: Optional[str] = None) -> Any: """ 微信验证响应 """ # 获取服务配置 client_configs = ServiceConfigHelper.get_notification_configs() if not client_configs: return "未找到对应的消息配置" client_config = next((config for config in client_configs if config.type == "wechat" and config.enabled and config.config.get("WECHAT_MODE", "app") != "bot" and (not source or config.name == source)), None) if not client_config: return "未找到对应的消息配置" try: wxcpt = WXBizMsgCrypt(sToken=client_config.config.get('WECHAT_TOKEN'), sEncodingAESKey=client_config.config.get('WECHAT_ENCODING_AESKEY'), sReceiveId=client_config.config.get('WECHAT_CORPID')) ret, sEchoStr = wxcpt.VerifyURL(sMsgSignature=msg_signature, sTimeStamp=timestamp, sNonce=nonce, sEchoStr=echostr) if ret == 0: # 验证URL成功,将sEchoStr返回给企业号 return PlainTextResponse(sEchoStr) return "微信验证失败" except Exception as err: logger.error(f"微信请求验证失败: {str(err)}") return str(err) def vocechat_verify() -> Any: """ VoceChat验证响应 """ return {"status": "OK"} @router.get("/", summary="回调请求验证") def incoming_verify(token: Optional[str] = None, echostr: Optional[str] = None, msg_signature: Optional[str] = None, timestamp: Union[str, int] = None, nonce: Optional[str] = None, source: Optional[str] = None, _: schemas.TokenPayload = Depends(verify_apitoken)) -> Any: """ 微信/VoceChat等验证响应 """ logger.info(f"收到验证请求: token={token}, echostr={echostr}, " f"msg_signature={msg_signature}, timestamp={timestamp}, nonce={nonce}") if echostr and msg_signature and timestamp and nonce: return wechat_verify(echostr, msg_signature, timestamp, nonce, source) return vocechat_verify() @router.post("/webpush/subscribe", summary="客户端webpush通知订阅", response_model=schemas.Response) async def subscribe(subscription: schemas.Subscription, _: schemas.TokenPayload = Depends(verify_token)): """ 客户端webpush通知订阅 """ subinfo = subscription.model_dump() if subinfo not in global_vars.get_subscriptions(): global_vars.push_subscription(subinfo) logger.debug(f"通知订阅成功: {subinfo}") return schemas.Response(success=True) @router.post("/webpush/send", summary="发送webpush通知", response_model=schemas.Response) def send_notification(payload: schemas.SubscriptionMessage, _: schemas.TokenPayload = Depends(verify_token)): """ 发送webpush通知 """ for sub in global_vars.get_subscriptions(): try: webpush( subscription_info=sub, data=json.dumps(payload.model_dump()), vapid_private_key=settings.VAPID.get("privateKey"), vapid_claims={ "sub": settings.VAPID.get("subject") }, ) except WebPushException as err: logger.error(f"WebPush发送失败: {str(err)}") continue return schemas.Response(success=True) ================================================ FILE: app/api/endpoints/mfa.py ================================================ """ MFA (Multi-Factor Authentication) API 端点 包含 OTP 和 PassKey 相关功能 """ from datetime import timedelta from typing import Any, Annotated, Optional from app.helper.sites import SitesHelper from fastapi import APIRouter, Depends, HTTPException, Body from sqlalchemy.ext.asyncio import AsyncSession from app import schemas from app.core import security from app.core.config import settings from app.db import get_async_db from app.db.models.passkey import PassKey from app.db.models.user import User from app.db.systemconfig_oper import SystemConfigOper from app.db.user_oper import get_current_active_user, get_current_active_user_async from app.helper.passkey import PassKeyHelper from app.log import logger from app.schemas.types import SystemConfigKey from app.utils.otp import OtpUtils router = APIRouter() # ==================== 辅助函数 ==================== def _build_credential_list(passkeys: list[PassKey]) -> list[dict[str, Any]]: """ 构建凭证列表 :param passkeys: PassKey 列表 :return: 凭证字典列表 """ return [ { 'credential_id': pk.credential_id, 'transports': pk.transports } for pk in passkeys ] if passkeys else [] def _extract_and_standardize_credential_id(credential: dict) -> str: """ 从凭证中提取并标准化 credential_id :param credential: 凭证字典 :return: 标准化后的 credential_id :raises ValueError: 如果凭证无效 """ credential_id_raw = credential.get('id') or credential.get('rawId') if not credential_id_raw: raise ValueError("无效的凭证") return PassKeyHelper.standardize_credential_id(credential_id_raw) def _verify_passkey_and_update( credential: dict, challenge: str, passkey: PassKey ) -> tuple[bool, int]: """ 验证 PassKey 并更新使用时间和签名计数 :param credential: 凭证字典 :param challenge: 挑战值 :param passkey: PassKey 对象 :return: (验证是否成功, 新的签名计数) """ success, new_sign_count = PassKeyHelper.verify_authentication_response( credential=credential, expected_challenge=challenge, credential_public_key=passkey.public_key, credential_current_sign_count=passkey.sign_count ) if success: passkey.update_last_used(db=None, sign_count=new_sign_count) return success, new_sign_count async def _check_user_has_passkey(db: AsyncSession, user_id: int) -> bool: """ 检查用户是否有 PassKey :param db: 数据库会话 :param user_id: 用户 ID :return: 是否有 PassKey """ return bool(await PassKey.async_get_by_user_id(db=db, user_id=user_id)) # ==================== 请求模型 ==================== class OtpVerifyRequest(schemas.BaseModel): """OTP验证请求""" uri: str otpPassword: str class OtpDisableRequest(schemas.BaseModel): """OTP禁用请求""" password: str class PassKeyDeleteRequest(schemas.BaseModel): """PassKey删除请求""" passkey_id: int password: str # ==================== 通用 MFA 接口 ==================== @router.get('/status/{username}', summary='判断用户是否开启双重验证(MFA)', response_model=schemas.Response) async def mfa_status(username: str, db: AsyncSession = Depends(get_async_db)) -> Any: """ 检查指定用户是否启用了任何双重验证方式(OTP 或 PassKey) """ user: User = await User.async_get_by_name(db, username) if not user: return schemas.Response(success=False) # 检查是否启用了OTP has_otp = user.is_otp # 检查是否有PassKey has_passkey = await _check_user_has_passkey(db, user.id) # 只要有任何一种验证方式,就需要双重验证 return schemas.Response(success=(has_otp or has_passkey)) # ==================== OTP 相关接口 ==================== @router.post('/otp/generate', summary='生成 OTP 验证 URI', response_model=schemas.Response) def otp_generate( current_user: Annotated[User, Depends(get_current_active_user)] ) -> Any: """生成 OTP 密钥及对应的 URI""" secret, uri = OtpUtils.generate_secret_key(current_user.name) return schemas.Response(success=secret != "", data={'secret': secret, 'uri': uri}) @router.post('/otp/verify', summary='绑定并验证 OTP', response_model=schemas.Response) async def otp_verify( data: OtpVerifyRequest, db: AsyncSession = Depends(get_async_db), current_user: User = Depends(get_current_active_user_async) ) -> Any: """验证用户输入的 OTP 码,验证通过后正式开启 OTP 验证""" if not OtpUtils.is_legal(data.uri, data.otpPassword): return schemas.Response(success=False, message="验证码错误") await current_user.async_update_otp_by_name(db, current_user.name, True, OtpUtils.get_secret(data.uri)) return schemas.Response(success=True) @router.post('/otp/disable', summary='关闭当前用户的 OTP 验证', response_model=schemas.Response) async def otp_disable( data: OtpDisableRequest, db: AsyncSession = Depends(get_async_db), current_user: User = Depends(get_current_active_user_async) ) -> Any: """关闭当前用户的 OTP 验证功能""" # 安全检查:如果存在 PassKey,默认不允许关闭 OTP,除非配置允许 has_passkey = await _check_user_has_passkey(db, current_user.id) if has_passkey and not settings.PASSKEY_ALLOW_REGISTER_WITHOUT_OTP: return schemas.Response( success=False, message="您已注册通行密钥,为了防止域名配置变更导致无法登录,请先删除所有通行密钥再关闭 OTP 验证" ) # 验证密码 if not security.verify_password(data.password, str(current_user.hashed_password)): return schemas.Response(success=False, message="密码错误") await current_user.async_update_otp_by_name(db, current_user.name, False, "") return schemas.Response(success=True) # ==================== PassKey 相关接口 ==================== class PassKeyRegistrationStart(schemas.BaseModel): """PassKey注册开始请求""" name: str = "通行密钥" class PassKeyRegistrationFinish(schemas.BaseModel): """PassKey注册完成请求""" credential: dict challenge: str name: str = "通行密钥" class PassKeyAuthenticationStart(schemas.BaseModel): """PassKey认证开始请求""" username: Optional[str] = None class PassKeyAuthenticationFinish(schemas.BaseModel): """PassKey认证完成请求""" credential: dict challenge: str @router.post("/passkey/register/start", summary="开始注册 PassKey", response_model=schemas.Response) def passkey_register_start( current_user: Annotated[User, Depends(get_current_active_user)] ) -> Any: """开始注册 PassKey - 生成注册选项""" try: # 安全检查:默认需要先启用 OTP,除非配置允许在未启用 OTP 时注册 if not current_user.is_otp and not settings.PASSKEY_ALLOW_REGISTER_WITHOUT_OTP: return schemas.Response( success=False, message="为了确保在域名配置错误时仍能找回访问权限,请先启用 OTP 验证码再注册通行密钥" ) # 获取用户已有的PassKey existing_passkeys = PassKey.get_by_user_id(db=None, user_id=current_user.id) existing_credentials = _build_credential_list(existing_passkeys) if existing_passkeys else None # 生成注册选项 options_json, challenge = PassKeyHelper.generate_registration_options( user_id=current_user.id, username=current_user.name, display_name=current_user.settings.get('nickname') if current_user.settings else None, existing_credentials=existing_credentials ) return schemas.Response( success=True, data={ 'options': options_json, 'challenge': challenge } ) except Exception as e: logger.error(f"生成PassKey注册选项失败: {e}") return schemas.Response( success=False, message=f"生成注册选项失败: {str(e)}" ) @router.post("/passkey/register/finish", summary="完成注册 PassKey", response_model=schemas.Response) def passkey_register_finish( passkey_req: PassKeyRegistrationFinish, current_user: Annotated[User, Depends(get_current_active_user)] ) -> Any: """完成注册 PassKey - 验证并保存凭证""" try: # 验证注册响应 credential_id, public_key, sign_count, aaguid = PassKeyHelper.verify_registration_response( credential=passkey_req.credential, expected_challenge=passkey_req.challenge ) # 提取transports transports = None if 'response' in passkey_req.credential and 'transports' in passkey_req.credential['response']: transports = ','.join(passkey_req.credential['response']['transports']) # 保存到数据库 passkey = PassKey( user_id=current_user.id, credential_id=credential_id, public_key=public_key, sign_count=sign_count, name=passkey_req.name or "通行密钥", aaguid=aaguid, transports=transports ) passkey.create() logger.info(f"用户 {current_user.name} 成功注册PassKey: {passkey_req.name}") return schemas.Response( success=True, message="通行密钥注册成功" ) except Exception as e: logger.error(f"注册PassKey失败: {e}") return schemas.Response( success=False, message=f"注册失败: {str(e)}" ) @router.post("/passkey/authenticate/start", summary="开始 PassKey 认证", response_model=schemas.Response) def passkey_authenticate_start( passkey_req: PassKeyAuthenticationStart = Body(...) ) -> Any: """开始 PassKey 认证 - 生成认证选项""" try: existing_credentials = None # 如果指定了用户名,只允许该用户的PassKey if passkey_req.username: user = User.get_by_name(db=None, name=passkey_req.username) existing_passkeys = PassKey.get_by_user_id(db=None, user_id=user.id) if user else None if not user or not existing_passkeys: return schemas.Response( success=False, message="认证失败" ) existing_credentials = _build_credential_list(existing_passkeys) # 生成认证选项 options_json, challenge = PassKeyHelper.generate_authentication_options( existing_credentials=existing_credentials ) return schemas.Response( success=True, data={ 'options': options_json, 'challenge': challenge } ) except Exception as e: logger.error(f"生成PassKey认证选项失败: {e}") return schemas.Response( success=False, message="认证失败" ) @router.post("/passkey/authenticate/finish", summary="完成 PassKey 认证", response_model=schemas.Token) def passkey_authenticate_finish( passkey_req: PassKeyAuthenticationFinish ) -> Any: """完成 PassKey 认证 - 验证凭证并返回 token""" try: # 提取并标准化凭证ID try: credential_id = _extract_and_standardize_credential_id(passkey_req.credential) except ValueError as e: logger.warning(f"PassKey认证失败,提供的凭证无效: {e}") raise HTTPException(status_code=401, detail="认证失败") # 查找PassKey并获取用户 passkey = PassKey.get_by_credential_id(db=None, credential_id=credential_id) user = User.get_by_id(db=None, user_id=passkey.user_id) if passkey else None if not passkey or not user or not user.is_active: raise HTTPException(status_code=401, detail="认证失败") # 验证认证响应并更新 success, _ = _verify_passkey_and_update( credential=passkey_req.credential, challenge=passkey_req.challenge, passkey=passkey ) if not success: raise HTTPException(status_code=401, detail="认证失败") logger.info(f"用户 {user.name} 通过PassKey认证成功") # 生成token level = SitesHelper().auth_level show_wizard = not SystemConfigOper().get(SystemConfigKey.SetupWizardState) and not settings.ADVANCED_MODE return schemas.Token( access_token=security.create_access_token( userid=user.id, username=user.name, super_user=user.is_superuser, expires_delta=timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES), level=level ), token_type="bearer", super_user=user.is_superuser, user_id=user.id, user_name=user.name, avatar=user.avatar, level=level, permissions=user.permissions or {}, wizard=show_wizard ) except HTTPException: raise except Exception as e: logger.error(f"PassKey认证失败: {e}") raise HTTPException(status_code=401, detail="认证失败") @router.get("/passkey/list", summary="获取当前用户的 PassKey 列表", response_model=schemas.Response) def passkey_list( current_user: Annotated[User, Depends(get_current_active_user)] ) -> Any: """获取当前用户的所有 PassKey""" try: passkeys = PassKey.get_by_user_id(db=None, user_id=current_user.id) key_list = [ { 'id': pk.id, 'name': pk.name, 'created_at': pk.created_at.isoformat() if pk.created_at else None, 'last_used_at': pk.last_used_at.isoformat() if pk.last_used_at else None, 'aaguid': pk.aaguid, 'transports': pk.transports } for pk in passkeys ] if passkeys else [] return schemas.Response( success=True, data=key_list ) except Exception as e: logger.error(f"获取PassKey列表失败: {e}") return schemas.Response( success=False, message=f"获取列表失败: {str(e)}" ) @router.post("/passkey/delete", summary="删除 PassKey", response_model=schemas.Response) async def passkey_delete( data: PassKeyDeleteRequest, current_user: User = Depends(get_current_active_user_async) ) -> Any: """删除指定的 PassKey""" try: # 验证密码 if not security.verify_password(data.password, str(current_user.hashed_password)): return schemas.Response(success=False, message="密码错误") success = PassKey.delete_by_id(db=None, passkey_id=data.passkey_id, user_id=current_user.id) if success: logger.info(f"用户 {current_user.name} 删除了PassKey: {data.passkey_id}") return schemas.Response( success=True, message="通行密钥已删除" ) else: return schemas.Response( success=False, message="通行密钥不存在或无权删除" ) except Exception as e: logger.error(f"删除PassKey失败: {e}") return schemas.Response( success=False, message=f"删除失败: {str(e)}" ) @router.post("/passkey/verify", summary="PassKey 二次验证", response_model=schemas.Response) def passkey_verify_mfa( passkey_req: PassKeyAuthenticationFinish, current_user: Annotated[User, Depends(get_current_active_user)] ) -> Any: """使用 PassKey 进行二次验证(MFA)""" try: # 提取并标准化凭证ID try: credential_id = _extract_and_standardize_credential_id(passkey_req.credential) except ValueError as e: logger.warning(f"PassKey二次验证失败,提供的凭证无效: {e}") return schemas.Response(success=False, message="验证失败") # 查找PassKey(必须属于当前用户) passkey = PassKey.get_by_credential_id(db=None, credential_id=credential_id) if not passkey or passkey.user_id != current_user.id: return schemas.Response( success=False, message="通行密钥不存在或不属于当前用户" ) # 验证认证响应并更新 success, _ = _verify_passkey_and_update( credential=passkey_req.credential, challenge=passkey_req.challenge, passkey=passkey ) if not success: return schemas.Response( success=False, message="通行密钥验证失败" ) logger.info(f"用户 {current_user.name} 通过PassKey二次验证成功") return schemas.Response( success=True, message="二次验证成功" ) except Exception as e: logger.error(f"PassKey二次验证失败: {e}") return schemas.Response( success=False, message="验证失败" ) ================================================ FILE: app/api/endpoints/plugin.py ================================================ import mimetypes import shutil from typing import Annotated, Any, List, Optional import aiofiles from anyio import Path as AsyncPath from fastapi import APIRouter, Depends, Header, HTTPException from fastapi.concurrency import run_in_threadpool from starlette import status from starlette.responses import StreamingResponse from app import schemas from app.command import Command from app.core.config import settings from app.core.plugin import PluginManager from app.core.security import verify_apikey, verify_token from app.db.models import User from app.db.systemconfig_oper import SystemConfigOper from app.db.user_oper import get_current_active_superuser, get_current_active_superuser_async from app.factory import app from app.helper.plugin import PluginHelper from app.log import logger from app.scheduler import Scheduler from app.schemas.types import SystemConfigKey PROTECTED_ROUTES = {"/api/v1/openapi.json", "/docs", "/docs/oauth2-redirect", "/redoc"} PLUGIN_PREFIX = f"{settings.API_V1_STR}/plugin" router = APIRouter() def register_plugin_api(plugin_id: Optional[str] = None): """ 动态注册插件 API :param plugin_id: 插件 ID,如果为 None,则注册所有插件 """ _update_plugin_api_routes(plugin_id, action="add") def remove_plugin_api(plugin_id: str): """ 动态移除单个插件的 API :param plugin_id: 插件 ID """ _update_plugin_api_routes(plugin_id, action="remove") def _update_plugin_api_routes(plugin_id: Optional[str], action: str): """ 插件 API 路由注册和移除 :param plugin_id: 插件 ID,如果 action 为 "add" 且 plugin_id 为 None,则处理所有插件 如果 action 为 "remove",plugin_id 必须是有效的插件 ID :param action: "add" 或 "remove",决定是添加还是移除路由 """ if action not in {"add", "remove"}: raise ValueError("Action must be 'add' or 'remove'") is_modified = False existing_paths = {route.path: route for route in app.routes} plugin_ids = [plugin_id] if plugin_id else PluginManager().get_running_plugin_ids() for plugin_id in plugin_ids: routes_removed = _remove_routes(plugin_id) if routes_removed: is_modified = True if action != "add": continue # 获取插件的 API 路由信息 plugin_apis = PluginManager().get_plugin_apis(plugin_id) for api in plugin_apis: api_path = f"{PLUGIN_PREFIX}{api.get('path', '')}" try: api["path"] = api_path allow_anonymous = api.pop("allow_anonymous", False) auth_mode = api.pop("auth", "apikey") dependencies = api.setdefault("dependencies", []) if not allow_anonymous: if auth_mode == "bear" and Depends(verify_token) not in dependencies: dependencies.append(Depends(verify_token)) elif Depends(verify_apikey) not in dependencies: dependencies.append(Depends(verify_apikey)) app.add_api_route(**api, tags=["plugin"]) is_modified = True logger.debug(f"Added plugin route: {api_path}") except Exception as e: logger.error(f"Error adding plugin route {api_path}: {str(e)}") if is_modified: _clean_protected_routes(existing_paths) app.openapi_schema = None app.setup() def _remove_routes(plugin_id: str) -> bool: """ 移除与单个插件相关的路由 :param plugin_id: 插件 ID :return: 是否有路由被移除 """ if not plugin_id: return False prefix = f"{PLUGIN_PREFIX}/{plugin_id}/" routes_to_remove = [route for route in app.routes if route.path.startswith(prefix)] removed = False for route in routes_to_remove: try: app.routes.remove(route) removed = True logger.debug(f"Removed plugin route: {route.path}") except Exception as e: logger.error(f"Error removing plugin route {route.path}: {str(e)}") return removed def _clean_protected_routes(existing_paths: dict): """ 清理受保护的路由,防止在插件操作中被删除或重复添加 :param existing_paths: 当前应用的路由路径映射 """ for protected_route in PROTECTED_ROUTES: try: existing_route = existing_paths.get(protected_route) if existing_route: app.routes.remove(existing_route) except Exception as e: logger.error(f"Error removing protected route {protected_route}: {str(e)}") def register_plugin(plugin_id: str): """ 注册一个插件相关的服务 """ # 注册插件服务 Scheduler().update_plugin_job(plugin_id) # 注册菜单命令 Command().init_commands(plugin_id) # 注册插件API register_plugin_api(plugin_id) @router.get("/", summary="所有插件", response_model=List[schemas.Plugin]) async def all_plugins(_: User = Depends(get_current_active_superuser_async), state: Optional[str] = "all", force: bool = False) -> List[schemas.Plugin]: """ 查询所有插件清单,包括本地插件和在线插件,插件状态:installed, market, all """ # 本地插件 plugin_manager = PluginManager() local_plugins = plugin_manager.get_local_plugins() # 已安装插件 installed_plugins = [plugin for plugin in local_plugins if plugin.installed] if state == "installed": return installed_plugins # 未安装的本地插件 not_installed_plugins = [plugin for plugin in local_plugins if not plugin.installed] # 在线插件 online_plugins = await plugin_manager.async_get_online_plugins(force) if not online_plugins: # 没有获取在线插件 if state == "market": # 返回未安装的本地插件 return not_installed_plugins return local_plugins # 插件市场插件清单 market_plugins = [] # 已安装插件IDS _installed_ids = [plugin.id for plugin in installed_plugins] # 未安装的线上插件或者有更新的插件 for plugin in online_plugins: if plugin.id not in _installed_ids: market_plugins.append(plugin) elif plugin.has_update: market_plugins.append(plugin) # 未安装的本地插件,且不在线上插件中 _plugin_ids = [plugin.id for plugin in market_plugins] for plugin in not_installed_plugins: if plugin.id not in _plugin_ids: market_plugins.append(plugin) # 返回插件清单 if state == "market": # 返回未安装的插件 return market_plugins # 返回所有插件 return installed_plugins + market_plugins @router.get("/installed", summary="已安装插件", response_model=List[str]) async def installed(_: User = Depends(get_current_active_superuser_async)) -> Any: """ 查询用户已安装插件清单 """ return SystemConfigOper().get(SystemConfigKey.UserInstalledPlugins) or [] @router.get("/statistic", summary="插件安装统计", response_model=dict) async def statistic(_: schemas.TokenPayload = Depends(verify_token)) -> Any: """ 插件安装统计 """ return await PluginHelper().async_get_statistic() @router.get("/reload/{plugin_id}", summary="重新加载插件", response_model=schemas.Response) def reload_plugin(plugin_id: str, _: User = Depends(get_current_active_superuser)) -> Any: """ 重新加载插件 """ # 重新加载插件 PluginManager().reload_plugin(plugin_id) # 注册插件服务 register_plugin(plugin_id) return schemas.Response(success=True) @router.get("/install/{plugin_id}", summary="安装插件", response_model=schemas.Response) async def install(plugin_id: str, repo_url: Optional[str] = "", force: Optional[bool] = False, _: User = Depends(get_current_active_superuser_async)) -> Any: """ 安装插件 """ # 已安装插件 install_plugins = SystemConfigOper().get(SystemConfigKey.UserInstalledPlugins) or [] # 首先检查插件是否已经存在,并且是否强制安装,否则只进行安装统计 plugin_helper = PluginHelper() if not force and plugin_id in PluginManager().get_plugin_ids(): await plugin_helper.async_install_reg(pid=plugin_id) else: # 插件不存在或需要强制安装,下载安装并注册插件 if repo_url: state, msg = await plugin_helper.async_install(pid=plugin_id, repo_url=repo_url) # 安装失败则直接响应 if not state: return schemas.Response(success=False, message=msg) else: # repo_url 为空时,也直接响应 return schemas.Response(success=False, message="没有传入仓库地址,无法正确安装插件,请检查配置") # 安装插件 if plugin_id not in install_plugins: install_plugins.append(plugin_id) # 保存设置 await SystemConfigOper().async_set(SystemConfigKey.UserInstalledPlugins, install_plugins) # 重新加载插件 await run_in_threadpool(reload_plugin, plugin_id) return schemas.Response(success=True) @router.get("/remotes", summary="获取插件联邦组件列表", response_model=List[dict]) async def remotes(token: str) -> Any: """ 获取插件联邦组件列表 """ if token != "moviepilot": raise HTTPException(status_code=403, detail="Forbidden") return PluginManager().get_plugin_remotes() @router.get("/form/{plugin_id}", summary="获取插件表单页面") def plugin_form(plugin_id: str, _: User = Depends(get_current_active_superuser)) -> dict: """ 根据插件ID获取插件配置表单或Vue组件URL """ plugin_manager = PluginManager() plugin_instance = plugin_manager.running_plugins.get(plugin_id) if not plugin_instance: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"插件 {plugin_id} 不存在或未加载") # 渲染模式 render_mode, _ = plugin_instance.get_render_mode() try: conf, model = plugin_instance.get_form() return { "render_mode": render_mode, "conf": conf, "model": plugin_manager.get_plugin_config(plugin_id) or model } except Exception as e: logger.error(f"插件 {plugin_id} 调用方法 get_form 出错: {str(e)}") return {} @router.get("/page/{plugin_id}", summary="获取插件数据页面") def plugin_page(plugin_id: str, _: User = Depends(get_current_active_superuser)) -> dict: """ 根据插件ID获取插件数据页面 """ plugin_instance = PluginManager().running_plugins.get(plugin_id) if not plugin_instance: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"插件 {plugin_id} 不存在或未加载") # 渲染模式 render_mode, _ = plugin_instance.get_render_mode() try: page = plugin_instance.get_page() return { "render_mode": render_mode, "page": page or [] } except Exception as e: logger.error(f"插件 {plugin_id} 调用方法 get_page 出错: {str(e)}") return {} @router.get("/dashboard/meta", summary="获取所有插件仪表板元信息") def plugin_dashboard_meta(_: schemas.TokenPayload = Depends(verify_token)) -> List[dict]: """ 获取所有插件仪表板元信息 """ return PluginManager().get_plugin_dashboard_meta() @router.get("/dashboard/{plugin_id}/{key}", summary="获取插件仪表板配置") def plugin_dashboard_by_key(plugin_id: str, key: str, user_agent: Annotated[str | None, Header()] = None, _: schemas.TokenPayload = Depends(verify_token)) -> Optional[schemas.PluginDashboard]: """ 根据插件ID获取插件仪表板 """ return PluginManager().get_plugin_dashboard(plugin_id, key, user_agent) @router.get("/dashboard/{plugin_id}", summary="获取插件仪表板配置") def plugin_dashboard(plugin_id: str, user_agent: Annotated[str | None, Header()] = None, _: schemas.TokenPayload = Depends(verify_token)) -> schemas.PluginDashboard: """ 根据插件ID获取插件仪表板 """ return plugin_dashboard_by_key(plugin_id, "", user_agent) @router.get("/reset/{plugin_id}", summary="重置插件配置及数据", response_model=schemas.Response) def reset_plugin(plugin_id: str, _: User = Depends(get_current_active_superuser)) -> Any: """ 根据插件ID重置插件配置及数据 """ plugin_manager = PluginManager() # 删除配置 plugin_manager.delete_plugin_config(plugin_id) # 删除插件所有数据 plugin_manager.delete_plugin_data(plugin_id) # 重新加载插件 reload_plugin(plugin_id) return schemas.Response(success=True) @router.get("/file/{plugin_id}/{filepath:path}", summary="获取插件静态文件") async def plugin_static_file(plugin_id: str, filepath: str): """ 获取插件静态文件 """ # 基础安全检查 if ".." in filepath or ".." in plugin_id: logger.warning(f"Static File API: Path traversal attempt detected: {plugin_id}/{filepath}") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden") plugin_base_dir = AsyncPath(settings.ROOT_PATH) / "app" / "plugins" / plugin_id.lower() plugin_file_path = plugin_base_dir / filepath.lstrip('/') try: resolved_base = await plugin_base_dir.resolve() resolved_file = await plugin_file_path.resolve() except Exception: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid path") if not resolved_file.is_relative_to(resolved_base): logger.warning(f"Static File API: Path traversal attempt detected: {plugin_id}/{filepath}") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden") if not await plugin_file_path.exists(): raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"{plugin_file_path} 不存在") if not await plugin_file_path.is_file(): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"{plugin_file_path} 不是文件") # 判断 MIME 类型 response_type, _ = mimetypes.guess_type(str(plugin_file_path)) suffix = plugin_file_path.suffix.lower() # 强制修正 .mjs 和 .js 的 MIME 类型 if suffix in ['.js', '.mjs']: response_type = 'application/javascript' elif suffix == '.css' and not response_type: # 如果 guess_type 没猜对 css,也修正 response_type = 'text/css' elif not response_type: # 对于其他猜不出的类型 response_type = 'application/octet-stream' try: # 异步生成器函数,用于流式读取文件 async def file_generator(): async with aiofiles.open(plugin_file_path, mode='rb') as file: # 8KB 块大小 while chunk := await file.read(8192): yield chunk return StreamingResponse( file_generator(), media_type=response_type, headers={"Content-Disposition": f"inline; filename={plugin_file_path.name}"} ) except Exception as e: logger.error(f"Error creating/sending StreamingResponse for {plugin_file_path}: {e}", exc_info=True) raise HTTPException(status_code=500, detail="Internal Server Error") @router.get("/folders", summary="获取插件文件夹配置", response_model=dict) async def get_plugin_folders(_: User = Depends(get_current_active_superuser_async)) -> dict: """ 获取插件文件夹分组配置 """ try: result = SystemConfigOper().get(SystemConfigKey.PluginFolders) or {} return result except Exception as e: logger.error(f"[文件夹API] 获取文件夹配置失败: {str(e)}") return {} @router.post("/folders", summary="保存插件文件夹配置", response_model=schemas.Response) async def save_plugin_folders(folders: dict, _: User = Depends(get_current_active_superuser_async)) -> Any: """ 保存插件文件夹分组配置 """ try: SystemConfigOper().set(SystemConfigKey.PluginFolders, folders) return schemas.Response(success=True) except Exception as e: logger.error(f"[文件夹API] 保存文件夹配置失败: {str(e)}") return schemas.Response(success=False, message=str(e)) @router.post("/folders/{folder_name}", summary="创建插件文件夹", response_model=schemas.Response) async def create_plugin_folder(folder_name: str, _: User = Depends(get_current_active_superuser_async)) -> Any: """ 创建新的插件文件夹 """ folders = SystemConfigOper().get(SystemConfigKey.PluginFolders) or {} if folder_name not in folders: folders[folder_name] = [] SystemConfigOper().set(SystemConfigKey.PluginFolders, folders) return schemas.Response(success=True, message=f"文件夹 '{folder_name}' 创建成功") else: return schemas.Response(success=False, message=f"文件夹 '{folder_name}' 已存在") @router.delete("/folders/{folder_name}", summary="删除插件文件夹", response_model=schemas.Response) async def delete_plugin_folder(folder_name: str, _: User = Depends(get_current_active_superuser_async)) -> Any: """ 删除插件文件夹 """ folders = SystemConfigOper().get(SystemConfigKey.PluginFolders) or {} if folder_name in folders: del folders[folder_name] await SystemConfigOper().async_set(SystemConfigKey.PluginFolders, folders) return schemas.Response(success=True, message=f"文件夹 '{folder_name}' 删除成功") else: return schemas.Response(success=False, message=f"文件夹 '{folder_name}' 不存在") @router.put("/folders/{folder_name}/plugins", summary="更新文件夹中的插件", response_model=schemas.Response) async def update_folder_plugins(folder_name: str, plugin_ids: List[str], _: User = Depends(get_current_active_superuser_async)) -> Any: """ 更新指定文件夹中的插件列表 """ folders = SystemConfigOper().get(SystemConfigKey.PluginFolders) or {} folders[folder_name] = plugin_ids await SystemConfigOper().async_set(SystemConfigKey.PluginFolders, folders) return schemas.Response(success=True, message=f"文件夹 '{folder_name}' 中的插件已更新") @router.post("/clone/{plugin_id}", summary="创建插件分身", response_model=schemas.Response) def clone_plugin(plugin_id: str, clone_data: dict, _: User = Depends(get_current_active_superuser)) -> Any: """ 创建插件分身 """ try: success, message = PluginManager().clone_plugin( plugin_id=plugin_id, suffix=clone_data.get("suffix", ""), name=clone_data.get("name", ""), description=clone_data.get("description", ""), version=clone_data.get("version", ""), icon=clone_data.get("icon", "") ) if success: # 注册插件服务 reload_plugin(message) # 将分身插件添加到原插件所在的文件夹中 _add_clone_to_plugin_folder(plugin_id, message) return schemas.Response(success=True, message="插件分身创建成功") else: return schemas.Response(success=False, message=message) except Exception as e: logger.error(f"创建插件分身失败:{str(e)}") return schemas.Response(success=False, message=f"创建插件分身失败:{str(e)}") @router.get("/{plugin_id}", summary="获取插件配置") async def plugin_config(plugin_id: str, _: User = Depends(get_current_active_superuser_async)) -> dict: """ 根据插件ID获取插件配置信息 """ return PluginManager().get_plugin_config(plugin_id) @router.put("/{plugin_id}", summary="更新插件配置", response_model=schemas.Response) def set_plugin_config(plugin_id: str, conf: dict, _: User = Depends(get_current_active_superuser)) -> Any: """ 更新插件配置 """ plugin_manager = PluginManager() # 保存配置 plugin_manager.save_plugin_config(plugin_id, conf) # 重新生效插件 plugin_manager.init_plugin(plugin_id, conf) # 注册插件服务 register_plugin(plugin_id) return schemas.Response(success=True) @router.delete("/{plugin_id}", summary="卸载插件", response_model=schemas.Response) def uninstall_plugin(plugin_id: str, _: User = Depends(get_current_active_superuser)) -> Any: """ 卸载插件 """ config_oper = SystemConfigOper() # 删除已安装信息 install_plugins = config_oper.get(SystemConfigKey.UserInstalledPlugins) or [] for plugin in install_plugins: if plugin == plugin_id: install_plugins.remove(plugin) break config_oper.set(SystemConfigKey.UserInstalledPlugins, install_plugins) # 移除插件API remove_plugin_api(plugin_id) # 移除插件服务 Scheduler().remove_plugin_job(plugin_id) # 判断是否为分身 plugin_manager = PluginManager() plugin_class = plugin_manager.plugins.get(plugin_id) if getattr(plugin_class, "is_clone", False): # 如果是分身插件,则删除分身数据和配置 plugin_manager.delete_plugin_config(plugin_id) plugin_manager.delete_plugin_data(plugin_id) # 删除分身文件 plugin_base_dir = settings.ROOT_PATH / "app" / "plugins" / plugin_id.lower() if plugin_base_dir.exists(): try: shutil.rmtree(plugin_base_dir) plugin_manager.plugins.pop(plugin_id, None) except Exception as e: logger.error(f"删除插件分身目录 {plugin_base_dir} 失败: {str(e)}") # 从插件文件夹中移除该插件 _remove_plugin_from_folders(plugin_id) # 移除插件 plugin_manager.remove_plugin(plugin_id) return schemas.Response(success=True) def _add_clone_to_plugin_folder(original_plugin_id: str, clone_plugin_id: str): """ 将分身插件添加到原插件所在的文件夹中 :param original_plugin_id: 原插件ID :param clone_plugin_id: 分身插件ID """ try: config_oper = SystemConfigOper() # 获取插件文件夹配置 folders = config_oper.get(SystemConfigKey.PluginFolders) or {} # 查找原插件所在的文件夹 target_folder = None for folder_name, folder_data in folders.items(): if isinstance(folder_data, dict) and 'plugins' in folder_data: # 新格式:{"plugins": [...], "order": ..., "icon": ...} if original_plugin_id in folder_data['plugins']: target_folder = folder_name break elif isinstance(folder_data, list): # 旧格式:直接是插件列表 if original_plugin_id in folder_data: target_folder = folder_name break # 如果找到了原插件所在的文件夹,则将分身插件也添加到该文件夹中 if target_folder: folder_data = folders[target_folder] if isinstance(folder_data, dict) and 'plugins' in folder_data: # 新格式 if clone_plugin_id not in folder_data['plugins']: folder_data['plugins'].append(clone_plugin_id) logger.info(f"已将分身插件 {clone_plugin_id} 添加到文件夹 '{target_folder}' 中") elif isinstance(folder_data, list): # 旧格式 if clone_plugin_id not in folder_data: folder_data.append(clone_plugin_id) logger.info(f"已将分身插件 {clone_plugin_id} 添加到文件夹 '{target_folder}' 中") # 保存更新后的文件夹配置 config_oper.set(SystemConfigKey.PluginFolders, folders) else: logger.info(f"原插件 {original_plugin_id} 不在任何文件夹中,分身插件 {clone_plugin_id} 将保持独立") except Exception as e: logger.error(f"处理插件文件夹时出错:{str(e)}") # 文件夹处理失败不影响插件分身创建的整体流程 def _remove_plugin_from_folders(plugin_id: str): """ 从所有文件夹中移除指定的插件 :param plugin_id: 要移除的插件ID """ try: config_oper = SystemConfigOper() # 获取插件文件夹配置 folders = config_oper.get(SystemConfigKey.PluginFolders) or {} # 标记是否有修改 modified = False # 遍历所有文件夹,移除指定插件 for folder_name, folder_data in folders.items(): if isinstance(folder_data, dict) and 'plugins' in folder_data: # 新格式:{"plugins": [...], "order": ..., "icon": ...} if plugin_id in folder_data['plugins']: folder_data['plugins'].remove(plugin_id) logger.info(f"已从文件夹 '{folder_name}' 中移除插件 {plugin_id}") modified = True elif isinstance(folder_data, list): # 旧格式:直接是插件列表 if plugin_id in folder_data: folder_data.remove(plugin_id) logger.info(f"已从文件夹 '{folder_name}' 中移除插件 {plugin_id}") modified = True # 如果有修改,保存更新后的文件夹配置 if modified: config_oper.set(SystemConfigKey.PluginFolders, folders) else: logger.debug(f"插件 {plugin_id} 不在任何文件夹中,无需移除") except Exception as e: logger.error(f"从文件夹中移除插件时出错:{str(e)}") # 文件夹处理失败不影响插件卸载的整体流程 ================================================ FILE: app/api/endpoints/recommend.py ================================================ from typing import Any, List, Optional from fastapi import APIRouter, Depends from app import schemas from app.chain.recommend import RecommendChain from app.core.event import eventmanager from app.core.security import verify_token from app.schemas import RecommendSourceEventData from app.schemas.types import ChainEventType router = APIRouter() @router.get("/source", summary="获取推荐数据源", response_model=List[schemas.RecommendMediaSource]) def source(_: schemas.TokenPayload = Depends(verify_token)) -> Any: """ 获取推荐数据源 """ # 广播事件,请示额外的推荐数据源支持 event_data = RecommendSourceEventData() event = eventmanager.send_event(ChainEventType.RecommendSource, event_data) # 使用事件返回的上下文数据 if event and event.event_data: event_data: RecommendSourceEventData = event.event_data if event_data.extra_sources: return event_data.extra_sources return [] @router.get("/bangumi_calendar", summary="Bangumi每日放送", response_model=List[schemas.MediaInfo]) async def bangumi_calendar(page: Optional[int] = 1, count: Optional[int] = 30, _: schemas.TokenPayload = Depends(verify_token)) -> Any: """ 浏览Bangumi每日放送 """ return await RecommendChain().async_bangumi_calendar(page=page, count=count) @router.get("/douban_showing", summary="豆瓣正在热映", response_model=List[schemas.MediaInfo]) async def douban_showing(page: Optional[int] = 1, count: Optional[int] = 30, _: schemas.TokenPayload = Depends(verify_token)) -> Any: """ 浏览豆瓣正在热映 """ return await RecommendChain().async_douban_movie_showing(page=page, count=count) @router.get("/douban_movies", summary="豆瓣电影", response_model=List[schemas.MediaInfo]) async def douban_movies(sort: Optional[str] = "R", tags: Optional[str] = "", page: Optional[int] = 1, count: Optional[int] = 30, _: schemas.TokenPayload = Depends(verify_token)) -> Any: """ 浏览豆瓣电影信息 """ return await RecommendChain().async_douban_movies(sort=sort, tags=tags, page=page, count=count) @router.get("/douban_tvs", summary="豆瓣剧集", response_model=List[schemas.MediaInfo]) async def douban_tvs(sort: Optional[str] = "R", tags: Optional[str] = "", page: Optional[int] = 1, count: Optional[int] = 30, _: schemas.TokenPayload = Depends(verify_token)) -> Any: """ 浏览豆瓣剧集信息 """ return await RecommendChain().async_douban_tvs(sort=sort, tags=tags, page=page, count=count) @router.get("/douban_movie_top250", summary="豆瓣电影TOP250", response_model=List[schemas.MediaInfo]) async def douban_movie_top250(page: Optional[int] = 1, count: Optional[int] = 30, _: schemas.TokenPayload = Depends(verify_token)) -> Any: """ 浏览豆瓣剧集信息 """ return await RecommendChain().async_douban_movie_top250(page=page, count=count) @router.get("/douban_tv_weekly_chinese", summary="豆瓣国产剧集周榜", response_model=List[schemas.MediaInfo]) async def douban_tv_weekly_chinese(page: Optional[int] = 1, count: Optional[int] = 30, _: schemas.TokenPayload = Depends(verify_token)) -> Any: """ 中国每周剧集口碑榜 """ return await RecommendChain().async_douban_tv_weekly_chinese(page=page, count=count) @router.get("/douban_tv_weekly_global", summary="豆瓣全球剧集周榜", response_model=List[schemas.MediaInfo]) async def douban_tv_weekly_global(page: Optional[int] = 1, count: Optional[int] = 30, _: schemas.TokenPayload = Depends(verify_token)) -> Any: """ 全球每周剧集口碑榜 """ return await RecommendChain().async_douban_tv_weekly_global(page=page, count=count) @router.get("/douban_tv_animation", summary="豆瓣动画剧集", response_model=List[schemas.MediaInfo]) async def douban_tv_animation(page: Optional[int] = 1, count: Optional[int] = 30, _: schemas.TokenPayload = Depends(verify_token)) -> Any: """ 热门动画剧集 """ return await RecommendChain().async_douban_tv_animation(page=page, count=count) @router.get("/douban_movie_hot", summary="豆瓣热门电影", response_model=List[schemas.MediaInfo]) async def douban_movie_hot(page: Optional[int] = 1, count: Optional[int] = 30, _: schemas.TokenPayload = Depends(verify_token)) -> Any: """ 热门电影 """ return await RecommendChain().async_douban_movie_hot(page=page, count=count) @router.get("/douban_tv_hot", summary="豆瓣热门电视剧", response_model=List[schemas.MediaInfo]) async def douban_tv_hot(page: Optional[int] = 1, count: Optional[int] = 30, _: schemas.TokenPayload = Depends(verify_token)) -> Any: """ 热门电视剧 """ return await RecommendChain().async_douban_tv_hot(page=page, count=count) @router.get("/tmdb_movies", summary="TMDB电影", response_model=List[schemas.MediaInfo]) async def tmdb_movies(sort_by: Optional[str] = "popularity.desc", with_genres: Optional[str] = "", with_original_language: Optional[str] = "", with_keywords: Optional[str] = "", with_watch_providers: Optional[str] = "", vote_average: Optional[float] = 0.0, vote_count: Optional[int] = 0, release_date: Optional[str] = "", page: Optional[int] = 1, _: schemas.TokenPayload = Depends(verify_token)) -> Any: """ 浏览TMDB电影信息 """ return await RecommendChain().async_tmdb_movies(sort_by=sort_by, with_genres=with_genres, with_original_language=with_original_language, with_keywords=with_keywords, with_watch_providers=with_watch_providers, vote_average=vote_average, vote_count=vote_count, release_date=release_date, page=page) @router.get("/tmdb_tvs", summary="TMDB剧集", response_model=List[schemas.MediaInfo]) async def tmdb_tvs(sort_by: Optional[str] = "popularity.desc", with_genres: Optional[str] = "", with_original_language: Optional[str] = "", with_keywords: Optional[str] = "", with_watch_providers: Optional[str] = "", vote_average: Optional[float] = 0.0, vote_count: Optional[int] = 0, release_date: Optional[str] = "", page: Optional[int] = 1, _: schemas.TokenPayload = Depends(verify_token)) -> Any: """ 浏览TMDB剧集信息 """ return await RecommendChain().async_tmdb_tvs(sort_by=sort_by, with_genres=with_genres, with_original_language=with_original_language, with_keywords=with_keywords, with_watch_providers=with_watch_providers, vote_average=vote_average, vote_count=vote_count, release_date=release_date, page=page) @router.get("/tmdb_trending", summary="TMDB流行趋势", response_model=List[schemas.MediaInfo]) async def tmdb_trending(page: Optional[int] = 1, _: schemas.TokenPayload = Depends(verify_token)) -> Any: """ TMDB流行趋势 """ return await RecommendChain().async_tmdb_trending(page=page) ================================================ FILE: app/api/endpoints/search.py ================================================ from typing import List, Any, Optional from fastapi import APIRouter, Depends, Body from app import schemas from app.chain.media import MediaChain from app.chain.search import SearchChain from app.chain.ai_recommend import AIRecommendChain from app.core.config import settings from app.core.event import eventmanager from app.core.metainfo import MetaInfo from app.core.security import verify_token from app.log import logger from app.schemas import MediaRecognizeConvertEventData from app.schemas.types import MediaType, ChainEventType router = APIRouter() @router.get("/last", summary="查询搜索结果", response_model=List[schemas.Context]) async def search_latest(_: schemas.TokenPayload = Depends(verify_token)) -> Any: """ 查询搜索结果 """ torrents = await SearchChain().async_last_search_results() or [] return [torrent.to_dict() for torrent in torrents] @router.get("/media/{mediaid}", summary="精确搜索资源", response_model=schemas.Response) async def search_by_id(mediaid: str, mtype: Optional[str] = None, area: Optional[str] = "title", title: Optional[str] = None, year: Optional[str] = None, season: Optional[str] = None, sites: Optional[str] = None, _: schemas.TokenPayload = Depends(verify_token)) -> Any: """ 根据TMDBID/豆瓣ID精确搜索站点资源 tmdb:/douban:/bangumi: """ # 取消正在运行的AI推荐(会清除数据库缓存) AIRecommendChain().cancel_ai_recommend() if mtype: media_type = MediaType(mtype) else: media_type = None if season: media_season = int(season) else: media_season = None if sites: site_list = [int(site) for site in sites.split(",") if site] else: site_list = None torrents = None media_chain = MediaChain() search_chain = SearchChain() # 根据前缀识别媒体ID if mediaid.startswith("tmdb:"): tmdbid = int(mediaid.replace("tmdb:", "")) if settings.RECOGNIZE_SOURCE == "douban": # 通过TMDBID识别豆瓣ID doubaninfo = await media_chain.async_get_doubaninfo_by_tmdbid(tmdbid=tmdbid, mtype=media_type) if doubaninfo: torrents = await search_chain.async_search_by_id(doubanid=doubaninfo.get("id"), mtype=media_type, area=area, season=media_season, sites=site_list, cache_local=True) else: return schemas.Response(success=False, message="未识别到豆瓣媒体信息") else: torrents = await search_chain.async_search_by_id(tmdbid=tmdbid, mtype=media_type, area=area, season=media_season, sites=site_list, cache_local=True) elif mediaid.startswith("douban:"): doubanid = mediaid.replace("douban:", "") if settings.RECOGNIZE_SOURCE == "themoviedb": # 通过豆瓣ID识别TMDBID tmdbinfo = await media_chain.async_get_tmdbinfo_by_doubanid(doubanid=doubanid, mtype=media_type) if tmdbinfo: if tmdbinfo.get('season') and not media_season: media_season = tmdbinfo.get('season') torrents = await search_chain.async_search_by_id(tmdbid=tmdbinfo.get("id"), mtype=media_type, area=area, season=media_season, sites=site_list, cache_local=True) else: return schemas.Response(success=False, message="未识别到TMDB媒体信息") else: torrents = await search_chain.async_search_by_id(doubanid=doubanid, mtype=media_type, area=area, season=media_season, sites=site_list, cache_local=True) elif mediaid.startswith("bangumi:"): bangumiid = int(mediaid.replace("bangumi:", "")) if settings.RECOGNIZE_SOURCE == "themoviedb": # 通过BangumiID识别TMDBID tmdbinfo = await media_chain.async_get_tmdbinfo_by_bangumiid(bangumiid=bangumiid) if tmdbinfo: torrents = await search_chain.async_search_by_id(tmdbid=tmdbinfo.get("id"), mtype=media_type, area=area, season=media_season, sites=site_list, cache_local=True) else: return schemas.Response(success=False, message="未识别到TMDB媒体信息") else: # 通过BangumiID识别豆瓣ID doubaninfo = await media_chain.async_get_doubaninfo_by_bangumiid(bangumiid=bangumiid) if doubaninfo: torrents = await search_chain.async_search_by_id(doubanid=doubaninfo.get("id"), mtype=media_type, area=area, season=media_season, sites=site_list, cache_local=True) else: return schemas.Response(success=False, message="未识别到豆瓣媒体信息") else: # 未知前缀,广播事件解析媒体信息 event_data = MediaRecognizeConvertEventData( mediaid=mediaid, convert_type=settings.RECOGNIZE_SOURCE ) event = await eventmanager.async_send_event(ChainEventType.MediaRecognizeConvert, event_data) # 使用事件返回的上下文数据 if event and event.event_data: event_data: MediaRecognizeConvertEventData = event.event_data if event_data.media_dict: search_id = event_data.media_dict.get("id") if event_data.convert_type == "themoviedb": torrents = await search_chain.async_search_by_id(tmdbid=search_id, mtype=media_type, area=area, season=media_season, cache_local=True) elif event_data.convert_type == "douban": torrents = await search_chain.async_search_by_id(doubanid=search_id, mtype=media_type, area=area, season=media_season, cache_local=True) else: if not title: return schemas.Response(success=False, message="未知的媒体ID") # 使用名称识别兜底 meta = MetaInfo(title) if year: meta.year = year if media_type: meta.type = media_type if media_season: meta.type = MediaType.TV meta.begin_season = media_season mediainfo = await media_chain.async_recognize_media(meta=meta) if mediainfo: if settings.RECOGNIZE_SOURCE == "themoviedb": torrents = await search_chain.async_search_by_id(tmdbid=mediainfo.tmdb_id, mtype=media_type, area=area, season=media_season, cache_local=True) else: torrents = await search_chain.async_search_by_id(doubanid=mediainfo.douban_id, mtype=media_type, area=area, season=media_season, cache_local=True) # 返回搜索结果 if not torrents: return schemas.Response(success=False, message="未搜索到任何资源") else: return schemas.Response(success=True, data=[torrent.to_dict() for torrent in torrents]) @router.get("/title", summary="模糊搜索资源", response_model=schemas.Response) async def search_by_title(keyword: Optional[str] = None, page: Optional[int] = 0, sites: Optional[str] = None, _: schemas.TokenPayload = Depends(verify_token)) -> Any: """ 根据名称模糊搜索站点资源,支持分页,关键词为空是返回首页资源 """ # 取消正在运行的AI推荐并清除数据库缓存 AIRecommendChain().cancel_ai_recommend() torrents = await SearchChain().async_search_by_title( title=keyword, page=page, sites=[int(site) for site in sites.split(",") if site] if sites else None, cache_local=True ) if not torrents: return schemas.Response(success=False, message="未搜索到任何资源") return schemas.Response(success=True, data=[torrent.to_dict() for torrent in torrents]) @router.post("/recommend", summary="AI推荐资源", response_model=schemas.Response) async def recommend_search_results( filtered_indices: Optional[List[int]] = Body(None, embed=True, description="筛选后的索引列表"), check_only: bool = Body(False, embed=True, description="仅检查状态,不启动新任务"), force: bool = Body(False, embed=True, description="强制重新推荐,清除旧结果"), _: schemas.TokenPayload = Depends(verify_token)) -> Any: """ AI推荐资源 - 轮询接口 前端轮询此接口,发送筛选后的索引(如果有筛选) 后端根据请求变化自动取消旧任务并启动新任务 参数: - filtered_indices: 筛选后的索引列表(可选,为空或不提供时使用所有结果) - check_only: 仅检查状态(首次打开页面时使用,避免触发不必要的重新推理) - force: 强制重新推荐(清除旧结果并重新启动) 返回数据结构: { "success": bool, "message": string, // 错误信息(仅在错误时存在) "data": { "status": string, // 状态: disabled | idle | running | completed | error "results": array // 推荐结果(仅status=completed时存在) } } """ # 从缓存获取上次搜索结果 results = await SearchChain().async_last_search_results() or [] if not results: return schemas.Response(success=False, message="没有可用的搜索结果", data={ "status": "error" }) recommend_chain = AIRecommendChain() # 如果是强制模式,先取消并清除旧结果,然后直接启动新任务 if force: # 检查功能是否启用 if not settings.AI_AGENT_ENABLE or not settings.AI_RECOMMEND_ENABLED: return schemas.Response(success=True, data={ "status": "disabled" }) logger.info("收到新推荐请求,清除旧结果并启动新任务") recommend_chain.cancel_ai_recommend() recommend_chain.start_recommend_task(filtered_indices, len(results), results) # 直接返回运行中状态 return schemas.Response(success=True, data={ "status": "running" }) # 如果是仅检查模式,不传递 filtered_indices(避免触发请求变化检测) if check_only: # 返回当前运行状态,不做任何任务启动或取消操作 current_status = recommend_chain.get_current_status_only() # 如果有错误,将错误信息放到message中 if current_status.get("status") == "error": error_msg = current_status.pop("error", "未知错误") return schemas.Response(success=False, message=error_msg, data=current_status) return schemas.Response(success=True, data=current_status) # 获取当前状态(会检测请求是否变化) status_data = recommend_chain.get_status(filtered_indices, len(results)) # 如果功能未启用,直接返回禁用状态 if status_data.get("status") == "disabled": return schemas.Response(success=True, data=status_data) # 如果是空闲状态,启动新任务 if status_data["status"] == "idle": recommend_chain.start_recommend_task(filtered_indices, len(results), results) # 立即返回运行中状态 return schemas.Response(success=True, data={ "status": "running" }) # 如果有错误,将错误信息放到message中 if status_data.get("status") == "error": error_msg = status_data.pop("error", "未知错误") return schemas.Response(success=False, message=error_msg, data=status_data) # 返回当前状态 return schemas.Response(success=True, data=status_data) ================================================ FILE: app/api/endpoints/site.py ================================================ from typing import List, Any, Dict, Optional from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import Session from starlette.background import BackgroundTasks from app import schemas from app.api.endpoints.plugin import register_plugin_api from app.chain.site import SiteChain from app.chain.torrents import TorrentsChain from app.command import Command from app.core.event import eventmanager from app.core.plugin import PluginManager from app.core.security import verify_token from app.db import get_db, get_async_db from app.db.models import User from app.db.models.site import Site from app.db.models.siteicon import SiteIcon from app.db.models.sitestatistic import SiteStatistic from app.db.models.siteuserdata import SiteUserData from app.db.site_oper import SiteOper from app.db.systemconfig_oper import SystemConfigOper from app.db.user_oper import get_current_active_superuser, get_current_active_superuser_async from app.helper.sites import SitesHelper # noqa from app.scheduler import Scheduler from app.schemas.types import SystemConfigKey, EventType from app.utils.string import StringUtils router = APIRouter() @router.get("/", summary="所有站点", response_model=List[schemas.Site]) async def read_sites(db: AsyncSession = Depends(get_async_db), _: User = Depends(get_current_active_superuser)) -> List[dict]: """ 获取站点列表 """ return await Site.async_list_order_by_pri(db) @router.post("/", summary="新增站点", response_model=schemas.Response) async def add_site( *, db: AsyncSession = Depends(get_async_db), site_in: schemas.Site, _: User = Depends(get_current_active_superuser) ) -> Any: """ 新增站点 """ if not site_in.url: return schemas.Response(success=False, message="站点地址不能为空") if SitesHelper().auth_level < 2: return schemas.Response(success=False, message="用户未通过认证,无法使用站点功能!") domain = StringUtils.get_url_domain(site_in.url) site_info = await SitesHelper().async_get_indexer(domain) if not site_info: return schemas.Response(success=False, message="该站点不支持,请检查站点域名是否正确") if await Site.async_get_by_domain(db, domain): return schemas.Response(success=False, message=f"{domain} 站点己存在") # 保存站点信息 site_in.domain = domain # 校正地址格式 _scheme, _netloc = StringUtils.get_url_netloc(site_in.url) site_in.url = f"{_scheme}://{_netloc}/" site_in.name = site_info.get("name") site_in.id = None site_in.public = 1 if site_info.get("public") else 0 site = Site(**site_in.model_dump()) site.create(db) # 通知站点更新 await eventmanager.async_send_event(EventType.SiteUpdated, { "domain": domain }) return schemas.Response(success=True) @router.put("/", summary="更新站点", response_model=schemas.Response) async def update_site( *, db: AsyncSession = Depends(get_async_db), site_in: schemas.Site, _: User = Depends(get_current_active_superuser) ) -> Any: """ 更新站点信息 """ site = await Site.async_get(db, site_in.id) if not site: return schemas.Response(success=False, message="站点不存在") # 校正地址格式 _scheme, _netloc = StringUtils.get_url_netloc(site_in.url) site_in.url = f"{_scheme}://{_netloc}/" site_in.domain = StringUtils.get_url_domain(site_in.url) await site.async_update(db, site_in.model_dump()) # 通知站点更新 await eventmanager.async_send_event(EventType.SiteUpdated, { "site_id": site_in.id, "domain": site_in.domain, "name": site_in.name, "site_url": site_in.url }) return schemas.Response(success=True) @router.get("/cookiecloud", summary="CookieCloud同步", response_model=schemas.Response) async def cookie_cloud_sync(background_tasks: BackgroundTasks, _: User = Depends(get_current_active_superuser_async)) -> Any: """ 运行CookieCloud同步站点信息 """ background_tasks.add_task(Scheduler().start, job_id="cookiecloud") return schemas.Response(success=True, message="CookieCloud同步任务已启动!") @router.get("/reset", summary="重置站点", response_model=schemas.Response) def reset(db: AsyncSession = Depends(get_db), _: User = Depends(get_current_active_superuser)) -> Any: """ 清空所有站点数据并重新同步CookieCloud站点信息 """ Site.reset(db) SystemConfigOper().set(SystemConfigKey.IndexerSites, []) SystemConfigOper().set(SystemConfigKey.RssSites, []) # 启动定时服务 Scheduler().start("cookiecloud", manual=True) # 插件站点删除 eventmanager.send_event(EventType.SiteDeleted, { "site_id": "*" }) return schemas.Response(success=True, message="站点已重置!") @router.post("/priorities", summary="批量更新站点优先级", response_model=schemas.Response) async def update_sites_priority( priorities: List[dict], db: AsyncSession = Depends(get_async_db), _: User = Depends(get_current_active_superuser_async)) -> Any: """ 批量更新站点优先级 """ for priority in priorities: site = await Site.async_get(db, priority.get("id")) if site: await site.async_update(db, {"pri": priority.get("pri")}) return schemas.Response(success=True) @router.get("/cookie/{site_id}", summary="更新站点Cookie&UA", response_model=schemas.Response) def update_cookie( site_id: int, username: str, password: str, code: Optional[str] = None, db: Session = Depends(get_db), _: User = Depends(get_current_active_superuser)) -> Any: """ 使用用户密码更新站点Cookie """ # 查询站点 site_info = Site.get(db, site_id) if not site_info: raise HTTPException( status_code=404, detail=f"站点 {site_id} 不存在!", ) # 更新Cookie state, message = SiteChain().update_cookie(site_info=site_info, username=username, password=password, two_step_code=code) return schemas.Response(success=state, message=message) @router.post("/userdata/{site_id}", summary="更新站点用户数据", response_model=schemas.Response) def refresh_userdata( site_id: int, db: Session = Depends(get_db), _: User = Depends(get_current_active_superuser)) -> Any: """ 刷新站点用户数据 """ site = Site.get(db, site_id) if not site: raise HTTPException( status_code=404, detail=f"站点 {site_id} 不存在", ) indexer = SitesHelper().get_indexer(site.domain) if not indexer: return schemas.Response(success=False, message="站点不支持索引或未通过用户认证!") user_data = SiteChain().refresh_userdata(site=indexer) or {} return schemas.Response(success=True, data=user_data) @router.get("/userdata/latest", summary="查询所有站点最新用户数据", response_model=List[schemas.SiteUserData]) async def read_userdata_latest( db: AsyncSession = Depends(get_async_db), _: User = Depends(get_current_active_superuser_async)) -> Any: """ 查询所有站点最新用户数据 """ user_datas = await SiteUserData.async_get_latest(db) if not user_datas: return [] return [user_data.to_dict() for user_data in user_datas] @router.get("/userdata/{site_id}", summary="查询某站点用户数据", response_model=schemas.Response) async def read_userdata( site_id: int, workdate: Optional[str] = None, db: AsyncSession = Depends(get_async_db), _: User = Depends(get_current_active_superuser_async)) -> Any: """ 查询站点用户数据 """ site = await Site.async_get(db, site_id) if not site: raise HTTPException( status_code=404, detail=f"站点 {site_id} 不存在", ) user_datas = await SiteUserData.async_get_by_domain(db, domain=site.domain, workdate=workdate) if not user_datas: return schemas.Response(success=False, data=[]) return schemas.Response(success=True, data=[data.to_dict() for data in user_datas]) @router.get("/test/{site_id}", summary="连接测试", response_model=schemas.Response) def test_site(site_id: int, db: Session = Depends(get_db), _: schemas.TokenPayload = Depends(verify_token)) -> Any: """ 测试站点是否可用 """ site = Site.get(db, site_id) if not site: raise HTTPException( status_code=404, detail=f"站点 {site_id} 不存在", ) status, message = SiteChain().test(site.domain) return schemas.Response(success=status, message=message) @router.get("/icon/{site_id}", summary="站点图标", response_model=schemas.Response) async def site_icon(site_id: int, db: AsyncSession = Depends(get_async_db), _: schemas.TokenPayload = Depends(verify_token)) -> Any: """ 获取站点图标:base64或者url """ site = await Site.async_get(db, site_id) if not site: raise HTTPException( status_code=404, detail=f"站点 {site_id} 不存在", ) icon = await SiteIcon.async_get_by_domain(db, site.domain) if not icon: return schemas.Response(success=False, message="站点图标不存在!") return schemas.Response(success=True, data={ "icon": icon.base64 if icon.base64 else icon.url }) @router.get("/category/{site_id}", summary="站点分类", response_model=List[schemas.SiteCategory]) async def site_category(site_id: int, db: AsyncSession = Depends(get_async_db), _: schemas.TokenPayload = Depends(verify_token)) -> Any: """ 获取站点分类 """ site = await Site.async_get(db, site_id) if not site: raise HTTPException( status_code=404, detail=f"站点 {site_id} 不存在", ) indexer = await SitesHelper().async_get_indexer(site.domain) if not indexer: raise HTTPException( status_code=404, detail=f"站点 {site.domain} 不支持", ) category: Dict[str, List[dict]] = indexer.get('category') or [] if not category: return [] result = [] for cats in category.values(): for cat in cats: if cat not in result: result.append(cat) return result @router.get("/resource/{site_id}", summary="站点资源", response_model=List[schemas.TorrentInfo]) async def site_resource(site_id: int, keyword: Optional[str] = None, cat: Optional[str] = None, page: Optional[int] = 0, db: AsyncSession = Depends(get_async_db), _: User = Depends(get_current_active_superuser_async)) -> Any: """ 浏览站点资源 """ site = await Site.async_get(db, site_id) if not site: raise HTTPException( status_code=404, detail=f"站点 {site_id} 不存在", ) torrents = await TorrentsChain().async_browse(domain=site.domain, keyword=keyword, cat=cat, page=page) if not torrents: return [] return [torrent.to_dict() for torrent in torrents] @router.get("/domain/{site_url}", summary="站点详情", response_model=schemas.Site) async def read_site_by_domain( site_url: str, db: AsyncSession = Depends(get_async_db), _: schemas.TokenPayload = Depends(verify_token) ) -> Any: """ 通过域名获取站点信息 """ domain = StringUtils.get_url_domain(site_url) site = await Site.async_get_by_domain(db, domain) if not site: raise HTTPException( status_code=404, detail=f"站点 {domain} 不存在", ) return site @router.get("/statistic/{site_url}", summary="特定站点统计信息", response_model=schemas.SiteStatistic) async def read_statistic_by_domain( site_url: str, db: AsyncSession = Depends(get_async_db), _: schemas.TokenPayload = Depends(verify_token) ) -> Any: """ 通过域名获取站点统计信息 """ domain = StringUtils.get_url_domain(site_url) sitestatistic = await SiteStatistic.async_get_by_domain(db, domain) if sitestatistic: return sitestatistic return schemas.SiteStatistic(domain=domain) @router.get("/statistic", summary="所有站点统计信息", response_model=List[schemas.SiteStatistic]) async def read_statistics( db: AsyncSession = Depends(get_async_db), _: schemas.TokenPayload = Depends(verify_token) ) -> Any: """ 获取所有站点统计信息 """ return await SiteStatistic.async_list(db) @router.get("/rss", summary="所有订阅站点", response_model=List[schemas.Site]) async def read_rss_sites(db: AsyncSession = Depends(get_async_db), _: schemas.TokenPayload = Depends(verify_token)) -> List[dict]: """ 获取站点列表 """ # 选中的rss站点 selected_sites = SystemConfigOper().get(SystemConfigKey.RssSites) or [] # 所有站点 all_site = await Site.async_list_order_by_pri(db) if not selected_sites: return all_site # 选中的rss站点 rss_sites = [site for site in all_site if site and site.id in selected_sites] return rss_sites @router.get("/auth", summary="查询认证站点", response_model=dict) async def read_auth_sites(_: schemas.TokenPayload = Depends(verify_token)) -> dict: """ 获取可认证站点列表 """ return SitesHelper().get_authsites() @router.post("/auth", summary="用户站点认证", response_model=schemas.Response) def auth_site( auth_info: schemas.SiteAuth, _: User = Depends(get_current_active_superuser) ) -> Any: """ 用户站点认证 """ if not auth_info or not auth_info.site or not auth_info.params: return schemas.Response(success=False, message="请输入认证站点和认证参数") status, msg = SitesHelper().check_user(auth_info.site, auth_info.params) SystemConfigOper().set(SystemConfigKey.UserSiteAuthParams, auth_info.model_dump()) # 认证成功后,重新初始化插件 PluginManager().init_config() Scheduler().init_plugin_jobs() Command().init_commands() register_plugin_api() return schemas.Response(success=status, message=msg) @router.get("/mapping", summary="获取站点域名到名称的映射", response_model=schemas.Response) async def site_mapping(_: User = Depends(get_current_active_superuser_async)): """ 获取站点域名到名称的映射关系 """ try: sites = await SiteOper().async_list() mapping = {} for site in sites: mapping[site.domain] = site.name return schemas.Response(success=True, data=mapping) except Exception as e: return schemas.Response(success=False, message=f"获取映射失败:{str(e)}") @router.get("/supporting", summary="获取支持的站点列表", response_model=dict) async def support_sites(_: User = Depends(get_current_active_superuser_async)): """ 获取支持的站点列表 """ return SitesHelper().get_indexsites() @router.get("/{site_id}", summary="站点详情", response_model=schemas.Site) async def read_site( site_id: int, db: AsyncSession = Depends(get_async_db), _: User = Depends(get_current_active_superuser_async) ) -> Any: """ 通过ID获取站点信息 """ site = await Site.async_get(db, site_id) if not site: raise HTTPException( status_code=404, detail=f"站点 {site_id} 不存在", ) return site @router.delete("/{site_id}", summary="删除站点", response_model=schemas.Response) async def delete_site( site_id: int, db: AsyncSession = Depends(get_async_db), _: User = Depends(get_current_active_superuser_async) ) -> Any: """ 删除站点 """ await Site.async_delete(db, site_id) # 插件站点删除 await eventmanager.async_send_event(EventType.SiteDeleted, { "site_id": site_id }) return schemas.Response(success=True) ================================================ FILE: app/api/endpoints/storage.py ================================================ import math from pathlib import Path from typing import Any, List, Optional from fastapi import APIRouter, Depends, HTTPException from starlette.responses import FileResponse, Response from app import schemas from app.chain.storage import StorageChain from app.chain.transfer import TransferChain from app.core.config import settings from app.core.metainfo import MetaInfoPath from app.core.security import verify_token from app.db.models import User from app.db.user_oper import get_current_active_superuser, get_current_active_superuser_async from app.helper.progress import ProgressHelper from app.schemas.types import ProgressKey from app.utils.string import StringUtils router = APIRouter() @router.get("/qrcode/{name}", summary="生成二维码内容", response_model=schemas.Response) def qrcode(name: str, _: schemas.TokenPayload = Depends(verify_token)) -> Any: """ 生成二维码 """ qrcode_data, errmsg = StorageChain().generate_qrcode(name) if qrcode_data: return schemas.Response(success=True, data=qrcode_data, message=errmsg) return schemas.Response(success=False, message=errmsg) @router.get("/auth_url/{name}", summary="获取 OAuth2 授权 URL", response_model=schemas.Response) def auth_url(name: str, _: schemas.TokenPayload = Depends(verify_token)) -> Any: """ 获取 OAuth2 授权 URL """ auth_data, errmsg = StorageChain().generate_auth_url(name) if auth_data: return schemas.Response(success=True, data=auth_data) return schemas.Response(success=False, message=errmsg) @router.get("/check/{name}", summary="二维码登录确认", response_model=schemas.Response) def check(name: str, ck: Optional[str] = None, t: Optional[str] = None, _: schemas.TokenPayload = Depends(verify_token)) -> Any: """ 二维码登录确认 """ if ck or t: data, errmsg = StorageChain().check_login(name, ck=ck, t=t) else: data, errmsg = StorageChain().check_login(name) if data: return schemas.Response(success=True, data=data) return schemas.Response(success=False, message=errmsg) @router.post("/save/{name}", summary="保存存储配置", response_model=schemas.Response) def save(name: str, conf: dict, _: User = Depends(get_current_active_superuser)) -> Any: """ 保存存储配置 """ StorageChain().save_config(name, conf) return schemas.Response(success=True) @router.get("/reset/{name}", summary="重置存储配置", response_model=schemas.Response) def reset(name: str, _: User = Depends(get_current_active_superuser)) -> Any: """ 重置存储配置 """ StorageChain().reset_config(name) return schemas.Response(success=True) @router.post("/list", summary="所有目录和文件", response_model=List[schemas.FileItem]) def list_files(fileitem: schemas.FileItem, sort: Optional[str] = 'updated_at', _: User = Depends(get_current_active_superuser)) -> Any: """ 查询当前目录下所有目录和文件 :param fileitem: 文件项 :param sort: 排序方式,name:按名称排序,time:按修改时间排序 :param _: token :return: 所有目录和文件 """ file_list = StorageChain().list_files(fileitem) if file_list: if sort == "name": file_list.sort(key=lambda x: StringUtils.natural_sort_key(x.name or "")) else: file_list.sort(key=lambda x: x.modify_time or -math.inf, reverse=True) return file_list @router.post("/mkdir", summary="创建目录", response_model=schemas.Response) def mkdir(fileitem: schemas.FileItem, name: str, _: User = Depends(get_current_active_superuser)) -> Any: """ 创建目录 :param fileitem: 文件项 :param name: 目录名称 :param _: token """ if not name: return schemas.Response(success=False) result = StorageChain().create_folder(fileitem, name) if result: return schemas.Response(success=True) return schemas.Response(success=False) @router.post("/delete", summary="删除文件或目录", response_model=schemas.Response) def delete(fileitem: schemas.FileItem, _: User = Depends(get_current_active_superuser)) -> Any: """ 删除文件或目录 :param fileitem: 文件项 :param _: token """ result = StorageChain().delete_file(fileitem) if result: return schemas.Response(success=True) return schemas.Response(success=False) @router.post("/download", summary="下载文件") def download(fileitem: schemas.FileItem, _: User = Depends(get_current_active_superuser)) -> Any: """ 下载文件或目录 :param fileitem: 文件项 :param _: token """ # 临时目录 tmp_file = StorageChain().download_file(fileitem) if tmp_file: return FileResponse(path=tmp_file) return schemas.Response(success=False) @router.post("/image", summary="预览图片") def image(fileitem: schemas.FileItem, _: User = Depends(get_current_active_superuser)) -> Any: """ 下载文件或目录 :param fileitem: 文件项 :param _: token """ # 临时目录 tmp_file = StorageChain().download_file(fileitem) if not tmp_file: raise HTTPException(status_code=500, detail="图片读取出错") return Response(content=tmp_file.read_bytes(), media_type="image/jpeg") @router.post("/rename", summary="重命名文件或目录", response_model=schemas.Response) def rename(fileitem: schemas.FileItem, new_name: str, recursive: Optional[bool] = False, _: User = Depends(get_current_active_superuser)) -> Any: """ 重命名文件或目录 :param fileitem: 文件项 :param new_name: 新名称 :param recursive: 是否递归修改 :param _: token """ if not new_name: return schemas.Response(success=False, message="新名称为空") # 重命名目录内文件 if recursive: transferchain = TransferChain() media_exts = settings.RMT_MEDIAEXT + settings.RMT_SUBEXT + settings.RMT_AUDIOEXT # 递归修改目录内文件(智能识别命名) sub_files: List[schemas.FileItem] = StorageChain().list_files(fileitem) if sub_files: # 开始进度 progress = ProgressHelper(ProgressKey.BatchRename) progress.start() total = len(sub_files) handled = 0 for sub_file in sub_files: handled += 1 progress.update(value=handled / total * 100, text=f"正在处理 {sub_file.name} ...") if sub_file.type == "dir": continue if not sub_file.extension: continue if f".{sub_file.extension.lower()}" not in media_exts: continue sub_path = Path(f"{fileitem.path}{sub_file.name}") meta = MetaInfoPath(sub_path) mediainfo = transferchain.recognize_media(meta) if not mediainfo: progress.end() return schemas.Response(success=False, message=f"{sub_path.name} 未识别到媒体信息") new_path = transferchain.recommend_name(meta=meta, mediainfo=mediainfo) if not new_path: progress.end() return schemas.Response(success=False, message=f"{sub_path.name} 未识别到新名称") ret: schemas.Response = rename(fileitem=sub_file, new_name=Path(new_path).name, recursive=False) if not ret.success: progress.end() return schemas.Response(success=False, message=f"{sub_path.name} 重命名失败!") progress.end() # 重命名自己 result = StorageChain().rename_file(fileitem, new_name) if result: return schemas.Response(success=True) return schemas.Response(success=False) @router.get("/usage/{name}", summary="存储空间信息", response_model=schemas.StorageUsage) def usage(name: str, _: User = Depends(get_current_active_superuser)) -> Any: """ 查询存储空间 """ ret = StorageChain().storage_usage(name) if ret: return ret return schemas.StorageUsage() @router.get("/transtype/{name}", summary="支持的整理方式获取", response_model=schemas.StorageTransType) async def transtype(name: str, _: User = Depends(get_current_active_superuser_async)) -> Any: """ 查询支持的整理方式 """ ret = StorageChain().support_transtype(name) if ret: return schemas.StorageTransType(transtype=ret) return schemas.StorageTransType() ================================================ FILE: app/api/endpoints/subscribe.py ================================================ from typing import List, Any, Annotated, Optional import cn2an from fastapi import APIRouter, Request, BackgroundTasks, Depends, HTTPException, Header from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import Session from app import schemas from app.chain.subscribe import SubscribeChain from app.core.config import settings from app.core.context import MediaInfo from app.core.event import eventmanager from app.core.metainfo import MetaInfo from app.core.security import verify_token, verify_apitoken from app.db import get_async_db, get_db from app.db.models.subscribe import Subscribe from app.db.models.subscribehistory import SubscribeHistory from app.db.models.user import User from app.db.systemconfig_oper import SystemConfigOper from app.db.user_oper import get_current_active_user_async from app.helper.subscribe import SubscribeHelper from app.scheduler import Scheduler from app.schemas.types import MediaType, EventType, SystemConfigKey router = APIRouter() def start_subscribe_add(title: str, year: str, mtype: MediaType, tmdbid: int, season: int, username: str): """ 启动订阅任务 """ SubscribeChain().add(title=title, year=year, mtype=mtype, tmdbid=tmdbid, season=season, username=username) @router.get("/", summary="查询所有订阅", response_model=List[schemas.Subscribe]) async def read_subscribes( db: AsyncSession = Depends(get_async_db), _: schemas.TokenPayload = Depends(verify_token)) -> Any: """ 查询所有订阅 """ return await Subscribe.async_list(db) @router.get("/list", summary="查询所有订阅(API_TOKEN)", response_model=List[schemas.Subscribe]) async def list_subscribes(_: Annotated[str, Depends(verify_apitoken)]) -> Any: """ 查询所有订阅 API_TOKEN认证(?token=xxx) """ return await read_subscribes() @router.post("/", summary="新增订阅", response_model=schemas.Response) async def create_subscribe( *, subscribe_in: schemas.Subscribe, current_user: User = Depends(get_current_active_user_async), ) -> schemas.Response: """ 新增订阅 """ # 类型转换 if subscribe_in.type: mtype = MediaType(subscribe_in.type) else: mtype = None # 豆瓣标理 if subscribe_in.doubanid or subscribe_in.bangumiid: meta = MetaInfo(subscribe_in.name) subscribe_in.name = meta.name subscribe_in.season = meta.begin_season # 标题转换 if subscribe_in.name: title = subscribe_in.name else: title = None # 订阅用户 subscribe_in.username = current_user.name # 转化为字典 subscribe_dict = subscribe_in.model_dump() if subscribe_in.id: subscribe_dict.pop("id", None) sid, message = await SubscribeChain().async_add(mtype=mtype, title=title, exist_ok=True, **subscribe_dict) return schemas.Response( success=bool(sid), message=message, data={"id": sid} ) @router.put("/", summary="更新订阅", response_model=schemas.Response) async def update_subscribe( *, subscribe_in: schemas.Subscribe, db: AsyncSession = Depends(get_async_db), _: schemas.TokenPayload = Depends(verify_token) ) -> Any: """ 更新订阅信息 """ subscribe = await Subscribe.async_get(db, subscribe_in.id) if not subscribe: return schemas.Response(success=False, message="订阅不存在") # 避免更新缺失集数 old_subscribe_dict = subscribe.to_dict() subscribe_dict = subscribe_in.model_dump() if not subscribe_in.lack_episode: # 没有缺失集数时,缺失集数清空,避免更新为0 subscribe_dict.pop("lack_episode") elif subscribe_in.total_episode: # 总集数增加时,缺失集数也要增加 if subscribe_in.total_episode > (subscribe.total_episode or 0): subscribe_dict["lack_episode"] = (subscribe.lack_episode + (subscribe_in.total_episode - (subscribe.total_episode or 0))) # 是否手动修改过总集数 if subscribe_in.total_episode != subscribe.total_episode: subscribe_dict["manual_total_episode"] = 1 # 更新到数据库 await subscribe.async_update(db, subscribe_dict) # 重新获取更新后的订阅数据 updated_subscribe = await Subscribe.async_get(db, subscribe_in.id) # 发送订阅调整事件 await eventmanager.async_send_event(EventType.SubscribeModified, { "subscribe_id": subscribe_in.id, "old_subscribe_info": old_subscribe_dict, "subscribe_info": updated_subscribe.to_dict() if updated_subscribe else {}, }) return schemas.Response(success=True) @router.put("/status/{subid}", summary="更新订阅状态", response_model=schemas.Response) async def update_subscribe_status( subid: int, state: str, db: AsyncSession = Depends(get_async_db), _: schemas.TokenPayload = Depends(verify_token)) -> Any: """ 更新订阅状态 """ subscribe = await Subscribe.async_get(db, subid) if not subscribe: return schemas.Response(success=False, message="订阅不存在") valid_states = ["R", "P", "S"] if state not in valid_states: return schemas.Response(success=False, message="无效的订阅状态") old_subscribe_dict = subscribe.to_dict() await subscribe.async_update(db, { "state": state }) # 重新获取更新后的订阅数据 updated_subscribe = await Subscribe.async_get(db, subid) # 发送订阅调整事件 await eventmanager.async_send_event(EventType.SubscribeModified, { "subscribe_id": subid, "old_subscribe_info": old_subscribe_dict, "subscribe_info": updated_subscribe.to_dict() if updated_subscribe else {}, }) return schemas.Response(success=True) @router.get("/media/{mediaid}", summary="查询订阅", response_model=schemas.Subscribe) async def subscribe_mediaid( mediaid: str, season: Optional[int] = None, title: Optional[str] = None, db: AsyncSession = Depends(get_async_db), _: schemas.TokenPayload = Depends(verify_token)) -> Any: """ 根据 TMDBID/豆瓣ID/BangumiId 查询订阅 tmdb:/douban: """ title_check = False if mediaid.startswith("tmdb:"): tmdbid = mediaid[5:] if not tmdbid or not str(tmdbid).isdigit(): return Subscribe() result = await Subscribe.async_exists(db, tmdbid=int(tmdbid), season=season) elif mediaid.startswith("douban:"): doubanid = mediaid[7:] if not doubanid: return Subscribe() result = await Subscribe.async_get_by_doubanid(db, doubanid) if not result and title: title_check = True elif mediaid.startswith("bangumi:"): bangumiid = mediaid[8:] if not bangumiid or not str(bangumiid).isdigit(): return Subscribe() result = await Subscribe.async_get_by_bangumiid(db, int(bangumiid)) if not result and title: title_check = True else: result = await Subscribe.async_get_by_mediaid(db, mediaid) if not result and title: title_check = True # 使用名称检查订阅 if title_check and title: meta = MetaInfo(title) if season is not None: meta.begin_season = season result = await Subscribe.async_get_by_title(db, title=meta.name, season=meta.begin_season) return result if result else Subscribe() @router.get("/refresh", summary="刷新订阅", response_model=schemas.Response) def refresh_subscribes( _: schemas.TokenPayload = Depends(verify_token)) -> Any: """ 刷新所有订阅 """ Scheduler().start("subscribe_refresh") return schemas.Response(success=True) @router.get("/reset/{subid}", summary="重置订阅", response_model=schemas.Response) async def reset_subscribes( subid: int, db: AsyncSession = Depends(get_async_db), _: schemas.TokenPayload = Depends(verify_token)) -> Any: """ 重置订阅 """ subscribe = await Subscribe.async_get(db, subid) if subscribe: # 在更新之前获取旧数据 old_subscribe_dict = subscribe.to_dict() # 更新订阅 await subscribe.async_update(db, { "note": [], "lack_episode": subscribe.total_episode, "state": "R" }) # 重新获取更新后的订阅数据 updated_subscribe = await Subscribe.async_get(db, subid) # 发送订阅调整事件 await eventmanager.async_send_event(EventType.SubscribeModified, { "subscribe_id": subid, "old_subscribe_info": old_subscribe_dict, "subscribe_info": updated_subscribe.to_dict() if updated_subscribe else {}, }) return schemas.Response(success=True) return schemas.Response(success=False, message="订阅不存在") @router.get("/check", summary="刷新订阅 TMDB 信息", response_model=schemas.Response) def check_subscribes( _: schemas.TokenPayload = Depends(verify_token)) -> Any: """ 刷新订阅 TMDB 信息 """ Scheduler().start("subscribe_tmdb") return schemas.Response(success=True) @router.get("/search", summary="搜索所有订阅", response_model=schemas.Response) async def search_subscribes( background_tasks: BackgroundTasks, _: schemas.TokenPayload = Depends(verify_token)) -> Any: """ 搜索所有订阅 """ background_tasks.add_task( Scheduler().start, job_id="subscribe_search", **{ "sid": None, "state": 'R', "manual": True } ) return schemas.Response(success=True) @router.get("/search/{subscribe_id}", summary="搜索订阅", response_model=schemas.Response) async def search_subscribe( subscribe_id: int, background_tasks: BackgroundTasks, _: schemas.TokenPayload = Depends(verify_token)) -> Any: """ 根据订阅编号搜索订阅 """ background_tasks.add_task( Scheduler().start, job_id="subscribe_search", **{ "sid": subscribe_id, "state": None, "manual": True } ) return schemas.Response(success=True) @router.delete("/media/{mediaid}", summary="删除订阅", response_model=schemas.Response) async def delete_subscribe_by_mediaid( mediaid: str, season: Optional[int] = None, db: AsyncSession = Depends(get_async_db), _: schemas.TokenPayload = Depends(verify_token) ) -> Any: """ 根据TMDBID或豆瓣ID删除订阅 tmdb:/douban: """ delete_subscribes = [] if mediaid.startswith("tmdb:"): tmdbid = mediaid[5:] if not tmdbid or not str(tmdbid).isdigit(): return schemas.Response(success=False) subscribes = await Subscribe.async_get_by_tmdbid(db, int(tmdbid), season) delete_subscribes.extend(subscribes) elif mediaid.startswith("douban:"): doubanid = mediaid[7:] if not doubanid: return schemas.Response(success=False) subscribe = await Subscribe.async_get_by_doubanid(db, doubanid) if subscribe: delete_subscribes.append(subscribe) else: subscribe = await Subscribe.async_get_by_mediaid(db, mediaid) if subscribe: delete_subscribes.append(subscribe) for subscribe in delete_subscribes: # 在删除之前获取订阅信息 subscribe_info = subscribe.to_dict() subscribe_id = subscribe.id await Subscribe.async_delete(db, subscribe_id) # 发送事件 await eventmanager.async_send_event(EventType.SubscribeDeleted, { "subscribe_id": subscribe_id, "subscribe_info": subscribe_info }) return schemas.Response(success=True) @router.post("/seerr", summary="OverSeerr/JellySeerr通知订阅", response_model=schemas.Response) async def seerr_subscribe(request: Request, background_tasks: BackgroundTasks, authorization: Annotated[str | None, Header()] = None) -> Any: """ Jellyseerr/Overseerr网络勾子通知订阅 """ if not authorization or authorization != settings.API_TOKEN: raise HTTPException( status_code=400, detail="授权失败", ) req_json = await request.json() if not req_json: raise HTTPException( status_code=500, detail="报文内容为空", ) notification_type = req_json.get("notification_type") if notification_type not in ["MEDIA_APPROVED", "MEDIA_AUTO_APPROVED"]: return schemas.Response(success=False, message="不支持的通知类型") subject = req_json.get("subject") media_type = MediaType.MOVIE if req_json.get("media", {}).get("media_type") == "movie" else MediaType.TV tmdbId = req_json.get("media", {}).get("tmdbId") if not media_type or not tmdbId or not subject: return schemas.Response(success=False, message="请求参数不正确") user_name = req_json.get("request", {}).get("requestedBy_username") # 添加订阅 if media_type == MediaType.MOVIE: background_tasks.add_task(start_subscribe_add, mtype=media_type, tmdbid=tmdbId, title=subject, year="", season=0, username=user_name) else: seasons = [] for extra in req_json.get("extra", []): if extra.get("name") == "Requested Seasons": seasons = [int(str(sea).strip()) for sea in extra.get("value").split(", ") if str(sea).isdigit()] break for season in seasons: background_tasks.add_task(start_subscribe_add, mtype=media_type, tmdbid=tmdbId, title=subject, year="", season=season, username=user_name) return schemas.Response(success=True) @router.get("/history/{mtype}", summary="查询订阅历史", response_model=List[schemas.Subscribe]) async def subscribe_history( mtype: str, page: Optional[int] = 1, count: Optional[int] = 30, db: AsyncSession = Depends(get_async_db), _: schemas.TokenPayload = Depends(verify_token)) -> Any: """ 查询电影/电视剧订阅历史 """ return await SubscribeHistory.async_list_by_type(db, mtype=mtype, page=page, count=count) @router.delete("/history/{history_id}", summary="删除订阅历史", response_model=schemas.Response) async def delete_subscribe( history_id: int, db: AsyncSession = Depends(get_async_db), _: schemas.TokenPayload = Depends(verify_token) ) -> Any: """ 删除订阅历史 """ await SubscribeHistory.async_delete(db, history_id) return schemas.Response(success=True) @router.get("/popular", summary="热门订阅(基于用户共享数据)", response_model=List[schemas.MediaInfo]) async def popular_subscribes( stype: str, page: Optional[int] = 1, count: Optional[int] = 30, min_sub: Optional[int] = None, genre_id: Optional[int] = None, min_rating: Optional[float] = None, max_rating: Optional[float] = None, sort_type: Optional[str] = None, _: schemas.TokenPayload = Depends(verify_token)) -> Any: """ 查询热门订阅 """ subscribes = await SubscribeHelper().async_get_statistic( stype=stype, page=page, count=count, genre_id=genre_id, min_rating=min_rating, max_rating=max_rating, sort_type=sort_type ) if subscribes: ret_medias = [] for sub in subscribes: # 订阅人数 count = sub.get("count") if min_sub and count < min_sub: continue media = MediaInfo() media.type = MediaType(sub.get("type")) media.tmdb_id = sub.get("tmdbid") # 处理标题 title = sub.get("name") season = sub.get("season") if season and int(season) > 1 and media.tmdb_id: # 小写数据转大写 season_str = cn2an.an2cn(season, "low") title = f"{title} 第{season_str}季" media.title = title media.year = sub.get("year") media.douban_id = sub.get("doubanid") media.bangumi_id = sub.get("bangumiid") media.tvdb_id = sub.get("tvdbid") media.imdb_id = sub.get("imdbid") media.season = sub.get("season") media.overview = sub.get("description") media.vote_average = sub.get("vote") media.poster_path = sub.get("poster") media.backdrop_path = sub.get("backdrop") media.popularity = count ret_medias.append(media) return [media.to_dict() for media in ret_medias] return [] @router.get("/user/{username}", summary="用户订阅", response_model=List[schemas.Subscribe]) async def user_subscribes( username: str, db: AsyncSession = Depends(get_async_db), _: schemas.TokenPayload = Depends(verify_token)) -> Any: """ 查询用户订阅 """ return await Subscribe.async_list_by_username(db, username) @router.get("/files/{subscribe_id}", summary="订阅相关文件信息", response_model=schemas.SubscrbieInfo) def subscribe_files( subscribe_id: int, db: Session = Depends(get_db), _: schemas.TokenPayload = Depends(verify_token)) -> Any: """ 订阅相关文件信息 """ subscribe = Subscribe.get(db, subscribe_id) if subscribe: return SubscribeChain().subscribe_files_info(subscribe) return schemas.SubscrbieInfo() @router.post("/share", summary="分享订阅", response_model=schemas.Response) async def subscribe_share( sub: schemas.SubscribeShare, _: schemas.TokenPayload = Depends(verify_token)) -> Any: """ 分享订阅 """ state, errmsg = await SubscribeHelper().async_sub_share(subscribe_id=sub.subscribe_id, share_title=sub.share_title, share_comment=sub.share_comment, share_user=sub.share_user) return schemas.Response(success=state, message=errmsg) @router.delete("/share/{share_id}", summary="删除分享", response_model=schemas.Response) async def subscribe_share_delete( share_id: int, _: schemas.TokenPayload = Depends(verify_token)) -> Any: """ 删除分享 """ state, errmsg = await SubscribeHelper().async_share_delete(share_id=share_id) return schemas.Response(success=state, message=errmsg) @router.post("/fork", summary="复用订阅", response_model=schemas.Response) async def subscribe_fork( sub: schemas.SubscribeShare, current_user: User = Depends(get_current_active_user_async)) -> Any: """ 复用订阅 """ sub_dict = sub.model_dump() sub_dict.pop("id") for key in list(sub_dict.keys()): if not hasattr(schemas.Subscribe(), key): sub_dict.pop(key) result = await create_subscribe(subscribe_in=schemas.Subscribe(**sub_dict), current_user=current_user) if result.success: await SubscribeHelper().async_sub_fork(share_id=sub.id) return result @router.get("/follow", summary="查询已Follow的订阅分享人", response_model=List[str]) async def followed_subscribers(_: schemas.TokenPayload = Depends(verify_token)) -> Any: """ 查询已Follow的订阅分享人 """ return SystemConfigOper().get(SystemConfigKey.FollowSubscribers) or [] @router.post("/follow", summary="Follow订阅分享人", response_model=schemas.Response) async def follow_subscriber( share_uid: Optional[str] = None, _: schemas.TokenPayload = Depends(verify_token)) -> Any: """ Follow订阅分享人 """ subscribers = SystemConfigOper().get(SystemConfigKey.FollowSubscribers) or [] if share_uid and share_uid not in subscribers: subscribers.append(share_uid) await SystemConfigOper().async_set(SystemConfigKey.FollowSubscribers, subscribers) return schemas.Response(success=True) @router.delete("/follow", summary="取消Follow订阅分享人", response_model=schemas.Response) async def unfollow_subscriber( share_uid: Optional[str] = None, _: schemas.TokenPayload = Depends(verify_token)) -> Any: """ 取消Follow订阅分享人 """ subscribers = SystemConfigOper().get(SystemConfigKey.FollowSubscribers) or [] if share_uid and share_uid in subscribers: subscribers.remove(share_uid) await SystemConfigOper().async_set(SystemConfigKey.FollowSubscribers, subscribers) return schemas.Response(success=True) @router.get("/shares", summary="查询分享的订阅", response_model=List[schemas.SubscribeShare]) async def popular_subscribes( name: Optional[str] = None, page: Optional[int] = 1, count: Optional[int] = 30, genre_id: Optional[int] = None, min_rating: Optional[float] = None, max_rating: Optional[float] = None, sort_type: Optional[str] = None, _: schemas.TokenPayload = Depends(verify_token)) -> Any: """ 查询分享的订阅 """ return await SubscribeHelper().async_get_shares( name=name, page=page, count=count, genre_id=genre_id, min_rating=min_rating, max_rating=max_rating, sort_type=sort_type ) @router.get("/share/statistics", summary="查询订阅分享统计", response_model=List[schemas.SubscribeShareStatistics]) async def subscribe_share_statistics(_: schemas.TokenPayload = Depends(verify_token)) -> Any: """ 查询订阅分享统计 返回每个分享人分享的媒体数量以及总的复用人次 """ return await SubscribeHelper().async_get_share_statistics() @router.get("/{subscribe_id}", summary="订阅详情", response_model=schemas.Subscribe) async def read_subscribe( subscribe_id: int, db: AsyncSession = Depends(get_async_db), _: schemas.TokenPayload = Depends(verify_token)) -> Any: """ 根据订阅编号查询订阅信息 """ if not subscribe_id: return Subscribe() return await Subscribe.async_get(db, subscribe_id) @router.delete("/{subscribe_id}", summary="删除订阅", response_model=schemas.Response) async def delete_subscribe( subscribe_id: int, db: AsyncSession = Depends(get_async_db), _: schemas.TokenPayload = Depends(verify_token) ) -> Any: """ 删除订阅信息 """ subscribe = await Subscribe.async_get(db, subscribe_id) if subscribe: # 在删除之前获取订阅信息 subscribe_info = subscribe.to_dict() await Subscribe.async_delete(db, subscribe_id) # 发送事件 await eventmanager.async_send_event(EventType.SubscribeDeleted, { "subscribe_id": subscribe_id, "subscribe_info": subscribe_info }) # 统计订阅 SubscribeHelper().sub_done_async({ "tmdbid": subscribe.tmdbid, "doubanid": subscribe.doubanid }) return schemas.Response(success=True) ================================================ FILE: app/api/endpoints/system.py ================================================ import asyncio import json import re from collections import deque from datetime import datetime from typing import Optional, Union, Annotated import aiofiles import pillow_avif # noqa 用于自动注册AVIF支持 from anyio import Path as AsyncPath from app.helper.sites import SitesHelper # noqa # noqa from fastapi import APIRouter, Body, Depends, HTTPException, Header, Request, Response from fastapi.responses import StreamingResponse from app import schemas from app.chain.mediaserver import MediaServerChain from app.chain.search import SearchChain from app.chain.system import SystemChain from app.core.config import global_vars, settings from app.core.event import eventmanager from app.core.metainfo import MetaInfo from app.core.module import ModuleManager from app.core.security import verify_apitoken, verify_resource_token, verify_token from app.db.models import User from app.db.systemconfig_oper import SystemConfigOper from app.db.user_oper import get_current_active_superuser, get_current_active_superuser_async, \ get_current_active_user_async from app.helper.llm import LLMHelper from app.helper.mediaserver import MediaServerHelper from app.helper.message import MessageHelper from app.helper.progress import ProgressHelper from app.helper.rule import RuleHelper from app.helper.subscribe import SubscribeHelper from app.helper.system import SystemHelper from app.helper.image import ImageHelper from app.log import logger from app.scheduler import Scheduler from app.schemas import ConfigChangeEventData from app.schemas.types import SystemConfigKey, EventType from app.utils.crypto import HashUtils from app.utils.http import RequestUtils, AsyncRequestUtils from app.utils.security import SecurityUtils from app.utils.url import UrlUtils from version import APP_VERSION router = APIRouter() async def fetch_image( url: str, proxy: Optional[bool] = None, use_cache: bool = False, if_none_match: Optional[str] = None, cookies: Optional[str | dict] = None, allowed_domains: Optional[set[str]] = None) -> Optional[Response]: """ 处理图片缓存逻辑,支持HTTP缓存和磁盘缓存 """ if not url: return None if allowed_domains is None: allowed_domains = set(settings.SECURITY_IMAGE_DOMAINS) # 验证URL安全性 if not SecurityUtils.is_safe_url(url, allowed_domains): logger.warn(f"Blocked unsafe image URL: {url}") return None content = await ImageHelper().async_fetch_image( url=url, proxy=proxy, use_cache=use_cache, cookies=cookies, ) if content: # 检查 If-None-Match etag = HashUtils.md5(content) headers = RequestUtils.generate_cache_headers(etag, max_age=86400 * 7) if if_none_match == etag: return Response(status_code=304, headers=headers) # 返回缓存图片 return Response( content=content, media_type=UrlUtils.get_mime_type(url, "image/jpeg"), headers=headers ) @router.get("/img/{proxy}", summary="图片代理") async def proxy_img( imgurl: str, proxy: bool = False, cache: bool = False, use_cookies: bool = False, if_none_match: Annotated[str | None, Header()] = None, _: schemas.TokenPayload = Depends(verify_resource_token) ) -> Response: """ 图片代理,可选是否使用代理服务器,支持 HTTP 缓存 """ # 媒体服务器添加图片代理支持 hosts = [config.config.get("host") for config in MediaServerHelper().get_configs().values() if config and config.config and config.config.get("host")] allowed_domains = set(settings.SECURITY_IMAGE_DOMAINS) | set(hosts) cookies = ( MediaServerChain().get_image_cookies(server=None, image_url=imgurl) if use_cookies else None ) return await fetch_image(url=imgurl, proxy=proxy, use_cache=cache, cookies=cookies, if_none_match=if_none_match, allowed_domains=allowed_domains) @router.get("/cache/image", summary="图片缓存") async def cache_img( url: str, if_none_match: Annotated[str | None, Header()] = None, _: schemas.TokenPayload = Depends(verify_resource_token) ) -> Response: """ 本地缓存图片文件,支持 HTTP 缓存,如果启用全局图片缓存,则使用磁盘缓存 """ # 如果没有启用全局图片缓存,则不使用磁盘缓存 return await fetch_image(url=url, use_cache=settings.GLOBAL_IMAGE_CACHE, if_none_match=if_none_match) @router.get("/global", summary="查询非敏感系统设置", response_model=schemas.Response) def get_global_setting(token: str): """ 查询非敏感系统设置(默认鉴权) 仅包含登录前UI初始化必需的字段 """ if token != "moviepilot": raise HTTPException(status_code=403, detail="Forbidden") # 白名单模式,仅包含登录前UI初始化必需的字段 info = settings.model_dump( include={ "TMDB_IMAGE_DOMAIN", "GLOBAL_IMAGE_CACHE", "ADVANCED_MODE", } ) # 追加版本信息(用于版本检查) info.update({ "FRONTEND_VERSION": SystemChain.get_frontend_version(), "BACKEND_VERSION": APP_VERSION }) return schemas.Response(success=True, data=info) @router.get("/global/user", summary="查询用户相关系统设置", response_model=schemas.Response) async def get_user_global_setting(_: User = Depends(get_current_active_user_async)): """ 查询用户相关系统设置(登录后获取) 包含业务功能相关的配置和用户权限信息 """ # 业务功能相关的配置字段 info = settings.model_dump( include={ "RECOGNIZE_SOURCE", "SEARCH_SOURCE", "AI_RECOMMEND_ENABLED", "PASSKEY_ALLOW_REGISTER_WITHOUT_OTP" } ) # 智能助手总开关未开启,智能推荐状态强制返回False if not settings.AI_AGENT_ENABLE: info["AI_RECOMMEND_ENABLED"] = False # 追加用户唯一ID和订阅分享管理权限 share_admin = SubscribeHelper().is_admin_user() info.update({ "USER_UNIQUE_ID": SubscribeHelper().get_user_uuid(), "SUBSCRIBE_SHARE_MANAGE": share_admin, "WORKFLOW_SHARE_MANAGE": share_admin, }) return schemas.Response(success=True, data=info) @router.get("/env", summary="查询系统配置", response_model=schemas.Response) async def get_env_setting(_: User = Depends(get_current_active_user_async)): """ 查询系统环境变量,包括当前版本号(仅管理员) """ info = settings.model_dump( exclude={"SECRET_KEY", "RESOURCE_SECRET_KEY"} ) info.update({ "VERSION": APP_VERSION, "AUTH_VERSION": SitesHelper().auth_version, "INDEXER_VERSION": SitesHelper().indexer_version, "FRONTEND_VERSION": SystemChain().get_frontend_version() }) return schemas.Response(success=True, data=info) @router.post("/env", summary="更新系统配置", response_model=schemas.Response) async def set_env_setting(env: dict, _: User = Depends(get_current_active_superuser_async)): """ 更新系统环境变量(仅管理员) """ result = settings.update_settings(env=env) # 统计成功和失败的结果 success_updates = {k: v for k, v in result.items() if v[0]} failed_updates = {k: v for k, v in result.items() if v[0] is False} if failed_updates: return schemas.Response( success=False, message=f"{', '.join([v[1] for v in failed_updates.values()])}", data={ "success_updates": success_updates, "failed_updates": failed_updates } ) if success_updates: # 发送配置变更事件 await eventmanager.async_send_event(etype=EventType.ConfigChanged, data=ConfigChangeEventData( key=success_updates.keys(), change_type="update" )) return schemas.Response( success=True, message="所有配置项更新成功", data={ "success_updates": success_updates } ) @router.get("/progress/{process_type}", summary="实时进度") async def get_progress(request: Request, process_type: str, _: schemas.TokenPayload = Depends(verify_resource_token)): """ 实时获取处理进度,返回格式为SSE """ progress = ProgressHelper(process_type) async def event_generator(): try: while not global_vars.is_system_stopped: if await request.is_disconnected(): break detail = progress.get() yield f"data: {json.dumps(detail)}\n\n" await asyncio.sleep(0.5) except asyncio.CancelledError: return return StreamingResponse(event_generator(), media_type="text/event-stream") @router.get("/setting/{key}", summary="查询系统设置", response_model=schemas.Response) async def get_setting(key: str, _: User = Depends(get_current_active_user_async)): """ 查询系统设置(仅管理员) """ if hasattr(settings, key): value = getattr(settings, key) else: value = SystemConfigOper().get(key) return schemas.Response(success=True, data={ "value": value }) @router.post("/setting/{key}", summary="更新系统设置", response_model=schemas.Response) async def set_setting( key: str, value: Annotated[Union[list, dict, bool, int, str] | None, Body()] = None, _: User = Depends(get_current_active_superuser_async), ): """ 更新系统设置(仅管理员) """ if hasattr(settings, key): success, message = settings.update_setting(key=key, value=value) if success: # 发送配置变更事件 await eventmanager.async_send_event(etype=EventType.ConfigChanged, data=ConfigChangeEventData( key=key, value=value, change_type="update" )) elif success is None: success = True return schemas.Response(success=success, message=message) elif key in {item.value for item in SystemConfigKey}: if isinstance(value, list): value = list(filter(None, value)) value = value if value else None success = await SystemConfigOper().async_set(key, value) if success: # 发送配置变更事件 await eventmanager.async_send_event(etype=EventType.ConfigChanged, data=ConfigChangeEventData( key=key, value=value, change_type="update" )) return schemas.Response(success=True) else: return schemas.Response(success=False, message=f"配置项 '{key}' 不存在") @router.get("/llm-models", summary="获取LLM模型列表", response_model=schemas.Response) async def get_llm_models(provider: str, api_key: str, base_url: Optional[str] = None, _: User = Depends(get_current_active_user_async)): """ 获取LLM模型列表 """ try: models = LLMHelper().get_models(provider, api_key, base_url) return schemas.Response(success=True, data=models) except Exception as e: return schemas.Response(success=False, message=str(e)) @router.get("/message", summary="实时消息") async def get_message(request: Request, role: Optional[str] = "system", _: schemas.TokenPayload = Depends(verify_resource_token)): """ 实时获取系统消息,返回格式为SSE """ message = MessageHelper() async def event_generator(): try: while not global_vars.is_system_stopped: if await request.is_disconnected(): break detail = message.get(role) yield f"data: {detail or ''}\n\n" await asyncio.sleep(3) except asyncio.CancelledError: return return StreamingResponse(event_generator(), media_type="text/event-stream") @router.get("/logging", summary="实时日志") async def get_logging(request: Request, length: Optional[int] = 50, logfile: Optional[str] = "moviepilot.log", _: schemas.TokenPayload = Depends(verify_resource_token)): """ 实时获取系统日志 length = -1 时, 返回text/plain 否则 返回格式SSE """ base_path = AsyncPath(settings.LOG_PATH) log_path = base_path / logfile if not await SecurityUtils.async_is_safe_path(base_path=base_path, user_path=log_path, allowed_suffixes={".log"}): raise HTTPException(status_code=404, detail="Not Found") if not await log_path.exists() or not await log_path.is_file(): raise HTTPException(status_code=404, detail="Not Found") async def log_generator(): try: # 使用固定大小的双向队列来限制内存使用 lines_queue = deque(maxlen=max(length, 50)) # 获取文件大小 file_stat = await log_path.stat() file_size = file_stat.st_size # 读取历史日志 async with aiofiles.open(log_path, mode="r", encoding="utf-8", errors="ignore") as f: # 优化大文件读取策略 if file_size > 100 * 1024: # 只读取最后100KB的内容 bytes_to_read = min(file_size, 100 * 1024) position = file_size - bytes_to_read await f.seek(position) content = await f.read() # 找到第一个完整的行 first_newline = content.find('\n') if first_newline != -1: content = content[first_newline + 1:] else: # 小文件直接读取全部内容 content = await f.read() # 按行分割并添加到队列,只保留非空行 lines = [line.strip() for line in content.splitlines() if line.strip()] # 只取最后N行 for line in lines[-max(length, 50):]: lines_queue.append(line) # 输出历史日志 for line in lines_queue: yield f"data: {line}\n\n" # 实时监听新日志 async with aiofiles.open(log_path, mode="r", encoding="utf-8", errors="ignore") as f: # 移动文件指针到文件末尾,继续监听新增内容 await f.seek(0, 2) # 记录初始文件大小 initial_stat = await log_path.stat() initial_size = initial_stat.st_size # 实时监听新日志,使用更短的轮询间隔 while not global_vars.is_system_stopped: if await request.is_disconnected(): break # 检查文件是否有新内容 current_stat = await log_path.stat() current_size = current_stat.st_size if current_size > initial_size: # 文件有新内容,读取新行 line = await f.readline() if line: line = line.strip() if line: yield f"data: {line}\n\n" initial_size = current_size else: # 没有新内容,短暂等待 await asyncio.sleep(0.5) except asyncio.CancelledError: return except Exception as err: logger.error(f"日志读取异常: {err}") yield f"data: 日志读取异常: {err}\n\n" # 根据length参数返回不同的响应 if length == -1: # 返回全部日志作为文本响应 if not await log_path.exists(): return Response(content="日志文件不存在!", media_type="text/plain") try: # 使用 aiofiles 异步读取文件 async with aiofiles.open(log_path, mode="r", encoding="utf-8", errors="ignore") as file: text = await file.read() # 倒序输出 text = "\n".join(text.split("\n")[::-1]) return Response(content=text, media_type="text/plain") except Exception as e: return Response(content=f"读取日志文件失败: {e}", media_type="text/plain") else: # 返回SSE流响应 return StreamingResponse(log_generator(), media_type="text/event-stream") @router.get("/versions", summary="查询Github所有Release版本", response_model=schemas.Response) async def latest_version(_: schemas.TokenPayload = Depends(verify_token)): """ 查询Github所有Release版本 """ version_res = await AsyncRequestUtils(proxies=settings.PROXY, headers=settings.GITHUB_HEADERS).get_res( f"https://api.github.com/repos/jxxghp/MoviePilot/releases") if version_res: ver_json = version_res.json() if ver_json: return schemas.Response(success=True, data=ver_json) return schemas.Response(success=False) @router.get("/ruletest", summary="过滤规则测试", response_model=schemas.Response) def ruletest(title: str, rulegroup_name: str, subtitle: Optional[str] = None, _: schemas.TokenPayload = Depends(verify_token)): """ 过滤规则测试,规则类型 1-订阅,2-洗版,3-搜索 """ torrent = schemas.TorrentInfo( title=title, description=subtitle, ) # 查询规则组详情 rulegroup = RuleHelper().get_rule_group(rulegroup_name) if not rulegroup: return schemas.Response(success=False, message=f"过滤规则组 {rulegroup_name} 不存在!") # 根据标题查询媒体信息 media_info = SearchChain().recognize_media(MetaInfo(title=title, subtitle=subtitle)) if not media_info: return schemas.Response(success=False, message="未识别到媒体信息!") # 过滤 result = SearchChain().filter_torrents(rule_groups=[rulegroup.name], torrent_list=[torrent], mediainfo=media_info) if not result: return schemas.Response(success=False, message="不符合过滤规则!") return schemas.Response(success=True, data={ "priority": 100 - result[0].pri_order + 1 }) @router.get("/nettest", summary="测试网络连通性") async def nettest( url: str, proxy: bool, include: Optional[str] = None, _: schemas.TokenPayload = Depends(verify_token), ): """ 测试网络连通性 """ # 记录开始的毫秒数 start_time = datetime.now() headers = None # 当前使用的加速代理 proxy_name = "" if "github" in url: # 这是github的连通性测试 headers = settings.GITHUB_HEADERS if "{GITHUB_PROXY}" in url: url = url.replace( "{GITHUB_PROXY}", UrlUtils.standardize_base_url(settings.GITHUB_PROXY or "") ) if settings.GITHUB_PROXY: proxy_name = "Github加速代理" if "{PIP_PROXY}" in url: url = url.replace( "{PIP_PROXY}", UrlUtils.standardize_base_url( settings.PIP_PROXY or "https://pypi.org/simple/" ), ) if settings.PIP_PROXY: proxy_name = "PIP加速代理" url = url.replace("{TMDBAPIKEY}", settings.TMDB_API_KEY) result = await AsyncRequestUtils( proxies=settings.PROXY if proxy else None, headers=headers, timeout=10, ua=settings.NORMAL_USER_AGENT, ).get_res(url) # 计时结束的毫秒数 end_time = datetime.now() time = round((end_time - start_time).total_seconds() * 1000) # 计算相关秒数 if result is None: return schemas.Response( success=False, message=f"{proxy_name}无法连接", data={"time": time} ) elif result.status_code == 200: if include and not re.search(r"%s" % include, result.text, re.IGNORECASE): # 通常是被加速代理跳转到其它页面了 logger.error(f"{url} 的响应内容不匹配包含规则 {include}") if proxy_name: message = f"{proxy_name}已失效,请检查配置" else: message = f"无效响应,不匹配 {include}" return schemas.Response( success=False, message=message, data={"time": time}, ) return schemas.Response(success=True, data={"time": time}) else: if proxy_name: # 加速代理失败 message = f"{proxy_name}已失效,错误码:{result.status_code}" else: message = f"错误码:{result.status_code}" if "github" in url: # 非加速代理访问github if result.status_code == 401: message = "Github Token已失效,请检查配置" elif result.status_code in {403, 429}: message = "触发限流,请配置Github Token" return schemas.Response(success=False, message=message, data={"time": time}) @router.get("/modulelist", summary="查询已加载的模块ID列表", response_model=schemas.Response) def modulelist(_: schemas.TokenPayload = Depends(verify_token)): """ 查询已加载的模块ID列表 """ modules = [{ "id": k, "name": v.get_name(), } for k, v in ModuleManager().get_modules().items()] return schemas.Response(success=True, data={ "modules": modules }) @router.get("/moduletest/{moduleid}", summary="模块可用性测试", response_model=schemas.Response) def moduletest(moduleid: str, _: schemas.TokenPayload = Depends(verify_token)): """ 模块可用性测试接口 """ state, errmsg = ModuleManager().test(moduleid) return schemas.Response(success=state, message=errmsg) @router.get("/restart", summary="重启系统", response_model=schemas.Response) def restart_system(_: User = Depends(get_current_active_superuser)): """ 重启系统(仅管理员) """ if not SystemHelper.can_restart(): return schemas.Response(success=False, message="当前运行环境不支持重启操作!") # 标识停止事件 global_vars.stop_system() # 执行重启 ret, msg = SystemHelper.restart() return schemas.Response(success=ret, message=msg) @router.get("/runscheduler", summary="运行服务", response_model=schemas.Response) def run_scheduler(jobid: str, _: User = Depends(get_current_active_superuser)): """ 执行命令(仅管理员) """ if not jobid: return schemas.Response(success=False, message="命令不能为空!") if jobid in {"recommend_refresh", "cookiecloud"}: Scheduler().start(jobid, manual=True) else: Scheduler().start(jobid) return schemas.Response(success=True) @router.get("/runscheduler2", summary="运行服务(API_TOKEN)", response_model=schemas.Response) def run_scheduler2(jobid: str, _: Annotated[str, Depends(verify_apitoken)]): """ 执行命令(API_TOKEN认证) """ if not jobid: return schemas.Response(success=False, message="命令不能为空!") if jobid in {"recommend_refresh", "cookiecloud"}: Scheduler().start(jobid, manual=True) else: Scheduler().start(jobid) return schemas.Response(success=True) ================================================ FILE: app/api/endpoints/tmdb.py ================================================ from typing import List, Any, Optional from fastapi import APIRouter, Depends from app import schemas from app.chain.tmdb import TmdbChain from app.core.security import verify_token from app.schemas.types import MediaType router = APIRouter() @router.get("/seasons/{tmdbid}", summary="TMDB所有季", response_model=List[schemas.TmdbSeason]) async def tmdb_seasons(tmdbid: int, _: schemas.TokenPayload = Depends(verify_token)) -> Any: """ 根据TMDBID查询themoviedb所有季信息 """ seasons_info = await TmdbChain().async_tmdb_seasons(tmdbid=tmdbid) if seasons_info: return seasons_info return [] @router.get("/similar/{tmdbid}/{type_name}", summary="类似电影/电视剧", response_model=List[schemas.MediaInfo]) async def tmdb_similar(tmdbid: int, type_name: str, _: schemas.TokenPayload = Depends(verify_token)) -> Any: """ 根据TMDBID查询类似电影/电视剧,type_name: 电影/电视剧 """ mediatype = MediaType(type_name) if mediatype == MediaType.MOVIE: medias = await TmdbChain().async_movie_similar(tmdbid=tmdbid) elif mediatype == MediaType.TV: medias = await TmdbChain().async_tv_similar(tmdbid=tmdbid) else: return [] if medias: return [media.to_dict() for media in medias] return [] @router.get("/recommend/{tmdbid}/{type_name}", summary="推荐电影/电视剧", response_model=List[schemas.MediaInfo]) async def tmdb_recommend(tmdbid: int, type_name: str, _: schemas.TokenPayload = Depends(verify_token)) -> Any: """ 根据TMDBID查询推荐电影/电视剧,type_name: 电影/电视剧 """ mediatype = MediaType(type_name) if mediatype == MediaType.MOVIE: medias = await TmdbChain().async_movie_recommend(tmdbid=tmdbid) elif mediatype == MediaType.TV: medias = await TmdbChain().async_tv_recommend(tmdbid=tmdbid) else: return [] if medias: return [media.to_dict() for media in medias] return [] @router.get("/collection/{collection_id}", summary="系列合集详情", response_model=List[schemas.MediaInfo]) async def tmdb_collection(collection_id: int, page: Optional[int] = 1, count: Optional[int] = 20, _: schemas.TokenPayload = Depends(verify_token)) -> Any: """ 根据合集ID查询合集详情 """ medias = await TmdbChain().async_tmdb_collection(collection_id=collection_id) if medias: return [media.to_dict() for media in medias][(page - 1) * count:page * count] return [] @router.get("/credits/{tmdbid}/{type_name}", summary="演员阵容", response_model=List[schemas.MediaPerson]) async def tmdb_credits(tmdbid: int, type_name: str, page: Optional[int] = 1, _: schemas.TokenPayload = Depends(verify_token)) -> Any: """ 根据TMDBID查询演员阵容,type_name: 电影/电视剧 """ mediatype = MediaType(type_name) if mediatype == MediaType.MOVIE: persons = await TmdbChain().async_movie_credits(tmdbid=tmdbid, page=page) elif mediatype == MediaType.TV: persons = await TmdbChain().async_tv_credits(tmdbid=tmdbid, page=page) else: return [] return persons or [] @router.get("/person/{person_id}", summary="人物详情", response_model=schemas.MediaPerson) async def tmdb_person(person_id: int, _: schemas.TokenPayload = Depends(verify_token)) -> Any: """ 根据人物ID查询人物详情 """ return await TmdbChain().async_person_detail(person_id=person_id) @router.get("/person/credits/{person_id}", summary="人物参演作品", response_model=List[schemas.MediaInfo]) async def tmdb_person_credits(person_id: int, page: Optional[int] = 1, _: schemas.TokenPayload = Depends(verify_token)) -> Any: """ 根据人物ID查询人物参演作品 """ medias = await TmdbChain().async_person_credits(person_id=person_id, page=page) if medias: return [media.to_dict() for media in medias] return [] @router.get("/{tmdbid}/{season}", summary="TMDB季所有集", response_model=List[schemas.TmdbEpisode]) async def tmdb_season_episodes(tmdbid: int, season: int, episode_group: Optional[str] = None, _: schemas.TokenPayload = Depends(verify_token)) -> Any: """ 根据TMDBID查询某季的所有信信息 """ return await TmdbChain().async_tmdb_episodes(tmdbid=tmdbid, season=season, episode_group=episode_group) ================================================ FILE: app/api/endpoints/torrent.py ================================================ from typing import Optional from fastapi import APIRouter, Depends from app import schemas from app.chain.media import MediaChain from app.chain.torrents import TorrentsChain from app.core.config import settings from app.core.context import MediaInfo from app.core.metainfo import MetaInfo from app.db.models import User from app.db.user_oper import get_current_active_superuser, get_current_active_superuser_async from app.utils.crypto import HashUtils router = APIRouter() @router.get("/cache", summary="获取种子缓存", response_model=schemas.Response) async def torrents_cache(_: User = Depends(get_current_active_superuser_async)): """ 获取当前种子缓存数据 """ torrents_chain = TorrentsChain() # 获取spider和rss两种缓存 if settings.SUBSCRIBE_MODE == "rss": cache_info = await torrents_chain.async_get_torrents("rss") else: cache_info = await torrents_chain.async_get_torrents("spider") # 统计信息 torrent_count = sum(len(torrents) for torrents in cache_info.values()) # 转换为前端需要的格式 torrent_data = [] for domain, contexts in cache_info.items(): for context in contexts: torrent_hash = HashUtils.md5(f"{context.torrent_info.title}{context.torrent_info.description}") torrent_data.append({ "hash": torrent_hash, "domain": domain, "title": context.torrent_info.title, "description": context.torrent_info.description, "size": context.torrent_info.size, "pubdate": context.torrent_info.pubdate, "site_name": context.torrent_info.site_name, "media_name": context.media_info.title if context.media_info else "", "media_year": context.media_info.year if context.media_info else "", "media_type": context.media_info.type if context.media_info else "", "season_episode": context.meta_info.season_episode if context.meta_info else "", "resource_term": context.meta_info.resource_term if context.meta_info else "", "enclosure": context.torrent_info.enclosure, "page_url": context.torrent_info.page_url, "poster_path": context.media_info.get_poster_image() if context.media_info else "", "backdrop_path": context.media_info.get_backdrop_image() if context.media_info else "" }) return schemas.Response(success=True, data={ "count": torrent_count, "sites": len(cache_info), "data": torrent_data }) @router.delete("/cache/{domain}/{torrent_hash}", summary="删除指定种子缓存", response_model=schemas.Response) async def delete_cache(domain: str, torrent_hash: str, _: User = Depends(get_current_active_superuser_async)): """ 删除指定的种子缓存 :param domain: 站点域名 :param torrent_hash: 种子hash(使用title+description的md5) :param _: 当前用户,必须是超级用户 """ torrents_chain = TorrentsChain() try: # 获取当前缓存 cache_data = await torrents_chain.async_get_torrents() if domain not in cache_data: return schemas.Response(success=False, message=f"站点 {domain} 缓存不存在") # 查找并删除指定种子 original_count = len(cache_data[domain]) cache_data[domain] = [ context for context in cache_data[domain] if HashUtils.md5(f"{context.torrent_info.title}{context.torrent_info.description}") != torrent_hash ] if len(cache_data[domain]) == original_count: return schemas.Response(success=False, message="未找到指定的种子") # 保存更新后的缓存 await torrents_chain.async_save_cache(cache_data, torrents_chain.cache_file) return schemas.Response(success=True, message="种子删除成功") except Exception as e: return schemas.Response(success=False, message=f"删除失败:{str(e)}") @router.delete("/cache", summary="清理种子缓存", response_model=schemas.Response) async def clear_cache(_: User = Depends(get_current_active_superuser_async)): """ 清理所有种子缓存 """ torrents_chain = TorrentsChain() try: await torrents_chain.async_clear_torrents() return schemas.Response(success=True, message="种子缓存清理完成") except Exception as e: return schemas.Response(success=False, message=f"清理失败:{str(e)}") @router.post("/cache/refresh", summary="刷新种子缓存", response_model=schemas.Response) def refresh_cache(_: User = Depends(get_current_active_superuser)): """ 刷新种子缓存 """ from app.chain.torrents import TorrentsChain torrents_chain = TorrentsChain() try: result = torrents_chain.refresh() # 统计刷新结果 total_count = sum(len(torrents) for torrents in result.values()) sites_count = len(result) return schemas.Response(success=True, message=f"缓存刷新完成,共刷新 {sites_count} 个站点,{total_count} 个种子") except Exception as e: return schemas.Response(success=False, message=f"刷新失败:{str(e)}") @router.post("/cache/reidentify/{domain}/{torrent_hash}", summary="重新识别种子", response_model=schemas.Response) async def reidentify_cache(domain: str, torrent_hash: str, tmdbid: Optional[int] = None, doubanid: Optional[str] = None, _: User = Depends(get_current_active_superuser_async)): """ 重新识别指定的种子 :param domain: 站点域名 :param torrent_hash: 种子hash(使用title+description的md5) :param tmdbid: 手动指定的TMDB ID :param doubanid: 手动指定的豆瓣ID :param _: 当前用户,必须是超级用户 """ torrents_chain = TorrentsChain() media_chain = MediaChain() try: # 获取当前缓存 cache_data = await torrents_chain.async_get_torrents() if domain not in cache_data: return schemas.Response(success=False, message=f"站点 {domain} 缓存不存在") # 查找指定种子 target_context = None for context in cache_data[domain]: if HashUtils.md5(f"{context.torrent_info.title}{context.torrent_info.description}") == torrent_hash: target_context = context break if not target_context: return schemas.Response(success=False, message="未找到指定的种子") # 重新识别 meta = MetaInfo(title=target_context.torrent_info.title, subtitle=target_context.torrent_info.description) if tmdbid or doubanid: # 手动指定媒体信息 mediainfo = await media_chain.async_recognize_media(meta=meta, tmdbid=tmdbid, doubanid=doubanid) else: # 自动重新识别 mediainfo = await media_chain.async_recognize_by_meta(meta) if not mediainfo: # 创建空的媒体信息 mediainfo = MediaInfo() else: # 清理多余数据 mediainfo.clear() # 更新上下文中的媒体信息 target_context.media_info = mediainfo # 保存更新后的缓存 await torrents_chain.async_save_cache(cache_data, TorrentsChain().cache_file) return schemas.Response(success=True, message="重新识别完成", data={ "media_name": mediainfo.title if mediainfo else "", "media_year": mediainfo.year if mediainfo else "", "media_type": mediainfo.type.value if mediainfo and mediainfo.type else "" }) except Exception as e: return schemas.Response(success=False, message=f"重新识别失败:{str(e)}") ================================================ FILE: app/api/endpoints/transfer.py ================================================ from pathlib import Path from typing import Any, List, Annotated, Optional from fastapi import APIRouter, Depends from sqlalchemy.orm import Session from app import schemas from app.chain.media import MediaChain from app.chain.storage import StorageChain from app.chain.transfer import TransferChain from app.core.config import settings, global_vars from app.core.metainfo import MetaInfoPath from app.core.security import verify_token, verify_apitoken from app.db import get_db from app.db.models import User from app.db.models.transferhistory import TransferHistory from app.db.user_oper import get_current_active_superuser from app.helper.directory import DirectoryHelper from app.schemas import MediaType, FileItem, ManualTransferItem router = APIRouter() @router.get("/name", summary="查询整理后的名称", response_model=schemas.Response) def query_name(path: str, filetype: str, _: schemas.TokenPayload = Depends(verify_token)) -> Any: """ 查询整理后的名称 :param path: 文件路径 :param filetype: 文件类型 :param _: Token校验 """ meta = MetaInfoPath(Path(path)) mediainfo = MediaChain().recognize_media(meta) if not mediainfo: return schemas.Response(success=False, message="未识别到媒体信息") new_path = TransferChain().recommend_name(meta=meta, mediainfo=mediainfo) if not new_path: return schemas.Response(success=False, message="未识别到新名称") if filetype == "dir": media_path = DirectoryHelper.get_media_root_path( rename_format=settings.RENAME_FORMAT(mediainfo.type), rename_path=Path(new_path), ) if media_path: new_name = media_path.name else: # fallback parents = Path(new_path).parents if len(parents) > 2: new_name = parents[1].name else: new_name = parents[0].name else: new_name = Path(new_path).name return schemas.Response(success=True, data={ "name": new_name }) @router.get("/queue", summary="查询整理队列", response_model=List[schemas.TransferJob]) async def query_queue(_: schemas.TokenPayload = Depends(verify_token)) -> Any: """ 查询整理队列 :param _: Token校验 """ return TransferChain().get_queue_tasks() @router.delete("/queue", summary="从整理队列中删除任务", response_model=schemas.Response) async def remove_queue(fileitem: schemas.FileItem, _: schemas.TokenPayload = Depends(verify_token)) -> Any: """ 查询整理队列 :param fileitem: 文件项 :param _: Token校验 """ TransferChain().remove_from_queue(fileitem) # 取消整理 global_vars.stop_transfer(fileitem.path) return schemas.Response(success=True) @router.post("/manual", summary="手动转移", response_model=schemas.Response) def manual_transfer(transer_item: ManualTransferItem, background: Optional[bool] = False, db: Session = Depends(get_db), _: User = Depends(get_current_active_superuser)) -> Any: """ 手动转移,文件或历史记录,支持自定义剧集识别格式 :param transer_item: 手工整理项 :param background: 后台运行 :param db: 数据库 :param _: Token校验 """ force = False downloader = None download_hash = None target_path = Path(transer_item.target_path) if transer_item.target_path else None if transer_item.logid: # 查询历史记录 history: TransferHistory = TransferHistory.get(db, transer_item.logid) if not history: return schemas.Response(success=False, message=f"整理记录不存在,ID:{transer_item.logid}") # 强制转移 force = True downloader = history.downloader download_hash = history.download_hash if history.status and ("move" in history.mode): # 重新整理成功的转移,则使用成功的 dest 做 in_path src_fileitem = FileItem(**history.dest_fileitem) else: # 源路径 src_fileitem = FileItem(**history.src_fileitem) # 目的路径 if history.dest_fileitem: # 删除旧的已整理文件 dest_fileitem = FileItem(**history.dest_fileitem) state = StorageChain().delete_media_file(dest_fileitem) if not state: return schemas.Response(success=False, message=f"{dest_fileitem.path} 删除失败") # 从历史数据获取信息 if transer_item.from_history: transer_item.type_name = history.type if history.type else transer_item.type_name transer_item.tmdbid = int(history.tmdbid) if history.tmdbid else transer_item.tmdbid transer_item.doubanid = str(history.doubanid) if history.doubanid else transer_item.doubanid transer_item.season = int(str(history.seasons).replace("S", "")) if history.seasons else transer_item.season transer_item.episode_group = history.episode_group or transer_item.episode_group if history.episodes: if "-" in str(history.episodes): # E01-E03多集合并 episode_start, episode_end = str(history.episodes).split("-") episode_list: list[int] = [] for i in range(int(episode_start.replace("E", "")), int(episode_end.replace("E", "")) + 1): episode_list.append(i) transer_item.episode_detail = ",".join(str(e) for e in episode_list) else: # E01单集 transer_item.episode_detail = str(history.episodes).replace("E", "") elif transer_item.fileitem: src_fileitem = transer_item.fileitem else: return schemas.Response(success=False, message=f"缺少参数") # 类型(“自动/auto/none”按未指定处理) mtype = None type_name = str(transer_item.type_name).strip() if transer_item.type_name else "" if type_name and type_name.lower() not in {"自动", "auto", "none"}: try: mtype = MediaType(type_name) except ValueError: return schemas.Response(success=False, message=f"不支持的媒体类型:{type_name}") # 自定义格式 epformat = None if transer_item.episode_offset or transer_item.episode_part \ or transer_item.episode_detail or transer_item.episode_format: epformat = schemas.EpisodeFormat( format=transer_item.episode_format, detail=transer_item.episode_detail, part=transer_item.episode_part, offset=transer_item.episode_offset, ) # 开始转移 state, errormsg = TransferChain().manual_transfer( fileitem=src_fileitem, target_storage=transer_item.target_storage, target_path=target_path, tmdbid=transer_item.tmdbid, doubanid=transer_item.doubanid, mtype=mtype, season=transer_item.season, episode_group=transer_item.episode_group, transfer_type=transer_item.transfer_type, epformat=epformat, min_filesize=transer_item.min_filesize, scrape=transer_item.scrape, library_type_folder=transer_item.library_type_folder, library_category_folder=transer_item.library_category_folder, force=force, background=background, downloader=downloader, download_hash=download_hash ) # 失败 if not state: if isinstance(errormsg, list): errormsg = f"整理完成,{len(errormsg)} 个文件转移失败!" return schemas.Response(success=False, message=errormsg) # 成功 return schemas.Response(success=True) @router.get("/now", summary="立即执行下载器文件整理", response_model=schemas.Response) def now(_: Annotated[str, Depends(verify_apitoken)]) -> Any: """ 立即执行下载器文件整理 API_TOKEN认证(?token=xxx) """ TransferChain().process() return schemas.Response(success=True) ================================================ FILE: app/api/endpoints/user.py ================================================ import base64 import re from typing import Annotated, Any, List, Union from fastapi import APIRouter, Body, Depends, HTTPException, UploadFile, File from sqlalchemy.ext.asyncio import AsyncSession from app import schemas from app.core.security import get_password_hash from app.db import get_async_db from app.db.models.user import User from app.db.user_oper import get_current_active_superuser_async, \ get_current_active_user_async, get_current_active_user from app.db.userconfig_oper import UserConfigOper from app.utils.otp import OtpUtils router = APIRouter() @router.get("/", summary="所有用户", response_model=List[schemas.User]) async def list_users( db: AsyncSession = Depends(get_async_db), current_user: User = Depends(get_current_active_superuser_async), ) -> Any: """ 查询用户列表 """ return await current_user.async_list(db) @router.post("/", summary="新增用户", response_model=schemas.Response) async def create_user( *, db: AsyncSession = Depends(get_async_db), user_in: schemas.UserCreate, current_user: User = Depends(get_current_active_superuser_async), ) -> Any: """ 新增用户 """ user = await current_user.async_get_by_name(db, name=user_in.name) if user: return schemas.Response(success=False, message="用户已存在") user_info = user_in.model_dump() if user_info.get("password"): user_info["hashed_password"] = get_password_hash(user_info["password"]) user_info.pop("password") user = await User(**user_info).async_create(db) return schemas.Response(success=True if user else False) @router.put("/", summary="更新用户", response_model=schemas.Response) async def update_user( *, db: AsyncSession = Depends(get_async_db), user_in: schemas.UserUpdate, current_user: User = Depends(get_current_active_superuser_async), ) -> Any: """ 更新用户 """ user_info = user_in.model_dump() if user_info.get("password"): # 正则表达式匹配密码包含字母、数字、特殊字符中的至少两项 pattern = r'^(?![a-zA-Z]+$)(?!\d+$)(?![^\da-zA-Z\s]+$).{6,50}$' if not re.match(pattern, user_info.get("password")): return schemas.Response(success=False, message="密码需要同时包含字母、数字、特殊字符中的至少两项,且长度大于6位") user_info["hashed_password"] = get_password_hash(user_info["password"]) user_info.pop("password") user = await current_user.async_get_by_id(db, user_id=user_info["id"]) user_name = user_info.get("name") if not user_name: return schemas.Response(success=False, message="用户名不能为空") # 新用户名去重 users = await current_user.async_list(db) for u in users: if u.name == user_name and u.id != user_info["id"]: return schemas.Response(success=False, message="用户名已被使用") if not user: return schemas.Response(success=False, message="用户不存在") await user.async_update(db, user_info) return schemas.Response(success=True) @router.get("/current", summary="当前登录用户信息", response_model=schemas.User) async def read_current_user( current_user: User = Depends(get_current_active_user_async) ) -> Any: """ 当前登录用户信息 """ return current_user @router.post("/avatar/{user_id}", summary="上传用户头像", response_model=schemas.Response) async def upload_avatar(user_id: int, db: AsyncSession = Depends(get_async_db), file: UploadFile = File(...), _: User = Depends(get_current_active_user_async)): """ 上传用户头像 """ # 将文件转换为Base64 file_base64 = base64.b64encode(file.file.read()) # 更新到用户表 user = await User.async_get(db, user_id) if not user: return schemas.Response(success=False, message="用户不存在") await user.async_update(db, { "avatar": f"data:image/ico;base64,{file_base64}" }) return schemas.Response(success=True, message=file.filename) @router.get("/config/{key}", summary="查询用户配置", response_model=schemas.Response) def get_config(key: str, current_user: User = Depends(get_current_active_user)): """ 查询用户配置 """ value = UserConfigOper().get(username=current_user.name, key=key) return schemas.Response(success=True, data={ "value": value }) @router.post("/config/{key}", summary="更新用户配置", response_model=schemas.Response) def set_config( key: str, value: Annotated[Union[list, dict, bool, int, str] | None, Body()] = None, current_user: User = Depends(get_current_active_user), ): """ 更新用户配置 """ UserConfigOper().set(username=current_user.name, key=key, value=value) return schemas.Response(success=True) @router.delete("/id/{user_id}", summary="删除用户", response_model=schemas.Response) async def delete_user_by_id( *, db: AsyncSession = Depends(get_async_db), user_id: int, current_user: User = Depends(get_current_active_superuser_async), ) -> Any: """ 通过唯一ID删除用户 """ user = await current_user.async_get_by_id(db, user_id=user_id) if not user: return schemas.Response(success=False, message="用户不存在") await current_user.async_delete(db, user_id) return schemas.Response(success=True) @router.delete("/name/{user_name}", summary="删除用户", response_model=schemas.Response) async def delete_user_by_name( *, db: AsyncSession = Depends(get_async_db), user_name: str, current_user: User = Depends(get_current_active_superuser_async), ) -> Any: """ 通过用户名删除用户 """ user = await current_user.async_get_by_name(db, name=user_name) if not user: return schemas.Response(success=False, message="用户不存在") await current_user.async_delete(db, user.id) return schemas.Response(success=True) @router.get("/{username}", summary="用户详情", response_model=schemas.User) async def read_user_by_name( username: str, current_user: User = Depends(get_current_active_user_async), db: AsyncSession = Depends(get_async_db), ) -> Any: """ 查询用户详情 """ user = await current_user.async_get_by_name(db, name=username) if not user: raise HTTPException( status_code=404, detail="用户不存在", ) if user == current_user: return user if not current_user.is_superuser: raise HTTPException( status_code=400, detail="用户权限不足" ) return user ================================================ FILE: app/api/endpoints/webhook.py ================================================ from typing import Any, Annotated from fastapi import APIRouter, BackgroundTasks, Request, Depends from app import schemas from app.chain.webhook import WebhookChain from app.core.security import verify_apitoken router = APIRouter() def start_webhook_chain(body: Any, form: Any, args: Any): """ 启动链式任务 """ WebhookChain().message(body=body, form=form, args=args) @router.post("/", summary="Webhook消息响应", response_model=schemas.Response) async def webhook_message(background_tasks: BackgroundTasks, request: Request, _: Annotated[str, Depends(verify_apitoken)] ) -> Any: """ Webhook响应,配置请求中需要添加参数:token=API_TOKEN&source=媒体服务器名 """ body = await request.body() form = await request.form() args = request.query_params background_tasks.add_task(start_webhook_chain, body, form, args) return schemas.Response(success=True) @router.get("/", summary="Webhook消息响应", response_model=schemas.Response) async def webhook_message(background_tasks: BackgroundTasks, request: Request, _: Annotated[str, Depends(verify_apitoken)]) -> Any: """ Webhook响应,配置请求中需要添加参数:token=API_TOKEN&source=媒体服务器名 """ args = request.query_params background_tasks.add_task(start_webhook_chain, None, None, args) return schemas.Response(success=True) ================================================ FILE: app/api/endpoints/workflow.py ================================================ import json from datetime import datetime from typing import List, Any, Optional from fastapi import APIRouter, Depends from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import Session from app import schemas from app.chain.workflow import WorkflowChain from app.core.config import global_vars from app.core.plugin import PluginManager from app.core.security import verify_token from app.workflow import WorkFlowManager from app.db import get_async_db, get_db from app.db.models import Workflow from app.db.systemconfig_oper import SystemConfigOper from app.db.workflow_oper import WorkflowOper from app.helper.workflow import WorkflowHelper from app.scheduler import Scheduler from app.schemas.types import EventType, EVENT_TYPE_NAMES router = APIRouter() @router.get("/", summary="所有工作流", response_model=List[schemas.Workflow]) async def list_workflows(db: AsyncSession = Depends(get_async_db), _: schemas.TokenPayload = Depends(verify_token)) -> Any: """ 获取工作流列表 """ return await WorkflowOper(db).async_list() @router.post("/", summary="创建工作流", response_model=schemas.Response) async def create_workflow(workflow: schemas.Workflow, db: AsyncSession = Depends(get_async_db), _: schemas.TokenPayload = Depends(verify_token)) -> Any: """ 创建工作流 """ if workflow.name and await WorkflowOper(db).async_get_by_name(workflow.name): return schemas.Response(success=False, message="已存在相同名称的工作流") if not workflow.add_time: workflow.add_time = datetime.strftime(datetime.now(), "%Y-%m-%d %H:%M:%S") if not workflow.state: workflow.state = "P" if not workflow.trigger_type: workflow.trigger_type = "timer" workflow_obj = Workflow(**workflow.model_dump()) await workflow_obj.async_create(db) return schemas.Response(success=True, message="创建工作流成功") @router.get("/plugin/actions", summary="查询插件动作", response_model=List[dict]) def list_plugin_actions(plugin_id: str = None, _: schemas.TokenPayload = Depends(verify_token)) -> Any: """ 获取所有动作 """ return PluginManager().get_plugin_actions(plugin_id) @router.get("/actions", summary="所有动作", response_model=List[dict]) async def list_actions(_: schemas.TokenPayload = Depends(verify_token)) -> Any: """ 获取所有动作 """ return WorkFlowManager().list_actions() @router.get("/event_types", summary="获取所有事件类型", response_model=List[dict]) async def get_event_types(_: schemas.TokenPayload = Depends(verify_token)) -> Any: """ 获取所有事件类型 """ return [{ "title": EVENT_TYPE_NAMES.get(event_type, event_type.name), "value": event_type.value } for event_type in EventType] @router.post("/share", summary="分享工作流", response_model=schemas.Response) async def workflow_share( workflow: schemas.WorkflowShare, _: schemas.TokenPayload = Depends(verify_token)) -> Any: """ 分享工作流 """ if not workflow.id or not workflow.share_title or not workflow.share_user: return schemas.Response(success=False, message="请填写工作流ID、分享标题和分享人") state, errmsg = await WorkflowHelper().async_workflow_share(workflow_id=workflow.id, share_title=workflow.share_title or "", share_comment=workflow.share_comment or "", share_user=workflow.share_user or "") return schemas.Response(success=state, message=errmsg) @router.delete("/share/{share_id}", summary="删除分享", response_model=schemas.Response) async def workflow_share_delete( share_id: int, _: schemas.TokenPayload = Depends(verify_token)) -> Any: """ 删除分享 """ state, errmsg = await WorkflowHelper().async_share_delete(share_id=share_id) return schemas.Response(success=state, message=errmsg) @router.post("/fork", summary="复用工作流", response_model=schemas.Response) async def workflow_fork( workflow: schemas.WorkflowShare, db: AsyncSession = Depends(get_async_db), _: schemas.User = Depends(verify_token)) -> Any: """ 复用工作流 """ if not workflow.name: return schemas.Response(success=False, message="工作流名称不能为空") # 解析JSON数据,添加错误处理 try: actions = json.loads(workflow.actions or "[]") except json.JSONDecodeError: return schemas.Response(success=False, message="actions字段JSON格式错误") try: flows = json.loads(workflow.flows or "[]") except json.JSONDecodeError: return schemas.Response(success=False, message="flows字段JSON格式错误") try: context = json.loads(workflow.context or "{}") except json.JSONDecodeError: return schemas.Response(success=False, message="context字段JSON格式错误") # 创建工作流 workflow_dict = { "name": workflow.name, "description": workflow.description, "timer": workflow.timer, "trigger_type": workflow.trigger_type or "timer", "event_type": workflow.event_type, "event_conditions": json.loads(workflow.event_conditions or "{}") if workflow.event_conditions else {}, "actions": actions, "flows": flows, "context": context, "state": "P" # 默认暂停状态 } # 检查名称是否重复 workflow_oper = WorkflowOper(db) if await workflow_oper.async_get_by_name(workflow_dict["name"]): return schemas.Response(success=False, message="已存在相同名称的工作流") # 创建新工作流 workflow = await Workflow(**workflow_dict).async_create(db) # 更新复用次数 if workflow: await WorkflowHelper().async_workflow_fork(share_id=workflow.id) return schemas.Response(success=True, message="复用成功") @router.get("/shares", summary="查询分享的工作流", response_model=List[schemas.WorkflowShare]) async def workflow_shares( name: Optional[str] = None, page: Optional[int] = 1, count: Optional[int] = 30, _: schemas.TokenPayload = Depends(verify_token)) -> Any: """ 查询分享的工作流 """ return await WorkflowHelper().async_get_shares(name=name, page=page, count=count) @router.post("/{workflow_id}/run", summary="执行工作流", response_model=schemas.Response) def run_workflow(workflow_id: int, from_begin: Optional[bool] = True, _: schemas.TokenPayload = Depends(verify_token)) -> Any: """ 执行工作流 """ state, errmsg = WorkflowChain().process(workflow_id, from_begin=from_begin) if not state: return schemas.Response(success=False, message=errmsg) return schemas.Response(success=True) @router.post("/{workflow_id}/start", summary="启用工作流", response_model=schemas.Response) def start_workflow(workflow_id: int, db: Session = Depends(get_db), _: schemas.TokenPayload = Depends(verify_token)) -> Any: """ 启用工作流 """ workflow = WorkflowOper(db).get(workflow_id) if not workflow: return schemas.Response(success=False, message="工作流不存在") if not workflow.trigger_type or workflow.trigger_type == "timer": # 添加定时任务 Scheduler().update_workflow_job(workflow) else: # 事件触发:添加到事件触发器 WorkFlowManager().load_workflow_events(workflow_id) # 更新状态 workflow.update_state(db, workflow_id, "W") return schemas.Response(success=True) @router.post("/{workflow_id}/pause", summary="停用工作流", response_model=schemas.Response) def pause_workflow(workflow_id: int, db: Session = Depends(get_db), _: schemas.TokenPayload = Depends(verify_token)) -> Any: """ 停用工作流 """ workflow = WorkflowOper(db).get(workflow_id) if not workflow: return schemas.Response(success=False, message="工作流不存在") # 根据触发类型进行不同处理 if workflow.trigger_type == "timer": # 定时触发:移除定时任务 Scheduler().remove_workflow_job(workflow) elif workflow.trigger_type == "event": # 事件触发:从事件触发器中移除 WorkFlowManager().remove_workflow_event(workflow_id, workflow.event_type) # 停止工作流 global_vars.stop_workflow(workflow_id) # 更新状态 workflow.update_state(db, workflow_id, "P") return schemas.Response(success=True) @router.post("/{workflow_id}/reset", summary="重置工作流", response_model=schemas.Response) async def reset_workflow(workflow_id: int, db: AsyncSession = Depends(get_async_db), _: schemas.TokenPayload = Depends(verify_token)) -> Any: """ 重置工作流 """ workflow = await WorkflowOper(db).async_get(workflow_id) if not workflow: return schemas.Response(success=False, message="工作流不存在") # 停止工作流 global_vars.stop_workflow(workflow_id) # 重置工作流 await Workflow.async_reset(db, workflow_id, reset_count=True) # 删除缓存 SystemConfigOper().delete(f"WorkflowCache-{workflow_id}") return schemas.Response(success=True) @router.get("/{workflow_id}", summary="工作流详情", response_model=schemas.Workflow) async def get_workflow(workflow_id: int, db: AsyncSession = Depends(get_async_db), _: schemas.TokenPayload = Depends(verify_token)) -> Any: """ 获取工作流详情 """ return await WorkflowOper(db).async_get(workflow_id) @router.put("/{workflow_id}", summary="更新工作流", response_model=schemas.Response) def update_workflow(workflow: schemas.Workflow, db: Session = Depends(get_db), _: schemas.TokenPayload = Depends(verify_token)) -> Any: """ 更新工作流 """ if not workflow.id: return schemas.Response(success=False, message="工作流ID不能为空") workflow_oper = WorkflowOper(db) wf = workflow_oper.get(workflow.id) if not wf: return schemas.Response(success=False, message="工作流不存在") if not wf.trigger_type: workflow.trigger_type = "timer" wf.update(db, workflow.model_dump()) # 更新后的工作流对象 updated_workflow = workflow_oper.get(workflow.id) # 更新定时任务 Scheduler().update_workflow_job(updated_workflow) # 更新事件注册 WorkFlowManager().update_workflow_event(updated_workflow) return schemas.Response(success=True, message="更新成功") @router.delete("/{workflow_id}", summary="删除工作流", response_model=schemas.Response) def delete_workflow(workflow_id: int, db: Session = Depends(get_db), _: schemas.TokenPayload = Depends(verify_token)) -> Any: """ 删除工作流 """ workflow = WorkflowOper(db).get(workflow_id) if not workflow: return schemas.Response(success=False, message="工作流不存在") if not workflow.trigger_type or workflow.trigger_type == "timer": # 定时触发:删除定时任务 Scheduler().remove_workflow_job(workflow) else: # 事件触发:从事件触发器中移除 WorkFlowManager().remove_workflow_event(workflow_id, workflow.event_type) # 删除工作流 Workflow.delete(db, workflow_id) # 删除缓存 SystemConfigOper().delete(f"WorkflowCache-{workflow_id}") return schemas.Response(success=True, message="删除成功") ================================================ FILE: app/api/servarr.py ================================================ from typing import Any, List, Annotated from fastapi import APIRouter, HTTPException, Depends from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import Session from app import schemas from app.chain.media import MediaChain from app.chain.subscribe import SubscribeChain from app.chain.tvdb import TvdbChain from app.core.metainfo import MetaInfo from app.core.security import verify_apikey from app.db import get_db, get_async_db from app.db.models.subscribe import Subscribe from app.schemas import RadarrMovie, SonarrSeries from app.schemas.types import MediaType from version import APP_VERSION arr_router = APIRouter(tags=['servarr']) @arr_router.get("/system/status", summary="系统状态") async def arr_system_status(_: Annotated[str, Depends(verify_apikey)]) -> Any: """ 模拟Radarr、Sonarr系统状态 """ return { "appName": "MoviePilot", "instanceName": "moviepilot", "version": APP_VERSION, "buildTime": "", "isDebug": False, "isProduction": True, "isAdmin": True, "isUserInteractive": True, "startupPath": "/app", "appData": "/config", "osName": "debian", "osVersion": "", "isNetCore": True, "isLinux": True, "isOsx": False, "isWindows": False, "isDocker": True, "mode": "console", "branch": "main", "databaseType": "sqLite", "databaseVersion": { "major": 0, "minor": 0, "build": 0, "revision": 0, "majorRevision": 0, "minorRevision": 0 }, "authentication": "none", "migrationVersion": 0, "urlBase": "", "runtimeVersion": { "major": 0, "minor": 0, "build": 0, "revision": 0, "majorRevision": 0, "minorRevision": 0 }, "runtimeName": "", "startTime": "", "packageVersion": "", "packageAuthor": "jxxghp", "packageUpdateMechanism": "builtIn", "packageUpdateMechanismMessage": "" } @arr_router.get("/qualityProfile", summary="质量配置") async def arr_qualityProfile(_: Annotated[str, Depends(verify_apikey)]) -> Any: """ 模拟Radarr、Sonarr质量配置 """ return [ { "id": 1, "name": "默认", "upgradeAllowed": True, "cutoff": 0, "items": [ { "id": 0, "name": "默认", "quality": { "id": 0, "name": "默认", "source": "0", "resolution": 0 }, "items": [ "string" ], "allowed": True } ], "minFormatScore": 0, "cutoffFormatScore": 0, "formatItems": [ { "id": 0, "format": 0, "name": "默认", "score": 0 } ] } ] @arr_router.get("/rootfolder", summary="根目录") async def arr_rootfolder(_: Annotated[str, Depends(verify_apikey)]) -> Any: """ 模拟Radarr、Sonarr根目录 """ return [ { "id": 1, "path": "/", "accessible": True, "freeSpace": 0, "unmappedFolders": [] } ] @arr_router.get("/tag", summary="标签") async def arr_tag(_: Annotated[str, Depends(verify_apikey)]) -> Any: """ 模拟Radarr、Sonarr标签 """ return [ { "id": 1, "label": "默认" } ] @arr_router.get("/languageprofile", summary="语言") async def arr_languageprofile(_: Annotated[str, Depends(verify_apikey)]) -> Any: """ 模拟Radarr、Sonarr语言 """ return [{ "id": 1, "name": "默认", "upgradeAllowed": True, "cutoff": { "id": 1, "name": "默认" }, "languages": [ { "id": 1, "language": { "id": 1, "name": "默认" }, "allowed": True } ] }] @arr_router.get("/movie", summary="所有订阅电影", response_model=List[schemas.RadarrMovie]) async def arr_movies(_: Annotated[str, Depends(verify_apikey)], db: AsyncSession = Depends(get_async_db)) -> Any: """ 查询Rardar电影 """ """ [ { "id": 0, "title": "string", "originalTitle": "string", "originalLanguage": { "id": 0, "name": "string" }, "secondaryYear": 0, "secondaryYearSourceId": 0, "sortTitle": "string", "sizeOnDisk": 0, "status": "tba", "overview": "string", "inCinemas": "2023-06-13T09:23:41.494Z", "physicalRelease": "2023-06-13T09:23:41.494Z", "digitalRelease": "2023-06-13T09:23:41.494Z", "physicalReleaseNote": "string", "images": [ { "coverType": "unknown", "url": "string", "remoteUrl": "string" } ], "website": "string", "remotePoster": "string", "year": 0, "hasFile": true, "youTubeTrailerId": "string", "studio": "string", "path": "string", "qualityProfileId": 0, "monitored": true, "minimumAvailability": "tba", "isAvailable": true, "folderName": "string", "runtime": 0, "cleanTitle": "string", "imdbId": "string", "tmdbId": 0, "titleSlug": "string", "rootFolderPath": "string", "folder": "string", "certification": "string", "genres": [ "string" ], "tags": [ 0 ], "added": "2023-06-13T09:23:41.494Z", "addOptions": { "ignoreEpisodesWithFiles": true, "ignoreEpisodesWithoutFiles": true, "monitor": "movieOnly", "searchForMovie": true, "addMethod": "manual" }, "popularity": 0 } ] """ # 查询所有电影订阅 result = [] subscribes = await Subscribe.async_list(db) for subscribe in subscribes: if subscribe.type != MediaType.MOVIE.value: continue result.append(RadarrMovie( id=subscribe.id, title=subscribe.name, year=subscribe.year, isAvailable=True, monitored=True, tmdbId=subscribe.tmdbid, imdbId=subscribe.imdbid, profileId=1, qualityProfileId=1, hasFile=False )) return result @arr_router.get("/movie/lookup", summary="查询电影", response_model=List[schemas.RadarrMovie]) def arr_movie_lookup(term: str, _: Annotated[str, Depends(verify_apikey)], db: Session = Depends(get_db)) -> Any: """ 查询Rardar电影 term: `tmdb:${id}` 存在和不存在均不能返回错误 """ tmdbid = term.replace("tmdb:", "") # 查询媒体信息 mediainfo = MediaChain().recognize_media(mtype=MediaType.MOVIE, tmdbid=int(tmdbid)) if not mediainfo: return [RadarrMovie()] # 查询是否已存在 exists = MediaChain().media_exists(mediainfo=mediainfo) if not exists: # 文件不存在 hasfile = False else: # 文件存在 hasfile = True # 查询是否已订阅 subscribes = Subscribe.get_by_tmdbid(db, int(tmdbid)) if subscribes: # 订阅ID subid = subscribes[0].id # 已订阅 monitored = True else: subid = None monitored = False return [RadarrMovie( id=subid, title=mediainfo.title, year=mediainfo.year, isAvailable=True, monitored=monitored, tmdbId=mediainfo.tmdb_id, imdbId=mediainfo.imdb_id, titleSlug=mediainfo.original_title, folderName=mediainfo.title_year, profileId=1, qualityProfileId=1, hasFile=hasfile )] @arr_router.get("/movie/{mid}", summary="电影订阅详情", response_model=schemas.RadarrMovie) async def arr_movie(mid: int, _: Annotated[str, Depends(verify_apikey)], db: AsyncSession = Depends(get_async_db)) -> Any: """ 查询Rardar电影订阅 """ subscribe = await Subscribe.async_get(db, mid) if subscribe: return RadarrMovie( id=subscribe.id, title=subscribe.name, year=subscribe.year, isAvailable=True, monitored=True, tmdbId=subscribe.tmdbid, imdbId=subscribe.imdbid, profileId=1, qualityProfileId=1, hasFile=False ) else: raise HTTPException( status_code=404, detail="未找到该电影!" ) @arr_router.post("/movie", summary="新增电影订阅") async def arr_add_movie(_: Annotated[str, Depends(verify_apikey)], movie: RadarrMovie, db: AsyncSession = Depends(get_async_db) ) -> Any: """ 新增Rardar电影订阅 """ # 检查订阅是否已存在 subscribe = await Subscribe.async_get_by_tmdbid(db, movie.tmdbId) if subscribe: return { "id": subscribe.id } # 添加订阅 sid, message = await SubscribeChain().async_add(title=movie.title, year=movie.year, mtype=MediaType.MOVIE, tmdbid=movie.tmdbId, username="Seerr") if sid: return { "id": sid } else: raise HTTPException( status_code=500, detail=f"添加订阅失败:{message}" ) @arr_router.delete("/movie/{mid}", summary="删除电影订阅", response_model=schemas.Response) async def arr_remove_movie(mid: int, _: Annotated[str, Depends(verify_apikey)], db: AsyncSession = Depends(get_async_db)) -> Any: """ 删除Rardar电影订阅 """ subscribe = await Subscribe.async_get(db, mid) if subscribe: await subscribe.async_delete(db, mid) return schemas.Response(success=True) else: raise HTTPException( status_code=404, detail="未找到该电影!" ) @arr_router.get("/series", summary="所有剧集", response_model=List[schemas.SonarrSeries]) async def arr_series(_: Annotated[str, Depends(verify_apikey)], db: AsyncSession = Depends(get_async_db)) -> Any: """ 查询Sonarr剧集 """ """ [ { "id": 0, "title": "string", "sortTitle": "string", "status": "continuing", "ended": true, "profileName": "string", "overview": "string", "nextAiring": "2023-06-13T09:08:17.624Z", "previousAiring": "2023-06-13T09:08:17.624Z", "network": "string", "airTime": "string", "images": [ { "coverType": "unknown", "url": "string", "remoteUrl": "string" } ], "originalLanguage": { "id": 0, "name": "string" }, "remotePoster": "string", "seasons": [ { "seasonNumber": 0, "monitored": true, "statistics": { "nextAiring": "2023-06-13T09:08:17.624Z", "previousAiring": "2023-06-13T09:08:17.624Z", "episodeFileCount": 0, "episodeCount": 0, "totalEpisodeCount": 0, "sizeOnDisk": 0, "releaseGroups": [ "string" ], "percentOfEpisodes": 0 }, "images": [ { "coverType": "unknown", "url": "string", "remoteUrl": "string" } ] } ], "year": 0, "path": "string", "qualityProfileId": 0, "seasonFolder": true, "monitored": true, "useSceneNumbering": true, "runtime": 0, "tvdbId": 0, "tvRageId": 0, "tvMazeId": 0, "firstAired": "2023-06-13T09:08:17.624Z", "seriesType": "standard", "cleanTitle": "string", "imdbId": "string", "titleSlug": "string", "rootFolderPath": "string", "folder": "string", "certification": "string", "genres": [ "string" ], "tags": [ 0 ], "added": "2023-06-13T09:08:17.624Z", "addOptions": { "ignoreEpisodesWithFiles": true, "ignoreEpisodesWithoutFiles": true, "monitor": "unknown", "searchForMissingEpisodes": true, "searchForCutoffUnmetEpisodes": true }, "ratings": { "votes": 0, "value": 0 }, "statistics": { "seasonCount": 0, "episodeFileCount": 0, "episodeCount": 0, "totalEpisodeCount": 0, "sizeOnDisk": 0, "releaseGroups": [ "string" ], "percentOfEpisodes": 0 }, "episodesChanged": true } ] """ # 查询所有电视剧订阅 result = [] subscribes = await Subscribe.async_list(db) for subscribe in subscribes: if subscribe.type != MediaType.TV.value: continue result.append(SonarrSeries( id=subscribe.id, title=subscribe.name, seasonCount=1, seasons=[{ "seasonNumber": subscribe.season, "monitored": True, }], remotePoster=subscribe.poster, year=subscribe.year, tmdbId=subscribe.tmdbid, tvdbId=subscribe.tvdbid, imdbId=subscribe.imdbid, profileId=1, languageProfileId=1, qualityProfileId=1, isAvailable=True, monitored=True, hasFile=False )) return result @arr_router.get("/series/lookup", summary="查询剧集") def arr_series_lookup(term: str, _: Annotated[str, Depends(verify_apikey)], db: Session = Depends(get_db)) -> Any: """ 查询Sonarr剧集 term: `tvdb:${id}` title """ # 季信息 seas: List[int] = [] # tvdbid 列表 tvdbids: List[int] = [] # 获取TVDBID if not term.startswith("tvdb:"): title = term.replace("+", " ") tvdbids = TvdbChain().get_tvdbid_by_name(title=title) else: tvdbid = int(term.replace("tvdb:", "")) tvdbids.append(tvdbid) sonarr_series_list = [] for tvdbid in tvdbids: # 查询TVDB信息 tvdbinfo = MediaChain().tvdb_info(tvdbid=tvdbid) if not tvdbinfo: continue # 季信息(只取默认季类型,排除特别季) sea_num = len([season for season in tvdbinfo.get('seasons') if season['type']['id'] == tvdbinfo.get('defaultSeasonType') and season['number'] > 0]) if sea_num: seas = list(range(1, int(sea_num) + 1)) # 根据TVDB查询媒体信息 mediainfo = MediaChain().recognize_media(meta=MetaInfo(tvdbinfo.get('name')), mtype=MediaType.TV) if not mediainfo: continue # 查询是否存在 exists = MediaChain().media_exists(mediainfo) if exists: hasfile = True else: hasfile = False # 查询订阅信息 seasons: List[dict] = [] subscribes = Subscribe.get_by_tmdbid(db, mediainfo.tmdb_id) if subscribes: # 已监控 monitored = True # 已监控季 sub_seas = [sub.season for sub in subscribes] for sea in seas: if sea in sub_seas: seasons.append({ "seasonNumber": sea, "monitored": True, }) else: seasons.append({ "seasonNumber": sea, "monitored": False, }) subid = subscribes[-1].id else: subid = None monitored = False for sea in seas: seasons.append({ "seasonNumber": sea, "monitored": False, }) sonarr_series = SonarrSeries( id=subid, title=mediainfo.title, seasonCount=len(seasons), seasons=seasons, remotePoster=mediainfo.get_poster_image(), year=mediainfo.year, tmdbId=mediainfo.tmdb_id, tvdbId=tvdbid, imdbId=mediainfo.imdb_id, profileId=1, languageProfileId=1, monitored=monitored, hasFile=hasfile, ) sonarr_series_list.append(sonarr_series) return sonarr_series_list if sonarr_series_list else [SonarrSeries()] @arr_router.get("/series/{tid}", summary="剧集详情") async def arr_serie(tid: int, _: Annotated[str, Depends(verify_apikey)], db: AsyncSession = Depends(get_async_db)) -> Any: """ 查询Sonarr剧集 """ subscribe = await Subscribe.async_get(db, tid) if subscribe: return SonarrSeries( id=subscribe.id, title=subscribe.name, seasonCount=1, seasons=[{ "seasonNumber": subscribe.season, "monitored": True, }], year=subscribe.year, remotePoster=subscribe.poster, tmdbId=subscribe.tmdbid, tvdbId=subscribe.tvdbid, imdbId=subscribe.imdbid, profileId=1, languageProfileId=1, qualityProfileId=1, isAvailable=True, monitored=True, hasFile=False ) else: raise HTTPException( status_code=404, detail="未找到该电视剧!" ) @arr_router.post("/series", summary="新增剧集订阅") async def arr_add_series(tv: schemas.SonarrSeries, _: Annotated[str, Depends(verify_apikey)], db: AsyncSession = Depends(get_async_db)) -> Any: """ 新增Sonarr剧集订阅 """ # 检查订阅是否存在 left_seasons = [] for season in tv.seasons: subscribe = await Subscribe.async_get_by_tmdbid(db, tmdbid=tv.tmdbId, season=season.get("seasonNumber")) if subscribe: continue left_seasons.append(season) # 全部已存在订阅 if not left_seasons: return { "id": 1 } # 剩下的添加订阅 sid = 0 message = "" for season in left_seasons: if not season.get("monitored"): continue sid, message = await SubscribeChain().async_add(title=tv.title, year=tv.year, season=season.get("seasonNumber"), tmdbid=tv.tmdbId, mtype=MediaType.TV, username="Seerr") if sid: return { "id": sid } else: raise HTTPException( status_code=500, detail=f"添加订阅失败:{message}" ) @arr_router.put("/series", summary="更新剧集订阅") async def arr_update_series(tv: schemas.SonarrSeries, _: Annotated[str, Depends(verify_apikey)]) -> Any: """ 更新Sonarr剧集订阅 """ return await arr_add_series(tv) @arr_router.delete("/series/{tid}", summary="删除剧集订阅") async def arr_remove_series(tid: int, _: Annotated[str, Depends(verify_apikey)], db: AsyncSession = Depends(get_async_db)) -> Any: """ 删除Sonarr剧集订阅 """ subscribe = await Subscribe.async_get(db, tid) if subscribe: await subscribe.async_delete(db, tid) return schemas.Response(success=True) else: raise HTTPException( status_code=404, detail="未找到该电视剧!" ) ================================================ FILE: app/api/servcookie.py ================================================ import gzip import json from typing import Annotated, Callable, Any, Dict, Optional import aiofiles from anyio import Path as AsyncPath from fastapi import APIRouter, Body, Depends, HTTPException, Path, Request, Response from fastapi.responses import PlainTextResponse from fastapi.routing import APIRoute from app import schemas from app.core.config import settings from app.log import logger from app.utils.crypto import CryptoJsUtils, HashUtils class GzipRequest(Request): async def body(self) -> bytes: if not hasattr(self, "_body"): body = await super().body() if "gzip" in self.headers.getlist("Content-Encoding"): body = gzip.decompress(body) self._body = body # noqa return self._body class GzipRoute(APIRoute): def get_route_handler(self) -> Callable: original_route_handler = super().get_route_handler() async def custom_route_handler(request: Request) -> Response: request = GzipRequest(request.scope, request.receive) return await original_route_handler(request) return custom_route_handler async def verify_server_enabled(): """ 校验CookieCloud服务路由是否打开 """ if not settings.COOKIECLOUD_ENABLE_LOCAL: raise HTTPException(status_code=400, detail="本地CookieCloud服务器未启用") return True cookie_router = APIRouter(route_class=GzipRoute, tags=["servcookie"], dependencies=[Depends(verify_server_enabled)]) @cookie_router.get("/", response_class=PlainTextResponse) async def get_root(): return "Hello MoviePilot! COOKIECLOUD API ROOT = /cookiecloud" @cookie_router.post("/", response_class=PlainTextResponse) async def post_root(): return "Hello MoviePilot! COOKIECLOUD API ROOT = /cookiecloud" @cookie_router.post("/update") async def update_cookie(req: schemas.CookieData): """ 上传Cookie数据 """ file_path = AsyncPath(settings.COOKIE_PATH) / f"{req.uuid}.json" content = json.dumps({"encrypted": req.encrypted}) async with aiofiles.open(file_path, encoding="utf-8", mode="w") as file: await file.write(content) async with aiofiles.open(file_path, encoding="utf-8", mode="r") as file: read_content = await file.read() if read_content == content: return {"action": "done"} else: return {"action": "error"} async def load_encrypt_data(uuid: str) -> Dict[str, Any]: """ 加载本地加密原始数据 """ file_path = AsyncPath(settings.COOKIE_PATH) / f"{uuid}.json" # 检查文件是否存在 if not file_path.exists(): raise HTTPException(status_code=404, detail="Item not found") # 读取文件 async with aiofiles.open(file_path, encoding="utf-8", mode="r") as file: read_content = await file.read() data = json.loads(read_content.encode("utf-8")) return data def get_decrypted_cookie_data(uuid: str, password: str, encrypted: str) -> Optional[Dict[str, Any]]: """ 加载本地加密数据并解密为Cookie """ combined_string = f"{uuid}-{password}" aes_key = HashUtils.md5(combined_string)[:16].encode("utf-8") if encrypted: try: decrypted_data = CryptoJsUtils.decrypt(encrypted, aes_key).decode("utf-8") decrypted_data = json.loads(decrypted_data) if "cookie_data" in decrypted_data: return decrypted_data except Exception as e: logger.error(f"解密Cookie数据失败:{str(e)}") return None else: return None @cookie_router.get("/get/{uuid}") async def get_cookie( uuid: Annotated[str, Path(min_length=5, pattern="^[a-zA-Z0-9]+$")]): """ GET 下载加密数据 """ return await load_encrypt_data(uuid) @cookie_router.post("/get/{uuid}") async def post_cookie( uuid: Annotated[str, Path(min_length=5, pattern="^[a-zA-Z0-9]+$")], request: Optional[schemas.CookiePassword] = Body(None)): """ POST 下载加密数据 """ data = await load_encrypt_data(uuid) if request is not None: return get_decrypted_cookie_data(uuid, request.password, data["encrypted"]) else: return data ================================================ FILE: app/chain/__init__.py ================================================ import copy import inspect import pickle import traceback from abc import ABCMeta from collections.abc import Callable from datetime import datetime from pathlib import Path from typing import Optional, Any, Tuple, List, Set, Union, Dict from fastapi.concurrency import run_in_threadpool from qbittorrentapi import TorrentFilesList from transmission_rpc import File from app.core.cache import FileCache, AsyncFileCache, fresh, async_fresh from app.core.config import settings from app.core.context import Context, MediaInfo, TorrentInfo from app.core.event import EventManager from app.core.meta import MetaBase from app.core.module import ModuleManager from app.core.plugin import PluginManager from app.db.message_oper import MessageOper from app.db.user_oper import UserOper from app.helper.message import MessageHelper, MessageQueueManager, MessageTemplateHelper from app.helper.service import ServiceConfigHelper from app.log import logger from app.schemas import TransferInfo, TransferTorrent, ExistMediaInfo, DownloadingTorrent, CommingMessage, Notification, \ WebhookEventInfo, TmdbEpisode, MediaPerson, FileItem, TransferDirectoryConf from app.schemas.category import CategoryConfig from app.schemas.types import TorrentStatus, MediaType, MediaImageType, EventType, MessageChannel from app.utils.object import ObjectUtils class ChainBase(metaclass=ABCMeta): """ 处理链基类 """ def __init__(self): """ 公共初始化 """ self.modulemanager = ModuleManager() self.eventmanager = EventManager() self.messageoper = MessageOper() self.messagehelper = MessageHelper() self.messagequeue = MessageQueueManager( send_callback=self.run_module ) self.pluginmanager = PluginManager() self.filecache = FileCache() self.async_filecache = AsyncFileCache() def load_cache(self, filename: str) -> Any: """ 加载缓存 """ content = self.filecache.get(filename) if not content: return None try: return pickle.loads(content) except Exception as err: logger.error(f"加载缓存 {filename} 出错:{str(err)}") return None async def async_load_cache(self, filename: str) -> Any: """ 异步加载缓存 """ content = await self.async_filecache.get(filename) if not content: return None try: return pickle.loads(content) except Exception as err: logger.error(f"异步加载缓存 {filename} 出错:{str(err)}") return None async def async_save_cache(self, cache: Any, filename: str) -> None: """ 异步保存缓存 """ try: await self.async_filecache.set(filename, pickle.dumps(cache)) except Exception as err: logger.error(f"异步保存缓存 {filename} 出错:{str(err)}") return def save_cache(self, cache: Any, filename: str) -> None: """ 保存缓存 """ try: self.filecache.set(filename, pickle.dumps(cache)) except Exception as err: logger.error(f"保存缓存 {filename} 出错:{str(err)}") return def remove_cache(self, filename: str) -> None: """ 删除缓存,同时删除Redis和本地缓存 """ self.filecache.delete(filename) async def async_remove_cache(self, filename: str) -> None: """ 异步删除缓存,同时删除Redis和本地缓存 """ await self.async_filecache.delete(filename) @staticmethod def __is_valid_empty(ret): """ 判断结果是否为空 """ if isinstance(ret, tuple): return all(value is None for value in ret) else: return ret is None def __handle_plugin_error(self, err: Exception, plugin_id: str, plugin_name: str, method: str, **kwargs): """ 处理插件模块执行错误 """ if kwargs.get("raise_exception"): raise logger.error( f"运行插件 {plugin_id} 模块 {method} 出错:{str(err)}\n{traceback.format_exc()}") self.messagehelper.put(title=f"{plugin_name} 发生了错误", message=str(err), role="plugin") self.eventmanager.send_event( EventType.SystemError, { "type": "plugin", "plugin_id": plugin_id, "plugin_name": plugin_name, "plugin_method": method, "error": str(err), "traceback": traceback.format_exc() } ) def __handle_system_error(self, err: Exception, module_id: str, module_name: str, method: str, **kwargs): """ 处理系统模块执行错误 """ if kwargs.get("raise_exception"): raise logger.error( f"运行模块 {module_id}.{method} 出错:{str(err)}\n{traceback.format_exc()}") self.messagehelper.put(title=f"{module_name}发生了错误", message=str(err), role="system") self.eventmanager.send_event( EventType.SystemError, { "type": "module", "module_id": module_id, "module_name": module_name, "module_method": method, "error": str(err), "traceback": traceback.format_exc() } ) def __execute_plugin_modules(self, method: str, result: Any, *args, **kwargs) -> Any: """ 执行插件模块 """ for plugin, module_dict in self.pluginmanager.get_plugin_modules().items(): plugin_id, plugin_name = plugin if method in module_dict: func = module_dict[method] if func: try: logger.info(f"请求插件 {plugin_name} 执行:{method} ...") if self.__is_valid_empty(result): # 返回None,第一次执行或者需继续执行下一模块 result = func(*args, **kwargs) elif isinstance(result, list): # 返回为列表,有多个模块运行结果时进行合并 temp = func(*args, **kwargs) if isinstance(temp, list): result.extend(temp) else: break except Exception as err: self.__handle_plugin_error(err, plugin_id, plugin_name, method, **kwargs) return result async def __async_execute_plugin_modules(self, method: str, result: Any, *args, **kwargs) -> Any: """ 异步执行插件模块 """ for plugin, module_dict in self.pluginmanager.get_plugin_modules().items(): plugin_id, plugin_name = plugin if method in module_dict: func = module_dict[method] if func: try: logger.info(f"请求插件 {plugin_name} 执行:{method} ...") if self.__is_valid_empty(result): # 返回None,第一次执行或者需继续执行下一模块 if inspect.iscoroutinefunction(func): result = await func(*args, **kwargs) else: # 插件同步函数在异步环境中运行,避免阻塞 result = await run_in_threadpool(func, *args, **kwargs) elif isinstance(result, list): # 返回为列表,有多个模块运行结果时进行合并 if inspect.iscoroutinefunction(func): temp = await func(*args, **kwargs) else: # 插件同步函数在异步环境中运行,避免阻塞 temp = await run_in_threadpool(func, *args, **kwargs) if isinstance(temp, list): result.extend(temp) else: break except Exception as err: self.__handle_plugin_error(err, plugin_id, plugin_name, method, **kwargs) return result def __execute_system_modules(self, method: str, result: Any, *args, **kwargs) -> Any: """ 执行系统模块 """ logger.debug(f"请求系统模块执行:{method} ...") for module in sorted(self.modulemanager.get_running_modules(method), key=lambda x: x.get_priority()): module_id = module.__class__.__name__ try: module_name = module.get_name() except Exception as err: logger.debug(f"获取模块名称出错:{str(err)}") module_name = module_id try: func = getattr(module, method) if self.__is_valid_empty(result): # 返回None,第一次执行或者需继续执行下一模块 result = func(*args, **kwargs) elif ObjectUtils.check_signature(func, result): # 返回结果与方法签名一致,将结果传入 result = func(result) elif isinstance(result, list): # 返回为列表,有多个模块运行结果时进行合并 temp = func(*args, **kwargs) if isinstance(temp, list): result.extend(temp) else: # 中止继续执行 break except Exception as err: logger.error(traceback.format_exc()) self.__handle_system_error(err, module_id, module_name, method, **kwargs) return result async def __async_execute_system_modules(self, method: str, result: Any, *args, **kwargs) -> Any: """ 异步执行系统模块 """ logger.debug(f"请求系统模块执行:{method} ...") for module in sorted(self.modulemanager.get_running_modules(method), key=lambda x: x.get_priority()): module_id = module.__class__.__name__ try: module_name = module.get_name() except Exception as err: logger.debug(f"获取模块名称出错:{str(err)}") module_name = module_id try: func = getattr(module, method) if self.__is_valid_empty(result): # 返回None,第一次执行或者需继续执行下一模块 if inspect.iscoroutinefunction(func): result = await func(*args, **kwargs) else: result = func(*args, **kwargs) elif ObjectUtils.check_signature(func, result): # 返回结果与方法签名一致,将结果传入 if inspect.iscoroutinefunction(func): result = await func(result) else: result = func(result) elif isinstance(result, list): # 返回为列表,有多个模块运行结果时进行合并 if inspect.iscoroutinefunction(func): temp = await func(*args, **kwargs) else: temp = func(*args, **kwargs) if isinstance(temp, list): result.extend(temp) else: # 中止继续执行 break except Exception as err: logger.error(traceback.format_exc()) self.__handle_system_error(err, module_id, module_name, method, **kwargs) return result def run_module(self, method: str, *args, **kwargs) -> Any: """ 运行包含该方法的所有模块,然后返回结果 当kwargs包含命名参数raise_exception时,如模块方法抛出异常且raise_exception为True,则同步抛出异常 """ result = None # 执行插件模块 result = self.__execute_plugin_modules(method, result, *args, **kwargs) if not self.__is_valid_empty(result) and not isinstance(result, list): # 插件模块返回结果不为空且不是列表,直接返回 return result # 执行系统模块 return self.__execute_system_modules(method, result, *args, **kwargs) async def async_run_module(self, method: str, *args, **kwargs) -> Any: """ 异步运行包含该方法的所有模块,然后返回结果 当kwargs包含命名参数raise_exception时,如模块方法抛出异常且raise_exception为True,则同步抛出异常 支持异步和同步方法的混合调用 """ result = None # 执行插件模块 result = await self.__async_execute_plugin_modules(method, result, *args, **kwargs) if not self.__is_valid_empty(result) and not isinstance(result, list): # 插件模块返回结果不为空且不是列表,直接返回 return result # 执行系统模块 return await self.__async_execute_system_modules(method, result, *args, **kwargs) def recognize_media(self, meta: MetaBase = None, mtype: Optional[MediaType] = None, tmdbid: Optional[int] = None, doubanid: Optional[str] = None, bangumiid: Optional[int] = None, episode_group: Optional[str] = None, cache: bool = True) -> Optional[MediaInfo]: """ 识别媒体信息,不含Fanart图片 :param meta: 识别的元数据 :param mtype: 识别的媒体类型,与tmdbid配套 :param tmdbid: tmdbid :param doubanid: 豆瓣ID :param bangumiid: BangumiID :param episode_group: 剧集组 :param cache: 是否使用缓存 :return: 识别的媒体信息,包括剧集信息 """ # 识别用名中含指定信息情形 if not mtype and meta and meta.type in [MediaType.TV, MediaType.MOVIE]: mtype = meta.type if not tmdbid and hasattr(meta, "tmdbid"): tmdbid = meta.tmdbid if not doubanid and hasattr(meta, "doubanid"): doubanid = meta.doubanid # 有tmdbid时不使用其它ID if tmdbid: doubanid = None bangumiid = None with fresh(not cache): return self.run_module("recognize_media", meta=meta, mtype=mtype, tmdbid=tmdbid, doubanid=doubanid, bangumiid=bangumiid, episode_group=episode_group, cache=cache) async def async_recognize_media(self, meta: MetaBase = None, mtype: Optional[MediaType] = None, tmdbid: Optional[int] = None, doubanid: Optional[str] = None, bangumiid: Optional[int] = None, episode_group: Optional[str] = None, cache: bool = True) -> Optional[MediaInfo]: """ 识别媒体信息,不含Fanart图片(异步版本) :param meta: 识别的元数据 :param mtype: 识别的媒体类型,与tmdbid配套 :param tmdbid: tmdbid :param doubanid: 豆瓣ID :param bangumiid: BangumiID :param episode_group: 剧集组 :param cache: 是否使用缓存 :return: 识别的媒体信息,包括剧集信息 """ # 识别用名中含指定信息情形 if not mtype and meta and meta.type in [MediaType.TV, MediaType.MOVIE]: mtype = meta.type if not tmdbid and hasattr(meta, "tmdbid"): tmdbid = meta.tmdbid if not doubanid and hasattr(meta, "doubanid"): doubanid = meta.doubanid # 有tmdbid时不使用其它ID if tmdbid: doubanid = None bangumiid = None async with async_fresh(not cache): return await self.async_run_module("async_recognize_media", meta=meta, mtype=mtype, tmdbid=tmdbid, doubanid=doubanid, bangumiid=bangumiid, episode_group=episode_group, cache=cache) def match_doubaninfo(self, name: str, imdbid: Optional[str] = None, mtype: Optional[MediaType] = None, year: Optional[str] = None, season: Optional[int] = None, raise_exception: bool = False) -> Optional[dict]: """ 搜索和匹配豆瓣信息 :param name: 标题 :param imdbid: imdbid :param mtype: 类型 :param year: 年份 :param season: 季 :param raise_exception: 触发速率限制时是否抛出异常 """ return self.run_module("match_doubaninfo", name=name, imdbid=imdbid, mtype=mtype, year=year, season=season, raise_exception=raise_exception) async def async_match_doubaninfo(self, name: str, imdbid: Optional[str] = None, mtype: Optional[MediaType] = None, year: Optional[str] = None, season: Optional[int] = None, raise_exception: bool = False) -> Optional[dict]: """ 搜索和匹配豆瓣信息(异步版本) :param name: 标题 :param imdbid: imdbid :param mtype: 类型 :param year: 年份 :param season: 季 :param raise_exception: 触发速率限制时是否抛出异常 """ return await self.async_run_module("async_match_doubaninfo", name=name, imdbid=imdbid, mtype=mtype, year=year, season=season, raise_exception=raise_exception) def match_tmdbinfo(self, name: str, mtype: Optional[MediaType] = None, year: Optional[str] = None, season: Optional[int] = None) -> Optional[dict]: """ 搜索和匹配TMDB信息 :param name: 标题 :param mtype: 类型 :param year: 年份 :param season: 季 """ return self.run_module("match_tmdbinfo", name=name, mtype=mtype, year=year, season=season) async def async_match_tmdbinfo(self, name: str, mtype: Optional[MediaType] = None, year: Optional[str] = None, season: Optional[int] = None) -> Optional[dict]: """ 搜索和匹配TMDB信息(异步版本) :param name: 标题 :param mtype: 类型 :param year: 年份 :param season: 季 """ return await self.async_run_module("async_match_tmdbinfo", name=name, mtype=mtype, year=year, season=season) def obtain_images(self, mediainfo: MediaInfo) -> Optional[MediaInfo]: """ 补充抓取媒体信息图片 :param mediainfo: 识别的媒体信息 :return: 更新后的媒体信息 """ return self.run_module("obtain_images", mediainfo=mediainfo) async def async_obtain_images(self, mediainfo: MediaInfo) -> Optional[MediaInfo]: """ 补充抓取媒体信息图片(异步版本) :param mediainfo: 识别的媒体信息 :return: 更新后的媒体信息 """ return await self.async_run_module("async_obtain_images", mediainfo=mediainfo) def obtain_specific_image(self, mediaid: Union[str, int], mtype: MediaType, image_type: MediaImageType, image_prefix: Optional[str] = None, season: Optional[int] = None, episode: Optional[int] = None) -> Optional[str]: """ 获取指定媒体信息图片,返回图片地址 :param mediaid: 媒体ID :param mtype: 媒体类型 :param image_type: 图片类型 :param image_prefix: 图片前缀 :param season: 季 :param episode: 集 """ return self.run_module("obtain_specific_image", mediaid=mediaid, mtype=mtype, image_prefix=image_prefix, image_type=image_type, season=season, episode=episode) def douban_info(self, doubanid: str, mtype: Optional[MediaType] = None, raise_exception: bool = False) -> Optional[dict]: """ 获取豆瓣信息 :param doubanid: 豆瓣ID :param mtype: 媒体类型 :return: 豆瓣信息 :param raise_exception: 触发速率限制时是否抛出异常 """ return self.run_module("douban_info", doubanid=doubanid, mtype=mtype, raise_exception=raise_exception) async def async_douban_info(self, doubanid: str, mtype: Optional[MediaType] = None, raise_exception: bool = False) -> Optional[dict]: """ 获取豆瓣信息(异步版本) :param doubanid: 豆瓣ID :param mtype: 媒体类型 :return: 豆瓣信息 :param raise_exception: 触发速率限制时是否抛出异常 """ return await self.async_run_module("async_douban_info", doubanid=doubanid, mtype=mtype, raise_exception=raise_exception) def tvdb_info(self, tvdbid: int) -> Optional[dict]: """ 获取TVDB信息 :param tvdbid: int :return: TVDB信息 """ return self.run_module("tvdb_info", tvdbid=tvdbid) def tmdb_info(self, tmdbid: int, mtype: MediaType, season: Optional[int] = None) -> Optional[dict]: """ 获取TMDB信息 :param tmdbid: int :param mtype: 媒体类型 :param season: 季 :return: TVDB信息 """ return self.run_module("tmdb_info", tmdbid=tmdbid, mtype=mtype, season=season) async def async_tmdb_info(self, tmdbid: int, mtype: MediaType, season: Optional[int] = None) -> Optional[dict]: """ 获取TMDB信息(异步版本) :param tmdbid: int :param mtype: 媒体类型 :param season: 季 :return: TVDB信息 """ return await self.async_run_module("async_tmdb_info", tmdbid=tmdbid, mtype=mtype, season=season) def bangumi_info(self, bangumiid: int) -> Optional[dict]: """ 获取Bangumi信息 :param bangumiid: int :return: Bangumi信息 """ return self.run_module("bangumi_info", bangumiid=bangumiid) async def async_bangumi_info(self, bangumiid: int) -> Optional[dict]: """ 获取Bangumi信息(异步版本) :param bangumiid: int :return: Bangumi信息 """ return await self.async_run_module("async_bangumi_info", bangumiid=bangumiid) def message_parser(self, source: str, body: Any, form: Any, args: Any) -> Optional[CommingMessage]: """ 解析消息内容,返回字典,注意以下约定值: userid: 用户ID username: 用户名 text: 内容 :param source: 消息来源(渠道配置名称) :param body: 请求体 :param form: 表单 :param args: 参数 :return: 消息渠道、消息内容 """ return self.run_module("message_parser", source=source, body=body, form=form, args=args) def webhook_parser(self, body: Any, form: Any, args: Any) -> Optional[WebhookEventInfo]: """ 解析Webhook报文体 :param body: 请求体 :param form: 请求表单 :param args: 请求参数 :return: 字典,解析为消息时需要包含:title、text、image """ return self.run_module("webhook_parser", body=body, form=form, args=args) def search_medias(self, meta: MetaBase) -> Optional[List[MediaInfo]]: """ 搜索媒体信息 :param meta: 识别的元数据 :reutrn: 媒体信息列表 """ return self.run_module("search_medias", meta=meta) async def async_search_medias(self, meta: MetaBase) -> Optional[List[MediaInfo]]: """ 搜索媒体信息(异步版本) :param meta: 识别的元数据 :reutrn: 媒体信息列表 """ return await self.async_run_module("async_search_medias", meta=meta) def search_persons(self, name: str) -> Optional[List[MediaPerson]]: """ 搜索人物信息 :param name: 人物名称 """ return self.run_module("search_persons", name=name) async def async_search_persons(self, name: str) -> Optional[List[MediaPerson]]: """ 搜索人物信息(异步版本) :param name: 人物名称 """ return await self.async_run_module("async_search_persons", name=name) def search_collections(self, name: str) -> Optional[List[MediaInfo]]: """ 搜索集合信息 :param name: 集合名称 """ return self.run_module("search_collections", name=name) async def async_search_collections(self, name: str) -> Optional[List[MediaInfo]]: """ 搜索集合信息(异步版本) :param name: 集合名称 """ return await self.async_run_module("async_search_collections", name=name) def search_torrents(self, site: dict, keyword: str, mtype: Optional[MediaType] = None, page: Optional[int] = 0) -> List[TorrentInfo]: """ 搜索一个站点的种子资源 :param site: 站点 :param keyword: 搜索关键词 :param mtype: 媒体类型 :param page: 页码 :reutrn: 资源列表 """ return self.run_module("search_torrents", site=site, keyword=keyword, mtype=mtype, page=page) async def async_search_torrents(self, site: dict, keyword: str, mtype: Optional[MediaType] = None, page: Optional[int] = 0) -> List[TorrentInfo]: """ 异步搜索一个站点的种子资源 :param site: 站点 :param keyword: 搜索关键词 :param mtype: 媒体类型 :param page: 页码 :reutrn: 资源列表 """ return await self.async_run_module("async_search_torrents", site=site, keyword=keyword, mtype=mtype, page=page) def refresh_torrents(self, site: dict, keyword: Optional[str] = None, cat: Optional[str] = None, page: Optional[int] = 0) -> List[TorrentInfo]: """ 获取站点最新一页的种子,多个站点需要多线程处理 :param site: 站点 :param keyword: 标题 :param cat: 分类 :param page: 页码 :reutrn: 种子资源列表 """ return self.run_module("refresh_torrents", site=site, keyword=keyword, cat=cat, page=page) async def async_refresh_torrents(self, site: dict, keyword: Optional[str] = None, cat: Optional[str] = None, page: Optional[int] = 0) -> List[TorrentInfo]: """ 异步获取站点最新一页的种子,多个站点需要多线程处理 :param site: 站点 :param keyword: 标题 :param cat: 分类 :param page: 页码 :reutrn: 种子资源列表 """ return await self.async_run_module("async_refresh_torrents", site=site, keyword=keyword, cat=cat, page=page) def filter_torrents(self, rule_groups: List[str], torrent_list: List[TorrentInfo], mediainfo: MediaInfo = None) -> List[TorrentInfo]: """ 过滤种子资源 :param rule_groups: 过滤规则组名称列表 :param torrent_list: 资源列表 :param mediainfo: 识别的媒体信息 :return: 过滤后的资源列表,添加资源优先级 """ return self.run_module("filter_torrents", rule_groups=rule_groups, torrent_list=torrent_list, mediainfo=mediainfo) def download(self, content: Union[Path, str, bytes], download_dir: Path, cookie: str, episodes: Set[int] = None, category: Optional[str] = None, label: Optional[str] = None, downloader: Optional[str] = None ) -> Optional[Tuple[Optional[str], Optional[str], Optional[str], str]]: """ 根据种子文件,选择并添加下载任务 :param content: 种子文件地址或者磁力链接或者种子内容 :param download_dir: 下载目录 :param cookie: cookie :param episodes: 需要下载的集数 :param category: 种子分类 :param label: 标签 :param downloader: 下载器 :return: 下载器名称、种子Hash、种子文件布局、错误原因 """ return self.run_module("download", content=content, download_dir=download_dir, cookie=cookie, episodes=episodes, category=category, label=label, downloader=downloader) def download_added(self, context: Context, download_dir: Path, torrent_content: Union[str, bytes] = None) -> None: """ 添加下载任务成功后,从站点下载字幕,保存到下载目录 :param context: 上下文,包括识别信息、媒体信息、种子信息 :param download_dir: 下载目录 :param torrent_content: 种子内容,如果有则直接使用该内容,否则从context中获取种子文件路径 :return: None,该方法可被多个模块同时处理 """ return self.run_module("download_added", context=context, torrent_content=torrent_content, download_dir=download_dir) def list_torrents(self, status: TorrentStatus = None, hashs: Union[list, str] = None, downloader: Optional[str] = None ) -> Optional[List[Union[TransferTorrent, DownloadingTorrent]]]: """ 获取下载器种子列表 :param status: 种子状态 :param hashs: 种子Hash :param downloader: 下载器 :return: 下载器中符合状态的种子列表 """ return self.run_module("list_torrents", status=status, hashs=hashs, downloader=downloader) def transfer(self, fileitem: FileItem, meta: MetaBase, mediainfo: MediaInfo, target_directory: TransferDirectoryConf = None, target_storage: Optional[str] = None, target_path: Path = None, transfer_type: Optional[str] = None, scrape: bool = None, library_type_folder: bool = None, library_category_folder: bool = None, episodes_info: List[TmdbEpisode] = None, source_oper: Callable = None, target_oper: Callable = None) -> Optional[TransferInfo]: """ 文件转移 :param fileitem: 文件信息 :param meta: 预识别的元数据 :param mediainfo: 识别的媒体信息 :param target_directory: 目标目录配置 :param target_storage: 目标存储 :param target_path: 目标路径 :param transfer_type: 转移模式 :param scrape: 是否刮削元数据 :param library_type_folder: 是否按类型创建目录 :param library_category_folder: 是否按类别创建目录 :param episodes_info: 当前季的全部集信息 :param source_oper: 源存储操作类 :param target_oper: 目标存储操作类 :return: {path, target_path, message} """ return self.run_module("transfer", fileitem=fileitem, meta=meta, mediainfo=mediainfo, target_directory=target_directory, target_path=target_path, target_storage=target_storage, transfer_type=transfer_type, scrape=scrape, library_type_folder=library_type_folder, library_category_folder=library_category_folder, episodes_info=episodes_info, source_oper=source_oper, target_oper=target_oper) def transfer_completed(self, hashs: str, downloader: Optional[str] = None) -> None: """ 下载器转移完成后的处理 :param hashs: 种子Hash :param downloader: 下载器 """ return self.run_module("transfer_completed", hashs=hashs, downloader=downloader) def remove_torrents(self, hashs: Union[str, list], delete_file: bool = True, downloader: Optional[str] = None) -> bool: """ 删除下载器种子 :param hashs: 种子Hash :param delete_file: 是否删除文件 :param downloader: 下载器 :return: bool """ return self.run_module("remove_torrents", hashs=hashs, delete_file=delete_file, downloader=downloader) def start_torrents(self, hashs: Union[list, str], downloader: Optional[str] = None) -> bool: """ 开始下载 :param hashs: 种子Hash :param downloader: 下载器 :return: bool """ return self.run_module("start_torrents", hashs=hashs, downloader=downloader) def stop_torrents(self, hashs: Union[list, str], downloader: Optional[str] = None) -> bool: """ 停止下载 :param hashs: 种子Hash :param downloader: 下载器 :return: bool """ return self.run_module("stop_torrents", hashs=hashs, downloader=downloader) def torrent_files(self, tid: str, downloader: Optional[str] = None) -> Optional[Union[TorrentFilesList, List[File]]]: """ 获取种子文件 :param tid: 种子Hash :param downloader: 下载器 :return: 种子文件 """ return self.run_module("torrent_files", tid=tid, downloader=downloader) def media_exists(self, mediainfo: MediaInfo, itemid: Optional[str] = None, server: Optional[str] = None) -> Optional[ExistMediaInfo]: """ 判断媒体文件是否存在 :param mediainfo: 识别的媒体信息 :param itemid: 媒体服务器ItemID :param server: 媒体服务器 :return: 如不存在返回None,存在时返回信息,包括每季已存在所有集{type: movie/tv, seasons: {season: [episodes]}} """ return self.run_module("media_exists", mediainfo=mediainfo, itemid=itemid, server=server) def media_files(self, mediainfo: MediaInfo) -> Optional[List[FileItem]]: """ 获取媒体文件清单 :param mediainfo: 识别的媒体信息 :return: 媒体文件列表 """ return self.run_module("media_files", mediainfo=mediainfo) def post_message(self, message: Optional[Notification] = None, meta: Optional[MetaBase] = None, mediainfo: Optional[MediaInfo] = None, torrentinfo: Optional[TorrentInfo] = None, transferinfo: Optional[TransferInfo] = None, **kwargs) -> None: """ 发送消息 :param message: Notification实例 :param meta: 元数据 :param mediainfo: 媒体信息 :param torrentinfo: 种子信息 :param transferinfo: 文件整理信息 :param kwargs: 其他参数(覆盖业务对象属性值) :return: 成功或失败 """ # 添加格式化的时间参数 kwargs.setdefault('current_time', datetime.now().strftime('%Y-%m-%d %H:%M:%S')) # 渲染消息 message = MessageTemplateHelper.render(message=message, meta=meta, mediainfo=mediainfo, torrentinfo=torrentinfo, transferinfo=transferinfo, **kwargs) # 检查消息是否有效 if not message: logger.warning("消息为空,跳过发送") return # 保存消息 self.messagehelper.put(message, role="user", title=message.title) self.messageoper.add(**message.model_dump()) # 发送消息按设置隔离 if not message.userid and message.mtype: # 消息隔离设置 notify_action = ServiceConfigHelper.get_notification_switch(message.mtype) if notify_action: # 'admin' 'user,admin' 'user' 'all' actions = notify_action.split(",") # 是否已发送管理员标志 admin_sended = False send_orignal = False useroper = UserOper() for action in actions: send_message = copy.deepcopy(message) if action == "admin" and not admin_sended: # 仅发送管理员 logger.info(f"{send_message.mtype} 的消息已设置发送给管理员") # 读取管理员消息IDS send_message.targets = useroper.get_settings(settings.SUPERUSER) admin_sended = True elif action == "user" and send_message.username: # 发送对应用户 logger.info(f"{send_message.mtype} 的消息已设置发送给用户 {send_message.username}") # 读取用户消息IDS send_message.targets = useroper.get_settings(send_message.username) if send_message.targets is None: # 没有找到用户 if not admin_sended: # 回滚发送管理员 logger.info(f"用户 {send_message.username} 不存在,消息将发送给管理员") # 读取管理员消息IDS send_message.targets = useroper.get_settings(settings.SUPERUSER) admin_sended = True else: # 管理员发过了,此消息不发了 logger.info(f"用户 {send_message.username} 不存在,消息无法发送到对应用户") continue elif send_message.username == settings.SUPERUSER: # 管理员同名已发送 admin_sended = True else: # 按原消息发送全体 if not admin_sended: send_orignal = True break # 按设定发送 self.eventmanager.send_event(etype=EventType.NoticeMessage, data={**send_message.model_dump(), "type": send_message.mtype}) self.messagequeue.send_message("post_message", message=send_message, **kwargs) if not send_orignal: return # 发送消息事件 self.eventmanager.send_event(etype=EventType.NoticeMessage, data={**message.model_dump(), "type": message.mtype}) # 按原消息发送 self.messagequeue.send_message("post_message", message=message, immediately=True if message.userid else False, **kwargs) async def async_post_message(self, message: Optional[Notification] = None, meta: Optional[MetaBase] = None, mediainfo: Optional[MediaInfo] = None, torrentinfo: Optional[TorrentInfo] = None, transferinfo: Optional[TransferInfo] = None, **kwargs) -> None: """ 异步发送消息 :param message: Notification实例 :param meta: 元数据 :param mediainfo: 媒体信息 :param torrentinfo: 种子信息 :param transferinfo: 文件整理信息 :param kwargs: 其他参数(覆盖业务对象属性值) :return: 成功或失败 """ # 添加格式化的时间参数 kwargs.setdefault('current_time', datetime.now().strftime('%Y-%m-%d %H:%M:%S')) # 渲染消息 message = MessageTemplateHelper.render(message=message, meta=meta, mediainfo=mediainfo, torrentinfo=torrentinfo, transferinfo=transferinfo, **kwargs) # 检查消息是否有效 if not message: logger.warning("消息为空,跳过发送") return # 保存消息 self.messagehelper.put(message, role="user", title=message.title) await self.messageoper.async_add(**message.model_dump()) # 发送消息按设置隔离 if not message.userid and message.mtype: # 消息隔离设置 notify_action = ServiceConfigHelper.get_notification_switch(message.mtype) if notify_action: # 'admin' 'user,admin' 'user' 'all' actions = notify_action.split(",") # 是否已发送管理员标志 admin_sended = False send_orignal = False useroper = UserOper() for action in actions: send_message = copy.deepcopy(message) if action == "admin" and not admin_sended: # 仅发送管理员 logger.info(f"{send_message.mtype} 的消息已设置发送给管理员") # 读取管理员消息IDS send_message.targets = useroper.get_settings(settings.SUPERUSER) admin_sended = True elif action == "user" and send_message.username: # 发送对应用户 logger.info(f"{send_message.mtype} 的消息已设置发送给用户 {send_message.username}") # 读取用户消息IDS send_message.targets = useroper.get_settings(send_message.username) if send_message.targets is None: # 没有找到用户 if not admin_sended: # 回滚发送管理员 logger.info(f"用户 {send_message.username} 不存在,消息将发送给管理员") # 读取管理员消息IDS send_message.targets = useroper.get_settings(settings.SUPERUSER) admin_sended = True else: # 管理员发过了,此消息不发了 logger.info(f"用户 {send_message.username} 不存在,消息无法发送到对应用户") continue elif send_message.username == settings.SUPERUSER: # 管理员同名已发送 admin_sended = True else: # 按原消息发送全体 if not admin_sended: send_orignal = True break # 按设定发送 await self.eventmanager.async_send_event(etype=EventType.NoticeMessage, data={**send_message.model_dump(), "type": send_message.mtype}) await self.messagequeue.async_send_message("post_message", message=send_message, **kwargs) if not send_orignal: return # 发送消息事件 await self.eventmanager.async_send_event(etype=EventType.NoticeMessage, data={**message.model_dump(), "type": message.mtype}) # 按原消息发送 await self.messagequeue.async_send_message("post_message", message=message, immediately=True if message.userid else False, **kwargs) def post_medias_message(self, message: Notification, medias: List[MediaInfo]) -> None: """ 发送媒体信息选择列表 :param message: 消息体 :param medias: 媒体列表 :return: 成功或失败 """ note_list = [media.to_dict() for media in medias] self.messagehelper.put(message, role="user", note=note_list, title=message.title) self.messageoper.add(**message.model_dump(), note=note_list) return self.messagequeue.send_message("post_medias_message", message=message, medias=medias, immediately=True if message.userid else False) def post_torrents_message(self, message: Notification, torrents: List[Context]) -> None: """ 发送种子信息选择列表 :param message: 消息体 :param torrents: 种子列表 :return: 成功或失败 """ note_list = [torrent.torrent_info.to_dict() for torrent in torrents] self.messagehelper.put(message, role="user", note=note_list, title=message.title) self.messageoper.add(**message.model_dump(), note=note_list) return self.messagequeue.send_message("post_torrents_message", message=message, torrents=torrents, immediately=True if message.userid else False) def delete_message(self, channel: MessageChannel, source: str, message_id: Union[str, int], chat_id: Optional[Union[str, int]] = None) -> bool: """ 删除消息 :param channel: 消息渠道 :param source: 消息源(指定特定的消息模块) :param message_id: 消息ID :param chat_id: 聊天ID(如群组ID) :return: 删除是否成功 """ return self.run_module("delete_message", channel=channel, source=source, message_id=message_id, chat_id=chat_id) def metadata_img(self, mediainfo: MediaInfo, season: Optional[int] = None, episode: Optional[int] = None) -> Optional[dict]: """ 获取图片名称和url :param mediainfo: 媒体信息 :param season: 季号 :param episode: 集号 """ return self.run_module("metadata_img", mediainfo=mediainfo, season=season, episode=episode) def media_category(self) -> Optional[Dict[str, list]]: """ 获取媒体分类 :return: 获取二级分类配置字典项,需包括电影、电视剧 """ return self.run_module("media_category") def category_config(self) -> CategoryConfig: """ 获取分类策略配置 """ return self.run_module("load_category_config") def save_category_config(self, config: CategoryConfig) -> bool: """ 保存分类策略配置 """ return self.run_module("save_category_config", config=config) def register_commands(self, commands: Dict[str, dict]) -> None: """ 注册菜单命令 """ self.run_module("register_commands", commands=commands) def scheduler_job(self) -> None: """ 定时任务,每10分钟调用一次,模块实现该接口以实现定时服务 """ self.run_module("scheduler_job") def clear_cache(self) -> None: """ 清理缓存,模块实现该接口响应清理缓存事件 """ self.run_module("clear_cache") ================================================ FILE: app/chain/ai_recommend.py ================================================ import re from typing import List, Optional, Dict, Any import asyncio import hashlib import json from app.chain import ChainBase from app.core.config import settings from app.log import logger from app.utils.common import log_execution_time from app.utils.singleton import Singleton from app.utils.string import StringUtils class AIRecommendChain(ChainBase, metaclass=Singleton): """ AI推荐处理链,单例运行 用于基于搜索结果的AI智能推荐 """ # 缓存文件名 __ai_indices_cache_file = "__ai_recommend_indices__" # AI推荐状态 _ai_recommend_running = False _ai_recommend_task: Optional[asyncio.Task] = None _current_request_hash: Optional[str] = None # 当前请求的哈希值 _ai_recommend_result: Optional[List[int]] = None # AI推荐索引缓存(索引列表) _ai_recommend_error: Optional[str] = None # AI推荐错误信息 @staticmethod def _calculate_request_hash( filtered_indices: Optional[List[int]], search_results_count: int ) -> str: """ 计算请求的哈希值,用于判断请求是否变化 """ request_data = { "filtered_indices": filtered_indices or [], "search_results_count": search_results_count, } return hashlib.md5( json.dumps(request_data, sort_keys=True).encode() ).hexdigest() @property def is_enabled(self) -> bool: """ 检查AI推荐功能是否已启用。 """ return settings.AI_AGENT_ENABLE and settings.AI_RECOMMEND_ENABLED def _build_status(self) -> Dict[str, Any]: """ 构建AI推荐状态字典 :return: 状态字典 """ if not self.is_enabled: return {"status": "disabled"} if self._ai_recommend_running: return {"status": "running"} # 尝试从数据库加载缓存 if self._ai_recommend_result is None: cached_indices = self.load_cache(self.__ai_indices_cache_file) if cached_indices is not None: self._ai_recommend_result = cached_indices # 只要有结果,始终返回completed状态和数据 if self._ai_recommend_result is not None: return {"status": "completed", "results": self._ai_recommend_result} if self._ai_recommend_error is not None: return {"status": "error", "error": self._ai_recommend_error} return {"status": "idle"} def get_current_status_only(self) -> Dict[str, Any]: """ 获取当前状态(不校验hash,用于check_only模式) """ return self._build_status() def get_status( self, filtered_indices: Optional[List[int]], search_results_count: int ) -> Dict[str, Any]: """ 获取AI推荐状态并检查请求是否变化(用于首次请求或force模式) 如果请求变化(筛选条件变化),返回idle状态 """ # 计算当前请求的hash request_hash = self._calculate_request_hash( filtered_indices, search_results_count ) # 检查请求是否变化 is_same_request = request_hash == self._current_request_hash # 如果请求变化了(筛选条件改变),返回idle状态 if not is_same_request: return {"status": "idle"} if self.is_enabled else {"status": "disabled"} # 请求未变化,返回当前实际状态 return self._build_status() @log_execution_time(logger=logger) async def async_ai_recommend(self, items: List[str], preference: str = None) -> str: """ AI推荐 :param items: 候选资源列表(JSON字符串格式) :param preference: 用户偏好(可选) :return: AI返回的推荐结果 """ # 设置运行状态 self._ai_recommend_running = True try: # 导入LLMHelper from app.helper.llm import LLMHelper # 获取LLM实例 llm = LLMHelper.get_llm() # 构建提示词 user_preference = ( preference or settings.AI_RECOMMEND_USER_PREFERENCE or "Prefer high-quality resources with more seeders" ) # 添加指令 instruction = """ Task: Select the best matching items from the list based on user preferences. Each item contains: - index: Item number - title: Full torrent title - size: File size - seeders: Number of seeders Output Format: Return ONLY a JSON array of "index" numbers (e.g., [0, 3, 1]). Do NOT include any explanations or other text. """ message = ( f"User Preference: {user_preference}\n{instruction}\nCandidate Resources:\n" + "\n".join(items) ) # 调用LLM response = await llm.ainvoke(message) return response.content except ValueError as e: logger.error(f"AI推荐配置错误: {e}") raise except Exception as e: raise finally: # 清除运行状态 self._ai_recommend_running = False self._ai_recommend_task = None def is_ai_recommend_running(self) -> bool: """ 检查AI推荐是否正在运行 """ return self._ai_recommend_running def cancel_ai_recommend(self): """ 取消正在运行的AI推荐任务 """ if self._ai_recommend_task and not self._ai_recommend_task.done(): self._ai_recommend_task.cancel() self._ai_recommend_running = False self._ai_recommend_task = None self._current_request_hash = None self._ai_recommend_result = None self._ai_recommend_error = None self.remove_cache(self.__ai_indices_cache_file) def start_recommend_task( self, filtered_indices: Optional[List[int]], search_results_count: int, results: List[Any], ) -> None: """ 启动AI推荐任务 :param filtered_indices: 筛选后的索引列表 :param search_results_count: 搜索结果总数 :param results: 搜索结果列表 """ # 防护检查:确保AI推荐功能已启用 if not self.is_enabled: logger.warning("AI推荐功能未启用,跳过任务执行") return # 计算新请求的哈希值 new_request_hash = self._calculate_request_hash( filtered_indices, search_results_count ) # 如果请求变化了,取消旧任务 if new_request_hash != self._current_request_hash: self.cancel_ai_recommend() # 更新请求哈希值 self._current_request_hash = new_request_hash # 重置状态 self._ai_recommend_result = None self._ai_recommend_error = None # 启动新任务 async def run_recommend(): # 获取当前任务对象,用于在finally中比对 current_task = asyncio.current_task() try: self._ai_recommend_running = True # 准备数据 items = [] valid_indices = [] max_items = settings.AI_RECOMMEND_MAX_ITEMS or 50 # 如果提供了筛选索引,先筛选结果;否则使用所有结果 if filtered_indices is not None and len(filtered_indices) > 0: results_to_process = [ results[i] for i in filtered_indices if 0 <= i < len(results) ] else: results_to_process = results for i, torrent in enumerate(results_to_process): if len(items) >= max_items: break if not torrent.torrent_info: continue valid_indices.append(i) item_info = { "index": i, "title": torrent.torrent_info.title or "未知", "size": ( StringUtils.format_size(torrent.torrent_info.size) if torrent.torrent_info.size else "0 B" ), "seeders": torrent.torrent_info.seeders or 0, } items.append(json.dumps(item_info, ensure_ascii=False)) if not items: self._ai_recommend_error = "没有可用于AI推荐的资源" return # 调用AI推荐 ai_response = await self.async_ai_recommend(items) # 解析AI返回的索引 try: # 使用正则提取JSON数组(非贪婪模式,避免匹配多个数组) json_match = re.search(r'\[.*?\]', ai_response, re.DOTALL) if not json_match: raise ValueError(ai_response) ai_indices = json.loads(json_match.group()) if not isinstance(ai_indices, list): raise ValueError(f"AI返回格式错误: {ai_response}") # 映射回原始索引 if filtered_indices: original_indices = [ filtered_indices[valid_indices[i]] for i in ai_indices if i < len(valid_indices) and 0 <= filtered_indices[valid_indices[i]] < len(results) ] else: original_indices = [ valid_indices[i] for i in ai_indices if i < len(valid_indices) and 0 <= valid_indices[i] < len(results) ] # 只返回索引列表,不返回完整数据 self._ai_recommend_result = original_indices # 保存到数据库 self.save_cache(original_indices, self.__ai_indices_cache_file) logger.info(f"AI推荐完成: {len(original_indices)}项") except Exception as e: logger.error( f"解析AI返回结果失败: {e}, 原始响应: {ai_response}" ) self._ai_recommend_error = str(e) except asyncio.CancelledError: logger.info("AI推荐任务被取消") except Exception as e: logger.error(f"AI推荐任务失败: {e}") self._ai_recommend_error = str(e) finally: # 只有当 self._ai_recommend_task 仍然是当前任务时,才清理状态 # 如果任务被取消并启动了新任务,self._ai_recommend_task 已经指向新任务,不应重置 if self._ai_recommend_task == current_task: self._ai_recommend_running = False self._ai_recommend_task = None # 创建并启动任务 self._ai_recommend_task = asyncio.create_task(run_recommend()) ================================================ FILE: app/chain/bangumi.py ================================================ from typing import Optional, List from app import schemas from app.chain import ChainBase from app.core.context import MediaInfo class BangumiChain(ChainBase): """ Bangumi处理链 """ def calendar(self) -> Optional[List[MediaInfo]]: """ 获取Bangumi每日放送 """ return self.run_module("bangumi_calendar") def discover(self, **kwargs) -> Optional[List[MediaInfo]]: """ 发现Bangumi番剧 """ return self.run_module("bangumi_discover", **kwargs) def bangumi_info(self, bangumiid: int) -> Optional[dict]: """ 获取Bangumi信息 :param bangumiid: BangumiID :return: Bangumi信息 """ return self.run_module("bangumi_info", bangumiid=bangumiid) def bangumi_credits(self, bangumiid: int) -> List[schemas.MediaPerson]: """ 根据BangumiID查询电影演职员表 :param bangumiid: BangumiID """ return self.run_module("bangumi_credits", bangumiid=bangumiid) def bangumi_recommend(self, bangumiid: int) -> Optional[List[MediaInfo]]: """ 根据BangumiID查询推荐电影 :param bangumiid: BangumiID """ return self.run_module("bangumi_recommend", bangumiid=bangumiid) def person_detail(self, person_id: int) -> Optional[schemas.MediaPerson]: """ 根据人物ID查询Bangumi人物详情 :param person_id: 人物ID """ return self.run_module("bangumi_person_detail", person_id=person_id) def person_credits(self, person_id: int) -> Optional[List[MediaInfo]]: """ 根据人物ID查询人物参演作品 :param person_id: 人物ID """ return self.run_module("bangumi_person_credits", person_id=person_id) async def async_calendar(self) -> Optional[List[MediaInfo]]: """ 获取Bangumi每日放送(异步版本) """ return await self.async_run_module("async_bangumi_calendar") async def async_discover(self, **kwargs) -> Optional[List[MediaInfo]]: """ 发现Bangumi番剧(异步版本) """ return await self.async_run_module("async_bangumi_discover", **kwargs) async def async_bangumi_info(self, bangumiid: int) -> Optional[dict]: """ 获取Bangumi信息(异步版本) :param bangumiid: BangumiID :return: Bangumi信息 """ return await self.async_run_module("async_bangumi_info", bangumiid=bangumiid) async def async_bangumi_credits(self, bangumiid: int) -> List[schemas.MediaPerson]: """ 根据BangumiID查询电影演职员表(异步版本) :param bangumiid: BangumiID """ return await self.async_run_module("async_bangumi_credits", bangumiid=bangumiid) async def async_bangumi_recommend(self, bangumiid: int) -> Optional[List[MediaInfo]]: """ 根据BangumiID查询推荐电影(异步版本) :param bangumiid: BangumiID """ return await self.async_run_module("async_bangumi_recommend", bangumiid=bangumiid) async def async_person_detail(self, person_id: int) -> Optional[schemas.MediaPerson]: """ 根据人物ID查询Bangumi人物详情(异步版本) :param person_id: 人物ID """ return await self.async_run_module("async_bangumi_person_detail", person_id=person_id) async def async_person_credits(self, person_id: int) -> Optional[List[MediaInfo]]: """ 根据人物ID查询人物参演作品(异步版本) :param person_id: 人物ID """ return await self.async_run_module("async_bangumi_person_credits", person_id=person_id) ================================================ FILE: app/chain/dashboard.py ================================================ from typing import Optional, List from app import schemas from app.chain import ChainBase class DashboardChain(ChainBase): """ 各类仪表板统计处理链 """ def media_statistic(self, server: Optional[str] = None) -> Optional[List[schemas.Statistic]]: """ 媒体数量统计 """ return self.run_module("media_statistic", server=server) def downloader_info(self, downloader: Optional[str] = None) -> Optional[List[schemas.DownloaderInfo]]: """ 下载器信息 """ return self.run_module("downloader_info", downloader=downloader) ================================================ FILE: app/chain/douban.py ================================================ from typing import Optional, List from app import schemas from app.chain import ChainBase from app.core.context import MediaInfo from app.schemas import MediaType class DoubanChain(ChainBase): """ 豆瓣处理链 """ def person_detail(self, person_id: int) -> Optional[schemas.MediaPerson]: """ 根据人物ID查询豆瓣人物详情 :param person_id: 人物ID """ return self.run_module("douban_person_detail", person_id=person_id) def person_credits(self, person_id: int, page: Optional[int] = 1) -> List[MediaInfo]: """ 根据人物ID查询人物参演作品 :param person_id: 人物ID :param page: 页码 """ return self.run_module("douban_person_credits", person_id=person_id, page=page) def movie_top250(self, page: Optional[int] = 1, count: Optional[int] = 30) -> Optional[List[MediaInfo]]: """ 获取豆瓣电影TOP250 :param page: 页码 :param count: 每页数量 """ return self.run_module("movie_top250", page=page, count=count) def movie_showing(self, page: Optional[int] = 1, count: Optional[int] = 30) -> Optional[List[MediaInfo]]: """ 获取正在上映的电影 """ return self.run_module("movie_showing", page=page, count=count) def tv_weekly_chinese(self, page: Optional[int] = 1, count: Optional[int] = 30) -> Optional[List[MediaInfo]]: """ 获取本周中国剧集榜 """ return self.run_module("tv_weekly_chinese", page=page, count=count) def tv_weekly_global(self, page: Optional[int] = 1, count: Optional[int] = 30) -> Optional[List[MediaInfo]]: """ 获取本周全球剧集榜 """ return self.run_module("tv_weekly_global", page=page, count=count) def douban_discover(self, mtype: MediaType, sort: str, tags: str, page: Optional[int] = 0, count: Optional[int] = 30) -> Optional[List[MediaInfo]]: """ 发现豆瓣电影、剧集 :param mtype: 媒体类型 :param sort: 排序方式 :param tags: 标签 :param page: 页码 :param count: 数量 :return: 媒体信息列表 """ return self.run_module("douban_discover", mtype=mtype, sort=sort, tags=tags, page=page, count=count) def tv_animation(self, page: Optional[int] = 1, count: Optional[int] = 30) -> Optional[List[MediaInfo]]: """ 获取动画剧集 """ return self.run_module("tv_animation", page=page, count=count) def movie_hot(self, page: Optional[int] = 1, count: Optional[int] = 30) -> Optional[List[MediaInfo]]: """ 获取热门电影 """ return self.run_module("movie_hot", page=page, count=count) def tv_hot(self, page: Optional[int] = 1, count: Optional[int] = 30) -> Optional[List[MediaInfo]]: """ 获取热门剧集 """ return self.run_module("tv_hot", page=page, count=count) def movie_credits(self, doubanid: str) -> Optional[List[schemas.MediaPerson]]: """ 根据TMDBID查询电影演职人员 :param doubanid: 豆瓣ID """ return self.run_module("douban_movie_credits", doubanid=doubanid) def tv_credits(self, doubanid: str) -> Optional[List[schemas.MediaPerson]]: """ 根据TMDBID查询电视剧演职人员 :param doubanid: 豆瓣ID """ return self.run_module("douban_tv_credits", doubanid=doubanid) def movie_recommend(self, doubanid: str) -> List[MediaInfo]: """ 根据豆瓣ID查询推荐电影 :param doubanid: 豆瓣ID """ return self.run_module("douban_movie_recommend", doubanid=doubanid) def tv_recommend(self, doubanid: str) -> List[MediaInfo]: """ 根据豆瓣ID查询推荐电视剧 :param doubanid: 豆瓣ID """ return self.run_module("douban_tv_recommend", doubanid=doubanid) async def async_person_detail(self, person_id: int) -> Optional[schemas.MediaPerson]: """ 根据人物ID查询豆瓣人物详情(异步版本) :param person_id: 人物ID """ return await self.async_run_module("async_douban_person_detail", person_id=person_id) async def async_person_credits(self, person_id: int, page: Optional[int] = 1) -> List[MediaInfo]: """ 根据人物ID查询人物参演作品(异步版本) :param person_id: 人物ID :param page: 页码 """ return await self.async_run_module("async_douban_person_credits", person_id=person_id, page=page) async def async_movie_top250(self, page: Optional[int] = 1, count: Optional[int] = 30) -> Optional[List[MediaInfo]]: """ 获取豆瓣电影TOP250(异步版本) :param page: 页码 :param count: 每页数量 """ return await self.async_run_module("async_movie_top250", page=page, count=count) async def async_movie_showing(self, page: Optional[int] = 1, count: Optional[int] = 30) -> Optional[List[MediaInfo]]: """ 获取正在上映的电影(异步版本) """ return await self.async_run_module("async_movie_showing", page=page, count=count) async def async_tv_weekly_chinese(self, page: Optional[int] = 1, count: Optional[int] = 30) -> Optional[List[MediaInfo]]: """ 获取本周中国剧集榜(异步版本) """ return await self.async_run_module("async_tv_weekly_chinese", page=page, count=count) async def async_tv_weekly_global(self, page: Optional[int] = 1, count: Optional[int] = 30) -> Optional[List[MediaInfo]]: """ 获取本周全球剧集榜(异步版本) """ return await self.async_run_module("async_tv_weekly_global", page=page, count=count) async def async_douban_discover(self, mtype: MediaType, sort: str, tags: str, page: Optional[int] = 0, count: Optional[int] = 30) -> Optional[List[MediaInfo]]: """ 发现豆瓣电影、剧集(异步版本) :param mtype: 媒体类型 :param sort: 排序方式 :param tags: 标签 :param page: 页码 :param count: 数量 :return: 媒体信息列表 """ return await self.async_run_module("async_douban_discover", mtype=mtype, sort=sort, tags=tags, page=page, count=count) async def async_tv_animation(self, page: Optional[int] = 1, count: Optional[int] = 30) -> Optional[List[MediaInfo]]: """ 获取动画剧集(异步版本) """ return await self.async_run_module("async_tv_animation", page=page, count=count) async def async_movie_hot(self, page: Optional[int] = 1, count: Optional[int] = 30) -> Optional[List[MediaInfo]]: """ 获取热门电影(异步版本) """ return await self.async_run_module("async_movie_hot", page=page, count=count) async def async_tv_hot(self, page: Optional[int] = 1, count: Optional[int] = 30) -> Optional[List[MediaInfo]]: """ 获取热门剧集(异步版本) """ return await self.async_run_module("async_tv_hot", page=page, count=count) async def async_movie_credits(self, doubanid: str) -> Optional[List[schemas.MediaPerson]]: """ 根据TMDBID查询电影演职人员(异步版本) :param doubanid: 豆瓣ID """ return await self.async_run_module("async_douban_movie_credits", doubanid=doubanid) async def async_tv_credits(self, doubanid: str) -> Optional[List[schemas.MediaPerson]]: """ 根据TMDBID查询电视剧演职人员(异步版本) :param doubanid: 豆瓣ID """ return await self.async_run_module("async_douban_tv_credits", doubanid=doubanid) async def async_movie_recommend(self, doubanid: str) -> List[MediaInfo]: """ 根据豆瓣ID查询推荐电影(异步版本) :param doubanid: 豆瓣ID """ return await self.async_run_module("async_douban_movie_recommend", doubanid=doubanid) async def async_tv_recommend(self, doubanid: str) -> List[MediaInfo]: """ 根据豆瓣ID查询推荐电视剧(异步版本) :param doubanid: 豆瓣ID """ return await self.async_run_module("async_douban_tv_recommend", doubanid=doubanid) ================================================ FILE: app/chain/download.py ================================================ import base64 import copy import json import re import time from pathlib import Path from typing import List, Optional, Tuple, Set, Dict, Union from app import schemas from app.chain import ChainBase from app.core.cache import FileCache from app.core.config import settings, global_vars from app.core.context import MediaInfo, TorrentInfo, Context from app.core.event import eventmanager, Event from app.core.meta import MetaBase from app.core.metainfo import MetaInfo from app.db.downloadhistory_oper import DownloadHistoryOper from app.db.mediaserver_oper import MediaServerOper from app.helper.directory import DirectoryHelper from app.helper.torrent import TorrentHelper from app.log import logger from app.schemas import ExistMediaInfo, FileURI, NotExistMediaInfo, DownloadingTorrent, Notification, ResourceSelectionEventData, \ ResourceDownloadEventData from app.schemas.types import MediaType, TorrentStatus, EventType, MessageChannel, NotificationType, ContentType, \ ChainEventType from app.utils.http import RequestUtils from app.utils.string import StringUtils class DownloadChain(ChainBase): """ 下载处理链 """ def download_torrent(self, torrent: TorrentInfo, channel: MessageChannel = None, source: Optional[str] = None, userid: Union[str, int] = None ) -> Tuple[Optional[Union[str, bytes]], str, list]: """ 下载种子文件,如果是磁力链,会返回磁力链接本身 :return: 种子内容,种子目录名,种子文件清单 """ def __get_redict_url(url: str, ua: Optional[str] = None, cookie: Optional[str] = None) -> Optional[str]: """ 获取下载链接, url格式:[base64]url """ # 获取[]中的内容 m = re.search(r"\[(.*)](.*)", url) if m: # 参数 base64_str = m.group(1) # URL url = m.group(2) if not base64_str: return url # 解码参数 req_str = base64.b64decode(base64_str.encode('utf-8')).decode('utf-8') req_params: Dict[str, dict] = json.loads(req_str) # 是否使用cookie if not req_params.get('cookie'): cookie = None # 代理 proxy = req_params.get('proxy') # 请求头 if req_params.get('header'): headers = req_params.get('header') else: headers = None if req_params.get('method') == 'get': # GET请求 res = RequestUtils( ua=ua, cookies=cookie, headers=headers, proxies=settings.PROXY if proxy else None ).get_res(url, params=req_params.get('params')) else: # POST请求 res = RequestUtils( ua=ua, cookies=cookie, headers=headers, proxies=settings.PROXY if proxy else None ).post_res(url, params=req_params.get('params')) if not res: return None if not req_params.get('result'): return res.text else: data = res.json() for key in str(req_params.get('result')).split("."): data = data.get(key) if not data: return None logger.info(f"获取到下载地址:{data}") return data return None # 获取下载链接 if not torrent.enclosure: return None, "", [] if torrent.enclosure.startswith("magnet:"): return torrent.enclosure, "", [] # Cookie site_cookie = torrent.site_cookie if torrent.enclosure.startswith("["): # 需要解码获取下载地址 torrent_url = __get_redict_url(url=torrent.enclosure, ua=torrent.site_ua, cookie=site_cookie) # 涉及解析地址的不使用Cookie下载种子,否则MT会出错 site_cookie = None else: torrent_url = torrent.enclosure if not torrent_url: logger.error(f"{torrent.title} 无法获取下载地址:{torrent.enclosure}!") return None, "", [] # 下载种子文件 _, content, download_folder, files, error_msg = TorrentHelper().download_torrent( url=torrent_url, cookie=site_cookie, ua=torrent.site_ua or settings.USER_AGENT, proxy=torrent.site_proxy) if isinstance(content, str): # 磁力链 return content, "", [] if not content: logger.error(f"下载种子文件失败:{torrent.title} - {torrent_url}") self.post_message(Notification( channel=channel, source=source if channel else None, mtype=NotificationType.Manual, title=f"{torrent.title} 种子下载失败!", text=f"错误信息:{error_msg}\n站点:{torrent.site_name}", userid=userid)) return None, "", [] # 返回 种子文件路径,种子目录名,种子文件清单 return content, download_folder, files def download_single(self, context: Context, torrent_file: Path = None, torrent_content: Optional[Union[str, bytes]] = None, episodes: Set[int] = None, channel: MessageChannel = None, source: Optional[str] = None, downloader: Optional[str] = None, save_path: Optional[str] = None, userid: Union[str, int] = None, username: Optional[str] = None, label: Optional[str] = None, return_detail: bool = False) -> Union[Optional[str], Tuple[Optional[str], Optional[str]]]: """ 下载及发送通知 :param context: 资源上下文 :param torrent_file: 种子文件路径 :param torrent_content: 种子内容(磁力链或种子文件内容) :param episodes: 需要下载的集数 :param channel: 通知渠道 :param source: 来源(消息通知、Subscribe、Manual等) :param downloader: 下载器 :param save_path: 保存路径, 支持:, 如rclone:/MP, smb:/server/share/Movies等 :param userid: 用户ID :param username: 调用下载的用户名/插件名 :param label: 自定义标签 :param return_detail: 是否返回详细结果;False 时返回下载任务 hash 或 None,True 时返回 (hash, error_msg) :return: return_detail=False 时返回下载任务 hash 或 None;return_detail=True 时返回 (hash, error_msg) """ _torrent = context.torrent_info _media = context.media_info _meta = context.meta_info _site_downloader = _torrent.site_downloader # 发送资源下载事件,允许外部拦截下载 event_data = ResourceDownloadEventData( context=context, episodes=episodes or context.meta_info.episode_list, channel=channel, origin=source, downloader=downloader, options={ "save_path": save_path, "userid": userid, "username": username, "media_category": _media.category } ) # 触发资源下载事件 event = eventmanager.send_event(ChainEventType.ResourceDownload, event_data) if event and event.event_data: event_data: ResourceDownloadEventData = event.event_data # 如果事件被取消,跳过资源下载 if event_data.cancel: logger.debug( f"Resource download canceled by event: {event_data.source}," f"Reason: {event_data.reason}") return (None, "下载被事件取消") if return_detail else None # 如果事件修改了下载路径,使用新路径 if event_data.options and event_data.options.get("save_path"): save_path = event_data.options.get("save_path") # 补充完整的media数据 if not _media.genre_ids: new_media = self.recognize_media(mtype=_media.type, tmdbid=_media.tmdb_id, doubanid=_media.douban_id, bangumiid=_media.bangumi_id, episode_group=_media.episode_group) if new_media: _media = new_media # 实际下载的集数 download_episodes = StringUtils.format_ep(list(episodes)) if episodes else None _folder_name = "" if not torrent_file and not torrent_content: # 下载种子文件,得到的可能是文件也可能是磁力链 torrent_content, _folder_name, _file_list = self.download_torrent(_torrent, channel=channel, source=source, userid=userid) elif torrent_file: if torrent_file.exists(): torrent_content = torrent_file.read_bytes() else: # 缓存处理器 cache_backend = FileCache() # 读取缓存的种子文件 torrent_content = cache_backend.get(torrent_file.as_posix(), region="torrents") if not torrent_content: return (None, "下载种子内容为空") if return_detail else None # 获取种子文件的文件夹名和文件清单 _folder_name, _file_list = TorrentHelper().get_fileinfo_from_torrent_content(torrent_content) storage = 'local' # 下载目录 if save_path: download_dir = Path(save_path) else: # 根据媒体信息查询下载目录配置 dir_info = DirectoryHelper().get_dir(_media, include_unsorted=True) storage = dir_info.storage if dir_info else storage # 拼装子目录 if dir_info: # 一级目录 if not dir_info.media_type and dir_info.download_type_folder: # 一级自动分类 download_dir = Path(dir_info.download_path) / _media.type.value else: # 一级不分类 download_dir = Path(dir_info.download_path) # 二级目录 if not dir_info.media_category and dir_info.download_category_folder and _media and _media.category: # 二级自动分类 download_dir = download_dir / _media.category else: # 未找到下载目录,且没有自定义下载目录 logger.error(f"未找到下载目录:{_media.type.value} {_media.title_year}") self.messagehelper.put(f"{_media.type.value} {_media.title_year} 未找到下载目录!", title="下载失败", role="system") return (None, "未找到下载目录") if return_detail else None fileURI = FileURI(storage=storage, path=download_dir.as_posix()) download_dir = Path(fileURI.uri) # 添加下载 result: Optional[tuple] = self.download(content=torrent_content, cookie=_torrent.site_cookie, episodes=episodes, download_dir=download_dir, category=_media.category, label=label, downloader=downloader or _site_downloader) if result: _downloader, _hash, _layout, error_msg = result else: _downloader, _hash, _layout, error_msg = None, None, None, "未找到下载器" if _hash: # `不创建子文件夹` 或 `不存在子文件夹` if _layout == "NoSubfolder" or not _folder_name: # 下载路径记录至文件 download_path = download_dir / _file_list[0] if _file_list else download_dir # 原始布局 elif _folder_name: download_path = download_dir / _folder_name # 创建子文件夹 else: download_path = download_dir / Path(_file_list[0]).stem if _file_list else download_dir # 文件保存路径 _save_path = download_dir if _layout == "NoSubfolder" or not _folder_name else download_path # 登记下载记录 downloadhis = DownloadHistoryOper() downloadhis.add( path=download_path.as_posix(), type=_media.type.value, title=_media.title, year=_media.year, tmdbid=_media.tmdb_id, imdbid=_media.imdb_id, tvdbid=_media.tvdb_id, doubanid=_media.douban_id, seasons=_meta.season, episodes=download_episodes or _meta.episode, image=_media.get_backdrop_image(), downloader=_downloader, download_hash=_hash, torrent_name=_torrent.title, torrent_description=_torrent.description, torrent_site=_torrent.site_name, userid=userid, username=username, channel=channel.value if channel else None, date=time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()), media_category=_media.category, episode_group=_media.episode_group, note={"source": source} ) # 登记下载文件 files_to_add = [] for file in _file_list: if episodes: # 识别文件集 file_meta = MetaInfo(Path(file).stem) if not file_meta.begin_episode \ or file_meta.begin_episode not in episodes: continue # 只处理音视频、字幕格式 media_exts = settings.RMT_MEDIAEXT + settings.RMT_SUBEXT + settings.RMT_AUDIOEXT if not Path(file).suffix \ or Path(file).suffix.lower() not in media_exts: continue files_to_add.append({ "download_hash": _hash, "downloader": _downloader, "fullpath": (_save_path / file).as_posix(), "savepath": _save_path.as_posix(), "filepath": file, "torrentname": _meta.org_string, }) if files_to_add: downloadhis.add_files(files_to_add) # 下载成功发送消息 self.post_message( Notification( channel=channel, source=source if channel else None, mtype=NotificationType.Download, ctype=ContentType.DownloadAdded, image=_media.get_message_image(), link=settings.MP_DOMAIN('/#/downloading'), userid=userid, username=username ), meta=_meta, mediainfo=_media, torrentinfo=_torrent, download_episodes=download_episodes, username=username, ) # 下载成功后处理 self.download_added(context=context, download_dir=download_dir, torrent_content=torrent_content) # 广播事件 self.eventmanager.send_event(EventType.DownloadAdded, { "hash": _hash, "context": context, "username": username, "downloader": _downloader, "episodes": episodes or _meta.episode_list, "source": source }) else: # 下载失败 logger.error(f"{_media.title_year} 添加下载任务失败:" f"{_torrent.title} - {_torrent.enclosure},{error_msg}") # 只发送给对应渠道和用户 self.post_message(Notification( channel=channel, source=source if channel else None, mtype=NotificationType.Manual, title="添加下载任务失败:%s %s" % (_media.title_year, _meta.season_episode), text=f"站点:{_torrent.site_name}\n" f"种子名称:{_meta.org_string}\n" f"错误信息:{error_msg}", image=_media.get_message_image(), userid=userid)) if return_detail: return _hash, error_msg return _hash def batch_download(self, contexts: List[Context], no_exists: Dict[Union[int, str], Dict[int, NotExistMediaInfo]] = None, save_path: Optional[str] = None, channel: MessageChannel = None, source: Optional[str] = None, userid: Optional[str] = None, username: Optional[str] = None, downloader: Optional[str] = None ) -> Tuple[List[Context], Dict[Union[int, str], Dict[int, NotExistMediaInfo]]]: """ 根据缺失数据,自动种子列表中组合择优下载 :param contexts: 资源上下文列表 :param no_exists: 缺失的剧集信息 :param save_path: 保存路径, 支持:, 如rclone:/MP, smb:/server/share/Movies等 :param channel: 通知渠道 :param source: 来源(消息通知、订阅、手工下载等) :param userid: 用户ID :param username: 调用下载的用户名/插件名 :param downloader: 下载器 :return: 已经下载的资源列表、剩余未下载到的剧集 no_exists[tmdb_id/douban_id] = {season: NotExistMediaInfo} """ # 已下载的项目 downloaded_list: List[Context] = [] def __update_seasons(_mid: Union[int, str], _need: list, _current: list) -> list: """ 更新need_tvs季数,返回剩余季数 :param _mid: TMDBID :param _need: 需要下载的季数 :param _current: 已经下载的季数 """ # 剩余季数 need = list(set(_need).difference(set(_current))) # 清除已下载的季信息 seas = copy.deepcopy(no_exists.get(_mid)) if seas: for _sea in list(seas): if _sea not in need: no_exists[_mid].pop(_sea) if not no_exists.get(_mid) and no_exists.get(_mid) is not None: no_exists.pop(_mid) break return need def __update_episodes(_mid: Union[int, str], _sea: int, _need: list, _current: set) -> list: """ 更新need_tvs集数,返回剩余集数 :param _mid: TMDBID :param _sea: 季数 :param _need: 需要下载的集数 :param _current: 已经下载的集数 """ # 剩余集数 need = list(set(_need).difference(set(_current))) if need: not_exist = no_exists[_mid][_sea] no_exists[_mid][_sea] = NotExistMediaInfo( season=not_exist.season, episodes=need, total_episode=not_exist.total_episode, start_episode=not_exist.start_episode ) else: no_exists[_mid].pop(_sea) if not no_exists.get(_mid) and no_exists.get(_mid) is not None: no_exists.pop(_mid) return need def __get_season_episodes(_mid: Union[int, str], season: int) -> int: """ 获取需要的季的集数 """ if not no_exists.get(_mid): return 9999 no_exist = no_exists.get(_mid) if not no_exist.get(season): return 9999 return no_exist[season].total_episode # 发送资源选择事件,允许外部修改上下文数据 logger.debug(f"Initial contexts: {len(contexts)} items, Downloader: {downloader}") event_data = ResourceSelectionEventData( contexts=contexts, downloader=downloader, origin=source ) event = eventmanager.send_event(ChainEventType.ResourceSelection, event_data) # 如果事件修改了上下文数据,使用更新后的数据 if event and event.event_data: event_data: ResourceSelectionEventData = event.event_data if event_data.updated and event_data.updated_contexts is not None: logger.debug(f"Contexts updated by event: " f"{len(event_data.updated_contexts)} items (source: {event_data.source})") contexts = event_data.updated_contexts # 分组排序 contexts = TorrentHelper().sort_group_torrents(contexts) # 如果是电影,直接下载 for context in contexts: if global_vars.is_system_stopped: break if context.media_info.type == MediaType.MOVIE: logger.info(f"开始下载电影 {context.torrent_info.title} ...") if self.download_single(context, save_path=save_path, channel=channel, source=source, userid=userid, username=username, downloader=downloader): # 下载成功 logger.info(f"{context.torrent_info.title} 添加下载成功") downloaded_list.append(context) # 电视剧整季匹配 if no_exists: logger.info(f"开始匹配电视剧整季:{no_exists}") # 先把整季缺失的拿出来,看是否刚好有所有季都满足的种子 {tmdbid: [seasons]} need_seasons: Dict[int, list] = {} for need_mid, need_tv in no_exists.items(): for tv in need_tv.values(): if not tv: continue # 季列表为空的,代表全季缺失 if not tv.episodes: if not need_seasons.get(need_mid): need_seasons[need_mid] = [] need_seasons[need_mid].append(tv.season or 1) logger.info(f"缺失整季:{need_seasons}") # 查找整季包含的种子,只处理整季没集的种子或者是集数超过季的种子 for need_mid, need_season in need_seasons.items(): # 循环种子 for context in contexts: if global_vars.is_system_stopped: break # 媒体信息 media = context.media_info # 识别元数据 meta = context.meta_info # 种子信息 torrent = context.torrent_info # 排除电视剧 if media.type != MediaType.TV: continue # 种子的季清单 torrent_season = meta.season_list # 没有季的默认为第1季 if not torrent_season: torrent_season = [1] # 种子有集的不要 if meta.episode_list: continue # 匹配TMDBID if need_mid == media.tmdb_id or need_mid == media.douban_id: # 不重复添加 if context in downloaded_list: continue # 种子季是需要季或者子集 if set(torrent_season).issubset(set(need_season)): if len(torrent_season) == 1: # 只有一季的可能是命名错误,需要打开种子鉴别,只有实际集数大于等于总集数才下载 logger.info(f"开始下载种子 {torrent.title} ...") content, _, torrent_files = self.download_torrent(torrent) if not content: logger.warn(f"{torrent.title} 种子下载失败!") continue if isinstance(content, str): logger.warn(f"{meta.org_string} 下载地址是磁力链,无法确定种子文件集数") continue torrent_episodes = TorrentHelper().get_torrent_episodes(torrent_files) logger.info(f"{meta.org_string} 解析种子文件集数为 {torrent_episodes}") if not torrent_episodes: continue # 更新集数范围 begin_ep = min(torrent_episodes) end_ep = max(torrent_episodes) meta.set_episodes(begin=begin_ep, end=end_ep) # 需要总集数 need_total = __get_season_episodes(need_mid, torrent_season[0]) if len(torrent_episodes) < need_total: logger.info( f"{meta.org_string} 解析文件集数发现不是完整合集,先放弃这个种子") continue else: # 下载 logger.info(f"开始下载 {torrent.title} ...") download_id = self.download_single( context=context, torrent_content=content, save_path=save_path, channel=channel, source=source, userid=userid, username=username, downloader=downloader ) else: # 下载 logger.info(f"开始下载 {torrent.title} ...") download_id = self.download_single(context, save_path=save_path, channel=channel, source=source, userid=userid, username=username, downloader=downloader) if download_id: # 下载成功 logger.info(f"{torrent.title} 添加下载成功") downloaded_list.append(context) # 更新仍需季集 need_season = __update_seasons(_mid=need_mid, _need=need_season, _current=torrent_season) logger.info(f"{need_mid} 剩余需要季:{need_season}") if not need_season: # 全部下载完成 break # 电视剧季内的集匹配 if no_exists: logger.info(f"开始电视剧完整集匹配:{no_exists}") # TMDBID列表 need_tv_list = list(no_exists) for need_mid in need_tv_list: # dict[season, [NotExistMediaInfo]] need_tv = no_exists.get(need_mid) if not need_tv: continue need_tv_copy = copy.deepcopy(no_exists.get(need_mid)) # 循环每一季 for sea, tv in need_tv_copy.items(): # 当前需要季 need_season = sea # 当前需要集 need_episodes = tv.episodes # TMDB总集数 total_episode = tv.total_episode # 需要开始集 start_episode = tv.start_episode or 1 # 缺失整季的转化为缺失集进行比较 if not need_episodes: need_episodes = list(range(start_episode, total_episode + 1)) # 循环种子 for context in contexts: if global_vars.is_system_stopped: break # 媒体信息 media = context.media_info # 识别元数据 meta = context.meta_info # 非剧集不处理 if media.type != MediaType.TV: continue # 匹配TMDB if media.tmdb_id == need_mid or media.douban_id == need_mid: # 不重复添加 if context in downloaded_list: continue # 种子季 torrent_season = meta.season_list # 只处理单季含集的种子 if len(torrent_season) != 1 or torrent_season[0] != need_season: continue # 种子集列表 torrent_episodes = set(meta.episode_list) # 整季的不处理 if not torrent_episodes: continue # 为需要集的子集则下载 if torrent_episodes.issubset(set(need_episodes)): # 下载 logger.info(f"开始下载 {meta.title} ...") download_id = self.download_single(context, save_path=save_path, channel=channel, source=source, userid=userid, username=username, downloader=downloader) if download_id: # 下载成功 logger.info(f"{meta.title} 添加下载成功") downloaded_list.append(context) # 更新仍需集数 need_episodes = __update_episodes(_mid=need_mid, _need=need_episodes, _sea=need_season, _current=torrent_episodes) logger.info(f"季 {need_season} 剩余需要集:{need_episodes}") # 仍然缺失的剧集,从整季中选择需要的集数文件下载,仅支持QB和TR if no_exists: logger.info(f"开始电视剧多集拆包匹配:{no_exists}") # TMDBID列表 no_exists_list = list(no_exists) for need_mid in no_exists_list: # dict[season, [NotExistMediaInfo]] need_tv = no_exists.get(need_mid) if not need_tv: continue # 需要季列表 need_tv_list = list(need_tv) # 循环需要季 for sea in need_tv_list: # NotExistMediaInfo tv = need_tv.get(sea) # 当前需要季 need_season = sea # 当前需要集 need_episodes = tv.episodes # 没有集的不处理 if not need_episodes: continue # 循环种子 for context in contexts: if global_vars.is_system_stopped: break # 媒体信息 media = context.media_info # 识别元数据 meta = context.meta_info # 种子信息 torrent = context.torrent_info # 非剧集不处理 if media.type != MediaType.TV: continue # 不重复添加 if context in downloaded_list: continue # 没有需要集后退出 if not need_episodes: break # 选中一个单季整季的或单季包括需要的所有集的 if (media.tmdb_id == need_mid or media.douban_id == need_mid) \ and (not meta.episode_list or set(meta.episode_list).intersection(set(need_episodes))) \ and len(meta.season_list) == 1 \ and meta.season_list[0] == need_season: # 检查种子看是否有需要的集 logger.info(f"开始下载种子 {torrent.title} ...") content, _, torrent_files = self.download_torrent(torrent) if not content: logger.info(f"{torrent.title} 种子下载失败!") continue if isinstance(content, str): logger.warn(f"{meta.org_string} 下载地址是磁力链,无法解析种子文件集数") continue # 种子全部集 torrent_episodes = TorrentHelper().get_torrent_episodes(torrent_files) logger.info(f"{torrent.site_name} - {meta.org_string} 解析种子文件集数:{torrent_episodes}") # 选中的集 selected_episodes = set(torrent_episodes).intersection(set(need_episodes)) if not selected_episodes: logger.info(f"{torrent.site_name} - {torrent.title} 没有需要的集,跳过...") continue logger.info(f"{torrent.site_name} - {torrent.title} 选中集数:{selected_episodes}") # 添加下载 logger.info(f"开始下载 {torrent.title} ...") download_id = self.download_single( context=context, torrent_content=content, episodes=selected_episodes, save_path=save_path, channel=channel, source=source, userid=userid, username=username, downloader=downloader ) if not download_id: continue # 下载成功 logger.info(f"{torrent.title} 添加下载成功") downloaded_list.append(context) # 更新种子集数范围 begin_ep = min(torrent_episodes) end_ep = max(torrent_episodes) meta.set_episodes(begin=begin_ep, end=end_ep) # 更新仍需集数 need_episodes = __update_episodes(_mid=need_mid, _need=need_episodes, _sea=need_season, _current=selected_episodes) logger.info(f"季 {need_season} 剩余需要集:{need_episodes}") # 返回下载的资源,剩下没下完的 logger.info(f"成功下载种子数:{len(downloaded_list)},剩余未下载的剧集:{no_exists}") return downloaded_list, no_exists def get_no_exists_info(self, meta: MetaBase, mediainfo: MediaInfo, no_exists: Dict[int, Dict[int, NotExistMediaInfo]] = None, totals: Dict[int, int] = None ) -> Tuple[bool, Dict[Union[int, str], Dict[int, NotExistMediaInfo]]]: """ 检查媒体库,查询是否存在,对于剧集同时返回不存在的季集信息 :param meta: 元数据 :param mediainfo: 已识别的媒体信息 :param no_exists: 在调用该方法前已经存储的不存在的季集信息,有传入时该函数搜索的内容将会叠加后输出 :param totals: 电视剧每季的总集数 :return: 当前媒体是否缺失,各标题总的季集和缺失的季集 """ def __append_no_exists(_season: int, _episodes: list, _total: int, _start: int): """ 添加不存在的季集信息 {tmdbid: [ "season": int, "episodes": list, "total_episode": int, "start_episode": int ]} """ mediakey = mediainfo.tmdb_id or mediainfo.douban_id if not no_exists.get(mediakey): no_exists[mediakey] = { _season: NotExistMediaInfo( season=_season, episodes=_episodes, total_episode=_total, start_episode=_start ) } else: no_exists[mediakey][_season] = NotExistMediaInfo( season=_season, episodes=_episodes, total_episode=_total, start_episode=_start ) if not no_exists: no_exists = {} if not totals: totals = {} mediaserver = MediaServerOper() if mediainfo.type == MediaType.MOVIE: # 电影 itemid = mediaserver.get_item_id(mtype=mediainfo.type.value, title=mediainfo.title, tmdbid=mediainfo.tmdb_id) exists_movies: Optional[ExistMediaInfo] = self.media_exists(mediainfo=mediainfo, itemid=itemid) if exists_movies: logger.info(f"媒体库中已存在电影:{mediainfo.title_year}") return True, {} return False, {} else: if not mediainfo.seasons: # 补充媒体信息 mediainfo: MediaInfo = self.recognize_media(mtype=mediainfo.type, tmdbid=mediainfo.tmdb_id, doubanid=mediainfo.douban_id, episode_group=mediainfo.episode_group) if not mediainfo: logger.error(f"媒体信息识别失败!") return False, {} if not mediainfo.seasons: logger.error(f"媒体信息中没有季集信息:{mediainfo.title_year}") return False, {} # 电视剧 itemid = mediaserver.get_item_id(mtype=mediainfo.type.value, title=mediainfo.title, tmdbid=mediainfo.tmdb_id, season=mediainfo.season) # 媒体库已存在的剧集 exists_tvs: Optional[ExistMediaInfo] = self.media_exists(mediainfo=mediainfo, itemid=itemid) if not exists_tvs: # 所有季集均缺失 for season, episodes in mediainfo.seasons.items(): if not episodes: continue # 全季不存在 if meta.sea \ and season not in meta.season_list: continue # 总集数 total_ep = totals.get(season) or len(episodes) __append_no_exists(_season=season, _episodes=[], _total=total_ep, _start=min(episodes)) return False, no_exists else: # 存在一些,检查每季缺失的季集 for season, episodes in mediainfo.seasons.items(): if meta.sea \ and season not in meta.season_list: continue if not episodes: continue # 该季总集数 season_total = totals.get(season) or len(episodes) # 该季已存在的集 exist_episodes = exists_tvs.seasons.get(season) if exist_episodes: # 已存在取差集 if totals.get(season): # 按总集数计算缺失集(开始集为TMDB中的最小集) lack_episodes = list(set(range(min(episodes), season_total + min(episodes)) ).difference(set(exist_episodes))) else: # 按TMDB集数计算缺失集 lack_episodes = list(set(episodes).difference(set(exist_episodes))) if not lack_episodes: # 全部集存在 continue # 添加不存在的季集信息 __append_no_exists(_season=season, _episodes=lack_episodes, _total=season_total, _start=min(lack_episodes)) else: # 全季不存在 __append_no_exists(_season=season, _episodes=[], _total=season_total, _start=min(episodes)) # 存在不完整的剧集 if no_exists: logger.debug(f"媒体库中已存在部分剧集,缺失:{no_exists}") return False, no_exists # 全部存在 return True, no_exists def remote_downloading(self, channel: MessageChannel, userid: Union[str, int] = None, source: Optional[str] = None): """ 查询正在下载的任务,并发送消息 """ torrents = self.list_torrents(status=TorrentStatus.DOWNLOADING) if not torrents: self.post_message(Notification( channel=channel, source=source, mtype=NotificationType.Download, title="没有正在下载的任务!", userid=userid, link=settings.MP_DOMAIN('#/downloading') )) return # 发送消息 title = f"共 {len(torrents)} 个任务正在下载:" messages = [] index = 1 for torrent in torrents: messages.append(f"{index}. {torrent.title} " f"{StringUtils.str_filesize(torrent.size)} " f"{round(torrent.progress, 1)}%") index += 1 self.post_message(Notification( channel=channel, source=source, mtype=NotificationType.Download, title=title, text="\n".join(messages), userid=userid, link=settings.MP_DOMAIN('#/downloading') )) def downloading(self, name: Optional[str] = None) -> List[DownloadingTorrent]: """ 查询正在下载的任务 """ torrents = self.list_torrents(downloader=name, status=TorrentStatus.DOWNLOADING) if not torrents: return [] ret_torrents = [] for torrent in torrents: history = DownloadHistoryOper().get_by_hash(torrent.hash) if history: # 媒体信息 torrent.media = { "tmdbid": history.tmdbid, "type": history.type, "title": history.title, "season": history.seasons, "episode": history.episodes, "image": history.image, } # 下载用户 torrent.userid = history.userid torrent.username = history.username ret_torrents.append(torrent) return ret_torrents def set_downloading(self, hash_str, oper: str, name: Optional[str] = None) -> bool: """ 控制下载任务 start/stop """ if oper == "start": return self.start_torrents(hashs=[hash_str], downloader=name) elif oper == "stop": return self.stop_torrents(hashs=[hash_str], downloader=name) return False def remove_downloading(self, hash_str: str, name: Optional[str] = None) -> bool: """ 删除下载任务 """ return self.remove_torrents(hashs=[hash_str], downloader=name) @eventmanager.register(EventType.DownloadFileDeleted) def download_file_deleted(self, event: Event): """ 下载文件删除时,同步删除下载任务 """ if not event: return hash_str = event.event_data.get("hash") if not hash_str: return logger.warn(f"检测到下载源文件被删除,删除下载任务(不含文件):{hash_str}") # 先查询种子 torrents: List[schemas.TransferTorrent] = self.list_torrents(hashs=[hash_str]) if torrents: self.remove_torrents(hashs=[hash_str], delete_file=False) # 发出下载任务删除事件,如需处理辅种,可监听该事件 self.eventmanager.send_event(EventType.DownloadDeleted, { "hash": hash_str, "torrents": [torrent.model_dump() for torrent in torrents] }) else: logger.info(f"没有在下载器中查询到 {hash_str} 对应的下载任务") ================================================ FILE: app/chain/media.py ================================================ import os from pathlib import Path from tempfile import NamedTemporaryFile from threading import Lock from typing import Optional, List, Tuple, Union from app import schemas from app.chain import ChainBase from app.chain.storage import StorageChain from app.core.config import settings from app.core.context import Context, MediaInfo from app.core.event import eventmanager, Event from app.core.meta import MetaBase from app.core.metainfo import MetaInfo, MetaInfoPath from app.db.systemconfig_oper import SystemConfigOper from app.log import logger from app.schemas import FileItem from app.schemas.types import ChainEventType, EventType, MediaType, \ ScrapingTarget, ScrapingMetadata, ScrapingPolicy, SystemConfigKey from app.utils.mixins import ConfigReloadMixin from app.utils.singleton import Singleton from app.utils.http import RequestUtils from app.utils.string import StringUtils recognize_lock = Lock() scraping_lock = Lock() current_umask = os.umask(0) os.umask(current_umask) class ScrapingOption: """刮削选项""" type: ScrapingTarget = ScrapingTarget.TV metadata: ScrapingMetadata = ScrapingMetadata.NFO policy: ScrapingPolicy = ScrapingPolicy.MISSINGONLY def __init__( self, type: Union[str, ScrapingTarget], metadata: Union[str, ScrapingMetadata], value: Union[ScrapingPolicy, bool, str], ): if isinstance(type, ScrapingTarget): self.type = type elif isinstance(type, str): self.type = ScrapingTarget(type) if isinstance(metadata, ScrapingMetadata): self.metadata = metadata elif isinstance(metadata, str): self.metadata = ScrapingMetadata(metadata) if isinstance(value, bool): # 兼容旧的布尔值格式 self.policy = ScrapingPolicy.MISSINGONLY if value else ScrapingPolicy.SKIP elif isinstance(value, ScrapingPolicy): self.policy = value elif isinstance(value, str): self.policy = ScrapingPolicy(value) else: logger.error(f"无效的刮削选项:type={type}, metadata={metadata}, value={value}") @property def is_skip(self) -> bool: """是否跳过""" return self.policy == ScrapingPolicy.SKIP @property def is_overwrite(self) -> bool: """是否覆盖模式""" return self.policy == ScrapingPolicy.OVERWRITE class ScrapingConfig: """媒体刮削配置""" _policies: dict[tuple[str], ScrapingOption] = {} def __init__(self, config_dict: dict[str, str] = None): """ 初始化配置对象 :param config_dict: 用户配置字典(扁平化格式),为 None 时使用默认配置 """ # 合并用户配置和默认配置 if config_dict is None: config_dict = {} # 以默认配置为基础,用用户配置覆盖 _config = self.get_default_config() for key, value in config_dict.items(): _config[key] = value for key, value in _config.items(): if "_" in key: items = key.split('_', 1) self._policies[tuple(items)] = ScrapingOption(*items, value) def option(self, item: Union[str, ScrapingTarget], metadata: Union[str, ScrapingMetadata]) -> ScrapingOption: if isinstance(item, ScrapingTarget): item = item.name.lower() if isinstance(metadata, ScrapingMetadata): metadata = metadata.name.lower() return self._policies.get((item, metadata), ScrapingOption(item, metadata, ScrapingPolicy.SKIP)) @classmethod def from_system_config(cls) -> 'ScrapingConfig': """ 从系统配置加载 :return: MediaScrapingConfig 实例 """ user_config = SystemConfigOper().get(SystemConfigKey.ScrapingSwitchs) or {} return cls(user_config) @staticmethod def get_default_config() -> dict[str, str]: """获取默认配置字典""" config_items = [ f"{mt}_{md}" for mt, mds in [ ('movie', ['nfo', 'poster', 'backdrop', 'logo', 'disc', 'banner', 'thumb']), ('tv', ['nfo', 'poster', 'backdrop', 'logo', 'banner', 'thumb']), ('season', ['nfo', 'poster', 'banner', 'thumb']), ('episode', ['nfo', 'thumb']) ] for md in mds ] return {item: ScrapingPolicy.MISSINGONLY for item in config_items} class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): """ 媒体信息处理链,单例运行 """ CONFIG_WATCH = {SystemConfigKey.ScrapingSwitchs.value} IMAGE_METADATA_MAP = { 'poster': ScrapingMetadata.POSTER, 'backdrop': ScrapingMetadata.BACKDROP, 'fanart': ScrapingMetadata.BACKDROP, 'background': ScrapingMetadata.BACKDROP, 'logo': ScrapingMetadata.LOGO, 'disc': ScrapingMetadata.DISC, 'cdart': ScrapingMetadata.DISC, 'banner': ScrapingMetadata.BANNER, 'thumb': ScrapingMetadata.THUMB, } scraping_policies = ScrapingConfig.from_system_config() storagechain = StorageChain() def on_config_changed(self): self.scraping_policies = ScrapingConfig.from_system_config() def _should_scrape(self, scraping_option: ScrapingOption, file_exists: bool, global_overwrite: bool = False) -> bool: """ 判断是否应该执行刮削操作 :param scraping_option: 刮削选项对象 :param file_exists: 文件是否已存在 :param global_overwrite: 全局覆盖标志 :return bool: 是否应该刮削 """ if scraping_option.is_skip: logger.info(f"{scraping_option.type.value} {scraping_option.metadata.value} 刮削策略 {scraping_option.policy.value}") return False if not file_exists: # 文件不存在 return True # 文件存在的情况 if scraping_option.is_overwrite or global_overwrite: logger.info( f"{scraping_option.type.value} {scraping_option.metadata.value} 文件存在," f"{'配置为覆盖' if scraping_option.is_overwrite else '配置为全局覆盖'}" ) return True else: logger.info(f"{scraping_option.type.value} {scraping_option.metadata.value} 文件已存在,跳过") return False def _save_file(self, fileitem: schemas.FileItem, path: Path, content: Union[bytes, str]): """ 保存或上传文件 :param fileitem: 关联的媒体文件项 :param path: 元数据文件路径 :param content: 文件内容 """ if not fileitem or not content or not path: return # 使用tempfile创建临时文件 with NamedTemporaryFile(delete=True, delete_on_close=False, suffix=path.suffix) as tmp_file: tmp_file_path = Path(tmp_file.name) # 写入内容 if isinstance(content, bytes): tmp_file.write(content) else: tmp_file.write(content.encode('utf-8')) tmp_file.flush() tmp_file.close() # 关闭文件句柄 # 刮削文件只需要读写权限 tmp_file_path.chmod(0o666 & ~current_umask) # 上传文件 item = self.storagechain.upload_file(fileitem=fileitem, path=tmp_file_path, new_name=path.name) if item: logger.info(f"已保存文件:{item.path}") else: logger.warn(f"文件保存失败:{path}") def _download_and_save_image(self, fileitem: schemas.FileItem, path: Path, url: str): """ 流式下载图片并保存到文件 :param storagechain: StorageChain实例 :param fileitem: 关联的媒体文件项 :param path: 图片文件路径 :param url: 图片下载URL """ if not fileitem or not url or not path: return try: logger.info(f"正在下载图片:{url} ...") request_utils = RequestUtils(proxies=settings.PROXY, ua=settings.NORMAL_USER_AGENT) with request_utils.get_stream(url=url) as r: if r and r.status_code == 200: # 使用tempfile创建临时文件,自动删除 with NamedTemporaryFile(delete=True, delete_on_close=False, suffix=path.suffix) as tmp_file: tmp_file_path = Path(tmp_file.name) # 流式写入文件 for chunk in r.iter_content(chunk_size=8192): if chunk: tmp_file.write(chunk) tmp_file.flush() tmp_file.close() # 关闭文件句柄 # 刮削的图片只需要读写权限 tmp_file_path.chmod(0o666 & ~current_umask) # 上传文件 item = self.storagechain.upload_file(fileitem=fileitem, path=tmp_file_path, new_name=path.name) if item: logger.info(f"已保存图片:{item.path}") else: logger.warn(f"图片保存失败:{path}") else: logger.info(f"{url} 图片下载失败") except Exception as err: logger.error(f"{url} 图片下载失败:{str(err)}!") def _get_target_fileitem_and_path(self, current_fileitem: schemas.FileItem, item_type: ScrapingTarget, metadata_type: ScrapingMetadata, filename_hint: Optional[str] = None, parent_fileitem: Optional[schemas.FileItem] = None ) -> Tuple[schemas.FileItem, Optional[Path]]: """ 根据当前上下文、刮削项类型和元数据类型生成目标 FileItem 和 Path 处理 NFO 和图片文件的命名约定及存储位置 """ # 默认保存的目录是当前文件项的目录 target_dir_item = current_fileitem target_dir_path = Path(current_fileitem.path) final_filename = filename_hint # 如果提供了 filename_hint,优先使用 # 针对 NFO 文件的特殊命名和存储逻辑 if metadata_type == ScrapingMetadata.NFO: if item_type == ScrapingTarget.MOVIE: if current_fileitem.type == "file": # 电影文件NFO: 放在电影文件同级目录,名称与电影文件主体一致,后缀.nfo final_filename = f"{target_dir_path.stem}.nfo" target_dir_item = parent_fileitem or self.storagechain.get_parent_item(current_fileitem) if not target_dir_item: logger.error(f"无法获取文件 {current_fileitem.path} 的父目录项。") return current_fileitem, None # 返回一个表示失败的FileItem和None target_dir_path = Path(target_dir_item.path) else: # current_fileitem.type == "dir" # 电影目录NFO (例如蓝光原盘): 放在电影目录内,名称与目录名主体一致,后缀.nfo final_filename = f"{target_dir_path.name}.nfo" # target_dir_item 保持为 current_fileitem # target_dir_path 保持为 Path(current_fileitem.path) elif item_type == ScrapingTarget.TV: # 电视剧根目录NFO: 放在剧集根目录内,命名为 tvshow.nfo final_filename = "tvshow.nfo" elif item_type == ScrapingTarget.SEASON: # 电视剧季目录NFO: 放在季目录内,命名为 season.nfo final_filename = "season.nfo" elif item_type == ScrapingTarget.EPISODE: # 电视剧集文件NFO: 放在集文件同级目录,名称与集文件主体一致,后缀.nfo final_filename = f"{target_dir_path.stem}.nfo" target_dir_item = parent_fileitem or self.storagechain.get_parent_item(current_fileitem) if not target_dir_item: logger.error(f"无法获取文件 {current_fileitem.path} 的父目录项。") return current_fileitem, None# 返回一个表示失败的FileItem和None target_dir_path = Path(target_dir_item.path) # 图片通常是放在当前目录 (current_fileitem) 下 # 如果是 EPISODE 类型的图片(如thumb),通常也是放在文件同级目录,调整 target_dir_item 和 target_dir_path elif metadata_type in [ScrapingMetadata.THUMB] and item_type == ScrapingTarget.EPISODE: target_dir_item = parent_fileitem or self.storagechain.get_parent_item(current_fileitem) if not target_dir_item: logger.error(f"无法获取文件 {current_fileitem.path} 的父目录项。") return current_fileitem, None # 返回一个表示失败的FileItem和None target_dir_path = Path(target_dir_item.path) # TODO: 考虑其他图片类型是否也需要保存到父目录 # 确保最终有文件名 if not final_filename: logger.error(f"无法为 {item_type.value} - {metadata_type.value} 确定文件名。filename_hint: {filename_hint}") # 返回一个表示失败的FileItem和None return current_fileitem, None target_full_path = target_dir_path / final_filename return target_dir_item, target_full_path def metadata_nfo(self, meta: MetaBase, mediainfo: MediaInfo, season: Optional[int] = None, episode: Optional[int] = None) -> Optional[str]: """ 获取NFO文件内容文本 :param meta: 元数据 :param mediainfo: 媒体信息 :param season: 季号 :param episode: 集号 """ return self.run_module("metadata_nfo", meta=meta, mediainfo=mediainfo, season=season, episode=episode) def select_recognize_source(self, log_name: str, log_context: str, native_fn, plugin_fn) -> Optional[MediaInfo]: """ 选择识别模式,插件优先或原生优先 :param log_name: 用于日志“标题:...”处的名称(如 file_path.name 或 title) :param log_context: 用于日志“未识别到...的媒体信息”处的上下文(如 path 或 title) :param native_fn: 原生识别函数 :param plugin_fn: 插件识别函数 """ mediainfo = None plugin_available = eventmanager.check(ChainEventType.NameRecognize) if settings.RECOGNIZE_PLUGIN_FIRST and plugin_available: # 插件优先 logger.info(f"插件优先模式已开启。请求辅助识别,标题:{log_name} ...") mediainfo = plugin_fn() if not mediainfo: logger.info(f'辅助识别未识别到 {log_context} 的媒体信息,尝试使用原生识别') mediainfo = native_fn() else: # 原生优先 logger.info(f"插件优先模式未开启。尝试原生识别,标题:{log_name} ...") mediainfo = native_fn() if not mediainfo and plugin_available: logger.info(f'原生识别未识别到 {log_context} 的媒体信息,尝试使用辅助识别') mediainfo = plugin_fn() return mediainfo def recognize_by_meta(self, metainfo: MetaBase, episode_group: Optional[str] = None) -> Optional[MediaInfo]: """ 根据主副标题识别媒体信息 """ title = metainfo.title # 按 config 中设置的识别顺序识别 mediainfo = self.select_recognize_source( log_name=title, log_context=title, native_fn=lambda: self.recognize_media(meta=metainfo, episode_group=episode_group), plugin_fn=lambda: self.recognize_help(title=title, org_meta=metainfo) ) if not mediainfo: logger.warn(f'{title} 未识别到媒体信息') return None # 识别成功 logger.info(f'{title} 识别到媒体信息:{mediainfo.type.value} {mediainfo.title_year}') # 更新媒体图片 self.obtain_images(mediainfo=mediainfo) # 返回上下文 return mediainfo def recognize_help(self, title: str, org_meta: MetaBase) -> Optional[MediaInfo]: """ 请求辅助识别,返回媒体信息 :param title: 标题 :param org_meta: 原始元数据 """ # 发送请求事件,等待结果 result: Event = eventmanager.send_event( ChainEventType.NameRecognize, { 'title': title, } ) if not result: return None # 获取返回事件数据 event_data = result.event_data or {} logger.info(f'获取到辅助识别结果:{event_data}') # 处理数据格式 title, year, season_number, episode_number = None, None, None, None if event_data.get("name"): title = str(event_data["name"]).split("/")[0].strip().replace(".", " ") if event_data.get("year"): year = str(event_data["year"]).split("/")[0].strip() if event_data.get("season") and str(event_data["season"]).isdigit(): season_number = int(event_data["season"]) if event_data.get("episode") and str(event_data["episode"]).isdigit(): episode_number = int(event_data["episode"]) if not title: return None if title == 'Unknown': return None if not str(year).isdigit(): year = None # 结果赋值 if title == org_meta.name and year == org_meta.year: logger.info(f'辅助识别与原始识别结果一致,无需重新识别媒体信息') return None logger.info(f'辅助识别结果与原始识别结果不一致,重新匹配媒体信息 ...') org_meta.name = title org_meta.year = year org_meta.begin_season = season_number org_meta.begin_episode = episode_number if org_meta.begin_season is not None or org_meta.begin_episode is not None: org_meta.type = MediaType.TV # 重新识别 return self.recognize_media(meta=org_meta) def recognize_by_path(self, path: str, episode_group: Optional[str] = None) -> Optional[Context]: """ 根据文件路径识别媒体信息 """ logger.info(f'开始识别媒体信息,文件:{path} ...') file_path = Path(path) # 元数据 file_meta = MetaInfoPath(file_path) # 按 config 中设置的识别顺序识别 mediainfo = self.select_recognize_source( log_name=file_path.name, log_context=path, native_fn=lambda: self.recognize_media(meta=file_meta, episode_group=episode_group), plugin_fn=lambda: self.recognize_help(title=path, org_meta=file_meta) ) if not mediainfo: logger.warn(f'{path} 未识别到媒体信息') return Context(meta_info=file_meta) logger.info(f'{path} 识别到媒体信息:{mediainfo.type.value} {mediainfo.title_year}') # 更新媒体图片 self.obtain_images(mediainfo=mediainfo) # 返回上下文 return Context(meta_info=file_meta, media_info=mediainfo) def search(self, title: str) -> Tuple[Optional[MetaBase], List[MediaInfo]]: """ 搜索媒体/人物信息 :param title: 搜索内容 :return: 识别元数据,媒体信息列表 """ # 提取要素 mtype, key_word, season_num, episode_num, year, content = StringUtils.get_keyword(title) # 识别 meta = MetaInfo(content) if not meta.name: meta.cn_name = content # 合并信息 if mtype: meta.type = mtype if season_num: meta.begin_season = season_num if episode_num: meta.begin_episode = episode_num if year: meta.year = year # 开始搜索 logger.info(f"开始搜索媒体信息:{meta.name}") medias: Optional[List[MediaInfo]] = self.search_medias(meta=meta) if not medias: logger.warn(f"{meta.name} 没有找到对应的媒体信息!") return meta, [] logger.info(f"{content} 搜索到 {len(medias)} 条相关媒体信息") # 识别的元数据,媒体信息列表 return meta, medias def get_tmdbinfo_by_doubanid(self, doubanid: str, mtype: MediaType = None) -> Optional[dict]: """ 根据豆瓣ID获取TMDB信息 """ tmdbinfo = None doubaninfo = self.douban_info(doubanid=doubanid, mtype=mtype) if doubaninfo: # 优先使用原标题匹配 if doubaninfo.get("original_title"): meta = MetaInfo(title=doubaninfo.get("title")) meta_org = MetaInfo(title=doubaninfo.get("original_title")) else: meta_org = meta = MetaInfo(title=doubaninfo.get("title")) # 年份 if doubaninfo.get("year"): meta.year = doubaninfo.get("year") # 处理类型 if isinstance(doubaninfo.get('media_type'), MediaType): meta.type = doubaninfo.get('media_type') else: meta.type = MediaType.MOVIE if doubaninfo.get("type") == "movie" else MediaType.TV # 匹配TMDB信息 meta_names = list(dict.fromkeys([k for k in [meta_org.name, meta.cn_name, meta.en_name] if k])) tmdbinfo = self._match_tmdb_with_names( meta_names=meta_names, year=meta.year, mtype=mtype or meta.type, season=meta.begin_season ) if tmdbinfo: # 合季季后返回 tmdbinfo['season'] = meta.begin_season return tmdbinfo def get_tmdbinfo_by_bangumiid(self, bangumiid: int) -> Optional[dict]: """ 根据BangumiID获取TMDB信息 """ bangumiinfo = self.bangumi_info(bangumiid=bangumiid) if bangumiinfo: # 优先使用原标题匹配 if bangumiinfo.get("name_cn"): meta = MetaInfo(title=bangumiinfo.get("name")) meta_cn = MetaInfo(title=bangumiinfo.get("name_cn")) else: meta_cn = meta = MetaInfo(title=bangumiinfo.get("name")) # 年份 year = self._extract_year_from_bangumi(bangumiinfo) # 识别TMDB媒体信息 meta_names = list(dict.fromkeys([k for k in [meta_cn.name, meta.name] if k])) tmdbinfo = self._match_tmdb_with_names( meta_names=meta_names, year=year, mtype=MediaType.TV, season=meta.begin_season ) return tmdbinfo return None def get_doubaninfo_by_tmdbid(self, tmdbid: int, mtype: MediaType = None, season: Optional[int] = None) -> Optional[dict]: """ 根据TMDBID获取豆瓣信息 """ tmdbinfo = self.tmdb_info(tmdbid=tmdbid, mtype=mtype) if tmdbinfo: # 名称 name = tmdbinfo.get("title") or tmdbinfo.get("name") # 年份 year = self._extract_year_from_tmdb(tmdbinfo, season) # IMDBID imdbid = tmdbinfo.get("external_ids", {}).get("imdb_id") return self.match_doubaninfo( name=name, year=year, mtype=mtype, imdbid=imdbid ) return None def get_doubaninfo_by_bangumiid(self, bangumiid: int) -> Optional[dict]: """ 根据BangumiID获取豆瓣信息 """ bangumiinfo = self.bangumi_info(bangumiid=bangumiid) if bangumiinfo: # 优先使用中文标题匹配 if bangumiinfo.get("name_cn"): meta = MetaInfo(title=bangumiinfo.get("name_cn")) else: meta = MetaInfo(title=bangumiinfo.get("name")) # 年份 year = self._extract_year_from_bangumi(bangumiinfo) # 使用名称识别豆瓣媒体信息 return self.match_doubaninfo( name=meta.name, year=year, mtype=MediaType.TV, season=meta.begin_season ) return None @eventmanager.register(EventType.MetadataScrape) def scrape_metadata_event(self, event: Event): """ 监控手动刮削事件 """ if not event: return event_data = event.event_data or {} # 媒体根目录 fileitem: FileItem = event_data.get("fileitem") # 媒体文件列表 file_list: List[str] = event_data.get("file_list", []) # 媒体元数据 meta: MetaBase = event_data.get("meta") # 媒体信息 mediainfo: MediaInfo = event_data.get("mediainfo") # 是否覆盖 overwrite = event_data.get("overwrite", False) # 检查媒体根目录 if not fileitem: return # 刮削锁 with scraping_lock: # 检查文件项是否存在 if not self.storagechain.get_item(fileitem): logger.warn(f"文件项不存在:{fileitem.path}") return # 检查是否为目录 if fileitem.type == "file": # 单个文件刮削 self.scrape_metadata(fileitem=fileitem, mediainfo=mediainfo, init_folder=False, parent=self.storagechain.get_parent_item(fileitem), overwrite=overwrite) else: if file_list: # 如果是BDMV原盘目录,只对根目录进行刮削,不处理子目录 if self.storagechain.is_bluray_folder(fileitem): logger.info(f"检测到BDMV原盘目录,只对根目录进行刮削:{fileitem.path}") self.scrape_metadata(fileitem=fileitem, mediainfo=mediainfo, init_folder=True, recursive=False, overwrite=overwrite) else: # 1. 收集fileitem和file_list中每个文件之间所有子目录 all_dirs = set() root_path = Path(fileitem.path) logger.debug(f"开始收集目录,根目录:{root_path}") # 收集根目录 all_dirs.add(root_path) # 收集所有目录(包括所有层级) for sub_file in file_list: sub_path = Path(sub_file) # 收集从根目录到文件的所有父目录 current_path = sub_path.parent while current_path != root_path and current_path.is_relative_to(root_path): all_dirs.add(current_path) current_path = current_path.parent logger.debug(f"共收集到 {len(all_dirs)} 个目录") # 2. 初始化一遍子目录,但不处理文件 for sub_dir in all_dirs: sub_dir_item = self.storagechain.get_file_item(storage=fileitem.storage, path=sub_dir) if sub_dir_item: logger.info(f"为目录生成海报和nfo:{sub_dir}") # 初始化目录元数据,但不处理文件 self.scrape_metadata(fileitem=sub_dir_item, mediainfo=mediainfo, init_folder=True, recursive=False, overwrite=overwrite) else: logger.warn(f"无法获取目录项:{sub_dir}") # 3. 刮削每个文件 logger.info(f"开始刮削 {len(file_list)} 个文件") for sub_file_path in file_list: sub_file_item = self.storagechain.get_file_item(storage=fileitem.storage, path=Path(sub_file_path)) if sub_file_item: self.scrape_metadata(fileitem=sub_file_item, mediainfo=mediainfo, init_folder=False, overwrite=overwrite) else: logger.warn(f"无法获取文件项:{sub_file_path}") else: # 执行全量刮削 logger.info(f"开始刮削目录 {fileitem.path} ...") self.scrape_metadata(fileitem=fileitem, meta=meta, init_folder=True, mediainfo=mediainfo, overwrite=overwrite) def _scrape_nfo_generic(self, current_fileitem: schemas.FileItem, meta: MetaBase, mediainfo: MediaInfo, item_type: ScrapingTarget, parent_fileitem: Optional[schemas.FileItem] = None, overwrite: bool = False, season_number: Optional[int] = None, episode_number: Optional[int] = None): """ NFO 刮削 """ # 获取刮削选项 nfo_option = self.scraping_policies.option(item_type, ScrapingMetadata.NFO) # 检查刮削开关 if nfo_option.is_skip: logger.info(f"{item_type.value} {ScrapingMetadata.NFO.value} 刮削策略 {nfo_option.policy.value}") return # 获取目标 FileItem (`base_item`) 和 Path (`nfo_path`) base_item, nfo_path = self._get_target_fileitem_and_path( current_fileitem=current_fileitem, item_type=item_type, metadata_type=ScrapingMetadata.NFO, parent_fileitem=parent_fileitem ) if not nfo_path: # _get_target_fileitem_and_path 内部错误处理返回None return # 文件存在检查 file_exists = self.storagechain.get_file_item(storage=base_item.storage, path=nfo_path) # 刮削决策 if self._should_scrape(nfo_option, bool(file_exists), overwrite): # 生成 NFO 内容 nfo_content = self.metadata_nfo(meta=meta, mediainfo=mediainfo, season=season_number, episode=episode_number) if nfo_content: self._save_file(fileitem=base_item, path=nfo_path, content=nfo_content) else: logger.warn(f"{nfo_path.name} NFO 文件生成失败!") def _scrape_images_generic(self, current_fileitem: schemas.FileItem, mediainfo: MediaInfo, item_type: ScrapingTarget, parent_fileitem: Optional[schemas.FileItem] = None, overwrite: bool = False, season_number: Optional[int] = None): """ 图片刮削 """ # 获取图片 URL if item_type == ScrapingTarget.SEASON and season_number is not None: image_dict = self.metadata_img(mediainfo=mediainfo, season=season_number) else: image_dict = self.metadata_img(mediainfo=mediainfo) if not image_dict: logger.info(f"未获取到 {item_type.value} 的图片信息,跳过图片刮削。") return # 遍历图片 image_name 和 image_url for image_name, image_url in image_dict.items(): metadata_type = None # 对每个 image_name 查找匹配的 ScrapingMetadata for keyword, meta_type in self.IMAGE_METADATA_MAP.items(): if keyword in image_name.lower(): metadata_type = meta_type break if metadata_type: # 获取对应的 ScrapingOption option = self.scraping_policies.option(item_type, metadata_type) if option.is_skip: logger.info(f"{item_type.value} {option.metadata.value} 刮削策略 {option.policy.value}") continue # 判断是否匹配当前刮削的季号 if item_type == ScrapingTarget.TV and image_name.lower().startswith("season"): logger.info(f"当前为电视剧根目录刮削,跳过季图片:{image_name}") continue if item_type == ScrapingTarget.SEASON and season_number is not None and image_name.lower().startswith("season"): # 检查是否只下载当前刮削季的图片 image_season_str = "00" if "specials" in image_name.lower() else image_name[6:8] if image_season_str is not None and image_season_str != str(season_number).rjust(2, '0'): logger.info(f"当前刮削季为:{season_number},跳过非本季图片:{image_name}") continue # 获取目标 FileItem (`base_item`) 和 Path (`image_path`) base_item, image_path = self._get_target_fileitem_and_path( current_fileitem=current_fileitem, item_type=item_type, metadata_type=metadata_type, filename_hint=image_name, parent_fileitem=parent_fileitem ) if not image_path: continue # 文件存在检查 file_exists = self.storagechain.get_file_item(storage=base_item.storage, path=image_path) # 刮削决策 if self._should_scrape(option, bool(file_exists), overwrite): self._download_and_save_image(fileitem=base_item, path=image_path, url=image_url) else: logger.debug(f"未找到图片类型 {image_name} 对应的 ScrapingMetadata,跳过。") def scrape_metadata(self, fileitem: schemas.FileItem, meta: MetaBase = None, mediainfo: MediaInfo = None, init_folder: bool = True, parent: schemas.FileItem = None, overwrite: bool = False, recursive: bool = True): """ 手动刮削媒体信息 :param fileitem: 刮削目录或文件 :param meta: 元数据 :param mediainfo: 媒体信息 :param init_folder: 是否刮削根目录 :param parent: 上级目录 :param overwrite: 是否覆盖已有文件 :param recursive: 是否递归处理目录内文件 """ if not fileitem: return # 当前文件路径 filepath = Path(fileitem.path) if fileitem.type == "file" \ and (not filepath.suffix or filepath.suffix.lower() not in settings.RMT_MEDIAEXT): return # 准备元数据和媒体信息 if not meta: meta = MetaInfoPath(filepath) if not mediainfo: mediainfo = self.recognize_by_meta(meta) if not mediainfo: logger.warn(f"{filepath} 无法识别文件媒体信息!") return logger.info(f"开始刮削:{filepath} ...") # 根据媒体类型分发处理逻辑 if mediainfo.type == MediaType.MOVIE: self._handle_movie_scraping( fileitem=fileitem, meta=meta, mediainfo=mediainfo, init_folder=init_folder, parent=parent, overwrite=overwrite, recursive=recursive ) else: self._handle_tv_scraping( fileitem=fileitem, meta=meta, mediainfo=mediainfo, init_folder=init_folder, parent=parent, overwrite=overwrite, recursive=recursive ) logger.info(f"{filepath.name} 刮削完成") def _handle_movie_scraping(self, fileitem: schemas.FileItem, meta: MetaBase, mediainfo: MediaInfo, init_folder: bool, parent: schemas.FileItem, overwrite: bool, recursive: bool): """ 处理电影刮削 """ if fileitem.type == "file": # 电影文件:仅处理 NFO self._scrape_nfo_generic( current_fileitem=fileitem, meta=meta, mediainfo=mediainfo, item_type=ScrapingTarget.MOVIE, parent_fileitem=parent, overwrite=overwrite ) else: # 电影目录:递归处理文件并初始化目录 self._handle_movie_directory( fileitem=fileitem, meta=meta, mediainfo=mediainfo, init_folder=init_folder, parent=parent, overwrite=overwrite, recursive=recursive ) def _handle_movie_directory(self, fileitem: schemas.FileItem, meta: MetaBase, mediainfo: MediaInfo, init_folder: bool, parent: schemas.FileItem, overwrite: bool, recursive: bool): """ 处理电影目录刮削 """ files = self.storagechain.list_files(fileitem=fileitem) or [] is_bluray_folder = self.storagechain.contains_bluray_subdirectories(files) # 递归处理文件(非蓝光原盘) if recursive and not is_bluray_folder: for file in files: if file.type == "dir": continue self.scrape_metadata(fileitem=file, mediainfo=mediainfo, init_folder=False, parent=fileitem, overwrite=overwrite) # 初始化目录元数据 if init_folder: if is_bluray_folder: # 蓝光原盘目录:仅处理 NFO self._scrape_nfo_generic( current_fileitem=fileitem, meta=meta, mediainfo=mediainfo, item_type=ScrapingTarget.MOVIE, overwrite=overwrite ) # 电影目录:处理图片 self._scrape_images_generic( current_fileitem=fileitem, mediainfo=mediainfo, item_type=ScrapingTarget.MOVIE, overwrite=overwrite ) def _handle_tv_scraping(self, fileitem: schemas.FileItem, meta: MetaBase, mediainfo: MediaInfo, init_folder: bool, parent: schemas.FileItem, overwrite: bool, recursive: bool): """ 处理电视剧刮削 """ filepath = Path(fileitem.path) if fileitem.type == "file": # 电视剧集文件:重新识别季集信息并刮削 self._handle_tv_episode_file( fileitem=fileitem, filepath=filepath, mediainfo=mediainfo, parent=parent, overwrite=overwrite ) else: # 电视剧目录:递归处理并初始化目录 self._handle_tv_directory( fileitem=fileitem, filepath=filepath, meta=meta, mediainfo=mediainfo, init_folder=init_folder, parent=parent, overwrite=overwrite, recursive=recursive ) def _handle_tv_episode_file(self, fileitem: schemas.FileItem, filepath: Path, mediainfo: MediaInfo, parent: schemas.FileItem, overwrite: bool): """ 处理电视剧集文件刮削 """ # 重新识别季集信息 file_meta = MetaInfoPath(filepath) if not file_meta.begin_episode: logger.warn(f"{filepath.name} 无法识别文件集数!") return file_mediainfo = self.recognize_media(meta=file_meta, tmdbid=mediainfo.tmdb_id, episode_group=mediainfo.episode_group) if not file_mediainfo: logger.warn(f"{filepath.name} 无法识别文件媒体信息!") return # 处理 NFO self._scrape_nfo_generic( current_fileitem=fileitem, meta=file_meta, mediainfo=file_mediainfo, item_type=ScrapingTarget.EPISODE, parent_fileitem=parent, overwrite=overwrite, season_number=file_meta.begin_season, episode_number=file_meta.begin_episode ) # 处理图片 self._scrape_images_generic( current_fileitem=fileitem, mediainfo=file_mediainfo, item_type=ScrapingTarget.EPISODE, parent_fileitem=parent, overwrite=overwrite, season_number=file_meta.begin_season ) def _handle_tv_directory(self, fileitem: schemas.FileItem, filepath: Path, meta: MetaBase, mediainfo: MediaInfo, init_folder: bool, parent: schemas.FileItem, overwrite: bool, recursive: bool): """ 处理电视剧目录刮削 """ # 递归处理子目录和文件 if recursive: files = self.storagechain.list_files(fileitem=fileitem) or [] for file in files: if ( file.type == "dir" and file.name not in settings.RENAME_FORMAT_S0_NAMES and MetaInfo(file.name).begin_season is None ): # 电视剧不处理非季子目录 continue self.scrape_metadata(fileitem=file, mediainfo=mediainfo, parent=fileitem if file.type == "file" else None, init_folder=True if file.type == "dir" else False, overwrite=overwrite) # 初始化目录元数据 if init_folder: self._initialize_tv_directory_metadata( fileitem=fileitem, filepath=filepath, meta=meta, mediainfo=mediainfo, parent=parent, overwrite=overwrite ) def _initialize_tv_directory_metadata(self, fileitem: schemas.FileItem, filepath: Path, meta: MetaBase, mediainfo: MediaInfo, parent: schemas.FileItem, overwrite: bool): """ 初始化电视剧目录元数据(识别季号并刮削) """ # 识别文件夹名称 season_meta = MetaInfo(filepath.name) # 特殊季目录处理(Specials/SPs) if filepath.name in settings.RENAME_FORMAT_S0_NAMES: season_meta.begin_season = 0 elif season_meta.name and season_meta.begin_season is not None: # 排除辅助词重新识别,避免误判根目录 (issue https://github.com/jxxghp/MoviePilot/issues/5501) season_meta_no_custom = MetaInfo(filepath.name, custom_words=["#"]) if season_meta_no_custom.begin_season is None: # 季号由辅助词指定,按剧集根目录处理 (issue https://github.com/jxxghp/MoviePilot/issues/5373) season_meta.begin_season = None # 根据季号判断目录类型并刮削 if season_meta.begin_season is not None: # 季目录:处理季 NFO 和图片 self._scrape_nfo_generic( current_fileitem=fileitem, meta=meta, mediainfo=mediainfo, item_type=ScrapingTarget.SEASON, overwrite=overwrite, season_number=season_meta.begin_season ) self._scrape_images_generic( current_fileitem=fileitem, mediainfo=mediainfo, item_type=ScrapingTarget.SEASON, parent_fileitem=parent, overwrite=overwrite, season_number=season_meta.begin_season ) elif season_meta.name: # 剧集根目录:处理电视剧 NFO 和图片 self._scrape_nfo_generic( current_fileitem=fileitem, meta=meta, mediainfo=mediainfo, item_type=ScrapingTarget.TV, overwrite=overwrite ) self._scrape_images_generic( current_fileitem=fileitem, mediainfo=mediainfo, item_type=ScrapingTarget.TV, overwrite=overwrite ) else: logger.warn("无法识别元数据,跳过") async def async_select_recognize_source(self, log_name: str, log_context: str, native_fn, plugin_fn) -> Optional[MediaInfo]: """ 选择识别模式,插件优先或原生优先(异步版本) :param log_name: 用于日志“标题:...”处的名称(如 file_path.name 或 title) :param log_context: 用于日志“未识别到...的媒体信息”处的上下文(如 path 或 title) :param native_fn: 原生识别函数 :param plugin_fn: 插件识别函数 """ mediainfo = None plugin_available = eventmanager.check(ChainEventType.NameRecognize) if settings.RECOGNIZE_PLUGIN_FIRST and plugin_available: # 插件优先 logger.info(f"插件优先模式已开启。请求辅助识别,标题:{log_name} ...") mediainfo = await plugin_fn() if not mediainfo: logger.info(f'辅助识别未识别到 {log_context} 的媒体信息,尝试使用原生识别') mediainfo = await native_fn() else: # 原生优先 logger.info(f"插件优先模式未开启。尝试原生识别,标题:{log_name} ...") mediainfo = await native_fn() if not mediainfo and plugin_available: logger.info(f'原生识别未识别到 {log_context} 的媒体信息,尝试使用辅助识别') mediainfo = await plugin_fn() return mediainfo async def async_recognize_by_meta(self, metainfo: MetaBase, episode_group: Optional[str] = None) -> Optional[MediaInfo]: """ 根据主副标题识别媒体信息(异步版本) """ title = metainfo.title # 定义识别函数 async def native_recognize(): return await self.async_recognize_media(meta=metainfo, episode_group=episode_group) async def plugin_recognize(): return await self.async_recognize_help(title=title, org_meta=metainfo) # 按 config 中设置的识别顺序识别 mediainfo = await self.async_select_recognize_source( log_name=title, log_context=title, native_fn=native_recognize, plugin_fn=plugin_recognize ) if not mediainfo: logger.warn(f'{title} 未识别到媒体信息') return None # 识别成功 logger.info(f'{title} 识别到媒体信息:{mediainfo.type.value} {mediainfo.title_year}') # 更新媒体图片 await self.async_obtain_images(mediainfo=mediainfo) # 返回上下文 return mediainfo async def async_recognize_help(self, title: str, org_meta: MetaBase) -> Optional[MediaInfo]: """ 请求辅助识别,返回媒体信息(异步版本) :param title: 标题 :param org_meta: 原始元数据 """ # 发送请求事件,等待结果 result: Event = await eventmanager.async_send_event( ChainEventType.NameRecognize, { 'title': title, } ) if not result: return None # 获取返回事件数据 event_data = result.event_data or {} logger.info(f'获取到辅助识别结果:{event_data}') # 处理数据格式 title, year, season_number, episode_number = None, None, None, None if event_data.get("name"): title = str(event_data["name"]).split("/")[0].strip().replace(".", " ") if event_data.get("year"): year = str(event_data["year"]).split("/")[0].strip() if event_data.get("season") and str(event_data["season"]).isdigit(): season_number = int(event_data["season"]) if event_data.get("episode") and str(event_data["episode"]).isdigit(): episode_number = int(event_data["episode"]) if not title: return None if title == 'Unknown': return None if not str(year).isdigit(): year = None # 结果赋值 if title == org_meta.name and year == org_meta.year: logger.info(f'辅助识别与原始识别结果一致,无需重新识别媒体信息') return None logger.info(f'辅助识别结果与原始识别结果不一致,重新匹配媒体信息 ...') org_meta.name = title org_meta.year = year org_meta.begin_season = season_number org_meta.begin_episode = episode_number if org_meta.begin_season or org_meta.begin_episode: org_meta.type = MediaType.TV # 重新识别 return await self.async_recognize_media(meta=org_meta) async def async_recognize_by_path(self, path: str, episode_group: Optional[str] = None) -> Optional[Context]: """ 根据文件路径识别媒体信息(异步版本) """ logger.info(f'开始识别媒体信息,文件:{path} ...') file_path = Path(path) # 元数据 file_meta = MetaInfoPath(file_path) # 定义识别函数 async def native_recognize(): return await self.async_recognize_media(meta=file_meta, episode_group=episode_group) async def plugin_recognize(): return await self.async_recognize_help(title=path, org_meta=file_meta) # 按 config 中设置的识别顺序识别 mediainfo = await self.async_select_recognize_source( log_name=file_path.name, log_context=path, native_fn=native_recognize, plugin_fn=plugin_recognize ) if not mediainfo: logger.warn(f'{path} 未识别到媒体信息') return Context(meta_info=file_meta) logger.info(f'{path} 识别到媒体信息:{mediainfo.type.value} {mediainfo.title_year}') # 更新媒体图片 await self.async_obtain_images(mediainfo=mediainfo) # 返回上下文 return Context(meta_info=file_meta, media_info=mediainfo) async def async_search(self, title: str) -> Tuple[Optional[MetaBase], List[MediaInfo]]: """ 搜索媒体/人物信息(异步版本) :param title: 搜索内容 :return: 识别元数据,媒体信息列表 """ # 提取要素 mtype, key_word, season_num, episode_num, year, content = StringUtils.get_keyword(title) # 识别 meta = MetaInfo(content) if not meta.name: meta.cn_name = content # 合并信息 if mtype: meta.type = mtype if season_num: meta.begin_season = season_num if episode_num: meta.begin_episode = episode_num if year: meta.year = year # 开始搜索 logger.info(f"开始搜索媒体信息:{meta.name}") medias: Optional[List[MediaInfo]] = await self.async_search_medias(meta=meta) if not medias: logger.warn(f"{meta.name} 没有找到对应的媒体信息!") return meta, [] logger.info(f"{content} 搜索到 {len(medias)} 条相关媒体信息") # 识别的元数据,媒体信息列表 return meta, medias @staticmethod def _extract_year_from_bangumi(bangumiinfo: dict) -> Optional[str]: """ 从Bangumi信息中提取年份 """ release_date = bangumiinfo.get("date") or bangumiinfo.get("air_date") if release_date: return release_date[:4] return None @staticmethod def _extract_year_from_tmdb(tmdbinfo: dict, season: Optional[int] = None) -> Optional[str]: """ 从TMDB信息中提取年份 """ year = None if tmdbinfo.get('release_date'): year = tmdbinfo['release_date'][:4] elif tmdbinfo.get('seasons') and season is not None: for seainfo in tmdbinfo['seasons']: season_number = seainfo.get("season_number") if season_number is None: continue air_date = seainfo.get("air_date") if air_date and season_number == season: year = air_date[:4] break return year def _match_tmdb_with_names(self, meta_names: list, year: Optional[str], mtype: MediaType, season: Optional[int] = None) -> Optional[dict]: """ 使用名称列表匹配TMDB信息 """ for name in meta_names: tmdbinfo = self.match_tmdbinfo( name=name, year=year, mtype=mtype, season=season ) if tmdbinfo: return tmdbinfo return None async def _async_match_tmdb_with_names(self, meta_names: list, year: Optional[str], mtype: MediaType, season: Optional[int] = None) -> Optional[dict]: """ 使用名称列表匹配TMDB信息(异步版本) """ for name in meta_names: tmdbinfo = await self.async_match_tmdbinfo( name=name, year=year, mtype=mtype, season=season ) if tmdbinfo: return tmdbinfo return None async def async_get_tmdbinfo_by_doubanid(self, doubanid: str, mtype: MediaType = None) -> Optional[dict]: """ 根据豆瓣ID获取TMDB信息(异步版本) """ tmdbinfo = None doubaninfo = await self.async_douban_info(doubanid=doubanid, mtype=mtype) if doubaninfo: # 优先使用原标题匹配 if doubaninfo.get("original_title"): meta = MetaInfo(title=doubaninfo.get("title")) meta_org = MetaInfo(title=doubaninfo.get("original_title")) else: meta_org = meta = MetaInfo(title=doubaninfo.get("title")) # 年份 if doubaninfo.get("year"): meta.year = doubaninfo.get("year") # 处理类型 if isinstance(doubaninfo.get('media_type'), MediaType): meta.type = doubaninfo.get('media_type') else: meta.type = MediaType.MOVIE if doubaninfo.get("type") == "movie" else MediaType.TV # 匹配TMDB信息 meta_names = list(dict.fromkeys([k for k in [meta_org.name, meta.cn_name, meta.en_name] if k])) tmdbinfo = await self._async_match_tmdb_with_names( meta_names=meta_names, year=meta.year, mtype=mtype or meta.type, season=meta.begin_season ) if tmdbinfo: # 合季季后返回 tmdbinfo['season'] = meta.begin_season return tmdbinfo async def async_get_tmdbinfo_by_bangumiid(self, bangumiid: int) -> Optional[dict]: """ 根据BangumiID获取TMDB信息(异步版本) """ bangumiinfo = await self.async_bangumi_info(bangumiid=bangumiid) if bangumiinfo: # 优先使用原标题匹配 if bangumiinfo.get("name_cn"): meta = MetaInfo(title=bangumiinfo.get("name")) meta_cn = MetaInfo(title=bangumiinfo.get("name_cn")) else: meta_cn = meta = MetaInfo(title=bangumiinfo.get("name")) # 年份 year = self._extract_year_from_bangumi(bangumiinfo) # 识别TMDB媒体信息 meta_names = list(dict.fromkeys([k for k in [meta_cn.name, meta.name] if k])) tmdbinfo = await self._async_match_tmdb_with_names( meta_names=meta_names, year=year, mtype=MediaType.TV, season=meta.begin_season ) return tmdbinfo return None async def async_get_doubaninfo_by_tmdbid(self, tmdbid: int, mtype: MediaType = None, season: Optional[int] = None) -> Optional[dict]: """ 根据TMDBID获取豆瓣信息(异步版本) """ tmdbinfo = await self.async_tmdb_info(tmdbid=tmdbid, mtype=mtype) if tmdbinfo: # 名称 name = tmdbinfo.get("title") or tmdbinfo.get("name") # 年份 year = self._extract_year_from_tmdb(tmdbinfo, season) # IMDBID imdbid = tmdbinfo.get("external_ids", {}).get("imdb_id") return await self.async_match_doubaninfo( name=name, year=year, mtype=mtype, imdbid=imdbid ) return None async def async_get_doubaninfo_by_bangumiid(self, bangumiid: int) -> Optional[dict]: """ 根据BangumiID获取豆瓣信息(异步版本) """ bangumiinfo = await self.async_bangumi_info(bangumiid=bangumiid) if bangumiinfo: # 优先使用中文标题匹配 if bangumiinfo.get("name_cn"): meta = MetaInfo(title=bangumiinfo.get("name_cn")) else: meta = MetaInfo(title=bangumiinfo.get("name")) # 年份 year = self._extract_year_from_bangumi(bangumiinfo) # 使用名称识别豆瓣媒体信息 return await self.async_match_doubaninfo( name=meta.name, year=year, mtype=MediaType.TV, season=meta.begin_season ) return None ================================================ FILE: app/chain/mediaserver.py ================================================ import threading from typing import List, Union, Optional, Generator, Any from app.chain import ChainBase from app.core.config import global_vars from app.db.mediaserver_oper import MediaServerOper from app.helper.service import ServiceConfigHelper from app.log import logger from app.schemas import MediaServerLibrary, MediaServerItem, MediaServerSeasonInfo, MediaServerPlayItem lock = threading.Lock() class MediaServerChain(ChainBase): """ 媒体服务器处理链 """ def librarys(self, server: str, username: Optional[str] = None, hidden: bool = False) -> List[MediaServerLibrary]: """ 获取媒体服务器所有媒体库 """ return self.run_module("mediaserver_librarys", server=server, username=username, hidden=hidden) def items(self, server: str, library_id: Union[str, int], start_index: Optional[int] = 0, limit: Optional[int] = -1) -> Generator[Any, None, None]: """ 获取媒体服务器项目列表,支持分页和不分页逻辑,默认不分页获取所有数据 :param server: 媒体服务器名称 :param library_id: 媒体库ID,用于标识要获取的媒体库 :param start_index: 起始索引,用于分页获取数据。默认为 0,即从第一个项目开始获取 :param limit: 每次请求的最大项目数,用于分页。如果为 None 或 -1,则表示一次性获取所有数据,默认为 -1 :return: 返回一个生成器对象,用于逐步获取媒体服务器中的项目 说明: - 特别注意的是,这里使用yield from返回迭代器,避免同时使用return与yield导致Python生成器解析异常 - 如果 `limit` 为 None 或 -1 时,表示一次性获取所有数据,分页处理将不再生效 - 在这种情况下,内存消耗可能会较大,特别是在数据量非常大的场景下 - 如果未来评估结果显示,不分页场景下的内存消耗远大于分页处理时的网络请求开销,可以考虑在此方法中实现自分页的处理 - 即通过 `while` 循环在上层进行分页控制,逐步获取所有数据,避免内存爆炸,当前该逻辑由具体实例来实现不分页的处理 - Plex 实际上已默认支持内部分页处理,Jellyfin 与 Emby 获取数据时存在内部过滤场景,如排除合集等,分页数据可能是错误的 if limit is not None and limit != -1: yield from self.run_module("mediaserver_items", server=server, library_id=library_id, start_index=start_index, limit=limit) else: # 自分页逻辑,通过循环逐步获取所有数据 page_size = 10 while True: data_generator = self.run_module("mediaserver_items", server=server, library_id=library_id, start_index=start_index, limit=page_size) if not data_generator: break count = 0 for item in data_generator: if item: count += 1 yield item if count < page_size: break start_index += page_size """ yield from self.run_module("mediaserver_items", server=server, library_id=library_id, start_index=start_index, limit=limit) def iteminfo(self, server: str, item_id: Union[str, int]) -> MediaServerItem: """ 获取媒体服务器项目信息 """ return self.run_module("mediaserver_iteminfo", server=server, item_id=item_id) def episodes(self, server: str, item_id: Union[str, int]) -> List[MediaServerSeasonInfo]: """ 获取媒体服务器剧集信息 """ return self.run_module("mediaserver_tv_episodes", server=server, item_id=item_id) def playing(self, server: str, count: Optional[int] = 20, username: Optional[str] = None) -> List[MediaServerPlayItem]: """ 获取媒体服务器正在播放信息 """ return self.run_module("mediaserver_playing", count=count, server=server, username=username) def latest(self, server: str, count: Optional[int] = 20, username: Optional[str] = None) -> List[MediaServerPlayItem]: """ 获取媒体服务器最新入库条目 """ return self.run_module("mediaserver_latest", count=count, server=server, username=username) def get_latest_wallpapers(self, server: Optional[str] = None, count: Optional[int] = 10, remote: bool = True, username: Optional[str] = None) -> List[str]: """ 获取最新最新入库条目海报作为壁纸,缓存1小时 """ return self.run_module("mediaserver_latest_images", server=server, count=count, remote=remote, username=username) def get_latest_wallpaper(self, server: Optional[str] = None, remote: bool = True, username: Optional[str] = None) -> Optional[str]: """ 获取最新最新入库条目海报作为壁纸,缓存1小时 """ wallpapers = self.get_latest_wallpapers(server=server, count=1, remote=remote, username=username) return wallpapers[0] if wallpapers else None def get_play_url(self, server: str, item_id: Union[str, int]) -> Optional[str]: """ 获取播放地址 """ return self.run_module("mediaserver_play_url", server=server, item_id=item_id) def get_image_cookies( self, server: Optional[str], image_url: str ) -> Optional[str | dict]: """ 获取图片的Cookies """ return self.run_module( "mediaserver_image_cookies", server=server, image_url=image_url ) def sync(self): """ 同步媒体库所有数据到本地数据库 """ # 设置的媒体服务器 mediaservers = ServiceConfigHelper.get_mediaserver_configs() if not mediaservers: return with lock: # 汇总统计 total_count = 0 # 清空登记薄 dboper = MediaServerOper() dboper.empty() # 遍历媒体服务器 for mediaserver in mediaservers: if not mediaserver: continue logger.info(f"正在准备同步媒体服务器 {mediaserver.name} 的数据") if not mediaserver.enabled: logger.info(f"媒体服务器 {mediaserver.name} 未启用,跳过") continue server_name = mediaserver.name sync_libraries = mediaserver.sync_libraries or [] logger.info(f"开始同步媒体服务器 {server_name} 的数据 ...") libraries = self.librarys(server_name) if not libraries: logger.info(f"没有获取到媒体服务器 {server_name} 的媒体库,跳过") continue for library in libraries: if sync_libraries \ and "all" not in sync_libraries \ and str(library.id) not in sync_libraries: logger.info(f"{library.name} 未在 {server_name} 同步媒体库列表中,跳过") continue logger.info(f"正在同步 {server_name} 媒体库 {library.name} ...") library_count = 0 for item in self.items(server=server_name, library_id=library.id): if global_vars.is_system_stopped: return if not item or not item.item_id: continue logger.debug(f"正在同步 {item.title} ...") # 计数 library_count += 1 seasoninfo = {} # 类型 item_type = "电视剧" if item.item_type in ["Series", "show"] else "电影" if item_type == "电视剧": # 查询剧集信息 espisodes_info = self.episodes(server_name, item.item_id) or [] for episode in espisodes_info: seasoninfo[episode.season] = episode.episodes # 插入数据 item_dict = item.model_dump() item_dict["seasoninfo"] = seasoninfo item_dict["item_type"] = item_type dboper.add(**item_dict) logger.info(f"{server_name} 媒体库 {library.name} 同步完成,共同步数量:{library_count}") # 总数累加 total_count += library_count logger.info(f"媒体服务器 {server_name} 数据同步完成,总同步数量:{total_count}") ================================================ FILE: app/chain/message.py ================================================ import asyncio import re import time from datetime import datetime, timedelta from typing import Any, Optional, Dict, Union, List from app.agent import agent_manager from app.chain import ChainBase from app.chain.download import DownloadChain from app.chain.media import MediaChain from app.chain.search import SearchChain from app.chain.subscribe import SubscribeChain from app.core.config import settings, global_vars from app.core.context import MediaInfo, Context from app.core.meta import MetaBase from app.db.user_oper import UserOper from app.helper.torrent import TorrentHelper from app.log import logger from app.schemas import Notification, NotExistMediaInfo, CommingMessage from app.schemas.message import ChannelCapabilityManager from app.schemas.types import EventType, MessageChannel, MediaType from app.utils.string import StringUtils # 当前页面 _current_page: int = 0 # 当前元数据 _current_meta: Optional[MetaBase] = None # 当前媒体信息 _current_media: Optional[MediaInfo] = None class MessageChain(ChainBase): """ 外来消息处理链 """ # 缓存的用户数据 {userid: {type: str, items: list}} _cache_file = "__user_messages__" # 每页数据量 _page_size: int = 8 # 用户会话信息 {userid: (session_id, last_time)} _user_sessions: Dict[Union[str, int], tuple] = {} # 会话超时时间(分钟) _session_timeout_minutes: int = 30 @staticmethod def __get_noexits_info( _meta: MetaBase, _mediainfo: MediaInfo) -> Dict[Union[int, str], Dict[int, NotExistMediaInfo]]: """ 获取缺失的媒体信息 """ if _mediainfo.type == MediaType.TV: if not _mediainfo.seasons: # 补充媒体信息 _mediainfo = MediaChain().recognize_media(mtype=_mediainfo.type, tmdbid=_mediainfo.tmdb_id, doubanid=_mediainfo.douban_id, cache=False) if not _mediainfo: logger.warn(f"{_mediainfo.tmdb_id or _mediainfo.douban_id} 媒体信息识别失败!") return {} if not _mediainfo.seasons: logger.warn(f"媒体信息中没有季集信息," f"标题:{_mediainfo.title}," f"tmdbid:{_mediainfo.tmdb_id},doubanid:{_mediainfo.douban_id}") return {} # KEY _mediakey = _mediainfo.tmdb_id or _mediainfo.douban_id _no_exists = { _mediakey: {} } if _meta.begin_season: # 指定季 episodes = _mediainfo.seasons.get(_meta.begin_season) if not episodes: return {} _no_exists[_mediakey][_meta.begin_season] = NotExistMediaInfo( season=_meta.begin_season, episodes=[], total_episode=len(episodes), start_episode=episodes[0] ) else: # 所有季 for sea, eps in _mediainfo.seasons.items(): if not eps: continue _no_exists[_mediakey][sea] = NotExistMediaInfo( season=sea, episodes=[], total_episode=len(eps), start_episode=eps[0] ) else: _no_exists = {} return _no_exists def process(self, body: Any, form: Any, args: Any) -> None: """ 调用模块识别消息内容 """ # 消息来源 source = args.get("source") # 获取消息内容 info = self.message_parser(source=source, body=body, form=form, args=args) if not info: return # 更新消息来源 source = info.source # 渠道 channel = info.channel # 用户ID userid = info.userid # 用户名(当渠道未提供公开用户名时,回退为 userid 的字符串,避免后续类型校验异常) username = str(info.username) if info.username not in (None, "") else str(userid) if userid is None or userid == '': logger.debug(f'未识别到用户ID:{body}{form}{args}') return # 消息内容 text = str(info.text).strip() if info.text else None if not text: logger.debug(f'未识别到消息内容::{body}{form}{args}') return # 获取原消息ID信息 original_message_id = info.message_id original_chat_id = info.chat_id # 处理消息 self.handle_message(channel=channel, source=source, userid=userid, username=username, text=text, original_message_id=original_message_id, original_chat_id=original_chat_id) def handle_message(self, channel: MessageChannel, source: str, userid: Union[str, int], username: str, text: str, original_message_id: Optional[Union[str, int]] = None, original_chat_id: Optional[str] = None) -> None: """ 识别消息内容,执行操作 """ # 申明全局变量 global _current_page, _current_meta, _current_media # 处理消息 logger.info(f'收到用户消息内容,用户:{userid},内容:{text}') # 加载缓存 user_cache: Dict[str, dict] = self.load_cache(self._cache_file) or {} try: # 保存消息 if not text.startswith('CALLBACK:'): self.messagehelper.put( CommingMessage( userid=userid, username=username, channel=channel, source=source, text=text ), role="user") self.messageoper.add( channel=channel, source=source, userid=username or userid, text=text, action=0 ) # 处理消息 if text.startswith('CALLBACK:'): # 处理按钮回调(适配支持回调的渠),优先级最高 if ChannelCapabilityManager.supports_callbacks(channel): self._handle_callback(text=text, channel=channel, source=source, userid=userid, username=username, original_message_id=original_message_id, original_chat_id=original_chat_id) else: logger.warning(f"渠道 {channel.value} 不支持回调,但收到了回调消息:{text}") elif text.startswith('/') and not text.lower().startswith('/ai'): # 执行特定命令命令(但不是/ai) self.eventmanager.send_event( EventType.CommandExcute, { "cmd": text, "user": userid, "channel": channel, "source": source } ) elif text.lower().startswith('/ai'): # 用户指定AI智能体消息响应 self._handle_ai_message(text=text, channel=channel, source=source, userid=userid, username=username) elif settings.AI_AGENT_ENABLE and settings.AI_AGENT_GLOBAL: # 普通消息,全局智能体响应 self._handle_ai_message(text=text, channel=channel, source=source, userid=userid, username=username) else: # 非智能体普通消息响应 if text.isdigit(): # 用户选择了具体的条目 # 缓存 cache_data: dict = user_cache.get(userid) if not cache_data: # 发送消息 self.post_message(Notification(channel=channel, source=source, title="输入有误!", userid=userid)) return cache_data = cache_data.copy() # 选择项目 if not cache_data.get('items') \ or len(cache_data.get('items')) < int(text): # 发送消息 self.post_message(Notification(channel=channel, source=source, title="输入有误!", userid=userid)) return try: # 选择的序号 _choice = int(text) + _current_page * self._page_size - 1 # 缓存类型 cache_type: str = cache_data.get('type') # 缓存列表 cache_list: list = cache_data.get('items').copy() # 选择 try: if cache_type in ["Search", "ReSearch"]: # 当前媒体信息 mediainfo: MediaInfo = cache_list[_choice] _current_media = mediainfo # 查询缺失的媒体信息 exist_flag, no_exists = DownloadChain().get_no_exists_info(meta=_current_meta, mediainfo=_current_media) if exist_flag and cache_type == "Search": # 媒体库中已存在 self.post_message( Notification(channel=channel, source=source, title=f"【{_current_media.title_year}" f"{_current_meta.sea} 媒体库中已存在,如需重新下载请发送:搜索 名称 或 下载 名称】", userid=userid)) return elif exist_flag: # 没有缺失,但要全量重新搜索和下载 no_exists = self.__get_noexits_info(_current_meta, _current_media) # 发送缺失的媒体信息 messages = [] if no_exists and cache_type == "Search": # 发送缺失消息 mediakey = mediainfo.tmdb_id or mediainfo.douban_id messages = [ f"第 {sea} 季缺失 {StringUtils.str_series(no_exist.episodes) if no_exist.episodes else no_exist.total_episode} 集" for sea, no_exist in no_exists.get(mediakey).items()] elif no_exists: # 发送总集数的消息 mediakey = mediainfo.tmdb_id or mediainfo.douban_id messages = [ f"第 {sea} 季总 {no_exist.total_episode} 集" for sea, no_exist in no_exists.get(mediakey).items()] if messages: self.post_message(Notification(channel=channel, source=source, title=f"{mediainfo.title_year}:\n" + "\n".join(messages), userid=userid)) # 搜索种子,过滤掉不需要的剧集,以便选择 logger.info(f"开始搜索 {mediainfo.title_year} ...") self.post_message( Notification(channel=channel, source=source, title=f"开始搜索 {mediainfo.type.value} {mediainfo.title_year} ...", userid=userid)) # 开始搜索 contexts = SearchChain().process(mediainfo=mediainfo, no_exists=no_exists) if not contexts: # 没有数据 self.post_message(Notification( channel=channel, source=source, title=f"{mediainfo.title}" f"{_current_meta.sea} 未搜索到需要的资源!", userid=userid)) return # 搜索结果排序 contexts = TorrentHelper().sort_torrents(contexts) try: # 判断是否设置自动下载 auto_download_user = settings.AUTO_DOWNLOAD_USER # 匹配到自动下载用户 if auto_download_user \ and (auto_download_user == "all" or any(userid == user for user in auto_download_user.split(","))): logger.info(f"用户 {userid} 在自动下载用户中,开始自动择优下载 ...") # 自动选择下载 self.__auto_download(channel=channel, source=source, cache_list=contexts, userid=userid, username=username, no_exists=no_exists) else: # 更新缓存 user_cache[userid] = { "type": "Torrent", "items": contexts } _current_page = 0 # 保存缓存 self.save_cache(user_cache, self._cache_file) # 删除原消息 if (original_message_id and original_chat_id and ChannelCapabilityManager.supports_deletion(channel)): self.delete_message( channel=channel, source=source, message_id=original_message_id, chat_id=original_chat_id ) # 发送种子数据 logger.info(f"搜索到 {len(contexts)} 条数据,开始发送选择消息 ...") self.__post_torrents_message(channel=channel, source=source, title=mediainfo.title, items=contexts[:self._page_size], userid=userid, total=len(contexts)) finally: contexts.clear() del contexts elif cache_type in ["Subscribe", "ReSubscribe"]: # 订阅或洗版媒体 mediainfo: MediaInfo = cache_list[_choice] # 洗版标识 best_version = False # 查询缺失的媒体信息 if cache_type == "Subscribe": exist_flag, _ = DownloadChain().get_no_exists_info(meta=_current_meta, mediainfo=mediainfo) if exist_flag: self.post_message(Notification( channel=channel, source=source, title=f"【{mediainfo.title_year}" f"{_current_meta.sea} 媒体库中已存在,如需洗版请发送:洗版 XXX】", userid=userid)) return else: best_version = True # 转换用户名 mp_name = UserOper().get_name( **{f"{channel.name.lower()}_userid": userid}) if channel else None # 添加订阅,状态为N SubscribeChain().add(title=mediainfo.title, year=mediainfo.year, mtype=mediainfo.type, tmdbid=mediainfo.tmdb_id, season=_current_meta.begin_season, channel=channel, source=source, userid=userid, username=mp_name or username, best_version=best_version) elif cache_type == "Torrent": if int(text) == 0: # 自动选择下载,强制下载模式 self.__auto_download(channel=channel, source=source, cache_list=cache_list, userid=userid, username=username) else: # 下载种子 context: Context = cache_list[_choice] # 下载 DownloadChain().download_single(context, channel=channel, source=source, userid=userid, username=username) finally: cache_list.clear() del cache_list finally: cache_data.clear() del cache_data elif text.lower() == "p": # 上一页 cache_data: dict = user_cache.get(userid) if not cache_data: # 没有缓存 self.post_message(Notification( channel=channel, source=source, title="输入有误!", userid=userid)) return cache_data = cache_data.copy() try: if _current_page == 0: # 第一页 self.post_message(Notification( channel=channel, source=source, title="已经是第一页了!", userid=userid)) return # 减一页 _current_page -= 1 cache_type: str = cache_data.get('type') # 产生副本,避免修改原值 cache_list: list = cache_data.get('items').copy() try: if _current_page == 0: start = 0 end = self._page_size else: start = _current_page * self._page_size end = start + self._page_size if cache_type == "Torrent": # 发送种子数据 self.__post_torrents_message(channel=channel, source=source, title=_current_media.title, items=cache_list[start:end], userid=userid, total=len(cache_list), original_message_id=original_message_id, original_chat_id=original_chat_id) else: # 发送媒体数据 self.__post_medias_message(channel=channel, source=source, title=_current_meta.name, items=cache_list[start:end], userid=userid, total=len(cache_list), original_message_id=original_message_id, original_chat_id=original_chat_id) finally: cache_list.clear() del cache_list finally: cache_data.clear() del cache_data elif text.lower() == "n": # 下一页 cache_data: dict = user_cache.get(userid) if not cache_data: # 没有缓存 self.post_message(Notification( channel=channel, source=source, title="输入有误!", userid=userid)) return cache_data = cache_data.copy() try: cache_type: str = cache_data.get('type') # 产生副本,避免修改原值 cache_list: list = cache_data.get('items').copy() total = len(cache_list) # 加一页 cache_list = cache_list[(_current_page + 1) * self._page_size:(_current_page + 2) * self._page_size] if not cache_list: # 没有数据 self.post_message(Notification( channel=channel, source=source, title="已经是最后一页了!", userid=userid)) return else: try: # 加一页 _current_page += 1 if cache_type == "Torrent": # 发送种子数据 self.__post_torrents_message(channel=channel, source=source, title=_current_media.title, items=cache_list, userid=userid, total=total, original_message_id=original_message_id, original_chat_id=original_chat_id) else: # 发送媒体数据 self.__post_medias_message(channel=channel, source=source, title=_current_meta.name, items=cache_list, userid=userid, total=total, original_message_id=original_message_id, original_chat_id=original_chat_id) finally: cache_list.clear() del cache_list finally: cache_data.clear() del cache_data else: # 搜索或订阅 if text.startswith("订阅"): # 订阅 content = re.sub(r"订阅[::\s]*", "", text) action = "Subscribe" elif text.startswith("洗版"): # 洗版 content = re.sub(r"洗版[::\s]*", "", text) action = "ReSubscribe" elif text.startswith("搜索") or text.startswith("下载"): # 重新搜索/下载 content = re.sub(r"(搜索|下载)[::\s]*", "", text) action = "ReSearch" elif StringUtils.is_link(text): # 链接 content = text action = "Link" elif not StringUtils.is_media_title_like(text): # 聊天 content = text action = "Chat" else: # 搜索 content = text action = "Search" if action in ["Search", "ReSearch", "Subscribe", "ReSubscribe"]: # 搜索 meta, medias = MediaChain().search(content) # 识别 if not meta.name: self.post_message(Notification( channel=channel, source=source, title="无法识别输入内容!", userid=userid)) return # 开始搜索 if not medias: self.post_message(Notification( channel=channel, source=source, title=f"{meta.name} 没有找到对应的媒体信息!", userid=userid)) return logger.info(f"搜索到 {len(medias)} 条相关媒体信息") try: # 记录当前状态 _current_meta = meta # 保存缓存 user_cache[userid] = { 'type': action, 'items': medias } self.save_cache(user_cache, self._cache_file) _current_page = 0 _current_media = None # 发送媒体列表 self.__post_medias_message(channel=channel, source=source, title=meta.name, items=medias[:self._page_size], userid=userid, total=len(medias)) finally: medias.clear() del medias else: # 广播事件 self.eventmanager.send_event( EventType.UserMessage, { "text": content, "userid": userid, "channel": channel, "source": source } ) finally: user_cache.clear() del user_cache def _handle_callback(self, text: str, channel: MessageChannel, source: str, userid: Union[str, int], username: str, original_message_id: Optional[Union[str, int]] = None, original_chat_id: Optional[str] = None) -> None: """ 处理按钮回调 """ global _current_media # 提取回调数据 callback_data = text[9:] # 去掉 "CALLBACK:" 前缀 logger.info(f"处理按钮回调:{callback_data}") # 插件消息的事件回调 [PLUGIN]插件ID|内容 if callback_data.startswith('[PLUGIN]'): # 提取插件ID和内容 plugin_id, content = callback_data.split("|", 1) # 广播给插件处理 self.eventmanager.send_event( EventType.MessageAction, { "plugin_id": plugin_id.replace("[PLUGIN]", ""), "text": content, "userid": userid, "channel": channel, "source": source, "original_message_id": original_message_id, "original_chat_id": original_chat_id } ) return # 解析系统回调数据 try: page_text = callback_data.split("_", 1)[1] self.handle_message(channel=channel, source=source, userid=userid, username=username, text=page_text, original_message_id=original_message_id, original_chat_id=original_chat_id) except IndexError: logger.error(f"回调数据格式错误:{callback_data}") self.post_message(Notification( channel=channel, source=source, userid=userid, username=username, title="回调数据格式错误,请检查!" )) def __auto_download(self, channel: MessageChannel, source: str, cache_list: list[Context], userid: Union[str, int], username: str, no_exists: Optional[Dict[Union[int, str], Dict[int, NotExistMediaInfo]]] = None): """ 自动择优下载 """ downloadchain = DownloadChain() if no_exists is None: # 查询缺失的媒体信息 exist_flag, no_exists = downloadchain.get_no_exists_info( meta=_current_meta, mediainfo=_current_media ) if exist_flag: # 媒体库中已存在,查询全量 no_exists = self.__get_noexits_info(_current_meta, _current_media) # 批量下载 downloads, lefts = downloadchain.batch_download(contexts=cache_list, no_exists=no_exists, channel=channel, source=source, userid=userid, username=username) if downloads and not lefts: # 全部下载完成 logger.info(f'{_current_media.title_year} 下载完成') else: # 未完成下载 logger.info(f'{_current_media.title_year} 未下载未完整,添加订阅 ...') if downloads and _current_media.type == MediaType.TV: # 获取已下载剧集 downloaded = [download.meta_info.begin_episode for download in downloads if download.meta_info.begin_episode] note = downloaded else: note = None # 转换用户名 mp_name = UserOper().get_name(**{f"{channel.name.lower()}_userid": userid}) if channel else None # 添加订阅,状态为R SubscribeChain().add(title=_current_media.title, year=_current_media.year, mtype=_current_media.type, tmdbid=_current_media.tmdb_id, season=_current_meta.begin_season, channel=channel, source=source, userid=userid, username=mp_name or username, state="R", note=note) def __post_medias_message(self, channel: MessageChannel, source: str, title: str, items: list, userid: str, total: int, original_message_id: Optional[Union[str, int]] = None, original_chat_id: Optional[str] = None): """ 发送媒体列表消息 """ # 检查渠道是否支持按钮 supports_buttons = ChannelCapabilityManager.supports_buttons(channel) if supports_buttons: # 支持按钮的渠道 if total > self._page_size: title = f"【{title}】共找到{total}条相关信息,请选择操作" else: title = f"【{title}】共找到{total}条相关信息,请选择操作" buttons = self._create_media_buttons(channel=channel, items=items, total=total) else: # 不支持按钮的渠道,使用文本提示 if total > self._page_size: title = f"【{title}】共找到{total}条相关信息,请回复对应数字选择(p: 上一页 n: 下一页)" else: title = f"【{title}】共找到{total}条相关信息,请回复对应数字选择" buttons = None notification = Notification( channel=channel, source=source, title=title, userid=userid, buttons=buttons, original_message_id=original_message_id, original_chat_id=original_chat_id ) self.post_medias_message(notification, medias=items) def _create_media_buttons(self, channel: MessageChannel, items: list, total: int) -> List[List[Dict]]: """ 创建媒体选择按钮 """ global _current_page buttons = [] max_text_length = ChannelCapabilityManager.get_max_button_text_length(channel) max_per_row = ChannelCapabilityManager.get_max_buttons_per_row(channel) # 为每个媒体项创建选择按钮 current_row = [] for i in range(len(items)): media = items[i] if max_per_row == 1: # 每行一个按钮,使用完整文本 button_text = f"{i + 1}. {media.title_year}" if len(button_text) > max_text_length: button_text = button_text[:max_text_length - 3] + "..." buttons.append([{ "text": button_text, "callback_data": f"select_{i + 1}" }]) else: # 多按钮一行的情况,使用简化文本 button_text = f"{i + 1}" current_row.append({ "text": button_text, "callback_data": f"select_{i + 1}" }) # 如果当前行已满或者是最后一个按钮,添加到按钮列表 if len(current_row) == max_per_row or i == len(items) - 1: buttons.append(current_row) current_row = [] # 添加翻页按钮 if total > self._page_size: page_buttons = [] if _current_page > 0: page_buttons.append({"text": "⬅️ 上一页", "callback_data": "page_p"}) if (_current_page + 1) * self._page_size < total: page_buttons.append({"text": "下一页 ➡️", "callback_data": "page_n"}) if page_buttons: buttons.append(page_buttons) return buttons def __post_torrents_message(self, channel: MessageChannel, source: str, title: str, items: list, userid: str, total: int, original_message_id: Optional[Union[str, int]] = None, original_chat_id: Optional[str] = None): """ 发送种子列表消息 """ # 检查渠道是否支持按钮 supports_buttons = ChannelCapabilityManager.supports_buttons(channel) if supports_buttons: # 支持按钮的渠道 if total > self._page_size: title = f"【{title}】共找到{total}条相关资源,请选择下载" else: title = f"【{title}】共找到{total}条相关资源,请选择下载" buttons = self._create_torrent_buttons(channel=channel, items=items, total=total) else: # 不支持按钮的渠道,使用文本提示 if total > self._page_size: title = f"【{title}】共找到{total}条相关资源,请回复对应数字下载(0: 自动选择 p: 上一页 n: 下一页)" else: title = f"【{title}】共找到{total}条相关资源,请回复对应数字下载(0: 自动选择)" buttons = None notification = Notification( channel=channel, source=source, title=title, userid=userid, link=settings.MP_DOMAIN('#/resource'), buttons=buttons, original_message_id=original_message_id, original_chat_id=original_chat_id ) self.post_torrents_message(notification, torrents=items) def _create_torrent_buttons(self, channel: MessageChannel, items: list, total: int) -> List[List[Dict]]: """ 创建种子下载按钮 """ global _current_page buttons = [] max_text_length = ChannelCapabilityManager.get_max_button_text_length(channel) max_per_row = ChannelCapabilityManager.get_max_buttons_per_row(channel) # 自动选择按钮 buttons.append([{"text": "🤖 自动选择下载", "callback_data": "download_0"}]) # 为每个种子项创建下载按钮 current_row = [] for i in range(len(items)): context = items[i] torrent = context.torrent_info if max_per_row == 1: # 每行一个按钮,使用完整文本 button_text = f"{i + 1}. {torrent.site_name} - {torrent.seeders}↑" if len(button_text) > max_text_length: button_text = button_text[:max_text_length - 3] + "..." buttons.append([{ "text": button_text, "callback_data": f"download_{i + 1}" }]) else: # 多按钮一行的情况,使用简化文本 button_text = f"{i + 1}" current_row.append({ "text": button_text, "callback_data": f"download_{i + 1}" }) # 如果当前行已满或者是最后一个按钮,添加到按钮列表 if len(current_row) == max_per_row or i == len(items) - 1: buttons.append(current_row) current_row = [] # 添加翻页按钮 if total > self._page_size: page_buttons = [] if _current_page > 0: page_buttons.append({"text": "⬅️ 上一页", "callback_data": "page_p"}) if (_current_page + 1) * self._page_size < total: page_buttons.append({"text": "下一页 ➡️", "callback_data": "page_n"}) if page_buttons: buttons.append(page_buttons) return buttons def _get_or_create_session_id(self, userid: Union[str, int]) -> str: """ 获取或创建会话ID 如果用户上次会话在15分钟内,则复用相同的会话ID;否则创建新的会话ID """ current_time = datetime.now() # 检查用户是否有已存在的会话 if userid in self._user_sessions: session_id, last_time = self._user_sessions[userid] # 计算时间差 time_diff = current_time - last_time # 如果时间差小于等于xx分钟,复用会话ID if time_diff <= timedelta(minutes=self._session_timeout_minutes): # 更新最后使用时间 self._user_sessions[userid] = (session_id, current_time) logger.info( f"复用会话ID: {session_id}, 用户: {userid}, 距离上次会话: {time_diff.total_seconds() / 60:.1f}分钟") return session_id # 创建新的会话ID new_session_id = f"user_{userid}_{int(time.time())}" self._user_sessions[userid] = (new_session_id, current_time) logger.info(f"创建新会话ID: {new_session_id}, 用户: {userid}") return new_session_id def clear_user_session(self, userid: Union[str, int]) -> bool: """ 清除指定用户的会话信息 返回是否成功清除 """ if userid in self._user_sessions: session_id, _ = self._user_sessions.pop(userid) logger.info(f"已清除用户 {userid} 的会话: {session_id}") return True return False def remote_clear_session(self, channel: MessageChannel, userid: Union[str, int], source: Optional[str] = None): """ 清除用户会话(远程命令接口) """ # 获取并清除会话信息 session_id = None if userid in self._user_sessions: session_id, _ = self._user_sessions.pop(userid) logger.info(f"已清除用户 {userid} 的会话: {session_id}") # 如果有会话ID,同时清除智能体的会话记忆 if session_id: try: asyncio.run_coroutine_threadsafe( agent_manager.clear_session( session_id=session_id, user_id=str(userid) ), global_vars.loop ) except Exception as e: logger.warning(f"清除智能体会话记忆失败: {e}") self.post_message(Notification( channel=channel, source=source, title="智能体会话已清除,下次将创建新的会话", userid=userid )) else: self.post_message(Notification( channel=channel, source=source, title="您当前没有活跃的智能体会话", userid=userid )) def _handle_ai_message(self, text: str, channel: MessageChannel, source: str, userid: Union[str, int], username: str) -> None: """ 处理AI智能体消息 """ try: # 检查AI智能体是否启用 if not settings.AI_AGENT_ENABLE: self.post_message(Notification( channel=channel, source=source, userid=userid, username=username, title="MoviePilot智能助手未启用,请在系统设置中启用" )) return # 提取用户消息 if text.lower().startswith("/ai"): user_message = text[3:].strip() # 移除 "/ai" 前缀(大小写不敏感) else: user_message = text.strip() # 按原消息处理 if not user_message: self.post_message(Notification( channel=channel, source=source, userid=userid, username=username, title="请输入您的问题或需求" )) return # 生成或复用会话ID session_id = self._get_or_create_session_id(userid) # 在事件循环中处理 asyncio.run_coroutine_threadsafe( agent_manager.process_message( session_id=session_id, user_id=str(userid), message=user_message, channel=channel.value if channel else None, source=source, username=username ), global_vars.loop ) except Exception as e: logger.error(f"处理AI智能体消息失败: {e}") self.messagehelper.put(f"AI智能体处理失败: {str(e)}", role="system", title="MoviePilot助手") ================================================ FILE: app/chain/recommend.py ================================================ from typing import List, Optional import pillow_avif # noqa 用于自动注册AVIF支持 from app.chain import ChainBase from app.chain.bangumi import BangumiChain from app.chain.douban import DoubanChain from app.chain.tmdb import TmdbChain from app.core.cache import cached, fresh from app.core.config import settings, global_vars from app.helper.image import ImageHelper from app.log import logger from app.schemas import MediaType from app.utils.common import log_execution_time from app.utils.singleton import Singleton class RecommendChain(ChainBase, metaclass=Singleton): """ 推荐处理链,单例运行 """ # 推荐缓存时间 recommend_ttl = 24 * 3600 # 推荐缓存页数 cache_max_pages = 5 # 推荐缓存区域 recommend_cache_region = "recommend" def refresh_recommend(self, manual: bool = False): """ 刷新推荐 :param manual: 手动触发 """ logger.debug("Starting to refresh Recommend data.") # 推荐来源方法 recommend_methods = [ self.tmdb_movies, self.tmdb_tvs, self.tmdb_trending, self.bangumi_calendar, self.douban_movie_showing, self.douban_movies, self.douban_tvs, self.douban_movie_top250, self.douban_tv_weekly_chinese, self.douban_tv_weekly_global, self.douban_tv_animation, self.douban_movie_hot, self.douban_tv_hot, ] # 缓存并刷新所有推荐数据 recommends = [] # 记录哪些方法已完成 methods_finished = set() # 这里避免区间内连续调用相同来源,因此遍历方案为每页遍历所有推荐来源,再进行页数遍历 for page in range(1, self.cache_max_pages + 1): for method in recommend_methods: if global_vars.is_system_stopped: return if method in methods_finished: continue logger.debug(f"Fetch {method.__name__} data for page {page}.") # 手动触发的刷新,总是需要获取最新数据 with fresh(manual): data = method(page=page) if not data: logger.debug("All recommendation methods have finished fetching data. Ending pagination early.") methods_finished.add(method) continue recommends.extend(data) # 如果所有方法都已经完成,提前结束循环 if len(methods_finished) == len(recommend_methods): break # 缓存收集到的海报 self.__cache_posters(recommends) logger.debug("Recommend data refresh completed.") def __cache_posters(self, datas: List[dict]): """ 提取 poster_path 并缓存图片 :param datas: 数据列表 """ if not settings.GLOBAL_IMAGE_CACHE: return for data in datas: if global_vars.is_system_stopped: return poster_path = data.get("poster_path") if poster_path: poster_url = poster_path.replace("original", "w500") self.__fetch_and_save_image(poster_url) @staticmethod def __fetch_and_save_image(url: str): """ 请求并保存图片 :param url: 图片路径 """ ImageHelper().fetch_image(url=url) @log_execution_time(logger=logger) @cached(ttl=recommend_ttl, region=recommend_cache_region) def tmdb_movies(self, sort_by: Optional[str] = "popularity.desc", with_genres: Optional[str] = "", with_original_language: Optional[str] = "", with_keywords: Optional[str] = "", with_watch_providers: Optional[str] = "", vote_average: Optional[float] = 0.0, vote_count: Optional[int] = 0, release_date: Optional[str] = "", page: Optional[int] = 1) -> List[dict]: """ TMDB热门电影 """ movies = TmdbChain().tmdb_discover(mtype=MediaType.MOVIE, sort_by=sort_by, with_genres=with_genres, with_original_language=with_original_language, with_keywords=with_keywords, with_watch_providers=with_watch_providers, vote_average=vote_average, vote_count=vote_count, release_date=release_date, page=page) return [movie.to_dict() for movie in movies] if movies else [] @log_execution_time(logger=logger) @cached(ttl=recommend_ttl, region=recommend_cache_region) def tmdb_tvs(self, sort_by: Optional[str] = "popularity.desc", with_genres: Optional[str] = "", with_original_language: Optional[str] = "zh|en|ja|ko", with_keywords: Optional[str] = "", with_watch_providers: Optional[str] = "", vote_average: Optional[float] = 0.0, vote_count: Optional[int] = 0, release_date: Optional[str] = "", page: Optional[int] = 1) -> List[dict]: """ TMDB热门电视剧 """ tvs = TmdbChain().tmdb_discover(mtype=MediaType.TV, sort_by=sort_by, with_genres=with_genres, with_original_language=with_original_language, with_keywords=with_keywords, with_watch_providers=with_watch_providers, vote_average=vote_average, vote_count=vote_count, release_date=release_date, page=page) return [tv.to_dict() for tv in tvs] if tvs else [] @log_execution_time(logger=logger) @cached(ttl=recommend_ttl, region=recommend_cache_region) def tmdb_trending(self, page: Optional[int] = 1) -> List[dict]: """ TMDB流行趋势 """ infos = TmdbChain().tmdb_trending(page=page) return [info.to_dict() for info in infos] if infos else [] @log_execution_time(logger=logger) @cached(ttl=recommend_ttl, region=recommend_cache_region) def bangumi_calendar(self, page: Optional[int] = 1, count: Optional[int] = 30) -> List[dict]: """ Bangumi每日放送 """ medias = BangumiChain().calendar() return [media.to_dict() for media in medias[(page - 1) * count: page * count]] if medias else [] @log_execution_time(logger=logger) @cached(ttl=recommend_ttl, region=recommend_cache_region) def douban_movie_showing(self, page: Optional[int] = 1, count: Optional[int] = 30) -> List[dict]: """ 豆瓣正在热映 """ movies = DoubanChain().movie_showing(page=page, count=count) return [media.to_dict() for media in movies] if movies else [] @log_execution_time(logger=logger) @cached(ttl=recommend_ttl, region=recommend_cache_region) def douban_movies(self, sort: Optional[str] = "R", tags: Optional[str] = "", page: Optional[int] = 1, count: Optional[int] = 30) -> List[dict]: """ 豆瓣最新电影 """ movies = DoubanChain().douban_discover(mtype=MediaType.MOVIE, sort=sort, tags=tags, page=page, count=count) return [media.to_dict() for media in movies] if movies else [] @log_execution_time(logger=logger) @cached(ttl=recommend_ttl, region=recommend_cache_region) def douban_tvs(self, sort: Optional[str] = "R", tags: Optional[str] = "", page: Optional[int] = 1, count: Optional[int] = 30) -> List[dict]: """ 豆瓣最新电视剧 """ tvs = DoubanChain().douban_discover(mtype=MediaType.TV, sort=sort, tags=tags, page=page, count=count) return [media.to_dict() for media in tvs] if tvs else [] @log_execution_time(logger=logger) @cached(ttl=recommend_ttl, region=recommend_cache_region) def douban_movie_top250(self, page: Optional[int] = 1, count: Optional[int] = 30) -> List[dict]: """ 豆瓣电影TOP250 """ movies = DoubanChain().movie_top250(page=page, count=count) return [media.to_dict() for media in movies] if movies else [] @log_execution_time(logger=logger) @cached(ttl=recommend_ttl, region=recommend_cache_region) def douban_tv_weekly_chinese(self, page: Optional[int] = 1, count: Optional[int] = 30) -> List[dict]: """ 豆瓣国产剧集榜 """ tvs = DoubanChain().tv_weekly_chinese(page=page, count=count) return [media.to_dict() for media in tvs] if tvs else [] @log_execution_time(logger=logger) @cached(ttl=recommend_ttl, region=recommend_cache_region) def douban_tv_weekly_global(self, page: Optional[int] = 1, count: Optional[int] = 30) -> List[dict]: """ 豆瓣全球剧集榜 """ tvs = DoubanChain().tv_weekly_global(page=page, count=count) return [media.to_dict() for media in tvs] if tvs else [] @log_execution_time(logger=logger) @cached(ttl=recommend_ttl, region=recommend_cache_region) def douban_tv_animation(self, page: Optional[int] = 1, count: Optional[int] = 30) -> List[dict]: """ 豆瓣热门动漫 """ tvs = DoubanChain().tv_animation(page=page, count=count) return [media.to_dict() for media in tvs] if tvs else [] @log_execution_time(logger=logger) @cached(ttl=recommend_ttl, region=recommend_cache_region) def douban_movie_hot(self, page: Optional[int] = 1, count: Optional[int] = 30) -> List[dict]: """ 豆瓣热门电影 """ movies = DoubanChain().movie_hot(page=page, count=count) return [media.to_dict() for media in movies] if movies else [] @log_execution_time(logger=logger) @cached(ttl=recommend_ttl, region=recommend_cache_region) def douban_tv_hot(self, page: Optional[int] = 1, count: Optional[int] = 30) -> List[dict]: """ 豆瓣热门电视剧 """ tvs = DoubanChain().tv_hot(page=page, count=count) return [media.to_dict() for media in tvs] if tvs else [] @log_execution_time(logger=logger) @cached(ttl=recommend_ttl, region=recommend_cache_region) async def async_tmdb_movies(self, sort_by: Optional[str] = "popularity.desc", with_genres: Optional[str] = "", with_original_language: Optional[str] = "", with_keywords: Optional[str] = "", with_watch_providers: Optional[str] = "", vote_average: Optional[float] = 0.0, vote_count: Optional[int] = 0, release_date: Optional[str] = "", page: Optional[int] = 1) -> List[dict]: """ 异步TMDB热门电影 """ movies = await TmdbChain().async_run_module("async_tmdb_discover", mtype=MediaType.MOVIE, sort_by=sort_by, with_genres=with_genres, with_original_language=with_original_language, with_keywords=with_keywords, with_watch_providers=with_watch_providers, vote_average=vote_average, vote_count=vote_count, release_date=release_date, page=page) return [movie.to_dict() for movie in movies] if movies else [] @log_execution_time(logger=logger) @cached(ttl=recommend_ttl, region=recommend_cache_region) async def async_tmdb_tvs(self, sort_by: Optional[str] = "popularity.desc", with_genres: Optional[str] = "", with_original_language: Optional[str] = "zh|en|ja|ko", with_keywords: Optional[str] = "", with_watch_providers: Optional[str] = "", vote_average: Optional[float] = 0.0, vote_count: Optional[int] = 0, release_date: Optional[str] = "", page: Optional[int] = 1) -> List[dict]: """ 异步TMDB热门电视剧 """ tvs = await TmdbChain().async_run_module("async_tmdb_discover", mtype=MediaType.TV, sort_by=sort_by, with_genres=with_genres, with_original_language=with_original_language, with_keywords=with_keywords, with_watch_providers=with_watch_providers, vote_average=vote_average, vote_count=vote_count, release_date=release_date, page=page) return [tv.to_dict() for tv in tvs] if tvs else [] @log_execution_time(logger=logger) @cached(ttl=recommend_ttl, region=recommend_cache_region) async def async_tmdb_trending(self, page: Optional[int] = 1) -> List[dict]: """ 异步TMDB流行趋势 """ infos = await TmdbChain().async_run_module("async_tmdb_trending", page=page) return [info.to_dict() for info in infos] if infos else [] @log_execution_time(logger=logger) @cached(ttl=recommend_ttl, region=recommend_cache_region) async def async_bangumi_calendar(self, page: Optional[int] = 1, count: Optional[int] = 30) -> List[dict]: """ 异步Bangumi每日放送 """ medias = await BangumiChain().async_run_module("async_bangumi_calendar") return [media.to_dict() for media in medias[(page - 1) * count: page * count]] if medias else [] @log_execution_time(logger=logger) @cached(ttl=recommend_ttl, region=recommend_cache_region) async def async_douban_movie_showing(self, page: Optional[int] = 1, count: Optional[int] = 30) -> List[dict]: """ 异步豆瓣正在热映 """ movies = await DoubanChain().async_run_module("async_movie_showing", page=page, count=count) return [media.to_dict() for media in movies] if movies else [] @log_execution_time(logger=logger) @cached(ttl=recommend_ttl, region=recommend_cache_region) async def async_douban_movies(self, sort: Optional[str] = "R", tags: Optional[str] = "", page: Optional[int] = 1, count: Optional[int] = 30) -> List[dict]: """ 异步豆瓣最新电影 """ movies = await DoubanChain().async_run_module("async_douban_discover", mtype=MediaType.MOVIE, sort=sort, tags=tags, page=page, count=count) return [media.to_dict() for media in movies] if movies else [] @log_execution_time(logger=logger) @cached(ttl=recommend_ttl, region=recommend_cache_region) async def async_douban_tvs(self, sort: Optional[str] = "R", tags: Optional[str] = "", page: Optional[int] = 1, count: Optional[int] = 30) -> List[dict]: """ 异步豆瓣最新电视剧 """ tvs = await DoubanChain().async_run_module("async_douban_discover", mtype=MediaType.TV, sort=sort, tags=tags, page=page, count=count) return [media.to_dict() for media in tvs] if tvs else [] @log_execution_time(logger=logger) @cached(ttl=recommend_ttl, region=recommend_cache_region) async def async_douban_movie_top250(self, page: Optional[int] = 1, count: Optional[int] = 30) -> List[dict]: """ 异步豆瓣电影TOP250 """ movies = await DoubanChain().async_run_module("async_movie_top250", page=page, count=count) return [media.to_dict() for media in movies] if movies else [] @log_execution_time(logger=logger) @cached(ttl=recommend_ttl, region=recommend_cache_region) async def async_douban_tv_weekly_chinese(self, page: Optional[int] = 1, count: Optional[int] = 30) -> List[dict]: """ 异步豆瓣国产剧集榜 """ tvs = await DoubanChain().async_run_module("async_tv_weekly_chinese", page=page, count=count) return [media.to_dict() for media in tvs] if tvs else [] @log_execution_time(logger=logger) @cached(ttl=recommend_ttl, region=recommend_cache_region) async def async_douban_tv_weekly_global(self, page: Optional[int] = 1, count: Optional[int] = 30) -> List[dict]: """ 异步豆瓣全球剧集榜 """ tvs = await DoubanChain().async_run_module("async_tv_weekly_global", page=page, count=count) return [media.to_dict() for media in tvs] if tvs else [] @log_execution_time(logger=logger) @cached(ttl=recommend_ttl, region=recommend_cache_region) async def async_douban_tv_animation(self, page: Optional[int] = 1, count: Optional[int] = 30) -> List[dict]: """ 异步豆瓣热门动漫 """ tvs = await DoubanChain().async_run_module("async_tv_animation", page=page, count=count) return [media.to_dict() for media in tvs] if tvs else [] @log_execution_time(logger=logger) @cached(ttl=recommend_ttl, region=recommend_cache_region) async def async_douban_movie_hot(self, page: Optional[int] = 1, count: Optional[int] = 30) -> List[dict]: """ 异步豆瓣热门电影 """ movies = await DoubanChain().async_run_module("async_movie_hot", page=page, count=count) return [media.to_dict() for media in movies] if movies else [] @log_execution_time(logger=logger) @cached(ttl=recommend_ttl, region=recommend_cache_region) async def async_douban_tv_hot(self, page: Optional[int] = 1, count: Optional[int] = 30) -> List[dict]: """ 异步豆瓣热门电视剧 """ tvs = await DoubanChain().async_run_module("async_tv_hot", page=page, count=count) return [media.to_dict() for media in tvs] if tvs else [] ================================================ FILE: app/chain/search.py ================================================ import asyncio import random import time from concurrent.futures import ThreadPoolExecutor, as_completed from datetime import datetime from typing import Dict, Tuple from typing import List, Optional from app.helper.sites import SitesHelper # noqa from fastapi.concurrency import run_in_threadpool from app.chain import ChainBase from app.core.config import global_vars, settings from app.core.context import Context from app.core.context import MediaInfo, TorrentInfo from app.core.event import eventmanager, Event from app.core.metainfo import MetaInfo from app.db.systemconfig_oper import SystemConfigOper from app.helper.progress import ProgressHelper from app.helper.torrent import TorrentHelper from app.log import logger from app.schemas import NotExistMediaInfo from app.schemas.types import MediaType, ProgressKey, SystemConfigKey, EventType class SearchChain(ChainBase): """ 站点资源搜索处理链 """ __result_temp_file = "__search_result__" __ai_result_temp_file = "__ai_search_result__" def search_by_id(self, tmdbid: Optional[int] = None, doubanid: Optional[str] = None, mtype: MediaType = None, area: Optional[str] = "title", season: Optional[int] = None, sites: List[int] = None, cache_local: bool = False) -> List[Context]: """ 根据TMDBID/豆瓣ID搜索资源,精确匹配,不过滤本地存在的资源 :param tmdbid: TMDB ID :param doubanid: 豆瓣 ID :param mtype: 媒体,电影 or 电视剧 :param area: 搜索范围,title or imdbid :param season: 季数 :param sites: 站点ID列表 :param cache_local: 是否缓存到本地 """ mediainfo = self.recognize_media(tmdbid=tmdbid, doubanid=doubanid, mtype=mtype) if not mediainfo: logger.error(f'{tmdbid} 媒体信息识别失败!') return [] no_exists = None if season is not None: no_exists = { tmdbid or doubanid: { season: NotExistMediaInfo(episodes=[]) } } results = self.process(mediainfo=mediainfo, sites=sites, area=area, no_exists=no_exists) # 保存到本地文件 if cache_local: self.save_cache(results, self.__result_temp_file) return results def search_by_title(self, title: str, page: Optional[int] = 0, sites: List[int] = None, cache_local: Optional[bool] = False) -> List[Context]: """ 根据标题搜索资源,不识别不过滤,直接返回站点内容 :param title: 标题,为空时返回所有站点首页内容 :param page: 页码 :param sites: 站点ID列表 :param cache_local: 是否缓存到本地 """ if title: logger.info(f'开始搜索资源,关键词:{title} ...') else: logger.info(f'开始浏览资源,站点:{sites} ...') # 搜索 torrents = self.__search_all_sites(keyword=title, sites=sites, page=page) or [] if not torrents: logger.warn(f'{title} 未搜索到资源') return [] # 组装上下文 contexts = [Context(meta_info=MetaInfo(title=torrent.title, subtitle=torrent.description), torrent_info=torrent) for torrent in torrents] # 保存到本地文件 if cache_local: self.save_cache(contexts, self.__result_temp_file) return contexts def last_search_results(self) -> Optional[List[Context]]: """ 获取上次搜索结果 """ return self.load_cache(self.__result_temp_file) async def async_last_search_results(self) -> Optional[List[Context]]: """ 异步获取上次搜索结果 """ return await self.async_load_cache(self.__result_temp_file) async def async_last_ai_results(self) -> Optional[List[Context]]: """ 异步获取上次AI推荐结果 """ return await self.async_load_cache(self.__ai_result_temp_file) async def async_save_ai_results(self, results: List[Context]): """ 异步保存AI推荐结果 """ await self.async_save_cache(results, self.__ai_result_temp_file) async def async_search_by_id(self, tmdbid: Optional[int] = None, doubanid: Optional[str] = None, mtype: MediaType = None, area: Optional[str] = "title", season: Optional[int] = None, sites: List[int] = None, cache_local: bool = False) -> List[Context]: """ 根据TMDBID/豆瓣ID异步搜索资源,精确匹配,不过滤本地存在的资源 :param tmdbid: TMDB ID :param doubanid: 豆瓣 ID :param mtype: 媒体,电影 or 电视剧 :param area: 搜索范围,title or imdbid :param season: 季数 :param sites: 站点ID列表 :param cache_local: 是否缓存到本地 """ mediainfo = await self.async_recognize_media(tmdbid=tmdbid, doubanid=doubanid, mtype=mtype) if not mediainfo: logger.error(f'{tmdbid} 媒体信息识别失败!') return [] no_exists = None if season is not None: no_exists = { tmdbid or doubanid: { season: NotExistMediaInfo(episodes=[]) } } results = await self.async_process(mediainfo=mediainfo, sites=sites, area=area, no_exists=no_exists) # 保存到本地文件 if cache_local: await self.async_save_cache(results, self.__result_temp_file) return results async def async_search_by_title(self, title: str, page: Optional[int] = 0, sites: List[int] = None, cache_local: Optional[bool] = False) -> List[Context]: """ 根据标题异步搜索资源,不识别不过滤,直接返回站点内容 :param title: 标题,为空时返回所有站点首页内容 :param page: 页码 :param sites: 站点ID列表 :param cache_local: 是否缓存到本地 """ if title: logger.info(f'开始搜索资源,关键词:{title} ...') else: logger.info(f'开始浏览资源,站点:{sites} ...') # 搜索 torrents = await self.__async_search_all_sites(keyword=title, sites=sites, page=page) or [] if not torrents: logger.warn(f'{title} 未搜索到资源') return [] # 组装上下文 contexts = [Context(meta_info=MetaInfo(title=torrent.title, subtitle=torrent.description), torrent_info=torrent) for torrent in torrents] # 保存到本地文件 if cache_local: await self.async_save_cache(contexts, self.__result_temp_file) return contexts @staticmethod def __prepare_params(mediainfo: MediaInfo, keyword: Optional[str] = None, no_exists: Dict[int, Dict[int, NotExistMediaInfo]] = None ) -> Tuple[Dict[int, List[int]], List[str]]: """ 准备搜索参数 """ # 缺失的季集 mediakey = mediainfo.tmdb_id or mediainfo.douban_id if no_exists and no_exists.get(mediakey): # 过滤剧集 season_episodes = {sea: info.episodes for sea, info in no_exists[mediakey].items()} elif mediainfo.season is not None: # 豆瓣只搜索当前季 season_episodes = {mediainfo.season: []} else: season_episodes = None # 搜索关键词 if keyword: keywords = [keyword] else: # 去重去空,但要保持顺序 keywords = list(dict.fromkeys([k for k in [mediainfo.title, mediainfo.original_title, mediainfo.en_title, mediainfo.hk_title, mediainfo.tw_title, mediainfo.sg_title] if k])) # 限制搜索关键词数量 if settings.MAX_SEARCH_NAME_LIMIT: keywords = keywords[:settings.MAX_SEARCH_NAME_LIMIT] return season_episodes, keywords def __parse_result(self, torrents: List[TorrentInfo], mediainfo: MediaInfo, keyword: Optional[str] = None, rule_groups: List[str] = None, season_episodes: Dict[int, List[int]] = None, custom_words: List[str] = None, filter_params: Dict[str, str] = None) -> List[Context]: """ 处理搜索结果 """ def __do_filter(torrent_list: List[TorrentInfo]) -> List[TorrentInfo]: """ 执行优先级过滤 """ return self.filter_torrents(rule_groups=rule_groups, torrent_list=torrent_list, mediainfo=mediainfo) or [] if not torrents: logger.warn(f'{keyword or mediainfo.title} 未搜索到资源') return [] # 开始新进度 progress = ProgressHelper(ProgressKey.Search) progress.start() # 开始过滤 progress.update(value=0, text=f'开始过滤,总 {len(torrents)} 个资源,请稍候...') # 匹配订阅附加参数 if filter_params: logger.info(f'开始附加参数过滤,附加参数:{filter_params} ...') torrents = [torrent for torrent in torrents if TorrentHelper().filter_torrent(torrent, filter_params)] # 开始过滤规则过滤 if rule_groups is None: # 取搜索过滤规则 rule_groups: List[str] = SystemConfigOper().get(SystemConfigKey.SearchFilterRuleGroups) if rule_groups: logger.info(f'开始过滤规则/剧集过滤,使用规则组:{rule_groups} ...') torrents = __do_filter(torrents) if not torrents: logger.warn(f'{keyword or mediainfo.title} 没有符合过滤规则的资源') return [] logger.info(f"过滤规则/剧集过滤完成,剩余 {len(torrents)} 个资源") # 过滤完成 progress.update(value=50, text=f'过滤完成,剩余 {len(torrents)} 个资源') # 总数 _total = len(torrents) # 已处理数 _count = 0 # 开始匹配 _match_torrents = [] torrenthelper = TorrentHelper() try: # 英文标题应该在别名/原标题中,不需要再匹配 logger.info(f"开始匹配结果 标题:{mediainfo.title},原标题:{mediainfo.original_title},别名:{mediainfo.names}") progress.update(value=51, text=f'开始匹配,总 {_total} 个资源 ...') for torrent in torrents: if global_vars.is_system_stopped: break _count += 1 progress.update(value=(_count / _total) * 96, text=f'正在匹配 {torrent.site_name},已完成 {_count} / {_total} ...') if not torrent.title: continue # 识别元数据 torrent_meta = MetaInfo(title=torrent.title, subtitle=torrent.description, custom_words=custom_words) if torrent.title != torrent_meta.org_string: logger.info(f"种子名称应用识别词后发生改变:{torrent.title} => {torrent_meta.org_string}") # 季集数过滤 if season_episodes \ and not TorrentHelper.match_season_episodes(torrent=torrent, meta=torrent_meta, season_episodes=season_episodes): continue # 比对IMDBID if torrent.imdbid \ and mediainfo.imdb_id \ and torrent.imdbid == mediainfo.imdb_id: logger.info(f'{mediainfo.title} 通过IMDBID匹配到资源:{torrent.site_name} - {torrent.title}') _match_torrents.append((torrent, torrent_meta)) continue # 比对种子 if torrenthelper.match_torrent(mediainfo=mediainfo, torrent_meta=torrent_meta, torrent=torrent): # 匹配成功 _match_torrents.append((torrent, torrent_meta)) continue # 匹配完成 logger.info(f"匹配完成,共匹配到 {len(_match_torrents)} 个资源") progress.update(value=97, text=f'匹配完成,共匹配到 {len(_match_torrents)} 个资源') # 去掉mediainfo中多余的数据 mediainfo.clear() # 组装上下文 contexts = [Context(torrent_info=t[0], media_info=mediainfo, meta_info=t[1]) for t in _match_torrents] finally: torrents.clear() del torrents _match_torrents.clear() del _match_torrents # 排序 progress.update(value=99, text=f'正在对 {len(contexts)} 个资源进行排序,请稍候...') contexts = torrenthelper.sort_torrents(contexts) # 结束进度 logger.info(f'搜索完成,共 {len(contexts)} 个资源') progress.update(value=100, text=f'搜索完成,共 {len(contexts)} 个资源') progress.end() # 去重后返回 return self.__remove_duplicate(contexts) @staticmethod def __remove_duplicate(_torrents: List[Context]) -> List[Context]: """ 去除重复的种子 :param _torrents: 种子列表 :return: 去重后的种子列表 """ return list({f"{t.torrent_info.site_name}_{t.torrent_info.title}_{t.torrent_info.description}": t for t in _torrents}.values()) def process(self, mediainfo: MediaInfo, keyword: Optional[str] = None, no_exists: Dict[int, Dict[int, NotExistMediaInfo]] = None, sites: List[int] = None, rule_groups: List[str] = None, area: Optional[str] = "title", custom_words: List[str] = None, filter_params: Dict[str, str] = None) -> List[Context]: """ 根据媒体信息搜索种子资源,精确匹配,应用过滤规则,同时根据no_exists过滤本地已存在的资源 :param mediainfo: 媒体信息 :param keyword: 搜索关键词 :param no_exists: 缺失的媒体信息 :param sites: 站点ID列表,为空时搜索所有站点 :param rule_groups: 过滤规则组名称列表 :param area: 搜索范围,title or imdbid :param custom_words: 自定义识别词列表 :param filter_params: 过滤参数 """ # 豆瓣标题处理 if not mediainfo.tmdb_id: meta = MetaInfo(title=mediainfo.title) mediainfo.title = meta.name mediainfo.season = meta.begin_season logger.info(f'开始搜索资源,关键词:{keyword or mediainfo.title} ...') # 补充媒体信息 if not mediainfo.names: mediainfo: MediaInfo = self.recognize_media(mtype=mediainfo.type, tmdbid=mediainfo.tmdb_id, doubanid=mediainfo.douban_id) if not mediainfo: logger.error(f'媒体信息识别失败!') return [] # 准备搜索参数 season_episodes, keywords = self.__prepare_params( mediainfo=mediainfo, keyword=keyword, no_exists=no_exists ) # 站点搜索结果 torrents: List[TorrentInfo] = [] # 站点搜索次数 search_count = 0 # 多关键字执行搜索 for search_word in keywords: # 强制休眠 1-10 秒 if search_count > 0: logger.info(f"已搜索 {search_count} 次,强制休眠 1-10 秒 ...") time.sleep(random.randint(1, 10)) # 搜索站点 results = self.__search_all_sites( mediainfo=mediainfo, keyword=search_word, sites=sites, area=area ) or [] # 合并结果 search_count += 1 torrents.extend(results) # 有结果则停止 if not settings.SEARCH_MULTIPLE_NAME and torrents: logger.info(f"共搜索到 {len(torrents)} 个资源,停止搜索") break # 处理结果 return self.__parse_result( torrents=torrents, mediainfo=mediainfo, keyword=keyword, rule_groups=rule_groups, season_episodes=season_episodes, custom_words=custom_words, filter_params=filter_params ) async def async_process(self, mediainfo: MediaInfo, keyword: Optional[str] = None, no_exists: Dict[int, Dict[int, NotExistMediaInfo]] = None, sites: List[int] = None, rule_groups: List[str] = None, area: Optional[str] = "title", custom_words: List[str] = None, filter_params: Dict[str, str] = None) -> List[Context]: """ 根据媒体信息异步搜索种子资源,精确匹配,应用过滤规则,同时根据no_exists过滤本地已存在的资源 :param mediainfo: 媒体信息 :param keyword: 搜索关键词 :param no_exists: 缺失的媒体信息 :param sites: 站点ID列表,为空时搜索所有站点 :param rule_groups: 过滤规则组名称列表 :param area: 搜索范围,title or imdbid :param custom_words: 自定义识别词列表 :param filter_params: 过滤参数 """ # 豆瓣标题处理 if not mediainfo.tmdb_id: meta = MetaInfo(title=mediainfo.title) mediainfo.title = meta.name mediainfo.season = meta.begin_season logger.info(f'开始搜索资源,关键词:{keyword or mediainfo.title} ...') # 补充媒体信息 if not mediainfo.names: mediainfo: MediaInfo = await self.async_recognize_media(mtype=mediainfo.type, tmdbid=mediainfo.tmdb_id, doubanid=mediainfo.douban_id) if not mediainfo: logger.error(f'媒体信息识别失败!') return [] # 准备搜索参数 season_episodes, keywords = self.__prepare_params( mediainfo=mediainfo, keyword=keyword, no_exists=no_exists ) # 站点搜索结果 torrents: List[TorrentInfo] = [] # 站点搜索次数 search_count = 0 # 多关键字执行搜索 for search_word in keywords: # 强制休眠 1-10 秒 if search_count > 0: logger.info(f"已搜索 {search_count} 次,强制休眠 1-10 秒 ...") await asyncio.sleep(random.randint(1, 10)) # 搜索站点 torrents.extend( await self.__async_search_all_sites( mediainfo=mediainfo, keyword=search_word, sites=sites, area=area ) or [] ) search_count += 1 # 有结果则停止 if torrents: logger.info(f"共搜索到 {len(torrents)} 个资源,停止搜索") break # 处理结果 return await run_in_threadpool(self.__parse_result, torrents=torrents, mediainfo=mediainfo, keyword=keyword, rule_groups=rule_groups, season_episodes=season_episodes, custom_words=custom_words, filter_params=filter_params ) def __search_all_sites(self, keyword: str, mediainfo: Optional[MediaInfo] = None, sites: List[int] = None, page: Optional[int] = 0, area: Optional[str] = "title") -> Optional[List[TorrentInfo]]: """ 多线程搜索多个站点 :param mediainfo: 识别的媒体信息 :param keyword: 搜索关键词 :param sites: 指定站点ID列表,如有则只搜索指定站点,否则搜索所有站点 :param page: 搜索页码 :param area: 搜索区域 title or imdbid :reutrn: 资源列表 """ # 未开启的站点不搜索 indexer_sites = [] # 配置的索引站点 if not sites: sites = SystemConfigOper().get(SystemConfigKey.IndexerSites) or [] for indexer in SitesHelper().get_indexers(): # 检查站点索引开关 if not sites or indexer.get("id") in sites: indexer_sites.append(indexer) if not indexer_sites: logger.warn('未开启任何有效站点,无法搜索资源') return [] # 开始进度 progress = ProgressHelper(ProgressKey.Search) progress.start() # 开始计时 start_time = datetime.now() # 总数 total_num = len(indexer_sites) # 完成数 finish_count = 0 # 更新进度 progress.update(value=0, text=f"开始搜索,共 {total_num} 个站点 ...") # 结果集 results = [] # 多线程 with ThreadPoolExecutor(max_workers=len(indexer_sites)) as executor: all_task = [] for site in indexer_sites: if area == "imdbid": # 搜索IMDBID task = executor.submit(self.search_torrents, site=site, keyword=mediainfo.imdb_id if mediainfo else None, mtype=mediainfo.type if mediainfo else None, page=page) else: # 搜索标题 task = executor.submit(self.search_torrents, site=site, keyword=keyword, mtype=mediainfo.type if mediainfo else None, page=page) all_task.append(task) for future in as_completed(all_task): if global_vars.is_system_stopped: break finish_count += 1 result = future.result() if result: results.extend(result) logger.info(f"站点搜索进度:{finish_count} / {total_num}") progress.update(value=finish_count / total_num * 100, text=f"正在搜索{keyword or ''},已完成 {finish_count} / {total_num} 个站点 ...") # 计算耗时 end_time = datetime.now() # 更新进度 progress.update(value=100, text=f"站点搜索完成,有效资源数:{len(results)},总耗时 {(end_time - start_time).seconds} 秒") logger.info(f"站点搜索完成,有效资源数:{len(results)},总耗时 {(end_time - start_time).seconds} 秒") # 结束进度 progress.end() # 返回 return results async def __async_search_all_sites(self, keyword: str, mediainfo: Optional[MediaInfo] = None, sites: List[int] = None, page: Optional[int] = 0, area: Optional[str] = "title") -> Optional[List[TorrentInfo]]: """ 异步搜索多个站点 :param mediainfo: 识别的媒体信息 :param keyword: 搜索关键词 :param sites: 指定站点ID列表,如有则只搜索指定站点,否则搜索所有站点 :param page: 搜索页码 :param area: 搜索区域 title or imdbid :reutrn: 资源列表 """ # 未开启的站点不搜索 indexer_sites = [] # 配置的索引站点 if not sites: sites = SystemConfigOper().get(SystemConfigKey.IndexerSites) or [] for indexer in await SitesHelper().async_get_indexers(): # 检查站点索引开关 if not sites or indexer.get("id") in sites: indexer_sites.append(indexer) if not indexer_sites: logger.warn('未开启任何有效站点,无法搜索资源') return [] # 开始进度 progress = ProgressHelper(ProgressKey.Search) progress.start() # 开始计时 start_time = datetime.now() # 总数 total_num = len(indexer_sites) # 完成数 finish_count = 0 # 更新进度 progress.update(value=0, text=f"开始搜索,共 {total_num} 个站点 ...") # 结果集 results = [] # 创建异步任务列表 tasks = [] for site in indexer_sites: if area == "imdbid": # 搜索IMDBID task = self.async_search_torrents(site=site, keyword=mediainfo.imdb_id if mediainfo else None, mtype=mediainfo.type if mediainfo else None, page=page) else: # 搜索标题 task = self.async_search_torrents(site=site, keyword=keyword, mtype=mediainfo.type if mediainfo else None, page=page) tasks.append(task) # 使用asyncio.as_completed来处理并发任务 for future in asyncio.as_completed(tasks): if global_vars.is_system_stopped: break finish_count += 1 result = await future if result: results.extend(result) logger.info(f"站点搜索进度:{finish_count} / {total_num}") progress.update(value=finish_count / total_num * 100, text=f"正在搜索{keyword or ''},已完成 {finish_count} / {total_num} 个站点 ...") # 计算耗时 end_time = datetime.now() # 更新进度 progress.update(value=100, text=f"站点搜索完成,有效资源数:{len(results)},总耗时 {(end_time - start_time).seconds} 秒") logger.info(f"站点搜索完成,有效资源数:{len(results)},总耗时 {(end_time - start_time).seconds} 秒") # 结束进度 progress.end() # 返回 return results @eventmanager.register(EventType.SiteDeleted) def remove_site(self, event: Event): """ 从搜索站点中移除与已删除站点相关的设置 """ if not event: return event_data = event.event_data or {} site_id = event_data.get("site_id") if not site_id: return if site_id == "*": # 清空搜索站点 SystemConfigOper().set(SystemConfigKey.IndexerSites, []) return # 从选中的rss站点中移除 selected_sites = SystemConfigOper().get(SystemConfigKey.IndexerSites) or [] if site_id in selected_sites: selected_sites.remove(site_id) SystemConfigOper().set(SystemConfigKey.IndexerSites, selected_sites) ================================================ FILE: app/chain/site.py ================================================ import base64 import re from datetime import datetime from typing import Optional, Tuple, Union, Dict from urllib.parse import urljoin from lxml import etree from app.chain import ChainBase from app.core.config import global_vars, settings from app.core.event import Event, eventmanager from app.db.models.site import Site from app.db.site_oper import SiteOper from app.db.systemconfig_oper import SystemConfigOper from app.helper.browser import PlaywrightHelper from app.helper.cloudflare import under_challenge from app.helper.cookie import CookieHelper from app.helper.cookiecloud import CookieCloudHelper from app.helper.rss import RssHelper from app.helper.sites import SitesHelper # noqa from app.log import logger from app.schemas import MessageChannel, Notification, SiteUserData from app.schemas.types import EventType, NotificationType from app.utils.http import RequestUtils from app.utils.site import SiteUtils from app.utils.string import StringUtils class SiteChain(ChainBase): """ 站点管理处理链 """ def __init__(self): super().__init__() # 特殊站点登录验证 self.special_site_test = { "zhuque.in": self.__zhuque_test, "m-team.io": self.__mteam_test, "m-team.cc": self.__mteam_test, "ptlsp.com": self.__indexphp_test, "1ptba.com": self.__indexphp_test, "star-space.net": self.__indexphp_test, "yemapt.org": self.__yema_test, "hddolby.com": self.__hddolby_test, "rousi.pro": self.__rousi_test, } def refresh_userdata(self, site: dict = None) -> Optional[SiteUserData]: """ 刷新站点的用户数据 :param site: 站点 :return: 用户数据 """ userdata: SiteUserData = self.run_module("refresh_userdata", site=site) if userdata: SiteOper().update_userdata(domain=StringUtils.get_url_domain(site.get("domain")), name=site.get("name"), payload=userdata.model_dump()) # 发送事件 eventmanager.send_event(EventType.SiteRefreshed, { "site_id": site.get("id") }) # 发送站点消息 if userdata.message_unread: if userdata.message_unread_contents and len(userdata.message_unread_contents) > 0: for head, date, content in userdata.message_unread_contents: msg_title = f"【站点 {site.get('name')} 消息】" msg_text = f"时间:{date}\n标题:{head}\n内容:\n{content}" self.post_message(Notification( mtype=NotificationType.SiteMessage, title=msg_title, text=msg_text, link=site.get("url") )) else: self.post_message(Notification( mtype=NotificationType.SiteMessage, title=f"站点 {site.get('name')} 收到 " f"{userdata.message_unread} 条新消息,请登陆查看", link=site.get("url") )) # 低分享率警告 if userdata.ratio and float(userdata.ratio) < 1 and not bool( re.search(r"(贵宾|VIP?)", userdata.user_level or "", re.IGNORECASE)): self.post_message(Notification( mtype=NotificationType.SiteMessage, title=f"【站点分享率低预警】", text=f"站点 {site.get('name')} 分享率 {userdata.ratio},请注意!" )) return userdata def refresh_userdatas(self) -> Optional[Dict[str, SiteUserData]]: """ 刷新所有站点的用户数据 """ any_site_updated = False result = {} for site in SitesHelper().get_indexers(): if global_vars.is_system_stopped: return None if site.get("is_active"): userdata = self.refresh_userdata(site) if userdata: any_site_updated = True result[site.get("name")] = userdata if any_site_updated: eventmanager.send_event(EventType.SiteRefreshed, { "site_id": "*" }) return result def is_special_site(self, domain: str) -> bool: """ 判断是否特殊站点 """ return domain in self.special_site_test @staticmethod def __zhuque_test(site: Site) -> Tuple[bool, str]: """ 判断站点是否已经登陆:zhuique """ # 获取token token = None user_agent = site.ua or settings.USER_AGENT res = RequestUtils( ua=user_agent, cookies=site.cookie, proxies=settings.PROXY if site.proxy else None, timeout=site.timeout or 15 ).get_res(url=site.url) if res is None: return False, "无法打开网站!" if res.status_code == 200: csrf_token = re.search(r'', res.text) if csrf_token: token = csrf_token.group(1) else: return False, f"错误:{res.status_code} {res.reason}" if not token: return False, "无法获取Token" # 调用查询用户信息接口 user_res = RequestUtils( headers={ 'X-CSRF-TOKEN': token, "Content-Type": "application/json; charset=utf-8", "User-Agent": f"{user_agent}" }, cookies=site.cookie, proxies=settings.PROXY if site.proxy else None, timeout=site.timeout or 15 ).get_res(url=f"{site.url}api/user/getInfo") if user_res is None: return False, "无法打开网站!" if user_res.status_code == 200: user_info = user_res.json() if user_info and user_info.get("data"): return True, "连接成功" return False, "Cookie已失效" else: return False, f"错误:{user_res.status_code} {user_res.reason}" @staticmethod def __mteam_test(site: Site) -> Tuple[bool, str]: """ 判断站点是否已经登陆:m-team """ user_agent = site.ua or settings.USER_AGENT domain = StringUtils.get_url_domain(site.url) url = f"https://api.{domain}/api/member/profile" headers = { "User-Agent": user_agent, "Accept": "application/json, text/plain, */*", "x-api-key": site.apikey, } res = RequestUtils( headers=headers, proxies=settings.PROXY if site.proxy else None, timeout=site.timeout or 15 ).post_res(url=url) if res is None: return False, "无法打开网站!" if res.status_code == 200: user_info = res.json() or {} if user_info.get("data"): return True, "连接成功" return False, user_info.get("message", "鉴权已过期或无效") else: return False, f"错误:{res.status_code} {res.reason}" @staticmethod def __yema_test(site: Site) -> Tuple[bool, str]: """ 判断站点是否已经登陆:yemapt """ user_agent = site.ua or settings.USER_AGENT url = f"{site.url}api/consumer/fetchSelfDetail" headers = { "User-Agent": user_agent, "Content-Type": "application/json", "Accept": "application/json, text/plain, */*", } res = RequestUtils( headers=headers, cookies=site.cookie, proxies=settings.PROXY if site.proxy else None, timeout=site.timeout or 15 ).get_res(url=url) if res is None: return False, "无法打开网站!" if res.status_code == 200: user_info = res.json() if user_info and user_info.get("success"): return True, "连接成功" return False, "Cookie已过期" else: return False, f"错误:{res.status_code} {res.reason}" def __indexphp_test(self, site: Site) -> Tuple[bool, str]: """ 判断站点是否已经登陆:ptlsp/1ptba """ site.url = f"{site.url}index.php" return self.__test(site) @staticmethod def __hddolby_test(site: Site) -> Tuple[bool, str]: """ 判断站点是否已经登陆:hddolby """ url = f"{site.url}api/v1/user/data" headers = { "Content-Type": "application/json", "Accept": "application/json, text/plain, */*", "x-api-key": site.apikey, } res = RequestUtils( headers=headers, proxies=settings.PROXY if site.proxy else None, timeout=site.timeout or 15 ).get_res(url=url) if res is None: return False, "无法打开网站!" if res.status_code == 200: user_info = res.json() if user_info and user_info.get("status") == 0: return True, "连接成功" return False, "APIKEY已过期" else: return False, f"错误:{res.status_code} {res.reason}" @staticmethod def __rousi_test(site: Site) -> Tuple[bool, str]: """ 判断站点是否已经登陆:rousi """ url = f"https://{StringUtils.get_url_domain(site.url)}/api/v1/profile" headers = { "Content-Type": "application/json", "Accept": "application/json", "Authorization": f"Bearer {site.apikey}", } res = RequestUtils( headers=headers, proxies=settings.PROXY if site.proxy else None, timeout=site.timeout or 15 ).get_res(url=url) if res is None: return False, "无法打开网站!" if res.status_code == 200: user_info = res.json() if user_info and user_info.get("code") == 0: return True, "连接成功" return False, "APIKEY已过期" else: return False, f"错误:{res.status_code} {res.reason}" @staticmethod def __parse_favicon(url: str, cookie: str, ua: str) -> Tuple[str, Optional[str]]: """ 解析站点favicon,返回base64 fav图标 :param url: 站点地址 :param cookie: Cookie :param ua: User-Agent :return: """ favicon_url = urljoin(url, "favicon.ico") res = RequestUtils(cookies=cookie, timeout=30, ua=ua).get_res(url=url) if res: html_text = res.text else: logger.error(f"获取站点页面失败:{url}") return favicon_url, None html = etree.HTML(html_text) try: if StringUtils.is_valid_html_element(html): fav_link = html.xpath('//head/link[contains(@rel, "icon")]/@href') if fav_link: favicon_url = urljoin(url, fav_link[0]) res = RequestUtils(cookies=cookie, timeout=15, ua=ua).get_res(url=favicon_url) if res: return favicon_url, base64.b64encode(res.content).decode() else: logger.error(f"获取站点图标失败:{favicon_url}") finally: if html is not None: del html return favicon_url, None def sync_cookies(self, manual=False) -> Tuple[bool, str]: """ 通过CookieCloud同步站点Cookie """ def __indexer_domain(inx: dict, sub_domain: str) -> str: """ 根据主域名获取索引器地址 """ if StringUtils.get_url_domain(inx.get("domain")) == sub_domain: return inx.get("domain") for ext_d in inx.get("ext_domains", []): if StringUtils.get_url_domain(ext_d) == sub_domain: return ext_d return sub_domain logger.info("开始同步CookieCloud站点 ...") cookies, msg = CookieCloudHelper().download() if not cookies: logger.error(f"CookieCloud同步失败:{msg}") if manual: self.messagehelper.put(msg, title="CookieCloud同步失败", role="system") return False, msg # 保存Cookie或新增站点 _update_count = 0 _add_count = 0 _fail_count = 0 siteshelper = SitesHelper() siteoper = SiteOper() rsshelper = RssHelper() for domain, cookie in cookies.items(): # 检查系统是否停止 if global_vars.is_system_stopped: logger.info("系统正在停止,中断CookieCloud同步") return False, "系统正在停止,同步被中断" # 索引器信息 indexer = siteshelper.get_indexer(domain) # 数据库的站点信息 site_info = siteoper.get_by_domain(domain) if site_info and site_info.is_active: # 站点已存在,检查站点连通性 status, msg = self.test(domain) # 更新站点Cookie if status: logger.info(f"站点【{site_info.name}】连通性正常,不同步CookieCloud数据") # 更新站点rss地址 if not site_info.public and not site_info.rss: # 自动生成rss地址 rss_url, errmsg = rsshelper.get_rss_link( url=site_info.url, cookie=cookie, ua=site_info.ua or settings.USER_AGENT, proxy=True if site_info.proxy else False, timeout=site_info.timeout or 15 ) if rss_url: logger.info(f"更新站点 {domain} RSS地址 ...") siteoper.update_rss(domain=domain, rss=rss_url) else: logger.warn(errmsg) continue # 更新站点Cookie logger.info(f"更新站点 {domain} Cookie ...") siteoper.update_cookie(domain=domain, cookies=cookie) _update_count += 1 elif indexer: if settings.COOKIECLOUD_BLACKLIST and any( StringUtils.get_url_domain(domain) == StringUtils.get_url_domain(black_domain) for black_domain in str(settings.COOKIECLOUD_BLACKLIST).split(",")): logger.warn(f"站点 {domain} 已在黑名单中,不添加站点") continue # 新增站点 domain_url = __indexer_domain(inx=indexer, sub_domain=domain) proxy = False res = RequestUtils(cookies=cookie, ua=settings.USER_AGENT ).get_res(url=domain_url) if res and res.status_code in [200, 500, 403]: content = res.text if not indexer.get("public") and not SiteUtils.is_logged_in(content): _fail_count += 1 if under_challenge(content): logger.warn(f"站点 {indexer.get('name')} 被Cloudflare防护,无法登录,无法添加站点") continue logger.warn( f"站点 {indexer.get('name')} 登录失败,没有该站点账号或Cookie已失效,无法添加站点") continue elif res is not None: _fail_count += 1 logger.warn(f"站点 {indexer.get('name')} 连接状态码:{res.status_code},无法添加站点") continue else: if not settings.PROXY_HOST: _fail_count += 1 logger.warn(f"站点 {indexer.get('name')} 连接失败,无法添加站点") continue else: # 如果配置了代理,尝试通过代理重试 logger.info(f"站点 {indexer.get('name')} 初次连接失败,尝试通过代理重试...") proxy = True res = RequestUtils(cookies=cookie, ua=settings.USER_AGENT, proxies=settings.PROXY ).get_res(url=domain_url) if res and res.status_code in [200, 500, 403]: if not indexer.get("public") and not SiteUtils.is_logged_in(res.text): logger.warn(f"站点 {indexer.get('name')} 登录失败,即使通过代理,无法添加站点") _fail_count += 1 continue logger.info(f"站点 {indexer.get('name')} 通过代理连接成功") else: logger.warn(f"站点 {indexer.get('name')} 通过代理连接失败,无法添加站点") _fail_count += 1 continue # 获取rss地址 rss_url = None if not indexer.get("public") and domain_url: # 自动生成rss地址 rss_url, errmsg = rsshelper.get_rss_link(url=domain_url, cookie=cookie, ua=settings.USER_AGENT, proxy=proxy) if errmsg: logger.warn(errmsg) # 插入数据库 logger.info(f"新增站点 {indexer.get('name')} ...") siteoper.add(name=indexer.get("name"), url=domain_url, domain=domain, cookie=cookie, rss=rss_url, proxy=1 if proxy else 0, public=1 if indexer.get("public") else 0) _add_count += 1 # 通知站点更新 if indexer: eventmanager.send_event(EventType.SiteUpdated, { "domain": domain, }) # 处理完成 ret_msg = f"更新了{_update_count}个站点,新增了{_add_count}个站点" if _fail_count > 0: ret_msg += f",{_fail_count}个站点添加失败,下次同步时将重试,也可以手动添加" if manual: self.messagehelper.put(ret_msg, title="CookieCloud同步成功", role="system") logger.info(f"CookieCloud同步成功:{ret_msg}") return True, ret_msg @eventmanager.register(EventType.SiteUpdated) def cache_site_icon(self, event: Event): """ 缓存站点图标 """ if not event: return event_data = event.event_data or {} # 主域名 domain = event_data.get("domain") if not domain: return if str(domain).startswith("http"): domain = StringUtils.get_url_domain(domain) # 站点信息 siteoper = SiteOper() siteshelper = SitesHelper() siteinfo = siteoper.get_by_domain(domain) if not siteinfo: logger.warn(f"未维护站点 {domain} 信息!") return # Cookie cookie = siteinfo.cookie # 索引器 indexer = siteshelper.get_indexer(domain) if not indexer: logger.warn(f"站点 {domain} 索引器不存在!") return # 查询站点图标 logger.info(f"开始缓存站点 {indexer.get('name')} 图标 ...") icon_url, icon_base64 = self.__parse_favicon(url=indexer.get("domain"), cookie=cookie, ua=settings.USER_AGENT) if icon_url: siteoper.update_icon(name=indexer.get("name"), domain=domain, icon_url=icon_url, icon_base64=icon_base64) logger.info(f"缓存站点 {indexer.get('name')} 图标成功") else: logger.warn(f"缓存站点 {indexer.get('name')} 图标失败") @eventmanager.register(EventType.SiteUpdated) def clear_site_data(self, event: Event): """ 清理站点数据 """ if not event: return event_data = event.event_data or {} # 主域名 domain = event_data.get("domain") if not domain: return # 获取主域名中间那段 domain_host = StringUtils.get_url_host(domain) # 查询以"site.domain_host"开头的配置项,并清除 systemconfig = SystemConfigOper() site_keys = systemconfig.all().keys() for key in site_keys: if key.startswith(f"site.{domain_host}"): logger.info(f"清理站点配置:{key}") systemconfig.delete(key) @eventmanager.register(EventType.SiteUpdated) def cache_site_userdata(self, event: Event): """ 缓存站点用户数据 """ if not event: return event_data = event.event_data or {} # 主域名 domain = event_data.get("domain") if not domain: return if str(domain).startswith("http"): domain = StringUtils.get_url_domain(domain) indexer = SitesHelper().get_indexer(domain) if not indexer: return # 刷新站点用户数据 self.refresh_userdata(site=indexer) or {} def test(self, url: str) -> Tuple[bool, str]: """ 测试站点是否可用 :param url: 站点域名 :return: (是否可用, 错误信息) """ # 检查域名是否可用 domain = StringUtils.get_url_domain(url) siteoper = SiteOper() site_info = siteoper.get_by_domain(domain) if not site_info: return False, f"站点【{url}】不存在" # 模拟登录 try: # 开始记时 start_time = datetime.now() # 特殊站点测试 if self.special_site_test.get(domain): state, message = self.special_site_test[domain](site_info) else: # 通用站点测试 state, message = self.__test(site_info) # 统计 seconds = (datetime.now() - start_time).seconds if state: siteoper.success(domain=domain, seconds=seconds) else: siteoper.fail(domain) return state, message except Exception as e: return False, f"{str(e)}!" @staticmethod def __test(site_info: Site) -> Tuple[bool, str]: """ 通用站点测试 """ site_url = site_info.url site_cookie = site_info.cookie ua = site_info.ua or settings.USER_AGENT render = site_info.render public = site_info.public proxies = settings.PROXY if site_info.proxy else None proxy_server = settings.PROXY_SERVER if site_info.proxy else None timeout = site_info.timeout or 60 # 访问链接 if render: page_source = PlaywrightHelper().get_page_source(url=site_url, cookies=site_cookie, ua=ua, proxies=proxy_server, timeout=timeout) if not public and not SiteUtils.is_logged_in(page_source): if under_challenge(page_source): return False, f"无法通过Cloudflare!" return False, f"仿真登录失败,Cookie已失效!" else: res = RequestUtils(cookies=site_cookie, ua=ua, proxies=proxies ).get_res(url=site_url) # 判断登录状态 if res and res.status_code in [200, 500, 403]: content = res.text if not public and not SiteUtils.is_logged_in(content): if under_challenge(content): msg = "站点被Cloudflare防护,请打开站点浏览器仿真" elif res.status_code == 200: msg = "Cookie已失效" else: msg = f"错误:{res.status_code} {res.reason}" return False, f"{msg}!" elif public and res.status_code != 200: return False, f"错误:{res.status_code} {res.reason}!" elif res is not None: return False, f"错误:{res.status_code} {res.reason}!" else: return False, f"无法打开网站!" return True, "连接成功" def remote_list(self, channel: MessageChannel, userid: Union[str, int] = None, source: Optional[str] = None): """ 查询所有站点,发送消息 """ site_list = SiteOper().list() if not site_list: self.post_message(Notification( channel=channel, title="没有维护任何站点信息!", userid=userid, link=settings.MP_DOMAIN('#/site'))) title = f"共有 {len(site_list)} 个站点,回复对应指令操作:" \ f"\n- 禁用站点:/site_disable [id]" \ f"\n- 启用站点:/site_enable [id]" \ f"\n- 更新站点Cookie:/site_cookie [id] [username] [password] [2fa_code/secret]" messages = [] for site in site_list: if site.render: render_str = "🧭" else: render_str = "" if site.is_active: messages.append(f"{site.id}. {site.name} {render_str}") else: messages.append(f"{site.id}. {site.name} ⚠️") # 发送列表 self.post_message(Notification( channel=channel, source=source, title=title, text="\n".join(messages), userid=userid, link=settings.MP_DOMAIN('#/site')) ) def remote_disable(self, arg_str: str, channel: MessageChannel, userid: Union[str, int] = None, source: Optional[str] = None): """ 禁用站点 """ if not arg_str: return arg_str = str(arg_str).strip() if not arg_str.isdigit(): return site_id = int(arg_str) siteoper = SiteOper() site = siteoper.get(site_id) if not site: self.post_message(Notification( channel=channel, title=f"站点编号 {site_id} 不存在!", userid=userid)) return # 禁用站点 siteoper.update(site_id, { "is_active": False }) # 重新发送消息 self.remote_list(channel=channel, userid=userid, source=source) def remote_enable(self, arg_str: str, channel: MessageChannel, userid: Union[str, int] = None, source: Optional[str] = None): """ 启用站点 """ if not arg_str: return arg_strs = str(arg_str).split() siteoper = SiteOper() for arg_str in arg_strs: arg_str = arg_str.strip() if not arg_str.isdigit(): continue site_id = int(arg_str) site = siteoper.get(site_id) if not site: self.post_message(Notification( channel=channel, title=f"站点编号 {site_id} 不存在!", userid=userid)) return # 禁用站点 siteoper.update(site_id, { "is_active": True }) # 重新发送消息 self.remote_list(channel=channel, userid=userid, source=source) @staticmethod def update_cookie(site_info: Site, username: str, password: str, two_step_code: Optional[str] = None) -> Tuple[bool, str]: """ 根据用户名密码更新站点Cookie :param site_info: 站点信息 :param username: 用户名 :param password: 密码 :param two_step_code: 二步验证码或密钥 :return: (是否成功, 错误信息) """ # 更新站点Cookie result = CookieHelper().get_site_cookie_ua( url=site_info.url, username=username, password=password, two_step_code=two_step_code, proxies=settings.PROXY_SERVER if site_info.proxy else None, timeout=site_info.timeout or 60 ) if result: cookie, ua, msg = result if not cookie: return False, msg SiteOper().update(site_info.id, { "cookie": cookie, "ua": ua }) return True, msg return False, "未知错误" def remote_cookie(self, arg_str: str, channel: MessageChannel, userid: Union[str, int] = None, source: Optional[str] = None): """ 使用用户名密码更新站点Cookie """ err_title = "请输入正确的命令格式:/site_cookie [id] [username] [password] [2fa_code/secret]," \ "[id]为站点编号,[uername]为站点用户名,[password]为站点密码,[2fa_code/secret]为站点二步验证码或密钥" if not arg_str: self.post_message(Notification( channel=channel, source=source, title=err_title, userid=userid)) return arg_str = str(arg_str).strip() args = arg_str.split() # 二步验证码 two_step_code = None if len(args) == 4: two_step_code = args[3] elif len(args) != 3: self.post_message(Notification( channel=channel, source=source, title=err_title, userid=userid)) return site_id = args[0] if not site_id.isdigit(): self.post_message(Notification( channel=channel, source=source, title=err_title, userid=userid)) return # 站点ID site_id = int(site_id) # 站点信息 site_info = SiteOper().get(site_id) if not site_info: self.post_message(Notification( channel=channel, source=source, title=f"站点编号 {site_id} 不存在!", userid=userid)) return self.post_message(Notification( channel=channel, source=source, title=f"开始更新【{site_info.name}】Cookie&UA ...", userid=userid)) # 用户名 username = args[1] # 密码 password = args[2] # 更新Cookie status, msg = self.update_cookie(site_info=site_info, username=username, password=password, two_step_code=two_step_code) if not status: logger.error(msg) self.post_message(Notification( channel=channel, source=source, title=f"【{site_info.name}】 Cookie&UA更新失败!", text=f"错误原因:{msg}", userid=userid)) else: self.post_message(Notification( channel=channel, source=source, title=f"【{site_info.name}】 Cookie&UA更新成功", userid=userid)) def remote_refresh_userdatas(self, channel: MessageChannel, userid: Union[str, int] = None, source: Optional[str] = None): """ 刷新所有站点用户数据 """ logger.info("收到命令,开始刷新站点数据 ...") self.post_message(Notification( channel=channel, source=source, title="开始刷新站点数据 ...", userid=userid )) # 刷新站点数据 site_datas = self.refresh_userdatas() if site_datas: # 发送消息 messages = {} # 总上传 incUploads = 0 # 总下载 incDownloads = 0 # 今天日期 today_date = datetime.now().strftime("%Y-%m-%d") for rand, site in enumerate(site_datas.keys()): upload = int(site_datas[site].upload or 0) download = int(site_datas[site].download or 0) updated_date = site_datas[site].updated_day if updated_date and updated_date != today_date: updated_date = f"({updated_date})" else: updated_date = "" if upload > 0 or download > 0: incUploads += upload incDownloads += download messages[upload + (rand / 1000)] = ( f"【{site}】{updated_date}\n" + f"上传量:{StringUtils.str_filesize(upload)}\n" + f"下载量:{StringUtils.str_filesize(download)}\n" + "————————————" ) if incDownloads or incUploads: sorted_messages = [messages[key] for key in sorted(messages.keys(), reverse=True)] sorted_messages.insert(0, f"【汇总】\n" f"总上传:{StringUtils.str_filesize(incUploads)}\n" f"总下载:{StringUtils.str_filesize(incDownloads)}\n" f"————————————") self.post_message(Notification( channel=channel, source=source, title="【站点数据统计】", text="\n".join(sorted_messages), userid=userid )) else: self.post_message(Notification( channel=channel, source=source, title="没有刷新到任何站点数据!", userid=userid )) ================================================ FILE: app/chain/storage.py ================================================ from pathlib import Path from typing import Optional, Tuple, List, Dict from app import schemas from app.chain import ChainBase from app.core.config import settings from app.helper.directory import DirectoryHelper from app.log import logger class StorageChain(ChainBase): """ 存储处理链 """ def save_config(self, storage: str, conf: dict) -> None: """ 保存存储配置 """ self.run_module("save_config", storage=storage, conf=conf) def reset_config(self, storage: str) -> None: """ 重置存储配置 """ self.run_module("reset_config", storage=storage) def generate_qrcode(self, storage: str) -> Optional[Tuple[dict, str]]: """ 生成二维码 """ return self.run_module("generate_qrcode", storage=storage) def generate_auth_url(self, storage: str) -> Optional[Tuple[dict, str]]: """ 生成 OAuth2 授权 URL """ return self.run_module("generate_auth_url", storage=storage) def check_login(self, storage: str, **kwargs) -> Optional[Tuple[dict, str]]: """ 登录确认 """ return self.run_module("check_login", storage=storage, **kwargs) def list_files(self, fileitem: schemas.FileItem, recursion: bool = False) -> Optional[List[schemas.FileItem]]: """ 查询当前目录下所有目录和文件 """ return self.run_module("list_files", fileitem=fileitem, recursion=recursion) def any_files(self, fileitem: schemas.FileItem, extensions: list = None) -> Optional[bool]: """ 查询当前目录下是否存在指定扩展名任意文件 """ return self.run_module("any_files", fileitem=fileitem, extensions=extensions) def create_folder(self, fileitem: schemas.FileItem, name: str) -> Optional[schemas.FileItem]: """ 创建目录 """ return self.run_module("create_folder", fileitem=fileitem, name=name) def download_file(self, fileitem: schemas.FileItem, path: Path = None) -> Optional[Path]: """ 下载文件 :param fileitem: 文件项 :param path: 本地保存路径 """ return self.run_module("download_file", fileitem=fileitem, path=path) def upload_file(self, fileitem: schemas.FileItem, path: Path, new_name: Optional[str] = None) -> Optional[schemas.FileItem]: """ 上传文件 :param fileitem: 保存目录项 :param path: 本地文件路径 :param new_name: 新文件名 """ return self.run_module("upload_file", fileitem=fileitem, path=path, new_name=new_name) def delete_file(self, fileitem: schemas.FileItem) -> Optional[bool]: """ 删除文件或目录 """ return self.run_module("delete_file", fileitem=fileitem) def rename_file(self, fileitem: schemas.FileItem, name: str) -> Optional[bool]: """ 重命名文件或目录 """ return self.run_module("rename_file", fileitem=fileitem, name=name) def exists(self, fileitem: schemas.FileItem) -> Optional[bool]: """ 判断文件或目录是否存在 """ return True if self.get_item(fileitem) else False def get_item(self, fileitem: schemas.FileItem) -> Optional[schemas.FileItem]: """ 查询目录或文件 """ return self.get_file_item(storage=fileitem.storage, path=Path(fileitem.path)) def get_file_item(self, storage: str, path: Path) -> Optional[schemas.FileItem]: """ 根据路径获取文件项 """ return self.run_module("get_file_item", storage=storage, path=path) def get_parent_item(self, fileitem: schemas.FileItem) -> Optional[schemas.FileItem]: """ 获取上级目录项 """ return self.run_module("get_parent_item", fileitem=fileitem) def snapshot_storage(self, storage: str, path: Path, last_snapshot_time: float = None, max_depth: int = 5) -> Optional[Dict[str, Dict]]: """ 快照存储 :param storage: 存储类型 :param path: 路径 :param last_snapshot_time: 上次快照时间,用于增量快照 :param max_depth: 最大递归深度,避免过深遍历 """ return self.run_module("snapshot_storage", storage=storage, path=path, last_snapshot_time=last_snapshot_time, max_depth=max_depth) def storage_usage(self, storage: str) -> Optional[schemas.StorageUsage]: """ 存储使用情况 """ return self.run_module("storage_usage", storage=storage) def support_transtype(self, storage: str) -> Optional[dict]: """ 获取支持的整理方式 """ return self.run_module("support_transtype", storage=storage) def is_bluray_folder(self, fileitem: Optional[schemas.FileItem]) -> bool: """ 检查是否蓝光目录 """ if not fileitem or fileitem.type != "dir": return False if self.get_file_item(storage=fileitem.storage, path=Path(fileitem.path) / "BDMV"): return True if self.get_file_item(storage=fileitem.storage, path=Path(fileitem.path) / "CERTIFICATE"): return True return False @staticmethod def contains_bluray_subdirectories(fileitems: Optional[List[schemas.FileItem]]) -> bool: """ 判断是否包含蓝光必备的文件夹 """ required_files = {"BDMV", "CERTIFICATE"} return any( item.type == "dir" and item.name in required_files for item in fileitems or [] ) def delete_media_file(self, fileitem: schemas.FileItem, delete_self: bool = True) -> bool: """ 删除媒体文件,以及不含媒体文件的目录 """ media_exts = settings.RMT_MEDIAEXT + settings.DOWNLOAD_TMPEXT + settings.RMT_SUBEXT + settings.RMT_AUDIOEXT fileitem_path = Path(fileitem.path) if fileitem.path else Path("") if len(fileitem_path.parts) <= 2: logger.warn(f"【{fileitem.storage}】{fileitem.path} 根目录或一级目录不允许删除") return False if fileitem.type == "dir": # 本身是目录 if self.is_bluray_folder(fileitem): logger.warn(f"正在删除蓝光原盘目录:【{fileitem.storage}】{fileitem.path}") if not self.delete_file(fileitem): logger.warn(f"【{fileitem.storage}】{fileitem.path} 删除失败") return False elif delete_self: # 本身是文件,需要删除文件 logger.warn(f"正在删除文件【{fileitem.storage}】{fileitem.path}") if not self.delete_file(fileitem): logger.warn(f"【{fileitem.storage}】{fileitem.path} 删除失败") return False # 检查和删除上级空目录 dir_item = fileitem if fileitem.type == "dir" else self.get_parent_item(fileitem) if not dir_item: logger.warn(f"【{fileitem.storage}】{fileitem.path} 上级目录不存在") return True # 查找操作文件项匹配的配置目录(资源目录、媒体库目录) associated_dir = max( ( Path(p) for d in DirectoryHelper().get_dirs() for p in (d.download_path, d.library_path) if p and fileitem_path.is_relative_to(p) ), key=lambda path: len(path.parts), default=None, ) while dir_item and len(Path(dir_item.path).parts) > 2: # 目录是资源目录、媒体库目录的上级,则不处理 if associated_dir and associated_dir.is_relative_to(Path(dir_item.path)): logger.debug(f"【{dir_item.storage}】{dir_item.path} 位于资源或媒体库目录结构中,不删除") break elif not associated_dir and self.list_files(dir_item, recursion=False): logger.debug(f"【{dir_item.storage}】{dir_item.path} 不是空目录,不删除") break if self.any_files(dir_item, extensions=media_exts) is not False: logger.debug(f"【{dir_item.storage}】{dir_item.path} 存在媒体文件,不删除") break # 删除空目录并继续处理父目录 logger.warn(f"【{dir_item.storage}】{dir_item.path} 不存在其它媒体文件,正在删除空目录") if not self.delete_file(dir_item): logger.warn(f"【{dir_item.storage}】{dir_item.path} 删除失败") return False dir_item = self.get_parent_item(dir_item) return True ================================================ FILE: app/chain/subscribe.py ================================================ import copy import json import random import threading import time from datetime import datetime from typing import Dict, List, Optional, Union, Tuple from app import schemas from app.chain import ChainBase from app.chain.download import DownloadChain from app.chain.media import MediaChain from app.chain.search import SearchChain from app.chain.tmdb import TmdbChain from app.chain.torrents import TorrentsChain from app.core.config import settings, global_vars from app.core.context import TorrentInfo, Context, MediaInfo from app.core.event import eventmanager, Event from app.core.meta import MetaBase from app.core.meta.words import WordsMatcher from app.core.metainfo import MetaInfo from app.db.downloadhistory_oper import DownloadHistoryOper from app.db.models.subscribe import Subscribe from app.db.site_oper import SiteOper from app.db.subscribe_oper import SubscribeOper from app.db.systemconfig_oper import SystemConfigOper from app.helper.subscribe import SubscribeHelper from app.helper.torrent import TorrentHelper from app.log import logger from app.schemas import MediaRecognizeConvertEventData from app.schemas.types import MediaType, SystemConfigKey, MessageChannel, NotificationType, EventType, ChainEventType, \ ContentType class SubscribeChain(ChainBase): """ 订阅管理处理链 """ _rlock = threading.RLock() # 避免莫名原因导致长时间持有锁 _LOCK_TIMOUT = 3600 * 2 @staticmethod def __get_event_media(_mediaid: str, _meta: MetaBase) -> Optional[MediaInfo]: """ 广播事件解析媒体信息 """ event_data = MediaRecognizeConvertEventData( mediaid=_mediaid, convert_type=settings.RECOGNIZE_SOURCE ) event = eventmanager.send_event(ChainEventType.MediaRecognizeConvert, event_data) # 使用事件返回的上下文数据 if event and event.event_data: event_data: MediaRecognizeConvertEventData = event.event_data if event_data.media_dict: mediachain = MediaChain() new_id = event_data.media_dict.get("id") if event_data.convert_type == "themoviedb": return mediachain.recognize_media(meta=_meta, tmdbid=new_id) elif event_data.convert_type == "douban": return mediachain.recognize_media(meta=_meta, doubanid=new_id) return None @staticmethod async def __async_get_event_meida(_mediaid: str, _meta: MetaBase) -> Optional[MediaInfo]: """ 广播事件解析媒体信息 """ event_data = MediaRecognizeConvertEventData( mediaid=_mediaid, convert_type=settings.RECOGNIZE_SOURCE ) event = await eventmanager.async_send_event(ChainEventType.MediaRecognizeConvert, event_data) # 使用事件返回的上下文数据 if event and event.event_data: event_data: MediaRecognizeConvertEventData = event.event_data if event_data.media_dict: mediachain = MediaChain() new_id = event_data.media_dict.get("id") if event_data.convert_type == "themoviedb": return await mediachain.async_recognize_media(meta=_meta, tmdbid=new_id) elif event_data.convert_type == "douban": return await mediachain.async_recognize_media(meta=_meta, doubanid=new_id) return None def __get_default_kwargs(self, mtype: MediaType, **kwargs) -> dict: """ 获取订阅默认配置 :param mtype: 媒体类型 :param key: 配置键 :return: 配置值 """ return { 'quality': self.__get_default_subscribe_config(mtype, "quality") if not kwargs.get( "quality") else kwargs.get("quality"), 'resolution': self.__get_default_subscribe_config(mtype, "resolution") if not kwargs.get( "resolution") else kwargs.get("resolution"), 'effect': self.__get_default_subscribe_config(mtype, "effect") if not kwargs.get( "effect") else kwargs.get("effect"), 'include': self.__get_default_subscribe_config(mtype, "include") if not kwargs.get( "include") else kwargs.get("include"), 'exclude': self.__get_default_subscribe_config(mtype, "exclude") if not kwargs.get( "exclude") else kwargs.get("exclude"), 'best_version': self.__get_default_subscribe_config(mtype, "best_version") if not kwargs.get( "best_version") else kwargs.get("best_version"), 'search_imdbid': self.__get_default_subscribe_config(mtype, "search_imdbid") if not kwargs.get( "search_imdbid") else kwargs.get("search_imdbid"), 'sites': self.__get_default_subscribe_config(mtype, "sites") or None if not kwargs.get( "sites") else kwargs.get("sites"), 'downloader': self.__get_default_subscribe_config(mtype, "downloader") if not kwargs.get( "downloader") else kwargs.get("downloader"), 'save_path': self.__get_default_subscribe_config(mtype, "save_path") if not kwargs.get( "save_path") else kwargs.get("save_path"), 'filter_groups': self.__get_default_subscribe_config(mtype, "filter_groups") if not kwargs.get( "filter_groups") else kwargs.get("filter_groups") } def add(self, title: str, year: str, mtype: MediaType = None, tmdbid: Optional[int] = None, doubanid: Optional[str] = None, bangumiid: Optional[int] = None, mediaid: Optional[str] = None, episode_group: Optional[str] = None, season: Optional[int] = None, channel: MessageChannel = None, source: Optional[str] = None, userid: Optional[str] = None, username: Optional[str] = None, message: Optional[bool] = True, exist_ok: Optional[bool] = False, **kwargs) -> Tuple[Optional[int], str]: """ 识别媒体信息并添加订阅 """ logger.info(f'开始添加订阅,标题:{title} ...') mediainfo = None metainfo = MetaInfo(title) if year: metainfo.year = year if mtype: metainfo.type = mtype if season is not None: metainfo.type = MediaType.TV metainfo.begin_season = season # 识别媒体信息 if settings.RECOGNIZE_SOURCE == "themoviedb": # TMDB识别模式 if not tmdbid: if doubanid: # 将豆瓣信息转换为TMDB信息 tmdbinfo = MediaChain().get_tmdbinfo_by_doubanid(doubanid=doubanid, mtype=mtype) if tmdbinfo: mediainfo = MediaInfo(tmdb_info=tmdbinfo) elif mediaid: # 未知前缀,广播事件解析媒体信息 mediainfo = self.__get_event_media(mediaid, metainfo) else: # 使用TMDBID识别 mediainfo = self.recognize_media(meta=metainfo, mtype=mtype, tmdbid=tmdbid, episode_group=episode_group, cache=False) else: if doubanid: # 豆瓣识别模式,不使用缓存 mediainfo = self.recognize_media(meta=metainfo, mtype=mtype, doubanid=doubanid, cache=False) elif mediaid: # 未知前缀,广播事件解析媒体信息 mediainfo = self.__get_event_media(mediaid, metainfo) if mediainfo: # 豆瓣标题处理 meta = MetaInfo(mediainfo.title) mediainfo.title = meta.name if season is None: season = meta.begin_season # 使用名称识别兜底 if not mediainfo: mediainfo = self.recognize_media(meta=metainfo, episode_group=episode_group) # 识别失败 if not mediainfo: logger.warn(f'未识别到媒体信息,标题:{title},tmdbid:{tmdbid},doubanid:{doubanid}') return None, "未识别到媒体信息" # 总集数 if mediainfo.type == MediaType.TV: if season is None: season = 1 # 总集数 if not kwargs.get('total_episode'): if not mediainfo.seasons or episode_group: # 补充媒体信息 mediainfo = self.recognize_media(mtype=mediainfo.type, tmdbid=mediainfo.tmdb_id, doubanid=mediainfo.douban_id, bangumiid=mediainfo.bangumi_id, episode_group=episode_group, cache=False) if not mediainfo: logger.error(f"媒体信息识别失败!") return None, "媒体信息识别失败" if not mediainfo.seasons: logger.error(f"媒体信息中没有季集信息,标题:{title},tmdbid:{tmdbid},doubanid:{doubanid}") return None, "媒体信息中没有季集信息" total_episode = len(mediainfo.seasons.get(season) or []) if not total_episode: logger.error(f'未获取到总集数,标题:{title},tmdbid:{tmdbid}, doubanid:{doubanid}') return None, f"未获取到第 {season} 季的总集数" kwargs.update({ 'total_episode': total_episode }) # 缺失集 if not kwargs.get('lack_episode'): kwargs.update({ 'lack_episode': kwargs.get('total_episode') }) else: # 避免season为0的问题 season = None # 更新媒体图片 self.obtain_images(mediainfo=mediainfo) # 合并信息 if doubanid: mediainfo.douban_id = doubanid if bangumiid: mediainfo.bangumi_id = bangumiid # 添加订阅 kwargs.update(self.__get_default_kwargs(mediainfo.type, **kwargs)) # 操作数据库 sid, err_msg = SubscribeOper().add(mediainfo=mediainfo, season=season, username=username, **kwargs) if not sid: logger.error(f'{mediainfo.title_year} {err_msg}') if not exist_ok and message: # 失败发回原用户 self.post_message(schemas.Notification(channel=channel, source=source, mtype=NotificationType.Subscribe, title=f"{mediainfo.title_year} {metainfo.season} " f"添加订阅失败!", text=f"{err_msg}", image=mediainfo.get_message_image(), userid=userid)) return None, err_msg elif message: if mediainfo.type == MediaType.TV: link = settings.MP_DOMAIN('#/subscribe/tv?tab=mysub') else: link = settings.MP_DOMAIN('#/subscribe/movie?tab=mysub') # 订阅成功按规则发送消息 self.post_message( schemas.Notification( channel=channel, source=source, mtype=NotificationType.Subscribe, ctype=ContentType.SubscribeAdded, image=mediainfo.get_message_image(), link=link, userid=userid, username=username ), meta=metainfo, mediainfo=mediainfo, username=username ) # 发送事件 eventmanager.send_event(EventType.SubscribeAdded, { "subscribe_id": sid, "username": username, "mediainfo": mediainfo.to_dict(), }) # 统计订阅 SubscribeHelper().sub_reg_async({ "name": title, "year": year, "type": metainfo.type.value, "tmdbid": mediainfo.tmdb_id, "imdbid": mediainfo.imdb_id, "tvdbid": mediainfo.tvdb_id, "doubanid": mediainfo.douban_id, "bangumiid": mediainfo.bangumi_id, "season": metainfo.begin_season, "poster": mediainfo.get_poster_image(), "backdrop": mediainfo.get_backdrop_image(), "vote": mediainfo.vote_average, "description": mediainfo.overview }) # 返回结果 return sid, err_msg async def async_add(self, title: str, year: str, mtype: MediaType = None, tmdbid: Optional[int] = None, doubanid: Optional[str] = None, bangumiid: Optional[int] = None, mediaid: Optional[str] = None, episode_group: Optional[str] = None, season: Optional[int] = None, channel: MessageChannel = None, source: Optional[str] = None, userid: Optional[str] = None, username: Optional[str] = None, message: Optional[bool] = True, exist_ok: Optional[bool] = False, **kwargs) -> Tuple[Optional[int], str]: """ 异步识别媒体信息并添加订阅 """ logger.info(f'开始添加订阅,标题:{title} ...') mediainfo = None metainfo = MetaInfo(title) if year: metainfo.year = year if mtype: metainfo.type = mtype if season is not None: metainfo.type = MediaType.TV metainfo.begin_season = season # 识别媒体信息 if settings.RECOGNIZE_SOURCE == "themoviedb": # TMDB识别模式 if not tmdbid: if doubanid: # 将豆瓣信息转换为TMDB信息 tmdbinfo = await MediaChain().async_get_tmdbinfo_by_doubanid(doubanid=doubanid, mtype=mtype) if tmdbinfo: mediainfo = MediaInfo(tmdb_info=tmdbinfo) elif mediaid: # 未知前缀,广播事件解析媒体信息 mediainfo = await self.__async_get_event_meida(mediaid, metainfo) else: # 使用TMDBID识别 mediainfo = await self.async_recognize_media(meta=metainfo, mtype=mtype, tmdbid=tmdbid, episode_group=episode_group, cache=False) else: if doubanid: # 豆瓣识别模式,不使用缓存 mediainfo = await self.async_recognize_media(meta=metainfo, mtype=mtype, doubanid=doubanid, cache=False) elif mediaid: # 未知前缀,广播事件解析媒体信息 mediainfo = await self.__async_get_event_meida(mediaid, metainfo) if mediainfo: # 豆瓣标题处理 meta = MetaInfo(mediainfo.title) mediainfo.title = meta.name if season is None: season = meta.begin_season # 使用名称识别兜底 if not mediainfo: mediainfo = await self.async_recognize_media(meta=metainfo, episode_group=episode_group) # 识别失败 if not mediainfo: logger.warn(f'未识别到媒体信息,标题:{title},tmdbid:{tmdbid},doubanid:{doubanid}') return None, "未识别到媒体信息" # 总集数 if mediainfo.type == MediaType.TV: if season is None: season = 1 # 总集数 if not kwargs.get('total_episode'): if not mediainfo.seasons or episode_group: # 补充媒体信息 mediainfo = await self.async_recognize_media(mtype=mediainfo.type, tmdbid=mediainfo.tmdb_id, doubanid=mediainfo.douban_id, bangumiid=mediainfo.bangumi_id, episode_group=episode_group, cache=False) if not mediainfo: logger.error(f"媒体信息识别失败!") return None, "媒体信息识别失败" if not mediainfo.seasons: logger.error(f"媒体信息中没有季集信息,标题:{title},tmdbid:{tmdbid},doubanid:{doubanid}") return None, "媒体信息中没有季集信息" total_episode = len(mediainfo.seasons.get(season) or []) if not total_episode: logger.error(f'未获取到总集数,标题:{title},tmdbid:{tmdbid}, doubanid:{doubanid}') return None, f"未获取到第 {season} 季的总集数" kwargs.update({ 'total_episode': total_episode }) # 缺失集 if not kwargs.get('lack_episode'): kwargs.update({ 'lack_episode': kwargs.get('total_episode') }) else: # 避免season为0的问题 season = None # 更新媒体图片 await self.async_obtain_images(mediainfo=mediainfo) # 合并信息 if doubanid: mediainfo.douban_id = doubanid if bangumiid: mediainfo.bangumi_id = bangumiid # 列新默认参数 kwargs.update(self.__get_default_kwargs(mediainfo.type, **kwargs)) # 操作数据库 sid, err_msg = await SubscribeOper().async_add(mediainfo=mediainfo, season=season, username=username, **kwargs) if not sid: logger.error(f'{mediainfo.title_year} {err_msg}') if not exist_ok and message: # 失败发回原用户 await self.async_post_message(schemas.Notification(channel=channel, source=source, mtype=NotificationType.Subscribe, title=f"{mediainfo.title_year} {metainfo.season} " f"添加订阅失败!", text=f"{err_msg}", image=mediainfo.get_message_image(), userid=userid)) return None, err_msg elif message: if mediainfo.type == MediaType.TV: link = settings.MP_DOMAIN('#/subscribe/tv?tab=mysub') else: link = settings.MP_DOMAIN('#/subscribe/movie?tab=mysub') # 订阅成功按规则发送消息 await self.async_post_message( schemas.Notification( channel=channel, source=source, mtype=NotificationType.Subscribe, ctype=ContentType.SubscribeAdded, image=mediainfo.get_message_image(), link=link, userid=userid, username=username ), meta=metainfo, mediainfo=mediainfo, username=username ) # 发送事件 await eventmanager.async_send_event(EventType.SubscribeAdded, { "subscribe_id": sid, "username": username, "mediainfo": mediainfo.to_dict(), }) # 统计订阅 await SubscribeHelper().async_sub_reg({ "name": title, "year": year, "type": metainfo.type.value, "tmdbid": mediainfo.tmdb_id, "imdbid": mediainfo.imdb_id, "tvdbid": mediainfo.tvdb_id, "doubanid": mediainfo.douban_id, "bangumiid": mediainfo.bangumi_id, "season": metainfo.begin_season, "poster": mediainfo.get_poster_image(), "backdrop": mediainfo.get_backdrop_image(), "vote": mediainfo.vote_average, "description": mediainfo.overview }) # 返回结果 return sid, err_msg @staticmethod def exists(mediainfo: MediaInfo, meta: MetaBase = None): """ 判断订阅是否已存在 """ if SubscribeOper().exists(tmdbid=mediainfo.tmdb_id, doubanid=mediainfo.douban_id, season=meta.begin_season if meta else None): return True return False def search(self, sid: Optional[int] = None, state: Optional[str] = 'N', manual: Optional[bool] = False): """ 订阅搜索 :param sid: 订阅ID,有值时只处理该订阅 :param state: 订阅状态 N:新建, R:订阅中, P:待定, S:暂停 :param manual: 是否手动搜索 :return: 更新订阅状态为R或删除订阅 """ lock_acquired = False try: if lock_acquired := self._rlock.acquire( blocking=True, timeout=self._LOCK_TIMOUT ): logger.debug(f"search lock acquired at {datetime.now()}") else: logger.warn("search上锁超时") subscribeoper = SubscribeOper() if sid: subscribe = subscribeoper.get(sid) subscribes = [subscribe] if subscribe else [] else: subscribes = subscribeoper.list(self.get_states_for_search(state)) try: # 遍历订阅 for subscribe in subscribes: if global_vars.is_system_stopped: break mediakey = subscribe.tmdbid or subscribe.doubanid custom_word_list = subscribe.custom_words.split("\n") if subscribe.custom_words else None # 校验当前时间减订阅创建时间是否大于1分钟,否则跳过先,留出编辑订阅的时间 if subscribe.date: now = datetime.now() subscribe_time = datetime.strptime(subscribe.date, '%Y-%m-%d %H:%M:%S') if (now - subscribe_time).total_seconds() < 60: logger.debug(f"订阅标题:{subscribe.name} 新增小于1分钟,暂不搜索...") continue # 随机休眠1-5分钟 if not sid and state in ['R', 'P']: sleep_time = random.randint(60, 300) logger.info(f'订阅搜索随机休眠 {sleep_time} 秒 ...') time.sleep(sleep_time) try: logger.info(f'开始搜索订阅,标题:{subscribe.name} ...') # 生成元数据 meta = MetaInfo(subscribe.name) meta.year = subscribe.year meta.begin_season = subscribe.season if subscribe.season is not None else None try: meta.type = MediaType(subscribe.type) except ValueError: logger.error(f'订阅 {subscribe.name} 类型错误:{subscribe.type}') continue # 识别媒体信息 mediainfo: MediaInfo = self.recognize_media(meta=meta, mtype=meta.type, tmdbid=subscribe.tmdbid, doubanid=subscribe.doubanid, episode_group=subscribe.episode_group, cache=False) if not mediainfo: logger.warn( f'未识别到媒体信息,标题:{subscribe.name},tmdbid:{subscribe.tmdbid},doubanid:{subscribe.doubanid}') continue # 如果媒体已存在或已下载完毕,跳过当前订阅处理 exist_flag, no_exists = self.check_and_handle_existing_media(subscribe=subscribe, meta=meta, mediainfo=mediainfo, mediakey=mediakey) if exist_flag: continue # 站点范围 sites = self.get_sub_sites(subscribe) # 优先级过滤规则 if subscribe.best_version: rule_groups = subscribe.filter_groups \ or SystemConfigOper().get(SystemConfigKey.BestVersionFilterRuleGroups) or [] else: rule_groups = subscribe.filter_groups \ or SystemConfigOper().get(SystemConfigKey.SubscribeFilterRuleGroups) or [] # 搜索,同时电视剧会过滤掉不需要的剧集 contexts = SearchChain().process(mediainfo=mediainfo, keyword=subscribe.keyword, no_exists=no_exists, sites=sites, rule_groups=rule_groups, area="imdbid" if subscribe.search_imdbid else "title", custom_words=custom_word_list, filter_params=self.get_params(subscribe)) if not contexts: logger.warn(f'订阅 {subscribe.keyword or subscribe.name} 未搜索到资源') self.finish_subscribe_or_not(subscribe=subscribe, meta=meta, mediainfo=mediainfo, lefts=no_exists) continue # 过滤搜索结果 matched_contexts = [] try: for context in contexts: if global_vars.is_system_stopped: break torrent_meta = context.meta_info torrent_info = context.torrent_info torrent_mediainfo = context.media_info # 洗版 if subscribe.best_version: # 洗版时,非整季不要 if torrent_mediainfo.type == MediaType.TV: if torrent_meta.episode_list: logger.info(f'{subscribe.name} 正在洗版,{torrent_info.title} 不是整季') continue # 洗版时,优先级小于等于已下载优先级的不要 if subscribe.current_priority \ and torrent_info.pri_order <= subscribe.current_priority: logger.info( f'{subscribe.name} 正在洗版,{torrent_info.title} 优先级低于或等于已下载优先级') continue # 更新订阅自定义属性 if subscribe.media_category: torrent_mediainfo.category = subscribe.media_category if subscribe.episode_group: torrent_mediainfo.episode_group = subscribe.episode_group matched_contexts.append(context) finally: contexts.clear() del contexts if not matched_contexts: logger.warn(f'订阅 {subscribe.name} 没有符合过滤条件的资源') self.finish_subscribe_or_not(subscribe=subscribe, meta=meta, mediainfo=mediainfo, lefts=no_exists) continue # 自动下载 downloads, lefts = DownloadChain().batch_download( contexts=matched_contexts, no_exists=no_exists, username=subscribe.username, save_path=subscribe.save_path, downloader=subscribe.downloader, source=self.get_subscribe_source_keyword(subscribe) ) # 同步外部修改,更新订阅信息 subscribe = subscribeoper.get(subscribe.id) # 判断是否应完成订阅 if subscribe: self.finish_subscribe_or_not(subscribe=subscribe, meta=meta, mediainfo=mediainfo, downloads=downloads, lefts=lefts) finally: # 如果状态为N则更新为R if subscribe and subscribe.state == 'N': subscribeoper.update(subscribe.id, {'state': 'R'}) # 手动触发时发送系统消息 if manual: if subscribes: if sid: self.messagehelper.put(f'{subscribes[0].name} 搜索完成!', title="订阅搜索", role="system") else: self.messagehelper.put('所有订阅搜索完成!', title="订阅搜索", role="system") else: self.messagehelper.put('没有找到订阅!', title="订阅搜索", role="system") finally: subscribes.clear() del subscribes finally: if lock_acquired: self._rlock.release() logger.debug(f"search Lock released at {datetime.now()}") def update_subscribe_priority(self, subscribe: Subscribe, meta: MetaBase, mediainfo: MediaInfo, downloads: Optional[List[Context]]): """ 更新订阅已下载资源的优先级 """ if not downloads: return if not subscribe.best_version: return # 当前下载资源的优先级 priority = max([item.torrent_info.pri_order for item in downloads]) # 订阅存在待定策略,不管是否已完成,均需更新订阅信息 SubscribeOper().update(subscribe.id, { "current_priority": priority, "last_update": datetime.now().strftime('%Y-%m-%d %H:%M:%S') }) if priority == 100: # 洗版完成 self.__finish_subscribe(subscribe=subscribe, meta=meta, mediainfo=mediainfo) else: # 正在洗版,更新资源优先级 logger.info(f'{mediainfo.title_year} 正在洗版,更新资源优先级为 {priority}') def finish_subscribe_or_not(self, subscribe: Subscribe, meta: MetaBase, mediainfo: MediaInfo, downloads: List[Context] = None, lefts: Dict[Union[int | str], Dict[int, schemas.NotExistMediaInfo]] = None, force: Optional[bool] = False): """ 判断是否应完成订阅 """ mediakey = subscribe.tmdbid or subscribe.doubanid # 是否有剩余集 no_lefts = not lefts or not lefts.get(mediakey) # 是否完成订阅 if not subscribe.best_version: # 订阅存在待定策略,不管是否已完成,均需更新订阅信息 # 更新订阅已下载信息 self.__update_subscribe_note(subscribe=subscribe, downloads=downloads) # 更新订阅剩余集数和时间 self.__update_lack_episodes(lefts=lefts, subscribe=subscribe, mediainfo=mediainfo, update_date=bool(downloads)) # 判断是否需要完成订阅 if ((no_lefts and meta.type == MediaType.TV) or (downloads and meta.type == MediaType.MOVIE) or force): self.__finish_subscribe(subscribe=subscribe, meta=meta, mediainfo=mediainfo) else: # 未下载到内容且不完整 logger.info(f'{mediainfo.title_year} 未下载完整,继续订阅 ...') elif downloads: # 洗版下载到了内容,更新资源优先级 self.update_subscribe_priority(subscribe=subscribe, meta=meta, mediainfo=mediainfo, downloads=downloads) elif subscribe.current_priority == 100: # 洗版完成 self.__finish_subscribe(subscribe=subscribe, meta=meta, mediainfo=mediainfo) else: # 洗版,未下载到内容 logger.info(f'{mediainfo.title_year} 继续洗版 ...') def refresh(self): """ 订阅刷新 """ # 触发刷新站点资源,从缓存中匹配订阅 sites = self.get_subscribed_sites() if sites is None: return self.match( TorrentsChain().refresh(sites=sites) ) @staticmethod def get_sub_sites(subscribe: Subscribe) -> List[int]: """ 获取订阅中涉及的站点清单 :param subscribe: 订阅信息对象 :return: 涉及的站点清单 """ # 从系统配置获取默认订阅站点 default_sites = SystemConfigOper().get(SystemConfigKey.RssSites) or [] # 如果订阅未指定站点,直接返回默认站点 if not subscribe.sites: return default_sites # 如果默认订阅站点未设置,直接返回订阅指定站点 if not default_sites: return subscribe.sites or [] # 尝试解析订阅中的站点数据 user_sites = subscribe.sites # 计算 user_sites 和 default_sites 的交集 intersection_sites = [site for site in user_sites if site in default_sites] # 如果交集为空,返回默认站点 return intersection_sites if intersection_sites else default_sites def get_subscribed_sites(self) -> Optional[List[int]]: """ 获取订阅中涉及的所有站点清单(节约资源) :return: 返回[]代表所有站点命中,返回None代表没有订阅 """ ret_sites = [] subscribes = SubscribeOper().list() if not subscribes: # 没有订阅 return None # 刷新订阅选中的Rss站点 for subscribe in subscribes: # 刷新选中的站点 if subscribe.state in self.get_states_for_search('R'): ret_sites.extend(self.get_sub_sites(subscribe)) # 去重 if ret_sites: ret_sites = list(set(ret_sites)) return ret_sites def match(self, torrents: Dict[str, List[Context]]): """ 从缓存中匹配订阅,并自动下载 """ if not torrents: logger.warn('没有缓存资源,无法匹配订阅') return lock_acquired = False try: if lock_acquired := self._rlock.acquire( blocking=True, timeout=self._LOCK_TIMOUT ): logger.debug(f"match lock acquired at {datetime.now()}") else: logger.warn("match上锁超时") # 预识别所有未识别的种子 processed_torrents: Dict[str, List[Context]] = {} for domain, contexts in torrents.items(): if global_vars.is_system_stopped: break processed_torrents[domain] = [] for context in contexts: if global_vars.is_system_stopped: break # 如果种子未识别且失败次数未超过3次,尝试识别 if (not context.media_info or (not context.media_info.tmdb_id and not context.media_info.douban_id)) and context.media_recognize_fail_count < 3: logger.debug( f'尝试重新识别种子:{context.torrent_info.title},当前失败次数:{context.media_recognize_fail_count}/3') re_mediainfo = self.recognize_media(meta=context.meta_info) if re_mediainfo: # 清理多余信息 re_mediainfo.clear() # 更新种子缓存 context.media_info = re_mediainfo # 重置失败次数 context.media_recognize_fail_count = 0 logger.debug(f'种子 {context.torrent_info.title} 重新识别成功') else: # 识别失败,增加失败次数 context.media_recognize_fail_count += 1 logger.debug( f'种子 {context.torrent_info.title} 媒体识别失败,失败次数:{context.media_recognize_fail_count}/3') elif context.media_recognize_fail_count >= 3: logger.debug(f'种子 {context.torrent_info.title} 已达到最大识别失败次数(3次),跳过识别') # 添加已预处理 processed_torrents[domain].append(context) # 所有订阅 subscribes = SubscribeOper().list(self.get_states_for_search('R')) try: for subscribe in subscribes: if global_vars.is_system_stopped: break logger.info(f'开始匹配订阅,标题:{subscribe.name} ...') mediakey = subscribe.tmdbid or subscribe.doubanid # 生成元数据 meta = MetaInfo(subscribe.name) meta.year = subscribe.year meta.begin_season = subscribe.season or None try: meta.type = MediaType(subscribe.type) except ValueError: logger.error(f'订阅 {subscribe.name} 类型错误:{subscribe.type}') continue # 订阅的站点域名列表 domains = [] if subscribe.sites: domains = SiteOper().get_domains_by_ids(subscribe.sites) # 识别媒体信息 mediainfo: MediaInfo = self.recognize_media(meta=meta, mtype=meta.type, tmdbid=subscribe.tmdbid, doubanid=subscribe.doubanid, episode_group=subscribe.episode_group, cache=False) if not mediainfo: logger.warn( f'未识别到媒体信息,标题:{subscribe.name},tmdbid:{subscribe.tmdbid},doubanid:{subscribe.doubanid}') continue # 如果媒体已存在或已下载完毕,跳过当前订阅处理 exist_flag, no_exists = self.check_and_handle_existing_media(subscribe=subscribe, meta=meta, mediainfo=mediainfo, mediakey=mediakey) if exist_flag: continue # 清理多余信息 mediainfo.clear() # 订阅识别词 if subscribe.custom_words: custom_words_list = subscribe.custom_words.split("\n") else: custom_words_list = None # 遍历预识别后的种子 _match_context = [] torrenthelper = TorrentHelper() systemconfig = SystemConfigOper() wordsmatcher = WordsMatcher() for domain, contexts in processed_torrents.items(): if global_vars.is_system_stopped: break if domains and domain not in domains: continue logger.debug(f'开始匹配站点:{domain},共缓存了 {len(contexts)} 个种子...') for context in contexts: if global_vars.is_system_stopped: break # 提取信息 _context = copy.copy(context) torrent_meta = _context.meta_info torrent_mediainfo = _context.media_info torrent_info = _context.torrent_info # 不在订阅站点范围的不处理 sub_sites = self.get_sub_sites(subscribe) if sub_sites and torrent_info.site not in sub_sites: logger.debug(f"{torrent_info.site_name} - {torrent_info.title} 不符合订阅站点要求") continue # 有自定义识别词时,需要判断是否需要重新识别 if custom_words_list: # 使用org_string,应用一次后理论上不能再次应用 _, apply_words = wordsmatcher.prepare(torrent_meta.org_string, custom_words=custom_words_list) if apply_words: logger.info( f'{torrent_info.site_name} - {torrent_info.title} 因订阅存在自定义识别词,重新识别元数据...') # 重新识别元数据 torrent_meta = MetaInfo(title=torrent_info.title, subtitle=torrent_info.description, custom_words=custom_words_list) # 更新元数据缓存 _context.meta_info = torrent_meta # 重新识别媒体信息 torrent_mediainfo = self.recognize_media(meta=torrent_meta, episode_group=subscribe.episode_group) if torrent_mediainfo: # 清理多余信息 torrent_mediainfo.clear() # 更新种子缓存 _context.media_info = torrent_mediainfo # 如果仍然没有识别到媒体信息,尝试标题匹配 if not torrent_mediainfo or ( not torrent_mediainfo.tmdb_id and not torrent_mediainfo.douban_id): logger.debug( f'{torrent_info.site_name} - {torrent_info.title} 重新识别失败,尝试通过标题匹配...') if torrenthelper.match_torrent(mediainfo=mediainfo, torrent_meta=torrent_meta, torrent=torrent_info): # 匹配成功 logger.info( f'{mediainfo.title_year} 通过标题匹配到可选资源:{torrent_info.site_name} - {torrent_info.title}') torrent_mediainfo = mediainfo # 更新种子缓存 _context.media_info = mediainfo else: continue # 直接比对媒体信息 if torrent_mediainfo and (torrent_mediainfo.tmdb_id or torrent_mediainfo.douban_id): if torrent_mediainfo.type != mediainfo.type: continue if torrent_mediainfo.tmdb_id \ and torrent_mediainfo.tmdb_id != mediainfo.tmdb_id: continue if torrent_mediainfo.douban_id \ and torrent_mediainfo.douban_id != mediainfo.douban_id: continue logger.info( f'{mediainfo.title_year} 通过媒体ID匹配到可选资源:{torrent_info.site_name} - {torrent_info.title}') else: continue # 如果是电视剧 if torrent_mediainfo.type == MediaType.TV: # 有多季的不要 if len(torrent_meta.season_list) > 1: logger.debug(f'{torrent_info.title} 有多季,不处理') continue # 比对季 if torrent_meta.begin_season: if meta.begin_season != torrent_meta.begin_season: logger.debug(f'{torrent_info.title} 季不匹配') continue elif meta.begin_season != 1: logger.debug(f'{torrent_info.title} 季不匹配') continue # 非洗版 if not subscribe.best_version: # 不是缺失的剧集不要 if no_exists and no_exists.get(mediakey): # 缺失集 no_exists_info = no_exists.get(mediakey).get(subscribe.season) if no_exists_info: # 是否有交集 if no_exists_info.episodes and \ torrent_meta.episode_list and \ not set(no_exists_info.episodes).intersection( set(torrent_meta.episode_list) ): logger.debug( f'{torrent_info.title} 对应剧集 {torrent_meta.episode_list} 未包含缺失的剧集' ) continue else: # 洗版时,非整季不要 if meta.type == MediaType.TV: if torrent_meta.episode_list: logger.debug(f'{subscribe.name} 正在洗版,{torrent_info.title} 不是整季') continue # 匹配订阅附加参数 if not torrenthelper.filter_torrent(torrent_info=torrent_info, filter_params=self.get_params(subscribe)): continue # 优先级过滤规则 if subscribe.best_version: rule_groups = subscribe.filter_groups \ or systemconfig.get(SystemConfigKey.BestVersionFilterRuleGroups) else: rule_groups = subscribe.filter_groups \ or systemconfig.get(SystemConfigKey.SubscribeFilterRuleGroups) result: List[TorrentInfo] = self.filter_torrents( rule_groups=rule_groups, torrent_list=[torrent_info], mediainfo=torrent_mediainfo) if result is not None and not result: # 不符合过滤规则 logger.debug(f"{torrent_info.title} 不匹配过滤规则") continue # 洗版时,优先级小于已下载优先级的不要 if subscribe.best_version: if subscribe.current_priority \ and torrent_info.pri_order <= subscribe.current_priority: logger.info( f'{subscribe.name} 正在洗版,{torrent_info.title} 优先级低于或等于已下载优先级') continue # 匹配成功 logger.info(f'{mediainfo.title_year} 匹配成功:{torrent_info.title}') # 自定义属性 if subscribe.media_category: torrent_mediainfo.category = subscribe.media_category if subscribe.episode_group: torrent_mediainfo.episode_group = subscribe.episode_group _match_context.append(_context) if not _match_context: # 未匹配到资源 logger.info(f'{mediainfo.title_year} 未匹配到符合条件的资源') self.finish_subscribe_or_not(subscribe=subscribe, meta=meta, mediainfo=mediainfo, lefts=no_exists) continue # 开始批量择优下载 logger.info(f'{mediainfo.title_year} 匹配完成,共匹配到{len(_match_context)}个资源') downloads, lefts = DownloadChain().batch_download(contexts=_match_context, no_exists=no_exists, username=subscribe.username, save_path=subscribe.save_path, downloader=subscribe.downloader, source=self.get_subscribe_source_keyword( subscribe) ) # 同步外部修改,更新订阅信息 subscribe = SubscribeOper().get(subscribe.id) # 判断是否要完成订阅 if subscribe: self.finish_subscribe_or_not(subscribe=subscribe, meta=meta, mediainfo=mediainfo, downloads=downloads, lefts=lefts) finally: processed_torrents.clear() del processed_torrents subscribes.clear() del subscribes finally: if lock_acquired: self._rlock.release() logger.debug(f"match Lock released at {datetime.now()}") def check(self): """ 定时检查订阅,更新订阅信息 """ # 查询所有订阅 subscribeoper = SubscribeOper() # 遍历订阅 for subscribe in subscribeoper.list(): if global_vars.is_system_stopped: break logger.info(f'开始更新订阅元数据:{subscribe.name} ...') # 生成元数据 meta = MetaInfo(subscribe.name) meta.year = subscribe.year meta.begin_season = subscribe.season or None try: meta.type = MediaType(subscribe.type) except ValueError: logger.error(f'订阅 {subscribe.name} 类型错误:{subscribe.type}') continue # 识别媒体信息 mediainfo: MediaInfo = self.recognize_media(meta=meta, mtype=meta.type, tmdbid=subscribe.tmdbid, doubanid=subscribe.doubanid, episode_group=subscribe.episode_group, cache=False) if not mediainfo: logger.warn( f'未识别到媒体信息,标题:{subscribe.name},tmdbid:{subscribe.tmdbid},doubanid:{subscribe.doubanid}') continue # 对于电视剧,获取当前季的总集数 episodes = mediainfo.seasons.get(subscribe.season) or [] if not subscribe.manual_total_episode and len(episodes): total_episode = len(episodes) lack_episode = subscribe.lack_episode + (total_episode - subscribe.total_episode) logger.info( f'订阅 {subscribe.name} 总集数变化,更新总集数为{total_episode},缺失集数为{lack_episode} ...') else: total_episode = subscribe.total_episode lack_episode = subscribe.lack_episode # 更新TMDB信息 subscribeoper.update(subscribe.id, { "name": mediainfo.title, "year": mediainfo.year, "vote": mediainfo.vote_average, "poster": mediainfo.get_poster_image(), "backdrop": mediainfo.get_backdrop_image(), "description": mediainfo.overview, "imdbid": mediainfo.imdb_id, "tvdbid": mediainfo.tvdb_id, "total_episode": total_episode, "lack_episode": lack_episode }) logger.info(f'{subscribe.name} 订阅元数据更新完成') def get_subscribe_by_source(self, source: str) -> Optional[Subscribe]: """ 从来源获取订阅 """ source_keyword = self.parse_subscribe_source_keyword(source) if not source_keyword: return None # 只保留需要的字段动态获取订阅 valid_fields = {k: v for k, v in source_keyword.items() if k in ["type", "season", "tmdbid", "doubanid", "bangumiid"]} # 暂时不考虑订阅历史, 若有必要再添加 return SubscribeOper().get_by(**valid_fields) @staticmethod def follow(): """ 刷新follow的用户分享,并自动添加订阅 """ follow_users: List[str] = SystemConfigOper().get(SystemConfigKey.FollowSubscribers) if not follow_users: return logger.info(f'开始刷新follow用户分享订阅 ...') success_count = 0 subscribeoper = SubscribeOper() for share_sub in SubscribeHelper().get_shares(): if global_vars.is_system_stopped: break uid = share_sub.get("share_uid") if uid and uid in follow_users: # 订阅已存在则跳过 if subscribeoper.exists(tmdbid=share_sub.get("tmdbid"), doubanid=share_sub.get("doubanid"), season=share_sub.get("season")): continue # 已经订阅过跳过 if subscribeoper.exist_history(tmdbid=share_sub.get("tmdbid"), doubanid=share_sub.get("doubanid"), season=share_sub.get("season")): continue # 去除无效属性 for key in list(share_sub.keys()): if not hasattr(schemas.Subscribe(), key): share_sub.pop(key) # 类型转换 subscribe_in = schemas.Subscribe(**share_sub) mtype = MediaType(subscribe_in.type) # 豆瓣标题处理 if subscribe_in.doubanid or subscribe_in.bangumiid: meta = MetaInfo(subscribe_in.name) subscribe_in.name = meta.name subscribe_in.season = meta.begin_season # 标题转换 if subscribe_in.name: title = subscribe_in.name else: title = None sid, message = SubscribeChain().add(mtype=mtype, title=title, year=subscribe_in.year, tmdbid=subscribe_in.tmdbid, season=subscribe_in.season, doubanid=subscribe_in.doubanid, bangumiid=subscribe_in.bangumiid, username="订阅分享", best_version=subscribe_in.best_version, save_path=subscribe_in.save_path, search_imdbid=subscribe_in.search_imdbid, custom_words=subscribe_in.custom_words, media_category=subscribe_in.media_category, filter_groups=subscribe_in.filter_groups, exist_ok=True) if sid: success_count += 1 logger.info(f'follow用户分享订阅 {title} 添加成功') else: logger.error(f'follow用户分享订阅 {title} 添加失败:{message}') logger.info(f'follow用户分享订阅刷新完成,共添加 {success_count} 个订阅') async def cache_calendar(self): """ 预缓存订阅日历,实际上就是查询一遍所有订阅的媒体信息 前端请示是异常的,所以需要使用异步缓存方法 """ logger.info(f'开始预缓存订阅日历 ...') for subscribe in await SubscribeOper().async_list(): if global_vars.is_system_stopped: break try: mtype = MediaType(subscribe.type) except ValueError: logger.error(f'订阅 {subscribe.name} 类型错误:{subscribe.type}') continue # 识别媒体信息 if mtype == MediaType.MOVIE: mediainfo: MediaInfo = await self.async_recognize_media(mtype=mtype, tmdbid=subscribe.tmdbid, doubanid=subscribe.doubanid, bangumiid=subscribe.bangumiid, episode_group=subscribe.episode_group, cache=False) if not mediainfo: logger.warn( f'未识别到媒体信息,标题:{subscribe.name},tmdbid:{subscribe.tmdbid},doubanid:{subscribe.doubanid}') continue else: episodes = await TmdbChain().async_tmdb_episodes(tmdbid=subscribe.tmdbid, season=subscribe.season, episode_group=subscribe.episode_group) if not episodes: logger.warn( f'未识别到季集信息,标题:{subscribe.name},tmdbid:{subscribe.tmdbid},豆瓣ID:{subscribe.doubanid},季:{subscribe.season}') continue logger.info(f'订阅日历预缓存完成') @staticmethod def __update_subscribe_note(subscribe: Subscribe, downloads: Optional[List[Context]]): """ 更新已下载信息到note字段 """ # 查询现有Note if not downloads: return note = [] if subscribe.note: note = subscribe.note or [] for context in downloads: meta = context.meta_info mediainfo = context.media_info if subscribe.tmdbid and mediainfo.tmdb_id \ and mediainfo.tmdb_id != subscribe.tmdbid: continue if subscribe.doubanid and mediainfo.douban_id \ and mediainfo.douban_id != subscribe.doubanid: continue items = [] if mediainfo.type == MediaType.TV: # 电视剧有集数,使用 episode_list items = meta.episode_list elif mediainfo.type == MediaType.MOVIE: # 电影只有一个条目,设置为 [1] items = [1] if not items: continue # 合并已下载的集数或电影项(去重) note = list(set(note).union(set(items))) # 更新订阅 if note: SubscribeOper().update(subscribe.id, { "note": note }) @staticmethod def __get_downloaded(subscribe: Subscribe) -> List[int]: """ 获取已下载过的集数或电影 """ if subscribe.best_version: return [] note = subscribe.note or [] if not note: return [] # 针对 TV 类型,返回已下载的集数 if subscribe.type == MediaType.TV.value: logger.info(f'订阅 {subscribe.name} 第{subscribe.season}季 已下载集数:{note}') return note # 针对 Movie 类型,直接返回已下载的电影 if subscribe.type == MediaType.MOVIE.value: logger.info(f'订阅 {subscribe.name} 已下载内容:{note}') return note return [] @staticmethod def __update_lack_episodes(lefts: Dict[Union[int, str], Dict[int, schemas.NotExistMediaInfo]], subscribe: Subscribe, mediainfo: MediaInfo, update_date: Optional[bool] = False): """ 更新订阅剩余集数及时间 """ update_data = {} if update_date: update_data["last_update"] = datetime.now().strftime('%Y-%m-%d %H:%M:%S') if subscribe.type == MediaType.TV.value: if not lefts: # 如果 lefts 为空,表示没有缺失集数,直接设置 lack_episode 为 0 lack_episode = 0 logger.info(f'{mediainfo.title_year} 没有缺失集数,直接更新为 0 ...') else: mediakey = subscribe.tmdbid or subscribe.doubanid left_seasons = lefts.get(mediakey) lack_episode = 0 if left_seasons: for season_info in left_seasons.values(): season = season_info.season if season == subscribe.season: left_episodes = season_info.episodes if not left_episodes: lack_episode = season_info.total_episode else: lack_episode = len(left_episodes) logger.info(f"{mediainfo.title_year} 季 {season} 更新缺失集数为{lack_episode} ...") break update_data["lack_episode"] = lack_episode # 更新数据库 if update_data: SubscribeOper().update(subscribe.id, update_data) def __finish_subscribe(self, subscribe: Subscribe, mediainfo: MediaInfo, meta: MetaBase): """ 完成订阅 """ # 如果订阅状态为待定(P),说明订阅信息尚未完全更新,无法完成订阅 if subscribe.state == "P": return # 完成订阅 msgstr = "订阅" if not subscribe.best_version else "洗版" logger.info(f'{mediainfo.title_year} 完成{msgstr}') # 新增订阅历史 subscribeoper = SubscribeOper() subscribeoper.add_history(**subscribe.to_dict()) # 删除订阅 subscribeoper.delete(subscribe.id) # 发送通知 if mediainfo.type == MediaType.TV: link = settings.MP_DOMAIN('#/subscribe/tv?tab=mysub') else: link = settings.MP_DOMAIN('#/subscribe/movie?tab=mysub') # 完成订阅按规则发送消息 self.post_message( schemas.Notification( mtype=NotificationType.Subscribe, ctype=ContentType.SubscribeComplete, image=mediainfo.get_message_image(), link=link, username=subscribe.username ), meta=meta, mediainfo=mediainfo, msgstr=msgstr, username=subscribe.username ) # 发送事件 eventmanager.send_event(EventType.SubscribeComplete, { "subscribe_id": subscribe.id, "subscribe_info": subscribe.to_dict(), "mediainfo": mediainfo.to_dict(), }) # 统计订阅 SubscribeHelper().sub_done_async({ "tmdbid": mediainfo.tmdb_id, "doubanid": mediainfo.douban_id }) def remote_list(self, channel: MessageChannel, userid: Union[str, int] = None, source: Optional[str] = None): """ 查询订阅并发送消息 """ subscribes = SubscribeOper().list() if not subscribes: self.post_message(schemas.Notification(channel=channel, source=source, title='没有任何订阅!', userid=userid)) return title = f"共有 {len(subscribes)} 个订阅,回复对应指令操作: " \ f"\n- 删除订阅:/subscribe_delete [id]" \ f"\n- 搜索订阅:/subscribe_search [id]" \ f"\n- 刷新订阅:/subscribe_refresh" messages = [] for subscribe in subscribes: if subscribe.type == MediaType.MOVIE.value: messages.append(f"{subscribe.id}. {subscribe.name}({subscribe.year})") else: messages.append(f"{subscribe.id}. {subscribe.name}({subscribe.year})" f"第{subscribe.season}季 " f"[{subscribe.total_episode - (subscribe.lack_episode or subscribe.total_episode)}" f"/{subscribe.total_episode}]") # 发送列表 self.post_message(schemas.Notification(channel=channel, source=source, title=title, text='\n'.join(messages), userid=userid)) def remote_delete(self, arg_str: str, channel: MessageChannel, userid: Union[str, int] = None, source: Optional[str] = None): """ 删除订阅 """ if not arg_str: self.post_message(schemas.Notification(channel=channel, source=source, title="请输入正确的命令格式:/subscribe_delete [id]," "[id]为订阅编号", userid=userid)) return arg_strs = str(arg_str).split() subscribeoper = SubscribeOper() subscribehelper = SubscribeHelper() for arg_str in arg_strs: arg_str = arg_str.strip() if not arg_str.isdigit(): continue subscribe_id = int(arg_str) subscribe = subscribeoper.get(subscribe_id) if not subscribe: self.post_message(schemas.Notification(channel=channel, source=source, title=f"订阅编号 {subscribe_id} 不存在!", userid=userid)) return # 删除订阅 subscribeoper.delete(subscribe_id) # 统计订阅 subscribehelper.sub_done_async({ "tmdbid": subscribe.tmdbid, "doubanid": subscribe.doubanid }) # 重新发送消息 self.remote_list(channel=channel, userid=userid, source=source) @staticmethod def __get_subscribe_no_exits(subscribe_name: str, no_exists: Dict[Union[int, str], Dict[int, schemas.NotExistMediaInfo]], mediakey: Union[str, int], begin_season: int, total_episode: Optional[int], start_episode: Optional[int], downloaded_episodes: List[int] = None ) -> Tuple[bool, Dict[Union[int, str], Dict[int, schemas.NotExistMediaInfo]]]: """ 根据订阅开始集数和总集数,结合TMDB信息计算当前订阅的缺失集数 :param subscribe_name: 订阅名称 :param no_exists: 缺失季集列表 :param mediakey: TMDB ID或豆瓣ID :param begin_season: 开始季 :param total_episode: 订阅设定总集数 :param start_episode: 订阅设定开始集数 :param downloaded_episodes: 已下载集数 """ # 使用订阅的总集数和开始集数替换no_exists if not no_exists or not no_exists.get(mediakey): return False, no_exists no_exists_item = no_exists.get(mediakey) if total_episode or start_episode: logger.info(f'订阅 {subscribe_name} 设定的开始集数:{start_episode}、总集数:{total_episode}') # 该季原缺失信息 no_exist_season = no_exists_item.get(begin_season) if no_exist_season: # 原集列表 episode_list = no_exist_season.episodes # 原总集数 total = no_exist_season.total_episode # 原开始集数 start = no_exist_season.start_episode # 更新剧集列表、开始集数、总集数 if not episode_list: # 整季缺失 episodes = [] start_episode = start_episode or start total_episode = total_episode or total else: # 部分缺失 if not start_episode \ and not total_episode: # 无需调整 return False, no_exists if not start_episode: # 没有自定义开始集 start_episode = start if not total_episode: # 没有自定义总集数 total_episode = total # 新的集列表 new_episodes = list(range(max(start_episode, start), total_episode + 1)) # 与原集列表取交集 episodes = list(set(episode_list).intersection(set(new_episodes))) # 交集为空时,说明订阅的剧集均已入库 if not episodes: return True, {} # 更新集合 no_exists[mediakey][begin_season] = schemas.NotExistMediaInfo( season=begin_season, episodes=episodes, total_episode=total_episode, start_episode=start_episode ) # 根据订阅已下载集数更新缺失集数 if downloaded_episodes: logger.info(f'订阅 {subscribe_name} 已下载集数:{downloaded_episodes}') # 该季原缺失信息 no_exist_season = no_exists_item.get(begin_season) if no_exist_season: # 原集列表 episode_list = no_exist_season.episodes # 原总集数 total = no_exist_season.total_episode # 原开始集数 start = no_exist_season.start_episode # 整季缺失 if not episode_list: episode_list = list(range(start, total + 1)) # 更新剧集列表 episodes = list(set(episode_list).difference(set(downloaded_episodes))) # 如果存在已下载剧集,则差集为空时,说明所有均已存在 if not episodes: return True, {} # 更新集合 no_exists[mediakey][begin_season] = schemas.NotExistMediaInfo( season=begin_season, episodes=episodes, total_episode=total, start_episode=start, ) else: # 开始集数 start = start_episode or 1 # 更新剧集列表 episodes = list(set(range(start, total_episode + 1)).difference(set(downloaded_episodes))) # 如果存在已下载剧集,则差集为空时,说明所有均已存在 if not episodes: return True, {} no_exists[mediakey][begin_season] = schemas.NotExistMediaInfo( season=begin_season, episodes=episodes, total_episode=total_episode, start_episode=start, ) logger.info(f'订阅 {subscribe_name} 缺失剧集数更新为:{no_exists}') return False, no_exists @eventmanager.register(EventType.SiteDeleted) def remove_site(self, event: Event): """ 从订阅中移除与站点相关的设置 """ if not event: return event_data = event.event_data or {} site_id = event_data.get("site_id") if not site_id: return subscribeoper = SubscribeOper() if site_id == "*": # 站点被重置 SystemConfigOper().set(SystemConfigKey.RssSites, []) for subscribe in subscribeoper.list(): if not subscribe.sites: continue subscribeoper.update(subscribe.id, { "sites": [] }) return # 从选中的rss站点中移除 selected_sites = SystemConfigOper().get(SystemConfigKey.RssSites) or [] if site_id in selected_sites: selected_sites.remove(site_id) SystemConfigOper().set(SystemConfigKey.RssSites, selected_sites) # 查询所有订阅 for subscribe in subscribeoper.list(): if not subscribe.sites: continue sites = subscribe.sites or [] if site_id not in sites: continue sites.remove(site_id) subscribeoper.update(subscribe.id, { "sites": sites }) @staticmethod def __get_default_subscribe_config(mtype: MediaType, default_config_key: str) -> Optional[str]: """ 获取默认订阅配置 """ default_subscribe_key = None if mtype == MediaType.TV: default_subscribe_key = SystemConfigKey.DefaultTvSubscribeConfig.value if mtype == MediaType.MOVIE: default_subscribe_key = SystemConfigKey.DefaultMovieSubscribeConfig.value # 默认订阅规则 if hasattr(settings, default_subscribe_key): value = getattr(settings, default_subscribe_key) else: value = SystemConfigOper().get(default_subscribe_key) if not value: return None return value.get(default_config_key) or None @staticmethod def get_params(subscribe: Subscribe): """ 获取订阅默认参数 """ # 默认过滤规则 default_rule = SystemConfigOper().get(SystemConfigKey.SubscribeDefaultParams) or {} return { key: value for key, value in { "include": subscribe.include or default_rule.get("include"), "exclude": subscribe.exclude or default_rule.get("exclude"), "quality": subscribe.quality or default_rule.get("quality"), "resolution": subscribe.resolution or default_rule.get("resolution"), "effect": subscribe.effect or default_rule.get("effect"), "tv_size": default_rule.get("tv_size"), "movie_size": default_rule.get("movie_size"), "min_seeders": default_rule.get("min_seeders"), "min_seeders_time": default_rule.get("min_seeders_time"), }.items() if value is not None} def subscribe_files_info(self, subscribe: Subscribe) -> Optional[schemas.SubscrbieInfo]: """ 订阅相关的下载和文件信息 """ if not subscribe: return None # 返回订阅数据 subscribe_info = schemas.SubscrbieInfo() # 所有集的数据 episodes: Dict[int, schemas.SubscribeEpisodeInfo] = {} if subscribe.tmdbid and subscribe.type == MediaType.TV.value: # 查询TMDB中的集信息 tmdb_episodes = TmdbChain().tmdb_episodes( tmdbid=subscribe.tmdbid, season=subscribe.season, episode_group=subscribe.episode_group ) if tmdb_episodes: for episode in tmdb_episodes: info = schemas.SubscribeEpisodeInfo() info.title = episode.name info.description = episode.overview info.backdrop = settings.TMDB_IMAGE_URL(episode.still_path, "w500") episodes[episode.episode_number] = info elif subscribe.type == MediaType.TV.value: # 根据开始结束集计算集信息 for i in range(subscribe.start_episode or 1, subscribe.total_episode + 1): info = schemas.SubscribeEpisodeInfo() info.title = f'第 {i} 集' episodes[i] = info else: # 电影 info = schemas.SubscribeEpisodeInfo() info.title = subscribe.name episodes[0] = info # 所有下载记录 downloadhis = DownloadHistoryOper() download_his = downloadhis.get_by_mediaid(tmdbid=subscribe.tmdbid, doubanid=subscribe.doubanid) if download_his: for his in download_his: # 查询下载文件 files = downloadhis.get_files_by_hash(his.download_hash, state=1) if files: for file in files: # 识别文件名 file_meta = MetaInfo(file.filepath) # 下载文件信息 file_info = schemas.SubscribeDownloadFileInfo( torrent_title=his.torrent_name, site_name=his.torrent_site, downloader=file.downloader, hash=his.download_hash, file_path=file.fullpath, ) if subscribe.type == MediaType.TV.value: season_number = file_meta.begin_season if season_number and season_number != subscribe.season: continue episode_number = file_meta.begin_episode if episode_number and episodes.get(episode_number): episodes[episode_number].download.append(file_info) else: episodes[0].download.append(file_info) # 生成元数据 meta = MetaInfo(subscribe.name) meta.year = subscribe.year meta.begin_season = subscribe.season or None try: meta.type = MediaType(subscribe.type) except ValueError: logger.error(f'订阅 {subscribe.name} 类型错误:{subscribe.type}') return subscribe_info # 识别媒体信息 mediainfo: MediaInfo = self.recognize_media(meta=meta, mtype=meta.type, tmdbid=subscribe.tmdbid, doubanid=subscribe.doubanid, episode_group=subscribe.episode_group, cache=False) if not mediainfo: logger.warn( f'未识别到媒体信息,标题:{subscribe.name},tmdbid:{subscribe.tmdbid},doubanid:{subscribe.doubanid}') return subscribe_info # 所有媒体库文件记录 library_fileitems = self.media_files(mediainfo) if library_fileitems: for fileitem in library_fileitems: # 识别文件名 file_meta = MetaInfo(fileitem.path) # 媒体库文件信息 file_info = schemas.SubscribeLibraryFileInfo( storage=fileitem.storage, file_path=fileitem.path, ) if subscribe.type == MediaType.TV.value: season_number = file_meta.begin_season if season_number and season_number != subscribe.season: continue episode_number = file_meta.begin_episode if episode_number and episodes.get(episode_number): episodes[episode_number].library.append(file_info) else: episodes[0].library.append(file_info) # 更新订阅信息 subscribe_info.subscribe = Subscribe(**subscribe.to_dict()) subscribe_info.episodes = episodes return subscribe_info def check_and_handle_existing_media(self, subscribe: Subscribe, meta: MetaBase, mediainfo: MediaInfo, mediakey: Union[str, int]): """ 检查媒体是否已经存在,并根据情况执行相应的操作 1. 查询缺失的媒体信息 2. 判断是否已经下载完毕 3. 根据媒体类型(电视剧或电影)执行不同的处理 :param subscribe: 订阅信息对象 :param meta: 媒体元数据 :param mediainfo: 媒体信息 :param mediakey: 媒体标识符 :return: - exist_flag (bool): 布尔值,表示媒体是否已经完全下载或已存在 - no_exists (dict): 缺失的媒体信息,包含缺失的集数或其他相关信息 """ # 非洗版 if not subscribe.best_version: # 每季总集数 totals = {} if subscribe.season and subscribe.total_episode: totals = { subscribe.season: subscribe.total_episode } # 查询媒体库缺失的媒体信息 exist_flag, no_exists = DownloadChain().get_no_exists_info( meta=meta, mediainfo=mediainfo, totals=totals ) else: # 洗版,如果已经满足了优先级,则认为已经洗版完成 if subscribe.current_priority == 100: exist_flag = True no_exists = {} else: exist_flag = False if meta.type == MediaType.TV: # 对于电视剧,构造缺失的媒体信息 no_exists = { mediakey: { subscribe.season: schemas.NotExistMediaInfo( season=subscribe.season, episodes=[], total_episode=subscribe.total_episode, start_episode=subscribe.start_episode or 1) } } else: no_exists = {} # 如果媒体已存在,执行订阅完成操作 if exist_flag: if not subscribe.best_version: logger.info(f'{mediainfo.title_year} 媒体库中已存在') self.finish_subscribe_or_not(subscribe=subscribe, meta=meta, mediainfo=mediainfo, force=True) return True, no_exists # 获取已下载的集数或电影 downloaded = self.__get_downloaded(subscribe) if meta.type == MediaType.TV: # 对于电视剧类型,整合缺失集数并剔除已下载的集数 exist_flag, no_exists = self.__get_subscribe_no_exits( subscribe_name=f'{subscribe.name} {meta.season}', no_exists=no_exists, mediakey=mediakey, begin_season=meta.begin_season, total_episode=subscribe.total_episode, start_episode=subscribe.start_episode, downloaded_episodes=downloaded ) elif meta.type == MediaType.MOVIE: # 对于电影类型,直接根据是否已下载判断 exist_flag = bool(downloaded) # 如果已下载完毕,执行订阅完成操作 if exist_flag: logger.info(f'{mediainfo.title_year} 已全部下载') self.finish_subscribe_or_not(subscribe=subscribe, meta=meta, mediainfo=mediainfo, force=True) return True, no_exists # 返回结果,表示媒体未完全下载或存在 return False, no_exists @staticmethod def get_states_for_search(state: str) -> str: """ 根据给定的状态返回实际需要搜索的状态列表,支持多个状态用逗号分隔 :param state: 订阅状态 N: New(新建,未处理) R: Resolved(订阅中) P: Pending(待定,信息待进一步更新,允许搜索,不允许完成) S: Suspended(暂停,订阅不参与任何动作,暂时停止处理) :return: 需要查询的状态列表(多个状态用逗号分隔) """ # 如果状态是 R 或 P,则视为一起搜索,返回 R,P 作为查询条件 if state in ["R", "P"]: return "R,P" return state @staticmethod def get_subscribe_source_keyword(subscribe: Subscribe) -> str: """ 构造用于订阅来源的关键字字符串 :param subscribe: Subscribe 对象 :return str: 格式化的订阅来源关键字字符串,格式为 "Subscribe|{...}" """ source_keyword = { 'id': subscribe.id, 'name': subscribe.name, 'year': subscribe.year, 'type': subscribe.type, 'season': subscribe.season, 'tmdbid': subscribe.tmdbid, 'imdbid': subscribe.imdbid, 'tvdbid': subscribe.tvdbid, 'doubanid': subscribe.doubanid, 'bangumiid': subscribe.bangumiid } return f"Subscribe|{json.dumps(source_keyword, ensure_ascii=False)}" @staticmethod def parse_subscribe_source_keyword(source_keyword_str: str) -> Optional[dict]: """ 解析订阅来源关键字字符串 :param source_keyword_str: 订阅来源关键字字符串,格式为 "Subscribe|{...}" :return Dict: 如果解析失败则返回None """ if not source_keyword_str or not source_keyword_str.startswith("Subscribe|"): return None try: # 分割字符串获取JSON部分 json_part = source_keyword_str.split("|", 1)[1] # 解析JSON字符串 source_keyword = json.loads(json_part) return source_keyword except (IndexError, json.JSONDecodeError, TypeError) as e: logger.error(f"解析订阅来源关键字失败: {e}") return None ================================================ FILE: app/chain/system.py ================================================ import json import re import shutil from pathlib import Path from typing import Union, Optional from app.chain import ChainBase from app.core.config import settings from app.core.plugin import PluginManager from app.helper.system import SystemHelper from app.log import logger from app.schemas import Notification, MessageChannel from app.utils.http import RequestUtils from app.utils.system import SystemUtils from version import FRONTEND_VERSION, APP_VERSION class SystemChain(ChainBase): """ 系统级处理链 """ _restart_file = "__system_restart__" def remote_clear_cache(self, channel: MessageChannel, userid: Union[int, str], source: Optional[str] = None): """ 清理系统缓存 """ self.clear_cache() self.post_message(Notification(channel=channel, source=source, title=f"缓存清理完成!", userid=userid)) def restart(self, channel: MessageChannel, userid: Union[int, str], source: Optional[str] = None): """ 重启系统 """ from app.core.config import global_vars if channel and userid: self.post_message(Notification(channel=channel, source=source, title="系统正在重启,请耐心等候!", userid=userid)) # 保存重启信息 self.save_cache({ "channel": channel.value, "userid": userid }, self._restart_file) # 主动备份一次插件 self.backup_plugins() # 设置停止标志,通知所有模块准备停止 global_vars.stop_system() # 重启 SystemHelper.restart() @staticmethod def backup_plugins(): """ 备份插件到用户配置目录(仅docker环境) """ # 非docker环境不处理 if not SystemUtils.is_docker(): return try: # 使用绝对路径确保准确性 plugins_dir = settings.ROOT_PATH / "app" / "plugins" backup_dir = settings.CONFIG_PATH / "plugins_backup" if not plugins_dir.exists(): logger.info("插件目录不存在,跳过备份") return # 确保备份目录存在 backup_dir.mkdir(parents=True, exist_ok=True) # 需要排除的文件和目录 exclude_items = {"__init__.py", "__pycache__", ".DS_Store"} # 遍历插件目录,备份除排除项外的所有内容 for item in plugins_dir.iterdir(): if item.name in exclude_items: continue target_path = backup_dir / item.name # 如果是目录 if item.is_dir(): if target_path.exists(): continue shutil.copytree(item, target_path) logger.info(f"已备份插件目录: {item.name}") # 如果是文件 elif item.is_file(): if target_path.exists(): continue shutil.copy2(item, target_path) logger.info(f"已备份插件文件: {item.name}") logger.info(f"插件备份完成,备份位置: {backup_dir}") except Exception as e: logger.error(f"插件备份失败: {str(e)}") @staticmethod def restore_plugins(): """ 从备份恢复插件到app/plugins目录,恢复完成后删除备份(仅docker环境) """ # 非docker环境不处理 if not SystemUtils.is_docker(): return # 使用绝对路径确保准确性 plugins_dir = settings.ROOT_PATH / "app" / "plugins" backup_dir = settings.CONFIG_PATH / "plugins_backup" if not backup_dir.exists(): logger.info("插件备份目录不存在,跳过恢复") return # 系统被重置才恢复插件 if SystemHelper().is_system_reset(): # 确保插件目录存在 plugins_dir.mkdir(parents=True, exist_ok=True) # 遍历备份目录,恢复所有内容 restored_count = 0 for item in backup_dir.iterdir(): target_path = plugins_dir / item.name try: # 如果是目录,且目录内有内容 if item.is_dir() and any(item.iterdir()): if target_path.exists(): shutil.rmtree(target_path) shutil.copytree(item, target_path) logger.info(f"已恢复插件目录: {item.name}") restored_count += 1 # 如果是文件 elif item.is_file(): shutil.copy2(item, target_path) logger.info(f"已恢复插件文件: {item.name}") restored_count += 1 except Exception as e: logger.error(f"恢复插件 {item.name} 时发生错误: {str(e)}") continue logger.info(f"插件恢复完成,共恢复 {restored_count} 个项目") # 安装缺少的依赖 PluginManager.install_plugin_missing_dependencies() # 删除备份目录 try: shutil.rmtree(backup_dir) logger.info(f"已删除插件备份目录: {backup_dir}") except Exception as e: logger.warning(f"删除备份目录失败: {str(e)}") def __get_version_message(self) -> str: """ 获取版本信息文本 """ server_release_version = self.__get_server_release_version() front_release_version = self.__get_front_release_version() server_local_version = self.get_server_local_version() front_local_version = self.get_frontend_version() if server_release_version == server_local_version: title = f"当前后端版本:{server_local_version},已是最新版本\n" else: title = f"当前后端版本:{server_local_version},远程版本:{server_release_version}\n" if front_release_version == front_local_version: title += f"当前前端版本:{front_local_version},已是最新版本" else: title += f"当前前端版本:{front_local_version},远程版本:{front_release_version}" return title def version(self, channel: MessageChannel, userid: Union[int, str], source: Optional[str] = None): """ 查看当前版本、远程版本 """ self.post_message(Notification(channel=channel, source=source, title=self.__get_version_message(), userid=userid)) def restart_finish(self): """ 如通过交互命令重启, 重启完发送msg """ # 重启消息 restart_channel = self.load_cache(self._restart_file) if restart_channel: # 发送重启完成msg if not isinstance(restart_channel, dict): restart_channel = json.loads(restart_channel) channel = next( (channel for channel in MessageChannel.__members__.values() if channel.value == restart_channel.get('channel')), None) userid = restart_channel.get('userid') # 版本号 title = self.__get_version_message() self.post_message(Notification(channel=channel, title=f"系统已重启完成!\n{title}", userid=userid)) self.remove_cache(self._restart_file) @staticmethod def __get_server_release_version(): """ 获取后端V2最新版本 """ try: # 获取所有发布的版本列表 response = RequestUtils( proxies=settings.PROXY, headers=settings.GITHUB_HEADERS ).get_res("https://api.github.com/repos/jxxghp/MoviePilot/releases") if response: releases = [release['tag_name'] for release in response.json()] v2_releases = [tag for tag in releases if re.match(r"^v2\.", tag)] if not v2_releases: logger.warn("获取v2后端最新版本版本出错!") else: # 找到最新的v2版本 latest_v2 = sorted(v2_releases, key=lambda s: list(map(int, re.findall(r'\d+', s))))[-1] logger.info(f"获取到后端最新版本:{latest_v2}") return latest_v2 else: logger.error("无法获取后端版本信息,请检查网络连接或GitHub API请求。") except Exception as err: logger.error(f"获取后端最新版本失败:{str(err)}") return None @staticmethod def __get_front_release_version(): """ 获取前端V2最新版本 """ try: # 获取所有发布的版本列表 response = RequestUtils( proxies=settings.PROXY, headers=settings.GITHUB_HEADERS ).get_res("https://api.github.com/repos/jxxghp/MoviePilot-Frontend/releases") if response: releases = [release['tag_name'] for release in response.json()] v2_releases = [tag for tag in releases if re.match(r"^v2\.", tag)] if not v2_releases: logger.warn("获取v2前端最新版本版本出错!") else: # 找到最新的v2版本 latest_v2 = sorted(v2_releases, key=lambda s: list(map(int, re.findall(r'\d+', s))))[-1] logger.info(f"获取到前端最新版本:{latest_v2}") return latest_v2 else: logger.error("无法获取前端版本信息,请检查网络连接或GitHub API请求。") except Exception as err: logger.error(f"获取前端最新版本失败:{str(err)}") return None @staticmethod def get_server_local_version(): """ 查看当前版本 """ return APP_VERSION @staticmethod def get_frontend_version(): """ 获取前端版本 """ if SystemUtils.is_frozen() and SystemUtils.is_windows(): version_file = settings.CONFIG_PATH.parent / "nginx" / "html" / "version.txt" else: version_file = Path(settings.FRONTEND_PATH) / "version.txt" if version_file.exists(): try: with open(version_file, 'r') as f: version = str(f.read()).strip() return version except Exception as err: logger.debug(f"加载版本文件 {version_file} 出错:{str(err)}") return FRONTEND_VERSION ================================================ FILE: app/chain/tmdb.py ================================================ import random from typing import Optional, List from app import schemas from app.chain import ChainBase from app.core.context import MediaInfo from app.schemas import MediaType class TmdbChain(ChainBase): """ TheMovieDB处理链,单例运行 """ def tmdb_discover(self, mtype: MediaType, sort_by: str, with_genres: str, with_original_language: str, with_keywords: str, with_watch_providers: str, vote_average: float, vote_count: int, release_date: str, page: Optional[int] = 1) -> Optional[List[MediaInfo]]: """ :param mtype: 媒体类型 :param sort_by: 排序方式 :param with_genres: 类型 :param with_original_language: 语言 :param with_keywords: 关键字 :param with_watch_providers: 提供商 :param vote_average: 评分 :param vote_count: 评分人数 :param release_date: 上映日期 :param page: 页码 :return: 媒体信息列表 """ return self.run_module("tmdb_discover", mtype=mtype, sort_by=sort_by, with_genres=with_genres, with_original_language=with_original_language, with_keywords=with_keywords, with_watch_providers=with_watch_providers, vote_average=vote_average, vote_count=vote_count, release_date=release_date, page=page) def tmdb_trending(self, page: Optional[int] = 1) -> Optional[List[MediaInfo]]: """ TMDB流行趋势 :param page: 第几页 :return: TMDB信息列表 """ return self.run_module("tmdb_trending", page=page) def tmdb_collection(self, collection_id: int) -> Optional[List[MediaInfo]]: """ 根据合集ID查询集合 :param collection_id: 合集ID """ return self.run_module("tmdb_collection", collection_id=collection_id) def tmdb_seasons(self, tmdbid: int) -> List[schemas.TmdbSeason]: """ 根据TMDBID查询themoviedb所有季信息 :param tmdbid: TMDBID """ return self.run_module("tmdb_seasons", tmdbid=tmdbid) def tmdb_group_seasons(self, group_id: str) -> List[schemas.TmdbSeason]: """ 根据剧集组ID查询themoviedb所有季集信息 :param group_id: 剧集组ID """ return self.run_module("tmdb_group_seasons", group_id=group_id) def tmdb_episodes(self, tmdbid: int, season: int, episode_group: Optional[str] = None) -> List[schemas.TmdbEpisode]: """ 根据TMDBID查询某季的所有信信息 :param tmdbid: TMDBID :param season: 季 :param episode_group: 剧集组 """ return self.run_module("tmdb_episodes", tmdbid=tmdbid, season=season, episode_group=episode_group) def movie_similar(self, tmdbid: int) -> Optional[List[MediaInfo]]: """ 根据TMDBID查询类似电影 :param tmdbid: TMDBID """ return self.run_module("tmdb_movie_similar", tmdbid=tmdbid) def tv_similar(self, tmdbid: int) -> Optional[List[MediaInfo]]: """ 根据TMDBID查询类似电视剧 :param tmdbid: TMDBID """ return self.run_module("tmdb_tv_similar", tmdbid=tmdbid) def movie_recommend(self, tmdbid: int) -> Optional[List[MediaInfo]]: """ 根据TMDBID查询推荐电影 :param tmdbid: TMDBID """ return self.run_module("tmdb_movie_recommend", tmdbid=tmdbid) def tv_recommend(self, tmdbid: int) -> Optional[List[MediaInfo]]: """ 根据TMDBID查询推荐电视剧 :param tmdbid: TMDBID """ return self.run_module("tmdb_tv_recommend", tmdbid=tmdbid) def movie_credits(self, tmdbid: int, page: Optional[int] = 1) -> Optional[List[schemas.MediaPerson]]: """ 根据TMDBID查询电影演职人员 :param tmdbid: TMDBID :param page: 页码 """ return self.run_module("tmdb_movie_credits", tmdbid=tmdbid, page=page) def tv_credits(self, tmdbid: int, page: Optional[int] = 1) -> Optional[List[schemas.MediaPerson]]: """ 根据TMDBID查询电视剧演职人员 :param tmdbid: TMDBID :param page: 页码 """ return self.run_module("tmdb_tv_credits", tmdbid=tmdbid, page=page) def person_detail(self, person_id: int) -> Optional[schemas.MediaPerson]: """ 根据TMDBID查询演职员详情 :param person_id: 人物ID """ return self.run_module("tmdb_person_detail", person_id=person_id) def person_credits(self, person_id: int, page: Optional[int] = 1) -> Optional[List[MediaInfo]]: """ 根据人物ID查询人物参演作品 :param person_id: 人物ID :param page: 页码 """ return self.run_module("tmdb_person_credits", person_id=person_id, page=page) def get_random_wallpager(self) -> Optional[str]: """ 获取随机壁纸,缓存1个小时 """ infos = self.tmdb_trending() if infos: # 随机一个电影 while True: info = random.choice(infos) if info and info.backdrop_path: return info.backdrop_path return None def get_trending_wallpapers(self, num: Optional[int] = 10) -> List[str]: """ 获取所有流行壁纸 """ infos = self.tmdb_trending() if infos: return [info.backdrop_path for info in infos if info and info.backdrop_path][:num] return [] async def async_tmdb_discover(self, mtype: MediaType, sort_by: str, with_genres: str, with_original_language: str, with_keywords: str, with_watch_providers: str, vote_average: float, vote_count: int, release_date: str, page: Optional[int] = 1) -> Optional[List[MediaInfo]]: """ 发现TMDB电影、剧集(异步版本) :param mtype: 媒体类型 :param sort_by: 排序方式 :param with_genres: 类型 :param with_original_language: 语言 :param with_keywords: 关键字 :param with_watch_providers: 提供商 :param vote_average: 评分 :param vote_count: 评分人数 :param release_date: 上映日期 :param page: 页码 :return: 媒体信息列表 """ return await self.async_run_module("async_tmdb_discover", mtype=mtype, sort_by=sort_by, with_genres=with_genres, with_original_language=with_original_language, with_keywords=with_keywords, with_watch_providers=with_watch_providers, vote_average=vote_average, vote_count=vote_count, release_date=release_date, page=page) async def async_tmdb_trending(self, page: Optional[int] = 1) -> Optional[List[MediaInfo]]: """ TMDB流行趋势(异步版本) :param page: 第几页 :return: TMDB信息列表 """ return await self.async_run_module("async_tmdb_trending", page=page) async def async_tmdb_collection(self, collection_id: int) -> Optional[List[MediaInfo]]: """ 根据合集ID查询集合(异步版本) :param collection_id: 合集ID """ return await self.async_run_module("async_tmdb_collection", collection_id=collection_id) async def async_tmdb_seasons(self, tmdbid: int) -> List[schemas.TmdbSeason]: """ 根据TMDBID查询themoviedb所有季信息(异步版本) :param tmdbid: TMDBID """ return await self.async_run_module("async_tmdb_seasons", tmdbid=tmdbid) async def async_tmdb_group_seasons(self, group_id: str) -> List[schemas.TmdbSeason]: """ 根据剧集组ID查询themoviedb所有季集信息(异步版本) :param group_id: 剧集组ID """ return await self.async_run_module("async_tmdb_group_seasons", group_id=group_id) async def async_tmdb_episodes(self, tmdbid: int, season: int, episode_group: Optional[str] = None) -> List[schemas.TmdbEpisode]: """ 根据TMDBID查询某季的所有信信息(异步版本) :param tmdbid: TMDBID :param season: 季 :param episode_group: 剧集组 """ return await self.async_run_module("async_tmdb_episodes", tmdbid=tmdbid, season=season, episode_group=episode_group) async def async_movie_similar(self, tmdbid: int) -> Optional[List[MediaInfo]]: """ 根据TMDBID查询类似电影(异步版本) :param tmdbid: TMDBID """ return await self.async_run_module("async_tmdb_movie_similar", tmdbid=tmdbid) async def async_tv_similar(self, tmdbid: int) -> Optional[List[MediaInfo]]: """ 根据TMDBID查询类似电视剧(异步版本) :param tmdbid: TMDBID """ return await self.async_run_module("async_tmdb_tv_similar", tmdbid=tmdbid) async def async_movie_recommend(self, tmdbid: int) -> Optional[List[MediaInfo]]: """ 根据TMDBID查询推荐电影(异步版本) :param tmdbid: TMDBID """ return await self.async_run_module("async_tmdb_movie_recommend", tmdbid=tmdbid) async def async_tv_recommend(self, tmdbid: int) -> Optional[List[MediaInfo]]: """ 根据TMDBID查询推荐电视剧(异步版本) :param tmdbid: TMDBID """ return await self.async_run_module("async_tmdb_tv_recommend", tmdbid=tmdbid) async def async_movie_credits(self, tmdbid: int, page: Optional[int] = 1) -> Optional[List[schemas.MediaPerson]]: """ 根据TMDBID查询电影演职人员(异步版本) :param tmdbid: TMDBID :param page: 页码 """ return await self.async_run_module("async_tmdb_movie_credits", tmdbid=tmdbid, page=page) async def async_tv_credits(self, tmdbid: int, page: Optional[int] = 1) -> Optional[List[schemas.MediaPerson]]: """ 根据TMDBID查询电视剧演职人员(异步版本) :param tmdbid: TMDBID :param page: 页码 """ return await self.async_run_module("async_tmdb_tv_credits", tmdbid=tmdbid, page=page) async def async_person_detail(self, person_id: int) -> Optional[schemas.MediaPerson]: """ 根据TMDBID查询演职员详情(异步版本) :param person_id: 人物ID """ return await self.async_run_module("async_tmdb_person_detail", person_id=person_id) async def async_person_credits(self, person_id: int, page: Optional[int] = 1) -> Optional[List[MediaInfo]]: """ 根据人物ID查询人物参演作品(异步版本) :param person_id: 人物ID :param page: 页码 """ return await self.async_run_module("async_tmdb_person_credits", person_id=person_id, page=page) async def async_get_random_wallpager(self) -> Optional[str]: """ 获取随机壁纸(异步版本),缓存1个小时 """ infos = await self.async_tmdb_trending() if infos: # 随机一个电影 while True: info = random.choice(infos) if info and info.backdrop_path: return info.backdrop_path return None async def async_get_trending_wallpapers(self, num: Optional[int] = 10) -> List[str]: """ 获取所有流行壁纸(异步版本) """ infos = await self.async_tmdb_trending() if infos: return [info.backdrop_path for info in infos if info and info.backdrop_path][:num] return [] ================================================ FILE: app/chain/torrents.py ================================================ import re import traceback from typing import Dict, List, Union, Optional from app.chain import ChainBase from app.chain.media import MediaChain from app.core.config import settings, global_vars from app.core.context import TorrentInfo, Context, MediaInfo from app.core.metainfo import MetaInfo from app.db.site_oper import SiteOper from app.db.systemconfig_oper import SystemConfigOper from app.helper.rss import RssHelper from app.helper.sites import SitesHelper # noqa from app.helper.torrent import TorrentHelper from app.log import logger from app.schemas import Notification from app.schemas.types import SystemConfigKey, MessageChannel, NotificationType, MediaType from app.utils.string import StringUtils class TorrentsChain(ChainBase): """ 站点首页或RSS种子处理链,服务于订阅、刷流等 """ _spider_file = "__torrents_cache__" _rss_file = "__rss_cache__" @property def cache_file(self) -> str: """ 返回缓存文件列表 """ if settings.SUBSCRIBE_MODE == 'spider': return self._spider_file return self._rss_file def remote_refresh(self, channel: MessageChannel, userid: Union[str, int] = None): """ 远程刷新订阅,发送消息 """ self.post_message(Notification(channel=channel, title=f"开始刷新种子 ...", userid=userid)) self.refresh() self.post_message(Notification(channel=channel, title=f"种子刷新完成!", userid=userid)) def get_torrents(self, stype: Optional[str] = None) -> Dict[str, List[Context]]: """ 获取当前缓存的种子 :param stype: 强制指定缓存类型,spider:爬虫缓存,rss:rss缓存 """ if not stype: stype = settings.SUBSCRIBE_MODE # 读取缓存 if stype == 'spider': torrents_cache = self.load_cache(self._spider_file) or {} else: torrents_cache = self.load_cache(self._rss_file) or {} # 兼容性处理:为旧版本的Context对象添加失败次数字段 self._ensure_context_compatibility(torrents_cache) return torrents_cache async def async_get_torrents(self, stype: Optional[str] = None) -> Dict[str, List[Context]]: """ 异步获取当前缓存的种子 :param stype: 强制指定缓存类型,spider:爬虫缓存,rss:rss缓存 """ if not stype: stype = settings.SUBSCRIBE_MODE # 异步读取缓存 if stype == 'spider': torrents_cache = await self.async_load_cache(self._spider_file) or {} else: torrents_cache = await self.async_load_cache(self._rss_file) or {} # 兼容性处理:为旧版本的Context对象添加失败次数字段 self._ensure_context_compatibility(torrents_cache) return torrents_cache def clear_torrents(self): """ 清理种子缓存数据 """ logger.info(f'开始清理种子缓存数据 ...') self.remove_cache(self._spider_file) self.remove_cache(self._rss_file) logger.info(f'种子缓存数据清理完成') async def async_clear_torrents(self): """ 异步清理种子缓存数据 """ logger.info(f'开始异步清理种子缓存数据 ...') await self.async_remove_cache(self._spider_file) await self.async_remove_cache(self._rss_file) logger.info(f'异步种子缓存数据清理完成') def browse(self, domain: str, keyword: Optional[str] = None, cat: Optional[str] = None, page: Optional[int] = 0) -> List[TorrentInfo]: """ 浏览站点首页内容,返回种子清单,TTL缓存5分钟 :param domain: 站点域名 :param keyword: 搜索标题 :param cat: 搜索分类 :param page: 页码 """ logger.info(f'开始获取站点 {domain} 最新种子 ...') site = SitesHelper().get_indexer(domain) if not site: logger.error(f'站点 {domain} 不存在!') return [] return self.refresh_torrents(site=site, keyword=keyword, cat=cat, page=page) async def async_browse(self, domain: str, keyword: Optional[str] = None, cat: Optional[str] = None, page: Optional[int] = 0) -> List[TorrentInfo]: """ 异步浏览站点首页内容,返回种子清单,TTL缓存5分钟 :param domain: 站点域名 :param keyword: 搜索标题 :param cat: 搜索分类 :param page: 页码 """ logger.info(f'开始获取站点 {domain} 最新种子 ...') site = await SitesHelper().async_get_indexer(domain) if not site: logger.error(f'站点 {domain} 不存在!') return [] return await self.async_refresh_torrents(site=site, keyword=keyword, cat=cat, page=page) def rss(self, domain: str) -> List[TorrentInfo]: """ 获取站点RSS内容,返回种子清单,TTL缓存3分钟 :param domain: 站点域名 """ logger.info(f'开始获取站点 {domain} RSS ...') site = SitesHelper().get_indexer(domain) if not site: logger.error(f'站点 {domain} 不存在!') return [] if not site.get("rss"): logger.error(f'站点 {domain} 未配置RSS地址!') return [] # 解析RSS rss_items = RssHelper().parse(site.get("rss"), True if site.get("proxy") else False, timeout=int(site.get("timeout") or 30), ua=site.get("ua") if site.get("ua") else None) if rss_items is None: # rss过期,尝试保留原配置生成新的rss self.__renew_rss_url(domain=domain, site=site) return [] if not rss_items: logger.error(f'站点 {domain} 未获取到RSS数据!') return [] # 组装种子 ret_torrents: List[TorrentInfo] = [] try: for item in rss_items: if not item.get("title"): continue torrentinfo = TorrentInfo( site=site.get("id"), site_name=site.get("name"), site_cookie=site.get("cookie"), site_ua=site.get("ua") or settings.USER_AGENT, site_proxy=site.get("proxy"), site_order=site.get("pri"), site_downloader=site.get("downloader"), title=item.get("title"), enclosure=item.get("enclosure"), page_url=item.get("link"), size=item.get("size"), pubdate=item["pubdate"].strftime("%Y-%m-%d %H:%M:%S") if item.get("pubdate") else None, ) ret_torrents.append(torrentinfo) finally: rss_items.clear() del rss_items return ret_torrents def refresh(self, stype: Optional[str] = None, sites: List[int] = None) -> Dict[str, List[Context]]: """ 刷新站点最新资源,识别并缓存起来 :param stype: 强制指定缓存类型,spider:爬虫缓存,rss:rss缓存 :param sites: 强制指定站点ID列表,为空则读取设置的订阅站点 """ def __is_no_cache_site(_domain: str) -> bool: """ 判断站点是否不需要缓存 """ for url_key in settings.NO_CACHE_SITE_KEY.split(','): if url_key in _domain: return True return False # 刷新类型 if not stype: stype = settings.SUBSCRIBE_MODE # 刷新站点 if not sites: sites = SystemConfigOper().get(SystemConfigKey.RssSites) or [] # 读取缓存 torrents_cache = self.get_torrents() # 缓存过滤掉无效种子 for _domain, _torrents in torrents_cache.items(): torrents_cache[_domain] = [_torrent for _torrent in _torrents if not TorrentHelper().is_invalid(_torrent.torrent_info.enclosure)] # 需要刷新的站点domain domains = [] # 遍历站点缓存资源 for indexer in SitesHelper().get_indexers(): if global_vars.is_system_stopped: break # 未开启的站点不刷新 if sites and indexer.get("id") not in sites: continue domain = StringUtils.get_url_domain(indexer.get("domain")) domains.append(domain) if stype == "spider": # 刷新首页种子 torrents: List[TorrentInfo] = [] # 读取第0页和第1页 for page in range(2): page_torrents = self.browse(domain=domain, page=page) if page_torrents: torrents.extend(page_torrents) else: # 如果某一页没有数据,说明已经到最后一页,停止获取 break else: # 刷新RSS种子 torrents: List[TorrentInfo] = self.rss(domain=domain) # 按pubdate降序排列 torrents.sort(key=lambda x: x.pubdate or '', reverse=True) # 取前N条 torrents = torrents[:settings.CONF.refresh] if torrents: if __is_no_cache_site(domain): # 不需要缓存的站点,直接处理 logger.info(f'{indexer.get("name")} 有 {len(torrents)} 个种子 (不缓存)') torrents_cache[domain] = [] else: # 过滤出没有处理过的种子 - 优化:使用集合查找,避免重复创建字符串列表 cached_signatures = {f'{t.torrent_info.title}{t.torrent_info.description}' for t in torrents_cache.get(domain) or []} torrents = [torrent for torrent in torrents if f'{torrent.title}{torrent.description}' not in cached_signatures] if torrents: logger.info(f'{indexer.get("name")} 有 {len(torrents)} 个新种子') else: logger.info(f'{indexer.get("name")} 没有新种子') continue try: for torrent in torrents: if global_vars.is_system_stopped: break if not torrent.enclosure: logger.warn(f"缺少种子链接,忽略处理: {torrent.title}") continue logger.info(f'处理资源:{torrent.title} ...') # 识别 meta = MetaInfo(title=torrent.title, subtitle=torrent.description) if torrent.title != meta.org_string: logger.info(f'种子名称应用识别词后发生改变:{torrent.title} => {meta.org_string}') # 使用站点种子分类,校正类型识别 if meta.type != MediaType.TV \ and torrent.category == MediaType.TV.value: meta.type = MediaType.TV # 识别媒体信息 mediainfo: MediaInfo = MediaChain().recognize_by_meta(meta) if not mediainfo: logger.warn(f'{torrent.title} 未识别到媒体信息') # 存储空的媒体信息 mediainfo = MediaInfo() # 清理多余数据,减少内存占用 mediainfo.clear() # 上下文 context = Context(meta_info=meta, media_info=mediainfo, torrent_info=torrent) # 如果未识别到媒体信息,设置初始失败次数为1 if not mediainfo or (not mediainfo.tmdb_id and not mediainfo.douban_id): context.media_recognize_fail_count = 1 # 添加到缓存 if not torrents_cache.get(domain): torrents_cache[domain] = [context] else: torrents_cache[domain].append(context) # 如果超过了限制条数则移除掉前面的 if len(torrents_cache[domain]) > settings.CONF.torrents: torrents_cache[domain] = torrents_cache[domain][-settings.CONF.torrents:] finally: torrents.clear() del torrents else: logger.info(f'{indexer.get("name")} 没有获取到种子') # 保存缓存到本地 if stype == "spider": self.save_cache(torrents_cache, self._spider_file) else: self.save_cache(torrents_cache, self._rss_file) # 去除不在站点范围内的缓存种子 if sites and torrents_cache: torrents_cache = {k: v for k, v in torrents_cache.items() if k in domains} return torrents_cache @staticmethod def _ensure_context_compatibility(torrents_cache: Dict[str, List[Context]]): """ 确保Context对象的兼容性,为旧版本添加缺失的字段 """ for domain, contexts in torrents_cache.items(): for context in contexts: # 如果Context对象没有media_recognize_fail_count字段,添加默认值 if not hasattr(context, 'media_recognize_fail_count'): context.media_recognize_fail_count = 0 # 如果媒体信息未识别,设置初始失败次数 if (not context.media_info or (not context.media_info.tmdb_id and not context.media_info.douban_id)): context.media_recognize_fail_count = 1 def __renew_rss_url(self, domain: str, site: dict): """ 保留原配置生成新的rss地址 """ try: # RSS链接过期 logger.error(f"站点 {domain} RSS链接已过期,正在尝试自动获取!") # 自动生成rss地址 rss_url, errmsg = RssHelper().get_rss_link( url=site.get("url"), cookie=site.get("cookie"), ua=site.get("ua") or settings.USER_AGENT, proxy=True if site.get("proxy") else False, timeout=site.get("timeout"), ) if rss_url: # 获取新的日期的passkey match = re.search(r'passkey=([a-zA-Z0-9]+)', rss_url) if match: new_passkey = match.group(1) # 获取过期rss除去passkey部分 new_rss = re.sub(r'&passkey=([a-zA-Z0-9]+)', f'&passkey={new_passkey}', site.get("rss")) logger.info(f"更新站点 {domain} RSS地址 ...") SiteOper().update_rss(domain=domain, rss=new_rss) else: # 发送消息 self.post_message( Notification(mtype=NotificationType.SiteMessage, title=f"站点 {domain} RSS链接已过期", link=settings.MP_DOMAIN('#/site')) ) else: self.post_message( Notification(mtype=NotificationType.SiteMessage, title=f"站点 {domain} RSS链接已过期", link=settings.MP_DOMAIN('#/site'))) except Exception as e: logger.error(f"站点 {domain} RSS链接自动获取失败:{str(e)} - {traceback.format_exc()}") self.post_message(Notification(mtype=NotificationType.SiteMessage, title=f"站点 {domain} RSS链接已过期", link=settings.MP_DOMAIN('#/site'))) ================================================ FILE: app/chain/transfer.py ================================================ import queue import re import threading import traceback from copy import deepcopy from pathlib import Path from typing import List, Optional, Tuple, Union, Dict, Callable from app import schemas from app.chain import ChainBase from app.chain.media import MediaChain from app.chain.storage import StorageChain from app.chain.subscribe import SubscribeChain from app.chain.tmdb import TmdbChain from app.core.config import settings, global_vars from app.core.context import MediaInfo from app.core.event import eventmanager from app.core.meta import MetaBase from app.core.metainfo import MetaInfoPath from app.db.downloadhistory_oper import DownloadHistoryOper from app.db.models.downloadhistory import DownloadHistory from app.db.models.transferhistory import TransferHistory from app.db.systemconfig_oper import SystemConfigOper from app.db.transferhistory_oper import TransferHistoryOper from app.helper.directory import DirectoryHelper from app.helper.format import FormatParser from app.helper.progress import ProgressHelper from app.log import logger from app.schemas import StorageOperSelectionEventData from app.schemas import TransferInfo, Notification, EpisodeFormat, FileItem, TransferDirectoryConf, \ TransferTask, TransferQueue, TransferJob, TransferJobTask from app.schemas.exception import OperationInterrupted from app.schemas.types import TorrentStatus, EventType, MediaType, ProgressKey, NotificationType, MessageChannel, \ SystemConfigKey, ChainEventType, ContentType from app.utils.mixins import ConfigReloadMixin from app.utils.singleton import Singleton from app.utils.string import StringUtils from app.utils.system import SystemUtils # 下载器锁 downloader_lock = threading.Lock() # 作业锁 job_lock = threading.Lock() # 任务锁 task_lock = threading.Lock() class JobManager: """ 作业管理器 task任务负责一个文件的整理,job作业负责一个媒体的整理 """ # 整理中的作业 _job_view: Dict[Tuple, TransferJob] = {} # 汇总季集清单 _season_episodes: Dict[Tuple, List[int]] = {} def __init__(self): self._job_view = {} self._season_episodes = {} @staticmethod def __get_meta_id(meta: MetaBase = None, season: Optional[int] = None) -> Tuple: """ 获取元数据ID """ return meta.name, season @staticmethod def __get_media_id(media: MediaInfo = None, season: Optional[int] = None) -> Tuple: """ 获取媒体ID """ if not media: return None, season return media.tmdb_id or media.douban_id, season def __get_id(self, task: TransferTask = None) -> Tuple: """ 获取作业ID """ if task.mediainfo: return self.__get_media_id(media=task.mediainfo, season=task.meta.begin_season) else: return self.__get_meta_id(meta=task.meta, season=task.meta.begin_season) @staticmethod def __get_media(task: TransferTask) -> schemas.MediaInfo: """ 获取媒体信息 """ if task.mediainfo: # 有媒体信息 mediainfo = deepcopy(task.mediainfo) mediainfo.clear() return schemas.MediaInfo(**mediainfo.to_dict()) else: # 没有媒体信息 meta: MetaBase = task.meta return schemas.MediaInfo( title=meta.name, year=meta.year, title_year=f"{meta.name} ({meta.year})", type=meta.type.value if meta.type else None ) @staticmethod def __get_meta(task: TransferTask) -> schemas.MetaInfo: """ 获取元数据 """ return schemas.MetaInfo(**task.meta.to_dict()) def add_task(self, task: TransferTask, state: Optional[str] = "waiting") -> bool: """ 添加整理任务,自动分组到对应的作业中 :return: True表示任务已添加,False表示任务无效或已存在(重复) """ if not all([task, task.meta, task.fileitem]): return False with job_lock: __mediaid__ = self.__get_id(task) if __mediaid__ not in self._job_view: self._job_view[__mediaid__] = TransferJob( media=self.__get_media(task), season=task.meta.begin_season, tasks=[TransferJobTask( fileitem=task.fileitem, meta=self.__get_meta(task), downloader=task.downloader, download_hash=task.download_hash, state=state )] ) else: # 不重复添加任务 if any([t.fileitem == task.fileitem for t in self._job_view[__mediaid__].tasks]): logger.debug(f"任务 {task.fileitem.name} 已存在,跳过重复添加") return False self._job_view[__mediaid__].tasks.append( TransferJobTask( fileitem=task.fileitem, meta=self.__get_meta(task), downloader=task.downloader, download_hash=task.download_hash, state=state ) ) # 添加季集信息 if self._season_episodes.get(__mediaid__): self._season_episodes[__mediaid__].extend(task.meta.episode_list) self._season_episodes[__mediaid__] = list(set(self._season_episodes[__mediaid__])) else: self._season_episodes[__mediaid__] = task.meta.episode_list return True def running_task(self, task: TransferTask): """ 设置任务为运行中 """ with job_lock: __mediaid__ = self.__get_id(task) if __mediaid__ not in self._job_view: return # 更新状态 for t in self._job_view[__mediaid__].tasks: if t.fileitem == task.fileitem: t.state = "running" break def finish_task(self, task: TransferTask): """ 设置任务为完成/成功 """ with job_lock: __mediaid__ = self.__get_id(task) if __mediaid__ not in self._job_view: return # 更新状态 for t in self._job_view[__mediaid__].tasks: if t.fileitem == task.fileitem: t.state = "completed" break def fail_task(self, task: TransferTask): """ 设置任务为失败 """ with job_lock: __mediaid__ = self.__get_id(task) if __mediaid__ not in self._job_view: return # 更新状态 for t in self._job_view[__mediaid__].tasks: if t.fileitem == task.fileitem: t.state = "failed" break # 移除剧集信息 if __mediaid__ in self._season_episodes: self._season_episodes[__mediaid__] = list( set(self._season_episodes[__mediaid__]) - set(task.meta.episode_list) ) def remove_task(self, fileitem: FileItem) -> Optional[TransferJobTask]: """ 根据文件项移除任务 """ with job_lock: for mediaid in list(self._job_view): job = self._job_view[mediaid] for task in job.tasks: if task.fileitem == fileitem: job.tasks.remove(task) # 如果没有作业了,则移除作业 if not job.tasks: self._job_view.pop(mediaid) # 移除季集信息 if mediaid in self._season_episodes: self._season_episodes[mediaid] = list( set(self._season_episodes[mediaid]) - set(task.meta.episode_list) ) return task return None def remove_job(self, task: TransferTask) -> Optional[TransferJob]: """ 移除任务对应的作业(强制,线程不安全) """ with job_lock: __mediaid__ = self.__get_id(task) if __mediaid__ in self._job_view: # 移除季集信息 if __mediaid__ in self._season_episodes: self._season_episodes.pop(__mediaid__) return self._job_view.pop(__mediaid__) return None def try_remove_job(self, task: TransferTask): """ 尝试移除任务对应的作业(严格检查未完成作业,线程安全) """ with job_lock: __metaid__ = self.__get_meta_id(meta=task.meta, season=task.meta.begin_season) __mediaid__ = self.__get_media_id(media=task.mediainfo, season=task.meta.begin_season) meta_done = True if __metaid__ in self._job_view: meta_done = all( t.state in ["completed", "failed"] for t in self._job_view[__metaid__].tasks ) media_done = True if __mediaid__ in self._job_view: media_done = all( t.state in ["completed", "failed"] for t in self._job_view[__mediaid__].tasks ) if meta_done and media_done: __id__ = self.__get_id(task) if __id__ in self._job_view: # 移除季集信息 if __id__ in self._season_episodes: self._season_episodes.pop(__id__) self._job_view.pop(__id__) def is_done(self, task: TransferTask) -> bool: """ 检查任务对应的作业是否整理完成(不管成功还是失败) """ with job_lock: __metaid__ = self.__get_meta_id(meta=task.meta, season=task.meta.begin_season) __mediaid__ = self.__get_media_id(media=task.mediainfo, season=task.meta.begin_season) if __metaid__ in self._job_view: meta_done = all( task.state in ["completed", "failed"] for task in self._job_view[__metaid__].tasks ) else: meta_done = True if __mediaid__ in self._job_view: media_done = all( task.state in ["completed", "failed"] for task in self._job_view[__mediaid__].tasks ) else: media_done = True return meta_done and media_done def is_finished(self, task: TransferTask) -> bool: """ 检查任务对应的作业是否已完成且有成功的记录 """ with job_lock: __metaid__ = self.__get_meta_id(meta=task.meta, season=task.meta.begin_season) __mediaid__ = self.__get_media_id(media=task.mediainfo, season=task.meta.begin_season) if __metaid__ in self._job_view: meta_finished = all( task.state in ["completed", "failed"] for task in self._job_view[__metaid__].tasks ) else: meta_finished = True if __mediaid__ in self._job_view: tasks = self._job_view[__mediaid__].tasks media_finished = all( task.state in ["completed", "failed"] for task in tasks ) and any( task.state == "completed" for task in tasks ) else: media_finished = True return meta_finished and media_finished def is_success(self, task: TransferTask) -> bool: """ 检查任务对应的作业是否全部成功 """ with job_lock: __metaid__ = self.__get_meta_id(meta=task.meta, season=task.meta.begin_season) __mediaid__ = self.__get_media_id(media=task.mediainfo, season=task.meta.begin_season) if __metaid__ in self._job_view: meta_success = all( task.state in ["completed"] for task in self._job_view[__metaid__].tasks ) else: meta_success = True if __mediaid__ in self._job_view: media_success = all( task.state in ["completed"] for task in self._job_view[__mediaid__].tasks ) else: media_success = True return meta_success and media_success def get_all_torrent_hashes(self) -> set[str]: """ 获取所有种子的哈希值集合 """ with job_lock: return { task.download_hash for job in self._job_view.values() for task in job.tasks } def is_torrent_done(self, download_hash: str) -> bool: """ 检查指定种子的所有任务是否都已完成 """ with job_lock: if any( task.state not in {"completed", "failed"} for job in self._job_view.values() for task in job.tasks if task.download_hash == download_hash ): return False return True def is_torrent_success(self, download_hash: str) -> bool: """ 检查指定种子的所有任务是否都已成功 """ with job_lock: if any( task.state != "completed" for job in self._job_view.values() for task in job.tasks if task.download_hash == download_hash ): return False return True def has_tasks(self, meta: MetaBase, mediainfo: Optional[MediaInfo] = None, season: Optional[int] = None) -> bool: """ 判断作业是否还有任务正在处理 """ with job_lock: if mediainfo: __mediaid__ = self.__get_media_id(media=mediainfo, season=season) if __mediaid__ in self._job_view: return True __metaid__ = self.__get_meta_id(meta=meta, season=season) return __metaid__ in self._job_view and len(self._job_view[__metaid__].tasks) > 0 def success_tasks(self, media: MediaInfo, season: Optional[int] = None) -> List[TransferJobTask]: """ 获取作业中所有成功的任务 """ with job_lock: __mediaid__ = self.__get_media_id(media=media, season=season) if __mediaid__ not in self._job_view: return [] return [task for task in self._job_view[__mediaid__].tasks if task.state == "completed"] def all_tasks(self, media: MediaInfo, season: Optional[int] = None) -> List[TransferJobTask]: """ 获取作业中全部任务 """ with job_lock: __mediaid__ = self.__get_media_id(media=media, season=season) if __mediaid__ not in self._job_view: return [] return self._job_view[__mediaid__].tasks def count(self, media: MediaInfo, season: Optional[int] = None) -> int: """ 获取作业中成功总数 """ with job_lock: __mediaid__ = self.__get_media_id(media=media, season=season) if __mediaid__ not in self._job_view: return 0 return len([task for task in self._job_view[__mediaid__].tasks if task.state == "completed"]) def size(self, media: MediaInfo, season: Optional[int] = None) -> int: """ 获取作业中所有成功文件总大小 """ with job_lock: __mediaid__ = self.__get_media_id(media=media, season=season) if __mediaid__ not in self._job_view: return 0 return sum([ task.fileitem.size if task.fileitem.size is not None else ( SystemUtils.get_directory_size(Path(task.fileitem.path)) if task.fileitem.storage == "local" else 0) for task in self._job_view[__mediaid__].tasks if task.state == "completed" ]) def total(self) -> int: """ 获取所有任务总数 """ with job_lock: return sum([len(job.tasks) for job in self._job_view.values()]) def list_jobs(self) -> List[TransferJob]: """ 获取所有作业的任务列表 """ with job_lock: return list(self._job_view.values()) def season_episodes(self, media: MediaInfo, season: Optional[int] = None) -> List[int]: """ 获取作业的季集清单 """ with job_lock: __mediaid__ = self.__get_media_id(media=media, season=season) return self._season_episodes.get(__mediaid__) or [] class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): """ 文件整理处理链 """ CONFIG_WATCH = { "TRANSFER_THREADS", } def __init__(self): super().__init__() # 主要媒体文件后缀 self._media_exts = settings.RMT_MEDIAEXT # 字幕文件后缀 self._subtitle_exts = settings.RMT_SUBEXT # 音频文件后缀 self._audio_exts = settings.RMT_AUDIOEXT # 可处理的文件后缀(视频文件、字幕、音频文件) self._allowed_exts = self._media_exts + self._audio_exts + self._subtitle_exts # 待整理任务队列 self._queue = queue.Queue() # 文件整理线程 self._transfer_threads = [] # 队列间隔时间(秒) self._transfer_interval = 15 # 事件管理器 self.jobview = JobManager() # 转移成功的文件清单 self._success_target_files: Dict[str, List[str]] = {} # 整理进度进度 self._progress = ProgressHelper(ProgressKey.FileTransfer) # 队列相关状态 self._threads = [] self._queue_active = False self._active_tasks = 0 self._processed_num = 0 self._fail_num = 0 self._total_num = 0 # 启动整理任务 self.__init() def __init(self): """ 启动文件整理线程 """ self._queue_active = True for i in range(settings.TRANSFER_THREADS): logger.info(f"启动文件整理线程 {i + 1} ...") thread = threading.Thread(target=self.__start_transfer, name=f"transfer-{i}", daemon=True) self._threads.append(thread) thread.start() def __stop(self): """ 停止文件整理进程 """ self._queue_active = False for thread in self._threads: thread.join() self._threads = [] logger.info("文件整理线程已停止") def on_config_changed(self): self.__stop() self.__init() def __is_subtitle_file(self, fileitem: FileItem) -> bool: """ 判断是否为字幕文件 """ if not fileitem.extension: return False return True if f".{fileitem.extension.lower()}" in self._subtitle_exts else False def __is_audio_file(self, fileitem: FileItem) -> bool: """ 判断是否为音频文件 """ if not fileitem.extension: return False return True if f".{fileitem.extension.lower()}" in self._audio_exts else False def __is_media_file(self, fileitem: FileItem) -> bool: """ 判断是否为主要媒体文件 """ if fileitem.type == "dir": # 蓝光原盘判断 return StorageChain().is_bluray_folder(fileitem) if not fileitem.extension: return False return True if f".{fileitem.extension.lower()}" in self._media_exts else False def __is_allowed_file(self, fileitem: FileItem) -> bool: """ 判断是否允许的扩展名 """ if not fileitem.extension: return False return True if f".{fileitem.extension.lower()}" in self._allowed_exts else False @staticmethod def __is_allow_filesize(fileitem: FileItem, min_filesize: int) -> bool: """ 判断是否满足最小文件大小 """ return True if not min_filesize or (fileitem.size or 0) > min_filesize * 1024 * 1024 else False def __default_callback(self, task: TransferTask, transferinfo: TransferInfo, /) -> Tuple[bool, str]: """ 整理完成后处理 """ # 状态 ret_status = True # 错误信息 ret_message = "" def __notify(): """ 完成时发送消息、刮削事件、移除任务等 """ # 更新文件数量 transferinfo.file_count = self.jobview.count(task.mediainfo, task.meta.begin_season) or 1 # 更新文件大小 transferinfo.total_size = self.jobview.size(task.mediainfo, task.meta.begin_season) or task.fileitem.size # 更新文件清单 with job_lock: transferinfo.file_list_new = self._success_target_files.pop(transferinfo.target_diritem.path, []) # 发送通知,实时手动整理时不发 if transferinfo.need_notify and (task.background or not task.manual): se_str = None if task.mediainfo.type == MediaType.TV: season_episodes = self.jobview.season_episodes(task.mediainfo, task.meta.begin_season) if season_episodes: se_str = f"{task.meta.season} {StringUtils.format_ep(season_episodes)}" else: se_str = f"{task.meta.season}" # 发送入库成功消息 self.send_transfer_message(meta=task.meta, mediainfo=task.mediainfo, transferinfo=transferinfo, season_episode=se_str, username=task.username) # 刮削事件 if transferinfo.need_scrape and self.__is_media_file(task.fileitem): self.eventmanager.send_event(EventType.MetadataScrape, { 'meta': task.meta, 'mediainfo': task.mediainfo, 'fileitem': transferinfo.target_diritem, 'file_list': transferinfo.file_list_new, 'overwrite': False }) transferhis = TransferHistoryOper() # 转移失败 if not transferinfo.success: logger.warn(f"{task.fileitem.name} 入库失败:{transferinfo.message}") # 新增转移失败历史记录 history = transferhis.add_fail( fileitem=task.fileitem, mode=transferinfo.transfer_type if transferinfo else '', downloader=task.downloader, download_hash=task.download_hash, meta=task.meta, mediainfo=task.mediainfo, transferinfo=transferinfo ) # 整理失败事件 if self.__is_media_file(task.fileitem): # 主要媒体文件整理失败事件 self.eventmanager.send_event(EventType.TransferFailed, { 'fileitem': task.fileitem, 'meta': task.meta, 'mediainfo': task.mediainfo, 'transferinfo': transferinfo, 'downloader': task.downloader, 'download_hash': task.download_hash, 'transfer_history_id': history.id if history else None, }) elif self.__is_subtitle_file(task.fileitem): # 字幕整理失败事件 self.eventmanager.send_event(EventType.SubtitleTransferFailed, { 'fileitem': task.fileitem, 'meta': task.meta, 'mediainfo': task.mediainfo, 'transferinfo': transferinfo, 'downloader': task.downloader, 'download_hash': task.download_hash, 'transfer_history_id': history.id if history else None, }) elif self.__is_audio_file(task.fileitem): # 音频文件整理失败事件 self.eventmanager.send_event(EventType.AudioTransferFailed, { 'fileitem': task.fileitem, 'meta': task.meta, 'mediainfo': task.mediainfo, 'transferinfo': transferinfo, 'downloader': task.downloader, 'download_hash': task.download_hash, 'transfer_history_id': history.id if history else None, }) # 发送失败消息 self.post_message(Notification( mtype=NotificationType.Manual, title=f"{task.mediainfo.title_year} {task.meta.season_episode} 入库失败!", text=f"原因:{transferinfo.message or '未知'}", image=task.mediainfo.get_message_image(), username=task.username, link=settings.MP_DOMAIN('#/history') )) # 设置任务失败 self.jobview.fail_task(task) # 返回失败 ret_status = False ret_message = transferinfo.message else: # 转移成功 logger.info(f"{task.fileitem.name} 入库成功:{transferinfo.target_diritem.path}") # 新增task转移成功历史记录 history = transferhis.add_success( fileitem=task.fileitem, mode=transferinfo.transfer_type if transferinfo else '', downloader=task.downloader, download_hash=task.download_hash, meta=task.meta, mediainfo=task.mediainfo, transferinfo=transferinfo ) # task整理完成事件 if self.__is_media_file(task.fileitem): # 主要媒体文件整理完成事件 self.eventmanager.send_event(EventType.TransferComplete, { 'fileitem': task.fileitem, 'meta': task.meta, 'mediainfo': task.mediainfo, 'transferinfo': transferinfo, 'downloader': task.downloader, 'download_hash': task.download_hash, 'transfer_history_id': history.id if history else None, }) elif self.__is_subtitle_file(task.fileitem): # 字幕整理完成事件 self.eventmanager.send_event(EventType.SubtitleTransferComplete, { 'fileitem': task.fileitem, 'meta': task.meta, 'mediainfo': task.mediainfo, 'transferinfo': transferinfo, 'downloader': task.downloader, 'download_hash': task.download_hash, 'transfer_history_id': history.id if history else None, }) elif self.__is_audio_file(task.fileitem): # 音频文件整理完成事件 self.eventmanager.send_event(EventType.AudioTransferComplete, { 'fileitem': task.fileitem, 'meta': task.meta, 'mediainfo': task.mediainfo, 'transferinfo': transferinfo, 'downloader': task.downloader, 'download_hash': task.download_hash, 'transfer_history_id': history.id if history else None, }) # task登记转移成功文件清单 target_dir_path = transferinfo.target_diritem.path target_files = transferinfo.file_list_new with job_lock: if self._success_target_files.get(target_dir_path): self._success_target_files[target_dir_path].extend(target_files) else: self._success_target_files[target_dir_path] = target_files # 设置任务成功 self.jobview.finish_task(task) # 全部整理完成且有成功的任务时,发送消息和事件 if self.jobview.is_finished(task): __notify() # 只要该种子的所有任务都已整理完成,则设置种子状态为已整理 if task.download_hash and self.jobview.is_torrent_done(task.download_hash): self.transfer_completed(hashs=task.download_hash, downloader=task.downloader) # 移动模式,全部成功时删除空目录和种子文件 if transferinfo.transfer_type in ["move"]: # 全部整理成功时 if self.jobview.is_success(task): # 所有成功的业务 tasks = self.jobview.success_tasks(task.mediainfo, task.meta.begin_season) # 获取整理屏蔽词 transfer_exclude_words = SystemConfigOper().get(SystemConfigKey.TransferExcludeWords) processed_hashes = set() for t in tasks: if t.download_hash and t.download_hash not in processed_hashes: # 检查该种子的所有任务(跨作业)是否都已成功 if self.jobview.is_torrent_success(t.download_hash): processed_hashes.add(t.download_hash) if self._can_delete_torrent(t.download_hash, t.downloader, transfer_exclude_words): # 移除种子及文件 if self.remove_torrents(t.download_hash, downloader=t.downloader): logger.info(f"移动模式删除种子成功:{t.download_hash}") if not t.download_hash and t.fileitem: # 删除剩余空目录 StorageChain().delete_media_file(t.fileitem, delete_self=False) return ret_status, ret_message def put_to_queue(self, task: TransferTask) -> bool: """ 添加到待整理队列 :param task: 任务信息 :return: True表示任务已添加到队列,False表示任务无效或已存在(重复) """ if not task: return False # 维护整理任务视图,如果任务已存在则不添加到队列 if not self.__put_to_jobview(task): return False # 添加到队列 self._queue.put(TransferQueue( task=task, callback=self.__default_callback )) return True def __put_to_jobview(self, task: TransferTask) -> bool: """ 添加到作业视图 :return: True表示任务已添加,False表示任务无效或已存在(重复) """ return self.jobview.add_task(task) def remove_from_queue(self, fileitem: FileItem): """ 从待整理队列移除 """ if not fileitem: return self.jobview.remove_task(fileitem) def __start_transfer(self): """ 处理队列 """ while not global_vars.is_system_stopped and self._queue_active: try: item: TransferQueue = self._queue.get(block=True, timeout=self._transfer_interval) if not item: continue task = item.task if not task: self._queue.task_done() continue # 文件信息 fileitem = task.fileitem with task_lock: # 获取当前最新总数 current_total = self.jobview.total() # 更新总数,取当前总数和当前已处理+运行中+队列中的最大值 self._total_num = max(self._total_num, current_total) # 如果当前没有在运行的任务且处理数为0,说明是一个新序列的开始 if self._active_tasks == 0 and self._processed_num == 0: logger.info("开始整理队列处理...") # 启动进度 self._progress.start() # 重置计数 self._processed_num = 0 self._fail_num = 0 __process_msg = f"开始整理队列处理,当前共 {self._total_num} 个文件 ..." logger.info(__process_msg) self._progress.update(value=0, text=__process_msg) # 增加运行中的任务数 self._active_tasks += 1 try: # 更新进度 __process_msg = f"正在整理 {fileitem.name} ..." logger.info(__process_msg) with task_lock: self._progress.update( value=(self._processed_num / self._total_num * 100) if self._total_num else 0, text=__process_msg) # 整理 state, err_msg = self.__handle_transfer(task=task, callback=item.callback) with task_lock: if not state: # 任务失败 self._fail_num += 1 # 更新进度 self._processed_num += 1 __process_msg = f"{fileitem.name} 整理完成" logger.info(__process_msg) self._progress.update( value=(self._processed_num / self._total_num * 100) if self._total_num else 100, text=__process_msg) except Exception as e: logger.error(f"{fileitem.name} 整理任务处理出现错误:{e} - {traceback.format_exc()}") with task_lock: self._processed_num += 1 self._fail_num += 1 finally: self._queue.task_done() with task_lock: # 减少运行中的任务数 self._active_tasks -= 1 # 检查是否所有任务都已完成且队列为空 if self._active_tasks == 0 and self._queue.empty(): # 结束进度 __end_msg = f"整理队列处理完成,共整理 {self._processed_num} 个文件,失败 {self._fail_num} 个" logger.info(__end_msg) self._progress.update(value=100, text=__end_msg) self._progress.end() # 重置计数 self._processed_num = 0 self._fail_num = 0 except queue.Empty: # 即使队列空了,如果还有任务在运行,也不应该结束进度 # 这部分逻辑已经在 finally 的 active_tasks == 0 中处理了 continue except Exception as e: logger.error(f"整理队列处理出现错误:{e} - {traceback.format_exc()}") def __handle_transfer(self, task: TransferTask, callback: Optional[Callable] = None) -> Optional[Tuple[bool, str]]: """ 处理整理任务 """ try: # 识别 transferhis = TransferHistoryOper() mediainfo = task.mediainfo mediainfo_changed = False if not mediainfo: download_history = task.download_history # 下载用户 if download_history: task.username = download_history.username # 识别媒体信息 if download_history.tmdbid or download_history.doubanid: # 下载记录中已存在识别信息 mediainfo: Optional[MediaInfo] = self.recognize_media(mtype=MediaType(download_history.type), tmdbid=download_history.tmdbid, doubanid=download_history.doubanid, episode_group=download_history.episode_group) if mediainfo: # 更新自定义媒体类别 if download_history.media_category: mediainfo.category = download_history.media_category else: # 识别媒体信息 mediainfo = MediaChain().recognize_by_meta(task.meta) # 更新媒体图片 if mediainfo: self.obtain_images(mediainfo=mediainfo) if not mediainfo: # 新增整理失败历史记录 his = transferhis.add_fail( fileitem=task.fileitem, mode=task.transfer_type, meta=task.meta, downloader=task.downloader, download_hash=task.download_hash ) self.post_message(Notification( mtype=NotificationType.Manual, title=f"{task.fileitem.name} 未识别到媒体信息,无法入库!", text=f"回复:\n```\n/redo {his.id} [tmdbid]|[类型]\n```\n手动识别整理。", username=task.username, link=settings.MP_DOMAIN('#/history') )) # 任务失败,直接移除task self.jobview.remove_task(task.fileitem) return False, "未识别到媒体信息" mediainfo_changed = True # 如果未开启新增已入库媒体是否跟随TMDB信息变化则根据tmdbid查询之前的title if not settings.SCRAP_FOLLOW_TMDB: transfer_history = transferhis.get_by_type_tmdbid(tmdbid=mediainfo.tmdb_id, mtype=mediainfo.type.value) if transfer_history and mediainfo.title != transfer_history.title: mediainfo.title = transfer_history.title mediainfo_changed = True if mediainfo_changed: # 更新任务信息 task.mediainfo = mediainfo # 更新队列任务 curr_task = self.jobview.remove_task(task.fileitem) self.jobview.add_task(task, state=curr_task.state if curr_task else "waiting") # 获取集数据 if task.mediainfo.type == MediaType.TV and not task.episodes_info: # 判断注意season为0的情况 season_num = task.mediainfo.season if season_num is None and task.meta.season_seq: if task.meta.season_seq.isdigit(): season_num = int(task.meta.season_seq) # 默认值1 if season_num is None: season_num = 1 task.episodes_info = TmdbChain().tmdb_episodes( tmdbid=task.mediainfo.tmdb_id, season=season_num, episode_group=task.mediainfo.episode_group ) # 查询整理目标目录 if not task.target_directory: if task.target_path: # 指定目标路径,`手动整理`场景下使用,忽略源目录匹配,使用指定目录匹配 task.target_directory = DirectoryHelper().get_dir(media=task.mediainfo, dest_path=task.target_path, target_storage=task.target_storage) else: # 启用源目录匹配时,根据源目录匹配下载目录,否则按源目录同盘优先原则,如无源目录,则根据媒体信息获取目标目录 task.target_directory = DirectoryHelper().get_dir(media=task.mediainfo, storage=task.fileitem.storage, src_path=Path(task.fileitem.path), target_storage=task.target_storage) if not task.target_storage and task.target_directory: task.target_storage = task.target_directory.library_storage # 正在处理 self.jobview.running_task(task) # 广播事件,请示额外的源存储支持 source_oper = None source_event_data = StorageOperSelectionEventData( storage=task.fileitem.storage, ) source_event = eventmanager.send_event(ChainEventType.StorageOperSelection, source_event_data) # 使用事件返回的上下文数据 if source_event and source_event.event_data: source_event_data: StorageOperSelectionEventData = source_event.event_data if source_event_data.storage_oper: source_oper = source_event_data.storage_oper # 广播事件,请示额外的目标存储支持 target_oper = None target_event_data = StorageOperSelectionEventData( storage=task.target_storage, ) target_event = eventmanager.send_event(ChainEventType.StorageOperSelection, target_event_data) # 使用事件返回的上下文数据 if target_event and target_event.event_data: target_event_data: StorageOperSelectionEventData = target_event.event_data if target_event_data.storage_oper: target_oper = target_event_data.storage_oper # 执行整理 transferinfo: TransferInfo = self.transfer(fileitem=task.fileitem, meta=task.meta, mediainfo=task.mediainfo, target_directory=task.target_directory, target_storage=task.target_storage, target_path=task.target_path, transfer_type=task.transfer_type, episodes_info=task.episodes_info, scrape=task.scrape, library_type_folder=task.library_type_folder, library_category_folder=task.library_category_folder, source_oper=source_oper, target_oper=target_oper) if not transferinfo: logger.error("文件整理模块运行失败") return False, "文件整理模块运行失败" # 回调,位置传参:任务、整理结果 if callback: return callback(task, transferinfo) return transferinfo.success, transferinfo.message finally: # 移除已完成的任务 self.jobview.try_remove_job(task) def get_queue_tasks(self) -> List[TransferJob]: """ 获取整理任务列表 """ return self.jobview.list_jobs() def recommend_name(self, meta: MetaBase, mediainfo: MediaInfo) -> Optional[str]: """ 获取重命名后的名称 :param meta: 元数据 :param mediainfo: 媒体信息 :return: 重命名后的名称(含目录) """ return self.run_module("recommend_name", meta=meta, mediainfo=mediainfo) def process(self) -> bool: """ 获取下载器中的种子列表,并执行整理 """ # 全局锁,避免定时服务重复 with downloader_lock: # 获取下载器监控目录 download_dirs = DirectoryHelper().get_download_dirs() # 如果没有下载器监控的目录则不处理 if not any(dir_info.monitor_type == "downloader" and dir_info.storage == "local" for dir_info in download_dirs): return True logger.info("开始整理下载器中已经完成下载的文件 ...") # 从下载器获取种子列表 if torrents_list := self.list_torrents(status=TorrentStatus.TRANSFER): seen = set() existing_hashes = self.jobview.get_all_torrent_hashes() torrents = [ torrent for torrent in torrents_list if (h := torrent.hash) not in existing_hashes # 排除多下载器返回的重复种子 and (h not in seen and (seen.add(h) or True)) ] else: torrents = [] if not torrents: logger.info("没有已完成下载但未整理的任务") return False logger.info(f"获取到 {len(torrents)} 个已完成的下载任务") try: for torrent in torrents: if global_vars.is_system_stopped: break # 文件路径 file_path = torrent.path if not file_path.exists(): logger.warn(f"文件不存在:{file_path}") continue # 检查是否为下载器监控目录中的文件 is_downloader_monitor = False for dir_info in download_dirs: if dir_info.monitor_type != "downloader": continue if not dir_info.download_path: continue if file_path.is_relative_to(Path(dir_info.download_path)): is_downloader_monitor = True break if not is_downloader_monitor: logger.debug(f"文件 {file_path} 不在下载器监控目录中,不通过下载器进行整理") continue # 查询下载记录识别情况 downloadhis: DownloadHistory = DownloadHistoryOper().get_by_hash(torrent.hash) if downloadhis: # 类型 try: mtype = MediaType(downloadhis.type) except ValueError: mtype = MediaType.TV # 识别媒体信息 mediainfo = self.recognize_media(mtype=mtype, tmdbid=downloadhis.tmdbid, doubanid=downloadhis.doubanid, episode_group=downloadhis.episode_group) if mediainfo: # 补充图片 self.obtain_images(mediainfo) # 更新自定义媒体类别 if downloadhis.media_category: mediainfo.category = downloadhis.media_category else: # 非MoviePilot下载的任务,按文件识别 mediainfo = None # 执行异步整理,匹配源目录 self.do_transfer( fileitem=FileItem( storage="local", path=file_path.as_posix() + ("/" if file_path.is_dir() else ""), type="dir" if not file_path.is_file() else "file", name=file_path.name, size=file_path.stat().st_size, extension=file_path.suffix.lstrip('.'), ), mediainfo=mediainfo, downloader=torrent.downloader, download_hash=torrent.hash ) finally: torrents.clear() del torrents return True def __get_trans_fileitems( self, fileitem: FileItem, predicate: Optional[Callable[[FileItem, bool], bool]], verify_file_exists: bool = True, ) -> List[Tuple[FileItem, bool]]: """ 获取待整理文件项列表 :param fileitem: 源文件项 :param predicate: 用于筛选目录或文件项 该函数接收两个参数: - `file_item`: 需要判断的文件项(类型为 `FileItem`) - `is_bluray_dir`: 表示该项是否为蓝光原盘目录(布尔值) 函数应返回 `True` 表示保留该项,`False` 表示过滤掉 若 `predicate` 为 `None`,则默认保留所有项 :param verify_file_exists: 验证目录或文件是否存在,默认值为 `True` """ if global_vars.is_system_stopped: raise OperationInterrupted() storagechain = StorageChain() def __is_bluray_sub(_path: str) -> bool: """ 判断是否蓝光原盘目录内的子目录或文件 """ return True if re.search(r"BDMV[/\\]STREAM", _path, re.IGNORECASE) else False def __get_bluray_dir(_storage: str, _path: Path) -> Optional[FileItem]: """ 获取蓝光原盘BDMV目录的上级目录 """ for p in _path.parents: if p.name == "BDMV": return storagechain.get_file_item(storage=_storage, path=p.parent) return None def _apply_predicate(file_item: FileItem, is_bluray_dir: bool) -> List[Tuple[FileItem, bool]]: if predicate is None or predicate(file_item, is_bluray_dir): return [(file_item, is_bluray_dir)] return [] if verify_file_exists: latest_fileitem = storagechain.get_item(fileitem) if not latest_fileitem: logger.warn(f"目录或文件不存在:{fileitem.path}") return [] # 确保从历史记录重新整理时 能获得最新的源文件大小、修改日期等 fileitem = latest_fileitem # 是否蓝光原盘子目录或文件 if __is_bluray_sub(fileitem.path): if bluray_dir := __get_bluray_dir(fileitem.storage, Path(fileitem.path)): # 返回该文件所在的原盘根目录 return _apply_predicate(bluray_dir, True) # 单文件 if fileitem.type == "file": return _apply_predicate(fileitem, False) # 是否蓝光原盘根目录 sub_items = storagechain.list_files(fileitem, recursion=False) or [] if storagechain.contains_bluray_subdirectories(sub_items): # 当前目录是原盘根目录,不需要递归 return _apply_predicate(fileitem, True) # 不是原盘根目录 递归获取目录内需要整理的文件项列表 return [ item for sub_item in sub_items for item in ( self.__get_trans_fileitems( sub_item, predicate, verify_file_exists=False ) if sub_item.type == "dir" else _apply_predicate(sub_item, False) ) ] def do_transfer(self, fileitem: FileItem, meta: MetaBase = None, mediainfo: MediaInfo = None, target_directory: TransferDirectoryConf = None, target_storage: Optional[str] = None, target_path: Path = None, transfer_type: Optional[str] = None, scrape: Optional[bool] = None, library_type_folder: Optional[bool] = None, library_category_folder: Optional[bool] = None, season: Optional[int] = None, epformat: EpisodeFormat = None, min_filesize: Optional[int] = 0, downloader: Optional[str] = None, download_hash: Optional[str] = None, force: Optional[bool] = False, background: Optional[bool] = True, manual: Optional[bool] = False, continue_callback: Callable = None) -> Tuple[bool, str]: """ 执行一个复杂目录的整理操作 :param fileitem: 文件项 :param meta: 元数据 :param mediainfo: 媒体信息 :param target_directory: 目标目录配置 :param target_storage: 目标存储器 :param target_path: 目标路径 :param transfer_type: 整理类型 :param scrape: 是否刮削元数据 :param library_type_folder: 媒体库类型子目录 :param library_category_folder: 媒体库类别子目录 :param season: 季 :param epformat: 剧集格式 :param min_filesize: 最小文件大小(MB) :param downloader: 下载器 :param download_hash: 下载记录hash :param force: 是否强制整理 :param background: 是否后台运行 :param manual: 是否手动整理 :param continue_callback: 继续处理回调 返回:成功标识,错误信息 """ # 是否全部成功 all_success = True # 自定义格式 formaterHandler = FormatParser(eformat=epformat.format, details=epformat.detail, part=epformat.part, offset=epformat.offset) if epformat else None # 整理屏蔽词 transfer_exclude_words = SystemConfigOper().get(SystemConfigKey.TransferExcludeWords) # 汇总错误信息 err_msgs: List[str] = [] def _filter(file_item: FileItem, is_bluray_dir: bool) -> bool: """ 过滤文件项 :return: True 表示保留,False 表示排除 """ if continue_callback and not continue_callback(): raise OperationInterrupted() # 有集自定义格式,过滤文件 if formaterHandler and not formaterHandler.match(file_item.name): return False # 过滤后缀和大小(蓝光目录、附加文件不过滤) if ( not is_bluray_dir and not self.__is_subtitle_file(file_item) and not self.__is_audio_file(file_item) ): if not self.__is_media_file(file_item): return False if not self.__is_allow_filesize(file_item, min_filesize): return False # 回收站及隐藏的文件不处理 if ( file_item.path.find("/@Recycle/") != -1 or file_item.path.find("/#recycle/") != -1 or file_item.path.find("/.") != -1 or file_item.path.find("/@eaDir") != -1 ): logger.debug(f"{file_item.path} 是回收站或隐藏的文件") return False # 整理屏蔽词不处理 if self._is_blocked_by_exclude_words(file_item.path, transfer_exclude_words): return False return True try: # 获取经过筛选后的待整理文件项列表 file_items = self.__get_trans_fileitems(fileitem, predicate=_filter) except OperationInterrupted: return False, f"{fileitem.name} 已取消" if not file_items: logger.warn(f"{fileitem.path} 没有找到可整理的媒体文件") return False, f"{fileitem.name} 没有找到可整理的媒体文件" logger.info(f"正在计划整理 {len(file_items)} 个文件...") # 整理所有文件 transfer_tasks: List[TransferTask] = [] try: for file_item, bluray_dir in file_items: if global_vars.is_system_stopped: raise OperationInterrupted() if continue_callback and not continue_callback(): raise OperationInterrupted() file_path = Path(file_item.path) # 整理成功的不再处理 if not force: transferd = TransferHistoryOper().get_by_src(file_item.path, storage=file_item.storage) if transferd: if not transferd.status: all_success = False logger.info(f"{file_item.path} 已整理过,如需重新处理,请删除整理记录。") err_msgs.append(f"{file_item.name} 已整理过") continue # 提前获取下载历史,以便获取自定义识别词 download_history = None downloadhis = DownloadHistoryOper() if download_hash: # 先按hash查询 download_history = downloadhis.get_by_hash(download_hash) elif bluray_dir: # 蓝光原盘,按目录名查询 download_history = downloadhis.get_by_path(file_path.as_posix()) else: # 按文件全路径查询 download_file = downloadhis.get_file_by_fullpath(file_path.as_posix()) if download_file: download_history = downloadhis.get_by_hash(download_file.download_hash) if not meta: subscribe_custom_words = None if download_history and isinstance(download_history.note, dict): # 使用source动态获取订阅 subscribe = SubscribeChain().get_subscribe_by_source(download_history.note.get("source")) subscribe_custom_words = subscribe.custom_words.split( "\n") if subscribe and subscribe.custom_words else None # 文件元数据(优先使用订阅识别词) file_meta = MetaInfoPath(file_path, custom_words=subscribe_custom_words) else: file_meta = meta # 合并季 if season is not None: file_meta.begin_season = season if not file_meta: all_success = False logger.error(f"{file_path.name} 无法识别有效信息") err_msgs.append(f"{file_path.name} 无法识别有效信息") continue # 自定义识别 if formaterHandler: # 开始集、结束集、PART begin_ep, end_ep, part = formaterHandler.split_episode(file_name=file_path.name, file_meta=file_meta) if begin_ep is not None: file_meta.begin_episode = begin_ep if part is not None: file_meta.part = part if end_ep is not None: file_meta.end_episode = end_ep # 获取下载Hash if download_history and (not downloader or not download_hash): _downloader = download_history.downloader _download_hash = download_history.download_hash else: _downloader = downloader _download_hash = download_hash # 后台整理 transfer_task = TransferTask( fileitem=file_item, meta=file_meta, mediainfo=mediainfo, target_directory=target_directory, target_storage=target_storage, target_path=target_path, transfer_type=transfer_type, scrape=scrape, library_type_folder=library_type_folder, library_category_folder=library_category_folder, downloader=_downloader, download_hash=_download_hash, download_history=download_history, manual=manual, background=background ) if background: if self.put_to_queue(task=transfer_task): logger.info(f"{file_path.name} 已添加到整理队列") else: logger.debug(f"{file_path.name} 已在整理队列中,跳过") else: # 加入列表 if self.__put_to_jobview(transfer_task): transfer_tasks.append(transfer_task) else: logger.debug(f"{file_path.name} 已在整理列表中,跳过") except OperationInterrupted: return False, f"{fileitem.name} 已取消" finally: file_items.clear() del file_items # 实时整理 if transfer_tasks: # 总数量 total_num = len(transfer_tasks) # 已处理数量 processed_num = 0 # 失败数量 fail_num = 0 # 已完成文件 finished_files = [] # 启动进度 progress = ProgressHelper(ProgressKey.FileTransfer) progress.start() __process_msg = f"开始整理,共 {total_num} 个文件 ..." logger.info(__process_msg) progress.update(value=0, text=__process_msg) try: for transfer_task in transfer_tasks: if global_vars.is_system_stopped: break if continue_callback and not continue_callback(): break # 更新进度 __process_msg = f"正在整理 ({processed_num + fail_num + 1}/{total_num}){transfer_task.fileitem.name} ..." logger.info(__process_msg) progress.update(value=(processed_num + fail_num) / total_num * 100, text=__process_msg, data={ "current": Path(transfer_task.fileitem.path).as_posix(), "finished": finished_files, }) state, err_msg = self.__handle_transfer( task=transfer_task, callback=self.__default_callback ) if not state: all_success = False logger.warn(f"{transfer_task.fileitem.name} {err_msg}") err_msgs.append(f"{transfer_task.fileitem.name} {err_msg}") fail_num += 1 else: processed_num += 1 # 记录已完成 finished_files.append(Path(transfer_task.fileitem.path).as_posix()) finally: transfer_tasks.clear() del transfer_tasks # 整理结束 __end_msg = f"整理队列处理完成,共整理 {total_num} 个文件,失败 {fail_num} 个" logger.info(__end_msg) progress.update(value=100, text=__end_msg, data={}) progress.end() error_msg = "、".join(err_msgs[:2]) + (f",等{len(err_msgs)}个文件错误!" if len(err_msgs) > 2 else "") return all_success, error_msg def remote_transfer(self, arg_str: str, channel: MessageChannel, userid: Union[str, int] = None, source: Optional[str] = None): """ 远程重新整理,参数 历史记录ID TMDBID|类型 """ def args_error(): self.post_message(Notification(channel=channel, source=source, title="请输入正确的命令格式:/redo [id] [tmdbid/豆瓣id]|[类型]," "[id]整理记录编号", userid=userid)) if not arg_str: args_error() return arg_strs = str(arg_str).split() if len(arg_strs) != 2: args_error() return # 历史记录ID logid = arg_strs[0] if not logid.isdigit(): args_error() return # TMDBID/豆瓣ID id_strs = arg_strs[1].split('|') media_id = id_strs[0] if not logid.isdigit(): args_error() return # 类型 type_str = id_strs[1] if len(id_strs) > 1 else None if not type_str or type_str not in [MediaType.MOVIE.value, MediaType.TV.value]: args_error() return state, errmsg = self.__re_transfer(logid=int(logid), mtype=MediaType(type_str), mediaid=media_id) if not state: self.post_message(Notification(channel=channel, title="手动整理失败", source=source, text=errmsg, userid=userid, link=settings.MP_DOMAIN('#/history'))) return def __re_transfer(self, logid: int, mtype: MediaType = None, mediaid: Optional[str] = None) -> Tuple[bool, str]: """ 根据历史记录,重新识别整理,只支持简单条件 :param logid: 历史记录ID :param mtype: 媒体类型 :param mediaid: TMDB ID/豆瓣ID """ # 查询历史记录 history: TransferHistory = TransferHistoryOper().get(logid) if not history: logger.error(f"整理记录不存在,ID:{logid}") return False, "整理记录不存在" # 按源目录路径重新整理 src_path = Path(history.src) if not src_path.exists(): return False, f"源目录不存在:{src_path}" # 查询媒体信息 if mtype and mediaid: mediainfo = self.recognize_media(mtype=mtype, tmdbid=int(mediaid) if str(mediaid).isdigit() else None, doubanid=mediaid, episode_group=history.episode_group) if mediainfo: # 更新媒体图片 self.obtain_images(mediainfo=mediainfo) else: mediainfo = MediaChain().recognize_by_path(str(src_path), episode_group=history.episode_group) if not mediainfo: return False, f"未识别到媒体信息,类型:{mtype.value},id:{mediaid}" # 重新执行整理 logger.info(f"{src_path.name} 识别为:{mediainfo.title_year}") # 删除旧的已整理文件 if history.dest_fileitem: # 解析目标文件对象 dest_fileitem = FileItem(**history.dest_fileitem) StorageChain().delete_file(dest_fileitem) # 强制整理 if history.src_fileitem: state, errmsg = self.do_transfer(fileitem=FileItem(**history.src_fileitem), mediainfo=mediainfo, download_hash=history.download_hash, force=True, background=False, manual=True) if not state: return False, errmsg return True, "" def manual_transfer(self, fileitem: FileItem, target_storage: Optional[str] = None, target_path: Path = None, tmdbid: Optional[int] = None, doubanid: Optional[str] = None, mtype: MediaType = None, season: Optional[int] = None, episode_group: Optional[str] = None, transfer_type: Optional[str] = None, epformat: EpisodeFormat = None, min_filesize: Optional[int] = 0, scrape: Optional[bool] = None, library_type_folder: Optional[bool] = None, library_category_folder: Optional[bool] = None, force: Optional[bool] = False, background: Optional[bool] = False, downloader: Optional[str] = None, download_hash: Optional[str] = None) -> Tuple[bool, Union[str, list]]: """ 手动整理,支持复杂条件,带进度显示 :param fileitem: 文件项 :param target_storage: 目标存储 :param target_path: 目标路径 :param tmdbid: TMDB ID :param doubanid: 豆瓣ID :param mtype: 媒体类型 :param season: 季度 :param episode_group: 剧集组 :param transfer_type: 整理类型 :param epformat: 剧集格式 :param min_filesize: 最小文件大小(MB) :param scrape: 是否刮削元数据 :param library_type_folder: 是否按类型建立目录 :param library_category_folder: 是否按类别建立目录 :param force: 是否强制整理 :param background: 是否后台运行 :param downloader: 下载器名称 :param download_hash: 下载任务哈希 """ logger.info(f"手动整理:{fileitem.path} ...") if tmdbid or doubanid: # 有输入TMDBID时单个识别 # 识别媒体信息 mediainfo: MediaInfo = MediaChain().recognize_media(tmdbid=tmdbid, doubanid=doubanid, mtype=mtype, episode_group=episode_group) if not mediainfo: return (False, f"媒体信息识别失败,tmdbid:{tmdbid},doubanid:{doubanid},type: {mtype.value if mtype else None}") else: # 更新媒体图片 self.obtain_images(mediainfo=mediainfo) # 开始整理 state, errmsg = self.do_transfer( fileitem=fileitem, target_storage=target_storage, target_path=target_path, mediainfo=mediainfo, transfer_type=transfer_type, season=season, epformat=epformat, min_filesize=min_filesize, scrape=scrape, library_type_folder=library_type_folder, library_category_folder=library_category_folder, force=force, background=background, manual=True, downloader=downloader, download_hash=download_hash ) if not state: return False, errmsg logger.info(f"{fileitem.path} 整理完成") return True, "" else: # 没有输入TMDBID时,按文件识别 state, errmsg = self.do_transfer(fileitem=fileitem, target_storage=target_storage, target_path=target_path, transfer_type=transfer_type, season=season, epformat=epformat, min_filesize=min_filesize, scrape=scrape, library_type_folder=library_type_folder, library_category_folder=library_category_folder, force=force, background=background, manual=True, downloader=downloader, download_hash=download_hash) return state, errmsg def send_transfer_message(self, meta: MetaBase, mediainfo: MediaInfo, transferinfo: TransferInfo, season_episode: Optional[str] = None, username: Optional[str] = None): """ 发送入库成功的消息 """ self.post_message( Notification( mtype=NotificationType.Organize, ctype=ContentType.OrganizeSuccess, image=mediainfo.get_message_image(), username=username, link=settings.MP_DOMAIN('#/history') ), meta=meta, mediainfo=mediainfo, transferinfo=transferinfo, season_episode=season_episode, username=username ) @staticmethod def _is_blocked_by_exclude_words(file_path: str, exclude_words: list) -> bool: """ 检查文件是否被整理屏蔽词阻止处理 :param file_path: 文件路径 :param exclude_words: 整理屏蔽词列表 :return: 如果被屏蔽返回True,否则返回False """ if not exclude_words: return False for keyword in exclude_words: if keyword and re.search(r"%s" % keyword, file_path, re.IGNORECASE): logger.warn(f"{file_path} 命中屏蔽词 {keyword}") return True return False def _can_delete_torrent(self, download_hash: str, downloader: str, transfer_exclude_words) -> bool: """ 检查是否可以删除种子文件 :param download_hash: 种子Hash :param downloader: 下载器名称 :param transfer_exclude_words: 整理屏蔽词 :return: 如果可以删除返回True,否则返回False """ try: # 获取种子信息 torrents = self.list_torrents(hashs=download_hash, downloader=downloader) if not torrents: return False # 未下载完成 if torrents[0].progress < 100: return False # 获取种子文件列表 torrent_files = self.torrent_files(download_hash, downloader) if not torrent_files: return False if not isinstance(torrent_files, list): torrent_files = torrent_files.data # 检查是否有媒体文件未被屏蔽且存在 save_path = torrents[0].path.parent for file in torrent_files: file_path = save_path / file.name # 如果存在未被屏蔽的媒体文件,则不删除种子 if (file_path.suffix in self._allowed_exts and not self._is_blocked_by_exclude_words(file_path.as_posix(), transfer_exclude_words) and file_path.exists()): return False # 所有媒体文件都被屏蔽或不存在,可以删除种子 return True except Exception as e: logger.error(f"检查种子 {download_hash} 是否需要删除失败:{e}") return False ================================================ FILE: app/chain/tvdb.py ================================================ from typing import List from app.chain import ChainBase class TvdbChain(ChainBase): """ Tvdb处理链,单例运行 """ def get_tvdbid_by_name(self, title: str) -> List[int]: tvdb_info_list = self.run_module("search_tvdb", title=title) return [int(item["tvdb_id"]) for item in tvdb_info_list] ================================================ FILE: app/chain/user.py ================================================ import secrets from typing import Optional, Tuple, Union from app.chain import ChainBase from app.core.config import settings from app.core.security import get_password_hash, verify_password from app.db.models.user import User from app.db.user_oper import UserOper from app.log import logger from app.schemas import AuthCredentials, AuthInterceptCredentials from app.schemas.types import ChainEventType from app.utils.otp import OtpUtils PASSWORD_INVALID_CREDENTIALS_MESSAGE = "用户名或密码或二次校验码不正确" class UserChain(ChainBase): """ 用户链,处理多种认证协议 """ def user_authenticate( self, username: Optional[str] = None, password: Optional[str] = None, mfa_code: Optional[str] = None, code: Optional[str] = None, grant_type: Optional[str] = "password" ) -> Union[Tuple[bool, Optional[str]], Tuple[bool, Optional[User]]]: """ 认证用户,根据不同的 grant_type 处理不同的认证流程 :param username: 用户名,适用于 "password" grant_type :param password: 用户密码,适用于 "password" grant_type :param mfa_code: 一次性密码,适用于 "password" grant_type :param code: 授权码,适用于 "authorization_code" grant_type :param grant_type: 认证类型,如 "password", "authorization_code", "client_credentials" :return: - 对于成功的认证,返回 (True, User) - 对于失败的认证,返回 (False, "错误信息") """ credentials = AuthCredentials( username=username, password=password, mfa_code=mfa_code, code=code, grant_type=grant_type ) logger.debug(f"认证类型:{grant_type},开始准备对用户 {username} 进行身份校验") if credentials.grant_type == "password": # Password 认证 success, user_or_message = self.password_authenticate(credentials=credentials) if success: # 如果用户启用了二次验证码,则进一步验证 mfa_result = self._verify_mfa(user_or_message, credentials.mfa_code) if mfa_result == "MFA_REQUIRED": return False, "MFA_REQUIRED" elif not mfa_result: return False, PASSWORD_INVALID_CREDENTIALS_MESSAGE logger.info(f"用户 {username} 通过密码认证成功") return True, user_or_message else: # 用户不存在或密码错误,考虑辅助认证 if settings.AUXILIARY_AUTH_ENABLE: logger.warning("密码认证失败,尝试通过外部服务进行辅助认证 ...") aux_success, aux_user_or_message = self.auxiliary_authenticate(credentials=credentials) if aux_success: # 辅助认证成功后再验证二次验证码 mfa_result = self._verify_mfa(aux_user_or_message, credentials.mfa_code) if mfa_result == "MFA_REQUIRED": return False, "MFA_REQUIRED" elif not mfa_result: return False, PASSWORD_INVALID_CREDENTIALS_MESSAGE return True, aux_user_or_message else: return False, PASSWORD_INVALID_CREDENTIALS_MESSAGE else: logger.debug(f"辅助认证未启用,用户 {username} 认证失败") return False, PASSWORD_INVALID_CREDENTIALS_MESSAGE elif credentials.grant_type == "authorization_code": # 处理其他认证类型的分支 if settings.AUXILIARY_AUTH_ENABLE: aux_success, aux_user_or_message = self.auxiliary_authenticate(credentials=credentials) if aux_success: return True, aux_user_or_message else: return False, "认证失败" else: return False, "认证失败" else: logger.debug(f"辅助认证未启用,认证类型 {grant_type} 未实现") return False, "不支持的认证类型" @staticmethod def password_authenticate(credentials: AuthCredentials) -> Tuple[bool, Union[User, str]]: """ 密码认证 :param credentials: 认证凭证,包含用户名、密码以及可选的 MFA 认证码 :return: - 成功时返回 (True, User),其中 User 是认证通过的用户对象 - 失败时返回 (False, "错误信息") """ if not credentials or credentials.grant_type != "password": logger.info("密码认证失败,认证类型不匹配") return False, PASSWORD_INVALID_CREDENTIALS_MESSAGE user = UserOper().get_by_name(name=credentials.username) if not user: logger.info(f"密码认证失败,用户 {credentials.username} 不存在") return False, PASSWORD_INVALID_CREDENTIALS_MESSAGE if not user.is_active: logger.info(f"密码认证失败,用户 {credentials.username} 已被禁用") return False, PASSWORD_INVALID_CREDENTIALS_MESSAGE if not verify_password(credentials.password, str(user.hashed_password)): logger.info(f"密码认证失败,用户 {credentials.username} 的密码验证不通过") return False, PASSWORD_INVALID_CREDENTIALS_MESSAGE return True, user def auxiliary_authenticate(self, credentials: AuthCredentials) -> Tuple[bool, Union[User, str]]: """ 辅助用户认证 :param credentials: 认证凭证,包含必要的认证信息 :return: - 成功时返回 (True, User),其中 User 是认证通过的用户对象 - 失败时返回 (False, "错误信息") """ if not credentials: return False, "认证凭证无效" # 检查是否因为用户被禁用 useroper = UserOper() if credentials.username: user = useroper.get_by_name(name=credentials.username) if user and not user.is_active: logger.info(f"用户 {user.name} 已被禁用,跳过后续身份校验") return False, PASSWORD_INVALID_CREDENTIALS_MESSAGE logger.debug(f"认证类型:{credentials.grant_type},尝试通过系统模块进行辅助认证,用户: {credentials.username}") result = self.run_module("user_authenticate", credentials=credentials) if not result: logger.debug(f"通过系统模块辅助认证失败,尝试触发 {ChainEventType.AuthVerification} 事件") event = self.eventmanager.send_event(etype=ChainEventType.AuthVerification, data=credentials) if not event or not event.event_data: logger.error(f"认证类型:{credentials.grant_type},辅助认证失败,未返回有效数据") return False, f"认证类型:{credentials.grant_type},辅助认证事件失败或无效" credentials = event.event_data # 使用事件返回的认证数据 else: logger.info(f"通过系统模块辅助认证成功,用户: {credentials.username}") credentials = result # 使用模块认证返回的认证数据 # 处理认证成功的逻辑 success = self._process_auth_success(username=credentials.username, credentials=credentials) if success: logger.info(f"用户 {credentials.username} 辅助认证通过") return True, useroper.get_by_name(credentials.username) else: logger.warning(f"用户 {credentials.username} 辅助认证未通过") return False, PASSWORD_INVALID_CREDENTIALS_MESSAGE @staticmethod def _verify_mfa(user: User, mfa_code: Optional[str]) -> Union[bool, str]: """ 验证 MFA(二次验证码) 检查用户是否启用了 OTP 或 PassKey,如果启用了任何一种,都需要提供验证 :param user: 用户对象 :param mfa_code: 二次验证码(如果提供了则验证OTP) :return: - 如果验证成功返回 True - 如果需要MFA但未提供,返回 "MFA_REQUIRED" - 如果MFA验证失败,返回 False """ # 检查用户是否有PassKey from app.db.models.passkey import PassKey has_passkey = bool(PassKey.get_by_user_id(db=None, user_id=user.id)) # 如果用户既没有启用OTP也没有PassKey,直接通过 if not user.is_otp and not has_passkey: return True # 如果用户启用了OTP或PassKey,但没有提供验证码,需要进行二次验证 if not mfa_code: logger.info(f"用户 {user.name} 已启用双重验证(OTP: {user.is_otp}, PassKey: {has_passkey}),需要提供验证码") return "MFA_REQUIRED" # 如果提供了验证码,且用户启用了 OTP,则验证 OTP if user.is_otp: if not OtpUtils.check(str(user.otp_secret), mfa_code): logger.info(f"用户 {user.name} 的 MFA 认证失败") return False # OTP 验证成功 return True # 用户未启用 OTP,此时提供的 mfa_code 无效;如果启用了 PassKey,则仍需通过 PassKey 验证 if has_passkey: logger.info( f"用户 {user.name} 未启用 OTP,但已启用 PassKey,提供的 MFA 验证码将被忽略,仍需通过 PassKey 验证" ) return "MFA_REQUIRED" return True def _process_auth_success(self, username: str, credentials: AuthCredentials) -> bool: """ 处理辅助认证成功的逻辑,返回用户对象或创建新用户 :param username: 用户名 :param credentials: 认证凭证,包含 token、channel、service 等信息 :return: - 如果认证成功并且用户存在或已创建,返回 User 对象 - 如果认证被拦截或失败,返回 None """ if not username: logger.info(f"未能获取到对应的用户信息,{credentials.grant_type} 认证不通过") return False token, channel, service = credentials.token, credentials.channel, credentials.service if not all([token, channel, service]): logger.info(f"用户 {username} 未通过 {credentials.grant_type} 认证,必要信息不足") return False # 触发认证通过的拦截事件 intercept_event = self.eventmanager.send_event( etype=ChainEventType.AuthIntercept, data=AuthInterceptCredentials(username=username, channel=channel, service=service, token=token, status="completed") ) if intercept_event and intercept_event.event_data: intercept_data: AuthInterceptCredentials = intercept_event.event_data if intercept_data.cancel: logger.warning( f"认证被拦截,用户:{username},渠道:{channel},服务:{service},拦截源:{intercept_data.source}") return False # 检查用户是否存在,如果不存在且当前为密码认证时则创建新用户 useroper = UserOper() user = useroper.get_by_name(name=username) if user: # 如果用户存在,但是已经被禁用,则直接响应 if not user.is_active: logger.info(f"辅助认证失败,用户 {username} 已被禁用") return False anonymized_token = f"{token[:len(token) // 2]}********" logger.info( f"认证类型:{credentials.grant_type},用户:{username},渠道:{channel}," f"服务:{service} 认证成功,token:{anonymized_token}") return True else: if credentials.grant_type == "password": useroper.add(name=username, is_active=True, is_superuser=False, hashed_password=get_password_hash(secrets.token_urlsafe(16))) logger.info(f"用户 {username} 不存在,已通过 {credentials.grant_type} 认证并已创建普通用户") return True else: logger.warning( f"认证类型:{credentials.grant_type},用户:{username},渠道:{channel}," f"服务:{service} 认证不通过,未能在本地找到对应的用户信息") return False ================================================ FILE: app/chain/webhook.py ================================================ from typing import Any from app.chain import ChainBase from app.schemas.types import EventType class WebhookChain(ChainBase): """ Webhook处理链 """ def message(self, body: Any, form: Any, args: Any) -> None: """ 处理Webhook报文并发送事件 """ # 获取主体内容 event_info = self.webhook_parser(body=body, form=form, args=args) if not event_info: return # 广播事件 self.eventmanager.send_event(EventType.WebhookMessage, event_info) ================================================ FILE: app/chain/workflow.py ================================================ import base64 import pickle import threading from collections import defaultdict, deque from concurrent.futures import ThreadPoolExecutor from time import sleep from typing import List, Tuple, Optional from pydantic.fields import Callable from app.chain import ChainBase from app.core.config import global_vars from app.core.event import Event, eventmanager from app.workflow import WorkFlowManager from app.db.models import Workflow from app.db.workflow_oper import WorkflowOper from app.log import logger from app.schemas import ActionContext, ActionFlow, Action, ActionExecution from app.schemas.types import EventType class WorkflowExecutor: """ 工作流执行器 """ def __init__(self, workflow: Workflow, step_callback: Callable = None): """ 初始化工作流执行器 :param workflow: 工作流对象 :param step_callback: 步骤回调函数 """ # 工作流数据 self.workflow = workflow self.step_callback = step_callback self.actions = {action['id']: Action(**action) for action in workflow.actions} self.flows = [ActionFlow(**flow) for flow in workflow.flows] self.total_actions = len(self.actions) self.finished_actions = 0 self.success = True self.errmsg = "" # 工作流管理器 self.workflowmanager = WorkFlowManager() # 线程安全队列 self.queue = deque() # 锁用于保证线程安全 self.lock = threading.Lock() # 线程池 self.executor = ThreadPoolExecutor() # 跟踪运行中的任务数 self.running_tasks = 0 # 构建邻接表、入度表 self.adjacency = defaultdict(list) self.indegree = defaultdict(int) for flow in self.flows: source = flow.source target = flow.target self.adjacency[source].append(target) self.indegree[target] += 1 # 初始化所有节点的入度(确保未被引用的节点入度为0) for action_id in self.actions: if action_id not in self.indegree: self.indegree[action_id] = 0 # 初始上下文 if workflow.current_action and workflow.context: logger.info(f"工作流已执行动作:{workflow.current_action}") # Base64解码 decoded_data = base64.b64decode(workflow.context["content"]) # 反序列化数据 self.context = pickle.loads(decoded_data) else: self.context = ActionContext() # 恢复工作流 global_vars.workflow_resume(self.workflow.id) # 初始化队列,添加入度为0的节点 for action_id in self.actions: if self.indegree[action_id] == 0: self.queue.append(action_id) def execute(self): """ 执行工作流 """ while True: with self.lock: # 退出条件:队列为空且无运行任务 if not self.queue and self.running_tasks == 0: break # 退出条件:出现了错误 if not self.success: break if not self.queue: sleep(0.1) continue # 取出队首节点 node_id = self.queue.popleft() # 标记任务开始 self.running_tasks += 1 # 已停机 if global_vars.is_workflow_stopped(self.workflow.id): global_vars.workflow_resume(self.workflow.id) break # 已执行的跳过 if (self.workflow.current_action and node_id in self.workflow.current_action.split(',')): continue # 提交任务到线程池 future = self.executor.submit( self.execute_node, self.workflow.id, node_id, self.context ) future.add_done_callback(self.on_node_complete) def execute_node(self, workflow_id: int, node_id: int, context: ActionContext) -> Tuple[Action, bool, str, ActionContext]: """ 执行单个节点操作,返回修改后的上下文和节点ID """ action = self.actions[node_id] state, message, result_ctx = self.workflowmanager.excute(workflow_id, action, context=context) return action, state, message, result_ctx def on_node_complete(self, future): """ 节点完成回调:更新上下文、处理后继节点 """ action, state, message, result_ctx = future.result() try: self.finished_actions += 1 # 更新当前进度 self.context.progress = round(self.finished_actions / self.total_actions) * 100 # 补充执行历史 self.context.execute_history.append( ActionExecution( action=action.name, result=state, message=message ) ) # 节点执行失败 if not state: self.success = False self.errmsg = f"{action.name} 失败" return with self.lock: # 更新主上下文 self.merge_context(result_ctx) # 回调 if self.step_callback: self.step_callback(action, self.context) # 处理后继节点 successors = self.adjacency.get(action.id, []) for succ_id in successors: with self.lock: self.indegree[succ_id] -= 1 if self.indegree[succ_id] == 0: self.queue.append(succ_id) finally: # 标记任务完成 with self.lock: self.running_tasks -= 1 def merge_context(self, context: ActionContext): """ 合并上下文 """ for key, value in context.model_dump().items(): if not getattr(self.context, key, None): setattr(self.context, key, value) class WorkflowChain(ChainBase): """ 工作流链 """ @eventmanager.register(EventType.WorkflowExecute) def event_process(self, event: Event): """ 事件触发工作流执行 """ workflow_id = event.event_data.get('workflow_id') if not workflow_id: return self.process(workflow_id, from_begin=False) @staticmethod def process(workflow_id: int, from_begin: Optional[bool] = True) -> Tuple[bool, str]: """ 处理工作流 :param workflow_id: 工作流ID :param from_begin: 是否从头开始,默认为True """ workflowoper = WorkflowOper() def save_step(action: Action, context: ActionContext): """ 保存上下文到数据库 """ # 序列化数据 serialized_data = pickle.dumps(context) # 使用Base64编码字节流 encoded_data = base64.b64encode(serialized_data).decode('utf-8') workflowoper.step(workflow_id, action_id=action.id, context={ "content": encoded_data }) # 重置工作流 if from_begin: workflowoper.reset(workflow_id) # 查询工作流数据 workflow = workflowoper.get(workflow_id) if not workflow: logger.warn(f"工作流 {workflow_id} 不存在") return False, "工作流不存在" if not workflow.actions: logger.warn(f"工作流 {workflow.name} 无动作") return False, "工作流无动作" if not workflow.flows: logger.warn(f"工作流 {workflow.name} 无流程") return False, "工作流无流程" logger.info(f"开始执行工作流 {workflow.name},共 {len(workflow.actions)} 个动作 ...") workflowoper.start(workflow_id) # 执行工作流 executor = WorkflowExecutor(workflow, step_callback=save_step) executor.execute() if not executor.success: logger.info(f"工作流 {workflow.name} 执行失败:{executor.errmsg}") workflowoper.fail(workflow_id, result=executor.errmsg) return False, executor.errmsg else: logger.info(f"工作流 {workflow.name} 执行完成") workflowoper.success(workflow_id) return True, "" @staticmethod def get_workflows() -> List[Workflow]: """ 获取工作流列表 """ return WorkflowOper().list_enabled() @staticmethod def get_timer_workflows() -> List[Workflow]: """ 获取定时触发的工作流列表 """ return WorkflowOper().get_timer_triggered_workflows() @staticmethod def get_event_workflows() -> List[Workflow]: """ 获取事件触发的工作流列表 """ return WorkflowOper().get_event_triggered_workflows() ================================================ FILE: app/command.py ================================================ import copy import threading import traceback from typing import Any, Union, Dict, Optional from app.chain import ChainBase from app.chain.download import DownloadChain from app.chain.message import MessageChain from app.chain.site import SiteChain from app.chain.subscribe import SubscribeChain from app.chain.system import SystemChain from app.chain.transfer import TransferChain from app.core.event import Event as ManagerEvent, eventmanager, Event from app.core.plugin import PluginManager from app.helper.message import MessageHelper from app.helper.thread import ThreadHelper from app.log import logger from app.scheduler import Scheduler from app.schemas import Notification, CommandRegisterEventData from app.schemas.types import EventType, MessageChannel, ChainEventType from app.utils.object import ObjectUtils from app.utils.singleton import Singleton from app.utils.structures import DictUtils class CommandChain(ChainBase): pass class Command(metaclass=Singleton): """ 全局命令管理,消费事件 """ def __init__(self): # 插件管理器 super().__init__() # 注册的命令集合 self._registered_commands = {} # 所有命令集合 self._commands = {} # 内建命令集合 self._preset_commands = { "/cookiecloud": { "id": "cookiecloud", "type": "scheduler", "description": "同步站点", "category": "站点" }, "/sites": { "func": SiteChain().remote_list, "description": "查询站点", "category": "站点", "data": {} }, "/site_cookie": { "func": SiteChain().remote_cookie, "description": "更新站点Cookie", "data": {} }, "/site_statistic": { "func": SiteChain().remote_refresh_userdatas, "description": "站点数据统计", "data": {} }, "/site_enable": { "func": SiteChain().remote_enable, "description": "启用站点", "data": {} }, "/site_disable": { "func": SiteChain().remote_disable, "description": "禁用站点", "data": {} }, "/mediaserver_sync": { "id": "mediaserver_sync", "type": "scheduler", "description": "同步媒体服务器", "category": "管理" }, "/subscribes": { "func": SubscribeChain().remote_list, "description": "查询订阅", "category": "订阅", "data": {} }, "/subscribe_refresh": { "id": "subscribe_refresh", "type": "scheduler", "description": "刷新订阅", "category": "订阅" }, "/subscribe_search": { "id": "subscribe_search", "type": "scheduler", "description": "搜索订阅", "category": "订阅" }, "/subscribe_delete": { "func": SubscribeChain().remote_delete, "description": "删除订阅", "data": {} }, "/subscribe_tmdb": { "id": "subscribe_tmdb", "type": "scheduler", "description": "订阅元数据更新" }, "/downloading": { "func": DownloadChain().remote_downloading, "description": "正在下载", "category": "管理", "data": {} }, "/transfer": { "id": "transfer", "type": "scheduler", "description": "下载文件整理", "category": "管理" }, "/redo": { "func": TransferChain().remote_transfer, "description": "手动整理", "data": {} }, "/clear_cache": { "func": SystemChain().remote_clear_cache, "description": "清理缓存", "category": "管理", "data": {} }, "/restart": { "func": SystemChain().restart, "description": "重启系统", "category": "管理", "data": {} }, "/version": { "func": SystemChain().version, "description": "当前版本", "category": "管理", "data": {} }, "/clear_session": { "func": MessageChain().remote_clear_session, "description": "清除会话", "category": "管理", "data": {} } } # 插件命令集合 self._plugin_commands = {} # 其他命令集合 self._other_commands = {} # 初始化锁 self._rlock = threading.RLock() # 插件管理 self.pluginmanager = PluginManager() # 定时服务管理 self.scheduler = Scheduler() # 消息管理器 self.messagehelper = MessageHelper() # 初始化命令 self.init_commands() def init_commands(self, pid: Optional[str] = None) -> None: """ 初始化菜单命令 """ # 使用线程池提交后台任务,避免引起阻塞 ThreadHelper().submit(self.__init_commands_background, pid) def __init_commands_background(self, pid: Optional[str] = None) -> None: """ 后台初始化菜单命令 """ try: with self._rlock: logger.debug("Acquired lock for initializing commands in background.") self._plugin_commands = self.__build_plugin_commands(pid) self._commands = { **self._preset_commands, **self._plugin_commands, **self._other_commands } # 强制触发注册 force_register = False # 触发事件允许可以拦截和调整命令 event, initial_commands = self.__trigger_register_commands_event() if event and event.event_data: # 如果事件返回有效的 event_data,使用事件中调整后的命令 event_data: CommandRegisterEventData = event.event_data # 如果事件被取消,跳过命令注册 if event_data.cancel: logger.debug(f"Command initialization canceled by event: {event_data.source}") return # 如果拦截源与插件标识一致时,这里认为需要强制触发注册 if pid is not None and pid == event_data.source: force_register = True initial_commands = event_data.commands or {} logger.debug(f"Registering command count from event: {len(initial_commands)}") else: logger.debug(f"Registering initial command count: {len(initial_commands)}") # initial_commands 必须是 self._commands 的子集 filtered_initial_commands = DictUtils.filter_keys_to_subset(initial_commands, self._commands) # 如果 filtered_initial_commands 为空,则跳过注册 if not filtered_initial_commands and not force_register: logger.debug("Filtered commands are empty, skipping registration.") return # 对比调整后的命令与当前命令 if filtered_initial_commands != self._registered_commands or force_register: logger.debug("Command set has changed or force registration is enabled.") self._registered_commands = filtered_initial_commands CommandChain().register_commands(commands=filtered_initial_commands) else: logger.debug("Command set unchanged, skipping broadcast registration.") except Exception as e: logger.error(f"Error occurred during command initialization in background: {e}", exc_info=True) def __trigger_register_commands_event(self) -> tuple[Optional[Event], dict]: """ 触发事件,允许调整命令数据 """ def add_commands(source, command_type): """ 添加命令集合 """ for cmd, command in source.items(): if not command.get("show", True): continue command_data = { "type": command_type, "description": command.get("description"), "category": command.get("category") } # 如果有 pid,则添加到命令数据中 plugin_id = command.get("pid") if plugin_id: command_data["pid"] = plugin_id commands[cmd] = command_data # 初始化命令字典 commands: Dict[str, dict] = {} add_commands(self._preset_commands, "preset") add_commands(self._plugin_commands, "plugin") add_commands(self._other_commands, "other") # 触发事件允许可以拦截和调整命令 event_data = CommandRegisterEventData(commands=commands, origin="CommandChain", service=None) event = eventmanager.send_event(ChainEventType.CommandRegister, event_data) return event, commands def __build_plugin_commands(self, _: Optional[str] = None) -> Dict[str, dict]: """ 构建插件命令 """ # 为了保证命令顺序的一致性,目前这里没有直接使用 pid 获取单一插件命令,后续如果存在性能问题,可以考虑优化这里的逻辑 plugin_commands = {} for command in self.pluginmanager.get_plugin_commands(): cmd = command.get("cmd") if cmd: plugin_commands[cmd] = { "pid": command.get("pid"), "func": self.send_plugin_event, "description": command.get("desc"), "category": command.get("category"), "show": command.get("show", True), "data": { "etype": command.get("event"), "data": command.get("data") } } return plugin_commands def __run_command(self, command: Dict[str, any], data_str: Optional[str] = "", channel: MessageChannel = None, source: Optional[str] = None, userid: Union[str, int] = None): """ 运行定时服务 """ if command.get("type") == "scheduler": # 定时服务 if userid: CommandChain().post_message( Notification( channel=channel, source=source, title=f"开始执行 {command.get('description')} ...", userid=userid ) ) # 执行定时任务 self.scheduler.start(job_id=command.get("id")) if userid: CommandChain().post_message( Notification( channel=channel, source=source, title=f"{command.get('description')} 执行完成", userid=userid ) ) else: # 命令 cmd_data = copy.deepcopy(command['data']) if command.get('data') else {} args_num = ObjectUtils.arguments(command['func']) if args_num > 0: if cmd_data: # 有内置参数直接使用内置参数 data = cmd_data.get("data") or {} data['channel'] = channel data['source'] = source data['user'] = userid if data_str: data['arg_str'] = data_str cmd_data['data'] = data command['func'](**cmd_data) elif args_num == 3: # 没有输入参数,只输入渠道来源、用户ID和消息来源 command['func'](channel, userid, source) elif args_num > 3: # 多个输入参数:用户输入、用户ID command['func'](data_str, channel, userid, source) else: # 没有参数 command['func']() def get_commands(self): """ 获取命令列表 """ return self._commands def get(self, cmd: str) -> Any: """ 获取命令 """ return self._commands.get(cmd, {}) def register(self, cmd: str, func: Any, data: Optional[dict] = None, desc: Optional[str] = None, category: Optional[str] = None, show: bool = True) -> None: """ 注册单个命令 """ # 单独调用的,统一注册到其他 self._other_commands[cmd] = { "func": func, "description": desc, "category": category, "data": data or {}, "show": show } def execute(self, cmd: str, data_str: Optional[str] = "", channel: MessageChannel = None, source: Optional[str] = None, userid: Union[str, int] = None) -> None: """ 执行命令 """ command = self.get(cmd) if command: try: if userid: logger.info(f"用户 {userid} 开始执行:{command.get('description')} ...") else: logger.info(f"开始执行:{command.get('description')} ...") # 执行命令 self.__run_command(command, data_str=data_str, channel=channel, source=source, userid=userid) if userid: logger.info(f"用户 {userid} {command.get('description')} 执行完成") else: logger.info(f"{command.get('description')} 执行完成") except Exception as err: logger.error(f"执行命令 {cmd} 出错:{str(err)} - {traceback.format_exc()}") self.messagehelper.put(title=f"执行命令 {cmd} 出错", message=str(err), role="system") @staticmethod def send_plugin_event(etype: EventType, data: dict) -> None: """ 发送插件命令 """ eventmanager.send_event(etype, data) @eventmanager.register(EventType.CommandExcute) def command_event(self, event: ManagerEvent) -> None: """ 注册命令执行事件 event_data: { "cmd": "/xxx args" } """ # 命令参数 event_str = event.event_data.get('cmd') # 消息渠道 event_channel = event.event_data.get('channel') # 消息来源 event_source = event.event_data.get('source') # 消息用户 event_user = event.event_data.get('user') if event_str: cmd = event_str.split()[0] args = " ".join(event_str.split()[1:]) if self.get(cmd): self.execute(cmd=cmd, data_str=args, channel=event_channel, source=event_source, userid=event_user) @eventmanager.register(EventType.ModuleReload) def module_reload_event(self, _: ManagerEvent) -> None: """ 注册模块重载事件 """ # 发生模块重载时,重新注册命令 self.init_commands() ================================================ FILE: app/core/__init__.py ================================================ ================================================ FILE: app/core/cache.py ================================================ import contextvars import inspect import shutil import tempfile import threading from abc import ABC, abstractmethod from contextlib import contextmanager, asynccontextmanager from functools import wraps from pathlib import Path from typing import Any, Dict, Optional, Generator, AsyncGenerator, Tuple, Literal, Union import aiofiles import aioshutil from anyio import Path as AsyncPath from cachetools import LRUCache as MemoryLRUCache from cachetools import TTLCache as MemoryTTLCache from cachetools.keys import hashkey from app.core.config import settings from app.helper.redis import RedisHelper, AsyncRedisHelper from app.log import logger # 默认缓存区 DEFAULT_CACHE_REGION = "DEFAULT" # 默认缓存大小 DEFAULT_CACHE_SIZE = 1024 # 默认缓存有效期 DEFAULT_CACHE_TTL = 365 * 24 * 60 * 60 # 上下文变量来控制缓存行为 _fresh = contextvars.ContextVar('fresh', default=False) class CacheBackend(ABC): """ 缓存后端基类,定义通用的缓存接口 """ def __getitem__(self, key: str) -> Any: """ 获取缓存项,类似 dict[key] """ value = self.get(key) if value is None: raise KeyError(key) return value def __setitem__(self, key: str, value: Any) -> None: """ 设置缓存项,类似 dict[key] = value """ self.set(key, value) def __delitem__(self, key: str) -> None: """ 删除缓存项,类似 del dict[key] """ if not self.exists(key): raise KeyError(key) self.delete(key) def __contains__(self, key: str) -> bool: """ 检查键是否存在,类似 key in dict """ return self.exists(key) def __iter__(self): """ 返回缓存的迭代器,类似 iter(dict) """ for key, _ in self.items(): yield key def __len__(self) -> int: """ 返回缓存项的数量,类似 len(dict) """ return sum(1 for _ in self.items()) @abstractmethod def set(self, key: str, value: Any, ttl: Optional[int] = None, region: Optional[str] = DEFAULT_CACHE_REGION, **kwargs) -> None: """ 设置缓存 :param key: 缓存的键 :param value: 缓存的值 :param ttl: 缓存的存活时间,单位秒 :param region: 缓存的区 :param kwargs: 其他参数 """ pass @abstractmethod def exists(self, key: str, region: Optional[str] = DEFAULT_CACHE_REGION) -> bool: """ 判断缓存键是否存在 :param key: 缓存的键 :param region: 缓存的区 :return: 存在返回 True,否则返回 False """ pass @abstractmethod def get(self, key: str, region: Optional[str] = DEFAULT_CACHE_REGION) -> Any: """ 获取缓存 :param key: 缓存的键 :param region: 缓存的区 :return: 返回缓存的值,如果缓存不存在返回 None """ pass @abstractmethod def delete(self, key: str, region: Optional[str] = DEFAULT_CACHE_REGION) -> None: """ 删除缓存 :param key: 缓存的键 :param region: 缓存的区 """ pass @abstractmethod def clear(self, region: Optional[str] = DEFAULT_CACHE_REGION) -> None: """ 清除指定区域的缓存或全部缓存 :param region: 缓存的区,为None时清空所有区缓存 """ pass @abstractmethod def items(self, region: Optional[str] = DEFAULT_CACHE_REGION) -> Generator[Tuple[str, Any], None, None]: """ 获取指定区域的所有缓存项 :param region: 缓存的区 :return: 返回一个字典,包含所有缓存键值对 """ pass def keys(self, region: Optional[str] = DEFAULT_CACHE_REGION) -> Generator[str, None, None]: """ 获取所有缓存键,类似 dict.keys() """ for key, _ in self.items(region=region): yield key def values(self, region: Optional[str] = DEFAULT_CACHE_REGION) -> Generator[Any, None, None]: """ 获取所有缓存值,类似 dict.values() """ for _, value in self.items(region=region): yield value def update(self, other: Dict[str, Any], region: Optional[str] = DEFAULT_CACHE_REGION, ttl: Optional[int] = None, **kwargs) -> None: """ 更新缓存,类似 dict.update() """ for key, value in other.items(): self.set(key, value, ttl=ttl, region=region, **kwargs) def pop(self, key: str, default: Any = None, region: Optional[str] = DEFAULT_CACHE_REGION) -> Any: """ 弹出缓存项,类似 dict.pop() """ value = self.get(key, region=region) if value is not None: self.delete(key, region=region) return value if default is not None: return default raise KeyError(key) def popitem(self, region: Optional[str] = DEFAULT_CACHE_REGION) -> Tuple[str, Any]: """ 弹出最后一个缓存项,类似 dict.popitem() """ items = list(self.items(region=region)) if not items: raise KeyError("popitem(): cache is empty") key, value = items[-1] self.delete(key, region=region) return key, value def setdefault(self, key: str, default: Any = None, region: Optional[str] = DEFAULT_CACHE_REGION, ttl: Optional[int] = None, **kwargs) -> Any: """ 设置默认值,类似 dict.setdefault() """ value = self.get(key, region=region) if value is None: self.set(key, default, ttl=ttl, region=region, **kwargs) return default return value @abstractmethod def close(self) -> None: """ 关闭缓存连接 """ pass @staticmethod def get_region(region: Optional[str] = None) -> str: """ 获取缓存的区 """ return f"region:{region}" if region else "region:default" @staticmethod def is_redis() -> bool: """ 判断当前缓存后端是否为 Redis """ return settings.CACHE_BACKEND_TYPE == "redis" class AsyncCacheBackend(CacheBackend): """ 缓存后端基类,定义通用的缓存接口(异步) """ @abstractmethod async def set(self, key: str, value: Any, ttl: Optional[int] = None, region: Optional[str] = DEFAULT_CACHE_REGION, **kwargs) -> None: """ 设置缓存 :param key: 缓存的键 :param value: 缓存的值 :param ttl: 缓存的存活时间,单位秒 :param region: 缓存的区 :param kwargs: 其他参数 """ pass @abstractmethod async def exists(self, key: str, region: Optional[str] = DEFAULT_CACHE_REGION) -> bool: """ 判断缓存键是否存在 :param key: 缓存的键 :param region: 缓存的区 :return: 存在返回 True,否则返回 False """ pass @abstractmethod async def get(self, key: str, region: Optional[str] = DEFAULT_CACHE_REGION) -> Any: """ 获取缓存 :param key: 缓存的键 :param region: 缓存的区 :return: 返回缓存的值,如果缓存不存在返回 None """ pass @abstractmethod async def delete(self, key: str, region: Optional[str] = DEFAULT_CACHE_REGION) -> None: """ 删除缓存 :param key: 缓存的键 :param region: 缓存的区 """ pass @abstractmethod async def clear(self, region: Optional[str] = DEFAULT_CACHE_REGION) -> None: """ 清除指定区域的缓存或全部缓存 :param region: 缓存的区,为None时清空所有区缓存 """ pass @abstractmethod async def items(self, region: Optional[str] = DEFAULT_CACHE_REGION) -> AsyncGenerator[Tuple[str, Any], None]: """ 获取指定区域的所有缓存项 :param region: 缓存的区 :return: 返回一个字典,包含所有缓存键值对 """ pass async def keys(self, region: Optional[str] = DEFAULT_CACHE_REGION) -> AsyncGenerator[str, None]: """ 获取所有缓存键,类似 dict.keys()(异步) """ async for key, _ in self.items(region=region): yield key async def values(self, region: Optional[str] = DEFAULT_CACHE_REGION) -> AsyncGenerator[Any, None]: """ 获取所有缓存值,类似 dict.values()(异步) """ async for _, value in self.items(region=region): yield value async def update(self, other: Dict[str, Any], region: Optional[str] = DEFAULT_CACHE_REGION, ttl: Optional[int] = None, **kwargs) -> None: """ 更新缓存,类似 dict.update()(异步) """ for key, value in other.items(): await self.set(key, value, ttl=ttl, region=region, **kwargs) async def pop(self, key: str, default: Any = None, region: Optional[str] = DEFAULT_CACHE_REGION) -> Any: """ 弹出缓存项,类似 dict.pop()(异步) """ value = await self.get(key, region=region) if value is not None: await self.delete(key, region=region) return value if default is not None: return default raise KeyError(key) async def popitem(self, region: Optional[str] = DEFAULT_CACHE_REGION) -> Tuple[str, Any]: """ 弹出最后一个缓存项,类似 dict.popitem()(异步) """ items = [] async for item in self.items(region=region): items.append(item) if not items: raise KeyError("popitem(): cache is empty") key, value = items[-1] await self.delete(key, region=region) return key, value async def setdefault(self, key: str, default: Any = None, region: Optional[str] = DEFAULT_CACHE_REGION, ttl: Optional[int] = None, **kwargs) -> Any: """ 设置默认值,类似 dict.setdefault()(异步) """ value = await self.get(key, region=region) if value is None: await self.set(key, default, ttl=ttl, region=region, **kwargs) return default return value @abstractmethod async def close(self) -> None: """ 关闭缓存连接 """ pass class MemoryBackend(CacheBackend): """ 基于 `cachetools.TTLCache` 实现的缓存后端 """ # 类变量 _region_caches 的互斥锁 _lock = threading.Lock() # 存储各个 region 的缓存实例,region -> TTLCache _region_caches: Dict[str, Union[MemoryTTLCache, MemoryLRUCache]] = {} def __init__(self, cache_type: Literal['ttl', 'lru'] = 'ttl', maxsize: Optional[int] = None, ttl: Optional[int] = None): """ 初始化缓存实例 :param cache_type: 缓存类型,支持 'ttl'(默认)和 'lru' :param maxsize: 缓存的最大条目数 :param ttl: 默认缓存存活时间,单位秒 """ self.cache_type = cache_type self.maxsize = maxsize or DEFAULT_CACHE_SIZE self.ttl = ttl or DEFAULT_CACHE_TTL def __get_region_cache(self, region: str) -> Optional[Union[MemoryTTLCache, MemoryLRUCache]]: """ 获取指定区域的缓存实例,如果不存在则返回 None """ region = self.get_region(region) return self._region_caches.get(region) def set(self, key: str, value: Any, ttl: Optional[int] = None, region: Optional[str] = DEFAULT_CACHE_REGION, **kwargs) -> None: """ 设置缓存值支持每个 key 独立配置 TTL :param key: 缓存的键 :param value: 缓存的值 :param ttl: 缓存的存活时间,不传入为永久缓存,单位秒 :param region: 缓存的区 """ ttl = ttl or self.ttl maxsize = kwargs.get("maxsize", self.maxsize) region = self.get_region(region) # 设置缓存值 with self._lock: # 如果该 key 尚未有缓存实例,则创建一个新的 TTLCache 实例 region_cache = self._region_caches.setdefault( region, MemoryTTLCache(maxsize=maxsize, ttl=ttl) if self.cache_type == 'ttl' else MemoryLRUCache(maxsize=maxsize) ) region_cache[key] = value def exists(self, key: str, region: Optional[str] = DEFAULT_CACHE_REGION) -> bool: """ 判断缓存键是否存在 :param key: 缓存的键 :param region: 缓存的区 :return: 存在返回 True,否则返回 False """ region_cache = self.__get_region_cache(region) if region_cache is None: return False return key in region_cache def get(self, key: str, region: Optional[str] = DEFAULT_CACHE_REGION) -> Any: """ 获取缓存的值 :param key: 缓存的键 :param region: 缓存的区 :return: 返回缓存的值,如果缓存不存在返回 None """ region_cache = self.__get_region_cache(region) if region_cache is None: return None return region_cache.get(key) def delete(self, key: str, region: Optional[str] = DEFAULT_CACHE_REGION): """ 删除缓存 :param key: 缓存的键 :param region: 缓存的区 """ region_cache = self.__get_region_cache(region) if region_cache is None: return with self._lock: del region_cache[key] def clear(self, region: Optional[str] = DEFAULT_CACHE_REGION) -> None: """ 清除指定区域的缓存或全部缓存 :param region: 缓存的区,为None时清空所有区缓存 """ if region: # 清理指定缓存区 region_cache = self.__get_region_cache(region) if region_cache: with self._lock: region_cache.clear() logger.debug(f"Cleared cache for region: {region}") else: # 清除所有区域的缓存 for region_cache in self._region_caches.values(): with self._lock: region_cache.clear() logger.info("Cleared all cache") def items(self, region: Optional[str] = DEFAULT_CACHE_REGION) -> Generator[Tuple[str, Any], None, None]: """ 获取指定区域的所有缓存项 :param region: 缓存的区 :return: 返回一个字典,包含所有缓存键值对 """ region_cache = self.__get_region_cache(region) if region_cache is None: yield from () return # 使用锁保护迭代过程,避免在迭代时缓存被修改 with self._lock: # 创建快照避免并发修改问题 items_snapshot = list(region_cache.items()) for item in items_snapshot: yield item def close(self) -> None: """ 内存缓存不需要关闭资源 """ pass class AsyncMemoryBackend(AsyncCacheBackend): """ 基于 `cachetools.TTLCache` 实现的异步缓存后端 """ def __init__(self, cache_type: Literal['ttl', 'lru'] = 'ttl', maxsize: Optional[int] = None, ttl: Optional[int] = None): """ 初始化缓存实例 :param cache_type: 缓存类型,支持 'ttl'(默认)和 'lru' :param maxsize: 缓存的最大条目数 :param ttl: 默认缓存存活时间,单位秒 """ self._backend = MemoryBackend(cache_type=cache_type, maxsize=maxsize, ttl=ttl) async def set(self, key: str, value: Any, ttl: Optional[int] = None, region: Optional[str] = DEFAULT_CACHE_REGION, **kwargs) -> None: """ 设置缓存值支持每个 key 独立配置 TTL :param key: 缓存的键 :param value: 缓存的值 :param ttl: 缓存的存活时间,不传入为永久缓存,单位秒 :param region: 缓存的区 """ return self._backend.set(key=key, value=value, ttl=ttl, region=region, **kwargs) async def exists(self, key: str, region: Optional[str] = DEFAULT_CACHE_REGION) -> bool: """ 判断缓存键是否存在 :param key: 缓存的键 :param region: 缓存的区 :return: 存在返回 True,否则返回 False """ return self._backend.exists(key=key, region=region) async def get(self, key: str, region: Optional[str] = DEFAULT_CACHE_REGION) -> Any: """ 获取缓存的值 :param key: 缓存的键 :param region: 缓存的区 :return: 返回缓存的值,如果缓存不存在返回 None """ return self._backend.get(key=key, region=region) async def delete(self, key: str, region: Optional[str] = DEFAULT_CACHE_REGION): """ 删除缓存 :param key: 缓存的键 :param region: 缓存的区 """ return self._backend.delete(key=key, region=region) async def clear(self, region: Optional[str] = DEFAULT_CACHE_REGION) -> None: """ 清除指定区域的缓存或全部缓存 :param region: 缓存的区,为None时清空所有区缓存 """ return self._backend.clear(region=region) async def items(self, region: Optional[str] = DEFAULT_CACHE_REGION) -> AsyncGenerator[Tuple[str, Any], None]: """ 获取指定区域的所有缓存项 :param region: 缓存的区 :return: 返回一个字典,包含所有缓存键值对 """ for item in self._backend.items(region): yield item async def close(self) -> None: """ 内存缓存不需要关闭资源 """ pass class RedisBackend(CacheBackend): """ 基于 Redis 实现的缓存后端,支持通过 Redis 存储缓存 """ def __init__(self, ttl: Optional[int] = None): """ 初始化 Redis 缓存实例 :param ttl: 缓存的存活时间,单位秒 """ self.ttl = ttl self.redis_helper = RedisHelper() def set(self, key: str, value: Any, ttl: Optional[int] = None, region: Optional[str] = DEFAULT_CACHE_REGION, **kwargs) -> None: """ 设置缓存 :param key: 缓存的键 :param value: 缓存的值 :param ttl: 缓存的存活时间,未传入则为永久缓存,单位秒 :param region: 缓存的区 :param kwargs: kwargs """ ttl = ttl or self.ttl self.redis_helper.set(key, value, ttl=ttl, region=region, **kwargs) def exists(self, key: str, region: Optional[str] = DEFAULT_CACHE_REGION) -> bool: """ 判断缓存键是否存在 :param key: 缓存的键 :param region: 缓存的区 :return: 存在返回 True,否则返回 False """ return self.redis_helper.exists(key, region=region) def get(self, key: str, region: Optional[str] = DEFAULT_CACHE_REGION) -> Optional[Any]: """ 获取缓存的值 :param key: 缓存的键 :param region: 缓存的区 :return: 返回缓存的值,如果缓存不存在返回 None """ return self.redis_helper.get(key, region=region) def delete(self, key: str, region: Optional[str] = DEFAULT_CACHE_REGION) -> None: """ 删除缓存 :param key: 缓存的键 :param region: 缓存的区 """ self.redis_helper.delete(key, region=region) def clear(self, region: Optional[str] = DEFAULT_CACHE_REGION) -> None: """ 清除指定区域的缓存或全部缓存 :param region: 缓存的区,为None时清空所有区缓存 """ self.redis_helper.clear(region=region) def items(self, region: Optional[str] = DEFAULT_CACHE_REGION) -> Generator[Tuple[str, Any], None, None]: """ 获取指定区域的所有缓存项 :param region: 缓存的区 :return: 返回一个字典,包含所有缓存键值对 """ return self.redis_helper.items(region=region) def close(self) -> None: """ 关闭 Redis 客户端的连接池 """ self.redis_helper.close() class AsyncRedisBackend(AsyncCacheBackend): """ 基于 Redis 实现的缓存后端,支持通过 Redis 存储缓存 """ def __init__(self, ttl: Optional[int] = None): """ 初始化 Redis 缓存实例 :param ttl: 缓存的存活时间,单位秒 """ self.ttl = ttl self.redis_helper = AsyncRedisHelper() async def set(self, key: str, value: Any, ttl: Optional[int] = None, region: Optional[str] = DEFAULT_CACHE_REGION, **kwargs) -> None: """ 设置缓存 :param key: 缓存的键 :param value: 缓存的值 :param ttl: 缓存的存活时间,未传入则为永久缓存,单位秒 :param region: 缓存的区 :param kwargs: kwargs """ ttl = ttl or self.ttl await self.redis_helper.set(key, value, ttl=ttl, region=region, **kwargs) async def exists(self, key: str, region: Optional[str] = DEFAULT_CACHE_REGION) -> bool: """ 判断缓存键是否存在 :param key: 缓存的键 :param region: 缓存的区 :return: 存在返回 True,否则返回 False """ return await self.redis_helper.exists(key, region=region) async def get(self, key: str, region: Optional[str] = DEFAULT_CACHE_REGION) -> Optional[Any]: """ 获取缓存的值 :param key: 缓存的键 :param region: 缓存的区 :return: 返回缓存的值,如果缓存不存在返回 None """ return await self.redis_helper.get(key, region=region) async def delete(self, key: str, region: Optional[str] = DEFAULT_CACHE_REGION) -> None: """ 删除缓存 :param key: 缓存的键 :param region: 缓存的区 """ await self.redis_helper.delete(key, region=region) async def clear(self, region: Optional[str] = DEFAULT_CACHE_REGION) -> None: """ 清除指定区域的缓存或全部缓存 :param region: 缓存的区,为None时清空所有区缓存 """ await self.redis_helper.clear(region=region) async def items(self, region: Optional[str] = DEFAULT_CACHE_REGION) -> AsyncGenerator[Tuple[str, Any], None]: """ 获取指定区域的所有缓存项 :param region: 缓存的区 :return: 返回一个字典,包含所有缓存键值对 """ async for item in self.redis_helper.items(region=region): yield item async def close(self) -> None: """ 关闭 Redis 客户端的连接池 """ await self.redis_helper.close() class FileBackend(CacheBackend): """ 基于 文件系统 实现的缓存后端 """ def __init__(self, base: Path): """ 初始化文件缓存实例 """ self.base = base if not self.base.exists(): self.base.mkdir(parents=True, exist_ok=True) def set(self, key: str, value: Any, region: Optional[str] = DEFAULT_CACHE_REGION, **kwargs) -> None: """ 设置缓存 :param key: 缓存的键 :param value: 缓存的值 :param region: 缓存的区 :param kwargs: kwargs """ cache_path = self.base / region / key # 确保缓存目录存在 cache_path.parent.mkdir(parents=True, exist_ok=True) # 将值序列化为字符串存储 with tempfile.NamedTemporaryFile(dir=cache_path.parent, delete=False) as tmp_file: tmp_file.write(value) temp_path = Path(tmp_file.name) temp_path.replace(cache_path) def exists(self, key: str, region: Optional[str] = DEFAULT_CACHE_REGION) -> bool: """ 判断缓存键是否存在 :param key: 缓存的键 :param region: 缓存的区 :return: 存在返回 True,否则返回 False """ cache_path = self.base / region / key return cache_path.exists() def get(self, key: str, region: Optional[str] = DEFAULT_CACHE_REGION) -> Optional[Any]: """ 获取缓存的值 :param key: 缓存的键 :param region: 缓存的区 :return: 返回缓存的值,如果缓存不存在返回 None """ cache_path = self.base / region / key if not cache_path.exists(): return None with open(cache_path, 'rb') as f: return f.read() def delete(self, key: str, region: Optional[str] = DEFAULT_CACHE_REGION) -> None: """ 删除缓存 :param key: 缓存的键 :param region: 缓存的区 """ cache_path = self.base / region / key if cache_path.exists(): cache_path.unlink() def clear(self, region: Optional[str] = DEFAULT_CACHE_REGION) -> None: """ 清除指定区域的缓存或全部缓存 :param region: 缓存的区,为None时清空所有区缓存 """ if region: # 清理指定缓存区 cache_path = self.base / region if cache_path.exists(): for item in cache_path.iterdir(): if item.is_file(): item.unlink() else: shutil.rmtree(item, ignore_errors=True) else: # 清除所有区域的缓存 for item in self.base.iterdir(): if item.is_file(): item.unlink() else: shutil.rmtree(item, ignore_errors=True) def items(self, region: Optional[str] = DEFAULT_CACHE_REGION) -> Generator[Tuple[str, Any], None, None]: """ 获取指定区域的所有缓存项 :param region: 缓存的区 :return: 返回一个字典,包含所有缓存键值对 """ cache_path = self.base / region if not cache_path.exists(): yield from () return for item in cache_path.iterdir(): if item.is_file(): with open(item, 'r') as f: yield item.as_posix(), f.read() def close(self) -> None: """ 关闭 Redis 客户端的连接池 """ pass class AsyncFileBackend(AsyncCacheBackend): """ 基于 文件系统 实现的缓存后端(异步模式) """ def __init__(self, base: Path): """ 初始化文件缓存实例 """ self.base = base if not self.base.exists(): self.base.mkdir(parents=True, exist_ok=True) async def set(self, key: str, value: Any, region: Optional[str] = DEFAULT_CACHE_REGION, **kwargs) -> None: """ 设置缓存 :param key: 缓存的键 :param value: 缓存的值 :param region: 缓存的区 :param kwargs: kwargs """ cache_path = AsyncPath(self.base) / region / key # 确保缓存目录存在 await cache_path.parent.mkdir(parents=True, exist_ok=True) # 保存文件 async with aiofiles.tempfile.NamedTemporaryFile(dir=cache_path.parent, delete=False) as tmp_file: await tmp_file.write(value) temp_path = AsyncPath(tmp_file.name) await temp_path.replace(cache_path) async def exists(self, key: str, region: Optional[str] = DEFAULT_CACHE_REGION) -> bool: """ 判断缓存键是否存在 :param key: 缓存的键 :param region: 缓存的区 :return: 存在返回 True,否则返回 False """ cache_path = AsyncPath(self.base) / region / key return await cache_path.exists() async def get(self, key: str, region: Optional[str] = DEFAULT_CACHE_REGION) -> Optional[Any]: """ 获取缓存的值 :param key: 缓存的键 :param region: 缓存的区 :return: 返回缓存的值,如果缓存不存在返回 None """ cache_path = AsyncPath(self.base) / region / key if not await cache_path.exists(): return None async with aiofiles.open(cache_path, 'rb') as f: return await f.read() async def delete(self, key: str, region: Optional[str] = DEFAULT_CACHE_REGION) -> None: """ 删除缓存 :param key: 缓存的键 :param region: 缓存的区 """ cache_path = AsyncPath(self.base) / region / key if await cache_path.exists(): await cache_path.unlink() async def clear(self, region: Optional[str] = DEFAULT_CACHE_REGION) -> None: """ 清除指定区域的缓存或全部缓存 :param region: 缓存的区,为None时清空所有区缓存 """ if region: # 清理指定缓存区 cache_path = AsyncPath(self.base) / region if await cache_path.exists(): async for item in cache_path.iterdir(): if await item.is_file(): await item.unlink() else: await aioshutil.rmtree(item, ignore_errors=True) else: # 清除所有区域的缓存 async for item in AsyncPath(self.base).iterdir(): if await item.is_file(): await item.unlink() else: await aioshutil.rmtree(item, ignore_errors=True) async def items(self, region: Optional[str] = DEFAULT_CACHE_REGION) -> AsyncGenerator[Tuple[str, Any], None]: """ 获取指定区域的所有缓存项 :param region: 缓存的区 :return: 返回一个字典,包含所有缓存键值对 """ cache_path = AsyncPath(self.base) / region if not await cache_path.exists(): yield "", None return async for item in cache_path.iterdir(): if await item.is_file(): async with aiofiles.open(item, 'r') as f: yield item.as_posix(), await f.read() async def close(self) -> None: """ 关闭 Redis 客户端的连接池 """ pass @contextmanager def fresh(fresh: bool = True): """ 是否获取新数据(不使用缓存的值) Usage: with fresh(): result = some_cached_function() """ token = _fresh.set(fresh or is_fresh()) try: yield finally: _fresh.reset(token) @asynccontextmanager async def async_fresh(fresh: bool = True): """ 是否获取新数据(不使用缓存的值) Usage: async with async_fresh(): result = await some_async_cached_function() """ token = _fresh.set(fresh or is_fresh()) try: yield finally: _fresh.reset(token) def is_fresh() -> bool: """ 是否获取新数据 """ try: return _fresh.get() except LookupError: return False def FileCache(base: Path = settings.TEMP_PATH, ttl: Optional[int] = None) -> CacheBackend: """ 获取文件缓存后端实例(Redis或文件系统),ttl仅在Redis环境中有效 """ if settings.CACHE_BACKEND_TYPE == "redis": # 如果使用 Redis,则设置缓存的存活时间为配置的天数转换为秒 return RedisBackend(ttl=ttl or settings.TEMP_FILE_DAYS * 24 * 3600) else: # 如果使用文件系统,在停止服务时会自动清理过期文件 return FileBackend(base=base) def AsyncFileCache(base: Path = settings.TEMP_PATH, ttl: Optional[int] = None) -> AsyncCacheBackend: """ 获取文件异步缓存后端实例(Redis或文件系统),ttl仅在Redis环境中有效 """ if settings.CACHE_BACKEND_TYPE == "redis": # 如果使用 Redis,则设置缓存的存活时间为配置的天数转换为秒 return AsyncRedisBackend(ttl=ttl or settings.TEMP_FILE_DAYS * 24 * 3600) else: # 如果使用文件系统,在停止服务时会自动清理过期文件 return AsyncFileBackend(base=base) def Cache(cache_type: Literal['ttl', 'lru'] = 'ttl', maxsize: Optional[int] = None, ttl: Optional[int] = None) -> CacheBackend: """ 根据配置获取缓存后端实例(内存或Redis),maxsize仅在未启用Redis时生效 :param cache_type: 缓存类型,仅使用内存缓存时生效,支持 'ttl'(默认)和 'lru' :param maxsize: 缓存的最大条目数,仅使用cachetools时生效 :param ttl: 缓存的默认存活时间,单位秒 :return: 返回缓存后端实例 """ if settings.CACHE_BACKEND_TYPE == "redis": return RedisBackend(ttl=ttl) else: # 使用内存缓存,maxsize需要有值 return MemoryBackend(cache_type=cache_type, maxsize=maxsize, ttl=ttl) def AsyncCache(cache_type: Literal['ttl', 'lru'] = 'ttl', maxsize: Optional[int] = None, ttl: Optional[int] = None) -> AsyncCacheBackend: """ 根据配置获取异步缓存后端实例(内存或Redis),maxsize仅在未启用Redis时生效 :param cache_type: 缓存类型,仅使用内存缓存时生效,支持 'ttl'(默认)和 'lru' :param maxsize: 缓存的最大条目数,仅使用cachetools时生效 :param ttl: 缓存的默认存活时间,单位秒 :return: 返回异步缓存后端实例 """ if settings.CACHE_BACKEND_TYPE == "redis": return AsyncRedisBackend(ttl=ttl) else: # 使用异步内存缓存,maxsize需要有值 return AsyncMemoryBackend(cache_type=cache_type, maxsize=maxsize, ttl=ttl) def cached(region: Optional[str] = None, maxsize: Optional[int] = 1024, ttl: Optional[int] = None, skip_none: Optional[bool] = True, skip_empty: Optional[bool] = False, shared_key: Optional[str] = None): """ 自定义缓存装饰器,支持为每个 key 动态传递 maxsize 和 ttl :param region: 缓存区域的标识符,默认根据模块名、函数名等自动生成标识 :param maxsize: 缓存区内的最大条目数 :param ttl: 缓存的存活时间,单位秒,未传入则为永久缓存,单位秒 :param skip_none: 跳过 None 缓存,默认为 True :param skip_empty: 跳过空值缓存(如 None, [], {}, "", set()),默认为 False :param shared_key: 同步/异步函数共享缓存的键,默认使用函数名(异步函数名会标准化为同步格式,如移除 `async_` 前缀) :return: 装饰器函数 """ def decorator(func): def should_cache(value: Any) -> bool: """ 判断是否应该缓存结果,如果返回值是 None 或空值则不缓存 :param value: 要判断的缓存值 :return: 是否缓存结果 """ if skip_none and value is None: return False # if skip_empty and value in [None, [], {}, "", set()]: if skip_empty and not value: return False return True def is_valid_cache_value(_cache_key: str, _cached_value: Any, _cache_region: str) -> bool: """ 判断指定的值是否为一个有效的缓存值 :param _cache_key: 缓存的键 :param _cached_value: 缓存的值 :param _cache_region: 缓存的区 :return: 若值是有效的缓存值返回 True,否则返回 False """ # 如果 skip_none 为 False,且 value 为 None,需要判断缓存实际是否存在 if not skip_none and _cached_value is None: if not cache_backend.exists(key=_cache_key, region=_cache_region): return False return True async def async_is_valid_cache_value(_cache_key: str, _cached_value: Any, _cache_region: str) -> bool: """ 判断指定的值是否为一个有效的缓存值(异步版本) :param _cache_key: 缓存的键 :param _cached_value: 缓存的值 :param _cache_region: 缓存的区 :return: 若值是有效的缓存值返回 True,否则返回 False """ # 如果 skip_none 为 False,且 value 为 None,需要判断缓存实际是否存在 if not skip_none and _cached_value is None: if not await cache_backend.exists(key=_cache_key, region=_cache_region): return False return True def __standardize_func_name() -> str: """ 将异步函数名标准化为同步函数的命名,以生成统一的缓存键 """ # XXX 假设异步函数名与同步版本仅差`async_`前缀或`_async`后缀(当前MP代码大多符合),否则需通过`shared_key`参数显式指定 return ( func.__name__.removeprefix("async_").removesuffix("_async") if is_async else func.__name__ ) def __get_cache_key(args, kwargs) -> str: """ 根据函数和参数生成缓存键 :param args: 位置参数 :param kwargs: 关键字参数 :return: 缓存键 """ signature = inspect.signature(func) # 绑定传入的参数并应用默认值 bound = signature.bind(*args, **kwargs) bound.apply_defaults() # 忽略第一个参数,如果它是实例(self)或类(cls) parameters = list(signature.parameters.keys()) if parameters and parameters[0] in ("self", "cls"): bound.arguments.pop(parameters[0], None) # 按照函数签名顺序提取参数值列表 keys = [ bound.arguments[param] for param in signature.parameters if param in bound.arguments ] # 使用有序参数生成缓存键 return f"{func_name}_{hashkey(*keys)}" # 被装饰函数的上层名称(如类名或外层函数名) enclosing_name = ( func.__qualname__[:last_dot] if (last_dot := func.__qualname__.rfind(".")) != -1 else "" ) # 检查是否为异步函数 is_async = inspect.iscoroutinefunction(func) # 生成标准化后的函数名称,用于同步/异步函数共享缓存 func_name = shared_key if shared_key else __standardize_func_name() # 获取缓存区 cache_region = ( region if region is not None else f"{func.__module__}:{enclosing_name}:{func_name}" ) if is_async: # 异步函数使用异步缓存后端 cache_backend = AsyncCache(cache_type="ttl" if ttl else "lru", maxsize=maxsize, ttl=ttl) # 异步函数的缓存装饰器 @wraps(func) async def async_wrapper(*args, **kwargs): # 获取缓存键 cache_key = __get_cache_key(args, kwargs) if not is_fresh(): # 尝试获取缓存 cached_value = await cache_backend.get(cache_key, region=cache_region) if should_cache(cached_value) and await async_is_valid_cache_value(cache_key, cached_value, cache_region): return cached_value # 执行异步函数并缓存结果 result = await func(*args, **kwargs) # 判断是否需要缓存 if not should_cache(result): return result # 设置缓存(如果有传入的 maxsize 和 ttl,则覆盖默认值) await cache_backend.set(cache_key, result, ttl=ttl, maxsize=maxsize, region=cache_region) return result async def cache_clear(): """ 清理缓存区 """ await cache_backend.clear(region=cache_region) async_wrapper.cache_region = cache_region async_wrapper.cache_clear = cache_clear return async_wrapper else: # 同步函数使用同步缓存后端 cache_backend = Cache(cache_type="ttl" if ttl else "lru", maxsize=maxsize, ttl=ttl) # 同步函数的缓存装饰器 @wraps(func) def wrapper(*args, **kwargs): # 获取缓存键 cache_key = __get_cache_key(args, kwargs) if not is_fresh(): # 尝试获取缓存 cached_value = cache_backend.get(cache_key, region=cache_region) if should_cache(cached_value) and is_valid_cache_value(cache_key, cached_value, cache_region): return cached_value # 执行函数并缓存结果 result = func(*args, **kwargs) # 判断是否需要缓存 if not should_cache(result): return result # 设置缓存(如果有传入的 maxsize 和 ttl,则覆盖默认值) cache_backend.set(cache_key, result, ttl=ttl, maxsize=maxsize, region=cache_region) return result def cache_clear(): """ 清理缓存区 """ cache_backend.clear(region=cache_region) wrapper.cache_region = cache_region wrapper.cache_clear = cache_clear return wrapper return decorator class CacheProxy: """ 缓存代理类,将缓存后端的方法直接代理到实例上 """ def __init__(self, cache_backend: CacheBackend, region: str): """ 初始化缓存代理 :param cache_backend: 缓存后端实例 :param region: 缓存区域 """ self._cache_backend = cache_backend self._region = region def __getitem__(self, key): """ 获取缓存项 """ value = self._cache_backend.get(key, region=self._region) if value is None: raise KeyError(key) return value def __setitem__(self, key, value): """ 设置缓存项 """ kwargs = {'region': self._region} self._cache_backend.set(key, value, **kwargs) def __delitem__(self, key): """ 删除缓存项 """ if not self._cache_backend.exists(key, region=self._region): raise KeyError(key) self._cache_backend.delete(key, region=self._region) def __contains__(self, key): """ 检查键是否存在 """ return self._cache_backend.exists(key, region=self._region) def __iter__(self): """ 返回缓存的迭代器 """ for key, _ in self._cache_backend.items(region=self._region): yield key def __len__(self): """ 返回缓存项的数量 """ return sum(1 for _ in self._cache_backend.items(region=self._region)) def is_redis(self) -> bool: """ 检查当前缓存后端是否为 Redis """ return self._cache_backend.is_redis() def get(self, key: str, **kwargs) -> Any: """ 获取缓存值 """ kwargs.setdefault('region', self._region) return self._cache_backend.get(key, **kwargs) def set(self, key: str, value: Any, **kwargs) -> None: """ 设置缓存值 """ kwargs.setdefault('region', self._region) self._cache_backend.set(key, value, **kwargs) def delete(self, key: str, **kwargs) -> None: """ 删除缓存值 """ kwargs.setdefault('region', self._region) self._cache_backend.delete(key, **kwargs) def exists(self, key: str, **kwargs) -> bool: """ 检查缓存键是否存在 """ kwargs.setdefault('region', self._region) return self._cache_backend.exists(key, **kwargs) def clear(self, **kwargs) -> None: """ 清除缓存 """ kwargs.setdefault('region', self._region) self._cache_backend.clear(**kwargs) def items(self, **kwargs): """ 获取所有缓存项 """ kwargs.setdefault('region', self._region) return self._cache_backend.items(**kwargs) def keys(self, **kwargs): """ 获取所有缓存键 """ kwargs.setdefault('region', self._region) return self._cache_backend.keys(**kwargs) def values(self, **kwargs): """ 获取所有缓存值 """ kwargs.setdefault('region', self._region) return self._cache_backend.values(**kwargs) def update(self, other: Dict[str, Any], **kwargs) -> None: """ 更新缓存 """ kwargs.setdefault('region', self._region) self._cache_backend.update(other, **kwargs) def pop(self, key: str, default: Any = None, **kwargs) -> Any: """ 弹出缓存项 """ kwargs.setdefault('region', self._region) return self._cache_backend.pop(key, default, **kwargs) def popitem(self, **kwargs) -> Tuple[str, Any]: """ 弹出最后一个缓存项 """ kwargs.setdefault('region', self._region) return self._cache_backend.popitem(**kwargs) def setdefault(self, key: str, default: Any = None, **kwargs) -> Any: """ 设置默认值 """ kwargs.setdefault('region', self._region) return self._cache_backend.setdefault(key, default, **kwargs) def close(self) -> None: """ 关闭缓存连接 """ self._cache_backend.close() class TTLCache(CacheProxy): """ 基于 TTL 的缓存类,兼容 cachetools.TTLCache 接口 使用项目的缓存后端实现,支持 Redis 和内存缓存 """ def __init__(self, region: Optional[str] = DEFAULT_CACHE_REGION, maxsize: Optional[int] = DEFAULT_CACHE_SIZE, ttl: Optional[int] = DEFAULT_CACHE_TTL): """ 初始化 TTL 缓存 :param maxsize: 缓存的最大条目数 :param ttl: 缓存的存活时间,单位秒 :param region: 缓存的区,为 None 时使用默认区 """ super().__init__(Cache(cache_type='ttl', maxsize=maxsize, ttl=ttl), region) class LRUCache(CacheProxy): """ 基于 LRU 的缓存类,兼容 cachetools.LRUCache 接口 使用项目的缓存后端实现,支持 Redis 和内存缓存 """ def __init__(self, region: Optional[str] = DEFAULT_CACHE_REGION, maxsize: Optional[int] = DEFAULT_CACHE_SIZE ): """ 初始化 LRU 缓存 :param maxsize: 缓存的最大条目数 :param region: 缓存的区,为 None 时使用默认区 """ super().__init__(Cache(cache_type='lru', maxsize=maxsize), region) ================================================ FILE: app/core/config.py ================================================ import asyncio import copy import json import os import platform import re import secrets import sys import threading from asyncio import AbstractEventLoop from pathlib import Path from typing import Any, Dict, List, Optional, Tuple, Type from urllib.parse import urlparse from dotenv import set_key from pydantic import BaseModel, Field, ConfigDict, model_validator from pydantic_settings import BaseSettings, SettingsConfigDict from app.log import logger, log_settings, LogConfigModel from app.schemas import MediaType from app.utils.system import SystemUtils from app.utils.url import UrlUtils from version import APP_VERSION class SystemConfModel(BaseModel): """ 系统关键资源大小配置 """ # 缓存种子数量 torrents: int = 0 # 订阅刷新处理数量 refresh: int = 0 # TMDB请求缓存数量 tmdb: int = 0 # 豆瓣请求缓存数量 douban: int = 0 # Bangumi请求缓存数量 bangumi: int = 0 # Fanart请求缓存数量 fanart: int = 0 # 元数据缓存过期时间(秒) meta: int = 0 # 调度器数量 scheduler: int = 0 # 线程池大小 threadpool: int = 0 class ConfigModel(BaseModel): """ Pydantic 配置模型,描述所有配置项及其类型和默认值 """ model_config = ConfigDict(extra="ignore") # 忽略未定义的配置项 # ==================== 基础应用配置 ==================== # 项目名称 PROJECT_NAME: str = "MoviePilot" # 域名 格式;https://movie-pilot.org APP_DOMAIN: str = "" # API路径 API_V1_STR: str = "/api/v1" # 前端资源路径 FRONTEND_PATH: str = "/public" # 时区 TZ: str = "Asia/Shanghai" # API监听地址 HOST: str = "0.0.0.0" # API监听端口 PORT: int = 3001 # 前端监听端口 NGINX_PORT: int = 3000 # 配置文件目录 CONFIG_DIR: Optional[str] = None # 是否调试模式 DEBUG: bool = False # 是否开发模式 DEV: bool = False # 高级设置模式 ADVANCED_MODE: bool = True # ==================== 安全认证配置 ==================== # 密钥 SECRET_KEY: str = secrets.token_urlsafe(32) # RESOURCE密钥 RESOURCE_SECRET_KEY: str = secrets.token_urlsafe(32) # 允许的域名 ALLOWED_HOSTS: list = Field(default_factory=lambda: ["*"]) # TOKEN过期时间 ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 8 # RESOURCE_TOKEN过期时间 RESOURCE_ACCESS_TOKEN_EXPIRE_SECONDS: int = 60 * 30 # 超级管理员初始用户名 SUPERUSER: str = "admin" # 超级管理员初始密码 SUPERUSER_PASSWORD: Optional[str] = None # 辅助认证,允许通过外部服务进行认证、单点登录以及自动创建用户 AUXILIARY_AUTH_ENABLE: bool = False # API密钥,需要更换 API_TOKEN: Optional[str] = None # 用户认证站点 AUTH_SITE: str = "" # ==================== 数据库配置 ==================== # 数据库类型,支持 sqlite 和 postgresql,默认使用 sqlite DB_TYPE: str = "sqlite" # 是否在控制台输出 SQL 语句,默认关闭 DB_ECHO: bool = False # 数据库连接超时时间(秒),默认为 60 秒 DB_TIMEOUT: int = 60 # 是否启用 WAL 模式,仅适用于SQLite,默认开启 DB_WAL_ENABLE: bool = True # 数据库连接池类型,QueuePool, NullPool DB_POOL_TYPE: str = "QueuePool" # 是否在获取连接时进行预先 ping 操作 DB_POOL_PRE_PING: bool = True # 数据库连接的回收时间(秒) DB_POOL_RECYCLE: int = 300 # 数据库连接池获取连接的超时时间(秒) DB_POOL_TIMEOUT: int = 30 # SQLite 连接池大小 DB_SQLITE_POOL_SIZE: int = 10 # SQLite 连接池溢出数量 DB_SQLITE_MAX_OVERFLOW: int = 50 # PostgreSQL 主机地址 DB_POSTGRESQL_HOST: str = "localhost" # PostgreSQL 端口 DB_POSTGRESQL_PORT: int = 5432 # PostgreSQL 数据库名 DB_POSTGRESQL_DATABASE: str = "moviepilot" # PostgreSQL 用户名 DB_POSTGRESQL_USERNAME: str = "moviepilot" # PostgreSQL 密码 DB_POSTGRESQL_PASSWORD: str = "moviepilot" # PostgreSQL 连接池大小 DB_POSTGRESQL_POOL_SIZE: int = 10 # PostgreSQL 连接池溢出数量 DB_POSTGRESQL_MAX_OVERFLOW: int = 50 # ==================== 缓存配置 ==================== # 缓存类型,支持 cachetools 和 redis,默认使用 cachetools CACHE_BACKEND_TYPE: str = "cachetools" # 缓存连接字符串,仅外部缓存(如 Redis、Memcached)需要 CACHE_BACKEND_URL: Optional[str] = "redis://localhost:6379" # Redis 缓存最大内存限制,未配置时,如开启大内存模式时为 "1024mb",未开启时为 "256mb" CACHE_REDIS_MAXMEMORY: Optional[str] = None # 全局图片缓存,将媒体图片缓存到本地 GLOBAL_IMAGE_CACHE: bool = False # 全局图片缓存保留天数 GLOBAL_IMAGE_CACHE_DAYS: int = 7 # 临时文件保留天数 TEMP_FILE_DAYS: int = 3 # 元数据识别缓存过期时间(小时),0为自动 META_CACHE_EXPIRE: int = 0 # ==================== 网络代理配置 ==================== # 网络代理服务器地址 PROXY_HOST: Optional[str] = None # 是否启用DOH解析域名 DOH_ENABLE: bool = False # 使用 DOH 解析的域名列表 DOH_DOMAINS: str = ("api.themoviedb.org," "api.tmdb.org," "webservice.fanart.tv," "api.github.com," "github.com," "raw.githubusercontent.com," "codeload.github.com," "api.telegram.org") # DOH 解析服务器列表 DOH_RESOLVERS: str = "1.0.0.1,1.1.1.1,9.9.9.9,149.112.112.112" # ==================== 媒体元数据配置 ==================== # 媒体搜索来源 themoviedb/douban/bangumi,多个用,分隔 SEARCH_SOURCE: str = "themoviedb" # 媒体识别来源 themoviedb/douban RECOGNIZE_SOURCE: str = "themoviedb" # 刮削来源 themoviedb/douban SCRAP_SOURCE: str = "themoviedb" # 电视剧动漫的分类genre_ids ANIME_GENREIDS: List[int] = Field(default=[16]) # ==================== TMDB配置 ==================== # TMDB图片地址 TMDB_IMAGE_DOMAIN: str = "image.tmdb.org" # TMDB API地址 TMDB_API_DOMAIN: str = "api.themoviedb.org" # TMDB元数据语言 TMDB_LOCALE: str = "zh" # 刮削使用TMDB原始语种图片 TMDB_SCRAP_ORIGINAL_IMAGE: bool = False # TMDB API Key TMDB_API_KEY: str = "db55323b8d3e4154498498a75642b381" # ==================== TVDB配置 ==================== # TVDB API Key TVDB_V4_API_KEY: str = "ed2aa66b-7899-4677-92a7-67bc9ce3d93a" TVDB_V4_API_PIN: str = "" # ==================== Fanart配置 ==================== # Fanart开关 FANART_ENABLE: bool = True # Fanart语言 FANART_LANG: str = "zh,en" # Fanart API Key FANART_API_KEY: str = "d2d31f9ecabea050fc7d68aa3146015f" # ==================== 云盘配置 ==================== # 115 AppId U115_APP_ID: str = "100196807" # 115 OAuth2 Server 地址 U115_AUTH_SERVER: str = "https://movie-pilot.org" # Alipan AppId ALIPAN_APP_ID: str = "ac1bf04dc9fd4d9aaabb65b4a668d403" # ==================== 系统升级配置 ==================== # 重启自动升级 MOVIEPILOT_AUTO_UPDATE: str = 'release' # 自动检查和更新站点资源包(站点索引、认证等) AUTO_UPDATE_RESOURCE: bool = True # ==================== 媒体文件格式配置 ==================== # 支持的视频文件后缀格式 RMT_MEDIAEXT: list = Field( default_factory=lambda: ['.mp4', '.mkv', '.ts', '.iso', '.rmvb', '.avi', '.mov', '.mpeg', '.mpg', '.wmv', '.3gp', '.asf', '.m4v', '.flv', '.m2ts', '.strm', '.tp', '.f4v'] ) # 支持的字幕文件后缀格式 RMT_SUBEXT: list = Field(default_factory=lambda: ['.srt', '.ass', '.ssa', '.sup']) # 支持的音轨文件后缀格式 RMT_AUDIOEXT: list = Field( default_factory=lambda: ['.aac', '.ac3', '.amr', '.caf', '.cda', '.dsf', '.dff', '.kar', '.m4a', '.mp1', '.mp2', '.mp3', '.mid', '.mod', '.mka', '.mpc', '.nsf', '.ogg', '.pcm', '.rmi', '.s3m', '.snd', '.spx', '.tak', '.tta', '.vqf', '.wav', '.wma', '.aifc', '.aiff', '.alac', '.adif', '.adts', '.flac', '.midi', '.opus', '.sfalc'] ) # ==================== 媒体服务器配置 ==================== # 媒体服务器同步间隔(小时) MEDIASERVER_SYNC_INTERVAL: int = 6 # ==================== 订阅配置 ==================== # 订阅模式 SUBSCRIBE_MODE: str = "spider" # RSS订阅模式刷新时间间隔(分钟) SUBSCRIBE_RSS_INTERVAL: int = 30 # 订阅数据共享 SUBSCRIBE_STATISTIC_SHARE: bool = True # 订阅搜索开关 SUBSCRIBE_SEARCH: bool = False # 订阅搜索时间间隔(小时) SUBSCRIBE_SEARCH_INTERVAL: int = 24 # 检查本地媒体库是否存在资源开关 LOCAL_EXISTS_SEARCH: bool = True # ==================== 站点配置 ==================== # 站点数据刷新间隔(小时) SITEDATA_REFRESH_INTERVAL: int = 6 # 读取和发送站点消息 SITE_MESSAGE: bool = True # 不能缓存站点资源的站点域名,多个使用,分隔 NO_CACHE_SITE_KEY: str = "m-team" # OCR服务器地址,用于识别站点验证码 OCR_HOST: str = "https://movie-pilot.org" # 仿真类型:playwright 或 flaresolverr BROWSER_EMULATION: str = "playwright" # FlareSolverr 服务地址,例如 http://127.0.0.1:8191 FLARESOLVERR_URL: Optional[str] = None # ==================== 搜索配置 ==================== # 搜索多个名称 SEARCH_MULTIPLE_NAME: bool = False # 最大搜索名称数量 MAX_SEARCH_NAME_LIMIT: int = 3 # ==================== 下载配置 ==================== # 种子标签 TORRENT_TAG: str = "MOVIEPILOT" # 下载站点字幕 DOWNLOAD_SUBTITLE: bool = True # 交互搜索自动下载用户ID,使用,分割 AUTO_DOWNLOAD_USER: Optional[str] = None # 下载器临时文件后缀 DOWNLOAD_TMPEXT: list = Field(default_factory=lambda: ['.!qb', '.part']) # ==================== CookieCloud配置 ==================== # CookieCloud是否启动本地服务 COOKIECLOUD_ENABLE_LOCAL: Optional[bool] = False # CookieCloud服务器地址 COOKIECLOUD_HOST: str = "https://movie-pilot.org/cookiecloud" # CookieCloud用户KEY COOKIECLOUD_KEY: Optional[str] = None # CookieCloud端对端加密密码 COOKIECLOUD_PASSWORD: Optional[str] = None # CookieCloud同步间隔(分钟) COOKIECLOUD_INTERVAL: Optional[int] = 60 * 24 # CookieCloud同步黑名单,多个域名,分割 COOKIECLOUD_BLACKLIST: Optional[str] = None # ==================== 整理配置 ==================== # 文件整理线程数 TRANSFER_THREADS: int = 1 # 电影重命名格式 MOVIE_RENAME_FORMAT: str = "{{title}}{% if year %} ({{year}}){% endif %}" \ "/{{title}}{% if year %} ({{year}}){% endif %}{% if part %}-{{part}}{% endif %}{% if videoFormat %} - {{videoFormat}}{% endif %}" \ "{{fileExt}}" # 电视剧重命名格式 TV_RENAME_FORMAT: str = "{{title}}{% if year %} ({{year}}){% endif %}" \ "/Season {{season}}" \ "/{{title}} - {{season_episode}}{% if part %}-{{part}}{% endif %}{% if episode %} - 第 {{episode}} 集{% endif %}" \ "{{fileExt}}" # 重命名时支持的S0别名 RENAME_FORMAT_S0_NAMES: list = Field(default=["Specials", "SPs"]) # 为指定默认字幕添加.default后缀 DEFAULT_SUB: Optional[str] = "zh-cn" # 新增已入库媒体是否跟随TMDB信息变化 SCRAP_FOLLOW_TMDB: bool = True # 优先使用辅助识别 RECOGNIZE_PLUGIN_FIRST: bool = False # ==================== 服务地址配置 ==================== # 服务器地址,对应 https://github.com/jxxghp/MoviePilot-Server 项目 MP_SERVER_HOST: str = "https://movie-pilot.org" # ==================== 个性化 ==================== # 登录页面电影海报,tmdb/bing/mediaserver WALLPAPER: str = "tmdb" # 自定义壁纸api地址 CUSTOMIZE_WALLPAPER_API_URL: Optional[str] = None # ==================== 插件配置 ==================== # 插件市场仓库地址,多个地址使用,分隔,地址以/结尾 PLUGIN_MARKET: str = ("https://github.com/jxxghp/MoviePilot-Plugins," "https://github.com/thsrite/MoviePilot-Plugins," "https://github.com/honue/MoviePilot-Plugins," "https://github.com/InfinityPacer/MoviePilot-Plugins," "https://github.com/DDSRem-Dev/MoviePilot-Plugins," "https://github.com/madrays/MoviePilot-Plugins," "https://github.com/justzerock/MoviePilot-Plugins," "https://github.com/KoWming/MoviePilot-Plugins," "https://github.com/wikrin/MoviePilot-Plugins," "https://github.com/HankunYu/MoviePilot-Plugins," "https://github.com/baozaodetudou/MoviePilot-Plugins," "https://github.com/Aqr-K/MoviePilot-Plugins," "https://github.com/hotlcc/MoviePilot-Plugins-Third," "https://github.com/gxterry/MoviePilot-Plugins," "https://github.com/DzAvril/MoviePilot-Plugins," "https://github.com/mrtian2016/MoviePilot-Plugins," "https://github.com/Hqyel/MoviePilot-Plugins-Third," "https://github.com/xijin285/MoviePilot-Plugins," "https://github.com/Seed680/MoviePilot-Plugins," "https://github.com/imaliang/MoviePilot-Plugins") # 插件安装数据共享 PLUGIN_STATISTIC_SHARE: bool = True # 是否开启插件热加载 PLUGIN_AUTO_RELOAD: bool = False # ==================== Github & PIP ==================== # Github token,提高请求api限流阈值 ghp_**** GITHUB_TOKEN: Optional[str] = None # Github代理服务器,格式:https://mirror.ghproxy.com/ GITHUB_PROXY: Optional[str] = '' # pip镜像站点,格式:https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple PIP_PROXY: Optional[str] = '' # 指定的仓库Github token,多个仓库使用,分隔,格式:{user1}/{repo1}:ghp_****,{user2}/{repo2}:github_pat_**** REPO_GITHUB_TOKEN: Optional[str] = None # ==================== 性能配置 ==================== # 大内存模式 BIG_MEMORY_MODE: bool = False # 是否启用编码探测的性能模式 ENCODING_DETECTION_PERFORMANCE_MODE: bool = True # 编码探测的最低置信度阈值 ENCODING_DETECTION_MIN_CONFIDENCE: float = 0.8 # 主动内存回收时间间隔(分钟),0为不启用 MEMORY_GC_INTERVAL: int = 30 # ==================== 安全配置 ==================== # 允许的图片缓存域名 SECURITY_IMAGE_DOMAINS: list = Field(default=[ "image.tmdb.org", "static-mdb.v.geilijiasu.com", "bing.com", "doubanio.com", "lain.bgm.tv", "raw.githubusercontent.com", "github.com", "thetvdb.com", "cctvpic.com", "iqiyipic.com", "hdslb.com", "cmvideo.cn", "ykimg.com", "qpic.cn" ]) # 允许的图片文件后缀格式 SECURITY_IMAGE_SUFFIXES: list = Field(default=[".jpg", ".jpeg", ".png", ".webp", ".gif", ".svg", ".avif"]) # PassKey 是否强制用户验证(生物识别等) PASSKEY_REQUIRE_UV: bool = True # 允许在未启用 OTP 时直接注册 PassKey PASSKEY_ALLOW_REGISTER_WITHOUT_OTP: bool = False # ==================== 工作流配置 ==================== # 工作流数据共享 WORKFLOW_STATISTIC_SHARE: bool = True # ==================== 存储配置 ==================== # 对rclone进行快照对比时,是否检查文件夹的修改时间 RCLONE_SNAPSHOT_CHECK_FOLDER_MODTIME: bool = True # 对OpenList进行快照对比时,是否检查文件夹的修改时间 OPENLIST_SNAPSHOT_CHECK_FOLDER_MODTIME: bool = True # 对阿里云盘进行快照对比时,是否检查文件夹的修改时间(默认关闭,因为阿里云盘目录时间不随子文件变更而更新) ALIPAN_SNAPSHOT_CHECK_FOLDER_MODTIME: bool = False # ==================== Docker配置 ==================== # Docker Client API地址 DOCKER_CLIENT_API: Optional[str] = "tcp://127.0.0.1:38379" # Playwright浏览器类型,chromium/firefox PLAYWRIGHT_BROWSER_TYPE: str = "chromium" # ==================== AI智能体配置 ==================== # AI智能体开关 AI_AGENT_ENABLE: bool = False # 合局AI智能体 AI_AGENT_GLOBAL: bool = False # LLM提供商 (openai/google/deepseek) LLM_PROVIDER: str = "deepseek" # LLM模型名称 LLM_MODEL: str = "deepseek-chat" # LLM API密钥 LLM_API_KEY: Optional[str] = None # LLM基础URL(用于自定义API端点) LLM_BASE_URL: Optional[str] = "https://api.deepseek.com" # LLM最大上下文Token数量(K) LLM_MAX_CONTEXT_TOKENS: int = 64 # LLM温度参数 LLM_TEMPERATURE: float = 0.1 # LLM最大迭代次数 LLM_MAX_ITERATIONS: int = 128 # LLM工具调用超时时间(秒) LLM_TOOL_TIMEOUT: int = 300 # 是否启用详细日志 LLM_VERBOSE: bool = False # 最大记忆消息数量 LLM_MAX_MEMORY_MESSAGES: int = 30 # 内存记忆保留天数 LLM_MEMORY_RETENTION_DAYS: int = 1 # Redis记忆保留天数(如果使用Redis) LLM_REDIS_MEMORY_RETENTION_DAYS: int = 7 # 是否启用AI推荐 AI_RECOMMEND_ENABLED: bool = False # AI推荐用户偏好 AI_RECOMMEND_USER_PREFERENCE: str = "" # Tavily API密钥(用于网络搜索) TAVILY_API_KEY: str = "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" # AI推荐条目数量限制 AI_RECOMMEND_MAX_ITEMS: int = 50 class Settings(BaseSettings, ConfigModel, LogConfigModel): """ 系统配置类 """ model_config = SettingsConfigDict( case_sensitive=True, env_file=SystemUtils.get_env_path(), env_file_encoding="utf-8", ) def __init__(self, **kwargs): super().__init__(**kwargs) # 初始化配置目录及子目录 for path in [self.CONFIG_PATH, self.TEMP_PATH, self.LOG_PATH, self.COOKIE_PATH]: if not path.exists(): path.mkdir(parents=True, exist_ok=True) # 如果是二进制程序,确保配置文件存在 if SystemUtils.is_frozen(): app_env_path = self.CONFIG_PATH / "app.env" if not app_env_path.exists(): SystemUtils.copy(self.INNER_CONFIG_PATH / "app.env", app_env_path) @staticmethod def validate_api_token(value: Any, original_value: Any) -> Tuple[Any, bool]: """ 校验 API_TOKEN """ if isinstance(value, (list, dict, set)): value = copy.deepcopy(value) value = value.strip() if isinstance(value, str) else None if not value or len(value) < 16: new_token = secrets.token_urlsafe(16) if not value: logger.info(f"'API_TOKEN' 未设置,已随机生成新的【API_TOKEN】{new_token}") else: logger.warning(f"'API_TOKEN' 长度不足 16 个字符,存在安全隐患,已随机生成新的【API_TOKEN】{new_token}") return new_token, True return value, str(value) != str(original_value) @staticmethod def generic_type_converter(value: Any, original_value: Any, expected_type: Type, default: Any, field_name: str, raise_exception: bool = False) -> Tuple[Any, bool]: """ 通用类型转换函数,根据预期类型转换值。如果转换失败,返回默认值 :return: 元组 (转换后的值, 是否需要更新) """ if isinstance(value, (list, dict, set)): value = copy.deepcopy(value) # 如果 value 是 None,仍需要检查与 original_value 是否不一致 if value is None: return default, str(value) != str(original_value) if isinstance(value, str): value = value.strip() try: if expected_type is bool: if isinstance(value, bool): return value, str(value).lower() != str(original_value).lower() if isinstance(value, str): value_clean = value.lower() bool_map = { "false": False, "no": False, "0": False, "off": False, "true": True, "yes": True, "1": True, "on": True } if value_clean in bool_map: converted = bool_map[value_clean] return converted, str(converted).lower() != str(original_value).lower() elif isinstance(value, (int, float)): converted = bool(value) return converted, str(converted).lower() != str(original_value).lower() return default, True elif expected_type is int: if isinstance(value, int): return value, str(value) != str(original_value) if isinstance(value, str): converted = int(value) return converted, str(converted) != str(original_value) elif expected_type is float: if isinstance(value, float): return value, str(value) != str(original_value) if isinstance(value, str): converted = float(value) return converted, str(converted) != str(original_value) elif expected_type is str: converted = str(value).strip() return converted, converted != str(original_value) elif expected_type is list: if isinstance(value, list): return value, str(value) != str(original_value) if isinstance(value, str): items = json.loads(value) if isinstance(original_value, list): return items, items != original_value else: return items, str(items) != str(original_value) else: return value, str(value) != str(original_value) except (ValueError, TypeError) as e: if raise_exception: raise ValueError(f"配置项 '{field_name}' 的值 '{value}' 无法转换成正确的类型") from e logger.error( f"配置项 '{field_name}' 的值 '{value}' 无法转换成正确的类型,使用默认值 '{default}',错误信息: {e}") return default, True @model_validator(mode='before') @classmethod def generic_type_validator(cls, data: Any): # noqa """ 通用校验器,尝试将配置值转换为期望的类型 """ if not isinstance(data, dict): return data # 处理 API_TOKEN 特殊验证 if 'API_TOKEN' in data: converted_value, needs_update = cls.validate_api_token(data['API_TOKEN'], data['API_TOKEN']) if needs_update: cls.update_env_config("API_TOKEN", data["API_TOKEN"], converted_value) data['API_TOKEN'] = converted_value # 对其他字段进行类型转换 for field_name, field_info in cls.model_fields.items(): if field_name not in data: continue value = data[field_name] if value is None: continue field = cls.model_fields.get(field_name) if field: converted_value, needs_update = cls.generic_type_converter( value, value, field.annotation, field.default, field_name ) if needs_update: cls.update_env_config(field_name, value, converted_value) data[field_name] = converted_value return data @staticmethod def update_env_config(field_name: str, original_value: Any, converted_value: Any) -> Tuple[bool, str]: """ 更新 env 配置 """ message = None is_converted = original_value is not None and str(original_value) != str(converted_value) if is_converted: message = f"配置项 '{field_name}' 的值 '{original_value}' 无效,已替换为 '{converted_value}'" logger.warning(message) if field_name in os.environ: message = f"配置项 '{field_name}' 已在环境变量中设置,请手动更新以保持一致性" logger.warning(message) return False, message else: # 如果是列表、字典或集合类型,将其转换为JSON字符串 if isinstance(converted_value, (list, dict, set)): value_to_write = json.dumps(converted_value) else: value_to_write = str(converted_value) if converted_value is not None else "" set_key(dotenv_path=SystemUtils.get_env_path(), key_to_set=field_name, value_to_set=value_to_write, quote_mode="always") if is_converted: logger.info(f"配置项 '{field_name}' 已自动修正并写入到 'app.env' 文件") return True, message def update_setting(self, key: str, value: Any) -> Tuple[Optional[bool], str]: """ 更新单个配置项 :param key: 配置项的名称 :param value: 配置项的新值 :return: (是否成功 True 成功/False 失败/None 无需更新, 错误信息) """ if not hasattr(self, key): return False, f"配置项 '{key}' 不存在" try: field = Settings.model_fields[key] original_value = getattr(self, key) if key == "API_TOKEN": converted_value, needs_update = self.validate_api_token(value, original_value) else: converted_value, needs_update = self.generic_type_converter( value, original_value, field.annotation, field.default, key ) # 如果没有抛出异常,则统一使用 converted_value 进行更新 if needs_update or str(value) != str(converted_value): success, message = self.update_env_config(key, value, converted_value) # 仅成功更新配置时,才更新内存 if success: setattr(self, key, converted_value) if hasattr(log_settings, key): setattr(log_settings, key, converted_value) return success, message return None, "" except Exception as e: return False, str(e) def update_settings(self, env: Dict[str, Any]) -> Dict[str, Tuple[Optional[bool], str]]: """ 更新多个配置项 """ results = {} for k, v in env.items(): results[k] = self.update_setting(k, v) return results @property def VERSION_FLAG(self) -> str: """ 版本标识,用来区分重大版本,为空则为v1,不允许外部修改 """ return "v2" @property def USER_AGENT(self) -> str: """ 全局用户代理字符串 """ return f"{self.PROJECT_NAME}/{APP_VERSION[1:]} ({platform.system()} {platform.release()}; {SystemUtils.cpu_arch()})" @property def NORMAL_USER_AGENT(self) -> str: """ 默认浏览器用户代理字符串 """ return "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36" @property def INNER_CONFIG_PATH(self): return self.ROOT_PATH / "config" @property def CONFIG_PATH(self): if self.CONFIG_DIR: return Path(self.CONFIG_DIR) elif SystemUtils.is_docker(): return Path("/config") elif SystemUtils.is_frozen(): return Path(sys.executable).parent / "config" return self.ROOT_PATH / "config" @property def TEMP_PATH(self): return self.CONFIG_PATH / "temp" @property def CACHE_PATH(self): return self.CONFIG_PATH / "cache" @property def ROOT_PATH(self): return Path(__file__).parents[2] @property def PLUGIN_DATA_PATH(self): return self.CONFIG_PATH / "plugins" @property def LOG_PATH(self): return self.CONFIG_PATH / "logs" @property def COOKIE_PATH(self): return self.CONFIG_PATH / "cookies" @property def CONF(self) -> SystemConfModel: """ 根据内存模式返回系统配置 """ if self.BIG_MEMORY_MODE: return SystemConfModel( torrents=200, refresh=100, tmdb=1024, douban=512, bangumi=512, fanart=512, meta=(self.META_CACHE_EXPIRE or 72) * 3600, scheduler=100, threadpool=100 ) return SystemConfModel( torrents=100, refresh=50, tmdb=256, douban=256, bangumi=256, fanart=128, meta=(self.META_CACHE_EXPIRE or 24) * 3600, scheduler=50, threadpool=50 ) @property def PROXY(self): if self.PROXY_HOST: return { "http": self.PROXY_HOST, "https": self.PROXY_HOST, } return None @property def PROXY_SERVER(self): if self.PROXY_HOST: try: parsed = urlparse(self.PROXY_HOST) if not parsed.scheme: return {"server": self.PROXY_HOST} host = parsed.hostname or "" port = f":{parsed.port}" if parsed.port else "" server = f"{parsed.scheme}://{host}{port}" proxy = {"server": server} if parsed.username: proxy["username"] = parsed.username if parsed.password: proxy["password"] = parsed.password return proxy except Exception as err: logger.error(f"解析代理服务器地址 '{self.PROXY_HOST}' 时出错: {err}") return {"server": self.PROXY_HOST} return None @property def GITHUB_HEADERS(self): """ Github请求头 """ if self.GITHUB_TOKEN: return { "Authorization": f"Bearer {self.GITHUB_TOKEN}", "User-Agent": self.NORMAL_USER_AGENT, } return {} def REPO_GITHUB_HEADERS(self, repo: str = None): """ Github指定的仓库请求头 :param repo: 指定的仓库名称,格式为 "user/repo"。如果为空,或者没有找到指定仓库请求头,则返回默认的请求头信息 :return: Github请求头 """ # 如果没有传入指定的仓库名称,或没有配置指定的仓库Token,则返回默认的请求头信息 if not repo or not self.REPO_GITHUB_TOKEN: return self.GITHUB_HEADERS headers = {} # 格式:{user1}/{repo1}:ghp_****,{user2}/{repo2}:github_pat_**** token_pairs = self.REPO_GITHUB_TOKEN.split(",") for token_pair in token_pairs: try: parts = token_pair.split(":") if len(parts) != 2: print(f"无效的令牌格式: {token_pair}") continue repo_info = parts[0].strip() token = parts[1].strip() if not repo_info or not token: print(f"无效的令牌或仓库信息: {token_pair}") continue headers[repo_info] = { "Authorization": f"Bearer {token}", "User-Agent": self.NORMAL_USER_AGENT, } except Exception as e: print(f"处理令牌对 '{token_pair}' 时出错: {e}") # 如果传入了指定的仓库名称,则返回该仓库的请求头信息,否则返回默认请求头 return headers.get(repo, self.GITHUB_HEADERS) @property def VAPID(self): return { "subject": f"mailto:{self.SUPERUSER}@movie-pilot.org", "publicKey": "BH3w49sZA6jXUnE-yt4jO6VKh73lsdsvwoJ6Hx7fmPIDKoqGiUl2GEoZzy-iJfn4SfQQcx7yQdHf9RknwrL_lSM", "privateKey": "JTixnYY0vEw97t9uukfO3UWKfHKJdT5kCQDiv3gu894" } def MP_DOMAIN(self, url: str = None): if not self.APP_DOMAIN: return None return UrlUtils.combine_url(host=self.APP_DOMAIN, path=url) def RENAME_FORMAT(self, media_type: MediaType): """ 获取指定类型的重命名格式 :param media_type: MediaType.TV 或 MediaType.Movie :return: 重命名格式 """ rename_format = ( self.TV_RENAME_FORMAT if media_type == MediaType.TV else self.MOVIE_RENAME_FORMAT ) # 规范重命名格式 rename_format = rename_format.replace("\\", "/") rename_format = re.sub(r'/+', '/', rename_format) return rename_format.strip("/") def TMDB_IMAGE_URL( self, file_path: Optional[str], file_size: str = "original" ) -> Optional[str]: """ 获取TMDB图片网址 :param file_path: TMDB API返回的xxx_path :param file_size: 图片大小,例如:'original', 'w500' 等 :return: 图片的完整URL,如果 file_path 为空则返回 None """ if not file_path: return None return ( f"https://{self.TMDB_IMAGE_DOMAIN}/t/p/{file_size}/{file_path.removeprefix('/')}" ) # 实例化配置 settings = Settings() class GlobalVar(object): """ 全局标识 """ # 系统停止事件 STOP_EVENT: threading.Event = threading.Event() # webpush订阅 SUBSCRIPTIONS: List[dict] = [] # 需应急停止的工作流 EMERGENCY_STOP_WORKFLOWS: List[int] = [] # 需应急停止文件整理 EMERGENCY_STOP_TRANSFER: List[str] = [] # 当前事件循环 CURRENT_EVENT_LOOP: AbstractEventLoop = asyncio.get_event_loop() def stop_system(self): """ 停止系统 """ self.STOP_EVENT.set() @property def is_system_stopped(self): """ 是否停止 """ return self.STOP_EVENT.is_set() def get_subscriptions(self): """ 获取webpush订阅 """ return self.SUBSCRIPTIONS def push_subscription(self, subscription: dict): """ 添加webpush订阅 """ self.SUBSCRIPTIONS.append(subscription) def stop_workflow(self, workflow_id: int): """ 停止工作流 """ if workflow_id not in self.EMERGENCY_STOP_WORKFLOWS: self.EMERGENCY_STOP_WORKFLOWS.append(workflow_id) def workflow_resume(self, workflow_id: int): """ 恢复工作流 """ if workflow_id in self.EMERGENCY_STOP_WORKFLOWS: self.EMERGENCY_STOP_WORKFLOWS.remove(workflow_id) def is_workflow_stopped(self, workflow_id: int) -> bool: """ 是否停止工作流 """ return self.is_system_stopped or workflow_id in self.EMERGENCY_STOP_WORKFLOWS def stop_transfer(self, path: str): """ 停止文件整理 """ if path not in self.EMERGENCY_STOP_TRANSFER: self.EMERGENCY_STOP_TRANSFER.append(path) def is_transfer_stopped(self, path: str) -> bool: """ 是否停止文件整理 """ if self.is_system_stopped: return True if path in self.EMERGENCY_STOP_TRANSFER: self.EMERGENCY_STOP_TRANSFER.remove(path) return True return False @property def loop(self) -> AbstractEventLoop: """ 当前循环 """ return self.CURRENT_EVENT_LOOP def set_loop(self, loop: AbstractEventLoop): """ 设置循环 """ self.CURRENT_EVENT_LOOP = loop # 全局标识 global_vars = GlobalVar() ================================================ FILE: app/core/context.py ================================================ import re from dataclasses import dataclass, field from datetime import datetime from typing import List, Dict, Any, Tuple, Optional from app.core.config import settings from app.core.meta import MetaBase from app.core.metainfo import MetaInfo from app.schemas.types import MediaType from app.utils.string import StringUtils @dataclass class TorrentInfo: # 站点ID site: int = None # 站点名称 site_name: str = None # 站点Cookie site_cookie: str = None # 站点UA site_ua: str = None # 站点是否使用代理 site_proxy: bool = False # 站点优先级 site_order: int = 0 # 站点下载器 site_downloader: str = None # 种子名称 title: str = None # 种子副标题 description: str = None # IMDB ID imdbid: str = None # 种子链接 enclosure: str = None # 详情页面 page_url: str = None # 种子大小 size: float = 0.0 # 做种者 seeders: int = 0 # 下载者 peers: int = 0 # 完成者 grabs: int = 0 # 发布时间 pubdate: str = None # 已过时间 date_elapsed: str = None # 免费截止时间 freedate: str = None # 上传因子 uploadvolumefactor: float = None # 下载因子 downloadvolumefactor: float = None # HR hit_and_run: bool = False # 种子标签 labels: list = field(default_factory=list) # 种子优先级 pri_order: int = 0 # 种子分类 电影/电视剧 category: str = None def __setattr__(self, name: str, value: Any): self.__dict__[name] = value def __get_properties(self): """ 获取属性列表 """ property_names = [] for member_name in dir(self.__class__): member = getattr(self.__class__, member_name) if isinstance(member, property): property_names.append(member_name) return property_names def from_dict(self, data: dict): """ 从字典中初始化 """ properties = self.__get_properties() for key, value in data.items(): if key in properties: continue setattr(self, key, value) @staticmethod def get_free_string(upload_volume_factor: float, download_volume_factor: float) -> str: """ 计算促销类型 """ if upload_volume_factor is None or download_volume_factor is None: return "未知" free_strs = { "1.00 1.00": "普通", "1.00 0.00": "免费", "2.00 1.00": "2X", "4.00 1.00": "4X", "2.00 0.00": "2X免费", "4.00 0.00": "4X免费", "1.00 0.50": "50%", "2.00 0.50": "2X 50%", "1.00 0.70": "70%", "1.00 0.30": "30%", "1.00 0.75": "75%", "1.00 0.25": "25%" } return free_strs.get('%.2f %.2f' % (upload_volume_factor, download_volume_factor), "未知") @property def volume_factor(self): """ 返回促销信息 """ return self.get_free_string(self.uploadvolumefactor, self.downloadvolumefactor) @property def freedate_diff(self): """ 返回免费剩余时间 """ if not self.freedate: return "" return StringUtils.diff_time_str(self.freedate) def pub_minutes(self) -> float: """ 返回发布时间距离当前时间的分钟数 """ if not self.pubdate: return 0 try: pub_date = datetime.strptime(self.pubdate, "%Y-%m-%d %H:%M:%S") now_datetime = datetime.now() return (now_datetime - pub_date).total_seconds() // 60 except Exception as e: print(f"种子发布时间获取失败: {e}") return 0 def to_dict(self): """ 返回字典 """ dicts = vars(self).copy() dicts["volume_factor"] = self.volume_factor dicts["freedate_diff"] = self.freedate_diff return dicts @dataclass class MediaInfo: # 来源:themoviedb、douban、bangumi source: str = None # 类型 电影、电视剧 type: MediaType = None # 媒体标题 title: str = None # 英文标题 en_title: str = None # 香港标题 hk_title: str = None # 台湾标题 tw_title: str = None # 新加坡标题 sg_title: str = None # 年份 year: str = None # 季 season: int = None # TMDB ID tmdb_id: int = None # IMDB ID imdb_id: str = None # TVDB ID tvdb_id: int = None # 豆瓣ID douban_id: str = None # Bangumi ID bangumi_id: int = None # 合集ID collection_id: int = None # 媒体原语种 original_language: str = None # 媒体原发行标题 original_title: str = None # 媒体发行日期 release_date: str = None # 背景图片 backdrop_path: str = None # 海报图片 poster_path: str = None # LOGO logo_path: str = None # 评分 vote_average: float = None # 描述 overview: str = None # 风格ID genre_ids: list = field(default_factory=list) # 所有别名和译名 names: list = field(default_factory=list) # 各季的剧集清单信息 seasons: Dict[int, list] = field(default_factory=dict) # 各季详情 season_info: List[dict] = field(default_factory=list) # 各季的年份 season_years: dict = field(default_factory=dict) # 二级分类 category: str = "" # TMDB INFO tmdb_info: dict = field(default_factory=dict) # 豆瓣 INFO douban_info: dict = field(default_factory=dict) # Bangumi INFO bangumi_info: dict = field(default_factory=dict) # 导演 directors: List[dict] = field(default_factory=list) # 演员 actors: List[dict] = field(default_factory=list) # 是否成人内容 adult: bool = False # 创建人 created_by: list = field(default_factory=list) # 集时长 episode_run_time: list = field(default_factory=list) # 风格 genres: List[dict] = field(default_factory=list) # 首播日期 first_air_date: str = None # 首页 homepage: str = None # 语种 languages: list = field(default_factory=list) # 最后上映日期 last_air_date: str = None # 流媒体平台 networks: list = field(default_factory=list) # 集数 number_of_episodes: int = None # 季数 number_of_seasons: int = None # 原产国 origin_country: list = field(default_factory=list) # 原名 original_name: str = None # 出品公司 production_companies: list = field(default_factory=list) # 出品国 production_countries: list = field(default_factory=list) # 语种 spoken_languages: list = field(default_factory=list) # 所有发行日期 release_dates: list = field(default_factory=list) # 状态 status: str = None # 标签 tagline: str = None # 评价数量 vote_count: int = None # 流行度 popularity: float = None # 时长 runtime: int = None # 下一集 next_episode_to_air: dict = field(default_factory=dict) # 内容分级 content_rating: str = None # 全部剧集组 episode_groups: List[dict] = field(default_factory=list) # 剧集组 episode_group: str = None def __post_init__(self): # 设置媒体信息 if self.tmdb_info: self.set_tmdb_info(self.tmdb_info) if self.douban_info: self.set_douban_info(self.douban_info) if self.bangumi_info: self.set_bangumi_info(self.bangumi_info) def __setattr__(self, name: str, value: Any): self.__dict__[name] = value def __get_properties(self): """ 获取属性列表 """ property_names = [] for member_name in dir(self.__class__): member = getattr(self.__class__, member_name) if isinstance(member, property): property_names.append(member_name) return property_names def from_dict(self, data: dict): """ 从字典中初始化 """ properties = self.__get_properties() for key, value in data.items(): if key in properties: continue setattr(self, key, value) if isinstance(self.type, str): self.type = MediaType(self.type) def set_image(self, name: str, image: str): """ 设置图片地址 """ setattr(self, f"{name}_path", image) def get_image(self, name: str): """ 获取图片地址 """ try: return getattr(self, f"{name}_path") except AttributeError: return None def set_category(self, cat: str): """ 设置二级分类 """ self.category = cat or "" def set_tmdb_info(self, info: dict): """ 初始化媒信息 """ def __directors_actors(tmdbinfo: dict) -> Tuple[List[dict], List[dict]]: """ 查询导演和演员 :param tmdbinfo: TMDB元数据 :return: 导演列表,演员列表 """ """ "cast": [ { "adult": false, "gender": 2, "id": 3131, "known_for_department": "Acting", "name": "Antonio Banderas", "original_name": "Antonio Banderas", "popularity": 60.896, "profile_path": "/iWIUEwgn2KW50MssR7tdPeFoRGW.jpg", "cast_id": 2, "character": "Puss in Boots (voice)", "credit_id": "6052480e197de4006bb47b9a", "order": 0 } ], "crew": [ { "adult": false, "gender": 2, "id": 5524, "known_for_department": "Production", "name": "Andrew Adamson", "original_name": "Andrew Adamson", "popularity": 9.322, "profile_path": "/qqIAVKAe5LHRbPyZUlptsqlo4Kb.jpg", "credit_id": "63b86b2224b33300a0585bf1", "department": "Production", "job": "Executive Producer" } ] """ if not tmdbinfo: return [], [] _credits = tmdbinfo.get("credits") if not _credits: return [], [] directors = [] actors = [] for cast in _credits.get("cast") or []: if cast.get("known_for_department") == "Acting": actors.append(cast) for crew in _credits.get("crew") or []: if crew.get("job") in ["Director", "Writer", "Editor", "Producer"]: directors.append(crew) return directors, actors if not info: return # 来源 self.source = "themoviedb" # 本体 self.tmdb_info = info # 类型 if isinstance(info.get('media_type'), MediaType): self.type = info.get('media_type') elif info.get('media_type'): self.type = MediaType.MOVIE if info.get("media_type") == "movie" else MediaType.TV else: self.type = MediaType.MOVIE if info.get("title") else MediaType.TV # TMDBID self.tmdb_id = info.get('id') if not self.tmdb_id: return # 额外ID if info.get("external_ids"): self.tvdb_id = info.get("external_ids", {}).get("tvdb_id") self.imdb_id = info.get("external_ids", {}).get("imdb_id") # 合集ID self.collection_id = info.get('collection_id') # 评分 self.vote_average = round(float(info.get('vote_average')), 1) if info.get('vote_average') else 0 # 描述 self.overview = info.get('overview') # 风格 self.genre_ids = info.get('genre_ids') or [] # 原语种 self.original_language = info.get('original_language') # 英文标题 self.en_title = info.get('en_title') # 香港标题 self.hk_title = info.get('hk_title') # 台湾标题 self.tw_title = info.get('tw_title') # 新加坡标题 self.sg_title = info.get('sg_title') if self.type == MediaType.MOVIE: # 标题 self.title = info.get('title') # 原标题 self.original_title = info.get('original_title') # 发行日期 self.release_date = info.get('release_date') if self.release_date: # 年份 self.year = self.release_date[:4] # 所有发行日期 self.release_dates = [ { "date": release_date.get("release_date"), "iso_code": result.get("iso_3166_1"), "note": release_date.get("note"), "type": release_date.get("type"), } for result in info.get("release_dates", {}).get("results", []) for release_date in result.get("release_dates", []) if release_date.get("release_date") ] else: # 电视剧 self.title = info.get('name') # 原标题 self.original_title = info.get('original_name') # 发行日期 self.release_date = info.get('first_air_date') if self.release_date: # 年份 self.year = self.release_date[:4] # 季集信息 if info.get('seasons'): self.season_info = info.get('seasons') for seainfo in info.get('seasons'): # 季 season = seainfo.get("season_number") if season is None: continue # 集 episode_count = seainfo.get("episode_count") self.seasons[season] = list(range(1, episode_count + 1)) # 年份 air_date = seainfo.get("air_date") if air_date: self.season_years[season] = air_date[:4] # 剧集组 if info.get("episode_groups"): self.episode_groups = info.pop("episode_groups").get("results") or [] # 海报 if path := info.get('poster_path'): self.poster_path = settings.TMDB_IMAGE_URL(path) # 背景 if path := info.get('backdrop_path'): self.backdrop_path = settings.TMDB_IMAGE_URL(path) # 导演和演员 self.directors, self.actors = __directors_actors(info) # 别名和译名 self.names = info.get('names') or [] # 剩余属性赋值 for key, value in info.items(): if not value: continue if not hasattr(self, key): continue current_value = getattr(self, key) if current_value: continue if current_value is None: setattr(self, key, value) elif type(current_value) is type(value): setattr(self, key, value) def set_douban_info(self, info: dict): """ 初始化豆瓣信息 """ if not info: return # 来源 self.source = "douban" # 本体 self.douban_info = info # 豆瓣ID self.douban_id = str(info.get("id")) # 类型 if not self.type: if isinstance(info.get('media_type'), MediaType): self.type = info.get('media_type') elif info.get("subtype"): self.type = MediaType.MOVIE if info.get("subtype") == "movie" else MediaType.TV elif info.get("target_type"): self.type = MediaType.MOVIE if info.get("target_type") == "movie" else MediaType.TV elif info.get("type_name"): self.type = MediaType(info.get("type_name")) elif info.get("uri"): self.type = MediaType.MOVIE if "/movie/" in info.get("uri") else MediaType.TV elif info.get("type") and info.get("type") in ["movie", "tv"]: self.type = MediaType.MOVIE if info.get("type") == "movie" else MediaType.TV # 标题 if not self.title: self.title = info.get("title") # 英文标题,暂时不支持 if not self.en_title: self.en_title = info.get('original_title') # 原语种标题 if not self.original_title: self.original_title = info.get("original_title") # 年份 if not self.year: self.year = info.get("year")[:4] if info.get("year") else None if not self.year and info.get("extra"): self.year = info.get("extra").get("year") # 识别标题中的季 meta = MetaInfo(info.get("title")) # 季 if self.season is None: self.season = meta.begin_season if self.season is not None: self.type = MediaType.TV elif not self.type: self.type = MediaType.MOVIE # 评分 if not self.vote_average: rating = info.get("rating") if rating: vote_average = float(rating.get("value")) else: vote_average = 0 self.vote_average = vote_average # 发行日期 if not self.release_date: if info.get("release_date"): self.release_date = info.get("release_date") elif info.get("pubdate") and isinstance(info.get("pubdate"), list): release_date = info.get("pubdate")[0] if release_date: match = re.search(r'\d{4}-\d{2}-\d{2}', release_date) if match: self.release_date = match.group() # 海报 if not self.poster_path: if info.get("pic"): self.poster_path = info.get("pic", {}).get("large") if not self.poster_path and info.get("cover_url"): # imageView2/0/q/80/w/9999/h/120/format/webp -> imageView2/1/w/500/h/750/format/webp self.poster_path = re.sub(r'imageView2/\d/q/\d+/w/\d+/h/\d+/format/webp', 'imageView2/1/w/500/h/750/format/webp', info.get("cover_url")) if not self.poster_path and info.get("cover"): if info.get("cover").get("url"): self.poster_path = info.get("cover").get("url") else: self.poster_path = info.get("cover").get("large", {}).get("url") # 简介 if not self.overview: self.overview = info.get("intro") or info.get("card_subtitle") or "" if not self.overview: if info.get("extra", {}).get("info"): extra_info = info.get("extra").get("info") if extra_info: self.overview = ",".join([":".join(item) for item in extra_info]) # 从简介中提取年份 if self.overview and not self.year: match = re.search(r'\d{4}', self.overview) if match: self.year = match.group() # 导演和演员 if not self.directors: self.directors = info.get("directors") or [] if not self.actors: self.actors = info.get("actors") or [] # 别名 if not self.names: akas = info.get("aka") if akas: self.names = [re.sub(r'\([港台豆友译名]+\)', "", aka) for aka in akas] # 剧集 if self.type == MediaType.TV and not self.seasons: meta = MetaInfo(info.get("title")) season = meta.begin_season if meta.begin_season is not None else 1 episodes_count = info.get("episodes_count") if episodes_count: self.seasons[season] = list(range(1, episodes_count + 1)) # 季年份 if self.type == MediaType.TV and not self.season_years: season = self.season if self.season is not None else 1 self.season_years = { season: self.year } # 风格 if not self.genres: self.genres = [{"id": genre, "name": genre} for genre in info.get("genres") or []] # 时长 if not self.runtime and info.get("durations"): # 查找数字 match = re.search(r'\d+', info.get("durations")[0]) if match: self.runtime = int(match.group()) # 国家 if not self.production_countries: self.production_countries = [{"id": country, "name": country} for country in info.get("countries") or []] # 剩余属性赋值 for key, value in info.items(): if not value: continue if not hasattr(self, key): continue current_value = getattr(self, key) if current_value: continue if current_value is None: setattr(self, key, value) elif type(current_value) is type(value): setattr(self, key, value) def set_bangumi_info(self, info: dict): """ 初始化Bangumi信息 """ if not info: return # 来源 self.source = "bangumi" # 本体 self.bangumi_info = info # 豆瓣ID self.bangumi_id = info.get("id") # 类型 if not self.type: self.type = MediaType.TV # 标题 if not self.title: self.title = info.get("name_cn") or info.get("name") # 原语种标题 if not self.original_title: self.original_title = info.get("name") # 识别标题中的季 meta = MetaInfo(self.title) # 季 if self.season is None: self.season = meta.begin_season # 评分 if not self.vote_average: rating = info.get("rating") if rating: vote_average = float(rating.get("score")) else: vote_average = 0 self.vote_average = vote_average # 发行日期 if not self.release_date: self.release_date = info.get("date") or info.get("air_date") # 年份 if not self.year: self.year = self.release_date[:4] if self.release_date else None # 海报 if not self.poster_path: if info.get("images"): self.poster_path = info.get("images", {}).get("large") if not self.poster_path and info.get("image"): self.poster_path = info.get("image") # 简介 if not self.overview: self.overview = info.get("summary") # 别名 if not self.names: infobox = info.get("infobox") if infobox: akas = [item.get("value") for item in infobox if item.get("key") == "别名"] if akas: self.names = [aka.get("v") for aka in akas[0]] # 剧集 if self.type == MediaType.TV and not self.seasons: meta = MetaInfo(self.title) season = meta.begin_season if meta.begin_season is not None else 1 episodes_count = info.get("total_episodes") if episodes_count: self.seasons[season] = list(range(1, episodes_count + 1)) # 演员 if not self.actors: self.actors = info.get("actors") or [] @property def title_year(self): if self.title: return "%s (%s)" % (self.title, self.year) if self.year else self.title return "" @property def detail_link(self): """ TMDB媒体详情页地址 """ if self.tmdb_id: if self.type == MediaType.MOVIE: return "https://www.themoviedb.org/movie/%s" % self.tmdb_id else: return "https://www.themoviedb.org/tv/%s" % self.tmdb_id elif self.douban_id: return "https://movie.douban.com/subject/%s" % self.douban_id elif self.bangumi_id: return "http://bgm.tv/subject/%s" % self.bangumi_id return "" @property def stars(self): """ 返回评分星星个数 """ if not self.vote_average: return "" return "".rjust(int(self.vote_average), "★") @property def vote_star(self): if self.vote_average: return "评分:%s" % self.stars return "" def get_backdrop_image(self, default: bool = False): """ 返回背景图片地址 """ if self.backdrop_path: return self.backdrop_path.replace("original", "w500") return default or "" def get_message_image(self, default: Optional[bool] = None): """ 返回消息图片地址 """ if self.backdrop_path: return self.backdrop_path.replace("original", "w500") return self.get_poster_image(default=default) def get_poster_image(self, default: Optional[bool] = None): """ 返回海报图片地址 """ if self.poster_path: return self.poster_path.replace("original", "w500") return default or "" def get_overview_string(self, max_len: Optional[int] = 140): """ 返回带限定长度的简介信息 :param max_len: 内容长度 :return: """ overview = str(self.overview).strip() placeholder = ' ...' max_len = max(len(placeholder), max_len - len(placeholder)) overview = (overview[:max_len] + placeholder) if len(overview) > max_len else overview return overview def to_dict(self): """ 返回字典 """ dicts = vars(self).copy() dicts["type"] = self.type.value if self.type else None dicts["detail_link"] = self.detail_link dicts["title_year"] = self.title_year dicts["tmdb_info"] = None dicts["douban_info"] = None dicts["bangumi_info"] = None return dicts def clear(self): """ 去除多余数据,减小体积 """ self.tmdb_info = {} self.douban_info = {} self.bangumi_info = {} self.seasons = {} self.genres = [] self.season_info = [] self.names = [] self.actors = [] self.directors = [] self.production_companies = [] self.production_countries = [] self.spoken_languages = [] self.networks = [] self.next_episode_to_air = {} self.episode_groups = [] @dataclass class Context: """ 上下文对象 """ # 识别信息 meta_info: MetaBase = None # 媒体信息 media_info: MediaInfo = None # 种子信息 torrent_info: TorrentInfo = None # 媒体识别失败次数 media_recognize_fail_count: int = 0 def to_dict(self): """ 转换为字典 """ return { "meta_info": self.meta_info.to_dict() if self.meta_info else None, "torrent_info": self.torrent_info.to_dict() if self.torrent_info else None, "media_info": self.media_info.to_dict() if self.media_info else None, "media_recognize_fail_count": self.media_recognize_fail_count } ================================================ FILE: app/core/event.py ================================================ import asyncio import importlib import inspect import random import threading import time import traceback import uuid from queue import Empty, PriorityQueue from typing import Callable, Dict, List, Optional, Tuple, Union, Any from fastapi.concurrency import run_in_threadpool from app.core.config import global_vars from app.helper.thread import ThreadHelper from app.log import logger from app.schemas import ChainEventData from app.schemas.types import ChainEventType, EventType from app.utils.limit import ExponentialBackoffRateLimiter from app.utils.singleton import Singleton DEFAULT_EVENT_PRIORITY = 10 # 事件的默认优先级 MIN_EVENT_CONSUMER_THREADS = 1 # 最小事件消费者线程数 INITIAL_EVENT_QUEUE_IDLE_TIMEOUT_SECONDS = 1 # 事件队列空闲时的初始超时时间(秒) MAX_EVENT_QUEUE_IDLE_TIMEOUT_SECONDS = 5 # 事件队列空闲时的最大超时时间(秒) class Event: """ 事件类,封装事件的基本信息 """ def __init__(self, event_type: Union[EventType, ChainEventType], event_data: Optional[Union[Dict, ChainEventData]] = None, priority: Optional[int] = DEFAULT_EVENT_PRIORITY): """ :param event_type: 事件的类型,支持 EventType 或 ChainEventType :param event_data: 可选,事件携带的数据,默认为空字典 :param priority: 可选,事件的优先级,默认为 10 """ self.event_id = str(uuid.uuid4()) # 事件ID self.event_type = event_type # 事件类型 self.event_data = event_data or {} # 事件数据 self.priority = priority # 事件优先级 def __repr__(self) -> str: """ 重写 __repr__ 方法,用于返回事件的详细信息,包括事件类型、事件ID和优先级 """ event_kind = Event.get_event_kind(self.event_type) return f"<{event_kind}: {self.event_type.value}, ID: {self.event_id}, Priority: {self.priority}>" def __lt__(self, other): """ 定义事件对象的比较规则,基于优先级比较 优先级小的事件会被认为“更小”,优先级高的事件将被认为“更大” """ return self.priority < other.priority @staticmethod def get_event_kind(event_type: Union[EventType, ChainEventType]) -> str: """ 根据事件类型判断事件是广播事件还是链式事件 :param event_type: 事件类型,支持 EventType 或 ChainEventType :return: 返回 Broadcast Event 或 Chain Event """ return "Broadcast Event" if isinstance(event_type, EventType) else "Chain Event" class EventManager(metaclass=Singleton): """ EventManager 负责管理和调度广播事件和链式事件,包括订阅、发送和处理事件 """ def __init__(self): # 动态线程池,用于消费事件 self.__executor = ThreadHelper() # 用于保存启动的事件消费者线程 self.__consumer_threads = [] # 优先级队列 self.__event_queue = PriorityQueue() # 广播事件的订阅者 self.__broadcast_subscribers: Dict[EventType, Dict[str, Callable]] = {} # 链式事件的订阅者 self.__chain_subscribers: Dict[ChainEventType, Dict[str, tuple[int, Callable]]] = {} # 禁用的事件处理器集合 self.__disabled_handlers = set() # 禁用的事件处理器类集合 self.__disabled_classes = set() # 线程锁 self.__lock = threading.Lock() # 退出事件 self.__event = threading.Event() def start(self): """ 开始广播事件处理线程 """ # 启动消费者线程用于处理广播事件 self.__event.set() for _ in range(MIN_EVENT_CONSUMER_THREADS): thread = threading.Thread(target=self.__broadcast_consumer_loop, daemon=True) thread.start() self.__consumer_threads.append(thread) # 将线程对象保存到列表中 def stop(self): """ 停止广播事件处理线程 """ logger.info("正在停止事件处理...") self.__event.clear() # 停止广播事件处理 try: # 通过遍历保存的线程来等待它们完成 for consumer_thread in self.__consumer_threads: consumer_thread.join() logger.info("事件处理停止完成") except Exception as e: logger.error(f"停止事件处理线程出错:{str(e)} - {traceback.format_exc()}") def check(self, etype: Union[EventType, ChainEventType]) -> bool: """ 检查是否有启用的事件处理器可以响应某个事件类型 :param etype: 事件类型 (EventType 或 ChainEventType) :return: 返回是否存在可用的处理器 """ if isinstance(etype, ChainEventType): handlers = self.__chain_subscribers.get(etype, {}) return any( self.__is_handler_enabled(handler) for _, handler in handlers.values() ) else: handlers = self.__broadcast_subscribers.get(etype, {}) return any( self.__is_handler_enabled(handler) for handler in handlers.values() ) def send_event(self, etype: Union[EventType, ChainEventType], data: Optional[Union[Dict, ChainEventData]] = None, priority: Optional[int] = DEFAULT_EVENT_PRIORITY) -> Optional[Event]: """ 发送事件,根据事件类型决定是广播事件还是链式事件 :param etype: 事件类型 (EventType 或 ChainEventType) :param data: 可选,事件数据 :param priority: 广播事件的优先级,默认为 10 :return: 如果是链式事件,返回处理后的事件数据;否则返回 None """ event = Event(etype, data, priority) if isinstance(etype, EventType): return self.__trigger_broadcast_event(event) elif isinstance(etype, ChainEventType): return self.__trigger_chain_event(event) else: logger.error(f"Unknown event type: {etype}") return None async def async_send_event(self, etype: Union[EventType, ChainEventType], data: Optional[Union[Dict, ChainEventData]] = None, priority: Optional[int] = DEFAULT_EVENT_PRIORITY) -> Optional[Event]: """ 异步发送事件,根据事件类型决定是广播事件还是链式事件 :param etype: 事件类型 (EventType 或 ChainEventType) :param data: 可选,事件数据 :param priority: 广播事件的优先级,默认为 10 :return: 如果是链式事件,返回处理后的事件数据;否则返回 None """ event = Event(etype, data, priority) if isinstance(etype, EventType): return self.__trigger_broadcast_event(event) elif isinstance(etype, ChainEventType): return await self.__trigger_chain_event_async(event) else: logger.error(f"Unknown event type: {etype}") return None def add_event_listener(self, event_type: Union[EventType, ChainEventType], handler: Callable, priority: Optional[int] = DEFAULT_EVENT_PRIORITY): """ 注册事件处理器,将处理器添加到对应的事件订阅列表中 :param event_type: 事件类型 (EventType 或 ChainEventType) :param handler: 处理器 :param priority: 可选,链式事件的优先级,默认为 10;广播事件不需要优先级 """ with self.__lock: handler_identifier = self.__get_handler_identifier(handler) if isinstance(event_type, ChainEventType): # 链式事件,按优先级排序 if event_type not in self.__chain_subscribers: self.__chain_subscribers[event_type] = {} handlers = self.__chain_subscribers[event_type] if handler_identifier in handlers: handlers.pop(handler_identifier) else: logger.debug( f"Subscribed to chain event: {event_type.value}, " f"Priority: {priority} - {handler_identifier}") handlers[handler_identifier] = (priority, handler) # 根据优先级排序 self.__chain_subscribers[event_type] = dict( sorted(self.__chain_subscribers[event_type].items(), key=lambda x: x[1][0]) ) else: # 广播事件 if event_type not in self.__broadcast_subscribers: self.__broadcast_subscribers[event_type] = {} handlers = self.__broadcast_subscribers[event_type] if handler_identifier in handlers: handlers.pop(handler_identifier) else: logger.debug(f"Subscribed to broadcast event: {event_type.value} - {handler_identifier}") handlers[handler_identifier] = handler def remove_event_listener(self, event_type: Union[EventType, ChainEventType], handler: Callable): """ 移除事件处理器,将处理器从对应事件的订阅列表中删除 :param event_type: 事件类型 (EventType 或 ChainEventType) :param handler: 要移除的处理器 """ with self.__lock: handler_identifier = self.__get_handler_identifier(handler) if isinstance(event_type, ChainEventType) and event_type in self.__chain_subscribers: self.__chain_subscribers[event_type].pop(handler_identifier, None) logger.debug(f"Unsubscribed from chain event: {event_type.value} - {handler_identifier}") elif event_type in self.__broadcast_subscribers: self.__broadcast_subscribers[event_type].pop(handler_identifier, None) logger.debug(f"Unsubscribed from broadcast event: {event_type.value} - {handler_identifier}") def disable_event_handler(self, target: Union[Callable, type]): """ 禁用指定的事件处理器或事件处理器类 :param target: 处理器函数或类 """ identifier = self.__get_handler_identifier(target) if identifier in self.__disabled_handlers or identifier in self.__disabled_classes: return if isinstance(target, type): self.__disabled_classes.add(identifier) logger.debug(f"Disabled event handler class - {identifier}") else: self.__disabled_handlers.add(identifier) logger.debug(f"Disabled event handler - {identifier}") def enable_event_handler(self, target: Union[Callable, type]): """ 启用指定的事件处理器或事件处理器类 :param target: 处理器函数或类 """ identifier = self.__get_handler_identifier(target) if isinstance(target, type): self.__disabled_classes.discard(identifier) logger.debug(f"Enabled event handler class - {identifier}") else: self.__disabled_handlers.discard(identifier) logger.debug(f"Enabled event handler - {identifier}") def visualize_handlers(self) -> List[Dict]: """ 可视化所有事件处理器,包括是否被禁用的状态 :return: 处理器列表,包含事件类型、处理器标识符、优先级(如果有)和状态 """ def parse_handler_data(data): """ 解析处理器数据,判断是否包含优先级 :param data: 订阅者数据,可能是元组或单一值 :return: (priority, handler),若没有优先级则返回 (None, handler) """ if isinstance(data, tuple) and len(data) == 2: return data return None, data handler_info = [] # 统一处理广播事件和链式事件 for event_type, subscribers in {**self.__broadcast_subscribers, **self.__chain_subscribers}.items(): for handler_identifier, handler_data in subscribers.items(): # 解析优先级和处理器 priority, handler = parse_handler_data(handler_data) # 检查处理器的启用状态 status = "enabled" if self.__is_handler_enabled(handler) else "disabled" # 构建处理器信息字典 handler_dict = { "event_type": event_type.value, "handler_identifier": handler_identifier, "status": status } if priority is not None: handler_dict["priority"] = priority handler_info.append(handler_dict) return handler_info @classmethod def __get_handler_identifier(cls, target: Union[Callable, type]) -> Optional[str]: """ 获取处理器或处理器类的唯一标识符,包括模块名和类名/方法名 :param target: 处理器函数或类 :return: 唯一标识符 """ # 统一使用 inspect.getmodule 来获取模块名 module = inspect.getmodule(target) module_name = module.__name__ if module else "unknown_module" # 使用 __qualname__ 获取目标的限定名 qualname = target.__qualname__ return f"{module_name}.{qualname}" @classmethod def __get_class_from_callable(cls, handler: Callable) -> Optional[str]: """ 获取可调用对象所属类的唯一标识符 :param handler: 可调用对象(函数、方法等) :return: 类的唯一标识符 """ # 对于绑定方法,通过 __self__.__class__ 获取类 if inspect.ismethod(handler) and hasattr(handler, "__self__"): return cls.__get_handler_identifier(handler.__self__.__class__) # 对于类实例(实现了 __call__ 方法) if not inspect.isfunction(handler) and hasattr(handler, "__call__"): handler_cls = handler.__class__ # noqa return cls.__get_handler_identifier(handler_cls) # 对于未绑定方法、静态方法、类方法,使用 __qualname__ 提取类信息 qualname_parts = handler.__qualname__.split(".") if len(qualname_parts) > 1: class_name = ".".join(qualname_parts[:-1]) module = inspect.getmodule(handler) module_name = module.__name__ if module else "unknown_module" return f"{module_name}.{class_name}" return None def __is_handler_enabled(self, handler: Callable) -> bool: """ 检查处理器是否已启用(没有被禁用) :param handler: 处理器函数 :return: 如果处理器启用则返回 True,否则返回 False """ # 获取处理器的唯一标识符 handler_id = self.__get_handler_identifier(handler) # 获取处理器所属类的唯一标识符 class_id = self.__get_class_from_callable(handler) # 检查处理器或类是否被禁用,只要其中之一被禁用则返回 False if handler_id in self.__disabled_handlers or (class_id is not None and class_id in self.__disabled_classes): return False return True def __trigger_chain_event(self, event: Event) -> Optional[Event]: """ 触发链式事件,按顺序调用订阅的处理器,并记录处理耗时 """ logger.debug(f"Triggering synchronous chain event: {event}") dispatch = self.__dispatch_chain_event(event) return event if dispatch else None async def __trigger_chain_event_async(self, event: Event) -> Optional[Event]: """ 异步触发链式事件,按顺序调用订阅的处理器,并记录处理耗时 """ logger.debug(f"Triggering asynchronous chain event: {event}") dispatch = await self.__dispatch_chain_event_async(event) return event if dispatch else None def __trigger_broadcast_event(self, event: Event): """ 触发广播事件,将事件插入到优先级队列中 :param event: 要处理的事件对象 """ logger.debug(f"Triggering broadcast event: {event}") self.__event_queue.put((event.priority, event)) def __dispatch_chain_event(self, event: Event) -> bool: """ 同步方式调度链式事件,按优先级顺序逐个调用事件处理器,并记录每个处理器的处理时间 :param event: 要调度的事件对象 """ handlers = self.__chain_subscribers.get(event.event_type, {}) if not handlers: logger.debug(f"No handlers found for chain event: {event}") return False # 过滤出启用的处理器 enabled_handlers = {handler_id: (priority, handler) for handler_id, (priority, handler) in handlers.items() if self.__is_handler_enabled(handler)} if not enabled_handlers: logger.debug(f"No enabled handlers found for chain event: {event}. Skipping execution.") return False self.__log_event_lifecycle(event, "Started") for handler_id, (priority, handler) in enabled_handlers.items(): start_time = time.time() self.__safe_invoke_handler(handler, event) logger.debug( f"{self.__get_handler_identifier(handler)} (Priority: {priority}), " f"completed in {time.time() - start_time:.3f}s for event: {event}" ) self.__log_event_lifecycle(event, "Completed") return True async def __dispatch_chain_event_async(self, event: Event) -> bool: """ 异步方式调度链式事件,按优先级顺序逐个调用事件处理器,并记录每个处理器的处理时间 :param event: 要调度的事件对象 """ handlers = self.__chain_subscribers.get(event.event_type, {}) if not handlers: logger.debug(f"No handlers found for chain event: {event}") return False # 过滤出启用的处理器 enabled_handlers = {handler_id: (priority, handler) for handler_id, (priority, handler) in handlers.items() if self.__is_handler_enabled(handler)} if not enabled_handlers: logger.debug(f"No enabled handlers found for chain event: {event}. Skipping execution.") return False self.__log_event_lifecycle(event, "Started") for handler_id, (priority, handler) in enabled_handlers.items(): start_time = time.time() await self.__safe_invoke_handler_async(handler, event) logger.debug( f"{self.__get_handler_identifier(handler)} (Priority: {priority}), " f"completed in {time.time() - start_time:.3f}s for event: {event}" ) self.__log_event_lifecycle(event, "Completed") return True def __dispatch_broadcast_event(self, event: Event): """ 异步方式调度广播事件,通过线程池逐个调用事件处理器 :param event: 要调度的事件对象 """ handlers = self.__broadcast_subscribers.get(event.event_type, {}) if not handlers: logger.debug(f"No handlers found for broadcast event: {event}") return # 为每个处理器提供独立的事件实例,防止某个处理器对 event_data 的修改影响其他处理器 for handler_id, handler in handlers.items(): # 仅浅拷贝顶层字典,避免不必要的深拷贝开销;这样可以隔离键级别的替换/赋值 if isinstance(event.event_data, dict): event_data_copy = event.event_data.copy() else: event_data_copy = event.event_data isolated_event = Event(event_type=event.event_type, event_data=event_data_copy, priority=event.priority) if inspect.iscoroutinefunction(handler): # 对于异步函数,直接在事件循环中运行 asyncio.run_coroutine_threadsafe( self.__safe_invoke_handler_async(handler, isolated_event), global_vars.loop ) else: # 对于同步函数,在线程池中运行 self.__executor.submit(self.__safe_invoke_handler, handler, isolated_event) def __safe_invoke_handler(self, handler: Callable, event: Event): """ 调用处理器,处理链式或广播事件 :param handler: 处理器 :param event: 事件对象 """ if not self.__is_handler_enabled(handler): logger.debug(f"Handler {self.__get_handler_identifier(handler)} is disabled. Skipping execution") return self.__invoke_handler_by_type_sync(handler, event) async def __safe_invoke_handler_async(self, handler: Callable, event: Event): """ 异步调用处理器,处理链式事件 :param handler: 处理器 :param event: 事件对象 """ if not self.__is_handler_enabled(handler): logger.debug(f"Handler {self.__get_handler_identifier(handler)} is disabled. Skipping execution") return await self.__invoke_handler_by_type_async(handler, event) def __invoke_handler_by_type_sync(self, handler: Callable, event: Event): """ 同步方式根据处理器类型调用相应的方法 :param handler: 处理器 :param event: 要处理的事件对象 """ class_name, method_name = self.__parse_handler_names(handler) from app.core.plugin import PluginManager from app.core.module import ModuleManager plugin_manager = PluginManager() module_manager = ModuleManager() if class_name in plugin_manager.get_plugin_ids(): # 插件处理器 plugin = plugin_manager.running_plugins.get(class_name) if not plugin: return method = getattr(plugin, method_name, None) if not method: return try: method(event) except Exception as e: self.__handle_event_error(event=event, module_name=plugin.name, class_name=class_name, method_name=method_name, e=e) elif class_name in module_manager.get_module_ids(): # 模块处理器 module = module_manager.get_running_module(class_name) if not module: return method = getattr(module, method_name, None) if not method: return try: method(event) except Exception as e: self.__handle_event_error(event=event, module_name=module.get_name(), class_name=class_name, method_name=method_name, e=e) else: # 全局处理器 class_obj = self.__get_class_instance(class_name) if not class_obj or not hasattr(class_obj, method_name): return method = getattr(class_obj, method_name, None) if not method: return try: method(event) except Exception as e: self.__handle_event_error(event=event, module_name=class_name, class_name=class_name, method_name=method_name, e=e) async def __invoke_handler_by_type_async(self, handler: Callable, event: Event): """ 异步方式根据处理器类型调用相应的方法 :param handler: 处理器 :param event: 要处理的事件对象 """ class_name, method_name = self.__parse_handler_names(handler) from app.core.plugin import PluginManager from app.core.module import ModuleManager plugin_manager = PluginManager() module_manager = ModuleManager() if class_name in plugin_manager.get_plugin_ids(): await self.__invoke_plugin_method_async(plugin_manager, class_name, method_name, event) elif class_name in module_manager.get_module_ids(): await self.__invoke_module_method_async(module_manager, class_name, method_name, event) else: await self.__invoke_global_method_async(class_name, method_name, event) @staticmethod def __parse_handler_names(handler: Callable) -> Tuple[str, str]: """ 解析处理器的类名和方法名 :param handler: 处理器 :return: (class_name, method_name) """ names = handler.__qualname__.split(".") return names[0], names[1] async def __invoke_plugin_method_async(self, handler: Any, class_name: str, method_name: str, event: Event): """ 异步调用插件方法 """ plugin = handler.running_plugins.get(class_name) if not plugin: return method = getattr(plugin, method_name, None) if not method: return try: if inspect.iscoroutinefunction(method): await method(event) else: # 插件同步函数在异步环境中运行,避免阻塞 await run_in_threadpool(method, event) except Exception as e: self.__handle_event_error(event=event, module_name=plugin.name, class_name=class_name, method_name=method_name, e=e) async def __invoke_module_method_async(self, handler: Any, class_name: str, method_name: str, event: Event): """ 异步调用模块方法 """ module = handler.get_running_module(class_name) if not module: return method = getattr(module, method_name, None) if not method: return try: if inspect.iscoroutinefunction(method): await method(event) else: method(event) except Exception as e: self.__handle_event_error(event=event, module_name=module.get_name(), class_name=class_name, method_name=method_name, e=e) async def __invoke_global_method_async(self, class_name: str, method_name: str, event: Event): """ 异步调用全局对象方法 """ class_obj = self.__get_class_instance(class_name) if not class_obj: return method = getattr(class_obj, method_name, None) if not method: return try: if inspect.iscoroutinefunction(method): await method(event) else: method(event) except Exception as e: self.__handle_event_error(event=event, module_name=class_name, class_name=class_name, method_name=method_name, e=e) @staticmethod def __get_class_instance(class_name: str): """ 根据类名获取类实例,首先检查全局变量中是否存在该类,如果不存在则尝试动态导入模块。 :param class_name: 类的名称 :return: 类的实例 """ # 检查类是否在全局变量中 if class_name in globals(): try: class_obj = globals()[class_name]() return class_obj except Exception as e: logger.error(f"事件处理出错:创建全局类实例出错:{str(e)} - {traceback.format_exc()}") return None # 如果类不在全局变量中,尝试动态导入模块并创建实例 try: if class_name.endswith("Manager"): module_name = f"app.core.{class_name[:-7].lower()}" module = importlib.import_module(module_name) elif class_name.endswith("Chain"): module_name = f"app.chain.{class_name[:-5].lower()}" module = importlib.import_module(module_name) elif class_name.endswith("Helper"): # 特殊处理 Async 类 if class_name.startswith("Async"): module_name = f"app.helper.{class_name[5:-6].lower()}" else: module_name = f"app.helper.{class_name[:-6].lower()}" module = importlib.import_module(module_name) else: module_name = f"app.{class_name.lower()}" module = importlib.import_module(module_name) if hasattr(module, class_name): class_obj = getattr(module, class_name)() return class_obj else: logger.debug(f"事件处理出错:模块 {module_name} 中没有找到类 {class_name}") except Exception as e: logger.debug(f"事件处理出错:{str(e)} - {traceback.format_exc()}") return None def __broadcast_consumer_loop(self): """ 持续从队列中提取事件的后台广播消费者线程 """ jitter_factor = 0.1 rate_limiter = ExponentialBackoffRateLimiter(base_wait=INITIAL_EVENT_QUEUE_IDLE_TIMEOUT_SECONDS, max_wait=MAX_EVENT_QUEUE_IDLE_TIMEOUT_SECONDS, backoff_factor=2.0, source="BroadcastConsumer", enable_logging=False) while self.__event.is_set(): try: priority, event = self.__event_queue.get(timeout=rate_limiter.current_wait) rate_limiter.reset() self.__dispatch_broadcast_event(event) except Empty: rate_limiter.current_wait = rate_limiter.current_wait * random.uniform(1, 1 + jitter_factor) rate_limiter.trigger_limit() @staticmethod def __log_event_lifecycle(event: Event, stage: str): """ 记录事件的生命周期日志 """ logger.debug(f"{stage} - {event}") def __handle_event_error(self, event: Event, module_name: str, class_name: str, method_name: str, e: Exception): """ 全局错误处理器,用于处理事件处理中的异常 """ logger.error(f"{module_name} 事件处理出错:{str(e)} - {traceback.format_exc()}") # 发送系统错误通知 from app.helper.message import MessageHelper MessageHelper().put(title=f"{module_name} 处理事件 {event.event_type} 时出错", message=f"{class_name}.{method_name}:{str(e)}", role="system") self.send_event( EventType.SystemError, { "type": "event", "event_type": event.event_type, "event_handle": f"{class_name}.{method_name}", "error": str(e), "traceback": traceback.format_exc() } ) def register(self, etype: Union[EventType, ChainEventType, List[Union[EventType, ChainEventType]], type], priority: Optional[int] = DEFAULT_EVENT_PRIORITY): """ 事件注册装饰器,用于将函数注册为事件的处理器 :param etype: - 单个事件类型成员 (如 EventType.MetadataScrape, ChainEventType.PluginAction) - 事件类型类 (EventType, ChainEventType) - 或事件类型成员的列表 :param priority: 可选,链式事件的优先级,默认为 DEFAULT_EVENT_PRIORITY """ def decorator(f: Callable): # 将输入的事件类型统一转换为列表格式 if isinstance(etype, list): # 传入的已经是列表,直接使用 event_list = etype else: # 不是列表则包裹成单一元素的列表 event_list = [etype] # 遍历列表,处理每个事件类型 for event in event_list: if isinstance(event, (EventType, ChainEventType)): self.add_event_listener(event, f, priority) elif isinstance(event, type) and issubclass(event, (EventType, ChainEventType)): # 如果是 EventType 或 ChainEventType 类,提取该类中的所有成员 for et in event.__members__.values(): self.add_event_listener(et, f, priority) else: raise ValueError(f"无效的事件类型: {event}") return f return decorator # 全局实例定义 eventmanager = EventManager() ================================================ FILE: app/core/meta/__init__.py ================================================ from .metabase import MetaBase from .metavideo import MetaVideo from .metaanime import MetaAnime ================================================ FILE: app/core/meta/customization.py ================================================ import regex as re from app.db.systemconfig_oper import SystemConfigOper from app.schemas.types import SystemConfigKey from app.utils.singleton import Singleton class CustomizationMatcher(metaclass=Singleton): """ 识别自定义占位符 """ def __init__(self): self.systemconfig = SystemConfigOper() self.customization = None self.custom_separator = None def match(self, title=None): """ :param title: 资源标题或文件名 :return: 匹配结果 """ if not title: return "" if not self.customization: # 自定义占位符 customization = self.systemconfig.get(SystemConfigKey.Customization) if not customization: return "" if isinstance(customization, str): customization = customization.replace("\n", ";").replace("|", ";").strip(";").split(";") self.customization = "|".join([f"({item})" for item in customization]) customization_re = re.compile(r"%s" % self.customization) # 处理重复多次的情况,保留先后顺序(按添加自定义占位符的顺序) unique_customization = {} for item in re.findall(customization_re, title): if not isinstance(item, tuple): item = (item,) for i in range(len(item)): if item[i] and unique_customization.get(item[i]) is None: unique_customization[item[i]] = i unique_customization = list(dict(sorted(unique_customization.items(), key=lambda x: x[1])).keys()) separator = self.custom_separator or "@" return separator.join(unique_customization) ================================================ FILE: app/core/meta/metaanime.py ================================================ import re import traceback import zhconv import anitopy from app.core.meta.customization import CustomizationMatcher from app.core.meta.metabase import MetaBase from app.core.meta.releasegroup import ReleaseGroupsMatcher from app.log import logger from app.utils.string import StringUtils from app.schemas.types import MediaType class MetaAnime(MetaBase): """ 识别动漫 """ _anime_no_words = ['CHS&CHT', 'MP4', 'GB MP4', 'WEB-DL'] _name_nostring_re = r"S\d{2}\s*-\s*S\d{2}|S\d{2}|\s+S\d{1,2}|EP?\d{2,4}\s*-\s*EP?\d{2,4}|EP?\d{2,4}|\s+EP?\d{1,4}|\s+GB" _fps_re = r"(\d{2,3})(?=FPS)" def __init__(self, title: str, subtitle: str = None, isfile: bool = False): super().__init__(title, subtitle, isfile) if not title: return # 调用第三方模块识别动漫 try: original_title = title # 字幕组信息会被预处理掉 anitopy_info_origin = anitopy.parse(title) title = self.__prepare_title(title) anitopy_info = anitopy.parse(title) if anitopy_info: # 名称 name = anitopy_info.get("anime_title") if not name or name in self._anime_no_words or (len(name) < 5 and not StringUtils.is_chinese(name)): anitopy_info = anitopy.parse("[ANIME]" + title) if anitopy_info: name = anitopy_info.get("anime_title") if not name or name in self._anime_no_words or (len(name) < 5 and not StringUtils.is_chinese(name)): name_match = re.search(r'\[(.+?)]', title) if name_match and name_match.group(1): name = name_match.group(1).strip() # 拆份中英文名称 if name: _split_flag = True # 按/拆分中英文 if name.find("/") != -1: names = name.split("/") if StringUtils.is_chinese(names[0]): self.cn_name = names[0] if len(names) > 1: self.en_name = names[1] _split_flag = False elif StringUtils.is_chinese(names[-1]): self.cn_name = names[-1] if len(names) > 1: self.en_name = names[0] _split_flag = False else: name = names[-1] # 拆分中英文 if _split_flag: lastword_type = "" for word in name.split(): if not word: continue if word.endswith(']'): word = word[:-1] if word.isdigit(): if lastword_type == "cn": self.cn_name = "%s %s" % (self.cn_name or "", word) elif lastword_type == "en": self.en_name = "%s %s" % (self.en_name or "", word) elif StringUtils.is_chinese(word): self.cn_name = "%s %s" % (self.cn_name or "", word) lastword_type = "cn" else: self.en_name = "%s %s" % (self.en_name or "", word) lastword_type = "en" if self.cn_name: _, self.cn_name, _, _, _, _ = StringUtils.get_keyword(self.cn_name) if self.cn_name: self.cn_name = re.sub(r'%s' % self._name_nostring_re, '', self.cn_name, flags=re.IGNORECASE).strip() if self.en_name: self.en_name = re.sub(r'%s' % self._name_nostring_re, '', self.en_name, flags=re.IGNORECASE).strip().title() self._name = StringUtils.str_title(self.en_name) # 年份 year = anitopy_info.get("anime_year") if str(year).isdigit(): self.year = str(year) # 季号 anime_season = anitopy_info.get("anime_season") if isinstance(anime_season, list): if len(anime_season) == 1: begin_season = anime_season[0] end_season = None else: begin_season = anime_season[0] end_season = anime_season[-1] elif anime_season: begin_season = anime_season end_season = None else: begin_season = None end_season = None if begin_season: self.begin_season = int(begin_season) if end_season and int(end_season) != self.begin_season: self.end_season = int(end_season) self.total_season = (self.end_season - self.begin_season) + 1 else: self.total_season = 1 self.type = MediaType.TV # 集号 episode_number = anitopy_info.get("episode_number") if isinstance(episode_number, list): if len(episode_number) == 1: begin_episode = episode_number[0] end_episode = None else: begin_episode = episode_number[0] end_episode = episode_number[-1] elif episode_number: begin_episode = episode_number end_episode = None else: begin_episode = None end_episode = None if begin_episode: try: self.begin_episode = int(begin_episode) if end_episode and int(end_episode) != self.begin_episode: self.end_episode = int(end_episode) self.total_episode = (self.end_episode - self.begin_episode) + 1 else: self.total_episode = 1 except Exception as err: logger.debug(f"解析集数失败:{str(err)} - {traceback.format_exc()}") self.begin_episode = None self.end_episode = None self.type = MediaType.TV # 类型 if not self.type: anime_type = anitopy_info.get('anime_type') if isinstance(anime_type, list): anime_type = anime_type[0] if anime_type and anime_type.upper() == "TV": self.type = MediaType.TV else: self.type = MediaType.MOVIE # 分辨率 self.resource_pix = anitopy_info.get("video_resolution") if isinstance(self.resource_pix, list): self.resource_pix = self.resource_pix[0] if self.resource_pix: if re.search(r'x', self.resource_pix, re.IGNORECASE): self.resource_pix = re.split(r'[Xx]', self.resource_pix)[-1] + "p" else: self.resource_pix = self.resource_pix.lower() if str(self.resource_pix).isdigit(): self.resource_pix = str(self.resource_pix) + "p" # 制作组/字幕组 self.resource_team = \ ReleaseGroupsMatcher().match(title=original_title) or \ anitopy_info_origin.get("release_group") or None # 自定义占位符 self.customization = CustomizationMatcher().match(title=original_title) or None # 视频编码 self.video_encode = anitopy_info.get("video_term") if isinstance(self.video_encode, list): self.video_encode = self.video_encode[0] # 音频编码 self.audio_encode = anitopy_info.get("audio_term") if isinstance(self.audio_encode, list): self.audio_encode = self.audio_encode[0] # 帧率信息 self.__init_anime_fps(anitopy_info, original_title) # 解析副标题,只要季和集 self.init_subtitle(self.org_string) if not self._subtitle_flag and self.subtitle: self.init_subtitle(self.subtitle) if not self.type: self.type = MediaType.TV except Exception as e: logger.error(f"解析动漫信息失败:{str(e)} - {traceback.format_exc()}") def __init_anime_fps(self, anitopy_info: dict, original_title: str): """ 从原始标题中提取帧率信息,与MetaVideo保持完全一致的实现 """ re_res = re.search(rf"({self._fps_re})", original_title, re.IGNORECASE) if re_res: fps_value = None if re_res.group(1): # FPS格式 fps_value = re_res.group(1) if fps_value and fps_value.isdigit(): # 只存储纯数值 self.fps = int(fps_value) @staticmethod def __prepare_title(title: str): """ 对命名进行预处理 """ if not title: return title # 所有【】换成[] title = title.replace("【", "[").replace("】", "]").strip() # 截掉xx番剧漫 match = re.search(r"新番|月?番|[日美国][漫剧]", title) if match and match.span()[1] < len(title) - 1: title = re.sub(".*番.|.*[日美国][漫剧].", "", title) elif match: title = title[:title.rfind('[')] # 截掉分类 first_item = title.split(']')[0] if first_item and re.search(r"[动漫画纪录片电影视连续剧集日美韩中港台海外亚洲华语大陆综艺原盘高清]{2,}|TV|Animation|Movie|Documentar|Anime", zhconv.convert(first_item, "zh-hans"), re.IGNORECASE): title = re.sub(r"^[^]]*]", "", title).strip() # 去掉大小 title = re.sub(r'[0-9.]+\s*[MGT]i?B(?![A-Z]+)', "", title, flags=re.IGNORECASE) # 将TVxx改为xx title = re.sub(r"\[TV\s+(\d{1,4})", r"[\1", title, flags=re.IGNORECASE) # 将4K转为2160p title = re.sub(r'\[4k]', '2160p', title, flags=re.IGNORECASE) # 处理/分隔的中英文标题 names = title.split("]") if len(names) > 1 and title.find("- ") == -1: titles = [] for name in names: if not name: continue left_char = '' if name.startswith('['): left_char = '[' name = name[1:] if name and name.find("/") != -1: if name.split("/")[-1].strip(): titles.append("%s%s" % (left_char, name.split("/")[-1].strip())) else: titles.append("%s%s" % (left_char, name.split("/")[0].strip())) elif name: if StringUtils.is_chinese(name) and not StringUtils.is_all_chinese(name): if not re.search(r"\[\d+", name, re.IGNORECASE): name = re.sub(r'[\d|#::\-()()\u4e00-\u9fff]', '', name).strip() if not name or name.strip().isdigit(): continue if name == '[': titles.append("") else: titles.append("%s%s" % (left_char, name.strip())) return "]".join(titles) return title ================================================ FILE: app/core/meta/metabase.py ================================================ import traceback from dataclasses import dataclass from typing import Union, Optional, List, Self import cn2an import regex as re from app.log import logger from app.schemas.types import MediaType from app.utils.string import StringUtils @dataclass class MetaBase(object): """ 媒体信息基类 """ # 是否处理的文件 isfile: bool = False # 原标题字符串(未经过识别词处理) title: str = "" # 识别用字符串(经过识别词处理后) org_string: Optional[str] = None # 副标题 subtitle: Optional[str] = None # 类型 电影、电视剧 type: MediaType = MediaType.UNKNOWN # 识别的中文名 cn_name: Optional[str] = None # 识别的英文名 en_name: Optional[str] = None # 年份 year: Optional[str] = None # 总季数 total_season: int = 0 # 识别的开始季 数字 begin_season: Optional[int] = None # 识别的结束季 数字 end_season: Optional[int] = None # 总集数 total_episode: int = 0 # 识别的开始集 begin_episode: Optional[int] = None # 识别的结束集 end_episode: Optional[int] = None # Partx Cd Dvd Disk Disc part: Optional[str] = None # 识别的资源类型 resource_type: Optional[str] = None # 识别的效果 resource_effect: Optional[str] = None # 识别的分辨率 resource_pix: Optional[str] = None # 识别的制作组/字幕组 resource_team: Optional[str] = None # 识别的自定义占位符 customization: Optional[str] = None # 识别的流媒体平台 web_source: Optional[str] = None # 视频编码 video_encode: Optional[str] = None # 音频编码 audio_encode: Optional[str] = None # 应用的识别词信息 apply_words: Optional[List[str]] = None # 附加信息 tmdbid: int = None doubanid: str = None # 帧率信息(纯数值) fps: Optional[int] = None # 副标题解析 _subtitle_flag = False _title_episodel_re = r"Episode\s+(\d{1,4})" _subtitle_season_re = r"(? str: """ 返回名称 """ if self.cn_name and StringUtils.is_all_chinese(self.cn_name): return self.cn_name elif self.en_name: return self.en_name elif self.cn_name: return self.cn_name return "" @name.setter def name(self, name: str): """ 设置名称 """ if StringUtils.is_all_chinese(name): self.cn_name = name else: self.en_name = name self.cn_name = None def init_subtitle(self, title_text: str): """ 副标题识别 """ if not title_text: return title_text = f" {title_text} " if re.search(r"%s" % self._title_episodel_re, title_text, re.IGNORECASE): episode_str = re.search(r'%s' % self._title_episodel_re, title_text, re.IGNORECASE) if episode_str: try: episode = int(episode_str.group(1)) except Exception as err: logger.debug(f'识别集失败:{str(err)} - {traceback.format_exc()}') return if episode >= 10000: return if self.begin_episode is None: self.begin_episode = episode self.total_episode = 1 self.type = MediaType.TV self._subtitle_flag = True elif re.search(r'[全第季集话話期幕]', title_text, re.IGNORECASE): # 全x季 x季全 season_all_str = re.search(r"%s" % self._subtitle_season_all_re, title_text, re.IGNORECASE) if season_all_str: season_all = season_all_str.group(1) if not season_all: season_all = season_all_str.group(2) if season_all and self.begin_season is None and self.begin_episode is None: try: self.total_season = int(cn2an.cn2an(season_all.strip(), mode='smart')) except Exception as err: logger.debug(f'识别季失败:{str(err)} - {traceback.format_exc()}') return self.begin_season = 1 self.end_season = self.total_season self.type = MediaType.TV self._subtitle_flag = True return # 第x季 season_str = re.search(r'%s' % self._subtitle_season_re, title_text, re.IGNORECASE) if season_str: seasons = season_str.group(1) if seasons: seasons = seasons.upper().replace("S", "").strip() else: return try: end_season = None if seasons.find('-') != -1: seasons = seasons.split('-') begin_season = int(cn2an.cn2an(seasons[0].strip(), mode='smart')) if len(seasons) > 1: end_season = int(cn2an.cn2an(seasons[1].strip(), mode='smart')) else: begin_season = int(cn2an.cn2an(seasons, mode='smart')) except Exception as err: logger.debug(f'识别季失败:{str(err)} - {traceback.format_exc()}') return if begin_season and begin_season > 100: return if end_season and end_season > 100: return if self.begin_season is None and isinstance(begin_season, int): self.begin_season = begin_season self.total_season = 1 if self.begin_season is not None \ and self.end_season is None \ and isinstance(end_season, int) \ and end_season != self.begin_season: self.end_season = end_season self.total_season = (self.end_season - self.begin_season) + 1 self.type = MediaType.TV self._subtitle_flag = True # 第x-x集 第x集-x集 episode_between_str = re.search(r'%s' % self._subtitle_episode_between_re, title_text, re.IGNORECASE) if episode_between_str: episodes = episode_between_str.groups() if episodes: begin_episode = episodes[0] end_episode = episodes[1] else: return try: begin_episode = int(cn2an.cn2an(begin_episode.strip(), mode='smart')) end_episode = int(cn2an.cn2an(end_episode.strip(), mode='smart')) except Exception as err: logger.debug(f'识别集失败:{str(err)} - {traceback.format_exc()}') return if begin_episode and begin_episode >= 10000: return if end_episode and end_episode >= 10000: return if self.begin_episode is None and isinstance(begin_episode, int): self.begin_episode = begin_episode self.total_episode = 1 if self.begin_episode is not None \ and self.end_episode is None \ and isinstance(end_episode, int) \ and end_episode != self.begin_episode: self.end_episode = end_episode self.total_episode = (self.end_episode - self.begin_episode) + 1 self.type = MediaType.TV self._subtitle_flag = True return # 第x集 episode_str = re.search(r'%s' % self._subtitle_episode_re, title_text, re.IGNORECASE) if episode_str: episodes = episode_str.group(1) if episodes: episodes = episodes.upper().replace("E", "").replace("P", "").strip() else: return try: end_episode = None if episodes.find('-') != -1: episodes = episodes.split('-') begin_episode = int(cn2an.cn2an(episodes[0].strip(), mode='smart')) if len(episodes) > 1: end_episode = int(cn2an.cn2an(episodes[1].strip(), mode='smart')) else: begin_episode = int(cn2an.cn2an(episodes, mode='smart')) except Exception as err: logger.debug(f'识别集失败:{str(err)} - {traceback.format_exc()}') return if begin_episode and begin_episode >= 10000: return if end_episode and end_episode >= 10000: return if self.begin_episode is None and isinstance(begin_episode, int): self.begin_episode = begin_episode self.total_episode = 1 if self.begin_episode is not None \ and self.end_episode is None \ and isinstance(end_episode, int) \ and end_episode != self.begin_episode: self.end_episode = end_episode self.total_episode = (self.end_episode - self.begin_episode) + 1 self.type = MediaType.TV self._subtitle_flag = True return # x集全/全x集 episode_all_str = re.search(r'%s' % self._subtitle_episode_all_re, title_text, re.IGNORECASE) if episode_all_str: episode_all = episode_all_str.group(1) if not episode_all: episode_all = episode_all_str.group(2) if episode_all and self.begin_episode is None: try: self.total_episode = int(cn2an.cn2an(episode_all.strip(), mode='smart')) except Exception as err: logger.debug(f'识别集失败:{str(err)} - {traceback.format_exc()}') return self.type = MediaType.TV self._subtitle_flag = True return @property def season(self) -> str: """ 返回开始季、结束季字符串,确定是剧集没有季的返回S01 """ if self.begin_season is not None: return "S%s" % str(self.begin_season).rjust(2, "0") \ if self.end_season is None \ else "S%s-S%s" % \ (str(self.begin_season).rjust(2, "0"), str(self.end_season).rjust(2, "0")) else: if self.type == MediaType.TV: return "S01" else: return "" @property def sea(self) -> str: """ 返回开始季字符串,确定是剧集没有季的返回空 """ if self.begin_season is not None: return self.season else: return "" @property def season_seq(self) -> str: """ 返回begin_season 的数字,电视剧没有季的返回1 """ if self.begin_season is not None: return str(self.begin_season) else: if self.type == MediaType.TV: return "1" else: return "" @property def season_list(self) -> List[int]: """ 返回季的数组 """ if self.begin_season is None: if self.type == MediaType.TV: return [1] else: return [] elif self.end_season is not None: return [season for season in range(self.begin_season, self.end_season + 1)] else: return [self.begin_season] @property def episode(self) -> str: """ 返回开始集、结束集字符串 """ if self.begin_episode is not None: return "E%s" % str(self.begin_episode).rjust(2, "0") \ if self.end_episode is None \ else "E%s-E%s" % \ ( str(self.begin_episode).rjust(2, "0"), str(self.end_episode).rjust(2, "0")) else: return "" @property def episode_list(self) -> List[int]: """ 返回集的数组 """ if self.begin_episode is None: return [] elif self.end_episode is not None: return [episode for episode in range(self.begin_episode, self.end_episode + 1)] else: return [self.begin_episode] @property def episodes(self) -> str: """ 返回集的并列表达方式,用于支持单文件多集 """ return "E%s" % "E".join(str(episode).rjust(2, '0') for episode in self.episode_list) @property def episode_seqs(self) -> str: """ 返回单文件多集的集数表达方式,用于支持单文件多集 """ episodes = self.episode_list if episodes: # 集 xx if len(episodes) == 1: return str(episodes[0]) else: return "%s-%s" % (episodes[0], episodes[-1]) else: return "" @property def episode_seq(self) -> str: """ 返回begin_episode 的数字 """ episodes = self.episode_list if episodes: return str(episodes[0]) else: return "" @property def season_episode(self) -> str: """ 返回季集字符串 """ if self.type == MediaType.TV: seaion = self.season episode = self.episode if seaion and episode: return "%s %s" % (seaion, episode) elif seaion: return "%s" % seaion elif episode: return "%s" % episode else: return "" return "" @property def resource_term(self) -> str: """ 返回资源类型字符串,含分辨率 """ ret_string = "" if self.resource_type: ret_string = f"{ret_string} {self.resource_type}" if self.resource_effect: ret_string = f"{ret_string} {self.resource_effect}" if self.resource_pix: ret_string = f"{ret_string} {self.resource_pix}" return ret_string @property def edition(self) -> str: """ 返回资源类型字符串,不含分辨率 """ ret_string = "" if self.resource_type: ret_string = f"{ret_string} {self.resource_type}" if self.resource_effect: ret_string = f"{ret_string} {self.resource_effect}" return ret_string.strip() @property def release_group(self) -> str: """ 返回发布组/字幕组字符串 """ if self.resource_team: return self.resource_team else: return "" @property def video_term(self) -> str: """ 返回视频编码 """ return self.video_encode or "" @property def audio_term(self) -> str: """ 返回音频编码 """ return self.audio_encode or "" @property def frame_rate(self) -> int: """ 返回帧率信息 """ return self.fps or None def is_in_season(self, season: Union[list, int, str]) -> bool: """ 是否包含季 """ if isinstance(season, list): if self.end_season is not None: meta_season = list(range(self.begin_season, self.end_season + 1)) else: if self.begin_season is not None: meta_season = [self.begin_season] else: meta_season = [1] return set(meta_season).issuperset(set(season)) else: if self.end_season is not None: return self.begin_season <= int(season) <= self.end_season else: if self.begin_season is not None: return int(season) == self.begin_season else: return int(season) == 1 def is_in_episode(self, episode: Union[list, int, str]) -> bool: """ 是否包含集 """ if isinstance(episode, list): if self.end_episode is not None: meta_episode = list(range(self.begin_episode, self.end_episode + 1)) else: meta_episode = [self.begin_episode] return set(meta_episode).issuperset(set(episode)) else: if self.end_episode is not None: return self.begin_episode <= int(episode) <= self.end_episode else: return int(episode) == self.begin_episode def set_season(self, sea: Union[list, int, str]): """ 更新季 """ if not sea: return if isinstance(sea, list): if len(sea) == 1 and str(sea[0]).isdigit(): self.begin_season = int(sea[0]) self.end_season = None elif len(sea) > 1 and str(sea[0]).isdigit() and str(sea[-1]).isdigit(): self.begin_season = int(sea[0]) self.end_season = int(sea[-1]) elif str(sea).isdigit(): self.begin_season = int(sea) self.end_season = None def set_episode(self, ep: Union[list, int, str]): """ 更新集 """ if not ep: return if isinstance(ep, list): if len(ep) == 1 and str(ep[0]).isdigit(): self.begin_episode = int(ep[0]) self.end_episode = None elif len(ep) > 1 and str(ep[0]).isdigit() and str(ep[-1]).isdigit(): self.begin_episode = int(ep[0]) self.end_episode = int(ep[-1]) self.total_episode = (self.end_episode - self.begin_episode) + 1 elif str(ep).isdigit(): self.begin_episode = int(ep) self.end_episode = None def set_episodes(self, begin: int, end: int): """ 设置开始集结束集 """ if begin: self.begin_episode = begin if end: self.end_episode = end if self.begin_episode and self.end_episode: self.total_episode = (self.end_episode - self.begin_episode) + 1 def merge(self, meta: Self): """ 合并Meta信息 """ # 类型 if self.type == MediaType.UNKNOWN \ and meta.type != MediaType.UNKNOWN: self.type = meta.type # 名称 if not self.name: self.cn_name = meta.cn_name self.en_name = meta.en_name # 年份 if not self.year: self.year = meta.year # 季 if (self.type == MediaType.TV and self.begin_season is None): self.begin_season = meta.begin_season self.end_season = meta.end_season self.total_season = meta.total_season # 开始集 if (self.type == MediaType.TV and self.begin_episode is None): self.begin_episode = meta.begin_episode self.end_episode = meta.end_episode self.total_episode = meta.total_episode # 版本 if not self.resource_type: self.resource_type = meta.resource_type # 分辨率 if not self.resource_pix: self.resource_pix = meta.resource_pix # 制作组/字幕组 if not self.resource_team: self.resource_team = meta.resource_team # 自定义占位符 if not self.customization: self.customization = meta.customization # 特效 if not self.resource_effect: self.resource_effect = meta.resource_effect # 视频编码 if not self.video_encode: self.video_encode = meta.video_encode # 音频编码 if not self.audio_encode: self.audio_encode = meta.audio_encode # 帧率信息 if not self.fps: self.fps = meta.fps # Part if not self.part: self.part = meta.part # tmdbid if not self.tmdbid and meta.tmdbid: self.tmdbid = meta.tmdbid # doubanid if not self.doubanid and meta.doubanid: self.doubanid = meta.doubanid def to_dict(self): """ 转为字典 """ dicts = vars(self).copy() dicts["type"] = self.type.value if self.type else None dicts["season_episode"] = self.season_episode dicts["edition"] = self.edition dicts["name"] = self.name dicts["episode_list"] = self.episode_list return dicts ================================================ FILE: app/core/meta/metavideo.py ================================================ import re from typing import Optional from Pinyin2Hanzi import is_pinyin from app.core.config import settings from app.core.meta.customization import CustomizationMatcher from app.core.meta.metabase import MetaBase from app.core.meta.releasegroup import ReleaseGroupsMatcher from app.schemas.types import MediaType from app.utils.string import StringUtils from app.utils.tokens import Tokens from app.core.meta.streamingplatform import StreamingPlatforms class MetaVideo(MetaBase): """ 识别电影、电视剧 """ # 控制标位区 _stop_name_flag = False _stop_cnname_flag = False _last_token = "" _last_token_type = "" _continue_flag = True _unknown_name_str = "" _source = "" _effect = [] # 正则式区 _season_re = r"S(\d{3})|^S(\d{1,3})$|S(\d{1,3})E" _episode_re = r"EP?(\d{2,4})$|^EP?(\d{1,4})$|^S\d{1,2}EP?(\d{1,4})$|S\d{2}EP?(\d{2,4})" _part_re = r"(^PART[0-9ABI]{0,2}$|^CD[0-9]{0,2}$|^DVD[0-9]{0,2}$|^DISK[0-9]{0,2}$|^DISC[0-9]{0,2}$)" _roman_numerals = r"^(?=[MDCLXVI])M*(C[MD]|D?C{0,3})(X[CL]|L?X{0,3})(I[XV]|V?I{0,3})$" _source_re = r"^BLURAY$|^HDTV$|^UHDTV$|^HDDVD$|^WEBRIP$|^DVDRIP$|^BDRIP$|^BLU$|^WEB$|^BD$|^HDRip$|^REMUX$|^UHD$" _effect_re = r"^SDR$|^HDR\d*$|^DOLBY$|^DOVI$|^DV$|^3D$|^REPACK$|^HLG$|^HDR10(\+|Plus)$|^EDR$|^HQ$" _resources_type_re = r"%s|%s" % (_source_re, _effect_re) _name_no_begin_re = r"^[\[【].+?[\]】]" _name_no_chinese_re = r".*版|.*字幕" _name_se_words = ['共', '第', '季', '集', '话', '話', '期'] _name_movie_words = ['剧场版', '劇場版', '电影版', '電影版'] _name_nostring_re = r"^PTS|^JADE|^AOD|^CHC|^[A-Z]{1,4}TV[\-0-9UVHDK]*" \ r"|HBO$|\s+HBO|\d{1,2}th|\d{1,2}bit|NETFLIX|AMAZON|IMAX|^3D|\s+3D|^BBC\s+|\s+BBC|BBC$|DISNEY\+?|XXX|\s+DC$" \ r"|[第\s共]+[0-9一二三四五六七八九十\-\s]+季" \ r"|[第\s共]+[0-9一二三四五六七八九十百零\-\s]+[集话話]" \ r"|连载|日剧|美剧|电视剧|动画片|动漫|欧美|西德|日韩|超高清|高清|无水印|下载|蓝光|翡翠台|梦幻天堂·龙网|★?\d*月?新番" \ r"|最终季|合集|[多中国英葡法俄日韩德意西印泰台港粤双文语简繁体特效内封官译外挂]+字幕|版本|出品|台版|港版|\w+字幕组|\w+字幕社" \ r"|未删减版|UNCUT$|UNRATE$|WITH EXTRAS$|RERIP$|SUBBED$|PROPER$|REPACK$|SEASON$|EPISODE$|Complete$|Extended$|Extended Version$" \ r"|S\d{2}\s*-\s*S\d{2}|S\d{2}|\s+S\d{1,2}|EP?\d{2,4}\s*-\s*EP?\d{2,4}|EP?\d{2,4}|\s+EP?\d{1,4}" \ r"|CD[\s.]*[1-9]|DVD[\s.]*[1-9]|DISK[\s.]*[1-9]|DISC[\s.]*[1-9]" \ r"|[248]K|\d{3,4}[PIX]+" \ r"|CD[\s.]*[1-9]|DVD[\s.]*[1-9]|DISK[\s.]*[1-9]|DISC[\s.]*[1-9]|\s+GB" _resources_pix_re = r"^[SBUHD]*(\d{3,4}[PI]+)|\d{3,4}X(\d{3,4})" _resources_pix_re2 = r"(^[248]+K)" _video_encode_re = r"^(H26[45])$|^(x26[45])$|^AVC$|^HEVC$|^VC\d?$|^MPEG\d?$|^Xvid$|^DivX$|^AV1$|^HDR\d*$|^AVS(\+|[23])$" _audio_encode_re = r"^DTS\d?$|^DTSHD$|^DTSHDMA$|^Atmos$|^TrueHD\d?$|^AC3$|^\dAudios?$|^DDP\d?$|^DD\+\d?$|^DD\d?$|^LPCM\d?$|^AAC\d?$|^FLAC\d?$|^HD\d?$|^MA\d?$|^HR\d?$|^Opus\d?$|^Vorbis\d?$|^AV[3S]A$" _fps_re = r"(\d{2,3})(?=FPS)" def __init__(self, title: str, subtitle: str = None, isfile: bool = False): """ 初始化 :param title: 标题,文件为去掉了后缀 :param subtitle: 副标题 :param isfile: 是否是文件名 """ super().__init__(title, subtitle, isfile) if not title: return original_title = title self._source = "" self._effect = [] self._index = 0 # 判断是否纯数字命名 if isfile \ and title.isdigit() \ and len(title) < 5: self.begin_episode = int(title) self.type = MediaType.TV return # 全名为Season xx 及 Sxx 直接返回 season_full_res = re.search(r"^(?:Season\s+|S)(\d{1,3})$", title, re.IGNORECASE) if season_full_res: self.type = MediaType.TV season = season_full_res.group(1) if season: self.begin_season = int(season) self.total_season = 1 return # 去掉名称中第1个[]的内容 title = re.sub(r'%s' % self._name_no_begin_re, "", title, count=1) # 把xxxx-xxxx年份换成前一个年份,常出现在季集上 title = re.sub(r'([\s.]+)(\d{4})-(\d{4})', r'\1\2', title) # 把大小去掉 title = re.sub(r'[0-9.]+\s*[MGT]i?B(?![A-Z]+)', "", title, flags=re.IGNORECASE) # 把年月日去掉 title = re.sub(r'\d{4}[\s._-]\d{1,2}[\s._-]\d{1,2}', "", title) # 拆分tokens tokens = Tokens(title) # 实例化StreamingPlatforms对象 streaming_platforms = StreamingPlatforms() # 解析名称、年份、季、集、资源类型、分辨率等 token = tokens.get_next() while token: self._index += 1 # 更新当前处理的token索引 # Part self.__init_part(token, tokens) # 标题 if self._continue_flag: self.__init_name(token) # 年份 if self._continue_flag: self.__init_year(token) # 分辨率 if self._continue_flag: self.__init_resource_pix(token) # 季 if self._continue_flag: self.__init_season(token) # 集 if self._continue_flag: self.__init_episode(token) # 资源类型 if self._continue_flag: self.__init_resource_type(token) # 流媒体平台 if self._continue_flag: self.__init_web_source(token, tokens, streaming_platforms) # 视频编码 if self._continue_flag: self.__init_video_encode(token) # 音频编码 if self._continue_flag: self.__init_audio_encode(token) # 帧率 if self._continue_flag: self.__init_fps(token) # 取下一个,直到没有为卡 token = tokens.get_next() self._continue_flag = True # 合成质量 if self._effect: self._effect.reverse() self.resource_effect = " ".join(self._effect) if self._source: self.resource_type = self._source.strip() # 提取原盘DIY if self.resource_type and "BluRay" in self.resource_type: if (self.subtitle and re.findall(r'D[Ii]Y', self.subtitle)) \ or re.findall(r'-D[Ii]Y@', original_title): self.resource_type = f"{self.resource_type} DIY" # 解析副标题,只要季和集 self.init_subtitle(self.org_string) if not self._subtitle_flag and self.subtitle: self.init_subtitle(self.subtitle) # 去掉名字中不需要的干扰字符,过短的纯数字不要 self.cn_name = self.__fix_name(self.cn_name) self.en_name = StringUtils.str_title(self.__fix_name(self.en_name)) # 处理part if self.part and self.part.upper() == "PART": self.part = None # 没有中文标题时,尝试中描述中获取中文名 if not self.cn_name and self.en_name and self.subtitle: if self.__is_pinyin(self.en_name): # 英文名是拼音 cn_name = self.__get_title_from_description(self.subtitle) if cn_name and len(cn_name) == len(self.en_name.split()): # 中文名和拼音单词数相同,认为是中文名 self.cn_name = cn_name # 制作组/字幕组 self.resource_team = ReleaseGroupsMatcher().match(title=original_title) or None # 自定义占位符 self.customization = CustomizationMatcher().match(title=original_title) or None @staticmethod def __get_title_from_description(description: str) -> Optional[str]: """ 从描述中提取标题 """ if not description: return None titles = re.split(r'[\s/|]+', description) if StringUtils.is_chinese(titles[0]): return titles[0] return None @staticmethod def __is_pinyin(name_str: Optional[str]) -> bool: """ 判断是否拼音 """ if not name_str: return False for n in name_str.lower().split(): if not is_pinyin(n): return False return True def __fix_name(self, name: Optional[str]): """ 去掉名字中不需要的干扰字符 """ if not name: return name name = re.sub(r'%s' % self._name_nostring_re, '', name, flags=re.IGNORECASE).strip() name = re.sub(r'\s+', ' ', name) if name.isdecimal() \ and int(name) < 1800 \ and not self.year \ and not self.begin_season \ and not self.resource_pix \ and not self.resource_type \ and not self.audio_encode \ and not self.video_encode: if self.begin_episode is None: self.begin_episode = int(name) name = None elif self.is_in_episode(int(name)) and not self.begin_season: name = None return name def __init_name(self, token: Optional[str]): """ 识别名称 """ if not token: return # 回收标题 if self._unknown_name_str: if not self.cn_name: if not self.en_name: self.en_name = self._unknown_name_str elif self._unknown_name_str != self.year: self.en_name = "%s %s" % (self.en_name, self._unknown_name_str) self._last_token_type = "enname" self._unknown_name_str = "" if self._stop_name_flag: return if token.upper() == "AKA": self._continue_flag = False self._stop_name_flag = True return if token in self._name_se_words: self._last_token_type = 'name_se_words' return if StringUtils.is_chinese(token): # 含有中文,直接做为标题(连着的数字或者英文会保留),且不再取用后面出现的中文 self._last_token_type = "cnname" if not self.cn_name: self.cn_name = token elif not self._stop_cnname_flag: if re.search("%s" % self._name_movie_words, token, flags=re.IGNORECASE) \ or (not re.search("%s" % self._name_no_chinese_re, token, flags=re.IGNORECASE) and not re.search("%s" % self._name_se_words, token, flags=re.IGNORECASE)): self.cn_name = "%s %s" % (self.cn_name, token) self._stop_cnname_flag = True else: is_roman_digit = re.search(self._roman_numerals, token) # 阿拉伯数字或者罗马数字 if token.isdigit() or is_roman_digit: # 第季集后面的不要 if self._last_token_type == 'name_se_words': return if self.name: # 名字后面以 0 开头的不要,极有可能是集 if token.startswith('0'): return # 检查是否真正的数字 if token.isdigit(): try: int(token) except ValueError: return # 中文名后面跟的数字不是年份的极有可能是集 if not is_roman_digit \ and self._last_token_type == "cnname" \ and int(token) < 1900: return if (token.isdigit() and len(token) < 4) or is_roman_digit: # 4位以下的数字或者罗马数字,拼装到已有标题中 if self._last_token_type == "cnname": self.cn_name = "%s %s" % (self.cn_name, token) elif self._last_token_type == "enname": self.en_name = "%s %s" % (self.en_name, token) self._continue_flag = False elif token.isdigit() and len(token) == 4: # 4位数字,可能是年份,也可能真的是标题的一部分,也有可能是集 if not self._unknown_name_str: self._unknown_name_str = token else: # 名字未出现前的第一个数字,记下来 if not self._unknown_name_str: self._unknown_name_str = token elif re.search(r"%s" % self._season_re, token, re.IGNORECASE): # 季的处理 if self.en_name and re.search(r"SEASON$", self.en_name, re.IGNORECASE): # 如果匹配到季,英文名结尾为Season,说明Season属于标题,不应在后续作为干扰词去除 self.en_name += ' ' self._stop_name_flag = True return elif re.search(r"%s" % self._episode_re, token, re.IGNORECASE) \ or re.search(r"(%s)" % self._resources_type_re, token, re.IGNORECASE) \ or re.search(r"%s" % self._resources_pix_re, token, re.IGNORECASE): # 集、来源、版本等不要 self._stop_name_flag = True return else: # 后缀名不要 media_exts = settings.RMT_MEDIAEXT + settings.RMT_SUBEXT + settings.RMT_AUDIOEXT if ".%s".lower() % token in media_exts: return # 英文或者英文+数字,拼装起来 if self.en_name: self.en_name = "%s %s" % (self.en_name, token) else: self.en_name = token self._last_token_type = "enname" def __init_part(self, token: str, tokens: Tokens): """ 识别Part """ if not self.name: return if not self.year \ and not self.begin_season \ and not self.begin_episode \ and not self.resource_pix \ and not self.resource_type: return re_res = re.search(r"%s" % self._part_re, token, re.IGNORECASE) if re_res: if not self.part: self.part = re_res.group(1) nextv = tokens.cur() if nextv \ and ((nextv.isdigit() and (len(nextv) == 1 or len(nextv) == 2 and nextv.startswith('0'))) or nextv.upper() in ['A', 'B', 'C', 'I', 'II', 'III']): self.part = "%s%s" % (self.part, nextv) tokens.get_next() self._last_token_type = "part" self._continue_flag = False # self._stop_name_flag = False def __init_year(self, token: str): """ 识别年份 """ if not self.name: return if not token.isdigit(): return if len(token) != 4: return if not 1900 < int(token) < 2050: return if self.year: if self.en_name: self.en_name = "%s %s" % (self.en_name.strip(), self.year) elif self.cn_name: self.cn_name = "%s %s" % (self.cn_name, self.year) elif self.en_name and re.search(r"SEASON$", self.en_name, re.IGNORECASE): # 如果匹配到年,且英文名结尾为Season,说明Season属于标题,不应在后续作为干扰词去除 self.en_name += ' ' self.year = token self._last_token_type = "year" self._continue_flag = False self._stop_name_flag = True def __init_resource_pix(self, token: str): """ 识别分辨率 """ if not self.name: return re_res = re.findall(r"%s" % self._resources_pix_re, token, re.IGNORECASE) if re_res: self._last_token_type = "pix" self._continue_flag = False self._stop_name_flag = True resource_pix = None for pixs in re_res: if isinstance(pixs, tuple): pix_t = None for pix_i in pixs: if pix_i: pix_t = pix_i break if pix_t: resource_pix = pix_t else: resource_pix = pixs if resource_pix and not self.resource_pix: self.resource_pix = resource_pix.lower() break if self.resource_pix \ and self.resource_pix.isdigit() \ and self.resource_pix[-1] not in 'kpi': self.resource_pix = "%sp" % self.resource_pix else: re_res = re.search(r"%s" % self._resources_pix_re2, token, re.IGNORECASE) if re_res: self._last_token_type = "pix" self._continue_flag = False self._stop_name_flag = True if not self.resource_pix: self.resource_pix = re_res.group(1).lower() def __init_season(self, token: str): """ 识别季 """ re_res = re.findall(r"%s" % self._season_re, token, re.IGNORECASE) if re_res: self._last_token_type = "season" self.type = MediaType.TV self._stop_name_flag = True self._continue_flag = True for se in re_res: if isinstance(se, tuple): se_t = None for se_i in se: if se_i and str(se_i).isdigit(): se_t = se_i break if se_t: se = int(se_t) else: break else: se = int(se) if self.begin_season is None: self.begin_season = se self.total_season = 1 else: if se > self.begin_season: self.end_season = se self.total_season = (self.end_season - self.begin_season) + 1 if self.isfile and self.total_season > 1: self.end_season = None self.total_season = 1 elif token.isdigit(): try: int(token) except ValueError: return if self._last_token_type == "SEASON" \ and self.begin_season is None \ and len(token) < 3: self.begin_season = int(token) self.total_season = 1 self._last_token_type = "season" self._stop_name_flag = True self._continue_flag = False self.type = MediaType.TV elif token.upper() == "SEASON" and self.begin_season is None: self._last_token_type = "SEASON" elif self.type == MediaType.TV and self.begin_season is None: self.begin_season = 1 def __init_episode(self, token: str): """ 识别集 """ re_res = re.findall(r"%s" % self._episode_re, token, re.IGNORECASE) if re_res: self._last_token_type = "episode" self._continue_flag = False self._stop_name_flag = True self.type = MediaType.TV for se in re_res: if isinstance(se, tuple): se_t = None for se_i in se: if se_i and str(se_i).isdigit(): se_t = se_i break if se_t: se = int(se_t) else: break else: se = int(se) if self.begin_episode is None: self.begin_episode = se self.total_episode = 1 else: if se > self.begin_episode: self.end_episode = se self.total_episode = (self.end_episode - self.begin_episode) + 1 if self.isfile and self.total_episode > 2: self.end_episode = None self.total_episode = 1 elif token.isdigit(): try: int(token) except ValueError: return if self.begin_episode is not None \ and self.end_episode is None \ and len(token) < 5 \ and int(token) > self.begin_episode \ and self._last_token_type == "episode": self.end_episode = int(token) self.total_episode = (self.end_episode - self.begin_episode) + 1 if self.isfile and self.total_episode > 2: self.end_episode = None self.total_episode = 1 self._continue_flag = False self.type = MediaType.TV elif self.begin_episode is None \ and 1 < len(token) < 4 \ and self._last_token_type != "year" \ and self._last_token_type != "videoencode" \ and token != self._unknown_name_str: self.begin_episode = int(token) self.total_episode = 1 self._last_token_type = "episode" self._continue_flag = False self._stop_name_flag = True self.type = MediaType.TV elif self._last_token_type == "EPISODE" \ and self.begin_episode is None \ and len(token) < 5: self.begin_episode = int(token) self.total_episode = 1 self._last_token_type = "episode" self._continue_flag = False self._stop_name_flag = True self.type = MediaType.TV elif token.upper() == "EPISODE": self._last_token_type = "EPISODE" def __init_resource_type(self, token): """ 识别资源类型 """ if not self.name: return if token.upper() == "DL" \ and self._last_token_type == "source" \ and self._last_token == "WEB": self._source = "WEB-DL" self._continue_flag = False return elif token.upper() == "RAY" \ and self._last_token_type == "source" \ and self._last_token == "BLU": # UHD BluRay组合 if self._source == "UHD": self._source = "UHD BluRay" else: self._source = "BluRay" self._continue_flag = False return elif token.upper() == "WEBDL": self._source = "WEB-DL" self._continue_flag = False return # UHD REMUX组合 if token.upper() == "REMUX" \ and self._source == "BluRay": self._source = "BluRay REMUX" self._continue_flag = False return elif token.upper() == "BLURAY" \ and self._source == "UHD": self._source = "UHD BluRay" self._continue_flag = False return source_res = re.search(r"(%s)" % self._source_re, token, re.IGNORECASE) if source_res: self._last_token_type = "source" self._continue_flag = False self._stop_name_flag = True if not self._source: self._source = source_res.group(1) self._last_token = self._source.upper() return effect_res = re.search(r"(%s)" % self._effect_re, token, re.IGNORECASE) if effect_res: self._last_token_type = "effect" self._continue_flag = False self._stop_name_flag = True effect = effect_res.group(1) if effect not in self._effect: self._effect.append(effect) self._last_token = effect.upper() def __init_web_source(self, token: str, tokens: Tokens, streaming_platforms: StreamingPlatforms): """ 识别流媒体平台 """ if not self.name: return platform_name = None query_range = 1 prev_token = None prev_idx = self._index - 2 if 0 <= prev_idx < len(tokens.tokens): prev_token = tokens.tokens[prev_idx] next_token = tokens.peek() if streaming_platforms.is_streaming_platform(token): platform_name = streaming_platforms.get_streaming_platform_name(token) else: for adjacent_token, is_next in [(prev_token, False), (next_token, True)]: if not adjacent_token or platform_name: continue for separator in [" ", "-"]: if is_next: combined_token = f"{token}{separator}{adjacent_token}" else: combined_token = f"{adjacent_token}{separator}{token}" if streaming_platforms.is_streaming_platform(combined_token): platform_name = streaming_platforms.get_streaming_platform_name(combined_token) query_range = 2 if is_next: tokens.get_next() break if not platform_name: return web_tokens = ["WEB", "DL", "WEBDL", "WEBRIP"] match_start_idx = self._index - query_range match_end_idx = self._index - 1 start_index = max(0, match_start_idx - query_range) end_index = min(len(tokens.tokens), match_end_idx + 1 + query_range) tokens_to_check = tokens.tokens[start_index:end_index] if any(tok and tok.upper() in web_tokens for tok in tokens_to_check): self.web_source = platform_name self._continue_flag = False def __init_video_encode(self, token: str): """ 识别视频编码 """ if not self.name: return if not self.year \ and not self.resource_pix \ and not self.resource_type \ and not self.begin_season \ and not self.begin_episode: return re_res = re.search(r"(%s)" % self._video_encode_re, token, re.IGNORECASE) if re_res: self._continue_flag = False self._stop_name_flag = True self._last_token_type = "videoencode" if not self.video_encode: if re_res.group(2): self.video_encode = re_res.group(2).upper() elif re_res.group(3): self.video_encode = re_res.group(3).lower() else: self.video_encode = re_res.group(1).upper() self._last_token = self.video_encode elif self.video_encode == "10bit": self.video_encode = f"{re_res.group(1).upper()} 10bit" self._last_token = re_res.group(1).upper() elif token.upper() in ['H', 'X']: self._continue_flag = False self._stop_name_flag = True self._last_token_type = "videoencode" self._last_token = token.upper() if token.upper() == "H" else token.lower() elif token in ["264", "265"] \ and self._last_token_type == "videoencode" \ and self._last_token in ['H', 'X']: self.video_encode = "%s%s" % (self._last_token, token) elif token.isdigit() \ and self._last_token_type == "videoencode" \ and self._last_token in ['VC', 'MPEG']: self.video_encode = "%s%s" % (self._last_token, token) elif token.upper() == "10BIT": self._last_token_type = "videoencode" if not self.video_encode: self.video_encode = "10bit" else: self.video_encode = f"{self.video_encode} 10bit" def __init_audio_encode(self, token: str): """ 识别音频编码 """ if not self.name: return if not self.year \ and not self.resource_pix \ and not self.resource_type \ and not self.begin_season \ and not self.begin_episode: return re_res = re.search(r"(%s)" % self._audio_encode_re, token, re.IGNORECASE) if re_res: self._continue_flag = False self._stop_name_flag = True self._last_token_type = "audioencode" self._last_token = re_res.group(1).upper() if not self.audio_encode: self.audio_encode = re_res.group(1) else: if self.audio_encode.upper() == "DTS": self.audio_encode = "%s-%s" % (self.audio_encode, re_res.group(1)) else: self.audio_encode = "%s %s" % (self.audio_encode, re_res.group(1)) elif token.isdigit() \ and self._last_token_type == "audioencode": if self.audio_encode: if self._last_token.isdigit(): self.audio_encode = "%s.%s" % (self.audio_encode, token) elif self.audio_encode[-1].isdigit(): self.audio_encode = "%s %s.%s" % (self.audio_encode[:-1], self.audio_encode[-1], token) else: self.audio_encode = "%s %s" % (self.audio_encode, token) self._last_token = token def __init_fps(self, token: str): """ 识别帧率 """ if not self.name: return re_res = re.search(rf"({self._fps_re})", token, re.IGNORECASE) if re_res: self._continue_flag = False self._stop_name_flag = True self._last_token_type = "fps" # 提取帧率数值 fps_value = None if re_res.group(1): # FPS格式 fps_value = re_res.group(1) if fps_value and fps_value.isdigit(): # 只存储纯数值 self.fps = int(fps_value) self._last_token = f"{self.fps}FPS" ================================================ FILE: app/core/meta/releasegroup.py ================================================ import regex as re from app.db.systemconfig_oper import SystemConfigOper from app.schemas.types import SystemConfigKey from app.utils.singleton import Singleton class ReleaseGroupsMatcher(metaclass=Singleton): """ 识别制作组、字幕组 """ # 内置组 RELEASE_GROUPS: dict = { "0ff": ['FF(?:(?:A|WE)B|CD|E(?:DU|B)|TV)'], "1pt": [], "52pt": [], "audiences": ['Audies', 'AD(?:Audio|E(?:book|)|Music|Web)'], "azusa": [], "beitai": ['BeiTai'], "btschool": ['Bts(?:CHOOL|HD|PAD|TV)', 'Zone'], "carpt": ['CarPT'], "chdbits": ['CHD(?:Bits|PAD|(?:|HK)TV|WEB|)', 'StBOX', 'OneHD', 'Lee', 'xiaopie'], "discfan": [], "dragonhd": [], "eastgame": ['(?:(?:iNT|(?:HALFC|Mini(?:S|H|FH)D))-|)TLF'], "filelist": [], "gainbound": ['(?:DG|GBWE)B'], "hares": ['Hares(?:(?:M|T)V|Web|)'], "hd4fans": [], "hdarea": ['HDA(?:pad|rea|TV)', 'EPiC'], "hdatmos": [], "hdbd": [], "hdchina": ['HDC(?:hina|TV|)', 'k9611', 'tudou', 'iHD'], "hddolby": ['D(?:ream|BTV)', '(?:HD|QHstudI)o'], "hdfans": ['beAst(?:TV|)'], "hdhome": ['HDH(?:ome|Pad|TV|WEB|)'], "hdpt": ['HDPT(?:Web|)'], "hdsky": ['HDS(?:ky|TV|Pad|WEB|)', 'AQLJ'], "hdtime": [], "HDU": [], "hdvideo": [], "hdzone": ['HDZ(?:one|)'], "hhanclub": ['HHWEB'], "hitpt": [], "htpt": ['HTPT'], "iptorrents": [], "joyhd": [], "keepfrds": ['FRDS', 'Yumi', 'cXcY'], "lemonhd": ['L(?:eague(?:(?:C|H)D|(?:M|T)V|NF|WEB)|HD)', 'i18n', 'CiNT'], "mteam": ['MTeam(?:TV|)', 'MPAD', 'MWeb'], "nanyangpt": [], "nicept": [], "oshen": [], "ourbits": ['Our(?:Bits|TV)', 'FLTTH', 'Ao', 'PbK', 'MGs', 'iLove(?:HD|TV)'], "panda": ['Panda', 'AilMWeb'], "piggo": ['PiGo(?:NF|(?:H|WE)B)'], "ptchina": [], "pterclub": ['PTer(?:DIY|Game|(?:M|T)V|WEB|)'], "pthome": ['PTH(?:Audio|eBook|music|ome|tv|WEB|)'], "ptmsg": [], "ptsbao": ['PTsbao', 'OPS', 'F(?:Fans(?:AIeNcE|BD|D(?:VD|IY)|TV|WEB)|HDMv)', 'SGXT'], "pttime": [], "putao": ['PuTao'], "soulvoice": [], "springsunday": ['CMCT(?:V|)'], "sharkpt": ['Shark(?:WEB|DIY|TV|MV|)'], "tccf": [], "tjupt": ['TJUPT'], "totheglory": ['TTG', 'WiKi', 'NGB', 'DoA', '(?:ARi|ExRE)N'], "U2": [], "ultrahd": [], "others": ['B(?:MDru|eyondHD|TN)', 'C(?:fandora|trlhd|MRG)', 'DON', 'EVO', 'FLUX', 'HONE(?:yG|)', 'N(?:oGroup|T(?:b|G))', 'PandaMoon', 'SMURF', 'T(?:EPES|aengoo|rollHD )'], "anime": ['ANi', 'HYSUB', 'KTXP', 'LoliHouse', 'MCE', 'Nekomoe kissaten', 'SweetSub', 'MingY', '(?:Lilith|NC)-Raws', '织梦字幕组', '枫叶字幕组', '猎户手抄部', '喵萌奶茶屋', '漫猫字幕社', '霜庭云花Sub', '北宇治字幕组', '氢气烤肉架', '云歌字幕组', '萌樱字幕组', '极影字幕社', '悠哈璃羽字幕社', '❀拨雪寻春❀', '沸羊羊(?:制作|字幕组)', '(?:桜|樱)都字幕组'], "forge": ['FROG(?:E|Web|)'], "ubits": ['UB(?:its|WEB|TV)'], } def __init__(self): release_groups = [] for site_groups in self.RELEASE_GROUPS.values(): for release_group in site_groups: release_groups.append(release_group) self.__release_groups = '|'.join(release_groups) def match(self, title: str = None, groups: str = None): """ :param title: 资源标题或文件名 :param groups: 制作组/字幕组 :return: 匹配结果 """ if not title: return "" if not groups: # 自定义组 custom_release_groups = SystemConfigOper().get(SystemConfigKey.CustomReleaseGroups) if isinstance(custom_release_groups, list): custom_release_groups = list(filter(None, custom_release_groups)) if custom_release_groups: custom_release_groups_str = '|'.join(custom_release_groups) groups = f"{self.__release_groups}|{custom_release_groups_str}" else: groups = self.__release_groups title = f"{title} " groups_re = re.compile(r"(?<=[-@\[£【&])(?:(?:%s))(?=$|[@.\s\]\[】&])" % groups, re.I) unique_groups = [] for item in re.findall(groups_re, title): item_str = item[0] if isinstance(item, tuple) else item if item_str not in unique_groups: unique_groups.append(item_str) return "@".join(unique_groups) ================================================ FILE: app/core/meta/streamingplatform.py ================================================ from typing import Optional, List, Tuple from app.utils.singleton import Singleton class StreamingPlatforms(metaclass=Singleton): """ 流媒体平台简称与全称。 """ STREAMING_PLATFORMS: List[Tuple[str, str]] = [ ("AMZN", "Amazon"), ("NF", "Netflix"), ("ATVP", "Apple TV+"), ("iT", "iTunes"), ("DSNP", "Disney+"), ("HS", "Hotstar"), ("APPS", "Disney+ MENA"), ("PMTP", "Paramount+"), ("HMAX", "Max"), ("", "Max"), ("HULU", "Hulu Networks"), ("MA", "Movies Anywhere"), ("BCORE", "Bravia Core"), ("MS", "Microsoft Store"), ("SHO", "Showtime"), ("STAN", "Stan"), ("PCOK", "Peacock"), ("SKST", "SkyShowtime"), ("NOW", "Now"), ("FXTL", "Foxtel Now"), ("BNGE", "Binge"), ("CRKL", "Crackle"), ("RKTN", "Rakuten TV"), ("ALL4", "Channel 4"), ("AS", "Adult Swim"), ("BRTB", "Brtb TV"), ("CNLP", "Canal+"), ("CRIT", "Criterion Channel"), ("DSCP", "Discovery+"), ("FOOD", "Food Network"), ("MUBI", "Mubi"), ("PLAY", "Google Play"), ("YT", "YouTube"), ("", "friDay"), ("", "KKTV"), ("", "ofiii"), ("", "LiTV"), ("", "MyVideo"), ("Hami", "Hami Video"), ("HamiVideo", "Hami Video"), ("MW", "meWATCH"), ("CATCHPLAY", "CATCHPLAY+"), ("CPP", "CATCHPLAY+"), ("LINETV", "LINE TV"), ("VIU", "Viu"), ("IQ", ""), ("", "WeTV"), ("ABMA", "Abema"), ("ADN", ""), ("AT-X", ""), ("Baha", ""), ("BG", "B-Global"), ("CR", "Crunchyroll"), ("", "DMM"), ("FOD", ""), ("FUNi", "Funimation"), ("HIDI", "HIDIVE"), ("UNXT", "U-NEXT"), ("FAA", "Filmarchiv Austria"), ("CC", "Comedy Central"), ("iP", "BBC iPlayer"), ("9NOW", "9Now"), ("ABC", ""), ("", "AMC"), ("", "ZEE5"), ("", "WAVO"), ("SHAHID", "Shahid"), ("Flixole", "FlixOlé"), ("TOU", "Ici TOU.TV"), ("ROKU", "Roku"), ("KNPY", "Kanopy"), ("SNXT", "Sun NXT"), ("CUR", "Curiosity Stream"), ("MY5", "Channel 5"), ("AHA", "aha"), ("WOWP", "WOW Presents Plus"), ("JC", "JioCinema"), ("", "Dekkoo"), ("FILMZIE", "Filmzie"), ("HoiChoi", "Hoichoi"), ("VIKI", "Rakuten Viki"), ("SF", "SF Anytime"), ("PLEX", "Plex"), ("SHDR", "Shudder"), ("CRAV", "Crave"), ("CPE", "Cineplex Entertainment"), ("JF HC", ""), ("JF", ""), ("JFFP", ""), ("VIAP", "Viaplay"), ("TUBI", "TubiTV"), ("", "PBS"), ("PBSK", "PBS KIDS"), ("LGP", "Lionsgate Play"), ("", "CTV"), ("", "Cineverse"), ("LN", "Love Nature"), ("MP", "Movistar Plus+"), ("RUNTIME", "Runtime"), ("STZ", "STARZ"), ("FUBO", "fuboTV"), ("TENK", "Tënk"), ("KNOW", "Knowledge Network"), ("TVO", "tvo"), ("", "OVID"), ("CBC", "CBC Gem"), ("FANDOR", "fandor"), ("CW", "The CW"), ("KNPY", "Kanopy"), ("FREE", "Freeform"), ("AE", "A&E"), ("LIFE", "Lifetime"), ("WWEN", "WWE Network"), ("CMAX", "Cinemax"), ("HLMK", "Hallmark"), ("BYU", "BYUtv"), ("", "ViX"), ("VICE", "Viceland"), ("", "TVING"), ("USAN", "USA Network"), ("FOX", ""), ("", "TCM"), ("BRAV", "BravoTV"), ("", "TNT"), ("", "ZDF"), ("", "IndieFlix"), ("", "TLC"), ("", "HGTV"), ("ANPL", "Animal Planet"), ("TRVL", "Travel Channel"), ("", "VH1"), ("SAINA", "Saina Play"), ("SP", "Saina Play"), ("OXGN", "Oxygen"), ("PSN", "PlayStation Network"), ("PMNT", "Paramount Network"), ("FAWESOME", "Fawesome"), ("KLASSIKI", "Klassiki"), ("STRP", "Star+"), ("NATG", "National Geographic"), ("REVEEL", "Reveel"), ("FYI", "FYI Network"), ("WatchiT", "WATCH IT"), ("ITVX", "ITV"), ("GAIA", "Gaia"), ("", "FlixLatino"), ("CNNP", "CNN+"), ("TROMA", "Troma"), ("IVI", "Ivi"), ("9NOW", "9Now"), ("A3P", "Atresplayer"), ("7PLUS", "7plus"), ("", "SBS"), ("TEN", "10Play"), ("AUBC", ""), ("DSNY", "Disney Networks"), ("OSN", "OSN+"), ("SVT", "Sveriges Television"), ("LACINETEK", "LaCinetek"), ("", "Maxdome"), ("RTL", "RTL+"), ("ARTE", "Arte"), ("JOYN", "Joyn"), ("TV2", "TV 2"), ("3SAT", "3sat"), ("FILMINGO", "filmingo"), ("", "WOW"), ("OKKO", "Okko"), ("", "Go3"), ("ARGP", "Argo"), ("VOYO", "Voyo"), ("VMAX", "vivamax"), ("FILMIN", "Filmin"), ("", "Mitele"), ("MY5", "Channel 5"), ("", "ARD"), ("BK", "Bentkey"), ("BOOM", "Boomerang"), ("", "CBS"), ("CLBI", "Club illico"), ("CMOR", "C More"), ("CMT", ""), ("", "CNBC"), ("COOK", "Cooking Channel"), ("CWS", "CW Seed"), ("DCU", "DC Universe"), ("DDY", "Digiturk Dilediğin Yerde"), ("DEST", "Destination America"), ("DISC", "Discovery Channel"), ("DW", "DailyWire+"), ("DLWP", "DailyWire+"), ("DPLY", "dplay"), ("DRPO", "Dropout"), ("EPIX", "EPIX MGM+"), ("ESQ", "Esquire"), ("ETV", "E!"), ("FBWatch", "Facebook Watch"), ("FPT", "FPT Play"), ("FTV", "France.tv"), ("GLOB", "GloboSat Play"), ("GLBO", "Globoplay"), ("GO90", "go90"), ("HIST", "History Channel"), ("HPLAY", "Hungama Play"), ("KS", "Kaleidescape"), ("", "MBC"), ("MMAX", "ManoramaMAX"), ("MNBC", "MSNBC"), ("MTOD", "Motor Trend OnDemand"), ("NBC", ""), ("NBLA", "Nebula"), ("NICK", "Nickelodeon"), ("ODK", "OnDemandKorea"), ("POGO", "PokerGO"), ("PUHU", "puhutv"), ("QIBI", "Quibi"), ("RTE", "RTÉ"), ("SESO", "Seeso"), ("SPIK", "Spike"), ("SS", "Simply South"), ("SYFY", "SyFy"), ("TIMV", "TIMvision"), ("TK", "Tentkotta"), ("", "TV4"), ("TVL", "TV Land"), ("", "TVNZ"), ("", "UKTV"), ("VLCT", "Discovery Velocity"), ("VMEO", "Vimeo"), ("VRV", "VRV Defunct"), ("WTCH", "Watcha"), ("", "NowPlayer"), ("HuluJP", "Hulu Networks"), ("Gaga", "GagaOOLala"), ("MyTVS", "MyTVSuper"), ("", "BBC"), ("CC", "Comedy Central"), ("NowE", "Now E"), ("WAVVE", "Wavve"), ("SE", ""), ("", "BritBox"), ("AOD", "Anime on Demand"), ("AF", ""), ("BCH", "Bandai Channel"), ("VMJ", "VideoMarket"), ("LFTL", "Laftel"), ("WAKA", "Wakanim"), ("WAKANIM", "Wakanim"), ("AO", "AnimeOnegai"), ("", "Lemino"), ("VIDIO", "Vidio"), ("TVER", "TVer"), ("", "MBS"), ("LFTLNET", "Laftel"), ("JONU", "Jonu Play"), ("PlutoTV", "Pluto TV"), ("AbemaTV", "Abema"), ("", "dTV"), ("NYMEY", "Nymey"), ("SMNS", "SAMANSA"), ("CTHP", "CATCHPLAY+"), ("HBOGO", "HBO GO"), ("HBO", "HBO"), ("FPTP", "FPT Play"), ("", "LOCIPO"), ("DANT", "DANET"), ("OV", "OceanVeil"), ] def __init__(self): """初始化流媒体平台匹配器""" self._lookup_cache = {} self._build_cache() def _build_cache(self) -> None: """ 构建查询缓存。 """ self._lookup_cache.clear() for short_name, full_name in self.STREAMING_PLATFORMS: canonical_name = full_name or short_name if not canonical_name: continue aliases = {short_name, full_name} for alias in aliases: if alias: self._lookup_cache[alias.upper()] = canonical_name def get_streaming_platform_name(self, platform_code: str) -> Optional[str]: """ 根据流媒体平台简称或全称获取标准名称。 """ if platform_code is None: return None return self._lookup_cache.get(platform_code.upper()) def is_streaming_platform(self, name: str) -> bool: """ 判断给定的字符串是否为已知的流媒体平台代码或名称。 """ if name is None: return False return name.upper() in self._lookup_cache ================================================ FILE: app/core/meta/words.py ================================================ from typing import List, Tuple import cn2an import regex as re from app.db.systemconfig_oper import SystemConfigOper from app.log import logger from app.schemas.types import SystemConfigKey from app.utils.singleton import Singleton class WordsMatcher(metaclass=Singleton): def __init__(self): self.systemconfig = SystemConfigOper() def prepare(self, title: str, custom_words: List[str] = None) -> Tuple[str, List[str]]: """ 预处理标题,支持三种格式 1:屏蔽词 2:被替换词 => 替换词 3:前定位词 <> 后定位词 >> 偏移量(EP) """ appley_words = [] # 读取自定义识别词 words: List[str] = custom_words or self.systemconfig.get(SystemConfigKey.CustomIdentifiers) or [] for word in words: if not word or word.startswith("#"): continue try: if word.count(" => ") and word.count(" && ") and word.count(" >> ") and word.count(" <> "): # 替换词 thc = str(re.findall(r'(.*?)\s*=>', word)[0]).strip() # 被替换词 bthc = str(re.findall(r'=>\s*(.*?)\s*&&', word)[0]).strip() # 集偏移前字段 pyq = str(re.findall(r'&&\s*(.*?)\s*<>', word)[0]).strip() # 集偏移后字段 pyh = str(re.findall(r'<>(.*?)\s*>>', word)[0]).strip() # 集偏移 offsets = str(re.findall(r'>>\s*(.*?)$', word)[0]).strip() # 替换词 title, message, state = self.__replace_regex(title, thc, bthc) if state: # 替换词成功再进行集偏移 title, message, state = self.__episode_offset(title, pyq, pyh, offsets) elif word.count(" => "): # 替换词 strings = word.split(" => ") title, message, state = self.__replace_regex(title, strings[0], strings[1]) elif word.count(" >> ") and word.count(" <> "): # 集偏移 strings = word.split(" <> ") offsets = strings[1].split(" >> ") strings[1] = offsets[0] title, message, state = self.__episode_offset(title, strings[0], strings[1], offsets[1]) else: # 屏蔽词 if not word.strip(): continue title, message, state = self.__replace_regex(title, word, "") if state: appley_words.append(word) except Exception as err: logger.warn(f"自定义识别词 {word} 预处理标题失败:{str(err)} - 标题:{title}") return title, appley_words @staticmethod def __replace_regex(title: str, replaced: str, replace: str) -> Tuple[str, str, bool]: """ 正则替换 """ try: if not re.findall(r'%s' % replaced, title): return title, "", False else: return re.sub(r'%s' % replaced, r'%s' % replace, title), "", True except Exception as err: logger.warn(f"自定义识别词正则替换失败:{str(err)} - 标题:{title},被替换词:{replaced},替换词:{replace}") return title, str(err), False @staticmethod def __episode_offset(title: str, front: str, back: str, offset: str) -> Tuple[str, str, bool]: """ 集数偏移 """ try: if back and not re.findall(r'%s' % back, title): return title, "", False if front and not re.findall(r'%s' % front, title): return title, "", False offset_word_info_re = re.compile(r'(?<=%s.*?)[0-9一二三四五六七八九十]+(?=.*?%s)' % (front, back)) episode_nums_str = re.findall(offset_word_info_re, title) if not episode_nums_str: return title, "", False episode_nums_offset_str = [] offset_order_flag = False for episode_num_str in episode_nums_str: episode_num_int = int(cn2an.cn2an(episode_num_str, "smart")) offset_caculate = offset.replace("EP", str(episode_num_int)) episode_num_offset_int = int(eval(offset_caculate)) # 向前偏移 if episode_num_int > episode_num_offset_int: offset_order_flag = True # 向后偏移 elif episode_num_int < episode_num_offset_int: offset_order_flag = False # 原值是中文数字,转换回中文数字,阿拉伯数字则还原0的填充 if not episode_num_str.isdigit(): episode_num_offset_str = cn2an.an2cn(episode_num_offset_int, "low") else: count_0 = re.findall(r"^0+", episode_num_str) if count_0: episode_num_offset_str = f"{count_0[0]}{episode_num_offset_int}" else: episode_num_offset_str = str(episode_num_offset_int) episode_nums_offset_str.append(episode_num_offset_str) episode_nums_dict = dict(zip(episode_nums_str, episode_nums_offset_str)) # 集数向前偏移,集数按升序处理 if offset_order_flag: episode_nums_list = sorted(episode_nums_dict.items(), key=lambda x: x[1]) # 集数向后偏移,集数按降序处理 else: episode_nums_list = sorted(episode_nums_dict.items(), key=lambda x: x[1], reverse=True) for episode_num in episode_nums_list: episode_offset_re = re.compile( r'(?<=%s.*?)%s(?=.*?%s)' % (front, episode_num[0], back)) title = re.sub(episode_offset_re, r'%s' % episode_num[1], title) return title, "", True except Exception as err: logger.warn(f"自定义识别词集数偏移失败:{str(err)} - 标题:{title},前定位词:{front},后定位词:{back},偏移量:{offset}") return title, str(err), False ================================================ FILE: app/core/metainfo.py ================================================ from pathlib import Path from typing import Tuple, List, Optional import regex as re from app.core.config import settings from app.core.meta import MetaAnime, MetaVideo, MetaBase from app.core.meta.words import WordsMatcher from app.log import logger from app.schemas.types import MediaType def MetaInfo(title: str, subtitle: Optional[str] = None, custom_words: List[str] = None) -> MetaBase: """ 根据标题和副标题识别元数据 :param title: 标题、种子名、文件名 :param subtitle: 副标题、描述 :param custom_words: 自定义识别词列表 :return: MetaAnime、MetaVideo """ # 原标题 org_title = title # 预处理标题 title, apply_words = WordsMatcher().prepare(title, custom_words=custom_words) # 获取标题中媒体信息 title, metainfo = find_metainfo(title) # 判断是否处理文件 media_exts = settings.RMT_MEDIAEXT + settings.RMT_SUBEXT + settings.RMT_AUDIOEXT if title and Path(title).suffix.lower() in media_exts: isfile = True # 去掉后缀 title = Path(title).stem else: isfile = False # 识别 meta = MetaAnime(title, subtitle, isfile) if is_anime(title) else MetaVideo(title, subtitle, isfile) # 记录原标题 meta.title = org_title # 记录使用的识别词 meta.apply_words = apply_words or [] # 修正媒体信息 if metainfo.get('tmdbid'): try: meta.tmdbid = int(metainfo['tmdbid']) except ValueError as _: logger.warn("tmdbid 必须是数字") if metainfo.get('doubanid'): meta.doubanid = metainfo['doubanid'] if metainfo.get('type'): meta.type = metainfo['type'] if metainfo.get('begin_season'): meta.begin_season = metainfo['begin_season'] if metainfo.get('end_season'): meta.end_season = metainfo['end_season'] if metainfo.get('total_season'): meta.total_season = metainfo['total_season'] if metainfo.get('begin_episode'): meta.begin_episode = metainfo['begin_episode'] if metainfo.get('end_episode'): meta.end_episode = metainfo['end_episode'] if metainfo.get('total_episode'): meta.total_episode = metainfo['total_episode'] return meta def MetaInfoPath(path: Path, custom_words: List[str] = None) -> MetaBase: """ 根据路径识别元数据 :param path: 路径 :param custom_words: 自定义识别词列表 """ # 文件元数据,不包含后缀 file_meta = MetaInfo(title=path.name, custom_words=custom_words) # 上级目录元数据 dir_meta = MetaInfo(title=path.parent.name, custom_words=custom_words) if file_meta.type == MediaType.TV or dir_meta.type != MediaType.TV: # 合并元数据 file_meta.merge(dir_meta) # 上上级目录元数据 root_meta = MetaInfo(title=path.parent.parent.name, custom_words=custom_words) if file_meta.type == MediaType.TV or root_meta.type != MediaType.TV: # 合并元数据 file_meta.merge(root_meta) return file_meta def is_anime(name: str) -> bool: """ 判断是否为动漫 :param name: 名称 :return: 是否动漫 """ if not name: return False if re.search(r'【[+0-9XVPI-]+】\s*【', name, re.IGNORECASE): return True if re.search(r'\s+-\s+[\dv]{1,4}\s+', name, re.IGNORECASE): return True if re.search(r"S\d{2}\s*-\s*S\d{2}|S\d{2}|\s+S\d{1,2}|EP?\d{2,4}\s*-\s*EP?\d{2,4}|EP?\d{2,4}|\s+EP?\d{1,4}", name, re.IGNORECASE): return False if re.search(r'\[[+0-9XVPI-]+]\s*\[', name, re.IGNORECASE): return True return False def find_metainfo(title: str) -> Tuple[str, dict]: """ 从标题中提取媒体信息 """ metainfo = { 'tmdbid': None, 'doubanid': None, 'type': None, 'begin_season': None, 'end_season': None, 'total_season': None, 'begin_episode': None, 'end_episode': None, 'total_episode': None, } if not title: return title, metainfo # 从标题中提取媒体信息 格式为{[tmdbid=xxx;type=xxx;s=xxx;e=xxx]} results = re.findall(r'(?<={\[)[\W\w]+(?=]})', title) if results: for result in results: # 查找tmdbid信息 tmdbid = re.findall(r'(?<=tmdbid=)\d+', result) if tmdbid and tmdbid[0].isdigit(): metainfo['tmdbid'] = tmdbid[0] # 查找豆瓣id信息 doubanid = re.findall(r'(?<=doubanid=)\d+', result) if doubanid and doubanid[0].isdigit(): metainfo['doubanid'] = doubanid[0] # 查找媒体类型 mtype = re.findall(r'(?<=type=)\w+', result) if mtype: if mtype[0] == "movies": metainfo['type'] = MediaType.MOVIE elif mtype[0] == "tv": metainfo['type'] = MediaType.TV # 查找季信息 begin_season = re.findall(r'(?<=s=)\d+', result) if begin_season and begin_season[0].isdigit(): metainfo['begin_season'] = int(begin_season[0]) end_season = re.findall(r'(?<=s=\d+-)\d+', result) if end_season and end_season[0].isdigit(): metainfo['end_season'] = int(end_season[0]) # 查找集信息 begin_episode = re.findall(r'(?<=e=)\d+', result) if begin_episode and begin_episode[0].isdigit(): metainfo['begin_episode'] = int(begin_episode[0]) end_episode = re.findall(r'(?<=e=\d+-)\d+', result) if end_episode and end_episode[0].isdigit(): metainfo['end_episode'] = int(end_episode[0]) # 去除title中该部分 if tmdbid or mtype or begin_season or end_season or begin_episode or end_episode: title = title.replace(f"{{[{result}]}}", '') # 支持Emby格式的ID标签 # 1. [tmdbid=xxxx] 或 [tmdbid-xxxx] 格式 tmdb_match = re.search(r'\[tmdbid[=\-](\d+)\]', title) if tmdb_match: metainfo['tmdbid'] = tmdb_match.group(1) title = re.sub(r'\[tmdbid[=\-](\d+)\]', '', title).strip() # 2. [tmdb=xxxx] 或 [tmdb-xxxx] 格式 if not metainfo['tmdbid']: tmdb_match = re.search(r'\[tmdb[=\-](\d+)\]', title) if tmdb_match: metainfo['tmdbid'] = tmdb_match.group(1) title = re.sub(r'\[tmdb[=\-](\d+)\]', '', title).strip() # 3. {tmdbid=xxxx} 或 {tmdbid-xxxx} 格式 if not metainfo['tmdbid']: tmdb_match = re.search(r'\{tmdbid[=\-](\d+)\}', title) if tmdb_match: metainfo['tmdbid'] = tmdb_match.group(1) title = re.sub(r'\{tmdbid[=\-](\d+)\}', '', title).strip() # 4. {tmdb=xxxx} 或 {tmdb-xxxx} 格式 if not metainfo['tmdbid']: tmdb_match = re.search(r'\{tmdb[=\-](\d+)\}', title) if tmdb_match: metainfo['tmdbid'] = tmdb_match.group(1) title = re.sub(r'\{tmdb[=\-](\d+)\}', '', title).strip() # 计算季集总数 if metainfo.get('begin_season') and metainfo.get('end_season'): if metainfo['begin_season'] > metainfo['end_season']: metainfo['begin_season'], metainfo['end_season'] = metainfo['end_season'], metainfo['begin_season'] metainfo['total_season'] = metainfo['end_season'] - metainfo['begin_season'] + 1 elif metainfo.get('begin_season') and not metainfo.get('end_season'): metainfo['total_season'] = 1 if metainfo.get('begin_episode') and metainfo.get('end_episode'): if metainfo['begin_episode'] > metainfo['end_episode']: metainfo['begin_episode'], metainfo['end_episode'] = metainfo['end_episode'], metainfo['begin_episode'] metainfo['total_episode'] = metainfo['end_episode'] - metainfo['begin_episode'] + 1 elif metainfo.get('begin_episode') and not metainfo.get('end_episode'): metainfo['total_episode'] = 1 return title, metainfo ================================================ FILE: app/core/module.py ================================================ import traceback from typing import Generator, Optional, Tuple, Any, Union, List from app.core.config import settings from app.core.event import eventmanager from app.helper.module import ModuleHelper from app.log import logger from app.schemas.types import EventType, ModuleType, DownloaderType, MediaServerType, MessageChannel, StorageSchema, \ OtherModulesType from app.utils.object import ObjectUtils from app.utils.singleton import Singleton class ModuleManager(metaclass=Singleton): """ 模块管理器 """ # 子模块类型集合 SubType = Union[DownloaderType, MediaServerType, MessageChannel, StorageSchema, OtherModulesType] def __init__(self): # 模块列表 self._modules: dict = {} # 运行态模块列表 self._running_modules: dict = {} self.load_modules() def load_modules(self): """ 加载所有模块 """ # 扫描模块目录 modules = ModuleHelper.load( "app.modules", filter_func=lambda _, obj: hasattr(obj, 'init_module') and hasattr(obj, 'init_setting') ) self._running_modules = {} self._modules = {} for module in modules: module_id = module.__name__ self._modules[module_id] = module try: # 生成实例 _module = module() # 初始化模块 if self.check_setting(_module.init_setting()): # 通过模板开关控制加载 _module.init_module() self._running_modules[module_id] = _module logger.debug(f"Moudle Loaded:{module_id}") except Exception as err: logger.error(f"Load Moudle Error:{module_id},{str(err)} - {traceback.format_exc()}", exc_info=True) def stop(self): """ 停止所有模块 """ logger.info("正在停止所有模块...") for module_id, module in self._running_modules.items(): if hasattr(module, "stop"): try: module.stop() logger.debug(f"Moudle Stoped:{module_id}") except Exception as err: logger.error(f"Stop Moudle Error:{module_id},{str(err)} - {traceback.format_exc()}", exc_info=True) logger.info("所有模块停止完成") def reload(self): """ 重新加载所有模块 """ self.stop() self.load_modules() eventmanager.send_event(etype=EventType.ModuleReload, data={}) def test(self, modleid: str) -> Tuple[bool, str]: """ 测试模块 """ if modleid not in self._running_modules: return False, "" module = self._running_modules[modleid] if hasattr(module, "test") \ and ObjectUtils.check_method(getattr(module, "test")): result = module.test() if not result: return False, "" return result return True, "模块不支持测试" @staticmethod def check_setting(setting: Optional[tuple]) -> bool: """ 检查开关是否己打开,开关使用,分隔多个值,符合其中即代表开启 """ if not setting: return True switch, value = setting option = getattr(settings, switch) if not option: return False if option and value is True: return True if value in option: return True return False def get_running_module(self, module_id: str) -> Any: """ 根据模块id获取模块运行实例 """ if not module_id: return None if not self._running_modules: return None return self._running_modules.get(module_id) def get_running_modules(self, method: str) -> Generator: """ 获取实现了同一方法的模块列表 """ if not self._running_modules: return for _, module in self._running_modules.items(): if hasattr(module, method) \ and ObjectUtils.check_method(getattr(module, method)): yield module def get_running_type_modules(self, module_type: ModuleType) -> Generator: """ 获取指定类型的模块列表 """ if not self._running_modules: return for _, module in self._running_modules.items(): if hasattr(module, 'get_type') \ and module.get_type() == module_type: yield module def get_running_subtype_module(self, module_subtype: SubType) -> Generator: """ 获取指定子类型的模块 """ if not self._running_modules: return for _, module in self._running_modules.items(): if hasattr(module, 'get_subtype') \ and module.get_subtype() == module_subtype: yield module def get_module(self, module_id: str) -> Any: """ 根据模块id获取模块 """ if not module_id: return None if not self._modules: return None return self._modules.get(module_id) def get_modules(self) -> dict: """ 获取模块列表 """ return self._modules def get_module_ids(self) -> List[str]: """ 获取模块id列表 """ return list(self._modules.keys()) ================================================ FILE: app/core/plugin.py ================================================ import ast import asyncio import concurrent import concurrent.futures import importlib.util import inspect import os import posixpath import sys import threading import time import traceback from concurrent.futures import ThreadPoolExecutor, as_completed from pathlib import Path from typing import Any, Dict, List, Optional, Type, Union, Callable, Tuple from fastapi import HTTPException from starlette import status from watchfiles import watch from app import schemas from app.core.cache import fresh, async_fresh from app.core.config import settings from app.core.event import eventmanager from app.db.plugindata_oper import PluginDataOper from app.db.systemconfig_oper import SystemConfigOper from app.helper.plugin import PluginHelper from app.helper.sites import SitesHelper # noqa from app.log import logger from app.schemas.types import EventType, SystemConfigKey from app.utils.crypto import RSAUtils from app.utils.mixins import ConfigReloadMixin from app.utils.object import ObjectUtils from app.utils.singleton import Singleton from app.utils.string import StringUtils from app.utils.system import SystemUtils class PluginManager(ConfigReloadMixin, metaclass=Singleton): """插件管理器""" CONFIG_WATCH = {"DEV", "PLUGIN_AUTO_RELOAD"} def __init__(self): # 插件列表 self._plugins: dict = {} # 运行态插件列表 self._running_plugins: dict = {} # 配置Key self._config_key: str = "plugin.%s" # 监控线程 self._monitor_thread: Optional[threading.Thread] = None # 监控停止事件 self._stop_monitor_event = threading.Event() # 开发者模式监测插件修改 if settings.DEV or settings.PLUGIN_AUTO_RELOAD: self.__start_monitor() def init_config(self): # 停止已有插件 self.stop() # 启动插件 self.start() def start(self, pid: Optional[str] = None): """ 启动加载插件 :param pid: 插件ID,为空加载所有插件 """ def check_module(module: Any): """ 检查模块 """ if not hasattr(module, 'init_plugin') or not hasattr(module, "plugin_name"): return False return True # 已安装插件 installed_plugins = SystemConfigOper().get(SystemConfigKey.UserInstalledPlugins) or [] # 扫描插件目录,只加载符合条件的插件 plugins = self._load_selective_plugins(pid, installed_plugins, check_module) # 排序 plugins.sort(key=lambda x: x.plugin_order if hasattr(x, "plugin_order") else 0) for plugin in plugins: plugin_id = plugin.__name__ if pid and plugin_id != pid: continue try: # 判断插件是否满足认证要求,如不满足则不进行实例化 if not self.__set_and_check_auth_level(plugin=plugin): # 如果是插件热更新实例,这里则进行替换 if plugin_id in self._plugins: self._plugins[plugin_id] = plugin continue # 存储Class self._plugins[plugin_id] = plugin # 生成实例 plugin_obj = plugin() # 生效插件配置 plugin_obj.init_plugin(self.get_plugin_config(plugin_id)) # 存储运行实例 self._running_plugins[plugin_id] = plugin_obj logger.info(f"加载插件:{plugin_id} 版本:{plugin_obj.plugin_version}") # 启用的插件才设置事件注册状态可用 if plugin_obj.get_state(): eventmanager.enable_event_handler(plugin) else: eventmanager.disable_event_handler(plugin) except Exception as err: logger.error(f"加载插件 {plugin_id} 出错:{str(err)} - {traceback.format_exc()}") def init_plugin(self, plugin_id: str, conf: dict): """ 初始化插件 :param plugin_id: 插件ID :param conf: 插件配置 """ plugin = self._running_plugins.get(plugin_id) if not plugin: return # 初始化插件 plugin.init_plugin(conf) # 检查插件状态并启用/禁用事件处理器 if plugin.get_state(): # 启用插件类的事件处理器 eventmanager.enable_event_handler(type(plugin)) else: # 禁用插件类的事件处理器 eventmanager.disable_event_handler(type(plugin)) def stop(self, pid: Optional[str] = None): """ 停止插件服务 :param pid: 插件ID,为空停止所有插件 """ # 停止插件 if pid: logger.info(f"正在停止插件 {pid}...") plugin_obj = self._running_plugins.get(pid) if not plugin_obj: logger.debug(f"插件 {pid} 不存在或未加载") return plugins = {pid: plugin_obj} else: logger.info("正在停止所有插件...") plugins = self._running_plugins for plugin_id, plugin in plugins.items(): eventmanager.disable_event_handler(type(plugin)) self.__stop_plugin(plugin) # 清空对像 if pid: # 清空指定插件 self._plugins.pop(pid, None) self._running_plugins.pop(pid, None) # 清除插件模块缓存,包括所有子模块 self._clear_plugin_modules(pid) else: # 清空 self._plugins = {} self._running_plugins = {} # 清除所有插件模块缓存 self._clear_plugin_modules() logger.info("插件停止完成") @staticmethod def _load_selective_plugins(pid: Optional[str], installed_plugins: List[str], check_module_func: Callable) -> List[Any]: """ 选择性加载插件,只import符合条件的插件 :param pid: 指定插件ID,为空则加载所有已安装插件 :param installed_plugins: 已安装插件列表 :param check_module_func: 模块检查函数 :return: 插件类列表 """ import importlib plugins = [] plugins_dir = settings.ROOT_PATH / "app" / "plugins" if not plugins_dir.exists(): logger.warning(f"插件目录不存在:{plugins_dir}") return plugins # 确定需要加载的插件目录名称列表 if pid: # 加载指定插件 target_plugins = [pid.lower()] else: # 加载已安装插件 target_plugins = [plugin_id.lower() for plugin_id in installed_plugins] if not target_plugins: logger.debug("没有需要加载的插件") return plugins # 扫描plugins目录 _loaded_modules = set() for plugin_dir in plugins_dir.iterdir(): if not plugin_dir.is_dir() or plugin_dir.name.startswith('_'): continue # 检查是否是需要加载的插件 if plugin_dir.name not in target_plugins: logger.debug(f"跳过插件目录:{plugin_dir.name}(不在加载列表中)") continue # 检查__init__.py是否存在 init_file = plugin_dir / "__init__.py" if not init_file.exists(): logger.debug(f"跳过插件目录:{plugin_dir.name}(缺少__init__.py)") continue try: # 构建模块名 module_name = f"app.plugins.{plugin_dir.name}" logger.debug(f"正在导入插件模块:{module_name}") # 导入模块 module = importlib.import_module(module_name) # 检查模块中的类 for name, obj in module.__dict__.items(): if name.startswith('_') or not isinstance(obj, type): continue if name in _loaded_modules: continue if check_module_func(obj): _loaded_modules.add(name) plugins.append(obj) logger.debug(f"找到符合条件的插件类:{name}") break except Exception as err: logger.error(f"加载插件 {plugin_dir.name} 失败:{str(err)} - {traceback.format_exc()}") return plugins @property def running_plugins(self) -> Dict[str, Any]: """ 获取运行态插件列表 :return: 运行态插件列表 """ return self._running_plugins @property def plugins(self) -> Dict[str, Any]: """ 获取插件列表 :return: 插件列表 """ return self._plugins def on_config_changed(self): self.reload_monitor() def get_reload_name(self) -> str: return "插件文件修改监测" def reload_monitor(self): """ 重新加载插件文件修改监测 """ if settings.DEV or settings.PLUGIN_AUTO_RELOAD: # 先关闭已有监测,再重新启动 self.stop_monitor() self.__start_monitor() else: self.stop_monitor() def __start_monitor(self): """ 启用监测插件文件修改监测 """ if self._monitor_thread and self._monitor_thread.is_alive(): logger.info("插件文件修改监测已经在运行中...") return logger.info("开始监测插件文件修改...") # 在启动新线程之前,确保停止事件是清除状态 self._stop_monitor_event.clear() # 创建并启动监控线程 self._monitor_thread = threading.Thread( target=self._run_file_watcher, daemon=True ) self._monitor_thread.start() def stop_monitor(self): """ 停止监测插件文件修改监测 """ if self._monitor_thread and self._monitor_thread.is_alive(): logger.info("正在停止插件文件修改监测...") self._stop_monitor_event.set() self._monitor_thread.join(timeout=5) if self._monitor_thread.is_alive(): logger.warning("插件文件修改监测线程在5秒内未能正常停止。") self._monitor_thread = None logger.info("插件文件修改监测停止完成") else: logger.info("未启用插件文件修改监测,无需停止") def _run_file_watcher(self): """ 运行 watchfiles 监视器的主循环。 """ # 监视插件目录 plugins_path = str(settings.ROOT_PATH / "app" / "plugins") logger.info(">>> 监控线程已启动,准备进入watch循环...") # 使用 watchfiles 监视目录变化,并响应变化事件 # Todo: yield_on_timeout = True 时,每秒检查停止事件,会返回空集合;后续可以考虑用来做心跳之类的功能? for changes in watch(plugins_path, stop_event=self._stop_monitor_event, rust_timeout=1000, yield_on_timeout=True): # 如果收到停止事件,退出循环 if not changes: continue # 处理变化事件 plugins_to_reload = set() for _change_type, path_str in changes: event_path = Path(path_str) # 跳过非 .py 文件以及 pycache 目录中的文件 if not event_path.name.endswith(".py") or "__pycache__" in event_path.parts: continue # 解析插件ID pid = self._get_plugin_id_from_path(event_path) # 跳过无效插件文件 if pid: # 收集需要重载的插件ID,自动去重,避免重复重载 plugins_to_reload.add(pid) # 触发重载 if plugins_to_reload: logger.info(f"检测到插件文件变化,准备重载: {list(plugins_to_reload)}") for pid in plugins_to_reload: try: self.reload_plugin(pid) except Exception as e: logger.error(f"插件 {pid} 热重载失败: {e}", exc_info=True) @staticmethod def _get_plugin_id_from_path(event_path: Path) -> Optional[str]: """ 根据文件路径解析出插件的ID。 :param event_path: 被修改文件的 Path 对象。 :return: 插件ID字符串,如果不是有效插件文件则返回 None。 """ try: plugins_root = settings.ROOT_PATH / "app" / "plugins" # 确保修改的文件在 plugins 目录下 if not event_path.is_relative_to(plugins_root): return None try: plugin_dir_name = event_path.relative_to(plugins_root).parts[0] plugin_dir = plugins_root / plugin_dir_name except (ValueError, IndexError): return None init_file = plugin_dir / "__init__.py" if not init_file.exists(): return None # 读取 __init__.py 文件,查找插件主类名 with open(init_file, "r", encoding="utf-8") as f: source_code = f.read() tree = ast.parse(source_code) # 遍历AST,查找继承自 _PluginBase 的类 for node in ast.walk(tree): # 检查节点是否为类定义 if isinstance(node, ast.ClassDef): # 遍历该类的所有基类 for base in node.bases: # 检查基类是否是我们寻找的 _PluginBase # ast.Name 用于处理简单的基类名 if isinstance(base, ast.Name) and base.id == '_PluginBase': # 返回这个类的名字 return node.name return None except Exception as e: logger.error(f"从路径解析插件ID时出错: {e}") return None @staticmethod def __stop_plugin(plugin: Any): """ 停止插件 :param plugin: 插件实例 """ try: # 关闭数据库 if hasattr(plugin, "close"): plugin.close() # 关闭插件 if hasattr(plugin, "stop_service"): plugin.stop_service() except Exception as e: logger.warn(f"停止插件 {plugin.get_name()} 时发生错误: {str(e)}") def remove_plugin(self, plugin_id: str): """ 从内存中移除一个插件 :param plugin_id: 插件ID """ self.stop(plugin_id) def reload_plugin(self, plugin_id: str): """ 将一个插件重新加载到内存 :param plugin_id: 插件ID """ # 先移除插件实例 self.stop(plugin_id) # 重新加载 self.start(plugin_id) # 广播事件 eventmanager.send_event(EventType.PluginReload, data={"plugin_id": plugin_id}) @staticmethod def _clear_plugin_modules(plugin_id: Optional[str] = None): """ 清除插件及其所有子模块的缓存 :param plugin_id: 插件ID """ # 构建插件模块前缀 if plugin_id: plugin_module_prefix = f"app.plugins.{plugin_id.lower()}" else: plugin_module_prefix = "app.plugins" # 收集需要删除的模块名(创建模块名列表的副本以避免迭代时修改字典) modules_to_remove = [] for module_name in list(sys.modules.keys()): if module_name == plugin_module_prefix or module_name.startswith(plugin_module_prefix + "."): modules_to_remove.append(module_name) # 删除模块 for module_name in modules_to_remove: try: del sys.modules[module_name] logger.debug(f"已清除插件模块缓存:{module_name}") except KeyError: # 模块可能已经被删除 pass importlib.invalidate_caches() logger.debug("已清除查找器的缓存") if plugin_id: if modules_to_remove: logger.info(f"插件 {plugin_id} 共清除 {len(modules_to_remove)} 个模块缓存:{modules_to_remove}") else: logger.debug(f"插件 {plugin_id} 没有找到需要清除的模块缓存") def sync(self) -> List[str]: """ 安装本地不存在或需要更新的插件 """ def install_plugin(plugin): start_time = time.time() state, msg = PluginHelper().install(pid=plugin.id, repo_url=plugin.repo_url, force_install=True) elapsed_time = time.time() - start_time if state: logger.info( f"插件 {plugin.plugin_name} 安装成功,版本:{plugin.plugin_version},耗时:{elapsed_time:.2f} 秒") sync_plugins.append(plugin.id) else: logger.error( f"插件 {plugin.plugin_name} v{plugin.plugin_version} 安装失败:{msg},耗时:{elapsed_time:.2f} 秒") failed_plugins.append(plugin.id) if SystemUtils.is_frozen(): return [] # 获取已安装插件列表 install_plugins = SystemConfigOper().get(SystemConfigKey.UserInstalledPlugins) or [] # 获取在线插件列表 online_plugins = self.get_online_plugins() # 确定需要安装的插件 plugins_to_install = [ plugin for plugin in online_plugins if plugin.id in install_plugins and not self.is_plugin_exists(plugin.id, plugin.plugin_version) ] if not plugins_to_install: return [] logger.info("开始安装第三方插件...") sync_plugins = [] failed_plugins = [] # 使用 ThreadPoolExecutor 进行并发安装 total_start_time = time.time() with ThreadPoolExecutor(max_workers=5) as executor: futures = { executor.submit(install_plugin, plugin): plugin for plugin in plugins_to_install } for future in as_completed(futures): plugin = futures[future] try: future.result() except Exception as exc: logger.error(f"插件 {plugin.plugin_name} 安装过程中出现异常: {exc}") total_elapsed_time = time.time() - total_start_time logger.info( f"第三方插件安装完成,成功:{len(sync_plugins)} 个," f"失败:{len(failed_plugins)} 个,总耗时:{total_elapsed_time:.2f} 秒" ) return sync_plugins @staticmethod def install_plugin_missing_dependencies() -> List[str]: """ 安装插件中缺失或不兼容的依赖项 """ pluginhelper = PluginHelper() # 第一步:获取需要安装的依赖项列表 missing_dependencies = pluginhelper.find_missing_dependencies() if not missing_dependencies: return missing_dependencies logger.debug(f"检测到缺失的依赖项: {missing_dependencies}") logger.info(f"开始安装缺失的依赖项,共 {len(missing_dependencies)} 个...") # 第二步:安装依赖项并返回结果 total_start_time = time.time() success, message = pluginhelper.install_dependencies(missing_dependencies) total_elapsed_time = time.time() - total_start_time if success: logger.info(f"已完成 {len(missing_dependencies)} 个依赖项安装,总耗时:{total_elapsed_time:.2f} 秒") else: logger.warning(f"存在缺失依赖项安装失败,请尝试手动安装,总耗时:{total_elapsed_time:.2f} 秒") return missing_dependencies def get_plugin_config(self, pid: str) -> dict: """ 获取插件配置 :param pid: 插件ID """ if not self._plugins.get(pid): return {} conf = SystemConfigOper().get(self._config_key % pid) if conf: # 去掉空Key return {k: v for k, v in conf.items() if k} return {} def save_plugin_config(self, pid: str, conf: dict, force: bool = False) -> bool: """ 保存插件配置 :param pid: 插件ID :param conf: 配置 :param force: 强制保存 """ if not force and not self._plugins.get(pid): return False SystemConfigOper().set(self._config_key % pid, conf) return True def delete_plugin_config(self, pid: str) -> bool: """ 删除插件配置 :param pid: 插件ID """ if not self._plugins.get(pid): return False return SystemConfigOper().delete(self._config_key % pid) def delete_plugin_data(self, pid: str) -> bool: """ 删除插件数据 :param pid: 插件ID """ if not self._plugins.get(pid): return False PluginDataOper().del_data(pid) return True def get_plugin_state(self, pid: str) -> bool: """ 获取插件状态 :param pid: 插件ID """ plugin = self._running_plugins.get(pid) return plugin.get_state() if plugin else False def get_plugin_commands(self, pid: Optional[str] = None) -> List[Dict[str, Any]]: """ 获取插件命令 [{ "cmd": "/xx", "event": EventType.xx, "desc": "xxxx", "data": {}, "pid": "", }] """ ret_commands = [] # 创建字典快照避免并发修改 running_plugins_snapshot = dict(self._running_plugins) for plugin_id, plugin in running_plugins_snapshot.items(): if pid and pid != plugin_id: continue if hasattr(plugin, "get_command") and ObjectUtils.check_method(plugin.get_command): try: if not plugin.get_state(): continue commands = plugin.get_command() or [] for command in commands: command["pid"] = plugin_id ret_commands.extend(commands) except Exception as e: logger.error(f"获取插件命令出错:{str(e)}") return ret_commands def get_plugin_apis(self, pid: Optional[str] = None) -> List[Dict[str, Any]]: """ 获取插件API [{ "path": "/xx", "endpoint": self.xxx, "methods": ["GET", "POST"], "summary": "API名称", "description": "API说明", "allow_anonymous": false }] """ ret_apis = [] if pid: plugins = {pid: self._running_plugins.get(pid)} else: plugins = self._running_plugins for plugin_id, plugin in plugins.items(): if pid and pid != plugin_id: continue if hasattr(plugin, "get_api") and ObjectUtils.check_method(plugin.get_api): try: apis = plugin.get_api() or [] for api in apis: api["path"] = f"/{plugin_id}{api['path']}" if not api.get("auth"): api["auth"] = "apikey" ret_apis.extend(apis) except Exception as e: logger.error(f"获取插件 {plugin_id} API出错:{str(e)}") return ret_apis def get_plugin_services(self, pid: Optional[str] = None) -> List[Dict[str, Any]]: """ 获取插件服务 [{ "id": "服务ID", "name": "服务名称", "trigger": "触发器:cron、interval、date、CronTrigger.from_crontab()", "func": self.xxx, "kwargs": {} # 定时器参数, "func_kwargs": {} # 方法参数 }] """ ret_services = [] # 创建字典快照避免并发修改 running_plugins_snapshot = dict(self._running_plugins) for plugin_id, plugin in running_plugins_snapshot.items(): if pid and pid != plugin_id: continue if hasattr(plugin, "get_service") and ObjectUtils.check_method(plugin.get_service): try: if not plugin.get_state(): continue services = plugin.get_service() or [] ret_services.extend(services) except Exception as e: logger.error(f"获取插件 {plugin_id} 服务出错:{str(e)}") return ret_services def get_plugin_modules(self, pid: Optional[str] = None) -> Dict[tuple, Dict[str, Any]]: """ 获取插件模块 { plugin_id: { method: function } } """ ret_modules = {} # 创建字典快照避免并发修改 running_plugins_snapshot = dict(self._running_plugins) for plugin_id, plugin in running_plugins_snapshot.items(): if pid and pid != plugin_id: continue if hasattr(plugin, "get_module") and ObjectUtils.check_method(plugin.get_module): try: if not plugin.get_state(): continue plugin_module = plugin.get_module() or [] ret_modules[(plugin_id, plugin.get_name())] = plugin_module except Exception as e: logger.error(f"获取插件 {plugin_id} 模块出错:{str(e)}") return ret_modules def get_plugin_actions(self, pid: Optional[str] = None) -> List[Dict[str, Any]]: """ 获取插件动作 [{ "id": "动作ID", "name": "动作名称", "func": self.xxx, "kwargs": {} # 需要附加传递的参数 }] """ ret_actions = [] # 创建字典快照避免并发修改 running_plugins_snapshot = dict(self._running_plugins) for plugin_id, plugin in running_plugins_snapshot.items(): if pid and pid != plugin_id: continue if hasattr(plugin, "get_actions") and ObjectUtils.check_method(plugin.get_actions): try: if not plugin.get_state(): continue actions = plugin.get_actions() if actions: ret_actions.append({ "plugin_id": plugin_id, "plugin_name": plugin.plugin_name, "actions": actions }) except Exception as e: logger.error(f"获取插件 {plugin_id} 动作出错:{str(e)}") return ret_actions def get_plugin_agent_tools(self, pid: Optional[str] = None) -> List[Dict[str, Any]]: """ 获取插件智能体工具 [{ "plugin_id": "插件ID", "plugin_name": "插件名称", "tools": [ToolClass1, ToolClass2, ...] }] """ ret_tools = [] # 创建字典快照避免并发修改 running_plugins_snapshot = dict(self._running_plugins) for plugin_id, plugin in running_plugins_snapshot.items(): if pid and pid != plugin_id: continue if hasattr(plugin, "get_agent_tools") and ObjectUtils.check_method(plugin.get_agent_tools): try: if not plugin.get_state(): continue tools = plugin.get_agent_tools() if tools: ret_tools.append({ "plugin_id": plugin_id, "plugin_name": plugin.plugin_name, "tools": tools }) except Exception as e: logger.error(f"获取插件 {plugin_id} 智能体工具出错:{str(e)}") return ret_tools @staticmethod def get_plugin_remote_entry(plugin_id: str, dist_path: str) -> str: """ 获取插件的远程入口地址 :param plugin_id: 插件 ID :param dist_path: 插件的分发路径 :return: 远程入口地址 """ dist_path = dist_path.strip("/") path = posixpath.join( "plugin", "file", plugin_id.lower(), dist_path, "remoteEntry.js", ) if not path.startswith("/"): path = "/" + path return path def get_plugin_remotes(self, pid: Optional[str] = None) -> List[Dict[str, Any]]: """ 获取插件联邦组件列表 """ remotes = [] # 创建字典快照避免并发修改 running_plugins_snapshot = dict(self._running_plugins) for plugin_id, plugin in running_plugins_snapshot.items(): if pid and pid != plugin_id: continue if hasattr(plugin, "get_render_mode"): render_mode, dist_path = plugin.get_render_mode() if render_mode != "vue": continue remotes.append({ "id": plugin_id, "url": self.get_plugin_remote_entry(plugin_id, dist_path), "name": plugin.plugin_name, }) return remotes def get_plugin_dashboard_meta(self) -> List[Dict[str, str]]: """ 获取所有插件仪表盘元信息 """ dashboard_meta = [] # 创建字典快照避免并发修改 running_plugins_snapshot = dict(self._running_plugins) for plugin_id, plugin in running_plugins_snapshot.items(): if not hasattr(plugin, "get_dashboard") or not ObjectUtils.check_method(plugin.get_dashboard): continue try: if not plugin.get_state(): continue # 如果是多仪表盘实现 if hasattr(plugin, "get_dashboard_meta") and ObjectUtils.check_method(plugin.get_dashboard_meta): meta = plugin.get_dashboard_meta() if meta: dashboard_meta.extend([{ "id": plugin_id, "name": m.get("name"), "key": m.get("key"), } for m in meta if m]) else: dashboard_meta.append({ "id": plugin_id, "name": plugin.plugin_name, "key": "", }) except Exception as e: logger.error(f"获取插件[{plugin_id}]仪表盘元数据出错:{str(e)}") return dashboard_meta def get_plugin_dashboard(self, pid: str, key: str, user_agent: str = None) -> schemas.PluginDashboard: """ 获取插件仪表盘 """ def __get_params_count(func: Callable): """ 获取函数的参数信息 """ signature = inspect.signature(func) return len(signature.parameters) # 获取插件实例 plugin_instance = self.running_plugins.get(pid) if not plugin_instance: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"插件 {pid} 不存在或未加载") # 渲染模式 render_mode, _ = plugin_instance.get_render_mode() # 获取插件仪表板 try: # 检查方法的参数个数 params_count = __get_params_count(plugin_instance.get_dashboard) if params_count > 1: dashboard: Tuple = plugin_instance.get_dashboard(key=key, user_agent=user_agent) elif params_count > 0: dashboard: Tuple = plugin_instance.get_dashboard(user_agent=user_agent) else: dashboard: Tuple = plugin_instance.get_dashboard() except Exception as e: logger.error(f"插件 {pid} 调用方法 get_dashboard 出错: {str(e)}") raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"插件 {pid} 调用方法 get_dashboard 出错: {str(e)}") cols, attrs, elements = dashboard return schemas.PluginDashboard( id=pid, name=plugin_instance.plugin_name, key=key, render_mode=render_mode, cols=cols or {}, attrs=attrs or {}, elements=elements ) def get_plugin_attr(self, pid: str, attr: str) -> Any: """ 获取插件属性 :param pid: 插件ID :param attr: 属性名 """ plugin = self._running_plugins.get(pid) if not plugin: return None if not hasattr(plugin, attr): return None return getattr(plugin, attr) def run_plugin_method(self, pid: str, method: str, *args, **kwargs) -> Any: """ 运行插件方法 :param pid: 插件ID :param method: 方法名 :param args: 参数 :param kwargs: 关键字参数 """ plugin = self._running_plugins.get(pid) if not plugin: return None if not hasattr(plugin, method): return None return getattr(plugin, method)(*args, **kwargs) async def async_run_plugin_method(self, pid: str, method: str, *args, **kwargs) -> Any: """ 异步运行插件方法 :param pid: 插件ID :param method: 方法名 :param args: 参数 :param kwargs: 关键字参数 """ plugin = self._running_plugins.get(pid) if not plugin: return None if not hasattr(plugin, method): return None method_func = getattr(plugin, method) if asyncio.iscoroutinefunction(method_func): return await method_func(*args, **kwargs) else: return method_func(*args, **kwargs) def get_plugin_ids(self) -> List[str]: """ 获取所有插件ID """ return list(self._plugins.keys()) def get_running_plugin_ids(self) -> List[str]: """ 获取所有运行态插件ID """ return list(self._running_plugins.keys()) def get_online_plugins(self, force: bool = False) -> List[schemas.Plugin]: """ 获取所有在线插件信息 """ if not settings.PLUGIN_MARKET: return [] # 用于存储高于 v1 版本的插件(如 v2, v3 等) higher_version_plugins = [] # 用于存储 v1 版本插件 base_version_plugins = [] # 使用多线程获取线上插件 with concurrent.futures.ThreadPoolExecutor() as executor: futures_to_version = {} for m in settings.PLUGIN_MARKET.split(","): if not m: continue # 提交任务获取 v1 版本插件,存储 future 到 version 的映射 base_future = executor.submit(self.get_plugins_from_market, m, None, force) futures_to_version[base_future] = "base_version" # 提交任务获取高版本插件(如 v2、v3),存储 future 到 version 的映射 if settings.VERSION_FLAG: higher_version_future = executor.submit(self.get_plugins_from_market, m, settings.VERSION_FLAG, force) futures_to_version[higher_version_future] = "higher_version" # 按照完成顺序处理结果 for future in concurrent.futures.as_completed(futures_to_version): plugins = future.result() version = futures_to_version[future] if plugins: if version == "higher_version": higher_version_plugins.extend(plugins) # 收集高版本插件 else: base_version_plugins.extend(plugins) # 收集 v1 版本插件 return self._process_plugins_list(higher_version_plugins, base_version_plugins) def get_local_plugins(self) -> List[schemas.Plugin]: """ 获取所有本地已下载的插件信息 """ # 返回值 plugins = [] # 已安装插件 installed_apps = SystemConfigOper().get(SystemConfigKey.UserInstalledPlugins) or [] for pid, plugin_class in self._plugins.items(): # 运行状插件 plugin_obj = self._running_plugins.get(pid) # 基本属性 plugin = schemas.Plugin() # ID plugin.id = pid # 安装状态 if pid in installed_apps: plugin.installed = True else: plugin.installed = False # 运行状态 if plugin_obj and hasattr(plugin_obj, "get_state"): try: state = plugin_obj.get_state() except Exception as e: logger.error(f"获取插件 {pid} 状态出错:{str(e)}") state = False plugin.state = state else: plugin.state = False # 是否有详情页面 if hasattr(plugin_class, "get_page"): if ObjectUtils.check_method(plugin_class.get_page): plugin.has_page = True else: plugin.has_page = False # 公钥 if hasattr(plugin_class, "plugin_public_key"): plugin.plugin_public_key = plugin_class.plugin_public_key # 权限 if not self.__set_and_check_auth_level(plugin=plugin, source=plugin_class): continue # 名称 if hasattr(plugin_class, "plugin_name"): plugin.plugin_name = plugin_class.plugin_name # 描述 if hasattr(plugin_class, "plugin_desc"): plugin.plugin_desc = plugin_class.plugin_desc # 版本 if hasattr(plugin_class, "plugin_version"): plugin.plugin_version = plugin_class.plugin_version # 图标 if hasattr(plugin_class, "plugin_icon"): plugin.plugin_icon = plugin_class.plugin_icon # 作者 if hasattr(plugin_class, "plugin_author"): plugin.plugin_author = plugin_class.plugin_author # 作者链接 if hasattr(plugin_class, "author_url"): plugin.author_url = plugin_class.author_url # 加载顺序 if hasattr(plugin_class, "plugin_order"): plugin.plugin_order = plugin_class.plugin_order # 是否需要更新 plugin.has_update = False # 本地标志 plugin.is_local = True # 汇总 plugins.append(plugin) # 根据加载排序重新排序 plugins.sort(key=lambda x: x.plugin_order if hasattr(x, "plugin_order") else 0) return plugins @staticmethod def is_plugin_exists(pid: str, version: str = None) -> bool: """ 判断插件是否存在,并满足版本要求(有传入version时) :param pid: 插件ID :param version: 插件版本 """ if not pid: return False try: # 构建包名 package_name = f"app.plugins.{pid.lower()}" # 检查包是否存在 spec = importlib.util.find_spec(package_name) package_exists = spec is not None and spec.origin is not None logger.debug(f"{pid} exists: {package_exists}") if not package_exists: return False local_version = PluginManager().get_plugin_attr(pid=pid, attr="plugin_version") if not local_version: return False if version and not StringUtils.compare_version(local_version, ">=", version): logger.warn(f"Plugin {pid} version: {local_version} (older than version: {version})") return False return True except Exception as e: logger.debug(f"获取插件是否在本地包中存在失败,{e}") return False def get_plugins_from_market(self, market: str, package_version: Optional[str] = None, force: bool = False) -> Optional[List[schemas.Plugin]]: """ 从指定的市场获取插件信息 :param market: 市场的 URL 或标识 :param package_version: 首选插件版本 (如 "v2", "v3"),如果不指定则获取 v1 版本 :param force: 是否强制刷新(忽略缓存) :return: 返回插件的列表,若获取失败返回 [] """ if not market: return [] # 已安装插件 installed_apps = SystemConfigOper().get(SystemConfigKey.UserInstalledPlugins) or [] # 获取在线插件 with fresh(force): online_plugins = PluginHelper().get_plugins(market, package_version) if online_plugins is None: logger.warning( f"获取{package_version if package_version else ''}插件库失败:{market},请检查 GitHub 网络连接") return [] ret_plugins = [] add_time = len(online_plugins) for pid, plugin_info in online_plugins.items(): plugin = self._process_plugin_info(pid, plugin_info, market, installed_apps, add_time, package_version) if plugin: ret_plugins.append(plugin) add_time -= 1 return ret_plugins @staticmethod def _process_plugins_list(higher_version_plugins: List[schemas.Plugin], base_version_plugins: List[schemas.Plugin]) -> List[schemas.Plugin]: """ 处理插件列表:合并、去重、排序、保留最高版本 :param higher_version_plugins: 高版本插件列表 :param base_version_plugins: 基础版本插件列表 :return: 处理后的插件列表 """ # 优先处理高版本插件 all_plugins = [] all_plugins.extend(higher_version_plugins) # 将未出现在高版本插件列表中的 v1 插件加入 all_plugins higher_plugin_ids = {f"{p.id}{p.plugin_version}" for p in higher_version_plugins} all_plugins.extend([p for p in base_version_plugins if f"{p.id}{p.plugin_version}" not in higher_plugin_ids]) # 去重 all_plugins = list({f"{p.id}{p.plugin_version}": p for p in all_plugins}.values()) # 所有插件按 repo 在设置中的顺序排序 all_plugins.sort( key=lambda x: settings.PLUGIN_MARKET.split(",").index(x.repo_url) if x.repo_url else 0 ) # 相同 ID 的插件保留版本号最大的版本 max_versions = {} for p in all_plugins: if p.id not in max_versions or StringUtils.compare_version(p.plugin_version, ">", max_versions[p.id]): max_versions[p.id] = p.plugin_version result = [p for p in all_plugins if p.plugin_version == max_versions[p.id]] logger.info(f"共获取到 {len(result)} 个线上插件") return result def _process_plugin_info(self, pid: str, plugin_info: dict, market: str, installed_apps: List[str], add_time: int, package_version: Optional[str] = None) -> Optional[schemas.Plugin]: """ 处理单个插件信息,创建 schemas.Plugin 对象 :param pid: 插件ID :param plugin_info: 插件信息字典 :param market: 市场URL :param installed_apps: 已安装插件列表 :param add_time: 添加顺序 :param package_version: 包版本 :return: 创建的插件对象,如果验证失败返回None """ if not isinstance(plugin_info, dict): return None # 如 package_version 为空,则需要判断插件是否兼容当前版本 if not package_version: if plugin_info.get(settings.VERSION_FLAG) is not True: # 插件当前版本不兼容 return None # 运行状插件 plugin_obj = self._running_plugins.get(pid) # 非运行态插件 plugin_static = self._plugins.get(pid) # 基本属性 plugin = schemas.Plugin() # ID plugin.id = pid # 安装状态 if pid in installed_apps and plugin_static: plugin.installed = True else: plugin.installed = False # 是否有新版本 plugin.has_update = False if plugin_static: installed_version = getattr(plugin_static, "plugin_version") if StringUtils.compare_version(installed_version, "<", plugin_info.get("version")): # 需要更新 plugin.has_update = True # 运行状态 if plugin_obj and hasattr(plugin_obj, "get_state"): try: state = plugin_obj.get_state() except Exception as e: logger.error(f"获取插件 {pid} 状态出错:{str(e)}") state = False plugin.state = state else: plugin.state = False # 是否有详情页面 plugin.has_page = False if plugin_obj and hasattr(plugin_obj, "get_page"): if ObjectUtils.check_method(plugin_obj.get_page): plugin.has_page = True # 公钥 if plugin_info.get("key"): plugin.plugin_public_key = plugin_info.get("key") # 权限 if not self.__set_and_check_auth_level(plugin=plugin, source=plugin_info): return None # 名称 if plugin_info.get("name"): plugin.plugin_name = plugin_info.get("name") # 描述 if plugin_info.get("description"): plugin.plugin_desc = plugin_info.get("description") # 版本 if plugin_info.get("version"): plugin.plugin_version = plugin_info.get("version") # 图标 if plugin_info.get("icon"): plugin.plugin_icon = plugin_info.get("icon") # 标签 if plugin_info.get("labels"): plugin.plugin_label = plugin_info.get("labels") # 作者 if plugin_info.get("author"): plugin.plugin_author = plugin_info.get("author") # 更新历史 if plugin_info.get("history"): plugin.history = plugin_info.get("history") # 仓库链接 plugin.repo_url = market # 本地标志 plugin.is_local = False # 添加顺序 plugin.add_time = add_time return plugin async def async_get_online_plugins(self, force: bool = False) -> List[schemas.Plugin]: """ 异步获取所有在线插件信息 :param force: 是否强制刷新(忽略缓存) """ if not settings.PLUGIN_MARKET: return [] # 用于存储高于 v1 版本的插件(如 v2, v3 等) higher_version_plugins = [] # 用于存储 v1 版本插件 base_version_plugins = [] # 使用异步并发获取线上插件 import asyncio tasks = [] task_to_version = {} for m in settings.PLUGIN_MARKET.split(","): if not m: continue # 创建任务获取 v1 版本插件 base_task = asyncio.create_task(self.async_get_plugins_from_market(m, None, force)) tasks.append(base_task) task_to_version[base_task] = "base_version" # 创建任务获取高版本插件(如 v2、v3) if settings.VERSION_FLAG: higher_version_task = asyncio.create_task( self.async_get_plugins_from_market(m, settings.VERSION_FLAG, force)) tasks.append(higher_version_task) task_to_version[higher_version_task] = "higher_version" # 并发执行所有任务 if tasks: completed_tasks = await asyncio.gather(*tasks, return_exceptions=True) for i, result in enumerate(completed_tasks): task = tasks[i] version = task_to_version[task] # 检查是否有异常 if isinstance(result, Exception): logger.error(f"获取插件市场数据失败:{str(result)}") continue plugins = result if plugins: if version == "higher_version": higher_version_plugins.extend(plugins) # 收集高版本插件 else: base_version_plugins.extend(plugins) # 收集 v1 版本插件 return self._process_plugins_list(higher_version_plugins, base_version_plugins) async def async_get_plugins_from_market(self, market: str, package_version: Optional[str] = None, force: bool = False) -> Optional[List[schemas.Plugin]]: """ 异步从指定的市场获取插件信息 :param market: 市场的 URL 或标识 :param package_version: 首选插件版本 (如 "v2", "v3"),如果不指定则获取 v1 版本 :param force: 是否强制刷新(忽略缓存) :return: 返回插件的列表,若获取失败返回 [] """ if not market: return [] # 已安装插件 installed_apps = SystemConfigOper().get(SystemConfigKey.UserInstalledPlugins) or [] # 获取在线插件 async with async_fresh(force): online_plugins = await PluginHelper().async_get_plugins(market, package_version) if online_plugins is None: logger.warning( f"获取{package_version if package_version else ''}插件库失败:{market},请检查 GitHub 网络连接") return [] ret_plugins = [] add_time = len(online_plugins) for pid, plugin_info in online_plugins.items(): plugin = self._process_plugin_info(pid, plugin_info, market, installed_apps, add_time, package_version) if plugin: ret_plugins.append(plugin) add_time -= 1 return ret_plugins @staticmethod def __set_and_check_auth_level(plugin: Union[schemas.Plugin, Type[Any]], source: Optional[Union[dict, Type[Any]]] = None) -> bool: """ 设置并检查插件的认证级别 :param plugin: 插件对象或包含 auth_level 属性的对象 :param source: 可选的字典对象或类对象,可能包含 "level" 或 "auth_level" 键 :return: 如果插件的认证级别有效且当前环境的认证级别满足要求,返回 True,否则返回 False """ # 检查并赋值 source 中的 level 或 auth_level if source: if isinstance(source, dict) and "level" in source: plugin.auth_level = source.get("level") elif hasattr(source, "auth_level"): plugin.auth_level = source.auth_level # 如果 source 为空且 plugin 本身没有 auth_level,直接返回 True elif not hasattr(plugin, "auth_level"): return True # auth_level 级别说明 # 1 - 所有用户可见 # 2 - 站点认证用户可见 # 3 - 站点&密钥认证可见 # 99 - 站点&特殊密钥认证可见 # 如果当前站点认证级别大于 1 且插件级别为 99,并存在插件公钥,说明为特殊密钥认证,通过密钥匹配进行认证 siteshelper = SitesHelper() if siteshelper.auth_level > 1 and plugin.auth_level == 99 and hasattr(plugin, "plugin_public_key"): plugin_id = plugin.id if isinstance(plugin, schemas.Plugin) else plugin.__name__ public_key = plugin.plugin_public_key if public_key: private_key = PluginManager.__get_plugin_private_key(plugin_id) verify = RSAUtils.verify_rsa_keys(public_key=public_key, private_key=private_key) return verify # 如果当前站点认证级别小于插件级别,则返回 False if siteshelper.auth_level < plugin.auth_level: return False return True @staticmethod def __get_plugin_private_key(plugin_id: str) -> Optional[str]: """ 根据插件标识获取对应的私钥 :param plugin_id: 插件标识 :return: 对应的插件私钥,如果未找到则返回 None """ try: # 将插件标识转换为大写并构建环境变量名称 env_var_name = f"PLUGIN_{plugin_id.upper()}_PRIVATE_KEY" private_key = os.environ.get(env_var_name) return private_key except Exception as e: logger.debug(f"获取插件 {plugin_id} 的私钥时发生错误:{e}") return None def clone_plugin(self, plugin_id: str, suffix: str, name: str, description: str, version: str = None, icon: str = None) -> Tuple[bool, str]: """ 创建插件分身 :param plugin_id: 原插件ID :param suffix: 分身后缀 :param name: 分身名称 :param description: 分身描述 :param version: 自定义版本号 :param icon: 自定义图标URL :return: (是否成功, 错误信息) """ try: # 验证参数 if not plugin_id or not suffix: return False, "插件ID和分身后缀不能为空" # 检查原插件是否存在 if plugin_id not in self._plugins: return False, f"原插件 {plugin_id} 不存在" # 生成分身插件ID clone_id = f"{plugin_id}{suffix.lower()}" # 检查分身插件是否已存在 if self.is_plugin_exists(clone_id): return False, f"分身插件 {clone_id} 已存在" # 获取原插件目录 original_plugin_dir = Path(settings.ROOT_PATH) / "app" / "plugins" / plugin_id.lower() if not original_plugin_dir.exists(): return False, f"原插件目录 {original_plugin_dir} 不存在" # 创建分身插件目录 clone_plugin_dir = Path(settings.ROOT_PATH) / "app" / "plugins" / clone_id.lower() # 复制插件目录 import shutil shutil.copytree(original_plugin_dir, clone_plugin_dir) logger.info(f"已复制插件目录:{original_plugin_dir} -> {clone_plugin_dir}") # 修改插件文件内容 success, msg = self._modify_plugin_files( plugin_dir=clone_plugin_dir, original_id=plugin_id, suffix=suffix.lower(), name=name, description=description, version=version, icon=icon ) if not success: # 如果修改失败,清理已创建的目录 if clone_plugin_dir.exists(): shutil.rmtree(clone_plugin_dir) return False, msg # 将分身插件添加到已安装列表 systemconfig = SystemConfigOper() installed_plugins = systemconfig.get(SystemConfigKey.UserInstalledPlugins) or [] if clone_id not in installed_plugins: installed_plugins.append(clone_id) systemconfig.set(SystemConfigKey.UserInstalledPlugins, installed_plugins) # 为分身插件创建初始配置(从原插件复制配置) logger.info(f"正在为分身插件 {clone_id} 创建初始配置...") original_config = self.get_plugin_config(plugin_id) if original_config: # 复制原插件配置作为分身插件的初始配置 clone_config = original_config.copy() # 可以在这里修改一些默认值,比如禁用分身插件 # 默认禁用分身插件,让用户手动配置 clone_config['enable'] = False clone_config['enabled'] = False self.save_plugin_config(clone_id, clone_config, force=True) logger.info(f"已为分身插件 {clone_id} 设置初始配置") else: logger.info(f"原插件 {plugin_id} 没有配置,分身插件 {clone_id} 将使用默认配置") # 注册分身插件的API和服务 logger.info(f"正在注册分身插件 {clone_id} ...") PluginManager().reload_plugin(clone_id) # 确保分身插件正确初始化配置 if clone_id in self._running_plugins: clone_instance = self._running_plugins[clone_id] clone_config = self.get_plugin_config(clone_id) if clone_config: logger.info(f"正在为分身插件 {clone_id} 重新初始化配置...") clone_instance.init_plugin(clone_config) logger.info(f"分身插件 {clone_id} 配置重新初始化完成") logger.info(f"插件分身 {clone_id} 创建成功") return True, clone_id except Exception as e: logger.error(f"创建插件分身失败:{str(e)}") return False, f"创建插件分身失败:{str(e)}" def _modify_plugin_files(self, plugin_dir: Path, original_id: str, suffix: str, name: str, description: str, version: str = None, icon: str = None) -> Tuple[bool, str]: """ 修改插件文件中的类名和相关信息 :param plugin_dir: 插件目录 :param original_id: 原插件ID :param suffix: 分身后缀 :param name: 分身名称 :param description: 分身描述 :param version: 自定义版本号 :param icon: 自定义图标URL :return: (是否成功, 错误信息) """ try: # 获取原插件类 original_plugin_class = self._plugins.get(original_id) if not original_plugin_class: return False, f"无法获取原插件类 {original_id}" # 获取原类名 original_class_name = original_plugin_class.__name__ clone_class_name = f"{original_class_name}{suffix}" # 修改 __init__.py 文件 init_file = plugin_dir / "__init__.py" if init_file.exists(): success, msg = self._modify_python_file( file_path=init_file, original_class_name=original_class_name, clone_class_name=clone_class_name, name=name, description=description, version=version, icon=icon ) if not success: return False, msg # 检查是否为联邦插件(存在dist目录) dist_dir = plugin_dir / "dist" if dist_dir.exists(): success, msg = self._modify_federation_files( dist_dir=dist_dir, original_class_name=original_class_name, clone_class_name=clone_class_name ) if not success: return False, msg return True, "文件修改成功" except Exception as e: logger.error(f"修改插件文件失败:{str(e)}") return False, f"修改插件文件失败:{str(e)}" @staticmethod def _modify_python_file(file_path: Path, original_class_name: str, clone_class_name: str, name: str, description: str, version: str = None, icon: str = None) -> Tuple[bool, str]: """ 修改Python文件中的类名和插件信息 """ try: with open(file_path, 'r', encoding='utf-8') as f: content = f.read() # 替换类名 content = content.replace(f"class {original_class_name}", f"class {clone_class_name}") # 替换插件名称和描述 import re # 替换 plugin_name if name: content = re.sub( r'plugin_name\s*=\s*["\'][^"\']*["\']', f'plugin_name = "{name}"', content ) # 替换 plugin_desc if description: content = re.sub( r'plugin_desc\s*=\s*["\'][^"\']*["\']', f'plugin_desc = "{description}"', content ) # 替换 plugin_config_prefix(如果存在) content = re.sub( r'plugin_config_prefix\s*=\s*["\'][^"\']*["\']', f'plugin_config_prefix = "{clone_class_name.lower()}_"', content ) # 替换 plugin_version(如果提供了自定义版本) if version: content = re.sub( r'plugin_version\s*=\s*["\'][^"\']*["\']', f'plugin_version = "{version}"', content ) # 替换 plugin_icon(如果提供了自定义图标) if icon and icon.strip(): old_content = content content = re.sub( r'plugin_icon\s*=\s*["\'][^"\']*["\']', f'plugin_icon = "{icon}"', content ) if old_content != content: logger.info(f"已替换插件图标为: {icon}") else: logger.warning(f"插件图标替换失败,未找到匹配的图标设置") else: logger.info("未提供自定义图标,保持原插件图标") # 添加分身标志 if "def init_plugin(self" in content: init_index = content.index("def init_plugin(self") # 在 def init_plugin(self 前添加 is_clone = True content = content[:init_index] + "is_clone = True\n\n " + content[init_index:] with open(file_path, 'w', encoding='utf-8') as f: f.write(content) logger.debug(f"已修改Python文件:{file_path}") return True, "Python文件修改成功" except Exception as e: logger.error(f"修改Python文件失败:{str(e)}") return False, f"修改Python文件失败:{str(e)}" def _modify_federation_files(self, dist_dir: Path, original_class_name: str, clone_class_name: str) -> Tuple[bool, str]: """ 修改联邦插件的前端文件 """ try: # 获取原始插件名(从类名推导) original_plugin_name = original_class_name clone_plugin_name = clone_class_name # 遍历dist目录下的所有文件 for file_path in dist_dir.rglob("*"): if not file_path.is_file(): continue # 处理JS文件 if file_path.suffix == '.js': try: with open(file_path, 'r', encoding='utf-8') as f: content = f.read() # 替换类名引用(精确匹配) content = content.replace(original_class_name, clone_class_name) # 替换插件名引用(如果存在) content = content.replace(f'"{original_plugin_name}"', f'"{clone_plugin_name}"') content = content.replace(f"'{original_plugin_name}'", f"'{clone_plugin_name}'") # 替换CSS key中的类名(联邦插件特有) content = content.replace(f'css__{original_class_name}__', f'css__{clone_class_name}__') # 替换可能的小写类名引用 content = content.replace(original_class_name.lower(), clone_class_name.lower()) with open(file_path, 'w', encoding='utf-8') as f: f.write(content) logger.debug(f"已修改联邦插件JS文件:{file_path}") except Exception as e: logger.warning(f"修改联邦插件文件 {file_path} 失败:{str(e)}") continue # 处理CSS文件 elif file_path.suffix == '.css': try: with open(file_path, 'r', encoding='utf-8') as f: content = f.read() # 替换CSS中可能的类名引用 content = content.replace(original_class_name.lower(), clone_class_name.lower()).replace(original_class_name, clone_class_name) with open(file_path, 'w', encoding='utf-8') as f: f.write(content) logger.debug(f"已修改联邦插件CSS文件:{file_path}") except Exception as e: logger.warning(f"修改联邦插件CSS文件 {file_path} 失败:{str(e)}") continue # 重命名构建文件(如果需要) self._rename_federation_assets(dist_dir, original_class_name, clone_class_name) return True, "联邦插件文件修改完成" except Exception as e: logger.error(f"修改联邦插件文件失败:{str(e)}") return False, f"修改联邦插件文件失败:{str(e)}" @staticmethod def _rename_federation_assets(dist_dir: Path, original_class_name: str, clone_class_name: str): """ 重命名联邦插件的资源文件,避免文件名冲突 """ try: # 查找包含原类名的文件并重命名 for file_path in dist_dir.glob("*"): if not file_path.is_file(): continue file_name = file_path.name # 如果文件名包含原类名,则重命名 if original_class_name.lower() in file_name.lower(): new_name = file_name.replace( original_class_name.lower(), clone_class_name.lower() ) new_path = file_path.parent / new_name # 避免重命名冲突 if not new_path.exists(): file_path.rename(new_path) logger.debug(f"重命名联邦插件文件:{file_name} -> {new_name}") except Exception as e: # 重命名失败不影响整体流程 logger.warning(f"重命名联邦插件资源文件失败:{str(e)}") ================================================ FILE: app/core/security.py ================================================ import base64 import datetime import hashlib import hmac import json import os import traceback from datetime import timedelta from typing import Any, Union, Annotated, Optional import jwt from Crypto.Cipher import AES from Crypto.Util.Padding import pad from cryptography.fernet import Fernet from fastapi import HTTPException, status, Security, Request, Response from fastapi.security import OAuth2PasswordBearer, APIKeyHeader, APIKeyQuery, APIKeyCookie from passlib.context import CryptContext from app import schemas from app.core.cache import cached from app.core.config import settings from app.log import logger pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") ALGORITHM = "HS256" # OAuth2PasswordBearer 用于 JWT Token 认证 oauth2_scheme_manual_error = OAuth2PasswordBearer( auto_error=False, # 禁用自动错误处理,用以支持API令牌鉴权 tokenUrl=f"{settings.API_V1_STR}/login/access-token" ) # RESOURCE TOKEN 通过 Cookie 认证 resource_token_cookie = APIKeyCookie(name=settings.PROJECT_NAME, auto_error=False, scheme_name="resource_token_cookie") # API TOKEN 通过 QUERY 认证 api_token_query = APIKeyQuery(name="token", auto_error=False, scheme_name="api_token_query") # API KEY 通过 Header 认证 api_key_header = APIKeyHeader(name="X-API-KEY", auto_error=False, scheme_name="api_key_header") # API KEY 通过 QUERY 认证 api_key_query = APIKeyQuery(name="apikey", auto_error=False, scheme_name="api_key_query") def __get_api_token( token_query: Annotated[str | None, Security(api_token_query)] = None ) -> str | None: """ 从 URL 查询参数中获取 API Token :param token_query: 从 URL 中的 `token` 查询参数获取 API Token :return: 返回获取到的 API Token,若无则返回 None """ return token_query def __get_api_key( key_query: Annotated[str | None, Security(api_key_query)] = None, key_header: Annotated[str | None, Security(api_key_header)] = None ) -> str | None: """ 从 URL 查询参数或请求头部获取 API Key,优先使用请求头 :param key_query: URL 中的 `apikey` 查询参数 :param key_header: 请求头中的 `X-API-KEY` 参数 :return: 返回从 URL 或请求头中获取的 API Key,若无则返回 None """ return key_header or key_query # 首选请求头 @cached(maxsize=1, ttl=600) def __create_superuser_token_payload() -> schemas.TokenPayload: """ 创建管理员用户的TokenPayload :return: 管理员TokenPayload """ # 延迟导入 # pylint: disable=import-outside-toplevel # pylint: disable=no-name-in-module from app.db.user_oper import UserOper from app.helper.sites import SitesHelper # noqa user = UserOper().get_by_name(settings.SUPERUSER) if not user or not user.is_superuser: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="用户权限不足", ) return schemas.TokenPayload( sub=user.id, username=user.name, super_user=user.is_superuser, level=SitesHelper().auth_level, purpose="authentication", ) def create_access_token( userid: Union[str, Any], username: str, super_user: Optional[bool] = False, expires_delta: Optional[timedelta] = None, level: Optional[int] = 1, purpose: Optional[str] = "authentication" ) -> str: """ 创建 JWT 访问令牌,包含用户 ID、用户名、是否为超级用户以及权限等级 :param userid: 用户的唯一标识符,通常是字符串或整数 :param username: 用户名,用于标识用户的账户名 :param super_user: 是否为超级用户,默认值为 False :param expires_delta: 令牌的有效期时长,如果不提供则根据用途使用默认过期时间 :param level: 用户的权限级别,默认为 1 :param purpose: 令牌的用途,"authentication" 或 "resource" :return: 编码后的 JWT 令牌字符串 :raises ValueError: 如果 expires_delta 为负数 """ if purpose == "resource": default_expire = timedelta(seconds=settings.RESOURCE_ACCESS_TOKEN_EXPIRE_SECONDS) secret_key = settings.RESOURCE_SECRET_KEY else: default_expire = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) secret_key = settings.SECRET_KEY if expires_delta is not None: if expires_delta.total_seconds() <= 0: raise ValueError("过期时间必须为正数") expire = datetime.datetime.now(datetime.UTC) + expires_delta else: expire = datetime.datetime.now(datetime.UTC) + default_expire to_encode = { "exp": expire, "iat": datetime.datetime.now(datetime.UTC), "sub": str(userid), "username": username, "super_user": super_user, "level": level, "purpose": purpose } encoded_jwt = jwt.encode(to_encode, secret_key, algorithm=ALGORITHM) return encoded_jwt def __set_or_refresh_resource_token_cookie(request: Request, response: Response, payload: schemas.TokenPayload): """ 设置资源令牌 Cookie :param request: 包含请求相关的上下文数据 :param response: 用于在服务器响应时设置 Cookie :param payload: 已通过身份验证的 TokenPayload 对象 """ resource_token = request.cookies.get(settings.PROJECT_NAME) if resource_token: # 检查令牌剩余时间 try: decoded_token = jwt.decode(resource_token, settings.RESOURCE_SECRET_KEY, algorithms=[ALGORITHM]) exp = decoded_token.get("exp") if exp: remaining_time = datetime.datetime.fromtimestamp(exp, tz=datetime.UTC) - datetime.datetime.now(datetime.UTC) # 根据剩余时长提前刷新令牌 if remaining_time < timedelta(seconds=(settings.RESOURCE_ACCESS_TOKEN_EXPIRE_SECONDS / 3)): raise jwt.ExpiredSignatureError except jwt.PyJWTError: logger.debug(f"Token error occurred. refreshing token") except Exception as e: logger.debug(f"Unexpected error occurred while decoding token: {e}") else: # 如果令牌有效且没有即将过期,则不需要刷新 return # 创建新的资源访问令牌 resource_token_expires = timedelta(seconds=settings.RESOURCE_ACCESS_TOKEN_EXPIRE_SECONDS) resource_token = create_access_token( userid=payload.sub, username=payload.username, super_user=payload.super_user, expires_delta=resource_token_expires, level=payload.level, purpose="resource" ) # 设置会话级别的 HttpOnly Cookie response.set_cookie( key=settings.PROJECT_NAME, value=resource_token, httponly=True, secure=request.url.scheme == "https", # 根据当前请求的协议设置 secure 属性 samesite="lax" # 不同浏览器对 "Strict" 的处理可能不同,设置 SameSite 为 "Lax",以平衡安全性和兼容性 ) def __verify_token(token: str, purpose: Optional[str] = "authentication") -> schemas.TokenPayload: """ 使用 JWT Token 进行身份认证并解析 Token 的内容 :param token: JWT 令牌 :param purpose: 期望的令牌用途,默认为 "authentication" :return: 包含用户身份信息的 Token 负载数据 :raises HTTPException: 如果令牌无效或用途不匹配 """ try: if purpose == "resource": secret_key = settings.RESOURCE_SECRET_KEY else: secret_key = settings.SECRET_KEY if not token: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=f"{purpose} token not found" ) payload = jwt.decode( token, secret_key, algorithms=[ALGORITHM] ) token_payload = schemas.TokenPayload(**payload) if token_payload.purpose != purpose: raise jwt.InvalidTokenError("令牌用途不匹配") return schemas.TokenPayload(**payload) except (jwt.DecodeError, jwt.InvalidTokenError, jwt.ImmatureSignatureError): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="token校验不通过", ) def verify_token( request: Request, response: Response, jwt_token: Annotated[str | None, Security(oauth2_scheme_manual_error)], api_key: Annotated[str | None, Security(__get_api_key)], api_token: Annotated[str | None, Security(__get_api_token)], ) -> schemas.TokenPayload: """ 验证 JWT 令牌并自动处理 resource_token 写入 如果缺少JWT令牌再尝试用API令牌鉴权 :param request: 请求对象,用于访问 Cookie 和请求信息 :param response: 响应对象,用于设置 Cookie :param jwt_token: 从 Authorization 头部获取的 JWT 令牌 :param api_key: 从 查询参数`apikey` 或 请求头`X-API-KEY` 获取 API Token :param api_token: 从 查询参数`token` 获取 API Token :return: 解析后的 TokenPayload :raises HTTPException: 如果令牌无效或用途不匹配 """ if jwt_token: # 验证并解析 JWT 认证令牌 payload = __verify_token(token=jwt_token, purpose="authentication") # 如果没有 resource_token,生成并写入到 Cookie __set_or_refresh_resource_token_cookie(request, response, payload) return payload elif api_key: verify_apikey(api_key) return __create_superuser_token_payload() elif api_token: verify_apitoken(api_token) return __create_superuser_token_payload() else: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated", headers={"WWW-Authenticate": "Bearer"}, ) def verify_resource_token( resource_token: Annotated[str, Security(resource_token_cookie)] ) -> schemas.TokenPayload: """ 验证资源访问令牌(从 Cookie 中获取) :param resource_token: 从 Cookie 中获取的资源访问令牌 :return: 解析后的 TokenPayload :raises HTTPException: 如果资源访问令牌无效 """ # 验证并解析资源访问令牌 return __verify_token(token=resource_token, purpose="resource") def __verify_key(key: str | None, expected_key: str, key_type: str) -> str: """ 通用的 API Key 或 Token 验证函数 :param key: 从请求中获取的 API Key 或 Token :param expected_key: 系统配置中的期望值,用于验证的 API Key 或 Token :param key_type: 键的类型(例如 "API_KEY" 或 "API_TOKEN"),用于错误消息 :return: 返回校验通过的 API Key 或 Token :raises HTTPException: 如果校验不通过,抛出 401 错误 """ if not key or key != expected_key: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail=f"{key_type} 校验不通过" ) return key def verify_apitoken(token: Annotated[str | None, Security(__get_api_token)]) -> str: """ 使用 API Token 进行身份认证 :param token: API Token,从 URL 查询参数中获取 token=xxx :return: 返回校验通过的 API Token """ return __verify_key(token, settings.API_TOKEN, "token") def verify_apikey(apikey: Annotated[str | None, Security(__get_api_key)]) -> str: """ 使用 API Key 进行身份认证 :param apikey: API Key,从 URL 查询参数中获取 apikey=xxx,或请求头中获取 X-API-KEY=xxx :return: 返回校验通过的 API Key """ return __verify_key(apikey, settings.API_TOKEN, "apikey") def verify_password(plain_password: str, hashed_password: str) -> bool: return pwd_context.verify(plain_password, hashed_password) def get_password_hash(password: str) -> str: return pwd_context.hash(password) def decrypt(data: bytes, key: bytes) -> Optional[bytes]: """ 解密二进制数据 """ fernet = Fernet(key) try: return fernet.decrypt(data) except Exception as e: logger.error(f"解密失败:{str(e)} - {traceback.format_exc()}") return None def encrypt_message(message: str, key: bytes) -> str: """ 使用给定的key对消息进行加密,并返回加密后的字符串 """ f = Fernet(key) encrypted_message = f.encrypt(message.encode()) return encrypted_message.decode() def hash_sha256(message: str) -> str: """ 对字符串做hash运算 """ return hashlib.sha256(message.encode()).hexdigest() def aes_decrypt(data: str, key: str) -> str: """ AES解密 """ if not data: return "" data = base64.b64decode(data) iv = data[:16] encrypted = data[16:] # 使用AES-256-CBC解密 cipher = AES.new(key.encode('utf-8'), AES.MODE_CBC, iv) result = cipher.decrypt(encrypted) # 去除填充 padding = result[-1] if padding < 1 or padding > AES.block_size: return "" result = result[:-padding] return result.decode('utf-8') def aes_encrypt(data: str, key: str) -> str: """ AES加密 """ if not data: return "" # 使用AES-256-CBC加密 cipher = AES.new(key.encode('utf-8'), AES.MODE_CBC) # 填充 padding = AES.block_size - len(data) % AES.block_size data += chr(padding) * padding result = cipher.encrypt(data.encode('utf-8')) # 使用base64编码 return base64.b64encode(cipher.iv + result).decode('utf-8') def nexusphp_encrypt(data_str: str, key: bytes) -> str: """ NexusPHP加密 """ # 生成16字节长的随机字符串 iv = os.urandom(16) # 对向量进行 Base64 编码 iv_base64 = base64.b64encode(iv) # 加密数据 cipher = AES.new(key, AES.MODE_CBC, iv) ciphertext = cipher.encrypt(pad(data_str.encode(), AES.block_size)) ciphertext_base64 = base64.b64encode(ciphertext) # 对向量的字符串表示进行签名 mac = hmac.new(key, msg=iv_base64 + ciphertext_base64, digestmod=hashlib.sha256).hexdigest() # 构造 JSON 字符串 json_str = json.dumps({ 'iv': iv_base64.decode(), 'value': ciphertext_base64.decode(), 'mac': mac, 'tag': '' }) # 对 JSON 字符串进行 Base64 编码 return base64.b64encode(json_str.encode()).decode() ================================================ FILE: app/db/__init__.py ================================================ import asyncio from typing import Any, Generator, List, Optional, Self, Tuple, AsyncGenerator, Union from sqlalchemy import NullPool, QueuePool, and_, create_engine, inspect, text, select, delete, Column, Integer, \ Sequence, Identity from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker from sqlalchemy.orm import Session, as_declarative, declared_attr, scoped_session, sessionmaker from app.core.config import settings def get_id_column(): """ 根据数据库类型返回合适的ID列定义 """ if settings.DB_TYPE.lower() == "postgresql": # PostgreSQL使用SERIAL类型,让数据库自动处理序列 return Column(Integer, Identity(start=1, cycle=True), primary_key=True, index=True) else: # SQLite使用Sequence return Column(Integer, Sequence('id'), primary_key=True, index=True) def _get_database_engine(is_async: bool = False): """ 获取数据库连接参数并设置WAL模式 :param is_async: 是否创建异步引擎,True - 异步引擎, False - 同步引擎 :return: 返回对应的数据库引擎 """ # 根据数据库类型选择连接方式 if settings.DB_TYPE.lower() == "postgresql": return _get_postgresql_engine(is_async) else: return _get_sqlite_engine(is_async) def _get_sqlite_engine(is_async: bool = False): """ 获取SQLite数据库引擎 """ # 连接参数 _connect_args = { "timeout": settings.DB_TIMEOUT, } # 启用 WAL 模式时的额外配置 if settings.DB_WAL_ENABLE: _connect_args["check_same_thread"] = False # 创建同步引擎 if not is_async: # 根据池类型设置 poolclass 和相关参数 _pool_class = NullPool if settings.DB_POOL_TYPE == "NullPool" else QueuePool # 数据库参数 _db_kwargs = { "url": f"sqlite:///{settings.CONFIG_PATH}/user.db", "pool_pre_ping": settings.DB_POOL_PRE_PING, "echo": settings.DB_ECHO, "poolclass": _pool_class, "pool_recycle": settings.DB_POOL_RECYCLE, "connect_args": _connect_args } # 当使用 QueuePool 时,添加 QueuePool 特有的参数 if _pool_class == QueuePool: _db_kwargs.update({ "pool_size": settings.DB_SQLITE_POOL_SIZE, "pool_timeout": settings.DB_POOL_TIMEOUT, "max_overflow": settings.DB_SQLITE_MAX_OVERFLOW }) # 创建数据库引擎 engine = create_engine(**_db_kwargs) # 设置WAL模式 _journal_mode = "WAL" if settings.DB_WAL_ENABLE else "DELETE" with engine.connect() as connection: current_mode = connection.execute(text(f"PRAGMA journal_mode={_journal_mode};")).scalar() print(f"SQLite database journal mode set to: {current_mode}") return engine else: # 数据库参数,只能使用 NullPool _db_kwargs = { "url": f"sqlite+aiosqlite:///{settings.CONFIG_PATH}/user.db", "pool_pre_ping": settings.DB_POOL_PRE_PING, "echo": settings.DB_ECHO, "poolclass": NullPool, "pool_recycle": settings.DB_POOL_RECYCLE, "connect_args": _connect_args } # 创建异步数据库引擎 async_engine = create_async_engine(**_db_kwargs) # 设置WAL模式 _journal_mode = "WAL" if settings.DB_WAL_ENABLE else "DELETE" async def set_async_wal_mode(): """ 设置异步引擎的WAL模式 """ async with async_engine.connect() as _connection: result = await _connection.execute(text(f"PRAGMA journal_mode={_journal_mode};")) _current_mode = result.scalar() print(f"Async SQLite database journal mode set to: {_current_mode}") try: asyncio.run(set_async_wal_mode()) except Exception as e: print(f"Failed to set async SQLite WAL mode: {e}") return async_engine def _get_postgresql_engine(is_async: bool = False): """ 获取PostgreSQL数据库引擎 """ # 构建PostgreSQL连接URL if settings.DB_POSTGRESQL_PASSWORD: db_url = f"postgresql://{settings.DB_POSTGRESQL_USERNAME}:{settings.DB_POSTGRESQL_PASSWORD}@{settings.DB_POSTGRESQL_HOST}:{settings.DB_POSTGRESQL_PORT}/{settings.DB_POSTGRESQL_DATABASE}" else: db_url = f"postgresql://{settings.DB_POSTGRESQL_USERNAME}@{settings.DB_POSTGRESQL_HOST}:{settings.DB_POSTGRESQL_PORT}/{settings.DB_POSTGRESQL_DATABASE}" # PostgreSQL连接参数 _connect_args = {} # 创建同步引擎 if not is_async: # 根据池类型设置 poolclass 和相关参数 _pool_class = NullPool if settings.DB_POOL_TYPE == "NullPool" else QueuePool # 数据库参数 _db_kwargs = { "url": db_url, "pool_pre_ping": settings.DB_POOL_PRE_PING, "echo": settings.DB_ECHO, "poolclass": _pool_class, "pool_recycle": settings.DB_POOL_RECYCLE, "connect_args": _connect_args } # 当使用 QueuePool 时,添加 QueuePool 特有的参数 if _pool_class == QueuePool: _db_kwargs.update({ "pool_size": settings.DB_POSTGRESQL_POOL_SIZE, "pool_timeout": settings.DB_POOL_TIMEOUT, "max_overflow": settings.DB_POSTGRESQL_MAX_OVERFLOW }) # 创建数据库引擎 engine = create_engine(**_db_kwargs) print(f"PostgreSQL database connected to {settings.DB_POSTGRESQL_HOST}:{settings.DB_POSTGRESQL_PORT}/{settings.DB_POSTGRESQL_DATABASE}") return engine else: # 构建异步PostgreSQL连接URL async_db_url = f"postgresql+asyncpg://{settings.DB_POSTGRESQL_USERNAME}:{settings.DB_POSTGRESQL_PASSWORD}@{settings.DB_POSTGRESQL_HOST}:{settings.DB_POSTGRESQL_PORT}/{settings.DB_POSTGRESQL_DATABASE}" # 数据库参数,只能使用 NullPool _db_kwargs = { "url": async_db_url, "pool_pre_ping": settings.DB_POOL_PRE_PING, "echo": settings.DB_ECHO, "poolclass": NullPool, "pool_recycle": settings.DB_POOL_RECYCLE, "connect_args": _connect_args } # 创建异步数据库引擎 async_engine = create_async_engine(**_db_kwargs) print(f"Async PostgreSQL database connected to {settings.DB_POSTGRESQL_HOST}:{settings.DB_POSTGRESQL_PORT}/{settings.DB_POSTGRESQL_DATABASE}") return async_engine # 同步数据库引擎 Engine = _get_database_engine(is_async=False) # 异步数据库引擎 AsyncEngine = _get_database_engine(is_async=True) # 同步会话工厂 SessionFactory = sessionmaker(bind=Engine) # 异步会话工厂 AsyncSessionFactory = async_sessionmaker(bind=AsyncEngine, class_=AsyncSession) # 同步多线程全局使用的数据库会话 ScopedSession = scoped_session(SessionFactory) def get_db() -> Generator: """ 获取数据库会话,用于WEB请求 :return: Session """ db = None try: db = SessionFactory() yield db finally: if db: db.close() async def get_async_db() -> AsyncGenerator[AsyncSession, None]: """ 获取异步数据库会话,用于WEB请求 :return: AsyncSession """ async with AsyncSessionFactory() as session: try: yield session finally: await session.close() async def close_database(): """ 关闭所有数据库连接并清理资源 """ try: # 释放同步连接池 Engine.dispose() # noqa # 释放异步连接池 await AsyncEngine.dispose() except Exception as err: print(f"Error while disposing database connections: {err}") def _get_args_db(args: tuple, kwargs: dict) -> Optional[Session]: """ 从参数中获取数据库Session对象 """ db = None if args: for arg in args: if isinstance(arg, Session): db = arg break if kwargs: for key, value in kwargs.items(): if isinstance(value, Session): db = value break return db def _get_args_async_db(args: tuple, kwargs: dict) -> Optional[AsyncSession]: """ 从参数中获取异步数据库AsyncSession对象 """ db = None if args: for arg in args: if isinstance(arg, AsyncSession): db = arg break if kwargs: for key, value in kwargs.items(): if isinstance(value, AsyncSession): db = value break return db def _update_args_db(args: tuple, kwargs: dict, db: Session) -> Tuple[tuple, dict]: """ 更新参数中的数据库Session对象,关键字传参时更新db的值,否则更新第1或第2个参数 """ if kwargs and 'db' in kwargs: kwargs['db'] = db elif args: if args[0] is None: args = (db, *args[1:]) else: args = (args[0], db, *args[2:]) return args, kwargs def _update_args_async_db(args: tuple, kwargs: dict, db: AsyncSession) -> Tuple[tuple, dict]: """ 更新参数中的异步数据库AsyncSession对象,关键字传参时更新db的值,否则更新第1或第2个参数 """ if kwargs and 'db' in kwargs: kwargs['db'] = db elif args: if args[0] is None: args = (db, *args[1:]) else: args = (args[0], db, *args[2:]) return args, kwargs def db_update(func): """ 数据库更新类操作装饰器,第一个参数必须是数据库会话或存在db参数 """ def wrapper(*args, **kwargs): # 是否关闭数据库会话 _close_db = False # 从参数中获取数据库会话 db = _get_args_db(args, kwargs) if not db: # 如果没有获取到数据库会话,创建一个 db = ScopedSession() # 标记需要关闭数据库会话 _close_db = True # 更新参数中的数据库会话 args, kwargs = _update_args_db(args, kwargs, db) try: # 执行函数 result = func(*args, **kwargs) # 提交事务 db.commit() except Exception as err: # 回滚事务 db.rollback() raise err finally: # 关闭数据库会话 if _close_db: db.close() return result return wrapper def async_db_update(func): """ 异步数据库更新类操作装饰器,第一个参数必须是异步数据库会话或存在db参数 """ async def wrapper(*args, **kwargs): # 是否关闭数据库会话 _close_db = False # 从参数中获取异步数据库会话 db = _get_args_async_db(args, kwargs) if not db: # 如果没有获取到异步数据库会话,创建一个 db = AsyncSessionFactory() # 标记需要关闭数据库会话 _close_db = True # 更新参数中的异步数据库会话 args, kwargs = _update_args_async_db(args, kwargs, db) try: # 执行函数 result = await func(*args, **kwargs) # 提交事务 await db.commit() except Exception as err: # 回滚事务 await db.rollback() raise err finally: # 关闭数据库会话 if _close_db: await db.close() return result return wrapper def db_query(func): """ 数据库查询操作装饰器,第一个参数必须是数据库会话或存在db参数 注意:db.query列表数据时,需要转换为list返回 """ def wrapper(*args, **kwargs): # 是否关闭数据库会话 _close_db = False # 从参数中获取数据库会话 db = _get_args_db(args, kwargs) if not db: # 如果没有获取到数据库会话,创建一个 db = ScopedSession() # 标记需要关闭数据库会话 _close_db = True # 更新参数中的数据库会话 args, kwargs = _update_args_db(args, kwargs, db) try: # 执行函数 result = func(*args, **kwargs) except Exception as err: raise err finally: # 关闭数据库会话 if _close_db: db.close() return result return wrapper def async_db_query(func): """ 异步数据库查询操作装饰器,第一个参数必须是异步数据库会话或存在db参数 注意:db.query列表数据时,需要转换为list返回 """ async def wrapper(*args, **kwargs): # 是否关闭数据库会话 _close_db = False # 从参数中获取异步数据库会话 db = _get_args_async_db(args, kwargs) if not db: # 如果没有获取到异步数据库会话,创建一个 db = AsyncSessionFactory() # 标记需要关闭数据库会话 _close_db = True # 更新参数中的异步数据库会话 args, kwargs = _update_args_async_db(args, kwargs, db) try: # 执行函数 result = await func(*args, **kwargs) except Exception as err: raise err finally: # 关闭数据库会话 if _close_db: await db.close() return result return wrapper @as_declarative() class Base: id: Any __name__: str @db_update def create(self, db: Session): db.add(self) @async_db_update async def async_create(self, db: AsyncSession): db.add(self) await db.flush() return self @classmethod @db_query def get(cls, db: Session, rid: int) -> Self: return db.query(cls).filter(and_(cls.id == rid)).first() @classmethod @async_db_query async def async_get(cls, db: AsyncSession, rid: int) -> Self: result = await db.execute(select(cls).where(and_(cls.id == rid))) return result.scalars().first() @db_update def update(self, db: Session, payload: dict): for key, value in payload.items(): setattr(self, key, value) if inspect(self).detached: db.add(self) @async_db_update async def async_update(self, db: AsyncSession, payload: dict): for key, value in payload.items(): setattr(self, key, value) if inspect(self).detached: db.add(self) @classmethod @db_update def delete(cls, db: Session, rid): db.query(cls).filter(and_(cls.id == rid)).delete() @classmethod @async_db_update async def async_delete(cls, db: AsyncSession, rid): result = await db.execute(select(cls).where(and_(cls.id == rid))) user = result.scalars().first() if user: await db.delete(user) @classmethod @db_update def truncate(cls, db: Session): db.query(cls).delete() @classmethod @async_db_update async def async_truncate(cls, db: AsyncSession): await db.execute(delete(cls)) @classmethod @db_query def list(cls, db: Session) -> List[Self]: return db.query(cls).all() @classmethod @async_db_query async def async_list(cls, db: AsyncSession) -> Sequence[Self]: result = await db.execute(select(cls)) return result.scalars().all() def to_dict(self): return {c.name: getattr(self, c.name, None) for c in self.__table__.columns} # noqa @declared_attr def __tablename__(self) -> str: return self.__name__.lower() class DbOper: """ 数据库操作基类 """ def __init__(self, db: Union[Session, AsyncSession] = None): self._db = db ================================================ FILE: app/db/downloadhistory_oper.py ================================================ from typing import List, Optional from app.db import DbOper from app.db.models.downloadhistory import DownloadHistory, DownloadFiles class DownloadHistoryOper(DbOper): """ 下载历史管理 """ def get_by_path(self, path: str) -> DownloadHistory: """ 按路径查询下载记录 :param path: 数据key """ return DownloadHistory.get_by_path(self._db, path) def get_by_hash(self, download_hash: str) -> DownloadHistory: """ 按Hash查询下载记录 :param download_hash: 数据key """ return DownloadHistory.get_by_hash(self._db, download_hash) def get_by_mediaid(self, tmdbid: int, doubanid: str) -> List[DownloadHistory]: """ 按媒体ID查询下载记录 :param tmdbid: tmdbid :param doubanid: doubanid """ return DownloadHistory.get_by_mediaid(self._db, tmdbid=tmdbid, doubanid=doubanid) def add(self, **kwargs): """ 新增下载历史 """ DownloadHistory(**kwargs).create(self._db) def add_files(self, file_items: List[dict]): """ 新增下载历史文件 """ for file_item in file_items: downloadfile = DownloadFiles(**file_item) downloadfile.create(self._db) def truncate_files(self): """ 清空下载历史文件记录 """ DownloadFiles.truncate(self._db) def get_files_by_hash(self, download_hash: str, state: Optional[int] = None) -> List[DownloadFiles]: """ 按Hash查询下载文件记录 :param download_hash: 数据key :param state: 删除状态 """ return DownloadFiles.get_by_hash(self._db, download_hash, state) def get_file_by_fullpath(self, fullpath: str) -> DownloadFiles: """ 按fullpath查询下载文件记录 :param fullpath: 数据key """ return DownloadFiles.get_by_fullpath(self._db, fullpath=fullpath, all_files=False) def get_files_by_fullpath(self, fullpath: str) -> List[DownloadFiles]: """ 按fullpath查询下载文件记录 :param fullpath: 数据key """ return DownloadFiles.get_by_fullpath(self._db, fullpath=fullpath, all_files=True) def get_files_by_savepath(self, fullpath: str) -> List[DownloadFiles]: """ 按savepath查询下载文件记录 :param fullpath: 数据key """ return DownloadFiles.get_by_savepath(self._db, fullpath) def delete_file_by_fullpath(self, fullpath: str): """ 按fullpath删除下载文件记录 :param fullpath: 数据key """ DownloadFiles.delete_by_fullpath(self._db, fullpath) def get_hash_by_fullpath(self, fullpath: str) -> str: """ 按fullpath查询下载文件记录hash :param fullpath: 数据key """ fileinfo: DownloadFiles = DownloadFiles.get_by_fullpath(self._db, fullpath=fullpath, all_files=False) if fileinfo: return fileinfo.download_hash return "" def list_by_page(self, page: Optional[int] = 1, count: Optional[int] = 30) -> List[DownloadHistory]: """ 分页查询下载历史 """ return DownloadHistory.list_by_page(self._db, page, count) def truncate(self): """ 清空下载记录 """ DownloadHistory.truncate(self._db) def get_last_by(self, mtype=None, title: Optional[str] = None, year: Optional[str] = None, season: Optional[str] = None, episode: Optional[str] = None, tmdbid=None) -> List[DownloadHistory]: """ 按类型、标题、年份、季集查询下载记录 tmdbid + mtype 或 title + year """ return DownloadHistory.get_last_by(db=self._db, mtype=mtype, title=title, year=year, season=season, episode=episode, tmdbid=tmdbid) def list_by_user_date(self, date: str, username: Optional[str] = None) -> List[DownloadHistory]: """ 查询某用户某时间之前的下载历史 """ return DownloadHistory.list_by_user_date(db=self._db, date=date, username=username) def list_by_date(self, date: str, type: str, tmdbid: str, seasons: Optional[str] = None) -> List[DownloadHistory]: """ 查询某时间之后的下载历史 """ return DownloadHistory.list_by_date(db=self._db, date=date, type=type, tmdbid=tmdbid, seasons=seasons) def list_by_type(self, mtype: str, days: Optional[int] = 7) -> List[DownloadHistory]: """ 获取指定类型的下载历史 """ return DownloadHistory.list_by_type(db=self._db, mtype=mtype, days=days) def delete_history(self, historyid): """ 删除下载记录 """ DownloadHistory.delete(self._db, historyid) def delete_downloadfile(self, downloadfileid): """ 删除下载文件记录 """ DownloadFiles.delete(self._db, downloadfileid) ================================================ FILE: app/db/init.py ================================================ from alembic.command import upgrade from alembic.config import Config from app.core.config import settings from app.db import Engine, Base from app.log import logger def init_db(): """ 初始化数据库 """ # 全量建表 Base.metadata.create_all(bind=Engine) # noqa def update_db(): """ 更新数据库 """ script_location = settings.ROOT_PATH / 'database' try: alembic_cfg = Config() alembic_cfg.set_main_option('script_location', str(script_location)) # 根据数据库类型设置不同的URL if settings.DB_TYPE.lower() == "postgresql": if settings.DB_POSTGRESQL_PASSWORD: db_url = f"postgresql://{settings.DB_POSTGRESQL_USERNAME}:{settings.DB_POSTGRESQL_PASSWORD}@{settings.DB_POSTGRESQL_HOST}:{settings.DB_POSTGRESQL_PORT}/{settings.DB_POSTGRESQL_DATABASE}" else: db_url = f"postgresql://{settings.DB_POSTGRESQL_USERNAME}@{settings.DB_POSTGRESQL_HOST}:{settings.DB_POSTGRESQL_PORT}/{settings.DB_POSTGRESQL_DATABASE}" else: db_location = settings.CONFIG_PATH / 'user.db' db_url = f"sqlite:///{db_location}" alembic_cfg.set_main_option('sqlalchemy.url', db_url) upgrade(alembic_cfg, 'head') except Exception as e: logger.error(f'数据库更新失败:{str(e)}') ================================================ FILE: app/db/mediaserver_oper.py ================================================ from typing import Optional from sqlalchemy.orm import Session from app.db import DbOper from app.db.models.mediaserver import MediaServerItem class MediaServerOper(DbOper): """ 媒体服务器数据管理 """ def __init__(self, db: Session = None): super().__init__(db) def add(self, **kwargs) -> bool: """ 新增媒体服务器数据 """ # MediaServerItem中没有的属性剔除 kwargs = {k: v for k, v in kwargs.items() if hasattr(MediaServerItem, k)} item = MediaServerItem(**kwargs) if not item.get_by_itemid(self._db, kwargs.get("item_id")): item.create(self._db) return True return False def empty(self, server: Optional[str] = None): """ 清空媒体服务器数据 """ MediaServerItem.empty(self._db, server) def exists(self, **kwargs) -> Optional[MediaServerItem]: """ 判断媒体服务器数据是否存在 """ if kwargs.get("tmdbid"): # 优先按TMDBID查 item = MediaServerItem.exist_by_tmdbid(self._db, tmdbid=kwargs.get("tmdbid"), mtype=kwargs.get("mtype")) elif kwargs.get("title"): # 按标题、类型、年份查 item = MediaServerItem.exists_by_title(self._db, title=kwargs.get("title"), mtype=kwargs.get("mtype"), year=kwargs.get("year")) else: return None if not item: return None if kwargs.get("season") is not None: # 判断季是否存在 if not item.seasoninfo: return None seasoninfo = item.seasoninfo or {} if kwargs.get("season") not in seasoninfo.keys(): return None return item async def async_exists(self, **kwargs) -> Optional[MediaServerItem]: """ 异步判断媒体服务器数据是否存在 """ if kwargs.get("tmdbid"): # 优先按TMDBID查 item = await MediaServerItem.async_exist_by_tmdbid(self._db, tmdbid=kwargs.get("tmdbid"), mtype=kwargs.get("mtype")) elif kwargs.get("title"): # 按标题、类型、年份查 item = await MediaServerItem.async_exists_by_title(self._db, title=kwargs.get("title"), mtype=kwargs.get("mtype"), year=kwargs.get("year")) else: return None if not item: return None if kwargs.get("season") is not None: # 判断季是否存在 if not item.seasoninfo: return None seasoninfo = item.seasoninfo or {} if kwargs.get("season") not in seasoninfo.keys(): return None return item def get_item_id(self, **kwargs) -> Optional[str]: """ 获取媒体服务器数据ID """ item = self.exists(**kwargs) if not item: return None return str(item.item_id) async def async_get_item_id(self, **kwargs) -> Optional[str]: """ 异步获取媒体服务器数据ID """ item = await self.async_exists(**kwargs) if not item: return None return str(item.item_id) ================================================ FILE: app/db/message_oper.py ================================================ import time from typing import Optional, Union from sqlalchemy.orm import Session from app.db import DbOper from app.db.models.message import Message from app.schemas import MessageChannel, NotificationType class MessageOper(DbOper): """ 消息数据管理 """ def __init__(self, db: Session = None): super().__init__(db) def add(self, channel: MessageChannel = None, source: Optional[str] = None, mtype: NotificationType = None, title: Optional[str] = None, text: Optional[str] = None, image: Optional[str] = None, link: Optional[str] = None, userid: Optional[str] = None, action: Optional[int] = 1, note: Union[list, dict] = None, **kwargs): """ 新增消息 :param channel: 消息渠道 :param source: 来源 :param mtype: 消息类型 :param title: 标题 :param text: 文本内容 :param image: 图片 :param link: 链接 :param userid: 用户ID :param action: 消息方向:0-接收息,1-发送消息 :param note: 附件json """ kwargs.update({ "channel": channel.value if channel else '', "source": source, "mtype": mtype.value if mtype else '', "title": title, "text": text, "image": image, "link": link, "userid": userid, "action": action, "reg_time": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()), "note": note or {} }) # 从kwargs中去掉Message中没有的字段 for k in list(kwargs.keys()): if k not in Message.__table__.columns.keys(): # noqa kwargs.pop(k) Message(**kwargs).create(self._db) async def async_add(self, channel: MessageChannel = None, source: Optional[str] = None, mtype: NotificationType = None, title: Optional[str] = None, text: Optional[str] = None, image: Optional[str] = None, link: Optional[str] = None, userid: Optional[str] = None, action: Optional[int] = 1, note: Union[list, dict] = None, **kwargs): """ 异步新增消息 """ kwargs.update({ "channel": channel.value if channel else '', "source": source, "mtype": mtype.value if mtype else '', "title": title, "text": text, "image": image, "link": link, "userid": userid, "action": action, "reg_time": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()), "note": note or {} }) # 从kwargs中去掉Message中没有的字段 for k in list(kwargs.keys()): if k not in Message.__table__.columns.keys(): # noqa kwargs.pop(k) await Message(**kwargs).async_create(self._db) def list_by_page(self, page: Optional[int] = 1, count: Optional[int] = 30) -> Optional[str]: """ 获取媒体服务器数据ID """ return Message.list_by_page(self._db, page, count) ================================================ FILE: app/db/models/__init__.py ================================================ from .downloadhistory import DownloadHistory, DownloadFiles from .mediaserver import MediaServerItem from .passkey import PassKey from .plugindata import PluginData from .site import Site from .siteicon import SiteIcon from .subscribe import Subscribe from .systemconfig import SystemConfig from .transferhistory import TransferHistory from .user import User from .userconfig import UserConfig from .workflow import Workflow ================================================ FILE: app/db/models/downloadhistory.py ================================================ import time from typing import Optional from sqlalchemy import Column, Integer, String, JSON, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import Session from app.db import db_query, db_update, get_id_column, Base, async_db_query class DownloadHistory(Base): """ 下载历史记录 """ id = get_id_column() # 保存路径 path = Column(String, nullable=False, index=True) # 类型 电影/电视剧 type = Column(String, nullable=False) # 标题 title = Column(String, nullable=False) # 年份 year = Column(String) tmdbid = Column(Integer, index=True) imdbid = Column(String) tvdbid = Column(Integer) doubanid = Column(String) # Sxx seasons = Column(String) # Exx episodes = Column(String) # 海报 image = Column(String) # 下载器 downloader = Column(String) # 下载任务Hash download_hash = Column(String, index=True) # 种子名称 torrent_name = Column(String) # 种子描述 torrent_description = Column(String) # 种子站点 torrent_site = Column(String) # 下载用户 userid = Column(String) # 下载用户名/插件名 username = Column(String) # 下载渠道 channel = Column(String) # 创建时间 date = Column(String) # 附加信息 note = Column(JSON) # 自定义媒体类别 media_category = Column(String) # 剧集组 episode_group = Column(String) # 自定义识别词(用于整理时应用) custom_words = Column(String) @classmethod @db_query def get_by_hash(cls, db: Session, download_hash: str): return db.query(DownloadHistory).filter(DownloadHistory.download_hash == download_hash).order_by( DownloadHistory.date.desc() ).first() @classmethod @db_query def get_by_mediaid(cls, db: Session, tmdbid: int, doubanid: str): if tmdbid: return db.query(DownloadHistory).filter(DownloadHistory.tmdbid == tmdbid).all() elif doubanid: return db.query(DownloadHistory).filter(DownloadHistory.doubanid == doubanid).all() return [] @classmethod @db_query def list_by_page(cls, db: Session, page: Optional[int] = 1, count: Optional[int] = 30): return db.query(DownloadHistory).offset((page - 1) * count).limit(count).all() @classmethod @async_db_query async def async_list_by_page(cls, db: AsyncSession, page: Optional[int] = 1, count: Optional[int] = 30): result = await db.execute( select(cls).offset((page - 1) * count).limit(count) ) return result.scalars().all() @classmethod @db_query def get_by_path(cls, db: Session, path: str): return db.query(DownloadHistory).filter(DownloadHistory.path == path).first() @classmethod @db_query def get_last_by(cls, db: Session, mtype: Optional[str] = None, title: Optional[str] = None, year: Optional[str] = None, season: Optional[str] = None, episode: Optional[str] = None, tmdbid: Optional[int] = None): """ 据tmdbid、season、season_episode查询下载记录 tmdbid + mtype 或 title + year """ # TMDBID + 类型 if tmdbid and mtype: # 电视剧某季某集 if season is not None and episode: return db.query(DownloadHistory).filter(DownloadHistory.tmdbid == tmdbid, DownloadHistory.type == mtype, DownloadHistory.seasons == season, DownloadHistory.episodes == episode).order_by( DownloadHistory.id.desc()).all() # 电视剧某季 elif season is not None: return db.query(DownloadHistory).filter(DownloadHistory.tmdbid == tmdbid, DownloadHistory.type == mtype, DownloadHistory.seasons == season).order_by( DownloadHistory.id.desc()).all() else: # 电视剧所有季集/电影 return db.query(DownloadHistory).filter(DownloadHistory.tmdbid == tmdbid, DownloadHistory.type == mtype).order_by( DownloadHistory.id.desc()).all() # 标题 + 年份 elif title and year: # 电视剧某季某集 if season is not None and episode: return db.query(DownloadHistory).filter(DownloadHistory.title == title, DownloadHistory.year == year, DownloadHistory.seasons == season, DownloadHistory.episodes == episode).order_by( DownloadHistory.id.desc()).all() # 电视剧某季 elif season is not None: return db.query(DownloadHistory).filter(DownloadHistory.title == title, DownloadHistory.year == year, DownloadHistory.seasons == season).order_by( DownloadHistory.id.desc()).all() else: # 电视剧所有季集/电影 return db.query(DownloadHistory).filter(DownloadHistory.title == title, DownloadHistory.year == year).order_by( DownloadHistory.id.desc()).all() return [] @classmethod @db_query def list_by_user_date(cls, db: Session, date: str, username: Optional[str] = None): """ 查询某用户某时间之后的下载历史 """ if username: return db.query(DownloadHistory).filter(DownloadHistory.date < date, DownloadHistory.username == username).order_by( DownloadHistory.id.desc()).all() else: return db.query(DownloadHistory).filter(DownloadHistory.date < date).order_by( DownloadHistory.id.desc()).all() @classmethod @db_query def list_by_date(cls, db: Session, date: str, type: str, tmdbid: str, seasons: Optional[str] = None): """ 查询某时间之后的下载历史 """ if seasons: return db.query(DownloadHistory).filter(DownloadHistory.date > date, DownloadHistory.type == type, DownloadHistory.tmdbid == tmdbid, DownloadHistory.seasons == seasons).order_by( DownloadHistory.id.desc()).all() else: return db.query(DownloadHistory).filter(DownloadHistory.date > date, DownloadHistory.type == type, DownloadHistory.tmdbid == tmdbid).order_by( DownloadHistory.id.desc()).all() @classmethod @db_query def list_by_type(cls, db: Session, mtype: str, days: int): return db.query(DownloadHistory) \ .filter(DownloadHistory.type == mtype, DownloadHistory.date >= time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(time.time() - 86400 * int(days))) ).all() class DownloadFiles(Base): """ 下载文件记录 """ id = get_id_column() # 下载器 downloader = Column(String) # 下载任务Hash download_hash = Column(String, index=True) # 完整路径 fullpath = Column(String, index=True) # 保存路径 savepath = Column(String, index=True) # 文件相对路径/名称 filepath = Column(String) # 种子名称 torrentname = Column(String) # 状态 0-已删除 1-正常 state = Column(Integer, nullable=False, default=1) @classmethod @db_query def get_by_hash(cls, db: Session, download_hash: str, state: Optional[int] = None): if state is not None: return db.query(cls).filter(cls.download_hash == download_hash, cls.state == state).all() else: return db.query(cls).filter(cls.download_hash == download_hash).all() @classmethod @db_query def get_by_fullpath(cls, db: Session, fullpath: str, all_files: bool = False): if not all_files: return db.query(cls).filter(cls.fullpath == fullpath).order_by( cls.id.desc()).first() else: return db.query(cls).filter(cls.fullpath == fullpath).order_by( cls.id.desc()).all() @classmethod @db_query def get_by_savepath(cls, db: Session, savepath: str): return db.query(cls).filter(cls.savepath == savepath).all() @classmethod @db_update def delete_by_fullpath(cls, db: Session, fullpath: str): db.query(cls).filter(cls.fullpath == fullpath, cls.state == 1).update( { "state": 0 } ) ================================================ FILE: app/db/models/mediaserver.py ================================================ from datetime import datetime from typing import Optional from sqlalchemy import Column, Integer, String, JSON from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import Session from app.db import db_query, db_update, get_id_column, async_db_query, Base class MediaServerItem(Base): """ 媒体服务器媒体条目表 """ id = get_id_column() # 服务器类型 server = Column(String) # 媒体库ID library = Column(String) # ID item_id = Column(String, index=True) # 类型 item_type = Column(String) # 标题 title = Column(String, index=True) # 原标题 original_title = Column(String) # 年份 year = Column(String) # TMDBID tmdbid = Column(Integer, index=True) # IMDBID imdbid = Column(String, index=True) # TVDBID tvdbid = Column(String, index=True) # 路径 path = Column(String) # 季集 seasoninfo = Column(JSON, default=dict) # 备注 note = Column(JSON) # 同步时间 lst_mod_date = Column(String, default=datetime.now().strftime("%Y-%m-%d %H:%M:%S")) @classmethod @db_query def get_by_itemid(cls, db: Session, item_id: str): return db.query(cls).filter(cls.item_id == item_id).first() @classmethod @db_update def empty(cls, db: Session, server: Optional[str] = None): if server is None: db.query(cls).delete() else: db.query(cls).filter(cls.server == server).delete() @classmethod @db_query def exist_by_tmdbid(cls, db: Session, tmdbid: int, mtype: str): return db.query(cls).filter(cls.tmdbid == tmdbid, cls.item_type == mtype).first() @classmethod @db_query def exists_by_title(cls, db: Session, title: str, mtype: str, year: str): if not mtype and not year: return db.query(cls).filter(cls.title == title).first() elif not year: return db.query(cls).filter(cls.title == title, cls.item_type == mtype).first() elif not mtype: return db.query(cls).filter(cls.title == title, cls.year == str(year)).first() return db.query(cls).filter(cls.title == title, cls.item_type == mtype, cls.year == str(year)).first() @classmethod @async_db_query async def async_get_by_itemid(cls, db: AsyncSession, item_id: str): result = await db.execute(select(cls).filter(cls.item_id == item_id)) return result.scalars().first() @classmethod @async_db_query async def async_exist_by_tmdbid(cls, db: AsyncSession, tmdbid: int, mtype: str): result = await db.execute(select(cls).filter(cls.tmdbid == tmdbid, cls.item_type == mtype)) return result.scalars().first() @classmethod @async_db_query async def async_exists_by_title(cls, db: AsyncSession, title: str, mtype: str, year: str): if not mtype and not year: result = await db.execute(select(cls).filter(cls.title == title)) elif not year: result = await db.execute(select(cls).filter(cls.title == title, cls.item_type == mtype)) elif not mtype: result = await db.execute(select(cls).filter(cls.title == title, cls.year == str(year))) else: result = await db.execute(select(cls).filter(cls.title == title, cls.item_type == mtype, cls.year == str(year))) return result.scalars().first() ================================================ FILE: app/db/models/message.py ================================================ from typing import Optional from sqlalchemy import Column, Integer, String, JSON, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import Session from app.db import db_query, Base, get_id_column, async_db_query class Message(Base): """ 消息表 """ id = get_id_column() # 消息渠道 channel = Column(String) # 消息来源 source = Column(String) # 消息类型 mtype = Column(String) # 标题 title = Column(String) # 文本内容 text = Column(String) # 图片 image = Column(String) # 链接 link = Column(String) # 用户ID userid = Column(String) # 登记时间 reg_time = Column(String, index=True) # 消息方向:0-接收息,1-发送消息 action = Column(Integer) # 附件json note = Column(JSON) @classmethod @db_query def list_by_page(cls, db: Session, page: Optional[int] = 1, count: Optional[int] = 30): return db.query(cls).order_by(cls.reg_time.desc()).offset((page - 1) * count).limit(count).all() @classmethod @async_db_query async def async_list_by_page(cls, db: AsyncSession, page: Optional[int] = 1, count: Optional[int] = 30): result = await db.execute( select(cls).order_by(cls.reg_time.desc()).offset((page - 1) * count).limit(count) ) return result.scalars().all() ================================================ FILE: app/db/models/passkey.py ================================================ from sqlalchemy import Column, Integer, String, Boolean, DateTime, Text, select, ForeignKey from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import Session from datetime import datetime from app.db import Base, db_query, db_update, async_db_query, async_db_update, get_id_column class PassKey(Base): """ 用户PassKey凭证表 """ # ID id = get_id_column() # 用户ID user_id = Column(Integer, ForeignKey('user.id'), nullable=False, index=True) # 凭证ID (credential_id) credential_id = Column(String, nullable=False, unique=True, index=True) # 凭证公钥 public_key = Column(Text, nullable=False) # 签名计数器 sign_count = Column(Integer, default=0) # 凭证名称(用户自定义) name = Column(String, default="通行密钥") # AAGUID (Authenticator Attestation GUID) aaguid = Column(String, nullable=True) # 创建时间 created_at = Column(DateTime, default=datetime.now) # 最后使用时间 last_used_at = Column(DateTime, nullable=True) # 是否启用 is_active = Column(Boolean, default=True) # 传输方式 (usb, nfc, ble, internal) transports = Column(String, nullable=True) @classmethod @db_query def get_by_user_id(cls, db: Session, user_id: int): """获取用户的所有PassKey""" return db.query(cls).filter(cls.user_id == user_id, cls.is_active.is_(True)).all() @classmethod @async_db_query async def async_get_by_user_id(cls, db: AsyncSession, user_id: int): """异步获取用户的所有PassKey""" result = await db.execute( select(cls).filter(cls.user_id == user_id, cls.is_active.is_(True)) ) return result.scalars().all() @classmethod @db_query def get_by_credential_id(cls, db: Session, credential_id: str): """根据凭证ID获取PassKey""" return db.query(cls).filter(cls.credential_id == credential_id, cls.is_active.is_(True)).first() @classmethod @async_db_query async def async_get_by_credential_id(cls, db: AsyncSession, credential_id: str): """异步根据凭证ID获取PassKey""" result = await db.execute( select(cls).filter(cls.credential_id == credential_id, cls.is_active.is_(True)) ) return result.scalars().first() @classmethod @db_query def get_by_id(cls, db: Session, passkey_id: int): """根据ID获取PassKey""" return db.query(cls).filter(cls.id == passkey_id).first() @classmethod @async_db_query async def async_get_by_id(cls, db: AsyncSession, passkey_id: int): """异步根据ID获取PassKey""" result = await db.execute( select(cls).filter(cls.id == passkey_id) ) return result.scalars().first() @classmethod @db_update def delete_by_id(cls, db: Session, passkey_id: int, user_id: int): """删除指定用户的PassKey""" passkey = db.query(cls).filter( cls.id == passkey_id, cls.user_id == user_id ).first() if passkey: passkey.delete(db, passkey.id) return True return False @classmethod @async_db_update async def async_delete_by_id(cls, db: AsyncSession, passkey_id: int, user_id: int): """异步删除指定用户的PassKey""" result = await db.execute( select(cls).filter( cls.id == passkey_id, cls.user_id == user_id ) ) passkey = result.scalars().first() if passkey: await passkey.async_delete(db, passkey.id) return True return False @db_update def update_last_used(self, db: Session, sign_count: int): """更新最后使用时间和签名计数""" self.update(db, { 'last_used_at': datetime.now(), 'sign_count': sign_count }) return True @async_db_update async def async_update_last_used(self, db: AsyncSession, sign_count: int): """异步更新最后使用时间和签名计数""" await self.async_update(db, { 'last_used_at': datetime.now(), 'sign_count': sign_count }) return True ================================================ FILE: app/db/models/plugindata.py ================================================ from sqlalchemy import Column, String, JSON from sqlalchemy.orm import Session from app.db import db_query, db_update, get_id_column, Base class PluginData(Base): """ 插件数据表 """ id = get_id_column() plugin_id = Column(String, nullable=False, index=True) key = Column(String, index=True, nullable=False) value = Column(JSON) @classmethod @db_query def get_plugin_data(cls, db: Session, plugin_id: str): return db.query(cls).filter(cls.plugin_id == plugin_id).all() @classmethod @db_query def get_plugin_data_by_key(cls, db: Session, plugin_id: str, key: str): return db.query(cls).filter(cls.plugin_id == plugin_id, cls.key == key).first() @classmethod @db_update def del_plugin_data_by_key(cls, db: Session, plugin_id: str, key: str): db.query(cls).filter(cls.plugin_id == plugin_id, cls.key == key).delete() @classmethod @db_update def del_plugin_data(cls, db: Session, plugin_id: str): db.query(cls).filter(cls.plugin_id == plugin_id).delete() @classmethod @db_query def get_plugin_data_by_plugin_id(cls, db: Session, plugin_id: str): return db.query(cls).filter(cls.plugin_id == plugin_id).all() ================================================ FILE: app/db/models/site.py ================================================ from datetime import datetime from sqlalchemy import Boolean, Column, Integer, String, JSON, select, delete from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import Session from app.db import db_query, db_update, Base, async_db_query, async_db_update, get_id_column class Site(Base): """ 站点表 """ id = get_id_column() # 站点名 name = Column(String, nullable=False) # 域名Key domain = Column(String, index=True) # 站点地址 url = Column(String, nullable=False) # 站点优先级 pri = Column(Integer, default=1) # RSS地址,未启用 rss = Column(String) # Cookie cookie = Column(String) # User-Agent ua = Column(String) # ApiKey apikey = Column(String) # Token token = Column(String) # 是否使用代理 0-否,1-是 proxy = Column(Integer) # 过滤规则 filter = Column(String) # 是否渲染 render = Column(Integer) # 是否公开站点 public = Column(Integer) # 附加信息 note = Column(JSON) # 流控单位周期 limit_interval = Column(Integer, default=0) # 流控次数 limit_count = Column(Integer, default=0) # 流控间隔 limit_seconds = Column(Integer, default=0) # 超时时间 timeout = Column(Integer, default=15) # 是否启用 is_active = Column(Boolean(), default=True) # 创建时间 lst_mod_date = Column(String, default=datetime.now().strftime("%Y-%m-%d %H:%M:%S")) # 下载器 downloader = Column(String) @classmethod @db_query def get_by_domain(cls, db: Session, domain: str): return db.query(cls).filter(cls.domain == domain).first() @classmethod @async_db_query async def async_get_by_domain(cls, db: AsyncSession, domain: str): result = await db.execute(select(cls).where(cls.domain == domain)) return result.scalar_one_or_none() @classmethod @async_db_query async def async_get_by_name(cls, db: AsyncSession, name: str): result = await db.execute(select(cls).where(cls.name == name)) return result.scalar_one_or_none() @classmethod @db_query def get_actives(cls, db: Session): return db.query(cls).filter(cls.is_active).all() @classmethod @async_db_query async def async_get_actives(cls, db: AsyncSession): result = await db.execute(select(cls).where(cls.is_active)) return result.scalars().all() @classmethod @db_query def list_order_by_pri(cls, db: Session): return db.query(cls).order_by(cls.pri).all() @classmethod @async_db_query async def async_list_order_by_pri(cls, db: AsyncSession): result = await db.execute(select(cls).order_by(cls.pri)) return result.scalars().all() @classmethod @db_query def get_domains_by_ids(cls, db: Session, ids: list): return [r[0] for r in db.query(cls.domain).filter(cls.id.in_(ids)).all()] @classmethod @db_update def reset(cls, db: Session): db.query(cls).delete() @classmethod @async_db_update async def async_reset(cls, db: AsyncSession): await db.execute(delete(cls)) ================================================ FILE: app/db/models/siteicon.py ================================================ from sqlalchemy import Column, String, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import Session from app.db import db_query, Base, get_id_column, async_db_query class SiteIcon(Base): """ 站点图标表 """ id = get_id_column() # 站点名称 name = Column(String, nullable=False) # 域名Key domain = Column(String, index=True) # 图标地址 url = Column(String, nullable=False) # 图标Base64 base64 = Column(String) @classmethod @db_query def get_by_domain(cls, db: Session, domain: str): return db.query(cls).filter(cls.domain == domain).first() @classmethod @async_db_query async def async_get_by_domain(cls, db: AsyncSession, domain: str): result = await db.execute(select(cls).where(cls.domain == domain)) return result.scalar_one_or_none() ================================================ FILE: app/db/models/sitestatistic.py ================================================ from datetime import datetime from sqlalchemy import Column, Integer, String, JSON, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import Session from app.db import db_query, db_update, get_id_column, Base, async_db_query class SiteStatistic(Base): """ 站点统计表 """ id = get_id_column() # 域名Key domain = Column(String, index=True) # 成功次数 success = Column(Integer) # 失败次数 fail = Column(Integer) # 平均耗时 秒 seconds = Column(Integer) # 最后一次访问状态 0-成功 1-失败 lst_state = Column(Integer) # 最后访问时间 lst_mod_date = Column(String, default=datetime.now().strftime("%Y-%m-%d %H:%M:%S")) # 耗时记录 Json note = Column(JSON) @classmethod @db_query def get_by_domain(cls, db: Session, domain: str): return db.query(cls).filter(cls.domain == domain).first() @classmethod @async_db_query async def async_get_by_domain(cls, db: AsyncSession, domain: str): result = await db.execute(select(cls).where(cls.domain == domain)) return result.scalar_one_or_none() @classmethod @db_update def reset(cls, db: Session): db.query(cls).delete() ================================================ FILE: app/db/models/siteuserdata.py ================================================ from datetime import datetime from typing import Optional from sqlalchemy import Column, Integer, String, Float, JSON, func, or_, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import Session from app.db import db_query, Base, get_id_column, async_db_query class SiteUserData(Base): """ 站点数据表 """ id = get_id_column() # 站点域名 domain = Column(String, index=True) # 站点名称 name = Column(String) # 用户名 username = Column(String) # 用户ID userid = Column(String) # 用户等级 user_level = Column(String) # 加入时间 join_at = Column(String) # 积分 bonus = Column(Float, default=0) # 上传量 upload = Column(Float, default=0) # 下载量 download = Column(Float, default=0) # 分享率 ratio = Column(Float, default=0) # 做种数 seeding = Column(Float, default=0) # 下载数 leeching = Column(Float, default=0) # 做种体积 seeding_size = Column(Float, default=0) # 下载体积 leeching_size = Column(Float, default=0) # 做种人数, 种子大小 JSON seeding_info = Column(JSON, default=dict) # 未读消息 message_unread = Column(Integer, default=0) # 未读消息内容 JSON message_unread_contents = Column(JSON, default=list) # 错误信息 err_msg = Column(String) # 更新日期 updated_day = Column(String, index=True, default=datetime.now().strftime('%Y-%m-%d')) # 更新时间 updated_time = Column(String, default=datetime.now().strftime('%H:%M:%S')) @classmethod @db_query def get_by_domain(cls, db: Session, domain: str, workdate: Optional[str] = None, worktime: Optional[str] = None): if workdate and worktime: return db.query(cls).filter(cls.domain == domain, cls.updated_day == workdate, cls.updated_time == worktime).all() elif workdate: return db.query(cls).filter(cls.domain == domain, cls.updated_day == workdate).all() return db.query(cls).filter(cls.domain == domain).all() @classmethod @async_db_query async def async_get_by_domain(cls, db: AsyncSession, domain: str, workdate: Optional[str] = None, worktime: Optional[str] = None): query = select(cls).filter(cls.domain == domain) if workdate and worktime: query = query.filter(cls.updated_day == workdate, cls.updated_time == worktime) elif workdate: query = query.filter(cls.updated_day == workdate) result = await db.execute(query) return result.scalars().all() @classmethod @db_query def get_by_date(cls, db: Session, date: str): return db.query(cls).filter(cls.updated_day == date).all() @classmethod @db_query def get_latest(cls, db: Session): """ 获取各站点最新一天的数据 """ subquery = ( db.query( cls.domain, func.max(cls.updated_day).label('latest_update_day') ) .group_by(cls.domain) .filter(or_(cls.err_msg.is_(None), cls.err_msg == "")) .subquery() ) # 主查询:按 domain 和 updated_day 获取最新的记录 return db.query(cls).join( subquery, (cls.domain == subquery.c.domain) & (cls.updated_day == subquery.c.latest_update_day) ).order_by(cls.updated_time.desc()).all() @classmethod @async_db_query async def async_get_latest(cls, db: AsyncSession): """ 异步获取各站点最新一天的数据 """ subquery = ( select( cls.domain, func.max(cls.updated_day).label('latest_update_day') ) .group_by(cls.domain) .filter(or_(cls.err_msg.is_(None), cls.err_msg == "")) .subquery() ) # 主查询:按 domain 和 updated_day 获取最新的记录 result = await db.execute( select(cls).join( subquery, (cls.domain == subquery.c.domain) & (cls.updated_day == subquery.c.latest_update_day) ).order_by(cls.updated_time.desc())) return result.scalars().all() ================================================ FILE: app/db/models/subscribe.py ================================================ import time from typing import Optional from sqlalchemy import Column, Integer, String, Float, JSON, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import Session from app.db import db_query, db_update, get_id_column, Base, async_db_query, async_db_update class Subscribe(Base): """ 订阅表 """ id = get_id_column() # 标题 name = Column(String, nullable=False, index=True) # 年份 year = Column(String) # 类型 type = Column(String) # 搜索关键字 keyword = Column(String) tmdbid = Column(Integer, index=True) imdbid = Column(String) tvdbid = Column(Integer) doubanid = Column(String, index=True) bangumiid = Column(Integer, index=True) mediaid = Column(String, index=True) # 季号 season = Column(Integer) # 海报 poster = Column(String) # 背景图 backdrop = Column(String) # 评分,float vote = Column(Float) # 简介 description = Column(String) # 过滤规则 filter = Column(String) # 包含 include = Column(String) # 排除 exclude = Column(String) # 质量 quality = Column(String) # 分辨率 resolution = Column(String) # 特效 effect = Column(String) # 总集数 total_episode = Column(Integer) # 开始集数 start_episode = Column(Integer) # 缺失集数 lack_episode = Column(Integer) # 附加信息 note = Column(JSON) # 状态:N-新建 R-订阅中 P-待定 S-暂停 state = Column(String, nullable=False, index=True, default='N') # 最后更新时间 last_update = Column(String) # 创建时间 date = Column(String) # 订阅用户 username = Column(String) # 订阅站点 sites = Column(JSON, default=list) # 下载器 downloader = Column(String) # 是否洗版 best_version = Column(Integer, default=0) # 当前优先级 current_priority = Column(Integer) # 保存路径 save_path = Column(String) # 是否使用 imdbid 搜索 search_imdbid = Column(Integer, default=0) # 是否手动修改过总集数 0否 1是 manual_total_episode = Column(Integer, default=0) # 自定义识别词 custom_words = Column(String) # 自定义媒体类别 media_category = Column(String) # 过滤规则组 filter_groups = Column(JSON, default=list) # 选择的剧集组 episode_group = Column(String) @classmethod @db_query def exists(cls, db: Session, tmdbid: Optional[int] = None, doubanid: Optional[str] = None, season: Optional[int] = None): if tmdbid: if season is not None: return db.query(cls).filter(cls.tmdbid == tmdbid, cls.season == season).first() return db.query(cls).filter(cls.tmdbid == tmdbid).first() elif doubanid: return db.query(cls).filter(cls.doubanid == doubanid).first() return None @classmethod @async_db_query async def async_exists(cls, db: AsyncSession, tmdbid: Optional[int] = None, doubanid: Optional[str] = None, season: Optional[int] = None): if tmdbid: if season is not None: result = await db.execute( select(cls).filter(cls.tmdbid == tmdbid, cls.season == season) ) else: result = await db.execute( select(cls).filter(cls.tmdbid == tmdbid) ) elif doubanid: result = await db.execute( select(cls).filter(cls.doubanid == doubanid) ) else: return None return result.scalars().first() @classmethod @db_query def get_by_state(cls, db: Session, state: str): # 如果 state 为空或 None,返回所有订阅 if not state: return db.query(cls).all() else: # 如果传入的状态不为空,拆分成多个状态 return db.query(cls).filter(cls.state.in_(state.split(','))).all() @classmethod @async_db_query async def async_get_by_state(cls, db: AsyncSession, state: str): # 如果 state 为空或 None,返回所有订阅 if not state: result = await db.execute(select(cls)) else: # 如果传入的状态不为空,拆分成多个状态 result = await db.execute( select(cls).filter(cls.state.in_(state.split(','))) ) return result.scalars().all() @classmethod @db_query def get_by_title(cls, db: Session, title: str, season: Optional[int] = None): if season is not None: return db.query(cls).filter(cls.name == title, cls.season == season).first() return db.query(cls).filter(cls.name == title).first() @classmethod @async_db_query async def async_get_by_title(cls, db: AsyncSession, title: str, season: Optional[int] = None): if season is not None: result = await db.execute( select(cls).filter(cls.name == title, cls.season == season) ) else: result = await db.execute( select(cls).filter(cls.name == title) ) return result.scalars().first() @classmethod @db_query def get_by_tmdbid(cls, db: Session, tmdbid: int, season: Optional[int] = None): if season is not None: return db.query(cls).filter(cls.tmdbid == tmdbid, cls.season == season).all() else: return db.query(cls).filter(cls.tmdbid == tmdbid).all() @classmethod @async_db_query async def async_get_by_tmdbid(cls, db: AsyncSession, tmdbid: int, season: Optional[int] = None): if season is not None: result = await db.execute( select(cls).filter(cls.tmdbid == tmdbid, cls.season == season) ) else: result = await db.execute( select(cls).filter(cls.tmdbid == tmdbid) ) return result.scalars().all() @classmethod @db_query def get_by_doubanid(cls, db: Session, doubanid: str): return db.query(cls).filter(cls.doubanid == doubanid).first() @classmethod @async_db_query async def async_get_by_doubanid(cls, db: AsyncSession, doubanid: str): result = await db.execute( select(cls).filter(cls.doubanid == doubanid) ) return result.scalars().first() @classmethod @db_query def get_by_bangumiid(cls, db: Session, bangumiid: int): return db.query(cls).filter(cls.bangumiid == bangumiid).first() @classmethod @async_db_query async def async_get_by_bangumiid(cls, db: AsyncSession, bangumiid: int): result = await db.execute( select(cls).filter(cls.bangumiid == bangumiid) ) return result.scalars().first() @classmethod @db_query def get_by_mediaid(cls, db: Session, mediaid: str): return db.query(cls).filter(cls.mediaid == mediaid).first() @classmethod @async_db_query async def async_get_by_mediaid(cls, db: AsyncSession, mediaid: str): result = await db.execute( select(cls).filter(cls.mediaid == mediaid) ) return result.scalars().first() @classmethod @db_query def get_by(cls, db: Session, type: str, season: Optional[str] = None, tmdbid: Optional[int] = None, doubanid: Optional[str] = None, bangumiid: Optional[str] = None): """ 根据条件查询订阅 """ # TMDBID if tmdbid: if season is not None: result = db.query(cls).filter( cls.tmdbid == tmdbid, cls.type == type, cls.season == season ) else: result = db.query(cls).filter(cls.tmdbid == tmdbid, cls.type == type) # 豆瓣ID elif doubanid: result = db.query(cls).filter(cls.doubanid == doubanid, cls.type == type) # BangumiID elif bangumiid: result = db.query(cls).filter(cls.bangumiid == bangumiid, cls.type == type) else: return None return result.first() @classmethod @async_db_query async def async_get_by(cls, db: AsyncSession, type: str, season: Optional[str] = None, tmdbid: Optional[int] = None, doubanid: Optional[str] = None, bangumiid: Optional[str] = None): """ 根据条件查询订阅 """ # TMDBID if tmdbid: if season is not None: result = await db.execute( select(cls).filter( cls.tmdbid == tmdbid, cls.type == type, cls.season == season ) ) else: result = await db.execute( select(cls).filter(cls.tmdbid == tmdbid, cls.type == type) ) # 豆瓣ID elif doubanid: result = await db.execute( select(cls).filter(cls.doubanid == doubanid, cls.type == type) ) # BangumiID elif bangumiid: result = await db.execute( select(cls).filter(cls.bangumiid == bangumiid, cls.type == type) ) else: return None return result.scalars().first() @db_update def delete_by_tmdbid(self, db: Session, tmdbid: int, season: int): subscrbies = self.get_by_tmdbid(db, tmdbid, season) for subscrbie in subscrbies: subscrbie.delete(db, subscrbie.id) return True @async_db_update async def async_delete_by_tmdbid(self, db: AsyncSession, tmdbid: int, season: int): subscrbies = await self.async_get_by_tmdbid(db, tmdbid, season) for subscrbie in subscrbies: await subscrbie.async_delete(db, subscrbie.id) return True @db_update def delete_by_doubanid(self, db: Session, doubanid: str): subscribe = self.get_by_doubanid(db, doubanid) if subscribe: subscribe.delete(db, subscribe.id) return True @async_db_update async def async_delete_by_doubanid(self, db: AsyncSession, doubanid: str): subscribe = await self.async_get_by_doubanid(db, doubanid) if subscribe: await subscribe.async_delete(db, subscribe.id) return True @db_update def delete_by_mediaid(self, db: Session, mediaid: str): subscribe = self.get_by_mediaid(db, mediaid) if subscribe: subscribe.delete(db, subscribe.id) return True @async_db_update async def async_delete_by_mediaid(self, db: AsyncSession, mediaid: str): subscribe = await self.async_get_by_mediaid(db, mediaid) if subscribe: await subscribe.async_delete(db, subscribe.id) return True @classmethod @db_query def list_by_username(cls, db: Session, username: str, state: Optional[str] = None, mtype: Optional[str] = None): if mtype: if state: return db.query(cls).filter(cls.state == state, cls.username == username, cls.type == mtype).all() else: return db.query(cls).filter(cls.username == username, cls.type == mtype).all() else: if state: return db.query(cls).filter(cls.state == state, cls.username == username).all() else: return db.query(cls).filter(cls.username == username).all() @classmethod @async_db_query async def async_list_by_username(cls, db: AsyncSession, username: str, state: Optional[str] = None, mtype: Optional[str] = None): if mtype: if state: result = await db.execute( select(cls).filter(cls.state == state, cls.username == username, cls.type == mtype) ) else: result = await db.execute( select(cls).filter(cls.username == username, cls.type == mtype) ) else: if state: result = await db.execute( select(cls).filter(cls.state == state, cls.username == username) ) else: result = await db.execute( select(cls).filter(cls.username == username) ) return result.scalars().all() @classmethod @db_query def list_by_type(cls, db: Session, mtype: str, days: int): return db.query(cls) \ .filter(cls.type == mtype, cls.date >= time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(time.time() - 86400 * int(days))) ).all() @classmethod @async_db_query async def async_list_by_type(cls, db: AsyncSession, mtype: str, days: int): result = await db.execute( select(cls).filter( cls.type == mtype, cls.date >= time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(time.time() - 86400 * int(days))) ) ) return result.scalars().all() ================================================ FILE: app/db/models/subscribehistory.py ================================================ from typing import Optional from sqlalchemy import Column, Integer, String, Float, JSON, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import Session from app.db import db_query, Base, get_id_column, async_db_query class SubscribeHistory(Base): """ 订阅历史表 """ id = get_id_column() # 标题 name = Column(String, nullable=False, index=True) # 年份 year = Column(String) # 类型 type = Column(String) # 搜索关键字 keyword = Column(String) tmdbid = Column(Integer, index=True) imdbid = Column(String) tvdbid = Column(Integer) doubanid = Column(String, index=True) bangumiid = Column(Integer, index=True) mediaid = Column(String, index=True) # 季号 season = Column(Integer) # 海报 poster = Column(String) # 背景图 backdrop = Column(String) # 评分,float vote = Column(Float) # 简介 description = Column(String) # 过滤规则 filter = Column(String) # 包含 include = Column(String) # 排除 exclude = Column(String) # 质量 quality = Column(String) # 分辨率 resolution = Column(String) # 特效 effect = Column(String) # 总集数 total_episode = Column(Integer) # 开始集数 start_episode = Column(Integer) # 订阅完成时间 date = Column(String) # 订阅用户 username = Column(String) # 订阅站点 sites = Column(JSON) # 是否洗版 best_version = Column(Integer, default=0) # 保存路径 save_path = Column(String) # 是否使用 imdbid 搜索 search_imdbid = Column(Integer, default=0) # 自定义识别词 custom_words = Column(String) # 自定义媒体类别 media_category = Column(String) # 过滤规则组 filter_groups = Column(JSON, default=list) # 剧集组 episode_group = Column(String) @classmethod @db_query def list_by_type(cls, db: Session, mtype: str, page: Optional[int] = 1, count: Optional[int] = 30): return db.query(cls).filter( cls.type == mtype ).order_by( cls.date.desc() ).offset((page - 1) * count).limit(count).all() @classmethod @async_db_query async def async_list_by_type(cls, db: AsyncSession, mtype: str, page: Optional[int] = 1, count: Optional[int] = 30): result = await db.execute( select(cls).filter( cls.type == mtype ).order_by( cls.date.desc() ).offset((page - 1) * count).limit(count) ) return result.scalars().all() @classmethod @db_query def exists(cls, db: Session, tmdbid: Optional[int] = None, doubanid: Optional[str] = None, season: Optional[int] = None): if tmdbid: if season is not None: return db.query(cls).filter(cls.tmdbid == tmdbid, cls.season == season).first() return db.query(cls).filter(cls.tmdbid == tmdbid).first() elif doubanid: return db.query(cls).filter(cls.doubanid == doubanid).first() return None @classmethod @async_db_query async def async_exists(cls, db: AsyncSession, tmdbid: Optional[int] = None, doubanid: Optional[str] = None, season: Optional[int] = None): if tmdbid: if season is not None: result = await db.execute( select(cls).filter(cls.tmdbid == tmdbid, cls.season == season) ) else: result = await db.execute( select(cls).filter(cls.tmdbid == tmdbid) ) elif doubanid: result = await db.execute( select(cls).filter(cls.doubanid == doubanid) ) else: return None return result.scalars().first() ================================================ FILE: app/db/models/systemconfig.py ================================================ from sqlalchemy import Column, String, JSON, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import Session from app.db import db_query, db_update, Base, async_db_query, get_id_column class SystemConfig(Base): """ 配置表 """ id = get_id_column() # 主键 key = Column(String, index=True) # 值 value = Column(JSON) @classmethod @db_query def get_by_key(cls, db: Session, key: str): return db.query(cls).filter(cls.key == key).first() @classmethod @async_db_query async def async_get_by_key(cls, db: AsyncSession, key: str): result = await db.execute(select(cls).where(cls.key == key)) return result.scalar_one_or_none() @db_update def delete_by_key(self, db: Session, key: str): systemconfig = self.get_by_key(db, key) if systemconfig: systemconfig.delete(db, systemconfig.id) return True ================================================ FILE: app/db/models/transferhistory.py ================================================ import time from typing import Optional from sqlalchemy import Column, Integer, String, Boolean, func, or_, JSON, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import Session from app.db import db_query, db_update, get_id_column, Base, async_db_query class TransferHistory(Base): """ 整理记录 """ id = get_id_column() # 源路径 src = Column(String, index=True) # 源存储 src_storage = Column(String) # 源文件项 src_fileitem = Column(JSON, default=dict) # 目标路径 dest = Column(String) # 目标存储 dest_storage = Column(String) # 目标文件项 dest_fileitem = Column(JSON, default=dict) # 转移模式 move/copy/link... mode = Column(String) # 类型 电影/电视剧 type = Column(String) # 二级分类 category = Column(String) # 标题 title = Column(String, index=True) # 年份 year = Column(String) tmdbid = Column(Integer, index=True) imdbid = Column(String) tvdbid = Column(Integer) doubanid = Column(String) # Sxx seasons = Column(String) # Exx episodes = Column(String) # 海报 image = Column(String) # 下载器 downloader = Column(String) # 下载器hash download_hash = Column(String, index=True) # 转移成功状态 status = Column(Boolean(), default=True) # 转移失败信息 errmsg = Column(String) # 时间 date = Column(String, index=True) # 文件清单,以JSON存储 files = Column(JSON, default=list) # 剧集组 episode_group = Column(String) @classmethod @db_query def list_by_title(cls, db: Session, title: str, page: Optional[int] = 1, count: Optional[int] = 30, status: bool = None): if status is not None: query = db.query(cls).filter( cls.status == status ).order_by( cls.date.desc() ) else: query = db.query(cls).filter(or_( cls.title.like(f'%{title}%'), cls.src.like(f'%{title}%'), cls.dest.like(f'%{title}%'), )).order_by( cls.date.desc() ) # 当count为负数时,不限制页数查询所有 if count >= 0: query = query.offset((page - 1) * count).limit(count) return query.all() @classmethod @async_db_query async def async_list_by_title(cls, db: AsyncSession, title: str, page: Optional[int] = 1, count: Optional[int] = 30, status: bool = None): if status is not None: query = select(cls).filter( cls.status == status ).order_by( cls.date.desc() ) else: query = select(cls).filter(or_( cls.title.like(f'%{title}%'), cls.src.like(f'%{title}%'), cls.dest.like(f'%{title}%'), )).order_by( cls.date.desc() ) # 当count为负数时,不限制页数查询所有 if count >= 0: query = query.offset((page - 1) * count).limit(count) result = await db.execute(query) return result.scalars().all() @classmethod @db_query def list_by_page(cls, db: Session, page: Optional[int] = 1, count: Optional[int] = 30, status: bool = None): if status is not None: query = db.query(cls).filter( cls.status == status ).order_by( cls.date.desc() ) else: query = db.query(cls).order_by( cls.date.desc() ) # 当count为负数时,不限制页数查询所有 if count >= 0: query = query.offset((page - 1) * count).limit(count) return query.all() @classmethod @async_db_query async def async_list_by_page(cls, db: AsyncSession, page: Optional[int] = 1, count: Optional[int] = 30, status: bool = None): if status is not None: query = select(cls).filter( cls.status == status ).order_by( cls.date.desc() ) else: query = select(cls).order_by( cls.date.desc() ) # 当count为负数时,不限制页数查询所有 if count >= 0: query = query.offset((page - 1) * count).limit(count) result = await db.execute(query) return result.scalars().all() @classmethod @db_query def get_by_hash(cls, db: Session, download_hash: str): return db.query(cls).filter(cls.download_hash == download_hash).first() @classmethod @db_query def get_by_src(cls, db: Session, src: str, storage: Optional[str] = None): if storage: return db.query(cls).filter(cls.src == src, cls.src_storage == storage).first() else: return db.query(cls).filter(cls.src == src).first() @classmethod @db_query def get_by_dest(cls, db: Session, dest: str): return db.query(cls).filter(cls.dest == dest).first() @classmethod @db_query def list_by_hash(cls, db: Session, download_hash: str): return db.query(cls).filter(cls.download_hash == download_hash).all() @classmethod @db_query def statistic(cls, db: Session, days: Optional[int] = 7): """ 统计最近days天的下载历史数量,按日期分组返回每日数量 """ sub_query = db.query(func.substr(cls.date, 1, 10).label('date'), cls.id.label('id')).filter( cls.date >= time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(time.time() - 86400 * days))).subquery() return db.query(sub_query.c.date, func.count(sub_query.c.id)).group_by(sub_query.c.date).all() @classmethod @async_db_query async def async_statistic(cls, db: AsyncSession, days: Optional[int] = 7): """ 统计最近days天的下载历史数量,按日期分组返回每日数量 """ sub_query = select(func.substr(cls.date, 1, 10).label('date'), cls.id.label('id')).filter( cls.date >= time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(time.time() - 86400 * days))).subquery() result = await db.execute( select(sub_query.c.date, func.count(sub_query.c.id)).group_by(sub_query.c.date) ) return result.all() @classmethod @db_query def count(cls, db: Session, status: bool = None): if status is not None: return db.query(func.count(cls.id)).filter(cls.status == status).first()[0] else: return db.query(func.count(cls.id)).first()[0] @classmethod @async_db_query async def async_count(cls, db: AsyncSession, status: bool = None): if status is not None: result = await db.execute( select(func.count(cls.id)).filter(cls.status == status) ) else: result = await db.execute( select(func.count(cls.id)) ) return result.scalar() @classmethod @db_query def count_by_title(cls, db: Session, title: str, status: bool = None): if status is not None: return db.query(func.count(cls.id)).filter(cls.status == status).first()[0] else: return db.query(func.count(cls.id)).filter(or_( cls.title.like(f'%{title}%'), cls.src.like(f'%{title}%'), cls.dest.like(f'%{title}%') )).first()[0] @classmethod @async_db_query async def async_count_by_title(cls, db: AsyncSession, title: str, status: bool = None): if status is not None: result = await db.execute( select(func.count(cls.id)).filter(cls.status == status) ) else: result = await db.execute( select(func.count(cls.id)).filter(or_( cls.title.like(f'%{title}%'), cls.src.like(f'%{title}%'), cls.dest.like(f'%{title}%') )) ) return result.scalar() @classmethod @db_query def list_by(cls, db: Session, mtype: Optional[str] = None, title: Optional[str] = None, year: Optional[str] = None, season: Optional[str] = None, episode: Optional[str] = None, tmdbid: Optional[int] = None, dest: Optional[str] = None): """ 据tmdbid、season、season_episode查询转移记录 tmdbid + mtype 或 title + year 必输 """ # TMDBID + 类型 if tmdbid and mtype: # 电视剧某季某集 if season is not None and episode: return db.query(cls).filter(cls.tmdbid == tmdbid, cls.type == mtype, cls.seasons == season, cls.episodes == episode, cls.dest == dest).all() # 电视剧某季 elif season is not None: return db.query(cls).filter(cls.tmdbid == tmdbid, cls.type == mtype, cls.seasons == season).all() else: if dest: # 电影 return db.query(cls).filter(cls.tmdbid == tmdbid, cls.type == mtype, cls.dest == dest).all() else: # 电视剧所有季集 return db.query(cls).filter(cls.tmdbid == tmdbid, cls.type == mtype).all() # 标题 + 年份 elif title and year: # 电视剧某季某集 if season is not None and episode: return db.query(cls).filter(cls.title == title, cls.year == year, cls.seasons == season, cls.episodes == episode, cls.dest == dest).all() # 电视剧某季 elif season is not None: return db.query(cls).filter(cls.title == title, cls.year == year, cls.seasons == season).all() else: if dest: # 电影 return db.query(cls).filter(cls.title == title, cls.year == year, cls.dest == dest).all() else: # 电视剧所有季集 return db.query(cls).filter(cls.title == title, cls.year == year).all() # 类型 + 转移路径(emby webhook season无tmdbid场景) elif mtype and season is not None and dest: # 电视剧某季 return db.query(cls).filter(cls.type == mtype, cls.seasons == season, cls.dest.like(f"{dest}%")).all() return [] @classmethod @db_query def get_by_type_tmdbid(cls, db: Session, mtype: Optional[str] = None, tmdbid: Optional[int] = None): """ 据tmdbid、type查询转移记录 """ return db.query(cls).filter(cls.tmdbid == tmdbid, cls.type == mtype).first() @classmethod @db_update def update_download_hash(cls, db: Session, historyid: Optional[int] = None, download_hash: Optional[str] = None): db.query(cls).filter(cls.id == historyid).update( { "download_hash": download_hash } ) @classmethod @db_query def list_by_date(cls, db: Session, date: str): """ 查询某时间之后的转移历史 """ return db.query(cls).filter(cls.date > date).order_by(cls.id.desc()).all() ================================================ FILE: app/db/models/user.py ================================================ from sqlalchemy import Boolean, Column, JSON, String, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import Session from app.db import Base, db_query, db_update, async_db_query, async_db_update, get_id_column class User(Base): """ 用户表 """ # ID id = get_id_column() # 用户名,唯一值 name = Column(String, index=True, nullable=False) # 邮箱 email = Column(String) # 加密后密码 hashed_password = Column(String) # 是否启用 is_active = Column(Boolean(), default=True) # 是否管理员 is_superuser = Column(Boolean(), default=False) # 头像 avatar = Column(String) # 是否启用otp二次验证 is_otp = Column(Boolean(), default=False) # otp秘钥 otp_secret = Column(String, default=None) # 用户权限 json permissions = Column(JSON, default=dict) # 用户个性化设置 json settings = Column(JSON, default=dict) @classmethod @db_query def get_by_name(cls, db: Session, name: str): return db.query(cls).filter(cls.name == name).first() @classmethod @async_db_query async def async_get_by_name(cls, db: AsyncSession, name: str): result = await db.execute( select(cls).filter(cls.name == name) ) return result.scalars().first() @classmethod @db_query def get_by_id(cls, db: Session, user_id: int): return db.query(cls).filter(cls.id == user_id).first() @classmethod @async_db_query async def async_get_by_id(cls, db: AsyncSession, user_id: int): result = await db.execute( select(cls).filter(cls.id == user_id) ) return result.scalars().first() @db_update def delete_by_name(self, db: Session, name: str): user = self.get_by_name(db, name) if user: user.delete(db, user.id) return True @async_db_update async def async_delete_by_name(self, db: AsyncSession, name: str): user = await self.async_get_by_name(db, name) if user: await user.async_delete(db, user.id) return True @db_update def delete_by_id(self, db: Session, user_id: int): user = self.get_by_id(db, user_id) if user: user.delete(db, user.id) return True @async_db_update async def async_delete_by_id(self, db: AsyncSession, user_id: int): user = await self.async_get_by_id(db, user_id) if user: await user.async_delete(db, user.id) return True @db_update def update_otp_by_name(self, db: Session, name: str, otp: bool, secret: str): user = self.get_by_name(db, name) if user: user.update(db, { 'is_otp': otp, 'otp_secret': secret }) return True return False @async_db_update async def async_update_otp_by_name(self, db: AsyncSession, name: str, otp: bool, secret: str): user = await self.async_get_by_name(db, name) if user: await user.async_update(db, { 'is_otp': otp, 'otp_secret': secret }) return True return False ================================================ FILE: app/db/models/userconfig.py ================================================ from sqlalchemy import Column, String, UniqueConstraint, Index, JSON from sqlalchemy.orm import Session from app.db import db_query, db_update, get_id_column, Base class UserConfig(Base): """ 用户配置表 """ id = get_id_column() # 用户名 username = Column(String, index=True) # 配置键 key = Column(String) # 值 value = Column(JSON) __table_args__ = ( # 用户名和配置键联合唯一 UniqueConstraint('username', 'key'), Index('ix_userconfig_username_key', 'username', 'key'), ) @classmethod @db_query def get_by_key(cls, db: Session, username: str, key: str): return db.query(cls) \ .filter(cls.username == username) \ .filter(cls.key == key) \ .first() @db_update def delete_by_key(self, db: Session, username: str, key: str): userconfig = self.get_by_key(db=db, username=username, key=key) if userconfig: userconfig.delete(db=db, rid=userconfig.id) return True ================================================ FILE: app/db/models/workflow.py ================================================ from datetime import datetime from typing import Optional from sqlalchemy import Column, Integer, JSON, String, and_, or_, select from sqlalchemy.ext.asyncio import AsyncSession from app.db import Base, db_query, get_id_column, db_update, async_db_query, async_db_update class Workflow(Base): """ 工作流表 """ # ID id = get_id_column() # 名称 name = Column(String, index=True, nullable=False) # 描述 description = Column(String) # 定时器 timer = Column(String) # 触发类型:timer-定时触发 event-事件触发 manual-手动触发 trigger_type = Column(String, default='timer') # 事件类型(当trigger_type为event时使用) event_type = Column(String) # 事件条件(JSON格式,用于过滤事件) event_conditions = Column(JSON, default=dict) # 状态:W-等待 R-运行中 P-暂停 S-成功 F-失败 state = Column(String, nullable=False, index=True, default='W') # 已执行动作(,分隔) current_action = Column(String) # 任务执行结果 result = Column(String) # 已执行次数 run_count = Column(Integer, default=0) # 任务列表 actions = Column(JSON, default=list) # 任务流 flows = Column(JSON, default=list) # 执行上下文 context = Column(JSON, default=dict) # 创建时间 add_time = Column(String, default=datetime.now().strftime('%Y-%m-%d %H:%M:%S')) # 最后执行时间 last_time = Column(String) @classmethod @db_query def list(cls, db): return db.query(cls).all() @classmethod @async_db_query async def async_list(cls, db: AsyncSession): result = await db.execute(select(cls)) return result.scalars().all() @classmethod @db_query def get_enabled_workflows(cls, db): return db.query(cls).filter(cls.state != 'P').all() @classmethod @async_db_query async def async_get_enabled_workflows(cls, db: AsyncSession): result = await db.execute(select(cls).where(cls.state != 'P')) return result.scalars().all() @classmethod @db_query def get_timer_triggered_workflows(cls, db): """获取定时触发的工作流""" return db.query(cls).filter( and_( or_( cls.trigger_type == 'timer', not cls.trigger_type ), cls.state != 'P' ) ).all() @classmethod @async_db_query async def async_get_timer_triggered_workflows(cls, db: AsyncSession): """异步获取定时触发的工作流""" result = await db.execute(select(cls).where( and_( or_( cls.trigger_type == 'timer', not cls.trigger_type ), cls.state != 'P' ) )) return result.scalars().all() @classmethod @db_query def get_event_triggered_workflows(cls, db): """获取事件触发的工作流""" return db.query(cls).filter( and_( cls.trigger_type == 'event', cls.state != 'P' ) ).all() @classmethod @async_db_query async def async_get_event_triggered_workflows(cls, db: AsyncSession): """异步获取事件触发的工作流""" result = await db.execute(select(cls).where( and_( cls.trigger_type == 'event', cls.state != 'P' ) )) return result.scalars().all() @classmethod @db_query def get_by_name(cls, db, name: str): return db.query(cls).filter(cls.name == name).first() @classmethod @async_db_query async def async_get_by_name(cls, db: AsyncSession, name: str): result = await db.execute(select(cls).where(cls.name == name)) return result.scalars().first() @classmethod @db_update def update_state(cls, db, wid: int, state: str): db.query(cls).filter(cls.id == wid).update({"state": state}) return True @classmethod @async_db_update async def async_update_state(cls, db: AsyncSession, wid: int, state: str): from sqlalchemy import update await db.execute(update(cls).where(cls.id == wid).values(state=state)) return True @classmethod @db_update def start(cls, db, wid: int): db.query(cls).filter(cls.id == wid).update({ "state": 'R' }) return True @classmethod @async_db_update async def async_start(cls, db: AsyncSession, wid: int): from sqlalchemy import update await db.execute(update(cls).where(cls.id == wid).values(state='R')) return True @classmethod @db_update def fail(cls, db, wid: int, result: str): db.query(cls).filter(and_(cls.id == wid, cls.state != "P")).update({ "state": 'F', "result": result, "last_time": datetime.now().strftime('%Y-%m-%d %H:%M:%S') }) return True @classmethod @async_db_update async def async_fail(cls, db: AsyncSession, wid: int, result: str): from sqlalchemy import update await db.execute(update(cls).where( and_(cls.id == wid, cls.state != "P") ).values( state='F', result=result, last_time=datetime.now().strftime('%Y-%m-%d %H:%M:%S') )) return True @classmethod @db_update def success(cls, db, wid: int, result: Optional[str] = None): db.query(cls).filter(and_(cls.id == wid, cls.state != "P")).update({ "state": 'S', "result": result, "run_count": cls.run_count + 1, "last_time": datetime.now().strftime('%Y-%m-%d %H:%M:%S') }) return True @classmethod @async_db_update async def async_success(cls, db: AsyncSession, wid: int, result: Optional[str] = None): from sqlalchemy import update await db.execute(update(cls).where( and_(cls.id == wid, cls.state != "P") ).values( state='S', result=result, run_count=cls.run_count + 1, last_time=datetime.now().strftime('%Y-%m-%d %H:%M:%S') )) return True @classmethod @db_update def reset(cls, db, wid: int, reset_count: Optional[bool] = False): db.query(cls).filter(cls.id == wid).update({ "state": 'W', "result": None, "current_action": None, "run_count": 0 if reset_count else cls.run_count, }) return True @classmethod @async_db_update async def async_reset(cls, db: AsyncSession, wid: int, reset_count: Optional[bool] = False): from sqlalchemy import update await db.execute(update(cls).where(cls.id == wid).values( state='W', result=None, current_action=None, run_count=0 if reset_count else cls.run_count, )) return True @classmethod @db_update def update_current_action(cls, db, wid: int, action_id: str, context: dict): db.query(cls).filter(cls.id == wid).update({ "current_action": cls.current_action + f",{action_id}" if cls.current_action else action_id, "context": context }) return True @classmethod @async_db_update async def async_update_current_action(cls, db: AsyncSession, wid: int, action_id: str, context: dict): from sqlalchemy import update # 先获取当前current_action result = await db.execute(select(cls.current_action).where(cls.id == wid)) current_action = result.scalar() new_current_action = current_action + f",{action_id}" if current_action else action_id await db.execute(update(cls).where(cls.id == wid).values( current_action=new_current_action, context=context )) return True ================================================ FILE: app/db/plugindata_oper.py ================================================ from typing import Any, Optional from app.db import DbOper from app.db.models.plugindata import PluginData class PluginDataOper(DbOper): """ 插件数据管理 """ def save(self, plugin_id: str, key: str, value: Any): """ 保存插件数据 :param plugin_id: 插件id :param key: 数据key :param value: 数据值 """ plugin = PluginData.get_plugin_data_by_key(self._db, plugin_id, key) if plugin: plugin.update(self._db, { "value": value }) else: PluginData(plugin_id=plugin_id, key=key, value=value).create(self._db) def get_data(self, plugin_id: str, key: Optional[str] = None) -> Any: """ 获取插件数据 :param plugin_id: 插件id :param key: 数据key """ if key: data = PluginData.get_plugin_data_by_key(self._db, plugin_id, key) if not data: return None return data.value else: return PluginData.get_plugin_data(self._db, plugin_id) def del_data(self, plugin_id: str, key: Optional[str] = None) -> Any: """ 删除插件数据 :param plugin_id: 插件id :param key: 数据key """ if key: PluginData.del_plugin_data_by_key(self._db, plugin_id, key) else: PluginData.del_plugin_data(self._db, plugin_id) def truncate(self): """ 清空插件数据 """ PluginData.truncate(self._db) def get_data_all(self, plugin_id: str) -> Any: """ 获取插件所有数据 :param plugin_id: 插件id """ return PluginData.get_plugin_data_by_plugin_id(self._db, plugin_id) ================================================ FILE: app/db/site_oper.py ================================================ from datetime import datetime from typing import List, Tuple, Optional from app.db import DbOper from app.db.models import SiteIcon from app.db.models.site import Site from app.db.models.sitestatistic import SiteStatistic from app.db.models.siteuserdata import SiteUserData class SiteOper(DbOper): """ 站点管理 """ def add(self, **kwargs) -> Tuple[bool, str]: """ 新增站点 """ site = Site(**kwargs) if not site.get_by_domain(self._db, kwargs.get("domain")): site.create(self._db) return True, "新增站点成功" return False, "站点已存在" def get(self, sid: int) -> Site: """ 查询单个站点 """ return Site.get(self._db, sid) async def async_get(self, sid: int) -> Site: """ 异步查询单个站点 """ return await Site.async_get(self._db, sid) def list(self) -> List[Site]: """ 获取站点列表 """ return Site.list(self._db) async def async_list(self) -> List[Site]: """ 异步获取站点列表 """ return await Site.async_list(self._db) def list_order_by_pri(self) -> List[Site]: """ 获取站点列表 """ return Site.list_order_by_pri(self._db) def list_active(self) -> List[Site]: """ 按状态获取站点列表 """ return Site.get_actives(self._db) async def async_list_active(self) -> List[Site]: """ 异步按状态获取站点列表 """ return await Site.async_get_actives(self._db) def delete(self, sid: int): """ 删除站点 """ Site.delete(self._db, sid) def update(self, sid: int, payload: dict) -> Site: """ 更新站点 """ site = Site.get(self._db, sid) site.update(self._db, payload) return site def get_by_domain(self, domain: str) -> Site: """ 按域名获取站点 """ return Site.get_by_domain(self._db, domain) async def async_get_by_domain(self, domain: str) -> Site: """ 异步按域名获取站点 """ return await Site.async_get_by_domain(self._db, domain) async def async_get_by_name(self, name: str) -> Site: """ 异步按名称获取站点 """ return await Site.async_get_by_name(self._db, name) def get_domains_by_ids(self, ids: List[int]) -> List[str]: """ 按ID获取站点域名 """ return Site.get_domains_by_ids(self._db, ids) def exists(self, domain: str) -> bool: """ 判断站点是否存在 """ return Site.get_by_domain(self._db, domain) is not None def update_cookie(self, domain: str, cookies: str) -> Tuple[bool, str]: """ 更新站点Cookie """ site = Site.get_by_domain(self._db, domain) if not site: return False, "站点不存在" site.update(self._db, { "cookie": cookies }) return True, "更新站点Cookie成功" def update_rss(self, domain: str, rss: str) -> Tuple[bool, str]: """ 更新站点rss """ site = Site.get_by_domain(self._db, domain) if not site: return False, "站点不存在" site.update(self._db, { "rss": rss }) return True, "更新站点RSS地址成功" def update_userdata(self, domain: str, name: str, payload: dict) -> Tuple[bool, str]: """ 更新站点用户数据 """ # 当前系统日期 current_day = datetime.now().strftime('%Y-%m-%d') current_time = datetime.now().strftime('%H:%M:%S') payload.update({ "domain": domain, "name": name, "updated_day": current_day, "updated_time": current_time, "err_msg": payload.get("err_msg") or "" }) # 按站点+天判断是否存在数据 siteuserdatas = SiteUserData.get_by_domain(self._db, domain=domain, workdate=current_day) if siteuserdatas: # 存在则更新 if not payload.get("err_msg"): siteuserdatas[0].update(self._db, payload) else: # 不存在则插入 SiteUserData(**payload).create(self._db) return True, "更新站点用户数据成功" def get_userdata(self) -> List[SiteUserData]: """ 获取站点用户数据 """ return SiteUserData.list(self._db) def get_userdata_by_domain(self, domain: str, workdate: Optional[str] = None) -> List[SiteUserData]: """ 获取站点用户数据 """ return SiteUserData.get_by_domain(self._db, domain=domain, workdate=workdate) def get_userdata_by_date(self, date: str) -> List[SiteUserData]: """ 获取站点用户数据 """ return SiteUserData.get_by_date(self._db, date) def get_userdata_latest(self) -> List[SiteUserData]: """ 获取站点最新数据 """ return SiteUserData.get_latest(self._db) def get_icon_by_domain(self, domain: str) -> SiteIcon: """ 按域名获取站点图标 """ return SiteIcon.get_by_domain(self._db, domain) def update_icon(self, name: str, domain: str, icon_url: str, icon_base64: str) -> bool: """ 更新站点图标 """ icon_base64 = f"data:image/ico;base64,{icon_base64}" if icon_base64 else "" siteicon = self.get_icon_by_domain(domain) if not siteicon: SiteIcon(name=name, domain=domain, url=icon_url, base64=icon_base64).create(self._db) elif icon_base64: siteicon.update(self._db, { "url": icon_url, "base64": icon_base64 }) return True def success(self, domain: str, seconds: Optional[int] = None): """ 站点访问成功 """ lst_date = datetime.now().strftime("%Y-%m-%d %H:%M:%S") sta = SiteStatistic.get_by_domain(self._db, domain) if sta: # 使用深复制确保 note 是全新的字典对象 note = dict(sta.note) if sta.note else {} avg_seconds = None if seconds is not None: note[lst_date] = seconds or 1 avg_times = len(note.keys()) if avg_times > 10: note = dict(sorted(note.items(), key=lambda x: x[0], reverse=True)[:10]) avg_seconds = sum([v for v in note.values()]) // avg_times sta.update(self._db, { "success": sta.success + 1, "seconds": avg_seconds or sta.seconds, "lst_state": 0, "lst_mod_date": lst_date, "note": note }) else: note = {} if seconds is not None: note = { lst_date: seconds or 1 } SiteStatistic( domain=domain, success=1, fail=0, seconds=seconds or 1, lst_state=0, lst_mod_date=lst_date, note=note ).create(self._db) def fail(self, domain: str): """ 站点访问失败 """ lst_date = datetime.now().strftime("%Y-%m-%d %H:%M:%S") sta = SiteStatistic.get_by_domain(self._db, domain) if sta: sta.update(self._db, { "fail": sta.fail + 1, "lst_state": 1, "lst_mod_date": lst_date }) else: SiteStatistic( domain=domain, success=0, fail=1, lst_state=1, lst_mod_date=lst_date ).create(self._db) async def async_success(self, domain: str, seconds: Optional[int] = None): """ 异步站点访问成功 """ lst_date = datetime.now().strftime("%Y-%m-%d %H:%M:%S") sta = await SiteStatistic.async_get_by_domain(self._db, domain) if sta: # 使用深复制确保 note 是全新的字典对象 note = dict(sta.note) if sta.note else {} avg_seconds = None if seconds is not None: note[lst_date] = seconds or 1 avg_times = len(note.keys()) if avg_times > 10: note = dict(sorted(note.items(), key=lambda x: x[0], reverse=True)[:10]) avg_seconds = sum([v for v in note.values()]) // avg_times await sta.async_update(self._db, { "success": sta.success + 1, "seconds": avg_seconds or sta.seconds, "lst_state": 0, "lst_mod_date": lst_date, "note": note }) else: note = {} if seconds is not None: note = { lst_date: seconds or 1 } await SiteStatistic( domain=domain, success=1, fail=0, seconds=seconds or 1, lst_state=0, lst_mod_date=lst_date, note=note ).async_create(self._db) async def async_fail(self, domain: str): """ 异步站点访问失败 """ lst_date = datetime.now().strftime("%Y-%m-%d %H:%M:%S") sta = await SiteStatistic.async_get_by_domain(self._db, domain) if sta: await sta.async_update(self._db, { "fail": sta.fail + 1, "lst_state": 1, "lst_mod_date": lst_date }) else: await SiteStatistic( domain=domain, success=0, fail=1, lst_state=1, lst_mod_date=lst_date ).async_create(self._db) ================================================ FILE: app/db/subscribe_oper.py ================================================ import time from typing import Tuple, List, Optional from app.core.context import MediaInfo from app.db import DbOper from app.db.models.subscribe import Subscribe from app.db.models.subscribehistory import SubscribeHistory class SubscribeOper(DbOper): """ 订阅管理 """ def add(self, mediainfo: MediaInfo, **kwargs) -> Tuple[int, str]: """ 新增订阅 """ subscribe = Subscribe.exists(self._db, tmdbid=mediainfo.tmdb_id, doubanid=mediainfo.douban_id, season=kwargs.get('season')) kwargs.update({ "name": mediainfo.title, "year": mediainfo.year, "type": mediainfo.type.value, "tmdbid": mediainfo.tmdb_id, "imdbid": mediainfo.imdb_id, "tvdbid": mediainfo.tvdb_id, "doubanid": mediainfo.douban_id, "bangumiid": mediainfo.bangumi_id, "episode_group": mediainfo.episode_group, "poster": mediainfo.get_poster_image(), "backdrop": mediainfo.get_backdrop_image(), "vote": mediainfo.vote_average, "description": mediainfo.overview, "search_imdbid": 1 if kwargs.get('search_imdbid') else 0, "date": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()) }) if not subscribe: subscribe = Subscribe(**kwargs) subscribe.create(self._db) # 查询订阅 subscribe = Subscribe.exists(self._db, tmdbid=mediainfo.tmdb_id, doubanid=mediainfo.douban_id, season=kwargs.get('season')) return subscribe.id, "新增订阅成功" else: return subscribe.id, "订阅已存在" async def async_add(self, mediainfo: MediaInfo, **kwargs) -> Tuple[int, str]: """ 异步新增订阅 """ subscribe = await Subscribe.async_exists(self._db, tmdbid=mediainfo.tmdb_id, doubanid=mediainfo.douban_id, season=kwargs.get('season')) kwargs.update({ "name": mediainfo.title, "year": mediainfo.year, "type": mediainfo.type.value, "tmdbid": mediainfo.tmdb_id, "imdbid": mediainfo.imdb_id, "tvdbid": mediainfo.tvdb_id, "doubanid": mediainfo.douban_id, "bangumiid": mediainfo.bangumi_id, "episode_group": mediainfo.episode_group, "poster": mediainfo.get_poster_image(), "backdrop": mediainfo.get_backdrop_image(), "vote": mediainfo.vote_average, "description": mediainfo.overview, "search_imdbid": 1 if kwargs.get('search_imdbid') else 0, "date": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()) }) if not subscribe: subscribe = Subscribe(**kwargs) await subscribe.async_create(self._db) # 查询订阅 subscribe = await Subscribe.async_exists(self._db, tmdbid=mediainfo.tmdb_id, doubanid=mediainfo.douban_id, season=kwargs.get('season')) return subscribe.id, "新增订阅成功" else: return subscribe.id, "订阅已存在" def exists(self, tmdbid: Optional[int] = None, doubanid: Optional[str] = None, season: Optional[int] = None) -> bool: """ 判断是否存在 """ if tmdbid: if season is not None: return True if Subscribe.exists(self._db, tmdbid=tmdbid, season=season) else False else: return True if Subscribe.exists(self._db, tmdbid=tmdbid) else False elif doubanid: return True if Subscribe.exists(self._db, doubanid=doubanid) else False return False def get(self, sid: int) -> Subscribe: """ 获取订阅 """ return Subscribe.get(self._db, rid=sid) async def async_get(self, sid: int) -> Subscribe: """ 获取订阅 """ return await Subscribe.async_get(self._db, rid=sid) def get_by(self, type: str, season: Optional[str] = None, tmdbid: Optional[int] = None, doubanid: Optional[str] = None, bangumiid: Optional[str] = None) -> Optional[Subscribe]: """ 根据条件查询订阅 """ return Subscribe.get_by(self._db, type, season, tmdbid, doubanid, bangumiid) async def async_get_by(self, type: str, season: Optional[str] = None, tmdbid: Optional[int] = None, doubanid: Optional[str] = None, bangumiid: Optional[str] = None) -> Optional[Subscribe]: """ 根据条件查询订阅 """ return await Subscribe.async_get_by(self._db, type, season, tmdbid, doubanid, bangumiid) def list(self, state: Optional[str] = None) -> List[Subscribe]: """ 获取订阅列表 """ if state: return Subscribe.get_by_state(self._db, state) return Subscribe.list(self._db) async def async_list(self, state: Optional[str] = None) -> List[Subscribe]: """ 异步获取订阅列表 """ if state: return await Subscribe.async_get_by_state(self._db, state) return await Subscribe.async_list(self._db) def delete(self, sid: int): """ 删除订阅 """ Subscribe.delete(self._db, rid=sid) def update(self, sid: int, payload: dict) -> Subscribe: """ 更新订阅 """ subscribe = self.get(sid) if subscribe: subscribe.update(self._db, payload) return subscribe def list_by_tmdbid(self, tmdbid: int, season: Optional[int] = None) -> List[Subscribe]: """ 获取指定tmdb_id的订阅 """ return Subscribe.get_by_tmdbid(self._db, tmdbid=tmdbid, season=season) def list_by_username(self, username: str, state: Optional[str] = None, mtype: Optional[str] = None) -> List[Subscribe]: """ 获取指定用户的订阅 """ return Subscribe.list_by_username(self._db, username=username, state=state, mtype=mtype) def list_by_type(self, mtype: str, days: Optional[int] = 7) -> Subscribe: """ 获取指定类型的订阅 """ return Subscribe.list_by_type(self._db, mtype=mtype, days=days) def add_history(self, **kwargs): """ 新增订阅 """ # 去除kwargs中 SubscribeHistory 没有的字段 kwargs = {k: v for k, v in kwargs.items() if hasattr(SubscribeHistory, k)} # 更新完成订阅时间 kwargs.update({"date": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())}) # 去掉主键 if "id" in kwargs: kwargs.pop("id") subscribe = SubscribeHistory(**kwargs) subscribe.create(self._db) def exist_history(self, tmdbid: Optional[int] = None, doubanid: Optional[str] = None, season: Optional[int] = None): """ 判断是否存在订阅历史 """ if tmdbid: if season is not None: return True if SubscribeHistory.exists(self._db, tmdbid=tmdbid, season=season) else False else: return True if SubscribeHistory.exists(self._db, tmdbid=tmdbid) else False elif doubanid: return True if SubscribeHistory.exists(self._db, doubanid=doubanid) else False return False ================================================ FILE: app/db/systemconfig_oper.py ================================================ import asyncio import copy import threading from typing import Any, Optional, Union from app.db import DbOper from app.db.models.systemconfig import SystemConfig from app.schemas.types import SystemConfigKey from app.utils.singleton import Singleton class SystemConfigOper(DbOper, metaclass=Singleton): """ 系统配置管理 """ def __init__(self): """ 加载配置到内存 """ super().__init__() self.__SYSTEMCONF = {} self._rlock = threading.RLock() self._alock = asyncio.Lock() for item in SystemConfig.list(self._db): self.__SYSTEMCONF[item.key] = item.value def set(self, key: Union[str, SystemConfigKey], value: Any) -> Optional[bool]: """ 设置系统设置 :param key: 配置键 :param value: 配置值 :return: 是否设置成功(True 成功/False 失败/None 无需更新) """ if isinstance(key, SystemConfigKey): key = key.value with self._rlock: # 旧值 old_value = self.__SYSTEMCONF.get(key) # 更新内存(deepcopy避免内存共享) self.__SYSTEMCONF[key] = copy.deepcopy(value) conf = SystemConfig.get_by_key(self._db, key) if conf: if old_value != value: if value: conf.update(self._db, {"value": value}) else: conf.delete(self._db, conf.id) return True return None else: conf = SystemConfig(key=key, value=value) conf.create(self._db) return True async def async_set(self, key: Union[str, SystemConfigKey], value: Any) -> Optional[bool]: """ 异步设置系统设置 :param key: 配置键 :param value: 配置值 :return: 是否设置成功(True 成功/False 失败/None 无需更新) """ if isinstance(key, SystemConfigKey): key = key.value async with self._alock: conf = await SystemConfig.async_get_by_key(self._db, key) # 确定是否需要更新数据库 needs_db_update = False if conf: if conf.value != value: needs_db_update = True else: # 记录不存在,总是需要创建/更新 needs_db_update = True if not needs_db_update: # 即使数据库值相同,也要确保缓存同步 with self._rlock: self.__SYSTEMCONF[key] = copy.deepcopy(value) return None # 执行数据库更新 if conf: if value: await conf.async_update(self._db, {"value": value}) else: await conf.async_delete(self._db, conf.id) else: conf = SystemConfig(key=key, value=value) await conf.async_create(self._db) # 数据库更新成功后,再更新缓存 with self._rlock: self.__SYSTEMCONF[key] = copy.deepcopy(value) return True def get(self, key: Union[str, SystemConfigKey] = None) -> Any: """ 获取系统设置 """ if isinstance(key, SystemConfigKey): key = key.value if not key: return self.all() with self._rlock: # 避免将__SYSTEMCONF内的值引用出去,会导致set时误判没有变动 return copy.deepcopy(self.__SYSTEMCONF.get(key)) def all(self): """ 获取所有系统设置 """ with self._rlock: # 避免将__SYSTEMCONF内的值引用出去,会导致set时误判没有变动 return copy.deepcopy(self.__SYSTEMCONF) def delete(self, key: Union[str, SystemConfigKey]) -> bool: """ 删除系统设置 """ if isinstance(key, SystemConfigKey): key = key.value with self._rlock: # 更新内存 self.__SYSTEMCONF.pop(key, None) # 写入数据库 conf = SystemConfig.get_by_key(self._db, key) if conf: conf.delete(self._db, conf.id) return True ================================================ FILE: app/db/transferhistory_oper.py ================================================ import time from typing import Any, List, Optional from app.core.context import MediaInfo from app.core.meta import MetaBase from app.db import DbOper from app.db.models.transferhistory import TransferHistory from app.schemas import TransferInfo, FileItem class TransferHistoryOper(DbOper): """ 转移历史管理 """ def get(self, historyid: int) -> TransferHistory: """ 获取转移历史 :param historyid: 转移历史id """ return TransferHistory.get(self._db, historyid) def get_by_title(self, title: str) -> List[TransferHistory]: """ 按标题查询转移记录 :param title: 数据key """ return TransferHistory.list_by_title(self._db, title) def get_by_src(self, src: str, storage: Optional[str] = None) -> TransferHistory: """ 按源查询转移记录 :param src: 数据key :param storage: 存储类型 """ return TransferHistory.get_by_src(self._db, src, storage) def get_by_dest(self, dest: str) -> TransferHistory: """ 按转移路径查询转移记录 :param dest: 数据key """ return TransferHistory.get_by_dest(self._db, dest) def list_by_hash(self, download_hash: str) -> List[TransferHistory]: """ 按种子hash查询转移记录 :param download_hash: 种子hash """ return TransferHistory.list_by_hash(self._db, download_hash) def add(self, **kwargs): """ 新增转移历史 """ kwargs.update({ "date": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()) }) TransferHistory(**kwargs).create(self._db) def statistic(self, days: Optional[int] = 7) -> List[Any]: """ 统计最近days天的下载历史数量 """ return TransferHistory.statistic(self._db, days) def get_by(self, title: Optional[str] = None, year: Optional[str] = None, mtype: Optional[str] = None, season: Optional[str] = None, episode: Optional[str] = None, tmdbid: Optional[int] = None, dest: Optional[str] = None) -> List[TransferHistory]: """ 按类型、标题、年份、季集查询转移记录 """ return TransferHistory.list_by(db=self._db, mtype=mtype, title=title, dest=dest, year=year, season=season, episode=episode, tmdbid=tmdbid) def get_by_type_tmdbid(self, mtype: Optional[str] = None, tmdbid: Optional[int] = None) -> TransferHistory: """ 按类型、tmdb查询转移记录 """ return TransferHistory.get_by_type_tmdbid(db=self._db, mtype=mtype, tmdbid=tmdbid) def delete(self, historyid): """ 删除转移记录 """ TransferHistory.delete(self._db, historyid) def truncate(self): """ 清空转移记录 """ TransferHistory.truncate(self._db) def add_force(self, **kwargs) -> TransferHistory: """ 新增转移历史,相同源目录的记录会被删除 """ if kwargs.get("src"): transferhistory = TransferHistory.get_by_src(self._db, kwargs.get("src")) if transferhistory: transferhistory.delete(self._db, transferhistory.id) kwargs.update({ "date": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()) }) TransferHistory(**kwargs).create(self._db) return TransferHistory.get_by_src(self._db, kwargs.get("src")) def update_download_hash(self, historyid, download_hash): """ 补充转移记录download_hash """ TransferHistory.update_download_hash(self._db, historyid, download_hash) def add_success(self, fileitem: FileItem, mode: str, meta: MetaBase, mediainfo: MediaInfo, transferinfo: TransferInfo, downloader: Optional[str] = None, download_hash: Optional[str] = None): """ 新增转移成功历史记录 """ return self.add_force( src=fileitem.path, src_storage=fileitem.storage, src_fileitem=fileitem.model_dump(), dest=transferinfo.target_item.path if transferinfo.target_item else None, dest_storage=transferinfo.target_item.storage if transferinfo.target_item else None, dest_fileitem=transferinfo.target_item.model_dump() if transferinfo.target_item else None, mode=mode, type=mediainfo.type.value, category=mediainfo.category, title=mediainfo.title, year=mediainfo.year, tmdbid=mediainfo.tmdb_id, imdbid=mediainfo.imdb_id, tvdbid=mediainfo.tvdb_id, doubanid=mediainfo.douban_id, seasons=meta.season, episodes=meta.episode, image=mediainfo.get_poster_image(), downloader=downloader, download_hash=download_hash, status=1, files=transferinfo.file_list ) def add_fail(self, fileitem: FileItem, mode: str, meta: MetaBase, mediainfo: MediaInfo = None, transferinfo: TransferInfo = None, downloader: Optional[str] = None, download_hash: Optional[str] = None): """ 新增转移失败历史记录 """ if mediainfo and transferinfo: his = self.add_force( src=fileitem.path, src_storage=fileitem.storage, src_fileitem=fileitem.model_dump(), dest=transferinfo.target_item.path if transferinfo.target_item else None, dest_storage=transferinfo.target_item.storage if transferinfo.target_item else None, dest_fileitem=transferinfo.target_item.model_dump() if transferinfo.target_item else None, mode=mode, type=mediainfo.type.value, category=mediainfo.category, title=mediainfo.title or meta.name, year=mediainfo.year or meta.year, tmdbid=mediainfo.tmdb_id, imdbid=mediainfo.imdb_id, tvdbid=mediainfo.tvdb_id, doubanid=mediainfo.douban_id, seasons=meta.season, episodes=meta.episode, image=mediainfo.get_poster_image(), downloader=downloader, download_hash=download_hash, episode_group=mediainfo.episode_group, status=0, errmsg=transferinfo.message or '未知错误', files=transferinfo.file_list ) else: his = self.add_force( title=meta.name, year=meta.year, src=fileitem.path, src_storage=fileitem.storage, src_fileitem=fileitem.model_dump(), mode=mode, seasons=meta.season, episodes=meta.episode, downloader=downloader, download_hash=download_hash, status=0, errmsg="未识别到媒体信息" ) return his def list_by_date(self, date: str) -> List[TransferHistory]: """ 查询某时间之后的转移历史 :param date: 日期 """ return TransferHistory.list_by_date(self._db, date) ================================================ FILE: app/db/user_oper.py ================================================ from typing import Optional, List from fastapi import Depends, HTTPException from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import Session from app import schemas from app.core.security import verify_token from app.db import DbOper, get_db, get_async_db from app.db.models.user import User def get_current_user( db: Session = Depends(get_db), token_data: schemas.TokenPayload = Depends(verify_token) ) -> User: """ 获取当前用户 """ user = User.get(db, rid=token_data.sub) if not user: raise HTTPException(status_code=403, detail="用户不存在") return user async def get_current_user_async( db: AsyncSession = Depends(get_async_db), token_data: schemas.TokenPayload = Depends(verify_token) ) -> User: """ 异步获取当前用户 """ user = await User.async_get(db, rid=token_data.sub) if not user: raise HTTPException(status_code=403, detail="用户不存在") return user def get_current_active_user( current_user: User = Depends(get_current_user), ) -> User: """ 获取当前激活用户 """ if not current_user.is_active: raise HTTPException(status_code=403, detail="用户未激活") return current_user async def get_current_active_user_async( current_user: User = Depends(get_current_user_async), ) -> User: """ 异步获取当前激活用户 """ if not current_user.is_active: raise HTTPException(status_code=403, detail="用户未激活") return current_user def get_current_active_superuser( current_user: User = Depends(get_current_user), ) -> User: """ 获取当前激活超级管理员 """ if not current_user.is_superuser: raise HTTPException( status_code=400, detail="用户权限不足" ) return current_user async def get_current_active_superuser_async( current_user: User = Depends(get_current_user_async), ) -> User: """ 异步获取当前激活超级管理员 """ if not current_user.is_superuser: raise HTTPException( status_code=400, detail="用户权限不足" ) return current_user class UserOper(DbOper): """ 用户管理 """ def list(self) -> List[User]: """ 获取用户列表 """ return User.list(self._db) def add(self, **kwargs): """ 新增用户 """ user = User(**kwargs) user.create(self._db) def get_by_name(self, name: str) -> User: """ 根据用户名获取用户 """ return User.get_by_name(self._db, name) def get_permissions(self, name: str) -> dict: """ 获取用户权限 """ user = User.get_by_name(self._db, name) if user: return user.permissions or {} return {} def get_settings(self, name: str) -> Optional[dict]: """ 获取用户个性化设置,返回None表示用户不存在 """ user = User.get_by_name(self._db, name) if user: return user.settings or {} return None def get_setting(self, name: str, key: str) -> Optional[str]: """ 获取用户个性化设置 """ settings = self.get_settings(name) if settings: return settings.get(key) return None def get_name(self, **kwargs) -> Optional[str]: """ 根据绑定账号获取用户名称 """ users = self.list() for user in users: user_setting = user.settings if user_setting: for k, v in kwargs.items(): if user_setting.get(k) == str(v): return user.name return None ================================================ FILE: app/db/userconfig_oper.py ================================================ from typing import Any, Union, Dict, Optional from app.db import DbOper from app.db.models.userconfig import UserConfig from app.schemas.types import UserConfigKey from app.utils.singleton import Singleton class UserConfigOper(DbOper, metaclass=Singleton): """ 用户配置管理 """ def __init__(self): """ 加载配置到内存 """ super().__init__() self.__USERCONF = {} for item in UserConfig.list(self._db): self.__set_config_cache(username=item.username, key=item.key, value=item.value) def set(self, username: str, key: Union[str, UserConfigKey], value: Any): """ 设置用户配置 """ if isinstance(key, UserConfigKey): key = key.value # 更新内存 self.__set_config_cache(username=username, key=key, value=value) # 写入数据库 conf = UserConfig.get_by_key(db=self._db, username=username, key=key) if conf: if value: conf.update(self._db, {"value": value}) else: conf.delete(self._db, conf.id) else: conf = UserConfig(username=username, key=key, value=value) conf.create(self._db) def get(self, username: str, key: Union[str, UserConfigKey] = None) -> Any: """ 获取用户配置 """ if not username: return self.__USERCONF if isinstance(key, UserConfigKey): key = key.value if not key: return self.__get_config_caches(username=username) return self.__get_config_cache(username=username, key=key) def __set_config_cache(self, username: str, key: str, value: Any): """ 设置配置缓存 """ if not username or not key: return cache = self.__USERCONF if not cache: cache = {} user_cache = cache.get(username) if not user_cache: user_cache = {} cache[username] = user_cache user_cache[key] = value self.__USERCONF = cache def __get_config_caches(self, username: str) -> Optional[Dict[str, Any]]: """ 获取配置缓存 """ if not username or not self.__USERCONF: return None return self.__USERCONF.get(username) def __get_config_cache(self, username: str, key: str) -> Any: """ 获取配置缓存 """ if not username or not key or not self.__USERCONF: return None user_cache = self.__get_config_caches(username) if not user_cache: return None return user_cache.get(key) ================================================ FILE: app/db/workflow_oper.py ================================================ from typing import List, Tuple, Optional, Any, Coroutine, Sequence from app.db import DbOper from app.db.models.workflow import Workflow class WorkflowOper(DbOper): """ 工作流管理 """ def add(self, **kwargs) -> Tuple[bool, str]: """ 新增工作流 """ wf = Workflow(**kwargs) if not wf.get_by_name(self._db, kwargs.get("name")): wf.create(self._db) return True, "新增工作流成功" return False, "工作流已存在" def get(self, wid: int) -> Workflow: """ 查询单个工作流 """ return Workflow.get(self._db, wid) async def async_get(self, wid: int) -> Workflow: """ 异步查询单个工作流 """ return await Workflow.async_get(self._db, wid) def list(self) -> List[Workflow]: """ 获取所有工作流列表 """ return Workflow.list(self._db) async def async_list(self) -> Coroutine[Any, Any, Sequence[Any]]: """ 异步获取所有工作流列表 """ return await Workflow.async_list(self._db) def list_enabled(self) -> List[Workflow]: """ 获取启用的工作流列表 """ return Workflow.get_enabled_workflows(self._db) def get_timer_triggered_workflows(self) -> List[Workflow]: """ 获取定时触发的工作流列表 """ return Workflow.get_timer_triggered_workflows(self._db) def get_event_triggered_workflows(self) -> List[Workflow]: """ 获取事件触发的工作流列表 """ return Workflow.get_event_triggered_workflows(self._db) def get_by_name(self, name: str) -> Workflow: """ 按名称获取工作流 """ return Workflow.get_by_name(self._db, name) async def async_get_by_name(self, name: str) -> Workflow: """ 异步按名称获取工作流 """ return await Workflow.async_get_by_name(self._db, name) def start(self, wid: int) -> bool: """ 启动 """ return Workflow.start(self._db, wid) def success(self, wid: int, result: Optional[str] = None) -> bool: """ 成功 """ return Workflow.success(self._db, wid, result) def fail(self, wid: int, result: str) -> bool: """ 失败 """ return Workflow.fail(self._db, wid, result) def step(self, wid: int, action_id: str, context: dict) -> bool: """ 步进 """ return Workflow.update_current_action(self._db, wid, action_id, context) def reset(self, wid: int, reset_count: bool = False) -> bool: """ 重置 """ return Workflow.reset(self._db, wid, reset_count=reset_count) ================================================ FILE: app/factory.py ================================================ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from app.core.config import settings from app.startup.lifecycle import lifespan def create_app() -> FastAPI: """ 创建并配置 FastAPI 应用实例。 """ _app = FastAPI( title=settings.PROJECT_NAME, openapi_url=f"{settings.API_V1_STR}/openapi.json", lifespan=lifespan ) # 配置 CORS 中间件 _app.add_middleware( CORSMiddleware, # noqa allow_origins=settings.ALLOWED_HOSTS, allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) return _app # 创建 FastAPI 应用实例 app = create_app() ================================================ FILE: app/helper/__init__.py ================================================ from .cloudflare import under_challenge ================================================ FILE: app/helper/browser.py ================================================ import uuid from typing import Callable, Any, Optional from cf_clearance import sync_cf_retry, sync_stealth from playwright.sync_api import sync_playwright, Page from app.core.config import settings from app.log import logger from app.utils.http import RequestUtils, cookie_parse class PlaywrightHelper: def __init__(self, browser_type=settings.PLAYWRIGHT_BROWSER_TYPE): self.browser_type = browser_type @staticmethod def __pass_cloudflare(url: str, page: Page) -> bool: """ 尝试跳过cloudfare验证 """ sync_stealth(page, pure=True) page.goto(url) return sync_cf_retry(page)[0] @staticmethod def __fs_cookie_str(cookies: list) -> str: if not cookies: return "" return "; ".join([f"{c.get('name')}={c.get('value')}" for c in cookies if c and c.get('name') is not None]) @staticmethod def __flaresolverr_request(url: str, cookies: Optional[str] = None, proxy_config: Optional[dict] = None, timeout: Optional[int] = 60) -> Optional[dict]: """ 调用 FlareSolverr 解决 Cloudflare 并返回 solution 结果 参考: https://github.com/FlareSolverr/FlareSolverr """ if not settings.FLARESOLVERR_URL: logger.warn("未配置 FLARESOLVERR_URL,无法使用 FlareSolverr") return None fs_api = settings.FLARESOLVERR_URL.rstrip("/") + "/v1" session_id = None try: # 检查是否需要代理认证 need_proxy_auth = (proxy_config and proxy_config.get("server") and (proxy_config.get("username") or proxy_config.get("password"))) if need_proxy_auth: # 使用 session 模式支持代理认证 logger.debug("检测到flaresolverr代理需要认证,使用 session 模式") # 1. 创建会话 session_id = str(uuid.uuid4()) create_payload: dict = { "cmd": "sessions.create", "session": session_id } # 添加代理配置到会话创建请求 if proxy_config and proxy_config.get("server"): proxy_payload: dict = {"url": proxy_config["server"]} if proxy_config.get("username"): proxy_payload["username"] = proxy_config["username"] if proxy_config.get("password"): proxy_payload["password"] = proxy_config["password"] create_payload["proxy"] = proxy_payload # 创建会话 create_result = RequestUtils(content_type="application/json", timeout=timeout or 60).post_json(url=fs_api, json=create_payload) if not create_result or create_result.get("status") != "ok": logger.error( f"创建 FlareSolverr 会话失败: {create_result.get('message') if create_result else '无响应'}") return None # 2. 使用会话发送请求 request_payload = { "cmd": "request.get", "url": url, "session": session_id, "maxTimeout": int(timeout or 60) * 1000, } else: # 使用普通模式(无代理认证) request_payload = { "cmd": "request.get", "url": url, "maxTimeout": int(timeout or 60) * 1000, } # 添加代理配置(仅 URL,无认证) if proxy_config and proxy_config.get("server"): request_payload["proxy"] = {"url": proxy_config["server"]} # 将 cookies 以数组形式传递给 FlareSolverr if cookies: try: request_payload["cookies"] = cookie_parse(cookies, array=True) except Exception as e: logger.debug(f"解析 cookies 失败,忽略: {str(e)}") # 发送请求 data = RequestUtils(content_type="application/json", timeout=timeout or 60).post_json(url=fs_api, json=request_payload) if not data: logger.error("FlareSolverr 返回空响应") return None if data.get("status") != "ok": logger.error(f"FlareSolverr 调用失败: {data.get('message')}") return None return data.get("solution") except Exception as e: logger.error(f"调用 FlareSolverr 失败: {str(e)}") return None finally: # 清理会话 if session_id: try: destroy_payload = { "cmd": "sessions.destroy", "session": session_id } RequestUtils(content_type="application/json", timeout=10).post_json(url=fs_api, json=destroy_payload) logger.debug(f"已清理 FlareSolverr 会话: {session_id}") except Exception as e: logger.warning(f"清理 FlareSolverr 会话失败: {str(e)}") def action(self, url: str, callback: Callable, cookies: Optional[str] = None, ua: Optional[str] = None, proxies: Optional[dict] = None, headless: Optional[bool] = False, timeout: Optional[int] = 60) -> Any: """ 访问网页,接收Page对象并执行操作 :param url: 网页地址 :param callback: 回调函数,需要接收page对象 :param cookies: cookies :param ua: user-agent :param proxies: 代理 :param headless: 是否无头模式 :param timeout: 超时时间 """ result = None try: with sync_playwright() as playwright: browser = None context = None page = None try: # 如果配置使用 FlareSolverr,先通过其获取清除后的 cookies 与 UA fs_cookie_header = None fs_ua = None if settings.BROWSER_EMULATION == "flaresolverr": solution = self.__flaresolverr_request(url=url, cookies=cookies, proxy_config=proxies, timeout=timeout) if solution: fs_cookie_header = self.__fs_cookie_str(solution.get("cookies", [])) fs_ua = solution.get("userAgent") browser = playwright[self.browser_type].launch(headless=headless) context = browser.new_context(user_agent=fs_ua or ua, proxy=proxies) page = context.new_page() # 优先使用 FlareSolverr 返回,其次使用入参 merged_cookie = fs_cookie_header or cookies if merged_cookie: page.set_extra_http_headers({"cookie": merged_cookie}) if settings.BROWSER_EMULATION == "playwright": if not self.__pass_cloudflare(url, page): logger.warn("cloudflare challenge fail!") else: page.goto(url) page.wait_for_load_state("networkidle", timeout=timeout * 1000) # 回调函数 result = callback(page) except Exception as e: logger.error(f"网页操作失败: {str(e)}") finally: if page: page.close() if context: context.close() if browser: browser.close() except Exception as e: logger.error(f"Playwright初始化失败: {str(e)}") return result def get_page_source(self, url: str, cookies: Optional[str] = None, ua: Optional[str] = None, proxies: Optional[dict] = None, headless: Optional[bool] = False, timeout: Optional[int] = 60) -> Optional[str]: """ 获取网页源码 :param url: 网页地址 :param cookies: cookies :param ua: user-agent :param proxies: 代理 :param headless: 是否无头模式 :param timeout: 超时时间 """ source = None # 如果配置为 FlareSolverr,则直接调用获取页面源码 if settings.BROWSER_EMULATION == "flaresolverr": try: solution = self.__flaresolverr_request(url=url, cookies=cookies, proxy_config=proxies, timeout=timeout) if solution: return solution.get("response") except Exception as e: logger.error(f"FlareSolverr 获取源码失败: {str(e)}") try: with sync_playwright() as playwright: browser = None context = None page = None try: browser = playwright[self.browser_type].launch(headless=headless) context = browser.new_context(user_agent=ua, proxy=proxies) page = context.new_page() if cookies: page.set_extra_http_headers({"cookie": cookies}) if not self.__pass_cloudflare(url, page): logger.warn("cloudflare challenge fail!") page.wait_for_load_state("networkidle", timeout=timeout * 1000) source = page.content() except Exception as e: logger.error(f"获取网页源码失败: {str(e)}") source = None finally: # 确保资源被正确清理 if page: page.close() if context: context.close() if browser: browser.close() except Exception as e: logger.error(f"Playwright初始化失败: {str(e)}") return source ================================================ FILE: app/helper/cloudflare.py ================================================ import os from pyquery import PyQuery from app.log import logger CHALLENGE_TITLES = [ # Cloudflare 'Just a moment...', '请稍候…', # DDoS-GUARD 'DDOS-GUARD', ] CHALLENGE_SELECTORS = [ # Cloudflare '#cf-challenge-running', '.ray_id', '.attack-box', '#cf-please-wait', '#challenge-spinner', '#trk_jschal_js', # Custom CloudFlare for EbookParadijs, Film-Paleis, MuziekFabriek and Puur-Hollands 'td.info #js_info', # Fairlane / pararius.com 'div.vc div.text-box h2' ] SHORT_TIMEOUT = 6 CF_TIMEOUT = int(os.getenv("NASTOOL_CF_TIMEOUT", "60")) def under_challenge(html_text: str): """ Check if the page is under challenge :param html_text: :return: """ # get the page title if not html_text: return False page_title = PyQuery(html_text)('title').text() logger.debug("under_challenge page_title=" + page_title) for title in CHALLENGE_TITLES: if page_title.lower() == title.lower(): return True for selector in CHALLENGE_SELECTORS: html_doc = PyQuery(html_text) if html_doc(selector): return True return False ================================================ FILE: app/helper/cookie.py ================================================ import base64 from typing import Tuple, Optional from lxml import etree from playwright.sync_api import Page from app.helper.browser import PlaywrightHelper from app.helper.ocr import OcrHelper from app.helper.twofa import TwoFactorAuth from app.log import logger from app.utils.http import RequestUtils from app.utils.site import SiteUtils from app.utils.string import StringUtils class CookieHelper: # 站点登录界面元素XPATH _SITE_LOGIN_XPATH = { "username": [ '//input[@name="username"]', '//input[@id="form_item_username"]', '//input[@id="username"]', ], "password": [ '//input[@name="password"]', '//input[@id="form_item_password"]', '//input[@id="password"]', '//input[@type="password"]', ], "captcha": [ '//input[@name="imagestring"]', '//input[@name="captcha"]', '//input[@id="form_item_captcha"]', '//input[@placeholder="驗證碼"]', ], "captcha_img": [ '//img[@alt="captcha"]/@src', '//img[@alt="CAPTCHA"]/@src', '//img[@alt="SECURITY CODE"]/@src', '//img[@id="LAY-user-get-vercode"]/@src', '//img[contains(@src,"/api/getCaptcha")]/@src', ], "submit": [ '//input[@type="submit"]', '//button[@type="submit"]', '//button[@lay-filter="login"]', '//button[@lay-filter="formLogin"]', '//input[@type="button"][@value="登录"]', '//input[@id="submit-btn"]', ], "error": [ "//table[@class='main']//td[@class='text']/text()", ], "twostep": [ '//input[@name="two_step_code"]', '//input[@name="2fa_secret"]', '//input[@name="otp"]', ] } @staticmethod def parse_cookies(cookies: list) -> str: """ 将浏览器返回的cookies转化为字符串 """ if not cookies: return "" cookie_str = "" for cookie in cookies: cookie_str += f"{cookie['name']}={cookie['value']}; " return cookie_str def get_site_cookie_ua(self, url: str, username: str, password: str, two_step_code: Optional[str] = None, proxies: Optional[dict] = None, timeout: int = None) -> Tuple[Optional[str], Optional[str], str]: """ 获取站点cookie和ua :param url: 站点地址 :param username: 用户名 :param password: 密码 :param two_step_code: 二步验证码或密钥 :param proxies: 代理 :param timeout: 超时时间 :return: cookie、ua、message """ def __page_handler(page: Page) -> Tuple[Optional[str], Optional[str], str]: """ 页面处理 :return: Cookie和UA """ # 登录页面代码 html_text = page.content() if not html_text: return None, None, "获取源码失败" # 查找用户名输入框 html = etree.HTML(html_text) try: username_xpath = None for xpath in self._SITE_LOGIN_XPATH.get("username"): if html.xpath(xpath): username_xpath = xpath break if not username_xpath: return None, None, "未找到用户名输入框" # 查找密码输入框 password_xpath = None for xpath in self._SITE_LOGIN_XPATH.get("password"): if html.xpath(xpath): password_xpath = xpath break if not password_xpath: return None, None, "未找到密码输入框" # 处理二步验证码 otp_code = TwoFactorAuth(two_step_code).get_code() # 查找二步验证码输入框 twostep_xpath = None if otp_code: for xpath in self._SITE_LOGIN_XPATH.get("twostep"): if html.xpath(xpath): twostep_xpath = xpath break # 查找验证码输入框 captcha_xpath = None for xpath in self._SITE_LOGIN_XPATH.get("captcha"): if html.xpath(xpath): captcha_xpath = xpath break # 查找验证码图片 captcha_img_url = None if captcha_xpath: for xpath in self._SITE_LOGIN_XPATH.get("captcha_img"): if html.xpath(xpath): captcha_img_url = html.xpath(xpath)[0] break if not captcha_img_url: return None, None, "未找到验证码图片" # 查找登录按钮 submit_xpath = None for xpath in self._SITE_LOGIN_XPATH.get("submit"): if html.xpath(xpath): submit_xpath = xpath break if not submit_xpath: return None, None, "未找到登录按钮" # 点击登录按钮 try: # 等待登录按钮准备好 page.wait_for_selector(submit_xpath) # 输入用户名 page.fill(username_xpath, username) # 输入密码 page.fill(password_xpath, password) # 输入二步验证码 if twostep_xpath: page.fill(twostep_xpath, otp_code) # 识别验证码 if captcha_xpath and captcha_img_url: captcha_element = page.query_selector(captcha_xpath) if captcha_element.is_visible(): # 验证码图片地址 code_url = self.__get_captcha_url(url, captcha_img_url) # 获取当前的cookie和ua cookie = self.parse_cookies(page.context.cookies()) ua = page.evaluate("() => window.navigator.userAgent") # 自动OCR识别验证码 captcha = self.__get_captcha_text(cookie=cookie, ua=ua, code_url=code_url) if captcha: logger.info("验证码地址为:%s,识别结果:%s" % (code_url, captcha)) else: return None, None, "验证码识别失败" # 输入验证码 captcha_element.fill(captcha) else: # 不可见元素不处理 pass # 点击登录按钮 page.click(submit_xpath) page.wait_for_load_state("networkidle", timeout=30 * 1000) except Exception as e: logger.error(f"仿真登录失败:{str(e)}") return None, None, f"仿真登录失败:{str(e)}" # 对于某二次验证码为单页面的站点,输入二次验证码 if "verify" in page.url: if not otp_code: return None, None, "需要二次验证码" html = etree.HTML(page.content()) for xpath in self._SITE_LOGIN_XPATH.get("twostep"): if html.xpath(xpath): try: # 刷新一下 2fa code otp_code = TwoFactorAuth(two_step_code).get_code() page.fill(xpath, otp_code) # 登录按钮 xpath 理论上相同,不再重复查找 page.click(submit_xpath) page.wait_for_load_state("networkidle", timeout=30 * 1000) except Exception as e: logger.error(f"二次验证码输入失败:{str(e)}") return None, None, f"二次验证码输入失败:{str(e)}" break # 登录后的源码 html_text = page.content() if not html_text: return None, None, "获取网页源码失败" if SiteUtils.is_logged_in(html_text): return self.parse_cookies(page.context.cookies()), \ page.evaluate("() => window.navigator.userAgent"), "" else: # 读取错误信息 error_xpath = None for xpath in self._SITE_LOGIN_XPATH.get("error"): if html.xpath(xpath): error_xpath = xpath break if not error_xpath: return None, None, "登录失败" else: error_msg = html.xpath(error_xpath)[0] return None, None, error_msg finally: if html: del html if not url or not username or not password: return None, None, "参数错误" return PlaywrightHelper().action(url=url, callback=__page_handler, proxies=proxies, timeout=timeout) @staticmethod def __get_captcha_text(cookie: str, ua: str, code_url: str) -> str: """ 识别验证码图片的内容 """ if not code_url: return "" ret = RequestUtils(ua=ua, cookies=cookie).get_res(code_url) if ret: if not ret.content: return "" return OcrHelper().get_captcha_text( image_b64=base64.b64encode(ret.content).decode() ) else: return "" @staticmethod def __get_captcha_url(siteurl: str, imageurl: str) -> str: """ 获取验证码图片的URL """ if not siteurl or not imageurl: return "" if imageurl.startswith("/"): imageurl = imageurl[1:] return "%s/%s" % (StringUtils.get_base_url(siteurl), imageurl) ================================================ FILE: app/helper/cookiecloud.py ================================================ import json from typing import Any, Dict, Tuple, Optional from app.core.config import settings from app.log import logger from app.utils.crypto import CryptoJsUtils, HashUtils from app.utils.http import RequestUtils from app.utils.string import StringUtils from app.utils.url import UrlUtils class CookieCloudHelper: _ignore_cookies: list = ["CookieAutoDeleteBrowsingDataCleanup", "CookieAutoDeleteCleaningDiscarded"] def __init__(self): self.__sync_setting() def __sync_setting(self): """ 同步CookieCloud配置项 """ self._server = UrlUtils.standardize_base_url(settings.COOKIECLOUD_HOST) self._key = StringUtils.safe_strip(settings.COOKIECLOUD_KEY) self._password = StringUtils.safe_strip(settings.COOKIECLOUD_PASSWORD) self._enable_local = settings.COOKIECLOUD_ENABLE_LOCAL self._local_path = settings.COOKIE_PATH def download(self) -> Tuple[Optional[dict], str]: """ 从CookieCloud下载数据 :return: Cookie数据、错误信息 """ # 更新为最新设置 self.__sync_setting() if ((not self._server and not self._enable_local) or not self._key or not self._password): return None, "CookieCloud参数不正确" if self._enable_local: # 开启本地服务时,从本地直接读取数据 result = self.__load_local_encrypt_data(self._key) if not result: return {}, "未从本地CookieCloud服务加载到cookie数据,请检查服务器设置、用户KEY及加密密码是否正确" else: req_url = UrlUtils.combine_url(host=self._server, path=f"get/{self._key}") ret = RequestUtils(content_type="application/json").get_res(url=req_url) if ret and ret.status_code == 200: try: result = ret.json() if not result: return {}, f"未从{self._server}下载到cookie数据" except Exception as err: return {}, f"从{self._server}下载cookie数据错误:{str(err)}" elif ret: return None, f"远程同步CookieCloud失败,错误码:{ret.status_code}" else: return None, "CookieCloud请求失败,请检查服务器地址、用户KEY及加密密码是否正确" encrypted = result.get("encrypted") if not encrypted: return {}, "未获取到cookie密文" else: crypt_key = self.__get_crypt_key() try: decrypted_data = CryptoJsUtils.decrypt(encrypted, crypt_key).decode("utf-8") result = json.loads(decrypted_data) except Exception as e: return {}, "cookie解密失败:" + str(e) if not result: return {}, "cookie解密为空" if result.get("cookie_data"): contents = result.get("cookie_data") else: contents = result # 整理数据,使用domain域名的最后两级作为分组依据 domain_groups = {} for site, cookies in contents.items(): for cookie in cookies: domain_key = StringUtils.get_url_domain(cookie.get("domain")) if not domain_groups.get(domain_key): domain_groups[domain_key] = [cookie] else: domain_groups[domain_key].append(cookie) # 返回错误 ret_cookies = {} # 索引器 for domain, content_list in domain_groups.items(): if not content_list: continue # 只有cf的cookie过滤掉 cloudflare_cookie = True for content in content_list: if content["name"] != "cf_clearance": cloudflare_cookie = False break if cloudflare_cookie: continue # 站点Cookie cookie_str = ";".join( [f"{content.get('name')}={content.get('value')}" for content in content_list if content.get("name") and content.get("name") not in self._ignore_cookies] ) ret_cookies[domain] = cookie_str return ret_cookies, "" def __get_crypt_key(self) -> bytes: """ 使用UUID和密码生成CookieCloud的加解密密钥 """ combined_string = f"{self._key}-{self._password}" return HashUtils.md5(combined_string)[:16].encode("utf-8") def __load_local_encrypt_data(self, uuid: str) -> Dict[str, Any]: """ 获取本地CookieCloud数据 """ file_path = self._local_path / f"{uuid}.json" # 检查文件是否存在 if not file_path.exists(): logger.warn(f"本地CookieCloud文件不存在:{file_path}") return {} # 读取文件 with open(file_path, encoding="utf-8", mode="r") as file: read_content = file.read() data = json.loads(read_content.encode("utf-8")) return data ================================================ FILE: app/helper/directory.py ================================================ import re from pathlib import Path from typing import List, Optional, Tuple from app import schemas from app.core.context import MediaInfo from app.db.systemconfig_oper import SystemConfigOper from app.log import logger from app.schemas.types import SystemConfigKey from app.utils.system import SystemUtils JINJA2_VAR_PATTERN = re.compile(r"\{\{.*?}}", re.DOTALL) class DirectoryHelper: """ 下载目录/媒体库目录帮助类 """ @staticmethod def get_dirs() -> List[schemas.TransferDirectoryConf]: """ 获取所有下载目录 """ dir_confs: List[dict] = SystemConfigOper().get(SystemConfigKey.Directories) if not dir_confs: return [] return [schemas.TransferDirectoryConf(**d) for d in dir_confs] def get_download_dirs(self) -> List[schemas.TransferDirectoryConf]: """ 获取所有下载目录 """ return sorted([d for d in self.get_dirs() if d.download_path], key=lambda x: x.priority) def get_local_download_dirs(self) -> List[schemas.TransferDirectoryConf]: """ 获取所有本地的可下载目录 """ return [d for d in self.get_download_dirs() if d.storage == "local"] def get_library_dirs(self) -> List[schemas.TransferDirectoryConf]: """ 获取所有媒体库目录 """ return sorted([d for d in self.get_dirs() if d.library_path], key=lambda x: x.priority) def get_local_library_dirs(self) -> List[schemas.TransferDirectoryConf]: """ 获取所有本地的媒体库目录 """ return [d for d in self.get_library_dirs() if d.library_storage == "local"] def get_dir(self, media: Optional[MediaInfo], include_unsorted: Optional[bool] = False, storage: Optional[str] = None, src_path: Path = None, target_storage: Optional[str] = None, dest_path: Path = None ) -> Optional[schemas.TransferDirectoryConf]: """ 根据媒体信息获取下载目录、媒体库目录配置 :param media: 媒体信息 :param include_unsorted: 包含不整理目录 :param storage: 源存储类型 :param target_storage: 目标存储类型 :param src_path: 源目录,有值时直接匹配 :param dest_path: 目标目录,有值时直接匹配 """ # 电影/电视剧 media_type = media.type.value if media else None dirs = self.get_dirs() # 如果存在源目录,并源目录为任一下载目录的子目录时,则进行源目录匹配,否则,允许源目录按同盘优先的逻辑匹配 matching_dirs = [d for d in dirs if src_path.is_relative_to(d.download_path)] if src_path else [] # 根据是否有匹配的源目录,决定要考虑的目录集合 dirs_to_consider = matching_dirs if matching_dirs else dirs # 已匹配的目录 matched_dirs: List[schemas.TransferDirectoryConf] = [] # 按照配置顺序查找 for d in dirs_to_consider: # 没有启用整理的目录 if not d.monitor_type and not include_unsorted: continue # 源存储类型不匹配 if storage and d.storage != storage: continue # 目标存储类型不匹配 if target_storage and d.library_storage != target_storage: continue # 有目标目录时,目标目录不匹配媒体库目录 if dest_path and dest_path != Path(d.library_path): continue # 目录类型为全部的,符合条件 if not media_type or not d.media_type: matched_dirs.append(d) continue # 目录类型相等,目录类别为全部,符合条件 if d.media_type == media_type and not d.media_category: matched_dirs.append(d) continue # 目录类型相等,目录类别相等,符合条件 if d.media_type == media_type and d.media_category == media.category: matched_dirs.append(d) continue if matched_dirs: if src_path: # 优先源目录同盘 for matched_dir in matched_dirs: matched_path = Path(matched_dir.download_path) if self._is_same_source((src_path, storage or "local"), (matched_path, matched_dir.library_storage)): return matched_dir return matched_dirs[0] return None @staticmethod def _is_same_source(src: Tuple[Path, str], tar: Tuple[Path, str]) -> bool: """ 判断源目录和目标目录是否在同一存储盘 :param src: 源目录路径和存储类型 :param tar: 目标目录路径和存储类型 :return: 是否在同一存储盘 """ src_path, src_storage = src tar_path, tar_storage = tar if "local" == tar_storage == src_storage: return SystemUtils.is_same_disk(src_path, tar_path) # 网络存储,直接比较类型 return src_storage == tar_storage @staticmethod def get_media_root_path(rename_format: str, rename_path: Path) -> Optional[Path]: """ 获取重命名后的媒体文件根路径 :param rename_format: 重命名格式 :param rename_path: 重命名后的路径 :return: 媒体文件根路径 """ if not rename_format: logger.error("重命名格式不能为空") return None # 计算重命名中的文件夹层数 rename_list = rename_format.split("/") rename_format_level = len(rename_list) - 1 # 反向查找标题参数所在层 for level, name in enumerate(reversed(rename_list)): if level == 0: # 跳过文件名的标题参数 continue matchs = JINJA2_VAR_PATTERN.findall(name) if not matchs: continue # 处理特例,有的人重命名的第一层是年份、分辨率 if (any("title" in m for m in matchs) and not any("season" in m for m in matchs)): # 找出最后一层含有标题且不含季参数的目录作为媒体根目录 rename_format_level = level break else: # 假定第一层目录是媒体根目录 logger.warn(f"重命名格式 {rename_format} 缺少标题目录") if rename_format_level > len(rename_path.parents): # 通常因为路径以/结尾,被Path规范化删除了 logger.error(f"路径 {rename_path} 不匹配重命名格式 {rename_format}") return None if rename_format_level <= 0: # 所有媒体文件都存在一个目录内的特殊需求 rename_format_level = 1 # 媒体根路径 media_root = rename_path.parents[rename_format_level - 1] return media_root ================================================ FILE: app/helper/display.py ================================================ from pyvirtualdisplay import Display from app.log import logger from app.utils.singleton import Singleton from app.utils.system import SystemUtils import os class DisplayHelper(metaclass=Singleton): def __init__(self): self._display = None if not SystemUtils.is_docker(): return try: self._display = Display(visible=False, size=(1024, 768), extra_args=[os.environ['DISPLAY']]) self._display.start() except Exception as err: logger.error(f"DisplayHelper init error: {str(err)}") def stop(self): if self._display: logger.info("正在停止虚拟显示...") self._display.stop() logger.info("虚拟显示已停止") ================================================ FILE: app/helper/doh.py ================================================ """ doh函数的实现。 author: https://github.com/C5H12O5/syno-videoinfo-plugin """ import base64 import concurrent import concurrent.futures import json import socket import struct import urllib import urllib.request from threading import Lock from typing import Dict, Optional from app.core.config import settings from app.log import logger from app.utils.mixins import ConfigReloadMixin from app.utils.singleton import Singleton # 定义一个全局线程池执行器 _executor = concurrent.futures.ThreadPoolExecutor() # 定义默认的DoH配置 _doh_timeout = 5 _doh_cache: Dict[str, str] = {} _doh_lock = Lock() # 保存原始的 socket.getaddrinfo 方法 _orig_getaddrinfo = socket.getaddrinfo def enable_doh(enable: bool): """ 对 socket.getaddrinfo 进行补丁 """ def _patched_getaddrinfo(host, *args, **kwargs): """ socket.getaddrinfo的补丁版本。 """ if host not in settings.DOH_DOMAINS.split(","): return _orig_getaddrinfo(host, *args, **kwargs) # 检查主机是否已解析 with _doh_lock: ip = _doh_cache.get("host", None) if ip is not None: logger.info("已解析 [%s] 为 [%s] (缓存)", host, ip) return _orig_getaddrinfo(ip, *args, **kwargs) # 使用DoH解析主机 futures = [] for resolver in settings.DOH_RESOLVERS.split(","): futures.append(_executor.submit(_doh_query, resolver, host)) for future in concurrent.futures.as_completed(futures): ip = future.result() if ip is not None: logger.info("已解析 [%s] 为 [%s]", host, ip) with _doh_lock: _doh_cache[host] = ip host = ip break return _orig_getaddrinfo(host, *args, **kwargs) if enable: # 替换 socket.getaddrinfo 方法 socket.getaddrinfo = _patched_getaddrinfo else: socket.getaddrinfo = _orig_getaddrinfo class DohHelper(ConfigReloadMixin, metaclass=Singleton): """ DoH帮助类,用于处理DNS over HTTPS解析。 """ CONFIG_WATCH = {"DOH_ENABLE", "DOH_DOMAINS", "DOH_RESOLVERS"} def __init__(self): enable_doh(settings.DOH_ENABLE) def on_config_changed(self): with _doh_lock: # DOH配置有变动的情况下,清空缓存 _doh_cache.clear() enable_doh(settings.DOH_ENABLE) def get_reload_name(self): return 'DoH' def _doh_query(resolver: str, host: str) -> Optional[str]: """ 使用给定的DoH解析器查询给定主机的IP地址。 """ # 构造DNS查询消息(RFC 1035) header = b"".join( [ b"\x00\x00", # ID: 0 b"\x01\x00", # FLAGS: 标准递归查询 b"\x00\x01", # QDCOUNT: 1 b"\x00\x00", # ANCOUNT: 0 b"\x00\x00", # NSCOUNT: 0 b"\x00\x00", # ARCOUNT: 0 ] ) question = b"".join( [ b"".join( [ struct.pack("B", len(item)) + item.encode("utf-8") for item in host.split(".") ] ) + b"\x00", # QNAME: 域名序列 b"\x00\x01", # QTYPE: A b"\x00\x01", # QCLASS: IN ] ) message = header + question try: # 发送GET请求到DoH解析器(RFC 8484) b64message = base64.b64encode(message).decode("utf-8").rstrip("=") url = f"https://{resolver}/dns-query?dns={b64message}" headers = {"Content-Type": "application/dns-message"} logger.debug("DoH请求: %s", url) request = urllib.request.Request(url, headers=headers, method="GET") with urllib.request.urlopen(request, timeout=_doh_timeout) as response: logger.debug("解析器(%s)响应: %s", resolver, response.status) if response.status != 200: return None resp_body = response.read() # 解析DNS响应消息(RFC 1035) # name(压缩):2 + type:2 + class:2 + ttl:4 + rdlength:2 = 12字节 first_rdata_start = len(header) + len(question) + 12 # rdata(A记录)= 4字节 first_rdata_end = first_rdata_start + 4 # 将rdata转换为IP地址 return socket.inet_ntoa(resp_body[first_rdata_start:first_rdata_end]) except Exception as e: logger.error("解析器(%s)请求错误: %s", resolver, e) return None def doh_query_json(resolver: str, host: str) -> Optional[str]: """ 使用给定的DoH解析器查询给定主机的IP地址。 """ url = f"https://{resolver}/dns-query?name={host}&type=A" headers = {"Accept": "application/dns-json"} logger.debug("DoH请求: %s", url) try: request = urllib.request.Request(url, headers=headers, method="GET") with urllib.request.urlopen(request, timeout=_doh_timeout) as response: logger.debug("解析器(%s)响应: %s", resolver, response.status) if response.status != 200: return None response_body = response.read().decode("utf-8") logger.debug("<== body: %s", response_body) answer = json.loads(response_body)["Answer"] return answer[0]["data"] except Exception as e: logger.error("解析器(%s)请求错误: %s", resolver, e) return None ================================================ FILE: app/helper/downloader.py ================================================ from typing import Optional from app.helper.service import ServiceBaseHelper from app.schemas import DownloaderConf, ServiceInfo from app.schemas.types import SystemConfigKey, ModuleType class DownloaderHelper(ServiceBaseHelper[DownloaderConf]): """ 下载器帮助类 """ def __init__(self): super().__init__( config_key=SystemConfigKey.Downloaders, conf_type=DownloaderConf, module_type=ModuleType.Downloader ) def is_downloader( self, service_type: Optional[str] = None, service: Optional[ServiceInfo] = None, name: Optional[str] = None, ) -> bool: """ 通用的下载器类型判断方法 :param service_type: 下载器的类型名称(如 'qbittorrent', 'transmission', 'rtorrent') :param service: 要判断的服务信息 :param name: 服务的名称 :return: 如果服务类型或实例为指定类型,返回 True;否则返回 False """ # 如果未提供 service 则通过 name 获取服务 service = service or self.get_service(name=name) # 判断服务类型是否为指定类型 return bool(service and service.type == service_type) ================================================ FILE: app/helper/format.py ================================================ import re from typing import Tuple, Optional import parse from app.core.meta.metabase import MetaBase class FormatParser(object): _key = "" _split_chars = r"\.|\s+|\(|\)|\[|]|-|\+|【|】|/|~|;|&|\||#|_|「|」|~" def __init__(self, eformat: str, details: Optional[str] = None, part: Optional[str] = None, offset: Optional[str] = None, key: Optional[str] = "ep"): """ :params eformat: 格式化字符串 :params details: 格式化详情 :params part: 分集 :params offset: 偏移量 -10/EP*2 :prams key: EP关键字 """ self._format = eformat self._start_ep = None self._end_ep = None if not offset: self.__offset = "EP" elif "EP" in offset: self.__offset = offset else: if offset.startswith("-") or offset.startswith("+"): self.__offset = f"EP{offset}" else: self.__offset = f"EP+{offset}" self._key = key self._part = None if part: self._part = part if details: if re.compile("\\d{1,4}-\\d{1,4}").match(details): self._start_ep = details self._end_ep = details else: tmp = details.split(",") if len(tmp) > 1: self._start_ep = int(tmp[0]) self._end_ep = int(tmp[0]) if int(tmp[0]) > int(tmp[1]) else int(tmp[1]) else: self._start_ep = self._end_ep = int(tmp[0]) @property def format(self): return self._format @property def start_ep(self): return self._start_ep @property def end_ep(self): return self._end_ep @property def part(self): return self._part @property def offset(self): return self.__offset def match(self, file: str) -> bool: if not self._format: return True s, e = self.__handle_single(file) if not s: return False if self._start_ep is None: return True if self._start_ep <= s <= self._end_ep: return True return False def split_episode(self, file_name: str, file_meta: MetaBase) -> Tuple[Optional[int], Optional[int], Optional[str]]: """ 拆分集数,返回开始集数,结束集数,Part信息 """ # 指定的具体集数,直接返回 if self._start_ep is not None: if self._start_ep == self._end_ep: # `details` 格式为 `X-X` 或者 `X` if isinstance(self._start_ep, str): # `details` 格式为 `X-X` s, e = self._start_ep.split("-") start_ep = self.__offset.replace("EP", s) end_ep = self.__offset.replace("EP", e) if int(s) == int(e): return int(eval(start_ep)), None, self.part return int(eval(start_ep)), int(eval(end_ep)), self.part else: # `details` 格式为 `X` start_ep = self.__offset.replace("EP", str(self._start_ep)) return int(eval(start_ep)), None, self.part elif not self._format: # `details` 格式为 `X,X` start_ep = self.__offset.replace("EP", str(self._start_ep)) end_ep = self.__offset.replace("EP", str(self._end_ep)) return int(eval(start_ep)), int(eval(end_ep)), self.part if not self._format: # 未填入`集数定位` 且没有`指定集数` 仅处理`集数偏移` start_ep = eval(self.__offset.replace("EP", str(file_meta.begin_episode))) if file_meta.begin_episode else None end_ep = eval(self.__offset.replace("EP", str(file_meta.end_episode))) if file_meta.end_episode else None return int(start_ep) if start_ep else None, int(end_ep) if end_ep else None, self.part else: # 有`集数定位` s, e = self.__handle_single(file_name) start_ep = self.__offset.replace("EP", str(s)) if s else None end_ep = self.__offset.replace("EP", str(e)) if e else None return int(eval(start_ep)) if start_ep else None, int(eval(end_ep)) if end_ep else None, self.part def __handle_single(self, file: str) -> Tuple[Optional[int], Optional[int]]: """ 处理单集,返回单集的开始和结束集数 """ if not self._format: return None, None ret = parse.parse(self._format, file) if not ret or not ret.__contains__(self._key): return None, None episodes = ret.__getitem__(self._key) if not re.compile(r"^(EP)?(\d{1,4})(-(EP)?(\d{1,4}))?$", re.IGNORECASE).match(episodes): return None, None episode_splits = list(filter(lambda x: re.compile(r'[a-zA-Z]*\d{1,4}', re.IGNORECASE).match(x), re.split(r'%s' % self._split_chars, episodes))) if len(episode_splits) == 1: return int(re.compile(r'[a-zA-Z]*', re.IGNORECASE).sub("", episode_splits[0])), None else: return int(re.compile(r'[a-zA-Z]*', re.IGNORECASE).sub("", episode_splits[0])), int( re.compile(r'[a-zA-Z]*', re.IGNORECASE).sub("", episode_splits[1])) ================================================ FILE: app/helper/image.py ================================================ import io from pathlib import Path from typing import Optional, List from PIL import Image from app.chain.mediaserver import MediaServerChain from app.chain.tmdb import TmdbChain from app.core.cache import cached, FileCache, AsyncFileCache from app.core.config import settings from app.log import logger from app.utils.http import RequestUtils, AsyncRequestUtils from app.utils.ip import IpUtils from app.utils.security import SecurityUtils from app.utils.singleton import Singleton class WallpaperHelper(metaclass=Singleton): """ 壁纸帮助类 """ def get_wallpaper(self) -> Optional[str]: """ 获取登录页面壁纸 """ if settings.WALLPAPER == "bing": return self.get_bing_wallpaper() elif settings.WALLPAPER == "mediaserver": return self.get_mediaserver_wallpaper() elif settings.WALLPAPER == "customize": return self.get_customize_wallpaper() elif settings.WALLPAPER == "tmdb": return self.get_tmdb_wallpaper() return '' def get_wallpapers(self, num: int = 10) -> List[str]: """ 获取登录页面壁纸列表 """ if settings.WALLPAPER == "bing": return self.get_bing_wallpapers(num) elif settings.WALLPAPER == "mediaserver": return self.get_mediaserver_wallpapers(num) elif settings.WALLPAPER == "customize": return self.get_customize_wallpapers() elif settings.WALLPAPER == "tmdb": return self.get_tmdb_wallpapers(num) return [] @cached(maxsize=1, ttl=3600) def get_tmdb_wallpaper(self) -> Optional[str]: """ 获取TMDB每日壁纸 """ return TmdbChain().get_random_wallpager() @cached(maxsize=1, ttl=3600, skip_empty=True) def get_tmdb_wallpapers(self, num: int = 10) -> List[str]: """ 获取7天的TMDB每日壁纸 """ return TmdbChain().get_trending_wallpapers(num) @cached(maxsize=1, ttl=3600) def get_bing_wallpaper(self) -> Optional[str]: """ 获取Bing每日壁纸 """ url = "https://cn.bing.com/HPImageArchive.aspx?format=js&idx=0&n=1" resp = RequestUtils(timeout=5).get_res(url) if resp and resp.status_code == 200: try: result = resp.json() if isinstance(result, dict): for image in result.get('images') or []: return f"https://cn.bing.com{image.get('url')}" if 'url' in image else '' except Exception as err: print(str(err)) return None @cached(maxsize=1, ttl=3600, skip_empty=True) def get_bing_wallpapers(self, num: int = 7) -> List[str]: """ 获取7天的Bing每日壁纸 """ url = f"https://cn.bing.com/HPImageArchive.aspx?format=js&idx=0&n={num}" resp = RequestUtils(timeout=5).get_res(url) if resp and resp.status_code == 200: try: result = resp.json() if isinstance(result, dict): return [f"https://cn.bing.com{image.get('url')}" for image in result.get('images') or []] except Exception as err: print(str(err)) return [] @cached(maxsize=1, ttl=3600) def get_mediaserver_wallpaper(self) -> Optional[str]: """ 获取媒体服务器壁纸 """ return MediaServerChain().get_latest_wallpaper() @cached(maxsize=1, ttl=3600, skip_empty=True) def get_mediaserver_wallpapers(self, num: int = 10) -> List[str]: """ 获取媒体服务器壁纸列表 """ return MediaServerChain().get_latest_wallpapers(count=num) @cached(maxsize=1, ttl=3600) def get_customize_wallpaper(self) -> Optional[str]: """ 获取自定义壁纸api壁纸 """ wallpaper_list = self.get_customize_wallpapers() if wallpaper_list: return wallpaper_list[0] return None @cached(maxsize=1, ttl=3600, skip_empty=True) def get_customize_wallpapers(self) -> List[str]: """ 获取自定义壁纸api壁纸 """ def find_files_with_suffixes(obj, suffixes: List[str]) -> List[str]: """ 递归查找对象中所有包含特定后缀的文件,返回匹配的字符串列表 支持输入:字典、列表、字符串 """ _result = [] # 处理字符串 if isinstance(obj, str): if obj.endswith(tuple(suffixes)): _result.append(obj) # 处理字典 elif isinstance(obj, dict): for value in obj.values(): _result.extend(find_files_with_suffixes(value, suffixes)) # 处理列表 elif isinstance(obj, list): for item in obj: _result.extend(find_files_with_suffixes(item, suffixes)) return _result # 判断是否存在自定义壁纸api if settings.CUSTOMIZE_WALLPAPER_API_URL: wallpaper_list = [] resp = RequestUtils(timeout=15).get_res(settings.CUSTOMIZE_WALLPAPER_API_URL) if resp and resp.status_code == 200: # 如果返回的是图片格式 content_type = resp.headers.get('Content-Type') if content_type and content_type.lower().startswith('image/'): wallpaper_list.append(settings.CUSTOMIZE_WALLPAPER_API_URL) else: try: result = resp.json() if isinstance(result, list) or isinstance(result, dict) or isinstance(result, str): wallpaper_list = find_files_with_suffixes(result, settings.SECURITY_IMAGE_SUFFIXES) except Exception as err: print(str(err)) return wallpaper_list else: return [] class ImageHelper(metaclass=Singleton): def __init__(self): _base_path = settings.CACHE_PATH _ttl = settings.GLOBAL_IMAGE_CACHE_DAYS * 24 * 3600 self.file_cache = FileCache(base=_base_path, ttl=_ttl) self.async_file_cache = AsyncFileCache(base=_base_path, ttl=_ttl) @staticmethod def _prepare_cache_path(url: str) -> str: """缓存路径""" sanitized_path = SecurityUtils.sanitize_url_path(url) cache_path = Path(sanitized_path) if not cache_path.suffix: cache_path = cache_path.with_suffix(".jpg") return cache_path.as_posix() @staticmethod def _validate_image(content: bytes) -> bool: """验证图片""" if not content: return False try: Image.open(io.BytesIO(content)).verify() return True except Exception as e: logger.warn(f"Invalid image format: {e}") return False @staticmethod def _get_request_params(url: str, proxy: Optional[bool], cookies: Optional[str | dict]) -> dict: """获取参数""" referer = "https://movie.douban.com/" if "doubanio.com" in url else None if proxy is None: proxies = settings.PROXY if not (referer or IpUtils.is_internal(url)) else None else: proxies = settings.PROXY if proxy else None return { "ua": settings.NORMAL_USER_AGENT, "proxies": proxies, "referer": referer, "cookies": cookies, "accept_type": "image/avif,image/webp,image/apng,*/*", } def fetch_image( self, url: str, proxy: Optional[bool] = None, use_cache: bool = True, cookies: Optional[str | dict] = None) -> Optional[bytes]: """ 获取图片(同步版本) """ if not url: return None cache_path = self._prepare_cache_path(url) # 检查缓存 if use_cache: content = self.file_cache.get(cache_path, region="images") if content: return content # 请求远程图片 params = self._get_request_params(url, proxy, cookies) response = RequestUtils(**params).get_res(url=url) if not response: logger.warn(f"Failed to fetch image from URL: {url}") return None content = response.content # 验证图片 if not self._validate_image(content): return None # 保存缓存 self.file_cache.set(cache_path, content, region="images") return content async def async_fetch_image( self, url: str, proxy: Optional[bool] = None, use_cache: bool = True, cookies: Optional[str | dict] = None) -> Optional[bytes]: """ 获取图片(异步版本) """ if not url: return None cache_path = self._prepare_cache_path(url) # 检查缓存 if use_cache: content = await self.async_file_cache.get(cache_path, region="images") if content: return content # 请求远程图片 params = self._get_request_params(url, proxy, cookies) response = await AsyncRequestUtils(**params).get_res(url=url) if not response: logger.warn(f"Failed to fetch image from URL: {url}") return None content = response.content # 验证图片 if not self._validate_image(content): return None # 保存缓存 await self.async_file_cache.set(cache_path, content, region="images") return content ================================================ FILE: app/helper/llm.py ================================================ """LLM模型相关辅助功能""" from typing import List, Optional from app.core.config import settings from app.log import logger class LLMHelper: """LLM模型相关辅助功能""" @staticmethod def get_llm(streaming: bool = False, callbacks: Optional[list] = None): """ 获取LLM实例 :param streaming: 是否启用流式输出 :param callbacks: 回调处理器列表 :return: LLM实例 """ provider = settings.LLM_PROVIDER.lower() api_key = settings.LLM_API_KEY if not api_key: raise ValueError("未配置LLM API Key") if provider == "google": if settings.PROXY_HOST: from langchain_openai import ChatOpenAI return ChatOpenAI( model=settings.LLM_MODEL, api_key=api_key, max_retries=3, base_url="https://generativelanguage.googleapis.com/v1beta/openai", temperature=settings.LLM_TEMPERATURE, streaming=streaming, callbacks=callbacks, stream_usage=True, openai_proxy=settings.PROXY_HOST ) else: from langchain_google_genai import ChatGoogleGenerativeAI return ChatGoogleGenerativeAI( model=settings.LLM_MODEL, google_api_key=api_key, max_retries=3, temperature=settings.LLM_TEMPERATURE, streaming=streaming, callbacks=callbacks ) elif provider == "deepseek": from langchain_deepseek import ChatDeepSeek return ChatDeepSeek( model=settings.LLM_MODEL, api_key=api_key, max_retries=3, temperature=settings.LLM_TEMPERATURE, streaming=streaming, callbacks=callbacks, stream_usage=True ) else: from langchain_openai import ChatOpenAI return ChatOpenAI( model=settings.LLM_MODEL, api_key=api_key, max_retries=3, base_url=settings.LLM_BASE_URL, temperature=settings.LLM_TEMPERATURE, streaming=streaming, callbacks=callbacks, stream_usage=True, openai_proxy=settings.PROXY_HOST ) def get_models(self, provider: str, api_key: str, base_url: str = None) -> List[str]: """获取模型列表""" logger.info(f"获取 {provider} 模型列表...") if provider == "google": return self._get_google_models(api_key) else: return self._get_openai_compatible_models(provider, api_key, base_url) @staticmethod def _get_google_models(api_key: str) -> List[str]: """获取Google模型列表""" try: import google.generativeai as genai genai.configure(api_key=api_key) models = genai.list_models() return [m.name for m in models if 'generateContent' in m.supported_generation_methods] except Exception as e: logger.error(f"获取Google模型列表失败:{e}") raise e @staticmethod def _get_openai_compatible_models(provider: str, api_key: str, base_url: str = None) -> List[str]: """获取OpenAI兼容模型列表""" try: from openai import OpenAI if provider == "deepseek": base_url = base_url or "https://api.deepseek.com" client = OpenAI(api_key=api_key, base_url=base_url) models = client.models.list() return [model.id for model in models.data] except Exception as e: logger.error(f"获取 {provider} 模型列表失败:{e}") raise e ================================================ FILE: app/helper/mediaserver.py ================================================ from typing import Optional from app.helper.service import ServiceBaseHelper from app.schemas import MediaServerConf, ServiceInfo from app.schemas.types import SystemConfigKey, ModuleType class MediaServerHelper(ServiceBaseHelper[MediaServerConf]): """ 媒体服务器帮助类 """ def __init__(self): super().__init__( config_key=SystemConfigKey.MediaServers, conf_type=MediaServerConf, module_type=ModuleType.MediaServer ) def is_media_server( self, service_type: Optional[str] = None, service: Optional[ServiceInfo] = None, name: Optional[str] = None, ) -> bool: """ 通用的媒体服务器类型判断方法 :param service_type: 媒体服务器的类型名称(如 'plex', 'emby', 'jellyfin') :param service: 要判断的服务信息 :param name: 服务的名称 :return: 如果服务类型或实例为指定类型,返回 True;否则返回 False """ # 如果未提供 service 则通过 name 获取服务 service = service or self.get_service(name=name) # 判断服务类型是否为指定类型 return bool(service and service.type == service_type) ================================================ FILE: app/helper/message.py ================================================ from __future__ import annotations import ast import json import queue import re import threading import time from datetime import datetime from typing import Any, Literal, Optional, List, Dict, Union from typing import Callable from jinja2 import Template from app.core.cache import TTLCache from app.core.config import global_vars from app.core.context import MediaInfo, TorrentInfo from app.core.meta import MetaBase from app.db.systemconfig_oper import SystemConfigOper from app.log import logger from app.schemas.message import Notification from app.schemas.tmdb import TmdbEpisode from app.schemas.transfer import TransferInfo from app.schemas.types import SystemConfigKey from app.utils.singleton import Singleton, SingletonClass from app.utils.string import StringUtils class TemplateContextBuilder: """ 模板上下文构建器 """ def __init__(self): self._context = {} def build( self, meta: Optional[MetaBase] = None, mediainfo: Optional[MediaInfo] = None, torrentinfo: Optional[TorrentInfo] = None, transferinfo: Optional[TransferInfo] = None, file_extension: Optional[str] = None, episodes_info: Optional[List[TmdbEpisode]] = None, include_raw_objects: bool = True, **kwargs ) -> Dict[str, Any]: """ :param meta: 媒体信息 :param mediainfo: 媒体信息 :param torrentinfo: 种子信息 :param transferinfo: 传输信息 :param file_extension: 文件扩展名 :param episodes_info: 剧集信息 :param include_raw_objects: 是否包含原始对象 :return: 渲染上下文字典 """ self._context.clear() self._add_episode_details(meta, episodes_info) self._add_media_info(mediainfo) self._add_transfer_info(transferinfo) self._add_torrent_info(torrentinfo) self._add_file_info(file_extension) if kwargs: self._context.update(kwargs) if include_raw_objects: self._add_raw_objects(meta, mediainfo, torrentinfo, transferinfo, episodes_info) # 移除空值 return {k: v for k, v in self._context.items() if v is not None} def _add_media_info(self, mediainfo: MediaInfo): """ 增加媒体信息 """ if not mediainfo: return season_fmt = f"S{mediainfo.season:02d}" if mediainfo.season is not None else None base_info = { # 标题 "title": self.__convert_invalid_characters(mediainfo.title), # 英文标题 "en_title": self.__convert_invalid_characters(mediainfo.en_title), # 原语种标题 "original_title": self.__convert_invalid_characters(mediainfo.original_title), # 季号 "season": self._context.get("season") or mediainfo.season, # Sxx "season_fmt": self._context.get("season_fmt") or season_fmt, # 年份 "year": mediainfo.year or self._context.get("year"), # 媒体标题 + 年份 "title_year": mediainfo.title_year or self._context.get("title_year"), } _meta_season = self._context.get("season") media_info = { # 类型 "type": mediainfo.type.value, # 类别 "category": mediainfo.category, # 评分 "vote_average": mediainfo.vote_average, # 海报 "poster": mediainfo.get_poster_image(), # 背景图 "backdrop": mediainfo.get_backdrop_image(), # 季年份根据season值获取 "season_year": mediainfo.season_years.get( int(_meta_season), None) if (mediainfo.season_years and _meta_season) else None, # 演员 "actors": '、 '.join([actor['name'] for actor in mediainfo.actors[:5]]), # 简介 "overview": mediainfo.overview, # TMDBID "tmdbid": mediainfo.tmdb_id, # IMDBID "imdbid": mediainfo.imdb_id, # 豆瓣ID "doubanid": mediainfo.douban_id, } self._context.update({**base_info, **media_info}) def _add_episode_details(self, meta: Optional[MetaBase], episodes: Optional[List[TmdbEpisode]]): """ 添加剧集详细信息 """ if not meta: return episode_data = {"episode_title": None, "episode_date": None} if meta.begin_episode and episodes: for episode in episodes: if episode.episode_number == meta.begin_episode: episode_data.update({ "episode_title": self.__convert_invalid_characters(episode.name), "episode_date": episode.air_date if episode.air_date else None }) break meta_info = { # 原文件名 "original_name": meta.title, # 识别名称(优先使用中文) "name": meta.name, # 识别的英文名称(可能为空) "en_name": meta.en_name, # 年份 "year": meta.year, # 名字 + 年份 "title_year": self._context.get("title_year") or "%s (%s)" % ( meta.name, meta.year) if meta.year else meta.name, # 季号 "season": meta.season_seq, # Sxx "season_fmt": meta.season, # 集号 "episode": meta.episode_seqs, # 季集 SxxExx "season_episode": "%s%s" % (meta.season, meta.episode), # 段/节 "part": meta.part, # 自定义占位符 "customization": meta.customization, # fps "fps": meta.fps, } tech_metadata = { # 资源类型 "resourceType": meta.resource_type, # 特效 "effect": meta.resource_effect, # 版本 "edition": meta.edition, # 分辨率 "videoFormat": meta.resource_pix, # 质量 "resource_term": meta.resource_term, # 制作组/字幕组 "releaseGroup": meta.resource_team, # 视频编码 "videoCodec": meta.video_encode, # 音频编码 "audioCodec": meta.audio_encode, # 流媒体平台 "webSource": meta.web_source, } self._context.update({**meta_info, **tech_metadata, **episode_data}) def _add_torrent_info(self, torrentinfo: Optional[TorrentInfo]): """ 添加种子信息 """ if not torrentinfo: return if torrentinfo.size: if str(torrentinfo.size).replace(".", "").isdigit(): size = StringUtils.str_filesize(torrentinfo.size) else: size = torrentinfo.size else: size = 0 if torrentinfo.description: html_re = re.compile(r'<[^>]+>', re.S) description = html_re.sub('', torrentinfo.description) torrentinfo.description = re.sub(r'<[^>]+>', '', description) torrent_info = { # 种子标题 "torrent_title": torrentinfo.title, # 发布时间 "pubdate": torrentinfo.pubdate, # 免费剩余时间 "freedate": torrentinfo.freedate_diff, # 做种数 "seeders": torrentinfo.seeders, # 促销信息 "volume_factor": torrentinfo.volume_factor, # Hit&Run "hit_and_run": "是" if torrentinfo.hit_and_run else "否", # 种子标签 "labels": ' '.join(torrentinfo.labels), # 描述 "description": torrentinfo.description, # 站点名称 "site_name": torrentinfo.site_name, # 种子大小 "size": size, } self._context.update(torrent_info) def _add_transfer_info(self, transferinfo: Optional[TransferInfo]) -> Optional[Dict]: """ 添加文件转移上下文 """ if not transferinfo: return None ctx = { "transfer_type": transferinfo.transfer_type, "file_count": transferinfo.file_count, "total_size": StringUtils.str_filesize(transferinfo.total_size), "err_msg": transferinfo.message, } return self._context.update(ctx) def _add_file_info(self, file_extension: Optional[str]): """ 添加文件信息 """ if not file_extension: return file_info = { # 文件后缀 "fileExt": file_extension, } self._context.update(file_info) def _add_raw_objects( self, meta: Optional[MetaBase], mediainfo: Optional[MediaInfo], torrentinfo: Optional[TorrentInfo], transferinfo: Optional[TransferInfo], episodes_info: Optional[List[TmdbEpisode]], ): """ 添加原始对象引用 """ raw_objects = { # 文件元数据 "__meta__": meta, # 识别的媒体信息 "__mediainfo__": mediainfo, # 种子信息 "__torrentinfo__": torrentinfo, # 文件转移信息 "__transferinfo__": transferinfo, # 当前季的全部集信息 "__episodes_info__": episodes_info, } self._context.update(raw_objects) @staticmethod def __convert_invalid_characters(filename: str): """ 将不支持的字符转换为全角字符 """ if not filename: return filename invalid_characters = r'\/:*?"<>|' # 创建半角到全角字符的转换表 halfwidth_chars = "".join([chr(i) for i in range(33, 127)]) fullwidth_chars = "".join([chr(i + 0xFEE0) for i in range(33, 127)]) translation_table = str.maketrans(halfwidth_chars, fullwidth_chars) # 将不支持的字符替换为对应的全角字符 for char in invalid_characters: filename = filename.replace(char, char.translate(translation_table)) return filename class TemplateHelper(metaclass=SingletonClass): """ 模板格式渲染帮助类 """ def __init__(self): self.builder = TemplateContextBuilder() self.cache = TTLCache(region="notification", maxsize=100, ttl=600) @staticmethod def _generate_cache_key(cuntent: Union[str, dict]) -> str: """ 生成缓存键 """ if isinstance(cuntent, dict): base_str = cuntent.get("title", '') + cuntent.get("text", '') return StringUtils.md5_hash(json.dumps(base_str, sort_keys=True, ensure_ascii=False)) return StringUtils.md5_hash(cuntent) def get_cache_context(self, cuntent: Union[str, dict]) -> Optional[dict]: """ 获取缓存上下文 """ cache_key = self._generate_cache_key(cuntent) return self.cache.get(cache_key) def set_cache_context(self, cuntent: Union[str, dict], context: dict) -> None: """ 设置缓存上下文 """ cache_key = self._generate_cache_key(cuntent) self.cache[cache_key] = context def render(self, template_content: str, template_type: Literal['string', 'dict', 'literal'] = "literal", **kwargs) -> Optional[Union[str, dict]]: """ 根据模板格式渲染内容 :param template_content: 模板字符串 :param template_type: 模板字符串类型(消息通知`literal`, 路径`string`) :param kwargs: 补传业务对象 :raises ValueError: 当模板处理过程中出现错误 :return: 渲染后的结果 """ try: # 解析模板字符 parsed = self.parse_template_content(template_content, template_type) if not parsed: raise ValueError("模板解析失败") context = self.builder.build(**kwargs) if not context: raise ValueError("上下文构建失败") rendered = self.render_with_context(parsed, context) if not rendered: raise ValueError("模板渲染失败") if rendered := rendered if template_type == 'string' else self.__process_formatted_string(rendered): # 缓存上下文 self.set_cache_context(rendered, context) # 返回渲染结果 return rendered return None except Exception as e: raise ValueError(f"模板处理失败: {str(e)}") from e @staticmethod def render_with_context(template_content: str, context: dict) -> str: """ 使用指定上下文渲染 Jinja2 模板字符串 template_content: Jinja2 模板字符串 context: 渲染用的上下文数据 """ # 渲染模板 template = Template(template_content) return template.render(context) @staticmethod def parse_template_content(template_content: Union[str, dict], template_type: Literal['string', 'dict', 'literal'] = None) -> Optional[str]: """ 解析模板字符 :param template_content 模板格式字符 :param template_type 模板字符类型 """ def parse_literal(_template_content: str) -> str: """ 解析Python字面量 """ try: template_dict = ast.literal_eval(_template_content) if isinstance(_template_content, str) else _template_content if not isinstance(template_dict, dict): raise ValueError("解析结果必须是一个字典") return json.dumps(template_dict, ensure_ascii=False) except (ValueError, SyntaxError) as err: raise ValueError(f"无效的Python字面量格式: {str(err)}") try: if template_type: parse_map = { 'string': lambda x: str(x), 'dict': lambda x: json.dumps(x, ensure_ascii=False), 'literal': parse_literal } return parse_map[template_type](template_content) # 自动判断模板类型 if isinstance(template_content, dict): return json.dumps(template_content, ensure_ascii=False) elif isinstance(template_content, str): try: json.loads(template_content) return template_content except json.JSONDecodeError: try: return parse_literal(template_content) except (ValueError, SyntaxError): return template_content else: raise ValueError(f"不支持的模板类型: {type(template_content)}") except Exception as e: logger.error(f"模板解析失败: {str(e)}") return None @staticmethod def __process_formatted_string(rendered: str) -> Optional[Union[dict, str]]: """ 处理格式化字符串 保留转义字符 """ def restore_chars(obj: Any) -> Any: """恢复特殊字符""" if isinstance(obj, str): return obj.replace('\\n', '\n').replace('\\r', '\r').replace('\\t', '\t').replace('\\b', '\b').replace( '\\f', '\f') elif isinstance(obj, dict): return {k: restore_chars(v) for k, v in obj.items()} elif isinstance(obj, list): return [restore_chars(item) for item in obj] return obj # 定义特殊字符映射 special_chars = { '\n': '\\n', # 换行符 '\r': '\\r', # 回车符 '\t': '\\t', # 制表符 '\b': '\\b', # 退格符 '\f': '\\f', # 换页符 } # 处理特殊字符 processed = rendered for char, escape in special_chars.items(): processed = processed.replace(char, escape) # 尝试解析为JSON try: rendered_dict = json.loads(processed) return restore_chars(rendered_dict) except json.JSONDecodeError: return rendered def close(self): """ 清理资源 """ if self.cache: self.cache.close() class MessageTemplateHelper: """ 消息模板渲染器 """ @staticmethod def render(message: Notification, *args, **kwargs) -> Optional[Notification]: """ 渲染消息模板 """ if not MessageTemplateHelper.is_instance_valid(message): if MessageTemplateHelper.meets_update_conditions(message, *args, **kwargs): logger.info("将使用模板渲染消息内容") return MessageTemplateHelper._apply_template_data(message, *args, **kwargs) return message @staticmethod def is_instance_valid(message: Notification) -> bool: """ 检查消息是否有效 """ if isinstance(message, Notification): return bool(message.title or message.text) return False @staticmethod def meets_update_conditions(message: Notification, *args, **kwargs) -> bool: """ 判断是否满足消息实例更新条件 满足条件需同时具备: 1. 消息为有效Notification实例 2. 消息指定了模板类型(ctype) 3. 存在待渲染的模板变量数据 """ if isinstance(message, Notification): return True if message.ctype and (args or kwargs) else False return False @staticmethod def _apply_template_data(message: Notification, *args, **kwargs) -> Optional[Notification]: """ 更新消息实例 """ try: if template := MessageTemplateHelper._get_template(message): rendered = TemplateHelper().render(template_content=template, *args, **kwargs) for key, value in rendered.items(): if hasattr(message, key): setattr(message, key, value) return message except Exception as e: logger.error(f"更新Notification时出现错误:{str(e)}") return message @staticmethod def _get_template(message: Notification) -> Optional[str]: """ 获取消息模板 """ template_dict: dict[str, str] = SystemConfigOper().get(SystemConfigKey.NotificationTemplates) return template_dict.get(message.ctype.value) class MessageQueueManager(metaclass=SingletonClass): """ 消息发送队列管理器 """ def __init__( self, send_callback: Optional[Callable] = None, check_interval: Optional[int] = 10 ) -> None: """ 消息队列管理器初始化 :param send_callback: 实际发送消息的回调函数 :param check_interval: 时间检查间隔(秒) """ self.schedule_periods: List[tuple[int, int, int, int]] = [] self.init_config() self.queue: queue.Queue[Any] = queue.Queue() self.send_callback = send_callback self.check_interval = check_interval self._running = True self.thread = threading.Thread(target=self._monitor_loop, daemon=True) self.thread.start() def init_config(self): """ 初始化配置 """ self.schedule_periods = self._parse_schedule( SystemConfigOper().get(SystemConfigKey.NotificationSendTime) ) @staticmethod def _parse_schedule(periods: Union[list, dict]) -> List[tuple[int, int, int, int]]: """ 将字符串时间格式转换为分钟数元组 支持格式为 'HH:MM' 或 'HH:MM:SS' 的时间字符串 """ parsed = [] if not periods: return parsed if not isinstance(periods, list): periods = [periods] for period in periods: if not period: continue if not period.get('start') or not period.get('end'): continue try: # 处理 start 时间 start_parts = period['start'].split(':') if len(start_parts) == 2: start_h, start_m = map(int, start_parts) elif len(start_parts) >= 3: start_h, start_m = map(int, start_parts[:2]) # 只取前两个部分 (HH:MM) else: continue # 处理 end 时间 end_parts = period['end'].split(':') if len(end_parts) == 2: end_h, end_m = map(int, end_parts) elif len(end_parts) >= 3: end_h, end_m = map(int, end_parts[:2]) # 只取前两个部分 (HH:MM) else: continue parsed.append((start_h, start_m, end_h, end_m)) except ValueError as e: logger.error(f"解析时间周期时出现错误:{period}. 错误:{str(e)}. 跳过此周期。") continue except Exception as e: logger.error(f"解析时间周期时出现意外错误:{period}. 错误:{str(e)}. 跳过此周期。") continue return parsed @staticmethod def _time_to_minutes(time_str: str) -> int: """ 将 'HH:MM' 格式转换为分钟数 """ hours, minutes = map(int, time_str.split(':')) return hours * 60 + minutes def _is_in_scheduled_time(self, current_time: datetime) -> bool: """ 检查当前时间是否在允许发送的时间段内 """ if not self.schedule_periods: return True current_minutes = current_time.hour * 60 + current_time.minute for period in self.schedule_periods: s_h, s_m, e_h, e_m = period start = s_h * 60 + s_m end = e_h * 60 + e_m if start <= end: if start <= current_minutes <= end: return True else: if current_minutes >= start or current_minutes <= end: return True return False def send_message(self, *args, **kwargs) -> None: """ 发送消息(立即发送或加入队列) """ immediately = kwargs.pop("immediately", False) if immediately or self._is_in_scheduled_time(datetime.now()): self._send(*args, **kwargs) else: self.queue.put({ "args": args, "kwargs": kwargs }) logger.info(f"消息已加入队列,当前队列长度:{self.queue.qsize()}") async def async_send_message(self, *args, **kwargs) -> None: """ 异步发送消息(直接加入队列) """ kwargs.pop("immediately", False) self.queue.put({ "args": args, "kwargs": kwargs }) logger.info(f"消息已加入队列,当前队列长度:{self.queue.qsize()}") def _send(self, *args, **kwargs) -> None: """ 实际发送消息(可通过回调函数自定义) """ if self.send_callback: try: logger.info(f"发送消息:{kwargs}") self.send_callback(*args, **kwargs) except Exception as e: logger.error(f"发送消息错误:{str(e)}") def _monitor_loop(self) -> None: """ 后台线程循环检查时间并处理队列 """ while self._running: current_time = datetime.now() if self._is_in_scheduled_time(current_time): while not self.queue.empty(): if global_vars.is_system_stopped: break if not self._is_in_scheduled_time(datetime.now()): break try: message = self.queue.get_nowait() self._send(*message['args'], **message['kwargs']) logger.info(f"队列剩余消息:{self.queue.qsize()}") except queue.Empty: break time.sleep(self.check_interval) def stop(self) -> None: """ 停止队列管理器 """ self._running = False logger.info("正在停止消息队列...") self.thread.join() logger.info("消息队列已停止") class MessageHelper(metaclass=Singleton): """ 消息队列管理器,包括系统消息和用户消息 """ def __init__(self): self.sys_queue = queue.Queue() self.user_queue = queue.Queue() def put(self, message: Any, role: str = "plugin", title: str = None, note: Union[list, dict] = None): """ 存消息 :param message: 消息 :param role: 消息通道 systm:系统消息,plugin:插件消息,user:用户消息 :param title: 标题 :param note: 附件json """ if role in ["system", "plugin"]: # 没有标题时获取插件名称 if role == "plugin" and not title: title = "插件通知" # 系统通知,默认 self.sys_queue.put(json.dumps({ "type": role, "title": title, "text": message, "date": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()), "note": note })) else: if isinstance(message, str): # 非系统的文本通知 self.user_queue.put(json.dumps({ "title": title, "text": message, "date": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()), "note": note })) elif hasattr(message, "to_dict"): # 非系统的复杂结构通知,如媒体信息/种子列表等。 content = message.to_dict() content['title'] = title content['date'] = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()) content['note'] = note self.user_queue.put(json.dumps(content)) def get(self, role: str = "system") -> Optional[str]: """ 取消息 :param role: 消息通道 systm:系统消息,plugin:插件消息,user:用户消息 """ if role == "system": if not self.sys_queue.empty(): return self.sys_queue.get(block=False) else: if not self.user_queue.empty(): return self.user_queue.get(block=False) return None def stop_message(): """ 停止消息服务 """ # 停止消息队列 MessageQueueManager().stop() # 关闭消息演染器 TemplateHelper().close() ================================================ FILE: app/helper/module.py ================================================ # -*- coding: utf-8 -*- import importlib import pkgutil import traceback from pathlib import Path from typing import List, Any, Callable from app.log import logger FilterFuncType = Callable[[str, Any], bool] def _default_filter(name: str, obj: Any) -> bool: """ 默认过滤器 """ return True if name and obj else False class ModuleHelper: """ 模块动态加载 """ @classmethod def load(cls, package_path: str, filter_func: FilterFuncType = _default_filter) -> List[Any]: """ 导入模块 :param package_path: 父包名 :param filter_func: 子模块过滤函数,入参为模块名和模块对象,返回True则导入,否则不导入 :return: 导入的模块对象列表 """ submodules: list = [] loaded_modules = set() packages = importlib.import_module(package_path) for importer, package_name, _ in pkgutil.iter_modules(packages.__path__): try: if package_name.startswith('_'): continue full_package_name = f'{package_path}.{package_name}' module = importlib.import_module(full_package_name) importlib.reload(module) for name, obj in module.__dict__.items(): if name.startswith('_'): continue if isinstance(obj, type) and filter_func(name, obj): if name in loaded_modules: continue loaded_modules.add(name) submodules.append(obj) except Exception as err: logger.debug(f'加载模块 {package_name} 失败:{str(err)} - {traceback.format_exc()}') return submodules @classmethod def load_with_pre_filter(cls, package_path: str, filter_func: FilterFuncType = _default_filter) -> List[Any]: """ 导入子模块 :param package_path: 父包名 :param filter_func: 子模块过滤函数,入参为模块名和模块对象,返回True则导入,否则不导入 :return: 导入的模块对象列表 """ submodules: list = [] packages = importlib.import_module(package_path) def reload_module_objects(target_module): """加载模块并返回对象""" importlib.reload(target_module) # reload后,重新过滤已经重新加载后的模块中的对象 return [ obj for name, obj in target_module.__dict__.items() if not name.startswith('_') and isinstance(obj, type) and filter_func(name, obj) ] def reload_sub_modules(parent_module, parent_module_name): """重新加载一级子模块""" for sub_importer, sub_module_name, sub_is_pkg in pkgutil.walk_packages(parent_module.__path__, parent_module_name + '.'): try: full_sub_module = importlib.import_module(sub_module_name) importlib.reload(full_sub_module) except Exception as sub_err: logger.debug(f'加载子模块 {sub_module_name} 失败:{str(sub_err)} - {traceback.format_exc()}') # 遍历包中的所有子模块 for importer, package_name, is_pkg in pkgutil.iter_modules(packages.__path__): if package_name.startswith('_'): continue full_package_name = f'{package_path}.{package_name}' try: module = importlib.import_module(full_package_name) # 预检查模块中的对象 candidates = [(name, obj) for name, obj in module.__dict__.items() if not name.startswith('_') and isinstance(obj, type)] # 确定是否需要重新加载 if any(filter_func(name, obj) for name, obj in candidates): # 如果子模块是包,重新加载其子模块 if is_pkg: reload_sub_modules(module, full_package_name) submodules.extend(reload_module_objects(module)) except Exception as err: logger.debug(f'加载模块 {package_name} 失败:{str(err)} - {traceback.format_exc()}') return submodules @staticmethod def dynamic_import_all_modules(base_path: Path, package_name: str): """ 动态导入目录下所有模块 """ modules = [] # 遍历文件夹,找到所有模块文件 for file in base_path.glob("*.py"): file_name = file.stem if file_name != "__init__": modules.append(file_name) full_module_name = f"{package_name}.{file_name}" importlib.import_module(full_module_name) ================================================ FILE: app/helper/nfo.py ================================================ import xml.etree.ElementTree as ET from pathlib import Path from typing import List, Optional class NfoReader: def __init__(self, xml_file_path: Path): self.xml_file_path = xml_file_path self.tree = ET.parse(xml_file_path) self.root = self.tree.getroot() def get_element_value(self, element_path) -> Optional[str]: element = self.root.find(element_path) return element.text if element is not None else None def get_elements(self, element_path) -> List[ET.Element]: return self.root.findall(element_path) ================================================ FILE: app/helper/notification.py ================================================ from typing import Optional from app.helper.service import ServiceBaseHelper from app.schemas import NotificationConf, ServiceInfo from app.schemas.types import SystemConfigKey, ModuleType class NotificationHelper(ServiceBaseHelper[NotificationConf]): """ 消息通知帮助类 """ def __init__(self): super().__init__( config_key=SystemConfigKey.Notifications, conf_type=NotificationConf, module_type=ModuleType.Notification ) def is_notification( self, service_type: Optional[str] = None, service: Optional[ServiceInfo] = None, name: Optional[str] = None, ) -> bool: """ 通用的消息通知服务类型判断方法 :param service_type: 消息通知服务的类型名称(如 'wechat', 'voicechat', 'telegram', 等) :param service: 要判断的服务信息 :param name: 服务的名称 :return: 如果服务类型或实例为指定类型,返回 True;否则返回 False """ # 如果未提供 service 则通过 name 获取服务 service = service or self.get_service(name=name) # 判断服务类型是否为指定类型 return bool(service and service.type == service_type) ================================================ FILE: app/helper/ocr.py ================================================ import base64 from typing import Optional from app.core.config import settings from app.utils.http import RequestUtils class OcrHelper: _ocr_b64_url = f"{settings.OCR_HOST}/captcha/base64" def get_captcha_text(self, image_url: Optional[str] = None, image_b64: Optional[str] = None, cookie: Optional[str] = None, ua: Optional[str] = None): """ 根据图片地址,获取验证码图片,并识别内容 :param image_url: 图片地址 :param image_b64: 图片base64,跳过图片地址下载 :param cookie: 下载图片使用的cookie :param ua: 下载图片使用的ua """ if image_url: ret = RequestUtils(ua=ua, cookies=cookie).get_res(image_url) if ret is not None: image_bin = ret.content if not image_bin: return "" image_b64 = base64.b64encode(image_bin).decode() if not image_b64: return "" ret = RequestUtils(content_type="application/json").post_res( url=self._ocr_b64_url, json={"base64_img": image_b64}) if ret: return ret.json().get("result") return "" ================================================ FILE: app/helper/passkey.py ================================================ """ PassKey WebAuthn 辅助工具类 """ import base64 import json import binascii from typing import Optional, Tuple, List, Dict, Any from urllib.parse import urlparse from webauthn import ( generate_registration_options, verify_registration_response, generate_authentication_options, verify_authentication_response, options_to_json ) from webauthn.helpers import ( parse_registration_credential_json, parse_authentication_credential_json ) from webauthn.helpers.structs import ( PublicKeyCredentialDescriptor, AuthenticatorTransport, UserVerificationRequirement, AuthenticatorAttachment, ResidentKeyRequirement, AuthenticatorSelectionCriteria ) from webauthn.helpers.cose import COSEAlgorithmIdentifier from app.core.config import settings from app.log import logger class PassKeyHelper: """ PassKey WebAuthn 辅助类 """ @staticmethod def get_rp_id() -> str: """ 获取 Relying Party ID """ if settings.APP_DOMAIN: app_domain = settings.APP_DOMAIN.strip() # 确保存在协议前缀,以便 urlparse 正确解析主机和端口 if not app_domain.startswith(('http://', 'https://')): app_domain = f'https://{app_domain}' parsed = urlparse(app_domain) host = parsed.hostname if host: return host # 从 APP_DOMAIN 中提取域名 host = settings.APP_DOMAIN.replace('https://', '').replace('http://', '') # 移除端口号 if ':' in host: host = host.split(':')[0] return host # 只有在未配置 APP_DOMAIN 时,才默认为 localhost return 'localhost' @staticmethod def get_rp_name() -> str: """ 获取 Relying Party 名称 """ return "MoviePilot" @staticmethod def get_origin() -> str: """ 获取源地址 """ if settings.APP_DOMAIN: return settings.APP_DOMAIN.rstrip('/') # 如果未配置APP_DOMAIN,使用默认的localhost地址 return f'http://localhost:{settings.NGINX_PORT}' @staticmethod def standardize_credential_id(credential_id: str) -> str: """ 标准化凭证ID(Base64 URL Safe) """ try: # Base64解码并重新编码以标准化格式 decoded = base64.urlsafe_b64decode(credential_id + '==') return base64.urlsafe_b64encode(decoded).decode('utf-8').rstrip('=') except (binascii.Error, TypeError, ValueError) as e: logger.error(f"标准化凭证ID失败: {e}") return credential_id @staticmethod def _base64_encode_urlsafe(data: bytes) -> str: """ Base64 URL Safe 编码(不带填充) :param data: 要编码的字节数据 :return: Base64 URL Safe 编码的字符串 """ return base64.urlsafe_b64encode(data).decode('utf-8').rstrip('=') @staticmethod def _base64_decode_urlsafe(data: str) -> bytes: """ Base64 URL Safe 解码(自动添加填充) :param data: Base64 URL Safe 编码的字符串 :return: 解码后的字节数据 """ return base64.urlsafe_b64decode(data + '==') @staticmethod def _parse_credential_list(credentials: List[Dict[str, Any]]) -> List[PublicKeyCredentialDescriptor]: """ 解析凭证列表为 PublicKeyCredentialDescriptor 列表 :param credentials: 凭证字典列表 :return: PublicKeyCredentialDescriptor 列表 """ result = [] for cred in credentials: try: result.append( PublicKeyCredentialDescriptor( id=PassKeyHelper._base64_decode_urlsafe(cred['credential_id']), transports=[ AuthenticatorTransport(t) for t in cred.get('transports', '').split(',') if t ] if cred.get('transports') else None ) ) except Exception as e: logger.warning(f"解析凭证失败: {e}") continue return result @staticmethod def _get_user_verification_requirement(user_verification: Optional[str] = None) -> UserVerificationRequirement: """ 获取用户验证要求 :param user_verification: 指定的用户验证要求,如果不指定则从配置中读取 :return: UserVerificationRequirement """ if user_verification: return UserVerificationRequirement(user_verification) return UserVerificationRequirement.REQUIRED if settings.PASSKEY_REQUIRE_UV \ else UserVerificationRequirement.PREFERRED @staticmethod def _get_verification_params( expected_origin: Optional[str] = None, expected_rp_id: Optional[str] = None ) -> Tuple[str, str]: """ 获取验证参数(origin 和 rp_id) :param expected_origin: 期望的源地址 :param expected_rp_id: 期望的RP ID :return: (origin, rp_id) """ origin = expected_origin or PassKeyHelper.get_origin() rp_id = expected_rp_id or PassKeyHelper.get_rp_id() return origin, rp_id @staticmethod def generate_registration_options( user_id: int, username: str, display_name: Optional[str] = None, existing_credentials: Optional[List[Dict[str, Any]]] = None ) -> Tuple[str, str]: """ 生成注册选项 :param user_id: 用户ID :param username: 用户名 :param display_name: 显示名称 :param existing_credentials: 已存在的凭证列表 :return: (options_json, challenge) """ try: # 用户信息 user_id_bytes = str(user_id).encode('utf-8') # 排除已有的凭证 exclude_credentials = PassKeyHelper._parse_credential_list(existing_credentials) \ if existing_credentials else None # 用户验证要求 uv_requirement = PassKeyHelper._get_user_verification_requirement() # 生成注册选项 options = generate_registration_options( rp_id=PassKeyHelper.get_rp_id(), rp_name=PassKeyHelper.get_rp_name(), user_id=user_id_bytes, user_name=username, user_display_name=display_name or username, exclude_credentials=exclude_credentials, authenticator_selection=AuthenticatorSelectionCriteria( authenticator_attachment=None, resident_key=ResidentKeyRequirement.REQUIRED, user_verification=uv_requirement, ), supported_pub_key_algs=[ COSEAlgorithmIdentifier.ECDSA_SHA_256, COSEAlgorithmIdentifier.RSASSA_PKCS1_v1_5_SHA_256, ] ) # 转换为JSON options_json = options_to_json(options) # 提取challenge(用于后续验证) challenge = PassKeyHelper._base64_encode_urlsafe(options.challenge) return options_json, challenge except Exception as e: logger.error(f"生成注册选项失败: {e}") raise @staticmethod def verify_registration_response( credential: Dict[str, Any], expected_challenge: str, expected_origin: Optional[str] = None, expected_rp_id: Optional[str] = None ) -> Tuple[str, str, int, Optional[str]]: """ 验证注册响应 :param credential: 客户端返回的凭证 :param expected_challenge: 期望的challenge :param expected_origin: 期望的源地址 :param expected_rp_id: 期望的RP ID :return: (credential_id, public_key, sign_count, aaguid) """ try: # 准备验证参数 origin, rp_id = PassKeyHelper._get_verification_params(expected_origin, expected_rp_id) # 解码challenge challenge_bytes = PassKeyHelper._base64_decode_urlsafe(expected_challenge) # 构建RegistrationCredential对象 registration_credential = parse_registration_credential_json(json.dumps(credential)) # 验证注册响应 verification = verify_registration_response( credential=registration_credential, expected_challenge=challenge_bytes, expected_rp_id=rp_id, expected_origin=origin, require_user_verification=settings.PASSKEY_REQUIRE_UV ) # 提取信息 credential_id = PassKeyHelper._base64_encode_urlsafe(verification.credential_id) public_key = PassKeyHelper._base64_encode_urlsafe(verification.credential_public_key) sign_count = verification.sign_count # aaguid 可能已经是字符串格式,也可能是bytes if verification.aaguid: if isinstance(verification.aaguid, bytes): aaguid = verification.aaguid.hex() else: aaguid = str(verification.aaguid) else: aaguid = None return credential_id, public_key, sign_count, aaguid except Exception as e: logger.error(f"验证注册响应失败: {e}") raise @staticmethod def generate_authentication_options( existing_credentials: Optional[List[Dict[str, Any]]] = None, user_verification: Optional[str] = None ) -> Tuple[str, str]: """ 生成认证选项 :param existing_credentials: 已存在的凭证列表(用于限制可用凭证) :param user_verification: 用户验证要求,如果不指定则从配置中读取 :return: (options_json, challenge) """ try: # 允许的凭证 allow_credentials = PassKeyHelper._parse_credential_list(existing_credentials) \ if existing_credentials else None # 用户验证要求 uv_requirement = PassKeyHelper._get_user_verification_requirement(user_verification) # 生成认证选项 options = generate_authentication_options( rp_id=PassKeyHelper.get_rp_id(), allow_credentials=allow_credentials, user_verification=uv_requirement ) # 转换为JSON options_json = options_to_json(options) # 提取challenge challenge = PassKeyHelper._base64_encode_urlsafe(options.challenge) return options_json, challenge except Exception as e: logger.error(f"生成认证选项失败: {e}") raise @staticmethod def verify_authentication_response( credential: Dict[str, Any], expected_challenge: str, credential_public_key: str, credential_current_sign_count: int, expected_origin: Optional[str] = None, expected_rp_id: Optional[str] = None ) -> Tuple[bool, int]: """ 验证认证响应 :param credential: 客户端返回的凭证 :param expected_challenge: 期望的challenge :param credential_public_key: 凭证公钥 :param credential_current_sign_count: 当前签名计数 :param expected_origin: 期望的源地址 :param expected_rp_id: 期望的RP ID :return: (验证成功, 新的签名计数) """ try: # 准备验证参数 origin, rp_id = PassKeyHelper._get_verification_params(expected_origin, expected_rp_id) # 解码 challenge_bytes = PassKeyHelper._base64_decode_urlsafe(expected_challenge) public_key_bytes = PassKeyHelper._base64_decode_urlsafe(credential_public_key) # 构建AuthenticationCredential对象 authentication_credential = parse_authentication_credential_json(json.dumps(credential)) # 验证认证响应 verification = verify_authentication_response( credential=authentication_credential, expected_challenge=challenge_bytes, expected_rp_id=rp_id, expected_origin=origin, credential_public_key=public_key_bytes, credential_current_sign_count=credential_current_sign_count, require_user_verification=settings.PASSKEY_REQUIRE_UV ) return True, verification.new_sign_count except Exception as e: logger.error(f"验证认证响应失败: {e}") return False, credential_current_sign_count ================================================ FILE: app/helper/plugin.py ================================================ import importlib import io import json import shutil import site import sys import traceback import zipfile from pathlib import Path from typing import Dict, List, Optional, Tuple, Set, Callable, Awaitable import aiofiles import aioshutil import httpx from anyio import Path as AsyncPath from packaging.requirements import Requirement from packaging.specifiers import SpecifierSet, InvalidSpecifier from packaging.version import Version, InvalidVersion from importlib.metadata import distributions from requests import Response from app.core.cache import cached from app.core.config import settings from app.db.systemconfig_oper import SystemConfigOper from app.log import logger from app.schemas.types import SystemConfigKey from app.utils.http import RequestUtils, AsyncRequestUtils from app.utils.singleton import WeakSingleton from app.utils.system import SystemUtils from app.utils.url import UrlUtils PLUGIN_DIR = Path(settings.ROOT_PATH) / "app" / "plugins" class PluginHelper(metaclass=WeakSingleton): """ 插件市场管理,下载安装插件到本地 """ _base_url = "https://raw.githubusercontent.com/{user}/{repo}/main/" _install_reg = f"{settings.MP_SERVER_HOST}/plugin/install/{{pid}}" _install_report = f"{settings.MP_SERVER_HOST}/plugin/install" _install_statistic = f"{settings.MP_SERVER_HOST}/plugin/statistic" def __init__(self): self.systemconfig = SystemConfigOper() if settings.PLUGIN_STATISTIC_SHARE: if not self.systemconfig.get(SystemConfigKey.PluginInstallReport): if self.install_report(): self.systemconfig.set(SystemConfigKey.PluginInstallReport, "1") @cached(maxsize=128, ttl=1800) def get_plugins(self, repo_url: str, package_version: Optional[str] = None) -> Optional[Dict[str, dict]]: """ 获取Github所有最新插件列表 :param repo_url: Github仓库地址 :param package_version: 首选插件版本 (如 "v2", "v3"),如果不指定则获取 v1 版本 """ if not repo_url: return None user, repo = self.get_repo_info(repo_url) if not user or not repo: return None raw_url = self._base_url.format(user=user, repo=repo) package_url = f"{raw_url}package.{package_version}.json" if package_version else f"{raw_url}package.json" res = self.__request_with_fallback(package_url, headers=settings.REPO_GITHUB_HEADERS(repo=f"{user}/{repo}")) if res is None: return None if res: content = res.text try: return json.loads(content) except json.JSONDecodeError: if "404: Not Found" not in content: logger.warn(f"插件包数据解析失败:{content}") return None return {} def get_plugin_package_version(self, pid: str, repo_url: str, package_version: Optional[str] = None) -> Optional[str]: """ 检查并获取指定插件的可用版本,支持多版本优先级加载和版本兼容性检测 1. 如果未指定版本,则使用系统配置的默认版本(通过 settings.VERSION_FLAG 设置) 2. 优先检查指定版本的插件(如 `package.v2.json`) 3. 如果插件不存在于指定版本,检查 `package.json` 文件,查看该插件是否兼容指定版本 4. 如果插件不存在或不兼容指定版本,返回 `None` :param pid: 插件 ID,用于在插件列表中查找 :param repo_url: 插件仓库的 URL,指定用于获取插件信息的 GitHub 仓库地址 :param package_version: 首选插件版本 (如 "v2", "v3"),如不指定则默认使用系统配置的版本 :return: 返回可用的插件版本号 (如 "v2",如果指定版本不可用则返回空字符串表示 v1),如果插件不可用则返回 None """ # 如果没有指定版本,则使用当前系统配置的版本(如 "v2") if not package_version: package_version = settings.VERSION_FLAG # 优先检查指定版本的插件,即 package.v(x).json 文件中是否存在该插件,如果存在,返回该版本号 if pid in (self.get_plugins(repo_url, package_version) or []): return package_version # 如果指定版本的插件不存在,检查全局 package.json 文件,查看插件是否兼容指定的版本 plugin = (self.get_plugins(repo_url) or {}).get(pid, None) # 检查插件是否明确支持当前指定的版本(如 v2 或 v3),如果支持,返回空字符串表示使用 package.json(v1) if plugin and plugin.get(package_version) is True: return "" # 如果所有版本都不存在或插件不兼容,返回 None,表示插件不可用 return None @staticmethod def get_repo_info(repo_url: str) -> Tuple[Optional[str], Optional[str]]: """ 获取GitHub仓库信息 """ if not repo_url: return None, None if not repo_url.endswith("/"): repo_url += "/" if repo_url.count("/") < 6: repo_url = f"{repo_url}main/" try: user, repo = repo_url.split("/")[-4:-2] except Exception as e: logger.error(f"解析GitHub仓库地址失败:{str(e)} - {traceback.format_exc()}") return None, None return user, repo @cached(maxsize=1, ttl=1800) def get_statistic(self) -> Dict: """ 获取插件安装统计 """ if not settings.PLUGIN_STATISTIC_SHARE: return {} res = RequestUtils(proxies=settings.PROXY, timeout=10).get_res(self._install_statistic) if res and res.status_code == 200: return res.json() return {} def install_reg(self, pid: str, repo_url: Optional[str] = None) -> bool: """ 安装插件统计 """ if not settings.PLUGIN_STATISTIC_SHARE: return False if not pid: return False install_reg_url = self._install_reg.format(pid=pid) res = RequestUtils( proxies=settings.PROXY, content_type="application/json", timeout=5 ).post(install_reg_url, json={ "plugin_id": pid, "repo_url": repo_url }) if res and res.status_code == 200: return True return False def install_report(self, items: Optional[List[Tuple[str, Optional[str]]]] = None) -> bool: """ 上报存量插件安装统计(批量)。支持上送 repo_url。 :param items: 可选,形如 [(plugin_id, repo_url), ...];不传则回落到历史配置,仅上送 plugin_id。 """ if not settings.PLUGIN_STATISTIC_SHARE: return False payload_plugins = [] if items: for pid, repo_url in items: if pid: payload_plugins.append({"plugin_id": pid, "repo_url": repo_url}) else: plugins = self.systemconfig.get(SystemConfigKey.UserInstalledPlugins) if not plugins: return False payload_plugins = [{"plugin_id": plugin, "repo_url": None} for plugin in plugins] res = RequestUtils(proxies=settings.PROXY, content_type="application/json", timeout=5).post(self._install_report, json={"plugins": payload_plugins}) return True if res else False def install(self, pid: str, repo_url: str, package_version: Optional[str] = None, force_install: bool = False) \ -> Tuple[bool, str]: """ 安装插件,包括依赖安装和文件下载,相关资源支持自动降级策略 1. 检查并获取插件的指定版本,确认版本兼容性 2. 从 GitHub 获取文件列表(包括 requirements.txt) 3. 删除旧的插件目录(如非强制安装则进行备份) 4. 下载并预安装 requirements.txt 中的依赖(如果存在) 5. 下载并安装插件的其他文件 6. 再次尝试安装依赖(确保安装完整) :param pid: 插件 ID :param repo_url: 插件仓库地址 :param package_version: 首选插件版本 (如 "v2", "v3"),如不指定则默认使用系统配置的版本 :param force_install: 是否强制安装插件,默认不启用,启用时不进行备份和恢复操作 :return: (是否成功, 错误信息) """ if SystemUtils.is_frozen(): return False, "可执行文件模式下,只能安装本地插件" # 验证参数 if not pid or not repo_url: return False, "参数错误" # 从 GitHub 的 repo_url 获取用户和项目名 user, repo = self.get_repo_info(repo_url) if not user or not repo: return False, "不支持的插件仓库地址格式" user_repo = f"{user}/{repo}" if not package_version: package_version = settings.VERSION_FLAG # 1. 优先检查指定版本的插件 package_version = self.get_plugin_package_version(pid, repo_url, package_version) # 如果 package_version 为None,说明没有找到匹配的插件 if package_version is None: msg = f"{pid} 没有找到适用于当前版本的插件" logger.debug(msg) return False, msg # package_version 为空,表示从 package.json 中找到插件 elif package_version == "": logger.debug(f"{pid} 从 package.json 中找到适用于当前版本的插件") else: logger.debug(f"{pid} 从 package.{package_version}.json 中找到适用于当前版本的插件") # 2. 决定安装方式(release 或 文件列表)并执行统一安装流程 meta = self.__get_plugin_meta(pid, repo_url, package_version) # 是否release打包 is_release = meta.get("release") # 插件版本号 plugin_version = meta.get("version") if is_release: # 使用 插件ID_插件版本号 作为 Release tag if not plugin_version: return False, f"未在插件清单中找到 {pid} 的版本号,无法进行 Release 安装" # 拼接 release_tag release_tag = f"{pid}_v{plugin_version}" # 使用 release 进行安装 def prepare_release() -> Tuple[bool, str]: return self.__install_from_release( pid, user_repo, release_tag ) return self.__install_flow_sync(pid, force_install, prepare_release, repo_url) else: # 如果 release_tag 不存在,说明插件没有发布版本,使用文件列表方式安装 def prepare_filelist() -> Tuple[bool, str]: return self.__prepare_content_via_filelist_sync(pid.lower(), user_repo, package_version) return self.__install_flow_sync(pid, force_install, prepare_filelist, repo_url) def __get_file_list(self, pid: str, user_repo: str, package_version: Optional[str] = None) -> \ Tuple[Optional[list], Optional[str]]: """ 获取插件的文件列表 :param pid: 插件 ID :param user_repo: GitHub 仓库的 user/repo 路径 :return: (文件列表, 错误信息) """ file_api = f"https://api.github.com/repos/{user_repo}/contents/plugins" # 如果 package_version 存在(如 "v2"),则加上版本号 if package_version: file_api += f".{package_version}" file_api += f"/{pid.lower()}" res = self.__request_with_fallback(file_api, headers=settings.REPO_GITHUB_HEADERS(repo=user_repo), is_api=True, timeout=30) if res is None: return None, "连接仓库失败" elif res.status_code != 200: return None, f"连接仓库失败:{res.status_code} - " \ f"{'超出速率限制,请设置Github Token或稍后重试' if res.status_code == 403 else res.reason}" try: ret = res.json() if isinstance(ret, list) and len(ret) > 0 and "message" not in ret[0]: return ret, "" else: return None, "插件在仓库中不存在或返回数据格式不正确" except Exception as e: logger.error(f"插件数据解析失败:{e}") return None, "插件数据解析失败" def __download_files(self, pid: str, file_list: List[dict], user_repo: str, package_version: Optional[str] = None, skip_requirements: bool = False) -> Tuple[bool, str]: """ 下载插件文件 :param pid: 插件 ID :param file_list: 要下载的文件列表,包含文件的元数据(包括下载链接) :param user_repo: GitHub 仓库的 user/repo 路径 :param skip_requirements: 是否跳过 requirements.txt 文件的下载 :return: (是否成功, 错误信息) """ if not file_list: return False, "文件列表为空" # 使用栈结构来替代递归调用,避免递归深度过大问题 stack = [(pid, file_list)] while stack: current_pid, current_file_list = stack.pop() for item in current_file_list: # 跳过 requirements.txt 的下载 if skip_requirements and item.get("name") == "requirements.txt": continue if item.get("download_url"): logger.debug(f"正在下载文件:{item.get('path')}") res = self.__request_with_fallback(item.get('download_url'), headers=settings.REPO_GITHUB_HEADERS(repo=user_repo)) if not res: return False, f"文件 {item.get('path')} 下载失败!" elif res.status_code != 200: return False, f"下载文件 {item.get('path')} 失败:{res.status_code}" # 确保文件路径不包含版本号(如 v2、v3),如果有 package_version,移除路径中的版本号 relative_path = item.get("path") if package_version: relative_path = relative_path.replace(f"plugins.{package_version}", "plugins", 1) # 创建插件文件夹并写入文件 file_path = Path(settings.ROOT_PATH) / "app" / relative_path file_path.parent.mkdir(parents=True, exist_ok=True) with open(file_path, "w", encoding="utf-8") as f: f.write(res.text) logger.debug(f"文件 {item.get('path')} 下载成功,保存路径:{file_path}") else: # 如果是子目录,则将子目录内容加入栈中继续处理 sub_list, msg = self.__get_file_list(f"{current_pid}/{item.get('name')}", user_repo, package_version) if not sub_list: return False, msg stack.append((f"{current_pid}/{item.get('name')}", sub_list)) return True, "" def __download_and_install_requirements(self, requirements_file_info: dict, pid: str, user_repo: str) \ -> Tuple[bool, str]: """ 下载并安装 requirements.txt 文件中的依赖 :param requirements_file_info: requirements.txt 文件的元数据信息 :param pid: 插件 ID :param user_repo: GitHub 仓库的 user/repo 路径 :return: (是否成功, 错误信息) """ # 下载 requirements.txt res = self.__request_with_fallback(requirements_file_info.get("download_url"), headers=settings.REPO_GITHUB_HEADERS(repo=user_repo)) if not res: return False, "requirements.txt 文件下载失败" elif res.status_code != 200: return False, f"下载 requirements.txt 文件失败:{res.status_code}" requirements_txt = res.text if requirements_txt.strip(): # 保存并安装依赖 requirements_file_path = PLUGIN_DIR / pid.lower() / "requirements.txt" requirements_file_path.parent.mkdir(parents=True, exist_ok=True) with open(requirements_file_path, "w", encoding="utf-8") as f: f.write(requirements_txt) return self.pip_install_with_fallback(requirements_file_path) return True, "" # 如果 requirements.txt 为空,视作成功 def __install_dependencies_if_required(self, pid: str) -> Tuple[bool, bool, str]: """ 安装插件依赖。 :param pid: 插件 ID :return: (是否存在依赖,安装是否成功, 错误信息) """ # 定位插件目录和依赖文件 plugin_dir = PLUGIN_DIR / pid.lower() requirements_file = plugin_dir / "requirements.txt" # 检查是否存在 requirements.txt 文件 if requirements_file.exists(): logger.info(f"{pid} 存在依赖,开始尝试安装依赖") success, error_message = self.pip_install_with_fallback(requirements_file) if success: return True, True, "" else: return True, False, error_message return False, False, "不存在依赖" @staticmethod def __backup_plugin(pid: str) -> str: """ 备份旧插件目录 :param pid: 插件 ID :return: 备份目录路径 """ plugin_dir = PLUGIN_DIR / pid.lower() backup_dir = Path(settings.TEMP_PATH) / "plugin_backup" / pid.lower() if plugin_dir.exists(): # 备份时清理已有的备份目录,防止残留文件影响 if backup_dir.exists(): shutil.rmtree(backup_dir, ignore_errors=True) logger.debug(f"{pid} 旧的备份目录已清理 {backup_dir}") shutil.copytree(plugin_dir, backup_dir, dirs_exist_ok=True) logger.debug(f"{pid} 插件已备份到 {backup_dir}") return str(backup_dir) if backup_dir.exists() else None @staticmethod def __restore_plugin(pid: str, backup_dir: str): """ 还原旧插件目录 :param pid: 插件 ID :param backup_dir: 备份目录路径 """ plugin_dir = PLUGIN_DIR / pid.lower() if plugin_dir.exists(): shutil.rmtree(plugin_dir, ignore_errors=True) logger.debug(f"{pid} 已清理插件目录 {plugin_dir}") if Path(backup_dir).exists(): shutil.copytree(backup_dir, plugin_dir, dirs_exist_ok=True) logger.debug(f"{pid} 已还原插件目录 {plugin_dir}") shutil.rmtree(backup_dir, ignore_errors=True) logger.debug(f"{pid} 已删除备份目录 {backup_dir}") @staticmethod def __remove_old_plugin(pid: str): """ 删除旧插件 :param pid: 插件 ID """ plugin_dir = PLUGIN_DIR / pid.lower() if plugin_dir.exists(): shutil.rmtree(plugin_dir, ignore_errors=True) @staticmethod def pip_install_with_fallback(requirements_file: Path) -> Tuple[bool, str]: """ 使用自动降级策略安装依赖,并确保新安装的包可被动态导入 :param requirements_file: 依赖的 requirements.txt 文件路径 :return: (是否成功, 错误信息) """ wheels_dir = requirements_file.parent / "wheels" find_links_option = [] if wheels_dir.is_dir(): # 如果目录存在,增加 --find-links 选项 logger.debug(f"[PIP] 发现插件内嵌的 wheels 目录: {wheels_dir},将优先从本地安装。") find_links_option = ["--find-links", str(wheels_dir)] else: # 如果不存在,选项为空列表,对后续命令无影响 logger.debug(f"[PIP] 未发现插件内嵌的 wheels 目录,将仅使用在线源。") base_cmd = [sys.executable, "-m", "pip", "install"] + find_links_option + ["-r", str(requirements_file)] strategies = [] # 添加策略到列表中 if settings.PIP_PROXY: strategies.append(("镜像站", base_cmd + ["-i", settings.PIP_PROXY])) if settings.PROXY_HOST: strategies.append(("代理", base_cmd + ["--proxy", settings.PROXY_HOST])) strategies.append(("直连", base_cmd)) # 记录当前已安装的包,以便后续刷新 before_installation = set(sys.modules.keys()) # 遍历策略进行安装 for strategy_name, pip_command in strategies: logger.debug(f"[PIP] 尝试使用策略:{strategy_name} 安装依赖,命令:{' '.join(pip_command)}") success, message = SystemUtils.execute_with_subprocess(pip_command) if success: logger.debug(f"[PIP] 策略:{strategy_name} 安装依赖成功,输出:{message}") # 安装成功后刷新Python的模块系统 importlib.reload(site) # 获取新安装的模块 current_modules = set(sys.modules.keys()) new_modules = current_modules - before_installation # 重新加载新安装的模块 for module in new_modules: if module in sys.modules: del sys.modules[module] logger.debug(f"[PIP] 已刷新导入系统,新加载的模块: {new_modules}") return True, message else: logger.error(f"[PIP] 策略:{strategy_name} 安装依赖失败,错误信息:{message}") return False, "[PIP] 所有策略均安装依赖失败,请检查网络连接或 PIP 配置" @staticmethod def __request_with_fallback(url: str, headers: Optional[dict] = None, timeout: Optional[int] = 60, is_api: bool = False) -> Optional[Response]: """ 使用自动降级策略,请求资源,优先级依次为镜像站、代理、直连 :param url: 目标URL :param headers: 请求头信息 :param timeout: 请求超时时间 :param is_api: 是否为GitHub API请求,API请求不走镜像站 :return: 请求成功则返回 Response,失败返回 None """ strategies = [] # 1. 尝试使用镜像站,镜像站一般不支持API请求,因此API请求直接跳过镜像站 if not is_api and settings.GITHUB_PROXY: proxy_url = f"{UrlUtils.standardize_base_url(settings.GITHUB_PROXY)}{url}" strategies.append(("镜像站", proxy_url, {"headers": headers, "timeout": timeout})) # 2. 尝试使用代理 if settings.PROXY_HOST: strategies.append(("代理", url, {"headers": headers, "proxies": settings.PROXY, "timeout": timeout})) # 3. 最后尝试直连 strategies.append(("直连", url, {"headers": headers, "timeout": timeout})) # 遍历策略并尝试请求 for strategy_name, target_url, request_params in strategies: logger.debug(f"[GitHub] 尝试使用策略:{strategy_name} 请求 URL:{target_url}") try: res = RequestUtils(**request_params).get_res(url=target_url, raise_exception=True) logger.debug(f"[GitHub] 请求成功,策略:{strategy_name}, URL: {target_url}") return res except Exception as e: logger.error(f"[GitHub] 请求失败,策略:{strategy_name}, URL: {target_url},错误:{str(e)}") logger.error(f"[GitHub] 所有策略均请求失败,URL: {url},请检查网络连接或 GitHub 配置") return None def __get_plugin_meta(self, pid: str, repo_url: str, package_version: Optional[str]) -> dict: try: plugins = ( self.get_plugins(repo_url) if not package_version else self.get_plugins(repo_url, package_version) ) or {} meta = plugins.get(pid) return meta if isinstance(meta, dict) else {} except Exception as e: logger.error(f"获取插件 {pid} 元数据失败:{e}") return {} def __install_flow_sync(self, pid: str, force_install: bool, prepare_content: Callable[[], Tuple[bool, str]], repo_url: Optional[str] = None) -> Tuple[bool, str]: """ 同步安装统一流程:备份→清理→准备内容→安装依赖→上报 prepare_content 负责把插件文件放到 app/plugins/{pid} """ backup_dir = None if not force_install: backup_dir = self.__backup_plugin(pid) self.__remove_old_plugin(pid) success, message = prepare_content() if not success: logger.error(f"{pid} 准备插件内容失败:{message}") if backup_dir: self.__restore_plugin(pid, backup_dir) logger.warning(f"{pid} 插件安装失败,已还原备份插件") else: self.__remove_old_plugin(pid) logger.warning(f"{pid} 已清理对应插件目录,请尝试重新安装") return False, message dependencies_exist, dep_ok, dep_msg = self.__install_dependencies_if_required(pid) if dependencies_exist and not dep_ok: logger.error(f"{pid} 依赖安装失败:{dep_msg}") if backup_dir: self.__restore_plugin(pid, backup_dir) logger.warning(f"{pid} 插件安装失败,已还原备份插件") else: self.__remove_old_plugin(pid) logger.warning(f"{pid} 已清理对应插件目录,请尝试重新安装") return False, dep_msg self.install_reg(pid, repo_url) return True, "" def __install_from_release(self, pid: str, user_repo: str, release_tag: str) -> Tuple[bool, str]: """ 通过 GitHub Release 资产文件安装插件。 规范:release 中存在名为 "{pid}_v{version}.zip" 的资产,zip 根即插件文件; 将其全部解压到 app/plugins/{pid} """ # 拼接资产文件名 asset_name = f"{release_tag.lower()}.zip" release_api = f"https://api.github.com/repos/{user_repo}/releases/tags/{release_tag}" rel_res = self.__request_with_fallback( release_api, headers=settings.REPO_GITHUB_HEADERS(repo=user_repo), timeout=30, is_api=True, ) if rel_res is None or rel_res.status_code != 200: return False, f"获取 Release 信息失败:{rel_res.status_code if rel_res else '连接失败'}" try: rel_json = rel_res.json() assets = rel_json.get("assets") or [] asset = next((a for a in assets if a.get("name") == asset_name), None) if not asset: return False, f"未找到资产文件:{asset_name}" asset_id = asset.get("id") if not asset_id: return False, "资产缺少ID信息" # 构建资产的API下载URL download_url = f"https://api.github.com/repos/{user_repo}/releases/assets/{asset_id}" except Exception as e: logger.error(f"解析 Release 信息失败:{e}") return False, f"解析 Release 信息失败:{e}" # 使用资产的API端点下载,需要设置Accept头为application/octet-stream headers = settings.REPO_GITHUB_HEADERS(repo=user_repo).copy() headers["Accept"] = "application/octet-stream" res = self.__request_with_fallback(download_url, headers=headers, is_api=True) if res is None or res.status_code != 200: return False, f"下载资产失败:{res.status_code if res else '连接失败'}" try: with zipfile.ZipFile(io.BytesIO(res.content)) as zf: namelist = zf.namelist() if not namelist: return False, "压缩包内容为空" # 若所有条目均在同一顶层目录下(如 pid/),则剥离这一层,避免出现双层目录 names_with_slash = [n for n in namelist if '/' in n] base_prefix = '' if names_with_slash and len(names_with_slash) == len(namelist): first_seg = names_with_slash[0].split('/')[0] if all(n.startswith(first_seg + '/') for n in namelist): base_prefix = first_seg + '/' dest_base = Path(settings.ROOT_PATH) / "app" / "plugins" / pid.lower() wrote_any = False for name in namelist: rel_path = name[len(base_prefix):] if not rel_path: continue if rel_path.endswith('/'): (dest_base / rel_path.rstrip('/')).mkdir(parents=True, exist_ok=True) continue dest_path = dest_base / rel_path dest_path.parent.mkdir(parents=True, exist_ok=True) with zf.open(name, 'r') as src, open(dest_path, 'wb') as dst: dst.write(src.read()) wrote_any = True if not wrote_any: return False, "压缩包中无可写入文件" return True, "" except Exception as e: logger.error(f"解压 Release 压缩包失败:{e}") return False, f"解压 Release 压缩包失败:{e}" def find_missing_dependencies(self) -> List[str]: """ 收集所有需要安装或更新的依赖项 1. 收集所有插件的依赖项,合并版本约束 2. 获取已安装的包及其版本 3. 比较已安装的包与所需的依赖项,找出需要安装或升级的包 :return: 需要安装或更新的依赖项列表,例如 ["package1>=1.0.0", "package2"] """ try: # 收集所有插件的依赖项 plugin_dependencies = self.__find_plugin_dependencies() # 返回格式为 {package_name: version_specifier} # 获取已安装的包及其版本 installed_packages = self.__get_installed_packages() # 返回格式为 {package_name: Version} # 需要安装或更新的依赖项列表 dependencies_to_install = [] for pkg_name, version_specifier in plugin_dependencies.items(): spec_set = SpecifierSet(version_specifier) installed_version = installed_packages.get(pkg_name) if installed_version is None: # 包未安装,需要安装 if version_specifier: dependencies_to_install.append(f"{pkg_name}{version_specifier}") else: dependencies_to_install.append(pkg_name) elif not spec_set.contains(installed_version, prereleases=True): # 已安装的版本不满足版本约束,需要升级或降级 if version_specifier: dependencies_to_install.append(f"{pkg_name}{version_specifier}") else: dependencies_to_install.append(pkg_name) # 已安装的版本满足要求,无需操作 return dependencies_to_install except Exception as e: logger.error(f"收集所有需要安装或更新的依赖项时发生错误:{e}") return [] def install_dependencies(self, dependencies: List[str]) -> Tuple[bool, str]: """ 安装指定的依赖项列表 :param dependencies: 需要安装或更新的依赖项列表 :return: (success, message) """ if not dependencies: return False, "没有传入需要安装的依赖项" try: logger.debug(f"需要安装或更新的依赖项:{dependencies}") # 创建临时的 requirements.txt 文件用于批量安装 requirements_temp_file = Path(settings.TEMP_PATH) / "plugin_dependencies" / "requirements.txt" requirements_temp_file.parent.mkdir(parents=True, exist_ok=True) with open(requirements_temp_file, "w", encoding="utf-8") as f: for dep in dependencies: f.write(dep + "\n") try: # 使用自动降级策略安装依赖 return self.pip_install_with_fallback(requirements_temp_file) finally: # 删除临时文件 requirements_temp_file.unlink() except Exception as e: logger.error(f"安装依赖项时发生错误:{e}") return False, f"安装依赖项时发生错误:{e}" def __get_installed_packages(self) -> Dict[str, Version]: """ 获取已安装的包及其版本 使用 importlib.metadata 获取当前环境中已安装的包,标准化包名并转换版本信息 对于无法解析的版本,记录警告日志并跳过 :return: 已安装包的字典,格式为 {package_name: Version} """ installed_packages = {} try: for dist in distributions(): name = dist.metadata.get("Name") if not name: continue pkg_name = self.__standardize_pkg_name(name) version_str = dist.metadata.get("Version") or getattr(dist, "version", None) if not version_str: continue try: v = Version(version_str) if pkg_name not in installed_packages or v > installed_packages[pkg_name]: installed_packages[pkg_name] = v except InvalidVersion: logger.debug(f"无法解析已安装包 '{pkg_name}' 的版本:{version_str}") continue return installed_packages except Exception as e: logger.error(f"获取已安装的包时发生错误:{e}") return {} def __find_plugin_dependencies(self) -> Dict[str, str]: """ 收集所有插件的依赖项 遍历 plugins 目录下的所有插件,查找存在 requirements.txt 的插件目录 ,并解析其中的依赖项,同时将所有插件的依赖项合并到字典中,方便后续统一处理 :return: 依赖项字典,格式为 {package_name: set(version_specifiers)} """ dependencies = {} try: install_plugins = { plugin_id.lower() # 对应插件的小写目录名 for plugin_id in SystemConfigOper().get( SystemConfigKey.UserInstalledPlugins ) or [] } for plugin_dir in PLUGIN_DIR.iterdir(): if plugin_dir.is_dir(): requirements_file = plugin_dir / "requirements.txt" if requirements_file.exists(): if plugin_dir.name not in install_plugins: # 这个插件不在安装列表中 忽略它的依赖 logger.debug(f"忽略插件 {plugin_dir.name} 的依赖") continue # 解析当前插件的 requirements.txt,获取依赖项 plugin_deps = self.__parse_requirements(requirements_file) for pkg_name, version_specifiers in plugin_deps.items(): if pkg_name in dependencies: # 更新已存在的包的版本约束集合 dependencies[pkg_name].update(version_specifiers) else: # 添加新的包及其版本约束 dependencies[pkg_name] = set(version_specifiers) return self.__merge_dependencies(dependencies) except Exception as e: logger.error(f"收集插件依赖项时发生错误:{e}") return {} def __parse_requirements(self, requirements_file: Path) -> Dict[str, List[str]]: """ 解析 requirements.txt 文件,返回依赖项字典 使用 packaging 库解析每一行依赖项,提取包名和版本约束 对于无法解析的行,记录警告日志,便于后续检查 :param requirements_file: requirements.txt 文件的路径 :return: 依赖项字典,格式为 {package_name: [version_specifier]} """ dependencies = {} try: with open(requirements_file, "r", encoding="utf-8") as f: for line in f: line = line.strip() if line and not line.startswith('#'): # 使用 packaging 库解析依赖项 try: req = Requirement(line) pkg_name = self.__standardize_pkg_name(req.name) version_specifier = str(req.specifier) if pkg_name in dependencies: dependencies[pkg_name].append(version_specifier) else: dependencies[pkg_name] = [version_specifier] except Exception as e: logger.debug(f"无法解析依赖项 '{line}':{e}") return dependencies except Exception as e: logger.error(f"解析 requirements.txt 时发生错误:{e}") return {} @staticmethod def __merge_dependencies(dependencies: Dict[str, Set[str]]) -> Dict[str, str]: """ 合并依赖项,选择每个包的最高版本要求 对于多个插件依赖同一包的情况,合并其版本约束,取交集以满足所有插件的要求 如果交集为空,表示存在版本冲突,需要根据策略进行处理 :param dependencies: 依赖项字典,格式为 {package_name: set(version_specifiers)} :return: 合并后的依赖项字典,格式为 {package_name: version_specifiers} """ try: merged_dependencies = {} for pkg_name, version_specifiers in dependencies.items(): # 合并版本约束 spec_set = SpecifierSet() for specifier in version_specifiers: try: if specifier: spec_set &= SpecifierSet(specifier) except InvalidSpecifier as e: logger.error(f"发生版本约束冲突:{e}") # 将合并后的版本约束添加到结果字典 merged_dependencies[pkg_name] = str(spec_set) if spec_set else '' return merged_dependencies except Exception as e: logger.error(f"合并依赖项时发生错误:{e}") return {} @staticmethod def __standardize_pkg_name(name: str) -> str: """ 标准化包名,将包名转换为小写,连字符与点替换为下划线(与 PEP 503 归一化风格一致) :param name: 原始包名 :return: 标准化后的包名 """ if not name: return name return name.lower().replace("-", "_").replace(".", "_") async def async_get_plugin_package_version(self, pid: str, repo_url: str, package_version: Optional[str] = None) -> Optional[str]: """ 异步版本的获取插件版本方法,功能同 get_plugin_package_version """ if not package_version: package_version = settings.VERSION_FLAG if pid in (await self.async_get_plugins(repo_url, package_version) or []): return package_version plugin = (await self.async_get_plugins(repo_url) or {}).get(pid, None) if plugin and plugin.get(package_version) is True: return "" return None @staticmethod async def __async_request_with_fallback(url: str, headers: Optional[dict] = None, timeout: Optional[int] = 60, is_api: bool = False) -> Optional[httpx.Response]: """ 使用自动降级策略,异步请求资源,优先级依次为镜像站、代理、直连 :param url: 目标URL :param headers: 请求头信息 :param timeout: 请求超时时间 :param is_api: 是否为GitHub API请求,API请求不走镜像站 :return: 请求成功则返回 Response,失败返回 None """ strategies = [] # 1. 尝试使用镜像站,镜像站一般不支持API请求,因此API请求直接跳过镜像站 if not is_api and settings.GITHUB_PROXY: proxy_url = f"{UrlUtils.standardize_base_url(settings.GITHUB_PROXY)}{url}" strategies.append(("镜像站", proxy_url, {"headers": headers, "timeout": timeout})) # 2. 尝试使用代理 if settings.PROXY_HOST: strategies.append(("代理", url, {"headers": headers, "proxies": settings.PROXY, "timeout": timeout})) # 3. 最后尝试直连 strategies.append(("直连", url, {"headers": headers, "timeout": timeout})) # 遍历策略并尝试请求 for strategy_name, target_url, request_params in strategies: logger.debug(f"[GitHub] 尝试使用策略:{strategy_name} 请求 URL:{target_url}") try: res = await AsyncRequestUtils(**request_params).get_res(url=target_url, raise_exception=True) logger.debug(f"[GitHub] 请求成功,策略:{strategy_name}, URL: {target_url}") return res except Exception as e: logger.error(f"[GitHub] 请求失败,策略:{strategy_name}, URL: {target_url},错误:{str(e)}") logger.error(f"[GitHub] 所有策略均请求失败,URL: {url},请检查网络连接或 GitHub 配置") return None @cached(maxsize=128, ttl=1800) async def async_get_plugins(self, repo_url: str, package_version: Optional[str] = None) -> Optional[Dict[str, dict]]: """ 异步获取Github所有最新插件列表 :param repo_url: Github仓库地址 :param package_version: 首选插件版本 (如 "v2", "v3"),如果不指定则获取 v1 版本 """ if not repo_url: return None user, repo = self.get_repo_info(repo_url) if not user or not repo: return None raw_url = self._base_url.format(user=user, repo=repo) package_url = f"{raw_url}package.{package_version}.json" if package_version else f"{raw_url}package.json" res = await self.__async_request_with_fallback(package_url, headers=settings.REPO_GITHUB_HEADERS(repo=f"{user}/{repo}")) if res is None: return None if res: content = res.text try: return json.loads(content) except json.JSONDecodeError: if "404: Not Found" not in content: logger.warn(f"插件包数据解析失败:{content}") return None return {} async def async_get_statistic(self) -> Dict: """ 异步获取插件安装统计 """ if not settings.PLUGIN_STATISTIC_SHARE: return {} res = await AsyncRequestUtils(proxies=settings.PROXY, timeout=10).get_res(self._install_statistic) if res and res.status_code == 200: return res.json() return {} async def async_install_reg(self, pid: str, repo_url: Optional[str] = None) -> bool: """ 异步安装插件统计 """ if not settings.PLUGIN_STATISTIC_SHARE: return False if not pid: return False install_reg_url = self._install_reg.format(pid=pid) res = await AsyncRequestUtils( proxies=settings.PROXY, content_type="application/json", timeout=5 ).post(install_reg_url, json={ "plugin_id": pid, "repo_url": repo_url }) if res and res.status_code == 200: return True return False async def async_install_report(self, items: Optional[List[Tuple[str, Optional[str]]]] = None) -> bool: """ 异步上报存量插件安装统计(批量)。支持上送 repo_url。 :param items: 可选,形如 [(plugin_id, repo_url), ...];不传则回落到历史配置,仅上送 plugin_id。 """ if not settings.PLUGIN_STATISTIC_SHARE: return False payload_plugins = [] if items: for pid, repo_url in items: if pid: payload_plugins.append({"plugin_id": pid, "repo_url": repo_url}) else: plugins = self.systemconfig.get(SystemConfigKey.UserInstalledPlugins) if not plugins: return False payload_plugins = [{"plugin_id": plugin, "repo_url": None} for plugin in plugins] res = await AsyncRequestUtils(proxies=settings.PROXY, content_type="application/json", timeout=5).post(self._install_report, json={"plugins": payload_plugins}) return True if res else False async def __async_get_file_list(self, pid: str, user_repo: str, package_version: Optional[str] = None) -> \ Tuple[Optional[list], Optional[str]]: """ 异步获取插件的文件列表 :param pid: 插件 ID :param user_repo: GitHub 仓库的 user/repo 路径 :return: (文件列表, 错误信息) """ file_api = f"https://api.github.com/repos/{user_repo}/contents/plugins" # 如果 package_version 存在(如 "v2"),则加上版本号 if package_version: file_api += f".{package_version}" file_api += f"/{pid.lower()}" res = await self.__async_request_with_fallback(file_api, headers=settings.REPO_GITHUB_HEADERS(repo=user_repo), is_api=True, timeout=30) if res is None: return None, "连接仓库失败" elif res.status_code != 200: return None, f"连接仓库失败:{res.status_code} - " \ f"{'超出速率限制,请设置Github Token或稍后重试' if res.status_code == 403 else res.text}" try: ret = res.json() if isinstance(ret, list) and len(ret) > 0 and "message" not in ret[0]: return ret, "" else: return None, "插件在仓库中不存在或返回数据格式不正确" except Exception as e: logger.error(f"插件数据解析失败:{e}") return None, "插件数据解析失败" async def __async_download_files(self, pid: str, file_list: List[dict], user_repo: str, package_version: Optional[str] = None, skip_requirements: bool = False) -> Tuple[bool, str]: """ 异步下载插件文件 :param pid: 插件 ID :param file_list: 要下载的文件列表,包含文件的元数据(包括下载链接) :param user_repo: GitHub 仓库的 user/repo 路径 :param skip_requirements: 是否跳过 requirements.txt 文件的下载 :return: (是否成功, 错误信息) """ if not file_list: return False, "文件列表为空" # 使用栈结构来替代递归调用,避免递归深度过大问题 stack = [(pid, file_list)] while stack: current_pid, current_file_list = stack.pop() for item in current_file_list: # 跳过 requirements.txt 的下载 if skip_requirements and item.get("name") == "requirements.txt": continue if item.get("download_url"): logger.debug(f"正在下载文件:{item.get('path')}") res = await self.__async_request_with_fallback(item.get('download_url'), headers=settings.REPO_GITHUB_HEADERS(repo=user_repo)) if not res: return False, f"文件 {item.get('path')} 下载失败!" elif res.status_code != 200: return False, f"下载文件 {item.get('path')} 失败:{res.status_code}" # 确保文件路径不包含版本号(如 v2、v3),如果有 package_version,移除路径中的版本号 relative_path = item.get("path") if package_version: relative_path = relative_path.replace(f"plugins.{package_version}", "plugins", 1) # 创建插件文件夹并写入文件 file_path = AsyncPath(settings.ROOT_PATH) / "app" / relative_path await file_path.parent.mkdir(parents=True, exist_ok=True) async with aiofiles.open(file_path, "w", encoding="utf-8") as f: await f.write(res.text) logger.debug(f"文件 {item.get('path')} 下载成功,保存路径:{file_path}") else: # 如果是子目录,则将子目录内容加入栈中继续处理 sub_list, msg = await self.__async_get_file_list(f"{current_pid}/{item.get('name')}", user_repo, package_version) if not sub_list: return False, msg stack.append((f"{current_pid}/{item.get('name')}", sub_list)) return True, "" async def __async_download_and_install_requirements(self, requirements_file_info: dict, pid: str, user_repo: str) \ -> Tuple[bool, str]: """ 异步下载并安装 requirements.txt 文件中的依赖 :param requirements_file_info: requirements.txt 文件的元数据信息 :param pid: 插件 ID :param user_repo: GitHub 仓库的 user/repo 路径 :return: (是否成功, 错误信息) """ # 下载 requirements.txt res = await self.__async_request_with_fallback(requirements_file_info.get("download_url"), headers=settings.REPO_GITHUB_HEADERS(repo=user_repo)) if not res: return False, "requirements.txt 文件下载失败" elif res.status_code != 200: return False, f"下载 requirements.txt 文件失败:{res.status_code}" requirements_txt = res.text if requirements_txt.strip(): # 保存并安装依赖 requirements_file_path = AsyncPath(PLUGIN_DIR) / pid.lower() / "requirements.txt" await requirements_file_path.parent.mkdir(parents=True, exist_ok=True) async with aiofiles.open(requirements_file_path, "w", encoding="utf-8") as f: await f.write(requirements_txt) return self.pip_install_with_fallback(Path(requirements_file_path)) return True, "" # 如果 requirements.txt 为空,视作成功 async def __async_backup_plugin(self, pid: str) -> str: """ 异步备份旧插件目录 :param pid: 插件 ID :return: 备份目录路径 """ plugin_dir = AsyncPath(PLUGIN_DIR) / pid.lower() backup_dir = AsyncPath(settings.TEMP_PATH) / "plugin_backup" / pid.lower() if await plugin_dir.exists(): # 备份时清理已有的备份目录,防止残留文件影响 if await backup_dir.exists(): await aioshutil.rmtree(backup_dir, ignore_errors=True) logger.debug(f"{pid} 旧的备份目录已清理 {backup_dir}") # 异步复制目录 await self._async_copytree(plugin_dir, backup_dir) logger.debug(f"{pid} 插件已备份到 {backup_dir}") return str(backup_dir) if await backup_dir.exists() else None async def __async_restore_plugin(self, pid: str, backup_dir: str): """ 异步还原旧插件目录 :param pid: 插件 ID :param backup_dir: 备份目录路径 """ plugin_dir = AsyncPath(PLUGIN_DIR) / pid.lower() if await plugin_dir.exists(): await aioshutil.rmtree(plugin_dir, ignore_errors=True) logger.debug(f"{pid} 已清理插件目录 {plugin_dir}") backup_path = AsyncPath(backup_dir) if await backup_path.exists(): await self._async_copytree(src=backup_path, dst=plugin_dir) logger.debug(f"{pid} 已还原插件目录 {plugin_dir}") await aioshutil.rmtree(backup_path, ignore_errors=True) logger.debug(f"{pid} 已删除备份目录 {backup_dir}") @staticmethod async def __async_remove_old_plugin(pid: str): """ 异步删除旧插件 :param pid: 插件 ID """ plugin_dir = AsyncPath(PLUGIN_DIR) / pid.lower() if await plugin_dir.exists(): await aioshutil.rmtree(plugin_dir, ignore_errors=True) async def _async_copytree(self, src: AsyncPath, dst: AsyncPath): """ 异步递归复制目录 :param src: 源目录 :param dst: 目标目录 """ if not await src.exists(): return await dst.mkdir(parents=True, exist_ok=True) async for item in src.iterdir(): dst_item = dst / item.name if await item.is_dir(): await self._async_copytree(item, dst_item) else: async with aiofiles.open(item, 'rb') as src_file: content = await src_file.read() async with aiofiles.open(dst_item, 'wb') as dst_file: await dst_file.write(content) async def __async_install_dependencies_if_required(self, pid: str) -> Tuple[bool, bool, str]: """ 异步安装插件依赖。 :param pid: 插件 ID :return: (是否存在依赖,安装是否成功, 错误信息) """ # 定位插件目录和依赖文件 plugin_dir = AsyncPath(PLUGIN_DIR) / pid.lower() requirements_file = plugin_dir / "requirements.txt" # 检查是否存在 requirements.txt 文件 if await requirements_file.exists(): logger.info(f"{pid} 存在依赖,开始尝试安装依赖") success, error_message = self.pip_install_with_fallback(Path(requirements_file)) if success: return True, True, "" else: return True, False, error_message return False, False, "不存在依赖" async def async_install_dependencies(self, dependencies: List[str]) -> Tuple[bool, str]: """ 异步安装指定的依赖项列表 :param dependencies: 需要安装或更新的依赖项列表 :return: (success, message) """ if not dependencies: return False, "没有传入需要安装的依赖项" try: logger.debug(f"需要安装或更新的依赖项:{dependencies}") # 创建临时的 requirements.txt 文件用于批量安装 requirements_temp_file = AsyncPath(settings.TEMP_PATH) / "plugin_dependencies" / "requirements.txt" await requirements_temp_file.parent.mkdir(parents=True, exist_ok=True) async with aiofiles.open(requirements_temp_file, "w", encoding="utf-8") as f: for dep in dependencies: await f.write(dep + "\n") try: # 使用自动降级策略安装依赖 return self.pip_install_with_fallback(Path(requirements_temp_file)) finally: # 删除临时文件 await requirements_temp_file.unlink() except Exception as e: logger.error(f"安装依赖项时发生错误:{e}") return False, f"安装依赖项时发生错误:{e}" async def __async_find_plugin_dependencies(self) -> Dict[str, str]: """ 异步收集所有插件的依赖项 遍历 plugins 目录下的所有插件,查找存在 requirements.txt 的插件目录 ,并解析其中的依赖项,同时将所有插件的依赖项合并到字典中,方便后续统一处理 :return: 依赖项字典,格式为 {package_name: set(version_specifiers)} """ dependencies = {} try: install_plugins = { plugin_id.lower() # 对应插件的小写目录名 for plugin_id in SystemConfigOper().get( SystemConfigKey.UserInstalledPlugins ) or [] } plugin_dir_path = AsyncPath(PLUGIN_DIR) async for plugin_dir in plugin_dir_path.iterdir(): if await plugin_dir.is_dir(): requirements_file = plugin_dir / "requirements.txt" if await requirements_file.exists(): if plugin_dir.name not in install_plugins: # 这个插件不在安装列表中 忽略它的依赖 logger.debug(f"忽略插件 {plugin_dir.name} 的依赖") continue # 解析当前插件的 requirements.txt,获取依赖项 plugin_deps = await self.__async_parse_requirements(requirements_file) for pkg_name, version_specifiers in plugin_deps.items(): if pkg_name in dependencies: # 更新已存在的包的版本约束集合 dependencies[pkg_name].update(version_specifiers) else: # 添加新的包及其版本约束 dependencies[pkg_name] = set(version_specifiers) return self.__merge_dependencies(dependencies) except Exception as e: logger.error(f"收集插件依赖项时发生错误:{e}") return {} async def __async_parse_requirements(self, requirements_file: AsyncPath) -> Dict[str, List[str]]: """ 异步解析 requirements.txt 文件,返回依赖项字典 使用 packaging 库解析每一行依赖项,提取包名和版本约束 对于无法解析的行,记录警告日志,便于后续检查 :param requirements_file: requirements.txt 文件的路径 :return: 依赖项字典,格式为 {package_name: [version_specifier]} """ dependencies = {} try: async with aiofiles.open(requirements_file, "r", encoding="utf-8") as f: async for line in f: line = str(line).strip() if line and not line.startswith('#'): # 使用 packaging 库解析依赖项 try: req = Requirement(line) pkg_name = self.__standardize_pkg_name(req.name) version_specifier = str(req.specifier) if pkg_name in dependencies: dependencies[pkg_name].append(version_specifier) else: dependencies[pkg_name] = [version_specifier] except Exception as e: logger.debug(f"无法解析依赖项 '{line}':{e}") return dependencies except Exception as e: logger.error(f"解析 requirements.txt 时发生错误:{e}") return {} async def async_find_missing_dependencies(self) -> List[str]: """ 异步收集所有需要安装或更新的依赖项 1. 收集所有插件的依赖项,合并版本约束 2. 获取已安装的包及其版本 3. 比较已安装的包与所需的依赖项,找出需要安装或升级的包 :return: 需要安装或更新的依赖项列表,例如 ["package1>=1.0.0", "package2"] """ try: # 收集所有插件的依赖项 plugin_dependencies = await self.__async_find_plugin_dependencies() # 返回格式为 {package_name: version_specifier} # 获取已安装的包及其版本 installed_packages = self.__get_installed_packages() # 返回格式为 {package_name: Version} # 需要安装或更新的依赖项列表 dependencies_to_install = [] for pkg_name, version_specifier in plugin_dependencies.items(): spec_set = SpecifierSet(version_specifier) installed_version = installed_packages.get(pkg_name) if installed_version is None: # 包未安装,需要安装 if version_specifier: dependencies_to_install.append(f"{pkg_name}{version_specifier}") else: dependencies_to_install.append(pkg_name) elif not spec_set.contains(installed_version, prereleases=True): # 已安装的版本不满足版本约束,需要升级或降级 if version_specifier: dependencies_to_install.append(f"{pkg_name}{version_specifier}") else: dependencies_to_install.append(pkg_name) # 已安装的版本满足要求,无需操作 return dependencies_to_install except Exception as e: logger.error(f"收集所有需要安装或更新的依赖项时发生错误:{e}") return [] async def async_install(self, pid: str, repo_url: str, package_version: Optional[str] = None, force_install: bool = False) -> Tuple[bool, str]: """ 异步安装插件,包括依赖安装和文件下载,相关资源支持自动降级策略 1. 检查并获取插件的指定版本,确认版本兼容性 2. 从 GitHub 获取文件列表(包括 requirements.txt) 3. 删除旧的插件目录(如非强制安装则进行备份) 4. 下载并预安装 requirements.txt 中的依赖(如果存在) 5. 下载并安装插件的其他文件 6. 再次尝试安装依赖(确保安装完整) :param pid: 插件 ID :param repo_url: 插件仓库地址 :param package_version: 首选插件版本 (如 "v2", "v3"),如不指定则默认使用系统配置的版本 :param force_install: 是否强制安装插件,默认不启用,启用时不进行备份和恢复操作 :return: (是否成功, 错误信息) """ if SystemUtils.is_frozen(): return False, "可执行文件模式下,只能安装本地插件" # 验证参数 if not pid or not repo_url: return False, "参数错误" # 从 GitHub 的 repo_url 获取用户和项目名 user, repo = self.get_repo_info(repo_url) if not user or not repo: return False, "不支持的插件仓库地址格式" user_repo = f"{user}/{repo}" if not package_version: package_version = settings.VERSION_FLAG # 1. 优先检查指定版本的插件 package_version = await self.async_get_plugin_package_version(pid, repo_url, package_version) # 如果 package_version 为None,说明没有找到匹配的插件 if package_version is None: msg = f"{pid} 没有找到适用于当前版本的插件" logger.debug(msg) return False, msg # package_version 为空,表示从 package.json 中找到插件 elif package_version == "": logger.debug(f"{pid} 从 package.json 中找到适用于当前版本的插件") else: logger.debug(f"{pid} 从 package.{package_version}.json 中找到适用于当前版本的插件") # 2. 统一异步安装流程(release 或 文件列表) meta = await self.__async_get_plugin_meta(pid, repo_url, package_version) # 是否release打包 is_release = meta.get("release") # 插件版本号 plugin_version = meta.get("version") if is_release: # 使用 插件ID_插件版本号 作为 Release tag if not plugin_version: return False, f"未在插件清单中找到 {pid} 的版本号,无法进行 Release 安装" # 拼接 release_tag release_tag = f"{pid}_v{plugin_version}" # 使用 release 进行安装 async def prepare_release() -> Tuple[bool, str]: return await self.__async_install_from_release( pid, user_repo, release_tag ) return await self.__install_flow_async(pid, force_install, prepare_release, repo_url) else: # 如果没有 release_tag,则使用文件列表安装方式 async def prepare_filelist() -> Tuple[bool, str]: return await self.__prepare_content_via_filelist_async(pid, user_repo, package_version) return await self.__install_flow_async(pid, force_install, prepare_filelist, repo_url) async def __async_get_plugin_meta(self, pid: str, repo_url: str, package_version: Optional[str]) -> dict: try: plugins = ( await self.async_get_plugins(repo_url) if not package_version else await self.async_get_plugins(repo_url, package_version) ) or {} meta = plugins.get(pid) return meta if isinstance(meta, dict) else {} except Exception as e: logger.warn(f"获取插件 {pid} 元数据失败:{e}") return {} async def __install_flow_async(self, pid: str, force_install: bool, prepare_content: Callable[[], Awaitable[Tuple[bool, str]]], repo_url: Optional[str] = None) -> Tuple[bool, str]: """ 异步安装流程,处理插件内容准备、依赖安装和注册 """ backup_dir = None if not force_install: backup_dir = await self.__async_backup_plugin(pid) await self.__async_remove_old_plugin(pid) success, message = await prepare_content() if not success: logger.error(f"{pid} 准备插件内容失败:{message}") if backup_dir: await self.__async_restore_plugin(pid, backup_dir) logger.warning(f"{pid} 插件安装失败,已还原备份插件") else: await self.__async_remove_old_plugin(pid) logger.warning(f"{pid} 已清理对应插件目录,请尝试重新安装") return False, message dependencies_exist, dep_ok, dep_msg = await self.__async_install_dependencies_if_required(pid) if dependencies_exist and not dep_ok: logger.error(f"{pid} 依赖安装失败:{dep_msg}") if backup_dir: await self.__async_restore_plugin(pid, backup_dir) logger.warning(f"{pid} 插件安装失败,已还原备份插件") else: await self.__async_remove_old_plugin(pid) logger.warning(f"{pid} 已清理对应插件目录,请尝试重新安装") return False, dep_msg await self.async_install_reg(pid, repo_url) return True, "" def __prepare_content_via_filelist_sync(self, pid: str, user_repo: str, package_version: Optional[str]) -> Tuple[bool, str]: """ 同步准备插件内容,通过文件列表获取插件文件和依赖 """ file_list, msg = self.__get_file_list(pid, user_repo, package_version) if not file_list: return False, msg requirements_file_info = next((f for f in file_list if f.get("name") == "requirements.txt"), None) if requirements_file_info: ok, m = self.__download_and_install_requirements(requirements_file_info, pid, user_repo) if not ok: logger.debug(f"{pid} 依赖预安装失败:{m}") else: logger.debug(f"{pid} 依赖预安装成功") ok, m = self.__download_files(pid, file_list, user_repo, package_version, True) if not ok: return False, m return True, "" async def __prepare_content_via_filelist_async(self, pid: str, user_repo: str, package_version: Optional[str]) -> Tuple[bool, str]: """ 异步准备插件内容,通过文件列表获取插件文件和依赖 """ file_list, msg = await self.__async_get_file_list(pid, user_repo, package_version) if not file_list: return False, msg requirements_file_info = next((f for f in file_list if f.get("name") == "requirements.txt"), None) if requirements_file_info: ok, m = await self.__async_download_and_install_requirements(requirements_file_info, pid, user_repo) if not ok: logger.debug(f"{pid} 依赖预安装失败:{m}") else: logger.debug(f"{pid} 依赖预安装成功") ok, m = await self.__async_download_files(pid, file_list, user_repo, package_version, True) if not ok: return False, m return True, "" async def __async_install_from_release(self, pid: str, user_repo: str, release_tag: str) -> Tuple[bool, str]: """ 通过 GitHub Release 资产文件安装插件(异步)。 规范:release 中存在名为 "{pid}_v{version}.zip" 的资产,zip 根即插件文件; 将其全部解压到 app/plugins/{pid} """ # 拼接资产文件名 asset_name = f"{release_tag.lower()}.zip" release_api = f"https://api.github.com/repos/{user_repo}/releases/tags/{release_tag}" rel_res = await self.__async_request_with_fallback( release_api, headers=settings.REPO_GITHUB_HEADERS(repo=user_repo), timeout=30, is_api=True, ) if rel_res is None or rel_res.status_code != 200: return False, f"获取 Release 信息失败:{rel_res.status_code if rel_res else '连接失败'}" try: rel_json = rel_res.json() assets = rel_json.get("assets") or [] asset = next((a for a in assets if a.get("name") == asset_name), None) if not asset: return False, f"未找到资产文件:{asset_name}" asset_id = asset.get("id") if not asset_id: return False, "资产缺少ID信息" # 构建资产的API下载URL download_url = f"https://api.github.com/repos/{user_repo}/releases/assets/{asset_id}" except Exception as e: logger.error(f"解析 Release 信息失败:{e}") return False, f"解析 Release 信息失败:{e}" # 使用资产的API端点下载,需要设置Accept头为application/octet-stream headers = settings.REPO_GITHUB_HEADERS(repo=user_repo).copy() headers["Accept"] = "application/octet-stream" res = await self.__async_request_with_fallback(download_url, headers=headers, is_api=True) if res is None or res.status_code != 200: return False, f"下载资产失败:{res.status_code if res else '连接失败'}" try: with zipfile.ZipFile(io.BytesIO(res.content)) as zf: namelist = zf.namelist() if not namelist: return False, "压缩包内容为空" names_with_slash = [n for n in namelist if '/' in n] base_prefix = '' if names_with_slash and len(names_with_slash) == len(namelist): first_seg = names_with_slash[0].split('/')[0] if all(n.startswith(first_seg + '/') for n in namelist): base_prefix = first_seg + '/' dest_base = AsyncPath(settings.ROOT_PATH) / "app" / "plugins" / pid.lower() wrote_any = False for name in namelist: rel_path = name[len(base_prefix):] if not rel_path: continue if rel_path.endswith('/'): await (dest_base / rel_path.rstrip('/')).mkdir(parents=True, exist_ok=True) continue dest_path = dest_base / rel_path await dest_path.parent.mkdir(parents=True, exist_ok=True) with zf.open(name, 'r') as src: data = src.read() async with aiofiles.open(dest_path, 'wb') as dst: await dst.write(data) wrote_any = True if not wrote_any: return False, "压缩包中无可写入文件" return True, "" except Exception as e: logger.error(f"解压 Release 压缩包失败:{e}") return False, f"解压 Release 压缩包失败:{e}" ================================================ FILE: app/helper/progress.py ================================================ from enum import Enum from typing import Union, Optional from app.core.cache import TTLCache from app.schemas.types import ProgressKey class ProgressHelper: """ 处理进度辅助类 """ def __init__(self, key: Union[ProgressKey, str]): if isinstance(key, Enum): key = key.value self._key = key self._progress = TTLCache(region="progress", maxsize=1024, ttl=24 * 60 * 60) def __reset(self): """ 重置进度 """ self._progress[self._key] = { "enable": False, "value": 0, "text": "请稍候...", "data": {} } def start(self): """ 开始进度 """ self.__reset() current = self._progress.get(self._key) if not current: return current['enable'] = True self._progress[self._key] = current def end(self): """ 结束进度 """ current = self._progress.get(self._key) if not current: return current.update( { "enable": False, "value": 100, "text": "" } ) self._progress[self._key] = current def update(self, value: Union[float, int] = None, text: Optional[str] = None, data: dict = None): """ 更新进度 """ current = self._progress.get(self._key) if not current or not current.get('enable'): return if value: current['value'] = value if text: current['text'] = text if data: if not current.get('data'): current['data'] = {} current['data'].update(data) self._progress[self._key] = current def get(self) -> dict: return self._progress.get(self._key) ================================================ FILE: app/helper/redis.py ================================================ import json import pickle from typing import Any, Optional, Generator, Tuple, AsyncGenerator, Union from urllib.parse import quote import redis from redis.asyncio import Redis from app.core.config import settings from app.log import logger from app.utils.mixins import ConfigReloadMixin from app.utils.singleton import Singleton # 类型缓存集合,针对非容器简单类型 _complex_serializable_types = set() _simple_serializable_types = set() # 默认连接参数 _socket_timeout = 30 _socket_connect_timeout = 5 _health_check_interval = 60 def serialize(value: Any) -> bytes: """ 将值序列化为二进制数据,根据序列化方式标识格式 """ def _is_container_type(t): """ 判断是否为容器类型 """ return t in (list, dict, tuple, set) vt = type(value) # 针对非容器类型使用缓存策略 if not _is_container_type(vt): # 如果已知需要复杂序列化 if vt in _complex_serializable_types: return b"PICKLE" + b"\x00" + pickle.dumps(value) # 如果已知可以简单序列化 if vt in _simple_serializable_types: json_data = json.dumps(value).encode("utf-8") return b"JSON" + b"\x00" + json_data # 对于未知的非容器类型,尝试简单序列化,如抛出异常,再使用复杂序列化 try: json_data = json.dumps(value).encode("utf-8") _simple_serializable_types.add(vt) return b"JSON" + b"\x00" + json_data except TypeError: _complex_serializable_types.add(vt) return b"PICKLE" + b"\x00" + pickle.dumps(value) else: # 针对容器类型,每次尝试简单序列化,不使用缓存 try: json_data = json.dumps(value).encode("utf-8") return b"JSON" + b"\x00" + json_data except TypeError: return b"PICKLE" + b"\x00" + pickle.dumps(value) def deserialize(value: bytes) -> Any: """ 将二进制数据反序列化为原始值,根据格式标识区分序列化方式 """ format_marker, data = value.split(b"\x00", 1) if format_marker == b"JSON": return json.loads(data.decode("utf-8")) elif format_marker == b"PICKLE": return pickle.loads(data) else: raise ValueError("Unknown serialization format") class RedisHelper(ConfigReloadMixin, metaclass=Singleton): """ Redis连接和操作助手类,单例模式 特性: - 管理Redis连接池和客户端 - 提供序列化和反序列化功能 - 支持内存限制和淘汰策略设置 - 提供键名生成和区域管理功能 """ CONFIG_WATCH = {"CACHE_BACKEND_TYPE", "CACHE_BACKEND_URL", "CACHE_REDIS_MAXMEMORY"} def __init__(self): """ 初始化Redis助手实例 """ self.redis_url = settings.CACHE_BACKEND_URL self.client = None def _connect(self): """ 建立Redis连接 """ try: if self.client is None: self.client = redis.Redis.from_url( self.redis_url, decode_responses=False, socket_timeout=_socket_timeout, socket_connect_timeout=_socket_connect_timeout, health_check_interval=_health_check_interval, ) # 测试连接,确保Redis可用 self.client.ping() logger.info(f"Successfully connected to Redis:{self.redis_url}") self.set_memory_limit() except Exception as e: logger.error(f"Failed to connect to Redis: {e}") self.client = None raise RuntimeError("Redis connection failed") from e def on_config_changed(self): self.close() self._connect() def get_reload_name(self): return "Redis" def set_memory_limit(self, policy: Optional[str] = "allkeys-lru"): """ 动态设置Redis最大内存和内存淘汰策略 :param policy: 淘汰策略(如'allkeys-lru') """ try: # 如果有显式值,则直接使用,为0时说明不限制,如果未配置,开启BIG_MEMORY_MODE时为"1024mb",未开启时为"256mb" maxmemory = settings.CACHE_REDIS_MAXMEMORY or ("1024mb" if settings.BIG_MEMORY_MODE else "256mb") self.client.config_set("maxmemory", maxmemory) self.client.config_set("maxmemory-policy", policy) logger.debug(f"Redis maxmemory set to {maxmemory}, policy: {policy}") except Exception as e: logger.error(f"Failed to set Redis maxmemory or policy: {e}") @staticmethod def __get_region(region: Optional[str] = None): """ 获取缓存的区 """ return f"region:{quote(region)}" if region else "region:DEFAULT" def __make_redis_key(self, region: str, key: str) -> str: """ 获取缓存Key """ # 使用region作为缓存键的一部分 region = self.__get_region(region) return f"{region}:key:{quote(key)}" @staticmethod def __get_original_key(redis_key: Union[str, bytes]) -> str: """ 从Redis键中提取原始key """ try: if isinstance(redis_key, bytes): redis_key = redis_key.decode("utf-8") parts = redis_key.split(":key:") return parts[-1] except Exception as e: logger.warn(f"Failed to parse redis key: {redis_key}, error: {e}") return redis_key def set(self, key: str, value: Any, ttl: Optional[int] = None, region: Optional[str] = "DEFAULT", **kwargs) -> None: """ 设置缓存 :param key: 缓存的键 :param value: 缓存的值 :param ttl: 缓存的存活时间,单位秒 :param region: 缓存的区 :param kwargs: 其他参数 """ try: self._connect() redis_key = self.__make_redis_key(region, key) # 对值进行序列化 serialized_value = serialize(value) kwargs.pop("maxsize", None) self.client.set(redis_key, serialized_value, ex=ttl, **kwargs) except Exception as e: logger.error(f"Failed to set key: {key} in region: {region}, error: {e}") def exists(self, key: str, region: Optional[str] = "DEFAULT") -> bool: """ 判断缓存键是否存在 :param key: 缓存的键 :param region: 缓存的区 :return: 存在返回True,否则返回False """ try: self._connect() redis_key = self.__make_redis_key(region, key) return self.client.exists(redis_key) == 1 except Exception as e: logger.error(f"Failed to exists key: {key} region: {region}, error: {e}") return False def get(self, key: str, region: Optional[str] = "DEFAULT") -> Optional[Any]: """ 获取缓存的值 :param key: 缓存的键 :param region: 缓存的区 :return: 返回缓存的值,如果缓存不存在返回None """ try: self._connect() redis_key = self.__make_redis_key(region, key) value = self.client.get(redis_key) if value is not None: return deserialize(value) return None except Exception as e: logger.error(f"Failed to get key: {key} in region: {region}, error: {e}") return None def delete(self, key: str, region: Optional[str] = "DEFAULT") -> None: """ 删除缓存 :param key: 缓存的键 :param region: 缓存的区 """ try: self._connect() redis_key = self.__make_redis_key(region, key) self.client.delete(redis_key) except Exception as e: logger.error(f"Failed to delete key: {key} in region: {region}, error: {e}") def clear(self, region: Optional[str] = None) -> None: """ 清除指定区域的缓存或全部缓存 :param region: 缓存的区 """ try: self._connect() if region: cache_region = self.__get_region(region) redis_key = f"{cache_region}:key:*" with self.client.pipeline() as pipe: for key in self.client.scan_iter(redis_key): pipe.delete(key) pipe.execute() logger.debug(f"Cleared Redis cache for region: {region}") else: self.client.flushdb() logger.info("All Redis cache Cleared!") except Exception as e: logger.error(f"Failed to clear cache, region: {region}, error: {e}") def items(self, region: Optional[str] = None) -> Generator[Tuple[str, Any], None, None]: """ 获取指定区域的所有缓存键值对 :param region: 缓存的区 :return: 返回键值对生成器 """ try: self._connect() if region: cache_region = self.__get_region(region) redis_key = f"{cache_region}:key:*" for key in self.client.scan_iter(redis_key): value = self.client.get(key) if value is not None: yield self.__get_original_key(key), deserialize(value) else: for key in self.client.scan_iter("*"): value = self.client.get(key) if value is not None: yield self.__get_original_key(key), deserialize(value) except Exception as e: logger.error(f"Failed to get items from Redis, region: {region}, error: {e}") def test(self) -> bool: """ 测试Redis连接性 """ try: self._connect() return True except Exception as e: logger.error(f"Redis connection test failed: {e}") return False def close(self) -> None: """ 关闭Redis客户端的连接池 """ if self.client: self.client.close() self.client = None logger.debug("Redis connection closed") class AsyncRedisHelper(ConfigReloadMixin, metaclass=Singleton): """ 异步Redis连接和操作助手类,单例模式 特性: - 管理异步Redis连接池和客户端 - 提供序列化和反序列化功能 - 支持内存限制和淘汰策略设置 - 提供键名生成和区域管理功能 - 所有操作都是异步的 """ CONFIG_WATCH = {"CACHE_BACKEND_TYPE", "CACHE_BACKEND_URL", "CACHE_REDIS_MAXMEMORY"} def __init__(self): """ 初始化异步Redis助手实例 """ self.redis_url = settings.CACHE_BACKEND_URL self.client: Optional[Redis] = None async def _connect(self): """ 建立异步Redis连接 """ try: if self.client is None: self.client = Redis.from_url( self.redis_url, decode_responses=False, socket_timeout=_socket_timeout, socket_connect_timeout=_socket_connect_timeout, health_check_interval=_health_check_interval, ) # 测试连接,确保Redis可用 await self.client.ping() logger.info(f"Successfully connected to Redis (async):{self.redis_url}") await self.set_memory_limit() except Exception as e: logger.error(f"Failed to connect to Redis (async): {e}") self.client = None raise RuntimeError("Redis async connection failed") from e async def on_config_changed(self): await self.close() await self._connect() def get_reload_name(self): return "Redis (async)" async def set_memory_limit(self, policy: Optional[str] = "allkeys-lru"): """ 动态设置Redis最大内存和内存淘汰策略 :param policy: 淘汰策略(如'allkeys-lru') """ try: # 如果有显式值,则直接使用,为0时说明不限制,如果未配置,开启BIG_MEMORY_MODE时为"1024mb",未开启时为"256mb" maxmemory = settings.CACHE_REDIS_MAXMEMORY or ("1024mb" if settings.BIG_MEMORY_MODE else "256mb") await self.client.config_set("maxmemory", maxmemory) await self.client.config_set("maxmemory-policy", policy) logger.debug(f"Redis maxmemory set to {maxmemory}, policy: {policy} (async)") except Exception as e: logger.error(f"Failed to set Redis maxmemory or policy (async): {e}") @staticmethod def __get_region(region: Optional[str] = "DEFAULT"): """ 获取缓存的区 """ return f"region:{region}" if region else "region:default" def __make_redis_key(self, region: str, key: str) -> str: """ 获取缓存Key """ # 使用region作为缓存键的一部分 region = self.__get_region(region) return f"{region}:key:{quote(key)}" @staticmethod def __get_original_key(redis_key: Union[str, bytes]) -> str: """ 从Redis键中提取原始key """ try: if isinstance(redis_key, bytes): redis_key = redis_key.decode("utf-8") parts = redis_key.split(":key:") return parts[-1] except Exception as e: logger.warn(f"Failed to parse redis key: {redis_key}, error: {e}") return redis_key async def set(self, key: str, value: Any, ttl: Optional[int] = None, region: Optional[str] = "DEFAULT", **kwargs) -> None: """ 异步设置缓存 :param key: 缓存的键 :param value: 缓存的值 :param ttl: 缓存的存活时间,单位秒 :param region: 缓存的区 :param kwargs: 其他参数 """ try: await self._connect() redis_key = self.__make_redis_key(region, key) # 对值进行序列化 serialized_value = serialize(value) kwargs.pop("maxsize", None) await self.client.set(redis_key, serialized_value, ex=ttl, **kwargs) except Exception as e: logger.error(f"Failed to set key (async): {key} in region: {region}, error: {e}") async def exists(self, key: str, region: Optional[str] = "DEFAULT") -> bool: """ 异步判断缓存键是否存在 :param key: 缓存的键 :param region: 缓存的区 :return: 存在返回True,否则返回False """ try: await self._connect() redis_key = self.__make_redis_key(region, key) result = await self.client.exists(redis_key) return result == 1 except Exception as e: logger.error(f"Failed to exists key (async): {key} region: {region}, error: {e}") return False async def get(self, key: str, region: Optional[str] = "DEFAULT") -> Optional[Any]: """ 异步获取缓存的值 :param key: 缓存的键 :param region: 缓存的区 :return: 返回缓存的值,如果缓存不存在返回None """ try: await self._connect() redis_key = self.__make_redis_key(region, key) value = await self.client.get(redis_key) if value is not None: return deserialize(value) return None except Exception as e: logger.error(f"Failed to get key (async): {key} in region: {region}, error: {e}") return None async def delete(self, key: str, region: Optional[str] = "DEFAULT") -> None: """ 异步删除缓存 :param key: 缓存的键 :param region: 缓存的区 """ try: await self._connect() redis_key = self.__make_redis_key(region, key) await self.client.delete(redis_key) except Exception as e: logger.error(f"Failed to delete key (async): {key} in region: {region}, error: {e}") async def clear(self, region: Optional[str] = None) -> None: """ 异步清除指定区域的缓存或全部缓存 :param region: 缓存的区 """ try: await self._connect() if region: cache_region = self.__get_region(region) redis_key = f"{cache_region}:key:*" async with self.client.pipeline() as pipe: async for key in self.client.scan_iter(redis_key): await pipe.delete(key) await pipe.execute() logger.debug(f"Cleared Redis cache for region (async): {region}") else: await self.client.flushdb() logger.info("Cleared all Redis cache (async)") except Exception as e: logger.error(f"Failed to clear cache (async), region: {region}, error: {e}") async def items(self, region: Optional[str] = None) -> AsyncGenerator[Tuple[str, Any], None]: """ 获取指定区域的所有缓存键值对 :param region: 缓存的区 :return: 返回键值对生成器 """ try: await self._connect() if region: cache_region = self.__get_region(region) redis_key = f"{cache_region}:key:*" async for key in self.client.scan_iter(redis_key): value = await self.client.get(key) if value is not None: yield self.__get_original_key(key), deserialize(value) else: async for key in self.client.scan_iter("*"): value = await self.client.get(key) if value is not None: yield self.__get_original_key(key), deserialize(value) except Exception as e: logger.error(f"Failed to get items from Redis, region: {region}, error: {e}") async def test(self) -> bool: """ 异步测试Redis连接性 """ try: await self._connect() return True except Exception as e: logger.error(f"Redis async connection test failed: {e}") return False async def close(self) -> None: """ 关闭异步Redis客户端的连接池 """ if self.client: await self.client.close() self.client = None logger.debug("Redis async connection closed") ================================================ FILE: app/helper/resource.py ================================================ import json from pathlib import Path from app.core.config import settings from app.helper.sites import SitesHelper # noqa from app.helper.system import SystemHelper from app.log import logger from app.utils.http import RequestUtils from app.utils.string import StringUtils from app.utils.system import SystemUtils class ResourceHelper: """ 检测和更新资源包 """ # 资源包的git仓库地址 _repo = f"{settings.GITHUB_PROXY}https://raw.githubusercontent.com/jxxghp/MoviePilot-Resources/main/package.v2.json" _files_api = f"https://api.github.com/repos/jxxghp/MoviePilot-Resources/contents/resources.v2" _base_dir: Path = settings.ROOT_PATH def __init__(self): self.check() @property def proxies(self): return None if settings.GITHUB_PROXY else settings.PROXY def check(self): """ 检测是否有更新,如有则下载安装 """ if not settings.AUTO_UPDATE_RESOURCE: return None if SystemUtils.is_frozen(): return None logger.info("开始检测资源包版本...") res = RequestUtils(proxies=self.proxies, headers=settings.GITHUB_HEADERS, timeout=10).get_res(self._repo) if res: try: resource_info = json.loads(res.text) online_version = resource_info.get("version") if online_version: logger.info(f"最新资源包版本:v{online_version}") # 需要更新的资源包 need_updates = {} # 资源明细 resources: dict = resource_info.get("resources") or {} for rname, resource in resources.items(): rtype = resource.get("type") platform = resource.get("platform") target = resource.get("target") version = resource.get("version") # 判断平台 if platform and platform != SystemUtils.platform(): continue # 判断版本号 if rtype == "auth": # 站点认证资源 local_version = SitesHelper().auth_version elif rtype == "sites": # 站点索引资源 local_version = SitesHelper().indexer_version else: continue if StringUtils.compare_version(version, ">", local_version): logger.info(f"{rname} 资源包有更新,最新版本:v{version}") else: continue # 需要安装 need_updates[rname] = target if need_updates: # 下载文件信息列表 r = RequestUtils(proxies=settings.PROXY, headers=settings.GITHUB_HEADERS, timeout=30).get_res(self._files_api) if r and not r.ok: return None, f"连接仓库失败:{r.status_code} - {r.reason}" elif not r: return None, "连接仓库失败" files_info = r.json() # 下载资源文件 success = True for item in files_info: save_path = need_updates.get(item.get("name")) if not save_path: continue if item.get("download_url"): logger.info(f"开始更新资源文件:{item.get('name')} ...") download_url = f"{settings.GITHUB_PROXY}{item.get('download_url')}" # 下载资源文件 res = RequestUtils(proxies=self.proxies, headers=settings.GITHUB_HEADERS, timeout=180).get_res(download_url) if not res: logger.error(f"文件 {item.get('name')} 下载失败!") success = False break elif res.status_code != 200: logger.error(f"下载文件 {item.get('name')} 失败:{res.status_code} - {res.reason}") success = False break # 创建插件文件夹 file_path = self._base_dir / save_path / item.get("name") if not file_path.parent.exists(): file_path.parent.mkdir(parents=True, exist_ok=True) # 写入文件 file_path.write_bytes(res.content) if success: logger.info("资源包更新完成,开始重启服务...") SystemHelper.restart() else: logger.warn("资源包更新失败,跳过升级!") else: logger.info("所有资源已最新,无需更新") except json.JSONDecodeError: logger.error("资源包仓库数据解析失败!") return None else: logger.warn("无法连接资源包仓库!") return None ================================================ FILE: app/helper/rss.py ================================================ import re import traceback from typing import List, Tuple, Union, Optional from urllib.parse import urljoin import chardet from lxml import etree from app.core.config import settings from app.helper.browser import PlaywrightHelper from app.log import logger from app.utils.http import RequestUtils from app.utils.string import StringUtils class RssHelper: """ RSS帮助类,解析RSS报文、获取RSS地址等 """ # RSS解析限制配置 MAX_RSS_SIZE = 50 * 1024 * 1024 # 50MB最大RSS文件大小 MAX_RSS_ITEMS = 1000 # 最大解析条目数 # 各站点RSS链接获取配置 rss_link_conf = { "default": { "xpath": "//a[@class='faqlink']/@href", "url": "getrss.php", "params": { "inclbookmarked": 0, "itemsmalldescr": 1, "showrows": 50, "search_mode": 1, } }, "hares.top": { "xpath": "//*[@id='layui-layer100001']/div[2]/div/p[4]/a/@href", "url": "getrss.php", "params": { "inclbookmarked": 0, "itemsmalldescr": 1, "showrows": 50, "search_mode": 1, } }, "et8.org": { "xpath": "//*[@id='outer']/table/tbody/tr/td/table/tbody/tr/td/a[2]/@href", "url": "getrss.php", "params": { "inclbookmarked": 0, "itemsmalldescr": 1, "showrows": 50, "search_mode": 1, } }, "pttime.org": { "xpath": "//*[@id='outer']/table/tbody/tr/td/table/tbody/tr/td/text()[5]", "url": "getrss.php", "params": { "showrows": 10, "inclbookmarked": 0, "itemsmalldescr": 1 } }, "ourbits.club": { "xpath": "//a[@class='gen_rsslink']/@href", "url": "getrss.php", "params": { "inclbookmarked": 0, "itemsmalldescr": 1, "showrows": 50, "search_mode": 1, } }, "totheglory.im": { "xpath": "//textarea/text()", "url": "rsstools.php?c51=51&c52=52&c53=53&c54=54&c108=108&c109=109&c62=62&c63=63&c67=67&c69=69&c70=70&c73=73&c76=76&c75=75&c74=74&c87=87&c88=88&c99=99&c90=90&c58=58&c103=103&c101=101&c60=60", "params": { "inclbookmarked": 0, "itemsmalldescr": 1, "showrows": 50, "search_mode": 1, } }, "monikadesign.uk": { "xpath": "//a/@href", "url": "rss", "params": { "inclbookmarked": 0, "itemsmalldescr": 1, "showrows": 50, "search_mode": 1, } }, "zhuque.in": { "xpath": "//a/@href", "url": "user/rss", "render": True, "params": { "inclbookmarked": 0, "itemsmalldescr": 1, "showrows": 50, "search_mode": 1, } }, "hdchina.org": { "xpath": "//a[@class='faqlink']/@href", "url": "getrss.php", "params": { "inclbookmarked": 0, "itemsmalldescr": 1, "showrows": 50, "search_mode": 1, "rsscart": 0 } }, "audiences.me": { "xpath": "//a[@class='faqlink']/@href", "url": "getrss.php", "params": { "inclbookmarked": 0, "itemsmalldescr": 1, "showrows": 50, "search_mode": 1, "torrent_type": 1, "exp": 180 } }, "shadowflow.org": { "xpath": "//a[@class='faqlink']/@href", "url": "getrss.php", "params": { "inclbookmarked": 0, "itemsmalldescr": 1, "paid": 0, "search_mode": 0, "showrows": 30 } }, "hddolby.com": { "xpath": "//a[@class='faqlink']/@href", "url": "getrss.php", "params": { "inclbookmarked": 0, "itemsmalldescr": 1, "showrows": 50, "search_mode": 1, "exp": 180 } }, "hdhome.org": { "xpath": "//a[@class='faqlink']/@href", "url": "getrss.php", "params": { "inclbookmarked": 0, "itemsmalldescr": 1, "showrows": 50, "search_mode": 1, "exp": 180 } }, "pthome.net": { "xpath": "//a[@class='faqlink']/@href", "url": "getrss.php", "params": { "inclbookmarked": 0, "itemsmalldescr": 1, "showrows": 50, "search_mode": 1, "exp": 180 } }, "ptsbao.club": { "xpath": "//a[@class='faqlink']/@href", "url": "getrss.php", "params": { "inclbookmarked": 0, "itemsmalldescr": 1, "showrows": 50, "search_mode": 1, "size": 0 } }, "leaves.red": { "xpath": "//a[@class='faqlink']/@href", "url": "getrss.php", "params": { "inclbookmarked": 0, "itemsmalldescr": 1, "showrows": 50, "search_mode": 0, "paid": 2 } }, "hdtime.org": { "xpath": "//a[@class='faqlink']/@href", "url": "getrss.php", "params": { "inclbookmarked": 0, "itemsmalldescr": 1, "showrows": 50, "search_mode": 0, } }, "m-team.io": { "xpath": "//a[@class='faqlink']/@href", "url": "getrss.php", "params": { "showrows": 50, "inclbookmarked": 0, "itemsmalldescr": 1, "https": 1 } }, "u2.dmhy.org": { "xpath": "//a[@class='faqlink']/@href", "url": "getrss.php", "params": { "inclbookmarked": 0, "itemsmalldescr": 1, "showrows": 50, "search_mode": 1, "inclautochecked": 1, "trackerssl": 1 } }, } def parse(self, url, proxy: bool = False, timeout: Optional[int] = 15, headers: dict = None, ua: str = None) -> Union[List[dict], None, bool]: """ 解析RSS订阅URL,获取RSS中的种子信息 :param url: RSS地址 :param proxy: 是否使用代理 :param timeout: 请求超时 :param headers: 自定义请求头 :param ua: 自定义User-Agent :return: 种子信息列表,如为None代表Rss过期,如果为False则为错误 """ # 开始处理 ret_array: list = [] if not url: return False try: ret = RequestUtils(ua=ua, proxies=settings.PROXY if proxy else None, timeout=timeout or 30, headers=headers).get_res(url) if not ret: logger.error(f"获取RSS失败:请求返回空值,URL: {url}") return False except Exception as err: logger.error(f"获取RSS失败:{str(err)} - {traceback.format_exc()}") return False if ret: # 检查HTTP状态码 if ret.status_code != 200: logger.error(f"RSS请求失败,状态码: {ret.status_code}, URL: {url}") return False ret_xml = None root = None try: # 检查响应大小,避免处理过大的RSS文件 raw_data = ret.content if raw_data and len(raw_data) > self.MAX_RSS_SIZE: logger.warning(f"RSS文件过大: {len(raw_data) / 1024 / 1024:.1f}MB,跳过解析") return False if raw_data: try: result = chardet.detect(raw_data) encoding = result['encoding'] # 解码为字符串 ret_xml = raw_data.decode(encoding) except Exception as e: logger.debug(f"chardet解码失败:{str(e)}") # 探测utf-8解码 match = re.search(r'encoding\s*=\s*["\']([^"\']+)["\']', ret.text) if match: encoding = match.group(1) if encoding: ret_xml = raw_data.decode(encoding) else: ret.encoding = ret.apparent_encoding if not ret_xml: ret_xml = ret.text # 验证RSS内容是否有效 if not ret_xml or not ret_xml.strip(): logger.error("RSS内容为空") return False # 检查是否包含基本的RSS/XML结构 ret_xml_stripped = ret_xml.strip() if not ret_xml_stripped.startswith('<'): logger.error("RSS内容不是有效的XML格式") return False # 使用lxml.etree解析XML parser = None try: # 创建解析器,禁用网络访问以提高安全性和性能 parser = etree.XMLParser( recover=True, # 容错模式 strip_cdata=False, # 保留CDATA resolve_entities=False, # 禁用外部实体解析 no_network=True, # 禁用网络访问 huge_tree=False # 禁用大文档解析,避免内存问题 ) root = etree.fromstring(ret_xml.encode('utf-8'), parser=parser) except etree.XMLSyntaxError as xml_error: logger.debug(f"XML解析失败:{str(xml_error)},尝试HTML解析") # 如果XML解析失败,尝试作为HTML解析 try: root = etree.HTML(ret_xml) if root is not None: # 查找RSS根节点 rss_root = root.xpath('//rss | //feed') if rss_root: root = rss_root[0] except Exception as e: logger.error(f"HTML解析也失败:{str(e)}") return False except Exception as general_error: logger.error(f"解析RSS时发生未预期错误:{str(general_error)}") return False finally: if parser is not None: try: parser.close() except Exception as close_error: logger.debug(f"关闭解析器时出错:{str(close_error)}") del parser if root is None: logger.error("无法解析RSS内容") return False # 查找所有item或entry节点 items = root.xpath('.//item | .//entry') # 限制处理的条目数量 items_count = min(len(items), self.MAX_RSS_ITEMS) if len(items) > self.MAX_RSS_ITEMS: logger.warning(f"RSS条目过多: {len(items)},仅处理前{self.MAX_RSS_ITEMS}个") try: for item in items[:items_count]: try: # 使用xpath提取信息,更高效 title_nodes = item.xpath('.//title') title = title_nodes[0].text if title_nodes and title_nodes[0].text else "" if not title: continue # 描述 desc_nodes = item.xpath('.//description | .//summary') description = desc_nodes[0].text if desc_nodes and desc_nodes[0].text else "" # 种子页面 link_nodes = item.xpath('.//link') if link_nodes: link = link_nodes[0].text if hasattr(link_nodes[0], 'text') and link_nodes[0].text else link_nodes[0].get('href', '') else: link = "" # 种子链接 enclosure_nodes = item.xpath('.//enclosure') enclosure = enclosure_nodes[0].get('url', '') if enclosure_nodes else "" if not enclosure and not link: continue # 部分RSS只有link没有enclosure if not enclosure and link: enclosure = link # 大小 size = 0 if enclosure_nodes: size_attr = enclosure_nodes[0].get('length', '0') if size_attr and str(size_attr).isdigit(): size = int(size_attr) # 发布日期 pubdate_nodes = item.xpath('./pubDate | ./published | ./updated') if not pubdate_nodes: pubdate_nodes = item.xpath('.//*[local-name()="pubDate"] | .//*[local-name()="published"] | .//*[local-name()="updated"]') pubdate = "" if pubdate_nodes and pubdate_nodes[0].text: pubdate = StringUtils.get_time(pubdate_nodes[0].text) if pubdate is not None: # 转为本地时区 pubdate = pubdate.astimezone(tz=None) # 获取豆瓣昵称 nickname_nodes = item.xpath('.//*[local-name()="creator"]') nickname = nickname_nodes[0].text if nickname_nodes and nickname_nodes[0].text else "" # 返回对象 tmp_dict = { 'title': title, 'enclosure': enclosure, 'size': size, 'description': description, 'link': link, 'pubdate': pubdate } # 如果豆瓣昵称不为空,返回数据增加豆瓣昵称,供doubansync插件获取 if nickname: tmp_dict['nickname'] = nickname ret_array.append(tmp_dict) except Exception as e1: logger.debug(f"解析RSS条目失败:{str(e1)} - {traceback.format_exc()}") continue finally: items.clear() del items except Exception as e2: logger.error(f"解析RSS失败:{str(e2)} - {traceback.format_exc()}") # RSS过期检查 _rss_expired_msg = [ "RSS 链接已过期, 您需要获得一个新的!", "RSS Link has expired, You need to get a new one!", "RSS Link has expired, You need to get new!" ] if ret_xml in _rss_expired_msg: return None return False finally: if root is not None: del root if ret_xml is not None: del ret_xml return ret_array def get_rss_link(self, url: str, cookie: str, ua: str, proxy: bool = False, timeout: int = None) -> Tuple[str, str]: """ 获取站点rss地址 :param url: 站点地址 :param cookie: 站点cookie :param ua: 站点ua :param proxy: 是否使用代理 :param timeout: 请求超时时间 :return: rss地址、错误信息 """ try: # 获取站点域名 domain = StringUtils.get_url_domain(url) # 获取配置 site_conf = self.rss_link_conf.get(domain) or self.rss_link_conf.get("default") # RSS地址 rss_url = urljoin(url, site_conf.get("url")) # RSS请求参数 rss_params = site_conf.get("params") # 请求RSS页面 if site_conf.get("render"): html_text = PlaywrightHelper().get_page_source( url=rss_url, cookies=cookie, ua=ua, proxies=settings.PROXY_SERVER if proxy else None, timeout=timeout or 60 ) else: res = RequestUtils( cookies=cookie, timeout=timeout or 30, ua=ua, proxies=settings.PROXY if proxy else None ).post_res(url=rss_url, data=rss_params) if res: html_text = res.text elif res is not None: return "", f"获取 {url} RSS链接失败,错误码:{res.status_code},错误原因:{res.reason}" else: return "", f"获取RSS链接失败:无法连接 {url} " # 解析HTML if html_text: html = None try: html = etree.HTML(html_text) if StringUtils.is_valid_html_element(html): rss_link = html.xpath(site_conf.get("xpath")) if rss_link: return str(rss_link[-1]), "" finally: if html is not None: del html return "", f"获取RSS链接失败:{url}" except Exception as e: return "", f"获取 {url} RSS链接失败:{str(e)}" ================================================ FILE: app/helper/rule.py ================================================ from typing import List, Optional from app.core.context import MediaInfo from app.db.systemconfig_oper import SystemConfigOper from app.schemas import FilterRuleGroup, CustomRule from app.schemas.types import SystemConfigKey class RuleHelper: """ 规划帮助类 """ @staticmethod def get_rule_groups() -> List[FilterRuleGroup]: """ 获取用户所有规则组 """ rule_groups: List[dict] = SystemConfigOper().get(SystemConfigKey.UserFilterRuleGroups) if not rule_groups: return [] return [FilterRuleGroup(**group) for group in rule_groups] def get_rule_group(self, group_name: str) -> Optional[FilterRuleGroup]: """ 获取规则组 """ rule_groups = self.get_rule_groups() for group in rule_groups: if group.name == group_name: return group return None def get_rule_group_by_media(self, media: MediaInfo = None, group_names: list = None) -> List[FilterRuleGroup]: """ 根据媒体信息获取规则组 """ ret_groups = [] rule_groups = self.get_rule_groups() if group_names: rule_groups = [group for group in rule_groups if group.name in group_names] for group in rule_groups: if not group.media_type: ret_groups.append(group) elif media and not group.category and group.media_type == media.type.value: ret_groups.append(group) elif media and group.category == media.category: ret_groups.append(group) return ret_groups @staticmethod def get_custom_rules() -> List[CustomRule]: """ 获取用户所有自定义规则 """ rules: List[dict] = SystemConfigOper().get(SystemConfigKey.CustomFilterRules) if not rules: return [] return [CustomRule(**rule) for rule in rules] def get_custom_rule(self, rule_id: str) -> Optional[CustomRule]: """ 获取自定义规则 """ rules = self.get_custom_rules() for rule in rules: if rule.id == rule_id: return rule return None ================================================ FILE: app/helper/service.py ================================================ from typing import Dict, List, Optional, Type, TypeVar, Generic, Iterator from app.core.module import ModuleManager from app.db.systemconfig_oper import SystemConfigOper from app.schemas import DownloaderConf, MediaServerConf, NotificationConf, NotificationSwitchConf, ServiceInfo from app.schemas.types import NotificationType, SystemConfigKey, ModuleType TConf = TypeVar("TConf") class ServiceConfigHelper: """ 配置帮助类,获取不同类型的服务配置 """ @staticmethod def get_configs(config_key: SystemConfigKey, conf_type: Type) -> List: """ 通用获取配置的方法,根据 config_key 获取相应的配置并返回指定类型的配置列表 :param config_key: 系统配置的 key :param conf_type: 用于实例化配置对象的类类型 :return: 配置对象列表 """ config_data = SystemConfigOper().get(config_key) if not config_data: return [] # 直接使用 conf_type 来实例化配置对象 return [conf_type(**conf) for conf in config_data] @staticmethod def get_downloader_configs() -> List[DownloaderConf]: """ 获取下载器的配置 """ return ServiceConfigHelper.get_configs(SystemConfigKey.Downloaders, DownloaderConf) @staticmethod def get_mediaserver_configs() -> List[MediaServerConf]: """ 获取媒体服务器的配置 """ return ServiceConfigHelper.get_configs(SystemConfigKey.MediaServers, MediaServerConf) @staticmethod def get_notification_configs() -> List[NotificationConf]: """ 获取消息通知渠道的配置 """ return ServiceConfigHelper.get_configs(SystemConfigKey.Notifications, NotificationConf) @staticmethod def get_notification_switches() -> List[NotificationSwitchConf]: """ 获取消息通知场景的开关 """ return ServiceConfigHelper.get_configs(SystemConfigKey.NotificationSwitchs, NotificationSwitchConf) @staticmethod def get_notification_switch(mtype: NotificationType) -> Optional[str]: """ 获取指定类型的消息通知场景的开关 """ switchs = ServiceConfigHelper.get_notification_switches() for switch in switchs: if switch.type == mtype.value: return switch.action return None class ServiceBaseHelper(Generic[TConf]): """ 通用服务帮助类,抽象获取配置和服务实例的通用逻辑 """ def __init__(self, config_key: SystemConfigKey, conf_type: Type[TConf], module_type: ModuleType): self.modulemanager = ModuleManager() self.config_key = config_key self.conf_type = conf_type self.module_type = module_type def get_configs(self, include_disabled: bool = False) -> Dict[str, TConf]: """ 获取配置列表 :param include_disabled: 是否包含禁用的配置,默认 False(仅返回启用的配置) :return: 配置字典 """ configs: List[TConf] = ServiceConfigHelper.get_configs(self.config_key, self.conf_type) return { config.name: config for config in configs if (config.name and config.type and config.enabled) or include_disabled } if configs else {} def get_config(self, name: str) -> Optional[TConf]: """ 获取指定名称配置 """ if not name: return None configs = self.get_configs() return configs.get(name) def iterate_module_instances(self) -> Iterator[ServiceInfo]: """ 迭代所有模块的实例及其对应的配置,返回 ServiceInfo 实例 """ configs = self.get_configs() for module in self.modulemanager.get_running_type_modules(self.module_type): if not module: continue module_instances = module.get_instances() if not isinstance(module_instances, dict): continue for name, instance in module_instances.items(): if not instance: continue config = configs.get(name) service_info = ServiceInfo( name=name, instance=instance, module=module, type=config.type if config else None, config=config ) yield service_info def get_services(self, type_filter: Optional[str] = None, name_filters: Optional[List[str]] = None) \ -> Dict[str, ServiceInfo]: """ 获取服务信息列表,并根据类型和名称列表进行过滤 :param type_filter: 需要过滤的服务类型 :param name_filters: 需要过滤的服务名称列表 :return: 过滤后的服务信息字典 """ name_filters_set = set(name_filters) if name_filters else None return { service_info.name: service_info for service_info in self.iterate_module_instances() if service_info.config and ( type_filter is None or service_info.type == type_filter ) and ( name_filters_set is None or service_info.name in name_filters_set) } def get_service(self, name: str, type_filter: Optional[str] = None) -> Optional[ServiceInfo]: """ 获取指定名称的服务信息,并根据类型过滤 :param name: 服务名称 :param type_filter: 需要过滤的服务类型 :return: 对应的服务信息,若不存在或类型不匹配则返回 None """ if not name: return None for service_info in self.iterate_module_instances(): if service_info.name == name: if service_info.config and (type_filter is None or service_info.type == type_filter): return service_info return None ================================================ FILE: app/helper/storage.py ================================================ from typing import List, Optional from app import schemas from app.db.systemconfig_oper import SystemConfigOper from app.schemas.types import SystemConfigKey class StorageHelper: """ 存储帮助类 """ @staticmethod def get_storagies() -> List[schemas.StorageConf]: """ 获取所有存储设置 """ storage_confs: List[dict] = SystemConfigOper().get(SystemConfigKey.Storages) if not storage_confs: return [] return [schemas.StorageConf(**s) for s in storage_confs] def get_storage(self, storage: str) -> Optional[schemas.StorageConf]: """ 获取指定存储配置 """ storagies = self.get_storagies() for s in storagies: if s.type == storage: return s return None def set_storage(self, storage: str, conf: dict): """ 设置存储配置 """ storagies = self.get_storagies() if not storagies: storagies = [ schemas.StorageConf( type=storage, config=conf ) ] else: for s in storagies: if s.type == storage: s.config = conf break SystemConfigOper().set(SystemConfigKey.Storages, [s.model_dump() for s in storagies]) def add_storage(self, storage: str, name: str, conf: dict): """ 添加存储配置 """ storagies = self.get_storagies() if not storagies: storagies = [ schemas.StorageConf( type=storage, name=name, config=conf ) ] else: storagies.append(schemas.StorageConf( type=storage, name=name, config=conf )) SystemConfigOper().set(SystemConfigKey.Storages, [s.model_dump() for s in storagies]) def reset_storage(self, storage: str): """ 重置存储配置 """ storagies = self.get_storagies() for s in storagies: if s.type == storage: s.config = {} break SystemConfigOper().set(SystemConfigKey.Storages, [s.model_dump() for s in storagies]) ================================================ FILE: app/helper/subscribe.py ================================================ from threading import Thread from typing import List, Tuple, Optional from app.core.cache import cached from app.core.config import settings from app.db.subscribe_oper import SubscribeOper from app.db.systemconfig_oper import SystemConfigOper from app.log import logger from app.schemas.types import SystemConfigKey from app.utils.http import RequestUtils, AsyncRequestUtils from app.utils.singleton import WeakSingleton from app.utils.system import SystemUtils class SubscribeHelper(metaclass=WeakSingleton): """ 订阅数据统计/订阅分享等 """ _sub_reg = f"{settings.MP_SERVER_HOST}/subscribe/add" _sub_done = f"{settings.MP_SERVER_HOST}/subscribe/done" _sub_report = f"{settings.MP_SERVER_HOST}/subscribe/report" _sub_statistic = f"{settings.MP_SERVER_HOST}/subscribe/statistic" _sub_share = f"{settings.MP_SERVER_HOST}/subscribe/share" _sub_shares = f"{settings.MP_SERVER_HOST}/subscribe/shares" _sub_share_statistic = f"{settings.MP_SERVER_HOST}/subscribe/share/statistics" _sub_fork = f"{settings.MP_SERVER_HOST}/subscribe/fork/%s" _shares_cache_region = "subscribe_share" _github_user = None _share_user_id = None _admin_users = [ "jxxghp", "thsrite", "InfinityPacer", "DDSRem", "Aqr-K", "Putarku", "4Nest", "xyswordzoro", "wikrin" ] def __init__(self): systemconfig = SystemConfigOper() if settings.SUBSCRIBE_STATISTIC_SHARE: if not systemconfig.get(SystemConfigKey.SubscribeReport): if self.sub_report(): systemconfig.set(SystemConfigKey.SubscribeReport, "1") self.get_user_uuid() self.get_github_user() @staticmethod def _check_subscribe_share_enabled() -> Tuple[bool, str]: """ 检查订阅分享功能是否开启 """ if not settings.SUBSCRIBE_STATISTIC_SHARE: return False, "当前没有开启订阅数据共享功能" return True, "" @staticmethod def _validate_subscribe(subscribe) -> Tuple[bool, str]: """ 验证订阅是否存在 """ if not subscribe: return False, "订阅不存在" return True, "" @staticmethod def _prepare_subscribe_data(subscribe) -> dict: """ 准备订阅分享数据 """ subscribe_dict = subscribe.to_dict() subscribe_dict.pop("id", None) return subscribe_dict def _build_share_payload(self, share_title: str, share_comment: str, share_user: str, subscribe_dict: dict) -> dict: """ 构建分享请求载荷 """ return { "share_title": share_title, "share_comment": share_comment, "share_user": share_user, "share_uid": self._share_user_id, **subscribe_dict } def _handle_response(self, res, clear_cache: bool = True) -> Tuple[bool, str]: """ 处理HTTP响应 """ if res is None: return False, "连接MoviePilot服务器失败" # 检查响应状态 if res and res.status_code == 200: # 清除缓存 if clear_cache: self.get_shares.cache_clear() self.get_statistic.cache_clear() self.get_share_statistics.cache_clear() self.async_get_shares.cache_clear() self.async_get_statistic.cache_clear() self.async_get_share_statistics.cache_clear() return True, "" else: return False, res.json().get("message") @staticmethod def _handle_list_response(res) -> List[dict]: """ 处理返回List的HTTP响应 """ if res and res.status_code == 200: return res.json() return [] @cached(region=_shares_cache_region, maxsize=5, ttl=1800, skip_empty=True) def get_statistic(self, stype: str, page: Optional[int] = 1, count: Optional[int] = 30, genre_id: Optional[int] = None, min_rating: Optional[float] = None, max_rating: Optional[float] = None, sort_type: Optional[str] = None) -> List[dict]: """ 获取订阅统计数据 """ enabled, _ = self._check_subscribe_share_enabled() if not enabled: return [] params = { "stype": stype, "page": page, "count": count } # 添加可选参数 if genre_id is not None: params["genre_id"] = genre_id if min_rating is not None: params["min_rating"] = min_rating if max_rating is not None: params["max_rating"] = max_rating if sort_type is not None: params["sort_type"] = sort_type res = RequestUtils(proxies=settings.PROXY, timeout=15).get_res(self._sub_statistic, params=params) return self._handle_list_response(res) @cached(region=_shares_cache_region, maxsize=5, ttl=1800, skip_empty=True) async def async_get_statistic(self, stype: str, page: Optional[int] = 1, count: Optional[int] = 30, genre_id: Optional[int] = None, min_rating: Optional[float] = None, max_rating: Optional[float] = None, sort_type: Optional[str] = None) -> List[dict]: """ 异步获取订阅统计数据 """ enabled, _ = self._check_subscribe_share_enabled() if not enabled: return [] params = { "stype": stype, "page": page, "count": count } # 添加可选参数 if genre_id is not None: params["genre_id"] = genre_id if min_rating is not None: params["min_rating"] = min_rating if max_rating is not None: params["max_rating"] = max_rating if sort_type is not None: params["sort_type"] = sort_type res = await AsyncRequestUtils(proxies=settings.PROXY, timeout=15).get_res(self._sub_statistic, params=params) return self._handle_list_response(res) def sub_reg(self, sub: dict) -> bool: """ 新增订阅统计 """ enabled, _ = self._check_subscribe_share_enabled() if not enabled: return False res = RequestUtils(proxies=settings.PROXY, timeout=5, headers={ "Content-Type": "application/json" }).post_res(self._sub_reg, json=sub) if res and res.status_code == 200: return True return False async def async_sub_reg(self, sub: dict) -> bool: """ 异步新增订阅统计 """ enabled, _ = self._check_subscribe_share_enabled() if not enabled: return False res = await AsyncRequestUtils(proxies=settings.PROXY, timeout=5, headers={ "Content-Type": "application/json" }).post_res(self._sub_reg, json=sub) if res and res.status_code == 200: return True return False def sub_done(self, sub: dict) -> bool: """ 完成订阅统计 """ enabled, _ = self._check_subscribe_share_enabled() if not enabled: return False res = RequestUtils(proxies=settings.PROXY, timeout=5, headers={ "Content-Type": "application/json" }).post_res(self._sub_done, json=sub) if res and res.status_code == 200: return True return False def sub_reg_async(self, sub: dict) -> bool: """ 异步新增订阅统计 """ # 开新线程处理 Thread(target=self.sub_reg, args=(sub,)).start() return True def sub_done_async(self, sub: dict) -> bool: """ 异步完成订阅统计 """ # 开新线程处理 Thread(target=self.sub_done, args=(sub,)).start() return True def sub_report(self) -> bool: """ 上报存量订阅统计 """ enabled, _ = self._check_subscribe_share_enabled() if not enabled: return False subscribes = SubscribeOper().list() if not subscribes: return True res = RequestUtils(proxies=settings.PROXY, content_type="application/json", timeout=10).post(self._sub_report, json={ "subscribes": [ sub.to_dict() for sub in subscribes ] }) return True if res else False def sub_share(self, subscribe_id: int, share_title: str, share_comment: str, share_user: str) -> Tuple[bool, str]: """ 分享订阅 """ # 检查功能是否开启 enabled, message = self._check_subscribe_share_enabled() if not enabled: return False, message # 获取订阅信息 subscribe = SubscribeOper().get(subscribe_id) # 验证订阅 valid, message = self._validate_subscribe(subscribe) if not valid: return False, message # 准备数据 subscribe_dict = self._prepare_subscribe_data(subscribe) payload = self._build_share_payload(share_title, share_comment, share_user, subscribe_dict) # 发送分享请求 res = RequestUtils(proxies=settings.PROXY, content_type="application/json", timeout=10).post(self._sub_share, json=payload) return self._handle_response(res) async def async_sub_share(self, subscribe_id: int, share_title: str, share_comment: str, share_user: str) -> Tuple[bool, str]: """ 异步分享订阅 """ # 检查功能是否开启 enabled, message = self._check_subscribe_share_enabled() if not enabled: return False, message # 获取订阅信息 subscribe = await SubscribeOper().async_get(subscribe_id) # 验证订阅 valid, message = self._validate_subscribe(subscribe) if not valid: return False, message # 准备数据 subscribe_dict = self._prepare_subscribe_data(subscribe) payload = self._build_share_payload(share_title, share_comment, share_user, subscribe_dict) # 发送分享请求 res = await AsyncRequestUtils(proxies=settings.PROXY, content_type="application/json", timeout=10).post(self._sub_share, json=payload) return self._handle_response(res) def share_delete(self, share_id: int) -> Tuple[bool, str]: """ 删除分享 """ # 检查功能是否开启 enabled, message = self._check_subscribe_share_enabled() if not enabled: return False, message res = RequestUtils(proxies=settings.PROXY, timeout=5).delete_res(f"{self._sub_share}/{share_id}", params={"share_uid": self._share_user_id}) return self._handle_response(res) async def async_share_delete(self, share_id: int) -> Tuple[bool, str]: """ 异步删除分享 """ # 检查功能是否开启 enabled, message = self._check_subscribe_share_enabled() if not enabled: return False, message res = await AsyncRequestUtils(proxies=settings.PROXY, timeout=5).delete_res(f"{self._sub_share}/{share_id}", params={"share_uid": self._share_user_id}) return self._handle_response(res) def sub_fork(self, share_id: int) -> Tuple[bool, str]: """ 复用分享的订阅 """ # 检查功能是否开启 enabled, message = self._check_subscribe_share_enabled() if not enabled: return False, message res = RequestUtils(proxies=settings.PROXY, timeout=5, headers={ "Content-Type": "application/json" }).get_res(self._sub_fork % share_id) return self._handle_response(res, clear_cache=False) async def async_sub_fork(self, share_id: int) -> Tuple[bool, str]: """ 异步复用分享的订阅 """ # 检查功能是否开启 enabled, message = self._check_subscribe_share_enabled() if not enabled: return False, message res = await AsyncRequestUtils(proxies=settings.PROXY, timeout=5, headers={ "Content-Type": "application/json" }).get_res(self._sub_fork % share_id) return self._handle_response(res, clear_cache=False) @cached(region=_shares_cache_region, maxsize=1, ttl=1800, skip_empty=True) def get_shares(self, name: Optional[str] = None, page: Optional[int] = 1, count: Optional[int] = 30, genre_id: Optional[int] = None, min_rating: Optional[float] = None, max_rating: Optional[float] = None, sort_type: Optional[str] = None) -> List[dict]: """ 获取订阅分享数据 """ enabled, _ = self._check_subscribe_share_enabled() if not enabled: return [] params = { "name": name, "page": page, "count": count } # 添加可选参数 if genre_id is not None: params["genre_id"] = genre_id if min_rating is not None: params["min_rating"] = min_rating if max_rating is not None: params["max_rating"] = max_rating if sort_type is not None: params["sort_type"] = sort_type res = RequestUtils(proxies=settings.PROXY, timeout=15).get_res(self._sub_shares, params=params) return self._handle_list_response(res) @cached(region=_shares_cache_region, maxsize=1, ttl=1800, skip_empty=True) async def async_get_shares(self, name: Optional[str] = None, page: Optional[int] = 1, count: Optional[int] = 30, genre_id: Optional[int] = None, min_rating: Optional[float] = None, max_rating: Optional[float] = None, sort_type: Optional[str] = None) -> List[dict]: """ 异步获取订阅分享数据 """ enabled, _ = self._check_subscribe_share_enabled() if not enabled: return [] params = { "name": name, "page": page, "count": count } # 添加可选参数 if genre_id is not None: params["genre_id"] = genre_id if min_rating is not None: params["min_rating"] = min_rating if max_rating is not None: params["max_rating"] = max_rating if sort_type is not None: params["sort_type"] = sort_type res = await AsyncRequestUtils(proxies=settings.PROXY, timeout=15).get_res(self._sub_shares, params=params) return self._handle_list_response(res) @cached(region=_shares_cache_region, maxsize=1, ttl=1800, skip_empty=True) def get_share_statistics(self) -> List[dict]: """ 获取订阅分享统计数据 """ enabled, _ = self._check_subscribe_share_enabled() if not enabled: return [] res = RequestUtils(proxies=settings.PROXY, timeout=15).get_res(self._sub_share_statistic) return self._handle_list_response(res) @cached(region=_shares_cache_region, maxsize=1, ttl=1800, skip_empty=True) async def async_get_share_statistics(self) -> List[dict]: """ 异步获取订阅分享统计数据 """ enabled, _ = self._check_subscribe_share_enabled() if not enabled: return [] res = await AsyncRequestUtils(proxies=settings.PROXY, timeout=15).get_res(self._sub_share_statistic) return self._handle_list_response(res) def get_user_uuid(self) -> str: """ 获取用户uuid """ if not self._share_user_id: self._share_user_id = SystemUtils.generate_user_unique_id() logger.info(f"当前用户UUID: {self._share_user_id}") return self._share_user_id def get_github_user(self) -> str: """ 获取github用户 """ if self._github_user is None and settings.GITHUB_HEADERS: res = RequestUtils(headers=settings.GITHUB_HEADERS, proxies=settings.PROXY, timeout=15).get_res(f"https://api.github.com/user") if res: self._github_user = res.json().get("login") logger.info(f"当前Github用户: {self._github_user}") return self._github_user def is_admin_user(self) -> bool: """ 判断是否是管理员 """ if not self._github_user: return False if self._github_user in self._admin_users: return True return False ================================================ FILE: app/helper/system.py ================================================ import os import signal import threading import time from pathlib import Path from typing import Tuple import docker from app.core.config import settings from app.log import logger from app.utils.mixins import ConfigReloadMixin from app.utils.system import SystemUtils class SystemHelper(ConfigReloadMixin): """ 系统工具类,提供系统相关的操作和判断 """ CONFIG_WATCH = { "DEBUG", "LOG_LEVEL", "LOG_MAX_FILE_SIZE", "LOG_BACKUP_COUNT", "LOG_FILE_FORMAT", "LOG_CONSOLE_FORMAT", } __system_flag_file = "/var/log/nginx/__moviepilot__" def on_config_changed(self): logger.update_loggers() def get_reload_name(self): return "日志设置" @staticmethod def can_restart() -> bool: """ 判断是否可以内部重启 """ return ( Path("/var/run/docker.sock").exists() or settings.DOCKER_CLIENT_API != "tcp://127.0.0.1:38379" ) @staticmethod def _get_container_id() -> str: """ 获取当前容器ID """ container_id = None try: with open("/proc/self/mountinfo", "r") as f: data = f.read() index_resolv_conf = data.find("resolv.conf") if index_resolv_conf != -1: index_second_slash = data.rfind("/", 0, index_resolv_conf) index_first_slash = data.rfind("/", 0, index_second_slash) + 1 container_id = data[index_first_slash:index_second_slash] if len(container_id) < 20: index_resolv_conf = data.find("/sys/fs/cgroup/devices") if index_resolv_conf != -1: index_second_slash = data.rfind(" ", 0, index_resolv_conf) index_first_slash = ( data.rfind("/", 0, index_second_slash) + 1 ) container_id = data[index_first_slash:index_second_slash] except Exception as e: logger.debug(f"获取容器ID失败: {str(e)}") return container_id.strip() if container_id else None @staticmethod def _check_restart_policy() -> bool: """ 检查当前容器是否配置了自动重启策略 """ try: # 获取当前容器ID container_id = SystemHelper._get_container_id() if not container_id: return False # 创建 Docker 客户端 client = docker.DockerClient(base_url=settings.DOCKER_CLIENT_API) # 获取容器信息 container = client.containers.get(container_id) restart_policy = container.attrs.get('HostConfig', {}).get('RestartPolicy', {}) policy_name = restart_policy.get('Name', 'no') # 检查是否有有效的重启策略 auto_restart_policies = ['always', 'unless-stopped', 'on-failure'] has_restart_policy = policy_name in auto_restart_policies logger.info(f"容器重启策略: {policy_name}, 支持自动重启: {has_restart_policy}") return has_restart_policy except Exception as e: logger.warning(f"检查重启策略失败: {str(e)}") return False @staticmethod def restart() -> Tuple[bool, str]: """ 执行Docker重启操作 """ if not SystemUtils.is_docker(): return False, "非Docker环境,无法重启!" try: # 检查容器是否配置了自动重启策略 has_restart_policy = SystemHelper._check_restart_policy() if has_restart_policy: # 有重启策略,使用优雅退出方式 logger.info("检测到容器配置了自动重启策略,使用优雅重启方式...") # 启动优雅退出超时监控 SystemHelper._start_graceful_shutdown_monitor() # 发送SIGTERM信号给当前进程,触发优雅停止 os.kill(os.getpid(), signal.SIGTERM) return True, "" else: # 没有重启策略,使用Docker API强制重启 logger.info("容器未配置自动重启策略,使用Docker API重启...") return SystemHelper._docker_api_restart() except Exception as err: logger.error(f"重启失败: {str(err)}") # 降级为Docker API重启 logger.warning("降级为Docker API重启...") return SystemHelper._docker_api_restart() @staticmethod def _start_graceful_shutdown_monitor(): """ 启动优雅退出超时监控 如果30秒内进程没有退出,则使用Docker API强制重启 """ def monitor_thread(): time.sleep(30) # 等待30秒 logger.warning("优雅退出超时30秒,使用Docker API强制重启...") try: SystemHelper._docker_api_restart() except Exception as e: logger.error(f"强制重启失败: {str(e)}") # 在后台线程中启动监控 thread = threading.Thread(target=monitor_thread, daemon=True) thread.start() @staticmethod def _docker_api_restart() -> Tuple[bool, str]: """ 使用Docker API重启容器,并尝试优雅停止 """ try: # 创建 Docker 客户端 client = docker.DockerClient(base_url=settings.DOCKER_CLIENT_API) container_id = SystemHelper._get_container_id() if not container_id: return False, "获取容器ID失败!" # 重启容器 client.containers.get(container_id).restart() return True, "" except Exception as docker_err: return False, f"重启时发生错误:{str(docker_err)}" def set_system_modified(self): """ 设置系统已修改标志 """ try: if SystemUtils.is_docker(): Path(self.__system_flag_file).touch(exist_ok=True) except Exception as e: print(f"设置系统修改标志失败: {str(e)}") def is_system_reset(self) -> bool: """ 检查系统是否已被重置 :return: 如果系统已重置,返回 True;否则返回 False """ if SystemUtils.is_docker(): return not Path(self.__system_flag_file).exists() return False ================================================ FILE: app/helper/thread.py ================================================ from concurrent.futures import ThreadPoolExecutor from app.core.config import settings from app.utils.singleton import Singleton class ThreadHelper(metaclass=Singleton): """ 线程池管理 """ def __init__(self): self.pool = ThreadPoolExecutor(max_workers=settings.CONF.threadpool) def submit(self, func, *args, **kwargs): """ 提交任务 :param func: 函数 :param args: 参数 :param kwargs: 参数 :return: future """ return self.pool.submit(func, *args, **kwargs) def shutdown(self): """ 关闭线程池 :return: """ self.pool.shutdown() ================================================ FILE: app/helper/torrent.py ================================================ import datetime import re from pathlib import Path from typing import Tuple, Optional, List, Union, Dict, Any from urllib.parse import unquote from torrentool.api import Torrent from app.core.cache import TTLCache, FileCache from app.core.config import settings from app.core.context import Context, TorrentInfo, MediaInfo from app.core.meta import MetaBase from app.core.metainfo import MetaInfo from app.db.site_oper import SiteOper from app.db.systemconfig_oper import SystemConfigOper from app.log import logger from app.schemas.types import MediaType, SystemConfigKey from app.utils.http import RequestUtils from app.utils.string import StringUtils class TorrentHelper: """ 种子帮助类 """ def __init__(self): self._invalid_torrents = TTLCache(region="invalid_torrents", maxsize=128, ttl=3600 * 24) def download_torrent(self, url: str, cookie: Optional[str] = None, ua: Optional[str] = None, referer: Optional[str] = None, proxy: Optional[bool] = False) \ -> Tuple[Optional[Path], Optional[Union[str, bytes]], Optional[str], Optional[list], Optional[str]]: """ 把种子下载到本地 :return: 种子缓存相对路径【用于索引缓存】, 种子内容、种子主目录、种子文件清单、错误信息 """ if url.startswith("magnet:"): return None, url, "", [], f"磁力链接" # 构建 torrent 种子文件的缓存路径 cache_path = Path(StringUtils.md5_hash(url)).with_suffix(".torrent") # 缓存处理器 cache_backend = FileCache() # 读取缓存的种子文件 torrent_content = cache_backend.get(cache_path.as_posix(), region="torrents") if torrent_content: # 缓存已存在 try: # 获取种子目录和文件清单 folder_name, file_list = self.get_fileinfo_from_torrent_content(torrent_content) # 无法获取信息,则认为缓存文件无效 if not folder_name and not file_list: raise ValueError("无效的缓存种子文件") # 成功拿到种子数据 return cache_path, torrent_content, folder_name, file_list, "" except Exception as err: logger.error(f"处理缓存的种子文件 {cache_path} 时出错: {err},将重新下载") # 下载种子文件 req = RequestUtils( ua=ua, cookies=cookie, referer=referer, proxies=settings.PROXY if proxy else None ).get_res(url=url, allow_redirects=False) while req and req.status_code in [301, 302]: url = req.headers['Location'] if url and url.startswith("magnet:"): return None, url, "", [], f"获取到磁力链接" req = RequestUtils( ua=ua, cookies=cookie, referer=referer, proxies=settings.PROXY if proxy else None ).get_res(url=url, allow_redirects=False) if req and req.status_code == 200: if not req.content: return cache_path, None, "", [], "未下载到种子数据" # 解析内容格式 if req.content.startswith(b"magnet:"): # 磁力链接 return cache_path, req.text, "", [], f"获取到磁力链接" if "下载种子文件".encode("utf-8") in req.content: # 首次下载提示页面 skip_flag = False try: forms = re.findall(r'(.*?)', req.text, re.S) for form in forms: action = form[0] if action != "?": continue action = url inputs = re.findall(r'', form[1], re.S) if inputs: data = {} for item in inputs: data[item[0]] = item[1] # 改写req req = RequestUtils( ua=ua, cookies=cookie, referer=referer, proxies=settings.PROXY if proxy else None ).post_res(url=action, data=data) if req and req.status_code == 200: # 检查是不是种子文件,如果不是抛出异常 Torrent.from_string(req.content) # 跳过成功 logger.info(f"触发了站点首次种子下载,已自动跳过:{url}") skip_flag = True elif req is not None: logger.warn(f"触发了站点首次种子下载,且无法自动跳过," f"返回码:{req.status_code},错误原因:{req.reason}") else: logger.warn(f"触发了站点首次种子下载,且无法自动跳过:{url}") break except Exception as err: logger.warn(f"触发了站点首次种子下载,尝试自动跳过时出现错误:{str(err)},链接:{url}") if not skip_flag: return cache_path, None, "", [], "种子数据有误,请确认链接是否正确,如为PT站点则需手工在站点下载一次种子" # 种子内容 if req.content: # 检查是不是种子文件,如果不是仍然抛出异常 try: # 获取种子目录和文件清单 folder_name, file_list = self.get_fileinfo_from_torrent_content(req.content) if file_list: # 保存到缓存 cache_backend.set(cache_path.as_posix(), req.content, region="torrents") # 成功拿到种子数据 return cache_path, req.content, folder_name, file_list, "" except Exception as err: logger.error(f"种子文件解析失败:{str(err)}") # 种子数据仍然错误 return cache_path, None, "", [], "种子数据有误,请确认链接是否正确" # 返回失败 return cache_path, None, "", [], "" elif req is None: return cache_path, None, "", [], "无法打开链接" elif req.status_code == 429: return cache_path, None, "", [], "触发站点流控,请稍后重试" else: # 把错误的种子记下来,避免重复使用 self.add_invalid(url) return cache_path, None, "", [], f"下载种子出错,状态码:{req.status_code}" def get_torrent_info(self, torrent_path: Path) -> Tuple[str, List[str]]: """ 获取种子文件的文件夹名和文件清单 :param torrent_path: 种子文件路径 :return: 文件夹名、文件清单,单文件种子返回空文件夹名 """ if not torrent_path or not torrent_path.exists(): return "", [] try: torrentinfo = Torrent.from_file(torrent_path) # 获取文件清单 return self.get_fileinfo_from_torrent(torrentinfo) except Exception as err: logger.error(f"种子文件解析失败:{str(err)}") return "", [] @staticmethod def get_fileinfo_from_torrent(torrent: Torrent) -> Tuple[str, List[str]]: """ 从种子文件中获取文件清单 :param torrent: 种子文件对象 :return: 文件夹名、文件清单,单文件种子返回空文件夹名 """ if not torrent or not torrent.files: return "", [] # 获取文件清单 if len(torrent.files) == 1 and torrent.files[0].name == torrent.name: # 单文件种子目录名返回空 folder_name = "" # 单文件种子 file_list = [torrent.name] else: # 目录名 folder_name = torrent.name # 文件清单,如果一级目录与种子名相同则去掉 file_list = [] for fileinfo in torrent.files: file_path = Path(fileinfo.name) # 根路径 root_path = file_path.parts[0] if root_path == folder_name: file_list.append(str(file_path.relative_to(root_path))) else: file_list.append(fileinfo.name) logger.debug(f"解析种子:{torrent.name} => 目录:{folder_name},文件清单:{file_list}") return folder_name, file_list def get_fileinfo_from_torrent_content(self, torrent_content: Union[str, bytes]) -> Tuple[str, List[str]]: """ 从种子内容中获取文件夹名和文件清单 :param torrent_content: 种子内容 :return: 文件夹名、文件清单,单文件种子返回空文件夹名 """ if not torrent_content: return "", [] # 检查是否为磁力链接 if StringUtils.is_magnet_link(torrent_content): return "", [] try: # 解析种子内容 torrentinfo = Torrent.from_string(torrent_content) # 获取文件清单 return self.get_fileinfo_from_torrent(torrentinfo) except Exception as err: logger.error(f"种子内容解析失败:{str(err)}") return "", [] @staticmethod def get_url_filename(req: Any, url: str) -> str: """ 从下载请求中获取种子文件名 """ if not req: return "" disposition = req.headers.get('content-disposition') or "" file_name = re.findall(r"filename=\"?(.+)\"?", disposition) if file_name: file_name = unquote(str(file_name[0].encode('ISO-8859-1').decode()).split(";")[0].strip()) if file_name.endswith('"'): file_name = file_name[:-1] elif url and url.endswith(".torrent"): file_name = unquote(url.split("/")[-1]) else: file_name = str(datetime.datetime.now()) return file_name @staticmethod def sort_torrents(torrent_list: List[Context]) -> List[Context]: """ 对种子对行排序:torrent、site、upload、seeder """ if not torrent_list: return [] # 下载规则 priority_rule: List[str] = SystemConfigOper().get( SystemConfigKey.TorrentsPriority) or ["torrent", "upload", "seeder"] # 站点上传量 site_uploads = { site.name: site.upload for site in SiteOper().get_userdata_latest() } def get_sort_str(_context): """ 拼装排序字段 """ _meta = _context.meta_info _torrent = _context.torrent_info _media = _context.media_info # 标题 _title = str(_media.title).ljust(200, ' ') # 站点优先级 _site_order = str(999 - (_torrent.site_order or 0)).rjust(3, '0') # 站点上传量 _site_upload = str(site_uploads.get(_torrent.site_name) or 0).rjust(30, '0') # 资源优先级 _torrent_order = str(_torrent.pri_order or 0).rjust(3, '0') # 资源做种数 _torrent_seeders = str(_torrent.seeders or 0).rjust(10, '0') # 季集 if not _meta.episode_list: # 无集数的排最前面 _season_episode = "%s%s" % (str(len(_meta.season_list)).rjust(3, '0'), "9999") else: # 集数越多的排越前面 _season_episode = "%s%s" % (str(len(_meta.season_list)).rjust(3, '0'), str(len(_meta.episode_list)).rjust(4, '0')) # 根据下载规则的顺序拼装排序字符串 _sort_str = _title for rule in priority_rule: if rule == "torrent": _sort_str += _torrent_order elif rule == "site": _sort_str += _site_order elif rule == "upload": _sort_str += _site_upload elif rule == "seeder": _sort_str += _torrent_seeders _sort_str += _season_episode return _sort_str # 排序 return sorted(torrent_list, key=lambda x: get_sort_str(x), reverse=True) def sort_group_torrents(self, torrent_list: List[Context]) -> List[Context]: """ 对媒体信息进行排序、去重 """ if not torrent_list: return [] # 排序 torrent_list = self.sort_torrents(torrent_list) # 控重 result = [] _added = [] # 排序后重新加入数组,按真实名称控重,即只取每个名称的第一个 for context in torrent_list: # 控重的主链是名称、年份、季、集 meta = context.meta_info media = context.media_info if media.type == MediaType.TV: media_name = "%s%s" % (media.title_year, meta.season_episode) else: media_name = media.title_year if media_name not in _added: _added.append(media_name) result.append(context) return result @staticmethod def get_torrent_episodes(files: list) -> list: """ 从种子的文件清单中获取所有集数 """ episodes = [] for file in files: if not file: continue file_path = Path(file) if not file_path.suffix or file_path.suffix.lower() not in settings.RMT_MEDIAEXT: continue # 只使用文件名识别 meta = MetaInfo(file_path.name) if not meta.begin_episode: continue episodes = list(set(episodes).union(set(meta.episode_list))) return episodes def is_invalid(self, url: Optional[str]) -> bool: """ 判断种子是否是无效种子 """ return url in self._invalid_torrents if url else True def add_invalid(self, url: str): """ 添加无效种子 """ if url not in self._invalid_torrents: self._invalid_torrents[url] = True @staticmethod def match_torrent(mediainfo: MediaInfo, torrent_meta: MetaBase, torrent: TorrentInfo) -> bool: """ 检查种子是否匹配媒体信息 :param mediainfo: 需要匹配的媒体信息 :param torrent_meta: 种子识别信息 :param torrent: 种子信息 """ # 比对词条指定的tmdbid if torrent_meta.tmdbid or torrent_meta.doubanid: if torrent_meta.tmdbid and torrent_meta.tmdbid == mediainfo.tmdb_id: logger.info( f'{mediainfo.title} 通过词表指定TMDBID匹配到资源:{torrent.site_name} - {torrent.title}') return True if torrent_meta.doubanid and torrent_meta.doubanid == mediainfo.douban_id: logger.info( f'{mediainfo.title} 通过词表指定豆瓣ID匹配到资源:{torrent.site_name} - {torrent.title}') return True # 要匹配的媒体标题、原标题 media_titles = { StringUtils.clear_upper(mediainfo.title), StringUtils.clear_upper(mediainfo.original_title) } - {""} # 要匹配的媒体别名、译名 media_names = {StringUtils.clear_upper(name) for name in mediainfo.names if name} # 识别的种子中英文名 meta_names = { StringUtils.clear_upper(torrent_meta.cn_name), StringUtils.clear_upper(torrent_meta.en_name) } - {""} # 比对种子识别类型 if torrent_meta.type == MediaType.TV and mediainfo.type != MediaType.TV: logger.debug(f'{torrent.site_name} - {torrent.title} 种子标题类型为 {torrent_meta.type.value},' f'不匹配 {mediainfo.type.value}') return False # 比对种子在站点中的类型 if torrent.category == MediaType.TV.value and mediainfo.type != MediaType.TV: logger.debug(f'{torrent.site_name} - {torrent.title} 种子在站点中归类为 {torrent.category},' f'不匹配 {mediainfo.type.value}') return False # 比对年份 if mediainfo.year: if mediainfo.type == MediaType.TV: # 剧集年份,每季的年份可能不同,没年份时不比较年份(很多剧集种子不带年份) if torrent_meta.year and torrent_meta.year not in [year for year in mediainfo.season_years.values()]: logger.debug(f'{torrent.site_name} - {torrent.title} 年份不匹配 {mediainfo.season_years}') return False else: # 电影年份,上下浮动1年,没年份时不通过 if not torrent_meta.year or torrent_meta.year not in [str(int(mediainfo.year) - 1), mediainfo.year, str(int(mediainfo.year) + 1)]: logger.debug(f'{torrent.site_name} - {torrent.title} 年份不匹配 {mediainfo.year}') return False # 比对标题和原语种标题 if meta_names.intersection(media_titles): logger.info(f'{mediainfo.title} 通过标题匹配到资源:{torrent.site_name} - {torrent.title}') return True # 比对别名和译名 if media_names: if meta_names.intersection(media_names): logger.info(f'{mediainfo.title} 通过别名或译名匹配到资源:{torrent.site_name} - {torrent.title}') return True # 标题拆分 if torrent_meta.org_string: # 只拆分出标题中的非英文单词进行匹配,英文单词容易误匹配(带空格的多个单词组合除外) titles = [StringUtils.clear_upper(t) for t in re.split( r'[\s/【】.\[\]\-]+', torrent_meta.org_string ) if not StringUtils.is_english_word(t)] # 在标题中判断是否存在标题、原语种标题 if media_titles.intersection(titles): logger.info(f'{mediainfo.title} 通过标题匹配到资源:{torrent.site_name} - {torrent.title}') return True # 在副标题中(非英文单词)判断是否存在标题、原语种标题、别名、译名 if torrent.description: subtitles = {StringUtils.clear_upper(t) for t in re.split( r'[\s/【】|]+', torrent.description) if not StringUtils.is_english_word(t)} if media_titles.intersection(subtitles) or media_names.intersection(subtitles): logger.info(f'{mediainfo.title} 通过副标题匹配到资源:{torrent.site_name} - {torrent.title},' f'副标题:{torrent.description}') return True # 未匹配 logger.debug(f'{torrent.site_name} - {torrent.title} 标题不匹配,识别名称:{meta_names}') return False @staticmethod def filter_torrent(torrent_info: TorrentInfo, filter_params: Dict[str, str]) -> bool: """ 检查种子是否匹配订阅过滤规则 """ if not filter_params: return True # 匹配内容 content = (f"{torrent_info.title} " f"{torrent_info.description} " f"{' '.join(torrent_info.labels or [])} " f"{torrent_info.volume_factor}") # 包含 include = filter_params.get("include") if include: if not re.search(r"%s" % include, content, re.I): logger.info(f"{content} 不匹配包含规则 {include}") return False # 排除 exclude = filter_params.get("exclude") if exclude: if re.search(r"%s" % exclude, content, re.I): logger.info(f"{content} 匹配排除规则 {exclude}") return False # 质量 quality = filter_params.get("quality") if quality: if not re.search(r"%s" % quality, torrent_info.title, re.I): logger.info(f"{torrent_info.title} 不匹配质量规则 {quality}") return False # 分辨率 resolution = filter_params.get("resolution") if resolution: if not re.search(r"%s" % resolution, torrent_info.title, re.I): logger.info(f"{torrent_info.title} 不匹配分辨率规则 {resolution}") return False # 特效 effect = filter_params.get("effect") if effect: if not re.search(r"%s" % effect, torrent_info.title, re.I): logger.info(f"{torrent_info.title} 不匹配特效规则 {effect}") return False # 大小 size_range = filter_params.get("size") if size_range: if size_range.find("-") != -1: # 区间 size_min, size_max = size_range.split("-") size_min = float(size_min.strip()) * 1024 * 1024 size_max = float(size_max.strip()) * 1024 * 1024 if torrent_info.size < size_min or torrent_info.size > size_max: return False elif size_range.startswith(">"): # 大于 size_min = float(size_range[1:].strip()) * 1024 * 1024 if torrent_info.size < size_min: return False elif size_range.startswith("<"): # 小于 size_max = float(size_range[1:].strip()) * 1024 * 1024 if torrent_info.size > size_max: return False return True @staticmethod def match_season_episodes(torrent: TorrentInfo, meta: MetaBase, season_episodes: Dict[int, list]) -> bool: """ 判断种子是否匹配季集数 :param torrent: 种子信息 :param meta: 种子元数据 :param season_episodes: 季集数 {season:[episodes]} """ # 匹配季 seasons = season_episodes.keys() # 种子季 torrent_seasons = meta.season_list if not torrent_seasons: # 按第一季处理 torrent_seasons = [1] # 种子集 torrent_episodes = meta.episode_list if not set(torrent_seasons).issubset(set(seasons)): # 种子季不在过滤季中 logger.debug( f"种子 {torrent.site_name} - {torrent.title} 包含季 {torrent_seasons} 不是需要的季 {list(seasons)}") return False if not torrent_episodes: # 整季按匹配处理 return True if len(torrent_seasons) == 1: need_episodes = season_episodes.get(torrent_seasons[0]) if need_episodes \ and not set(torrent_episodes).intersection(set(need_episodes)): # 单季集没有交集的不要 logger.debug(f"种子 {torrent.site_name} - {torrent.title} " f"集 {torrent_episodes} 没有需要的集:{need_episodes}") return False return True ================================================ FILE: app/helper/twofa.py ================================================ import base64 import hashlib import hmac import struct import sys import time from app.log import logger class TwoFactorAuth: def __init__(self, code_or_secret: str): if code_or_secret and len(code_or_secret) >= 16: self.code = None self.secret = code_or_secret else: self.code = code_or_secret self.secret = None @staticmethod def __calc(secret_key: str) -> str: if not secret_key: return "" try: input_time = int(time.time()) // 30 key = base64.b32decode(secret_key) msg = struct.pack(">Q", input_time) google_code = hmac.new(key, msg, hashlib.sha1).digest() o = ( google_code[19] & 15 if sys.version_info > (2, 7) else ord(str(google_code[19])) & 15 ) google_code = str( (struct.unpack(">I", google_code[o: o + 4])[0] & 0x7FFFFFFF) % 1000000 ) return f"0{google_code}" if len(google_code) == 5 else google_code except Exception as e: logger.error(f"计算动态验证码失败:{str(e)}") return "" def get_code(self) -> str: return self.code or self.__calc(self.secret) ================================================ FILE: app/helper/workflow.py ================================================ import json from typing import List, Tuple, Optional from app.core.cache import cached from app.core.config import settings from app.db.models import Workflow from app.db.workflow_oper import WorkflowOper from app.log import logger from app.utils.http import RequestUtils, AsyncRequestUtils from app.utils.singleton import WeakSingleton from app.utils.system import SystemUtils class WorkflowHelper(metaclass=WeakSingleton): """ 工作流分享等 """ _workflow_share = f"{settings.MP_SERVER_HOST}/workflow/share" _workflow_shares = f"{settings.MP_SERVER_HOST}/workflow/shares" _workflow_fork = f"{settings.MP_SERVER_HOST}/workflow/fork/%s" _shares_cache_region = "workflow_share" _share_user_id = None def __init__(self): self.get_user_uuid() @staticmethod def _check_workflow_share_enabled() -> Tuple[bool, str]: """ 检查工作流分享功能是否开启 """ if not settings.WORKFLOW_STATISTIC_SHARE: return False, "当前没有开启工作流数据共享功能" return True, "" @staticmethod def _validate_workflow(workflow: Workflow) -> Tuple[bool, str]: """ 验证工作流是否可以分享 """ if not workflow: return False, "工作流不存在" if not workflow.actions or not workflow.flows: return False, "请分享有动作和流程的工作流" return True, "" @staticmethod def _prepare_workflow_data(workflow: Workflow) -> dict: """ 准备工作流分享数据 """ workflow_dict = workflow.to_dict() workflow_dict.pop("id", None) workflow_dict.pop("context", None) workflow_dict['actions'] = json.dumps(workflow_dict['actions'] or []) workflow_dict['flows'] = json.dumps(workflow_dict['flows'] or []) return workflow_dict def _build_share_payload(self, share_title: str, share_comment: str, share_user: str, workflow_dict: dict) -> dict: """ 构建分享请求载荷 """ return { "share_title": share_title, "share_comment": share_comment, "share_user": share_user, "share_uid": self._share_user_id, **workflow_dict } def _handle_response(self, res, clear_cache: bool = True) -> Tuple[bool, str]: """ 处理HTTP响应 """ if res is None: return False, "连接MoviePilot服务器失败" # 检查响应状态 success = True if res.status_code == 200 else False if success: # 清除缓存 if clear_cache: self.get_shares.cache_clear() self.async_get_shares.cache_clear() return True, "" else: try: error_msg = res.json().get("message", "未知错误") except (json.JSONDecodeError, ValueError) as e: logger.error(f"工作流响应JSON解析失败: {e}") error_msg = f"响应解析失败: {res.text[:100]}..." return False, error_msg @staticmethod def _handle_list_response(res) -> List[dict]: """ 处理返回List的HTTP响应 """ if res and res.status_code == 200: try: return res.json() except (json.JSONDecodeError, ValueError) as e: logger.error(f"工作流列表响应JSON解析失败: {e}") return [] return [] def workflow_share(self, workflow_id: int, share_title: str, share_comment: str, share_user: str) -> Tuple[bool, str]: """ 分享工作流 """ # 检查功能是否开启 enabled, message = self._check_workflow_share_enabled() if not enabled: return False, message # 获取工作流信息 workflow = WorkflowOper().get(workflow_id) # 验证工作流 valid, message = self._validate_workflow(workflow) if not valid: return False, message # 准备数据 workflow_dict = self._prepare_workflow_data(workflow) payload = self._build_share_payload(share_title, share_comment, share_user, workflow_dict) # 发送分享请求 res = RequestUtils(proxies=settings.PROXY or {}, content_type="application/json", timeout=10).post(self._workflow_share, json=payload) return self._handle_response(res) async def async_workflow_share(self, workflow_id: int, share_title: str, share_comment: str, share_user: str) -> Tuple[bool, str]: """ 异步分享工作流 """ # 检查功能是否开启 enabled, message = self._check_workflow_share_enabled() if not enabled: return False, message # 获取工作流信息 workflow = await WorkflowOper().async_get(workflow_id) # 验证工作流 valid, message = self._validate_workflow(workflow) if not valid: return False, message # 准备数据 workflow_dict = self._prepare_workflow_data(workflow) payload = self._build_share_payload(share_title, share_comment, share_user, workflow_dict) # 发送分享请求 res = await AsyncRequestUtils(proxies=settings.PROXY or {}, content_type="application/json", timeout=10).post(self._workflow_share, json=payload) return self._handle_response(res) def share_delete(self, share_id: int) -> Tuple[bool, str]: """ 删除分享 """ # 检查功能是否开启 enabled, message = self._check_workflow_share_enabled() if not enabled: return False, message res = RequestUtils(proxies=settings.PROXY or {}, timeout=5).delete_res(f"{self._workflow_share}/{share_id}", params={"share_uid": self._share_user_id}) return self._handle_response(res) async def async_share_delete(self, share_id: int) -> Tuple[bool, str]: """ 异步删除分享 """ # 检查功能是否开启 enabled, message = self._check_workflow_share_enabled() if not enabled: return False, message res = await AsyncRequestUtils(proxies=settings.PROXY or {}, timeout=5).delete_res(f"{self._workflow_share}/{share_id}", params={"share_uid": self._share_user_id}) return self._handle_response(res) def workflow_fork(self, share_id: int) -> Tuple[bool, str]: """ 复用分享的工作流 """ # 检查功能是否开启 enabled, message = self._check_workflow_share_enabled() if not enabled: return False, message res = RequestUtils(proxies=settings.PROXY or {}, timeout=5, headers={ "Content-Type": "application/json" }).get_res(self._workflow_fork % share_id) return self._handle_response(res, clear_cache=False) async def async_workflow_fork(self, share_id: int) -> Tuple[bool, str]: """ 异步复用分享的工作流 """ # 检查功能是否开启 enabled, message = self._check_workflow_share_enabled() if not enabled: return False, message res = await AsyncRequestUtils(proxies=settings.PROXY or {}, timeout=5, headers={ "Content-Type": "application/json" }).get_res(self._workflow_fork % share_id) return self._handle_response(res, clear_cache=False) @cached(region=_shares_cache_region, maxsize=1, skip_empty=True) def get_shares(self, name: Optional[str] = None, page: Optional[int] = 1, count: Optional[int] = 30) -> List[dict]: """ 获取工作流分享数据 """ enabled, _ = self._check_workflow_share_enabled() if not enabled: return [] res = RequestUtils(proxies=settings.PROXY or {}, timeout=15).get_res(self._workflow_shares, params={ "name": name, "page": page, "count": count }) return self._handle_list_response(res) @cached(region=_shares_cache_region, maxsize=1, skip_empty=True) async def async_get_shares(self, name: Optional[str] = None, page: Optional[int] = 1, count: Optional[int] = 30) -> \ List[dict]: """ 异步获取工作流分享数据 """ enabled, _ = self._check_workflow_share_enabled() if not enabled: return [] res = await AsyncRequestUtils(proxies=settings.PROXY or {}, timeout=15).get_res(self._workflow_shares, params={ "name": name, "page": page, "count": count }) return self._handle_list_response(res) def get_user_uuid(self) -> str: """ 获取用户uuid """ if not self._share_user_id: self._share_user_id = SystemUtils.generate_user_unique_id() logger.info(f"当前用户UUID: {self._share_user_id}") return self._share_user_id or "" ================================================ FILE: app/log.py ================================================ import asyncio import logging import queue import sys import threading import time from concurrent.futures import ThreadPoolExecutor from datetime import datetime from logging.handlers import RotatingFileHandler from pathlib import Path from typing import Dict, Any, Optional import click from pydantic import BaseModel, ConfigDict from pydantic_settings import BaseSettings from app.utils.system import SystemUtils class LogConfigModel(BaseModel): """ Pydantic 配置模型,描述所有配置项及其类型和默认值 """ model_config = ConfigDict(extra="ignore") # 忽略未定义的配置项 # 配置文件目录 CONFIG_DIR: Optional[str] = None # 是否为调试模式 DEBUG: bool = False # 日志级别(DEBUG、INFO、WARNING、ERROR等) LOG_LEVEL: str = "INFO" # 日志文件最大大小(单位:MB) LOG_MAX_FILE_SIZE: int = 5 # 备份的日志文件数量 LOG_BACKUP_COUNT: int = 10 # 控制台日志格式 LOG_CONSOLE_FORMAT: str = "%(leveltext)s[%(name)s] %(asctime)s %(message)s" # 文件日志格式 LOG_FILE_FORMAT: str = "【%(levelname)s】%(asctime)s - %(message)s" # 异步文件写入队列大小 ASYNC_FILE_QUEUE_SIZE: int = 1000 # 异步文件写入线程数 ASYNC_FILE_WORKERS: int = 2 # 批量写入大小 BATCH_WRITE_SIZE: int = 50 # 写入超时时间(秒) WRITE_TIMEOUT: float = 3.0 class LogSettings(BaseSettings, LogConfigModel): """ 日志设置类 """ @property def CONFIG_PATH(self): return SystemUtils.get_config_path(self.CONFIG_DIR) @property def LOG_PATH(self): """ 获取日志存储路径 """ return self.CONFIG_PATH / "logs" @property def LOG_MAX_FILE_SIZE_BYTES(self): """ 将日志文件大小转换为字节(MB -> Bytes) """ return self.LOG_MAX_FILE_SIZE * 1024 * 1024 model_config = ConfigDict( case_sensitive=True, env_file=SystemUtils.get_env_path(), env_file_encoding="utf-8" ) # 实例化日志设置 log_settings = LogSettings() # 日志级别颜色映射 level_name_colors = { logging.DEBUG: lambda level_name: click.style(str(level_name), fg="cyan"), logging.INFO: lambda level_name: click.style(str(level_name), fg="green"), logging.WARNING: lambda level_name: click.style(str(level_name), fg="yellow"), logging.ERROR: lambda level_name: click.style(str(level_name), fg="red"), logging.CRITICAL: lambda level_name: click.style(str(level_name), fg="bright_red"), } class CustomFormatter(logging.Formatter): """ 自定义日志输出格式 """ def __init__(self, fmt=None): super().__init__(fmt) def format(self, record): separator = " " * (8 - len(record.levelname)) record.leveltext = level_name_colors[record.levelno](record.levelname + ":") + separator return super().format(record) class LogEntry: """ 日志条目 """ def __init__(self, level: str, message: str, file_path: Path, timestamp: datetime = None): self.level = level self.message = message self.file_path = file_path self.timestamp = timestamp or datetime.now() class NonBlockingFileHandler: """ 非阻塞文件处理器 - 使用RotatingFileHandler实现日志滚动 """ _instance = None _lock = threading.Lock() _rotating_handlers = {} def __new__(cls): if cls._instance is None: with cls._lock: if cls._instance is None: cls._instance = super().__new__(cls) return cls._instance def __init__(self): if hasattr(self, '_initialized'): return self._initialized = True self._write_queue = queue.Queue(maxsize=log_settings.ASYNC_FILE_QUEUE_SIZE) self._executor = ThreadPoolExecutor(max_workers=log_settings.ASYNC_FILE_WORKERS, thread_name_prefix="LogWriter") self._running = True # 启动后台写入线程 self._write_thread = threading.Thread(target=self._batch_writer, daemon=True) self._write_thread.start() def _get_rotating_handler(self, file_path: Path) -> RotatingFileHandler: """ 获取或创建RotatingFileHandler实例 """ if file_path not in self._rotating_handlers: # 确保目录存在 file_path.parent.mkdir(parents=True, exist_ok=True) # 创建RotatingFileHandler handler = RotatingFileHandler( filename=str(file_path), maxBytes=log_settings.LOG_MAX_FILE_SIZE_BYTES, backupCount=log_settings.LOG_BACKUP_COUNT, encoding='utf-8' ) # 设置格式化器 formatter = logging.Formatter(log_settings.LOG_FILE_FORMAT) handler.setFormatter(formatter) self._rotating_handlers[file_path] = handler return self._rotating_handlers[file_path] def write_log(self, level: str, message: str, file_path: Path): """ 写入日志 - 自动检测协程环境并使用合适的方式 """ entry = LogEntry(level, message, file_path) # 检测是否在协程环境中 if self._is_in_event_loop(): # 在协程环境中,使用非阻塞方式 self._write_non_blocking(entry) else: # 不在协程环境中,直接同步写入 self._write_sync(entry) @staticmethod def _is_in_event_loop() -> bool: """ 检测当前是否在事件循环中 """ try: loop = asyncio.get_running_loop() return loop is not None except RuntimeError: return False def _write_non_blocking(self, entry: LogEntry): """ 非阻塞写入(用于协程环境) """ try: self._write_queue.put_nowait(entry) except queue.Full: # 队列满时,使用线程池处理 self._executor.submit(self._write_sync, entry) @staticmethod def _write_sync(entry: LogEntry): """ 同步写入日志 """ try: # 获取RotatingFileHandler实例 handler = NonBlockingFileHandler()._get_rotating_handler(entry.file_path) # 使用RotatingFileHandler的emit方法,只传递原始消息 handler.emit(logging.LogRecord( name='', level=getattr(logging, entry.level.upper(), logging.INFO), pathname='', lineno=0, msg=entry.message, args=(), exc_info=None, created=entry.timestamp.timestamp() )) except Exception as e: # 如果文件写入失败,至少输出到控制台 print(f"日志写入失败 {entry.file_path}: {e}") print(f"【{entry.level.upper()}】{entry.timestamp} - {entry.message}") def _batch_writer(self): """ 后台批量写入线程 """ while self._running: try: # 收集一批日志条目 batch = [] end_time = time.time() + log_settings.WRITE_TIMEOUT while len(batch) < log_settings.BATCH_WRITE_SIZE and time.time() < end_time: try: remaining_time = max(0, end_time - time.time()) entry = self._write_queue.get(timeout=remaining_time) batch.append(entry) except queue.Empty: break if batch: self._write_batch(batch) except Exception as e: print(f"批量写入线程错误: {e}") time.sleep(0.1) def _write_batch(self, batch: list): """ 批量写入日志 """ # 按文件分组 file_groups = {} for entry in batch: if entry.file_path not in file_groups: file_groups[entry.file_path] = [] file_groups[entry.file_path].append(entry) # 批量写入每个文件 for file_path, entries in file_groups.items(): try: # 获取RotatingFileHandler handler = self._get_rotating_handler(file_path) # 批量写入 for entry in entries: # 使用RotatingFileHandler的emit方法,只传递原始消息 handler.emit(logging.LogRecord( name='', level=getattr(logging, entry.level.upper(), logging.INFO), pathname='', lineno=0, msg=entry.message, args=(), exc_info=None, created=entry.timestamp.timestamp() )) except Exception as e: print(f"批量写入失败 {file_path}: {e}") # 回退到逐个写入 for entry in entries: self._write_sync(entry) def shutdown(self): """ 关闭文件处理器 """ self._running = False if hasattr(self, '_write_thread'): self._write_thread.join(timeout=5) if self._executor: self._executor.shutdown(wait=True) # 清理缓存 self._rotating_handlers.clear() class LoggerManager: """ 日志管理 """ # 管理所有的 Logger _loggers: Dict[str, Any] = {} # 默认日志文件名称 _default_log_file = "moviepilot.log" # 线程锁 _lock = threading.Lock() # 非阻塞文件处理器 _file_handler = NonBlockingFileHandler() def get_logger(self, name: str) -> logging.Logger: """ 获取一个指定名称的、独立的日志记录器。 创建一个独立的日志文件,例如 'diag_memory.log'。 :param name: 日志记录器的名称,也将用作文件名。 :return: 一个配置好的 logging.Logger 实例。 """ # 使用名称作为日志文件名 logfile = f"{name}.log" with LoggerManager._lock: # 检查是否已经创建过这个 logger _logger = self._loggers.get(logfile) if not _logger: # 如果没有,就使用现有的 __setup_console_logger 来创建一个新的 _logger = self.__setup_console_logger(log_file=logfile) self._loggers[logfile] = _logger return _logger @staticmethod def __get_caller(): """ 获取调用者的文件名称与插件名称 如果是插件调用内置的模块, 也能写入到插件日志文件中 """ # 调用者文件名称 caller_name = None # 调用者插件名称 plugin_name = None try: frame = sys._getframe(3) # noqa except (AttributeError, ValueError): # 如果无法获取帧,返回默认值 return "log.py", None while frame: filepath = Path(frame.f_code.co_filename) parts = filepath.parts # 设定调用者文件名称 if not caller_name: if parts[-1] == "__init__.py" and len(parts) >= 2: caller_name = parts[-2] else: caller_name = parts[-1] # 设定调用者插件名称 if "app" in parts: if not plugin_name and "plugins" in parts: try: plugins_index = parts.index("plugins") if plugins_index + 1 < len(parts): plugin_candidate = parts[plugins_index + 1] if plugin_candidate == "__init__.py": plugin_name = "plugin" else: plugin_name = plugin_candidate break except ValueError: pass if "main.py" in parts: # 已经到达程序的入口,停止遍历 break elif len(parts) != 1: # 已经超出程序范围,停止遍历 break # 获取上一个帧 try: frame = frame.f_back except AttributeError: break return caller_name or "log.py", plugin_name @staticmethod def __setup_console_logger(log_file: str): """ 初始化控制台日志实例(文件输出由 NonBlockingFileHandler 处理) :param log_file:日志文件相对路径 """ log_file_path = log_settings.LOG_PATH / log_file # 创建新实例 _logger = logging.getLogger(log_file_path.stem) # 设置日志级别 _logger.setLevel(LoggerManager.__get_log_level()) # 移除已有的 handler,避免重复添加 for handler in _logger.handlers: _logger.removeHandler(handler) # 只设置终端日志(文件日志由 NonBlockingFileHandler 处理) console_handler = logging.StreamHandler() console_formatter = CustomFormatter(log_settings.LOG_CONSOLE_FORMAT) console_handler.setFormatter(console_formatter) _logger.addHandler(console_handler) # 禁止向父级log传递 _logger.propagate = False return _logger def update_loggers(self): """ 更新日志实例 """ with LoggerManager._lock: for _logger in self._loggers.values(): self.__update_logger_handlers(_logger) @staticmethod def __update_logger_handlers(_logger: logging.Logger): """ 更新 Logger 的 handler 配置 :param _logger: 需要更新的 Logger 实例 """ # 更新现有 handler(只有控制台 handler) for handler in _logger.handlers: try: if isinstance(handler, logging.StreamHandler): # 更新控制台输出格式 console_formatter = CustomFormatter(log_settings.LOG_CONSOLE_FORMAT) handler.setFormatter(console_formatter) except Exception as e: print(f"更新日志处理器失败: {handler}. 错误: {e}") # 更新日志级别 _logger.setLevel(LoggerManager.__get_log_level()) @staticmethod def __get_log_level(): """ 获取当前日志级别 """ return logging.DEBUG if log_settings.DEBUG else getattr(logging, log_settings.LOG_LEVEL.upper(), logging.INFO) def logger(self, method: str, msg: str, *args, **kwargs): """ 获取模块的logger :param method: 日志方法 :param msg: 日志信息 """ # 获取当前日志级别 current_level = self.__get_log_level() method_level = getattr(logging, method.upper(), logging.INFO) # 如果当前方法的级别低于设定的日志级别,则不处理 if method_level < current_level: return # 获取调用者文件名和插件名 caller_name, plugin_name = self.__get_caller() # 格式化消息 formatted_msg = f"{caller_name} - {msg}" if args: try: formatted_msg = formatted_msg % args except (TypeError, ValueError): # 如果格式化失败,直接拼接 formatted_msg = f"{formatted_msg} {' '.join(str(arg) for arg in args)}" # 区分插件日志 if plugin_name: # 使用插件日志文件 logfile = Path("plugins") / f"{plugin_name}.log" else: # 使用默认日志文件 logfile = self._default_log_file # 构建完整的日志文件路径 log_file_path = log_settings.LOG_PATH / logfile # 使用非阻塞文件处理器写入文件日志 self._file_handler.write_log(method.upper(), formatted_msg, log_file_path) # 同时保持控制台输出(使用标准 logging) with LoggerManager._lock: _logger = self._loggers.get(logfile) if not _logger: _logger = self.__setup_console_logger(log_file=logfile) self._loggers[logfile] = _logger # 只在控制台输出,文件写入已由 _file_handler 处理 if hasattr(_logger, method): log_method = getattr(_logger, method) log_method(formatted_msg) def info(self, msg: str, *args, **kwargs): """ 输出信息级别日志 """ self.logger("info", msg, *args, **kwargs) def debug(self, msg: str, *args, **kwargs): """ 输出调试级别日志 """ self.logger("debug", msg, *args, **kwargs) def warning(self, msg: str, *args, **kwargs): """ 输出警告级别日志 """ self.logger("warning", msg, *args, **kwargs) def warn(self, msg: str, *args, **kwargs): """ 输出警告级别日志(兼容) """ self.warning(msg, *args, **kwargs) def error(self, msg: str, *args, **kwargs): """ 输出错误级别日志 """ self.logger("error", msg, *args, **kwargs) def critical(self, msg: str, *args, **kwargs): """ 输出严重错误级别日志 """ self.logger("critical", msg, *args, **kwargs) @classmethod def shutdown(cls): """ 关闭日志管理器,清理资源 """ if cls._file_handler: cls._file_handler.shutdown() # 初始化日志管理 logger = LoggerManager() ================================================ FILE: app/main.py ================================================ import multiprocessing import os import setproctitle import signal import sys import threading import uvicorn as uvicorn from PIL import Image from uvicorn import Config from app.factory import app from app.utils.system import SystemUtils # 禁用输出 if SystemUtils.is_frozen(): sys.stdout = open(os.devnull, 'w') sys.stderr = open(os.devnull, 'w') from app.core.config import settings from app.db.init import init_db, update_db # 设置进程名 setproctitle.setproctitle(settings.PROJECT_NAME) # uvicorn服务 Server = uvicorn.Server(Config(app, host=settings.HOST, port=settings.PORT, reload=settings.DEV, workers=multiprocessing.cpu_count() * 2 + 1, timeout_graceful_shutdown=60)) def start_tray(): """ 启动托盘图标 """ if not SystemUtils.is_frozen(): return if not SystemUtils.is_windows(): return def open_web(): """ 调用浏览器打开前端页面 """ import webbrowser webbrowser.open(f"http://localhost:{settings.NGINX_PORT}") def quit_app(): """ 退出程序 """ TrayIcon.stop() Server.should_exit = True import pystray # 托盘图标 TrayIcon = pystray.Icon( settings.PROJECT_NAME, icon=Image.open(settings.ROOT_PATH / 'app.ico'), menu=pystray.Menu( pystray.MenuItem( '打开', open_web, ), pystray.MenuItem( '退出', quit_app, ) ) ) # 启动托盘图标 threading.Thread(target=TrayIcon.run, daemon=True).start() def signal_handler(signum, frame): """ 信号处理函数,用于优雅停止服务 """ print(f"收到信号 {signum},开始优雅停止服务...") Server.should_exit = True if __name__ == '__main__': # 注册信号处理器 signal.signal(signal.SIGTERM, signal_handler) signal.signal(signal.SIGINT, signal_handler) # 启动托盘 start_tray() # 初始化数据库 init_db() # 更新数据库 update_db() # 启动API服务 Server.run() ================================================ FILE: app/modules/__init__.py ================================================ from abc import abstractmethod, ABCMeta from typing import Generic, Tuple, Union, TypeVar, Type, Dict, Optional, Callable from pathlib import Path from app.helper.service import ServiceConfigHelper from app.schemas import Notification, NotificationConf, MediaServerConf, DownloaderConf from app.schemas.types import ModuleType, DownloaderType, MediaServerType, MessageChannel, StorageSchema, \ OtherModulesType, SystemConfigKey from app.utils.mixins import ConfigReloadMixin class _ModuleBase(ConfigReloadMixin, metaclass=ABCMeta): """ 模块基类,实现对应方法,在有需要时会被自动调用,返回None代表不启用该模块,将继续执行下一模块 输入参数与输出参数一致的,或没有输出的,可以被多个模块重复实现 """ def on_config_changed(self): self.init_module() def get_reload_name(self): return self.get_name() @abstractmethod def init_module(self) -> None: """ 模块初始化 """ pass @abstractmethod def init_setting(self) -> Tuple[str, Union[str, bool]]: """ 模块开关设置,返回开关名和开关值,开关值为True时代表有值即打开,不实现该方法或返回None代表不使用开关 部分模块支持同时开启多个,此时设置项以,分隔,开关值使用in判断 """ pass @staticmethod def get_name() -> str: """ 获取模块名称 """ pass @staticmethod def get_type() -> ModuleType: """ 获取模块类型 """ pass @staticmethod def get_subtype() -> Union[DownloaderType, MediaServerType, MessageChannel, StorageSchema, OtherModulesType]: """ 获取模块子类型(下载器、媒体服务器、消息通道、存储类型、其他杂项模块类型) """ pass @staticmethod def get_priority() -> int: """ 获取模块优先级,数字越小优先级越高,只有同一接口下优先级才生效 """ pass @abstractmethod def stop(self) -> None: """ 如果关闭时模块有服务需要停止,需要实现此方法 :return: None,该方法可被多个模块同时处理 """ pass @abstractmethod def test(self) -> Optional[Tuple[bool, str]]: """ 模块测试, 返回测试结果和错误信息 """ pass # 定义泛型,用于表示具体的服务类型和配置类型 TService = TypeVar("TService", bound=object) TConf = TypeVar("TConf") class ServiceBase(Generic[TService, TConf], metaclass=ABCMeta): """ 抽象服务基类,负责服务的初始化、获取实例和配置管理 """ def __init__(self): """ 初始化 ServiceBase 类的实例 """ self._configs: Optional[Dict[str, TConf]] = None self._instances: Optional[Dict[str, TService]] = None self._service_name: Optional[str] = None def init_service(self, service_name: str, service_type: Optional[Union[Type[TService], Callable[..., TService]]] = None): """ 初始化服务,获取配置并实例化对应服务 :param service_name: 服务名称,作为配置匹配的依据 :param service_type: 服务的类型,可以是类类型(Type[TService])、工厂函数(Callable)或 None 来跳过实例化 """ if not service_name: raise Exception("service_name is null") self._service_name = service_name configs = self.get_configs() if configs is None: return self._configs = configs self._instances = {} if not service_type: return for conf in self._configs.values(): # 通过服务类型或工厂函数来创建实例 if isinstance(service_type, type): # 如果传入的是类类型,调用构造函数实例化 self._instances[conf.name] = service_type(name=conf.name, **conf.config) else: # 如果传入的是工厂函数,直接调用工厂函数 self._instances[conf.name] = service_type(conf) def get_instances(self) -> Dict[str, TService]: """ 获取服务实例列表 :return: 返回服务实例列表 """ return self._instances or {} def get_instance(self, name: Optional[str] = None) -> Optional[TService]: """ 获取指定名称的服务实例 :param name: 实例名称,可选。如果为 None,则返回默认实例 :return: 返回符合条件的服务实例,若不存在则返回 None """ if not self._instances: return None if name: return self._instances.get(name) name = self.get_default_config_name() return self._instances.get(name) if name else None @abstractmethod def get_configs(self) -> Dict[str, TConf]: """ 获取已启用的服务配置字典 :return: 返回配置字典 """ pass def get_config(self, name: Optional[str] = None) -> Optional[TConf]: """ 获取指定名称的服务配置 :param name: 配置名称,可选。如果为 None,则返回默认服务配置 :return: 返回符合条件的配置,若不存在则返回 None """ if not self._configs: return None if name: return self._configs.get(name) name = self.get_default_config_name() return self._configs.get(name) if name else None def get_default_config_name(self) -> Optional[str]: """ 获取默认服务配置的名称 :return: 默认第一个配置的名称 """ # 默认使用第一个配置的名称 first_conf = next(iter(self._configs.values()), None) return first_conf.name if first_conf else None class _MessageBase(ServiceBase[TService, NotificationConf]): """ 消息基类 """ CONFIG_WATCH = {SystemConfigKey.Notifications.value} def __init__(self): """ 初始化消息基类,并设置消息通道 """ super().__init__() self._channel: Optional[MessageChannel] = None def get_configs(self) -> Dict[str, NotificationConf]: """ 获取已启用的消息通知渠道的配置字典 :return: 返回消息通知的配置字典 """ configs = ServiceConfigHelper.get_notification_configs() if not self._service_name: return {} return {conf.name: conf for conf in configs if conf.type == self._service_name and conf.enabled} def check_message(self, message: Notification, source: str = None) -> bool: """ 检查消息渠道及消息类型,判断是否处理消息 :param message: 要检查的通知消息 :param source: 消息来源,可选 :return: 返回布尔值,表示是否处理该消息 """ # 检查消息渠道 if message.channel and message.channel != self._channel: return False # 检查消息来源 if message.source and message.source != source: return False # 不是定向发送时,检查消息类型开关 if not message.userid and message.mtype: conf = self.get_config(source) if conf: switchs = conf.switchs or [] if message.mtype.value not in switchs: return False return True class _DownloaderBase(ServiceBase[TService, DownloaderConf]): """ 下载器基类 """ CONFIG_WATCH = {SystemConfigKey.Downloaders.value} def __init__(self): """ 初始化下载器基类 """ super().__init__() self._default_config_name: Optional[str] = None def init_service(self, service_name: str, service_type: Optional[Union[Type[TService], Callable[..., TService]]] = None): """ 初始化服务,获取配置并实例化对应服务 :param service_name: 服务名称,作为配置匹配的依据 :param service_type: 服务的类型,可以是类类型(Type[TService])、工厂函数(Callable)或 None 来跳过实例化 """ # 重置默认配置名称 self.reset_default_config_name() # 初始化服务 super().init_service(service_name, service_type) def get_default_config_name(self) -> Optional[str]: """ 获取默认服务配置的名称 :return: 优先从所有下载器中查找配置了默认的下载器,如果没有配置,则获取第一个下载器名称 """ # 优先查找默认配置 if self._default_config_name: return self._default_config_name configs = ServiceConfigHelper.get_downloader_configs() for conf in configs: if conf.default: self._default_config_name = conf.name return self._default_config_name # 如果没有默认配置,返回第一个配置的名称 first_conf = next(iter(configs), None) self._default_config_name = first_conf.name if first_conf else None return self._default_config_name def get_configs(self) -> Dict[str, DownloaderConf]: """ 获取已启用的下载器的配置字典 :return: 返回下载器配置字典 """ configs = ServiceConfigHelper.get_downloader_configs() if not self._service_name: return {} return {conf.name: conf for conf in configs if conf.type == self._service_name and conf.enabled} def reset_default_config_name(self): """ 重置默认配置名称 """ self._default_config_name = None def normalize_path(self, path: Path, downloader: Optional[str]) -> str: """ 根据下载器配置和路径映射,规范化下载路径 :param path: 存储路径 :param downloader: 下载器名称 :return: 规范化后发送给下载器的路径 """ dir = path.as_posix() conf = self.get_config(downloader) if conf and conf.path_mapping: for (storage_path, download_path) in conf.path_mapping: storage_path = Path(storage_path.strip()).as_posix() download_path = Path(download_path.strip()).as_posix() if dir.startswith(storage_path): dir = dir.replace(storage_path, download_path, 1) break # 去掉存储协议前缀 if any, 下载器无法识别 for s in StorageSchema: prefix = f"{s.value}:" if dir.startswith(prefix): return dir[len(prefix):] return dir class _MediaServerBase(ServiceBase[TService, MediaServerConf]): """ 媒体服务器基类 """ CONFIG_WATCH = {SystemConfigKey.MediaServers.value} def get_configs(self) -> Dict[str, MediaServerConf]: """ 获取已启用的媒体服务器的配置字典 :return: 返回媒体服务器配置字典 """ configs = ServiceConfigHelper.get_mediaserver_configs() if not self._service_name: return {} return {conf.name: conf for conf in configs if conf.type == self._service_name and conf.enabled} ================================================ FILE: app/modules/bangumi/__init__.py ================================================ from typing import List, Optional, Tuple, Union from app import schemas from app.core.config import settings from app.core.context import MediaInfo from app.core.meta import MetaBase from app.log import logger from app.modules import _ModuleBase from app.modules.bangumi.bangumi import BangumiApi from app.schemas.types import ModuleType, MediaRecognizeType from app.utils.http import RequestUtils class BangumiModule(_ModuleBase): bangumiapi: BangumiApi = None def init_module(self) -> None: self.bangumiapi = BangumiApi() def stop(self): self.bangumiapi.close() def test(self) -> Tuple[bool, str]: """ 测试模块连接性 """ ret = RequestUtils().get_res("https://api.bgm.tv/") if ret and ret.status_code == 200: return True, "" elif ret: return False, f"无法连接Bangumi,错误码:{ret.status_code}" return False, "Bangumi网络连接失败" def init_setting(self) -> Tuple[str, Union[str, bool]]: pass @staticmethod def get_name() -> str: return "Bangumi" @staticmethod def get_type() -> ModuleType: """ 获取模块类型 """ return ModuleType.MediaRecognize @staticmethod def get_subtype() -> MediaRecognizeType: """ 获取模块子类型 """ return MediaRecognizeType.Bangumi @staticmethod def get_priority() -> int: """ 获取模块优先级,数字越小优先级越高,只有同一接口下优先级才生效 """ return 3 def recognize_media(self, bangumiid: int = None, **kwargs) -> Optional[MediaInfo]: """ 识别媒体信息 :param bangumiid: 识别的Bangumi ID :return: 识别的媒体信息,包括剧集信息 """ if not bangumiid: return None # 直接查询详情 info = self.bangumi_info(bangumiid=bangumiid) if info: # 赋值TMDB信息并返回 mediainfo = MediaInfo(bangumi_info=info) logger.info(f"{bangumiid} Bangumi识别结果:{mediainfo.type.value} " f"{mediainfo.title_year}") return mediainfo else: logger.info(f"{bangumiid} 未匹配到Bangumi媒体信息") return None async def async_recognize_media(self, bangumiid: int = None, **kwargs) -> Optional[MediaInfo]: """ 识别媒体信息(异步版本) :param bangumiid: 识别的Bangumi ID :return: 识别的媒体信息,包括剧集信息 """ if not bangumiid: return None # 直接查询详情 info = await self.async_bangumi_info(bangumiid=bangumiid) if info: # 赋值TMDB信息并返回 mediainfo = MediaInfo(bangumi_info=info) logger.info(f"{bangumiid} Bangumi识别结果:{mediainfo.type.value} " f"{mediainfo.title_year}") return mediainfo else: logger.info(f"{bangumiid} 未匹配到Bangumi媒体信息") return None def search_medias(self, meta: MetaBase) -> Optional[List[MediaInfo]]: """ 搜索媒体信息 :param meta: 识别的元数据 :reutrn: 媒体信息 """ if settings.SEARCH_SOURCE and "bangumi" not in settings.SEARCH_SOURCE: return None if not meta.name: return [] infos = self.bangumiapi.search(meta.name) if infos: return [MediaInfo(bangumi_info=info) for info in infos if meta.name.lower() in str(info.get("name")).lower() or meta.name.lower() in str(info.get("name_cn")).lower()] return [] async def async_search_medias(self, meta: MetaBase) -> Optional[List[MediaInfo]]: """ 搜索媒体信息(异步版本) :param meta: 识别的元数据 :reutrn: 媒体信息 """ if settings.SEARCH_SOURCE and "bangumi" not in settings.SEARCH_SOURCE: return None if not meta.name: return [] infos = await self.bangumiapi.async_search(meta.name) if infos: return [MediaInfo(bangumi_info=info) for info in infos if meta.name.lower() in str(info.get("name")).lower() or meta.name.lower() in str(info.get("name_cn")).lower()] return [] def bangumi_info(self, bangumiid: int) -> Optional[dict]: """ 获取Bangumi信息 :param bangumiid: BangumiID :return: Bangumi信息 """ if not bangumiid: return None logger.info(f"开始获取Bangumi信息:{bangumiid} ...") return self.bangumiapi.detail(bangumiid) async def async_bangumi_info(self, bangumiid: int) -> Optional[dict]: """ 获取Bangumi信息(异步版本) :param bangumiid: BangumiID :return: Bangumi信息 """ if not bangumiid: return None logger.info(f"开始获取Bangumi信息:{bangumiid} ...") return await self.bangumiapi.async_detail(bangumiid) def bangumi_calendar(self) -> Optional[List[MediaInfo]]: """ 获取Bangumi每日放送 """ infos = self.bangumiapi.calendar() if infos: return [MediaInfo(bangumi_info=info) for info in infos] return [] async def async_bangumi_calendar(self) -> Optional[List[MediaInfo]]: """ 获取Bangumi每日放送(异步版本) """ infos = await self.bangumiapi.async_calendar() if infos: return [MediaInfo(bangumi_info=info) for info in infos] return [] def bangumi_credits(self, bangumiid: int) -> List[schemas.MediaPerson]: """ 根据TMDBID查询电影演职员表 :param bangumiid: BangumiID """ persons = self.bangumiapi.credits(bangumiid) if persons: return [schemas.MediaPerson(source='bangumi', **person) for person in persons] return [] async def async_bangumi_credits(self, bangumiid: int) -> List[schemas.MediaPerson]: """ 根据TMDBID查询电影演职员表(异步版本) :param bangumiid: BangumiID """ persons = await self.bangumiapi.async_credits(bangumiid) if persons: return [schemas.MediaPerson(source='bangumi', **person) for person in persons] return [] def bangumi_recommend(self, bangumiid: int) -> List[MediaInfo]: """ 根据BangumiID查询推荐电影 :param bangumiid: BangumiID """ subjects = self.bangumiapi.subjects(bangumiid) if subjects: return [MediaInfo(bangumi_info=subject) for subject in subjects] return [] async def async_bangumi_recommend(self, bangumiid: int) -> List[MediaInfo]: """ 根据BangumiID查询推荐电影(异步版本) :param bangumiid: BangumiID """ subjects = await self.bangumiapi.async_subjects(bangumiid) if subjects: return [MediaInfo(bangumi_info=subject) for subject in subjects] return [] def bangumi_person_detail(self, person_id: int) -> Optional[schemas.MediaPerson]: """ 获取人物详细信息 :param person_id: 豆瓣人物ID """ personinfo = self.bangumiapi.person_detail(person_id) if personinfo: return schemas.MediaPerson(source='bangumi', **{ "id": personinfo.get("id"), "name": personinfo.get("name"), "images": personinfo.get("images"), "biography": personinfo.get("summary"), "birthday": personinfo.get("birth_day"), "gender": personinfo.get("gender") }) return None async def async_bangumi_person_detail(self, person_id: int) -> Optional[schemas.MediaPerson]: """ 获取人物详细信息(异步版本) :param person_id: 豆瓣人物ID """ personinfo = await self.bangumiapi.async_person_detail(person_id) if personinfo: return schemas.MediaPerson(source='bangumi', **{ "id": personinfo.get("id"), "name": personinfo.get("name"), "images": personinfo.get("images"), "biography": personinfo.get("summary"), "birthday": personinfo.get("birth_day"), "gender": personinfo.get("gender") }) return None def bangumi_person_credits(self, person_id: int) -> List[MediaInfo]: """ 根据TMDBID查询人物参演作品 :param person_id: 人物ID """ credits_info = self.bangumiapi.person_credits(person_id=person_id) if credits_info: return [MediaInfo(bangumi_info=credit) for credit in credits_info] return [] async def async_bangumi_person_credits(self, person_id: int) -> List[MediaInfo]: """ 根据TMDBID查询人物参演作品(异步版本) :param person_id: 人物ID """ credits_info = await self.bangumiapi.async_person_credits(person_id=person_id) if credits_info: return [MediaInfo(bangumi_info=credit) for credit in credits_info] return [] def bangumi_discover(self, **kwargs) -> Optional[List[MediaInfo]]: """ 发现Bangumi番剧 """ infos = self.bangumiapi.discover(**kwargs) if infos: return [MediaInfo(bangumi_info=info) for info in infos] return [] async def async_bangumi_discover(self, **kwargs) -> Optional[List[MediaInfo]]: """ 发现Bangumi番剧(异步版本) """ infos = await self.bangumiapi.async_discover(**kwargs) if infos: return [MediaInfo(bangumi_info=info) for info in infos] return [] def clear_cache(self): """ 清除缓存 """ logger.info(f"开始清除{self.get_name()}缓存 ...") self.bangumiapi.clear_cache() logger.info(f"{self.get_name()}缓存清除完成") ================================================ FILE: app/modules/bangumi/bangumi.py ================================================ from datetime import datetime from typing import Optional import requests from app.core.cache import cached from app.core.config import settings from app.utils.http import RequestUtils, AsyncRequestUtils class BangumiApi(object): """ https://bangumi.github.io/api/ """ _urls = { "discover": "v0/subjects", "search": "search/subjects/%s?type=2", "calendar": "calendar", "detail": "v0/subjects/%s", "credits": "v0/subjects/%s/persons", "subjects": "v0/subjects/%s/subjects", "characters": "v0/subjects/%s/characters", "person_detail": "v0/persons/%s", "person_credits": "v0/persons/%s/subjects", } _base_url = "https://api.bgm.tv/" def __init__(self): self._session = requests.Session() self._req = RequestUtils(ua=settings.NORMAL_USER_AGENT, session=self._session) self._async_req = AsyncRequestUtils(ua=settings.NORMAL_USER_AGENT) @cached(maxsize=settings.CONF.bangumi, ttl=settings.CONF.meta, shared_key="get") def __invoke(self, url, key: Optional[str] = None, **kwargs): req_url = self._base_url + url params = {} if kwargs: params.update(kwargs) resp = self._req.get_res(url=req_url, params=params) try: if not resp: return None result = resp.json() return result.get(key) if key else result except Exception as e: print(e) return None @cached(maxsize=settings.CONF.bangumi, ttl=settings.CONF.meta, shared_key="get") async def __async_invoke(self, url, key: Optional[str] = None, **kwargs): req_url = self._base_url + url params = {} if kwargs: params.update(kwargs) resp = await self._async_req.get_res(url=req_url, params=params) try: if not resp: return None result = resp.json() return result.get(key) if key else result except Exception as e: print(e) return None def search(self, name): """ 搜索媒体信息 """ result = self.__invoke("search/subject/%s" % name) if result: return result.get("list") return [] async def async_search(self, name): """ 搜索媒体信息(异步版本) """ result = await self.__async_invoke("search/subject/%s" % name) if result: return result.get("list") return [] def calendar(self): """ 获取每日放送,返回items """ """ [ { "weekday": { "en": "Mon", "cn": "星期一", "ja": "月耀日", "id": 1 }, "items": [ { "id": 350235, "url": "http://bgm.tv/subject/350235", "type": 2, "name": "月が導く異世界道中 第二幕", "name_cn": "月光下的异世界之旅 第二幕", "summary": "", "air_date": "2024-01-08", "air_weekday": 1, "rating": { "total": 257, "count": { "1": 1, "2": 1, "3": 4, "4": 15, "5": 51, "6": 111, "7": 49, "8": 13, "9": 5, "10": 7 }, "score": 6.1 }, "rank": 6125, "images": { "large": "http://lain.bgm.tv/pic/cover/l/3c/a5/350235_A0USf.jpg", "common": "http://lain.bgm.tv/pic/cover/c/3c/a5/350235_A0USf.jpg", "medium": "http://lain.bgm.tv/pic/cover/m/3c/a5/350235_A0USf.jpg", "small": "http://lain.bgm.tv/pic/cover/s/3c/a5/350235_A0USf.jpg", "grid": "http://lain.bgm.tv/pic/cover/g/3c/a5/350235_A0USf.jpg" }, "collection": { "doing": 920 } }, { "id": 358561, "url": "http://bgm.tv/subject/358561", "type": 2, "name": "大宇宙时代", "name_cn": "大宇宙时代", "summary": "", "air_date": "2024-01-22", "air_weekday": 1, "rating": { "total": 2, "count": { "1": 0, "2": 0, "3": 0, "4": 0, "5": 1, "6": 1, "7": 0, "8": 0, "9": 0, "10": 0 }, "score": 5.5 }, "images": { "large": "http://lain.bgm.tv/pic/cover/l/71/66/358561_UzsLu.jpg", "common": "http://lain.bgm.tv/pic/cover/c/71/66/358561_UzsLu.jpg", "medium": "http://lain.bgm.tv/pic/cover/m/71/66/358561_UzsLu.jpg", "small": "http://lain.bgm.tv/pic/cover/s/71/66/358561_UzsLu.jpg", "grid": "http://lain.bgm.tv/pic/cover/g/71/66/358561_UzsLu.jpg" }, "collection": { "doing": 9 } } ] } ] """ ret_list = [] result = self.__invoke(self._urls["calendar"], _ts=datetime.strftime(datetime.now(), '%Y%m%d')) if result: for item in result: ret_list.extend(item.get("items") or []) return ret_list async def async_calendar(self): """ 获取每日放送,返回items(异步版本) """ ret_list = [] result = await self.__async_invoke(self._urls["calendar"], _ts=datetime.strftime(datetime.now(), '%Y%m%d')) if result: for item in result: ret_list.extend(item.get("items") or []) return ret_list def detail(self, bid: int): """ 获取番剧详情 """ return self.__invoke(self._urls["detail"] % bid, _ts=datetime.strftime(datetime.now(), '%Y%m%d')) async def async_detail(self, bid: int): """ 获取番剧详情(异步版本) """ return await self.__async_invoke(self._urls["detail"] % bid, _ts=datetime.strftime(datetime.now(), '%Y%m%d')) def credits(self, bid: int): """ 获取番剧人物 """ ret_list = [] result = self.__invoke(self._urls["characters"] % bid, _ts=datetime.strftime(datetime.now(), '%Y%m%d')) if result: for item in result: character_id = item.get("id") actors = item.get("actors") if character_id and actors and actors[0]: actor_info = actors[0] actor_info.update({'career': [item.get('name')]}) ret_list.append(actor_info) return ret_list async def async_credits(self, bid: int): """ 获取番剧人物(异步版本) """ ret_list = [] result = await self.__async_invoke(self._urls["characters"] % bid, _ts=datetime.strftime(datetime.now(), '%Y%m%d')) if result: for item in result: character_id = item.get("id") actors = item.get("actors") if character_id and actors and actors[0]: actor_info = actors[0] actor_info.update({'career': [item.get('name')]}) ret_list.append(actor_info) return ret_list def subjects(self, bid: int): """ 获取关联条目信息 """ return self.__invoke(self._urls["subjects"] % bid, _ts=datetime.strftime(datetime.now(), '%Y%m%d')) async def async_subjects(self, bid: int): """ 获取关联条目信息(异步版本) """ return await self.__async_invoke(self._urls["subjects"] % bid, _ts=datetime.strftime(datetime.now(), '%Y%m%d')) def person_detail(self, person_id: int): """ 获取人物详细信息 """ return self.__invoke(self._urls["person_detail"] % person_id, _ts=datetime.strftime(datetime.now(), '%Y%m%d')) async def async_person_detail(self, person_id: int): """ 获取人物详细信息(异步版本) """ return await self.__async_invoke(self._urls["person_detail"] % person_id, _ts=datetime.strftime(datetime.now(), '%Y%m%d')) def person_credits(self, person_id: int): """ 获取人物参演作品 """ ret_list = [] result = self.__invoke(self._urls["person_credits"] % person_id, _ts=datetime.strftime(datetime.now(), '%Y%m%d')) if result: for item in result: ret_list.append(item) return ret_list async def async_person_credits(self, person_id: int): """ 获取人物参演作品(异步版本) """ ret_list = [] result = await self.__async_invoke(self._urls["person_credits"] % person_id, _ts=datetime.strftime(datetime.now(), '%Y%m%d')) if result: for item in result: ret_list.append(item) return ret_list def discover(self, **kwargs): """ 发现 """ return self.__invoke(self._urls["discover"], key="data", _ts=datetime.strftime(datetime.now(), '%Y%m%d'), **kwargs) async def async_discover(self, **kwargs): """ 发现(异步版本) """ return await self.__async_invoke(self._urls["discover"], key="data", _ts=datetime.strftime(datetime.now(), '%Y%m%d'), **kwargs) def clear_cache(self): """ 清除缓存 """ self.__invoke.cache_clear() def close(self): if self._session: self._session.close() ================================================ FILE: app/modules/discord/__init__.py ================================================ import json from typing import Optional, Union, List, Tuple, Any from app.core.context import MediaInfo, Context from app.log import logger from app.modules import _ModuleBase, _MessageBase from app.schemas import MessageChannel, CommingMessage, Notification from app.schemas.types import ModuleType try: from app.modules.discord.discord import Discord except Exception as err: # ImportError or other load issues Discord = None logger.error(f"Discord 模块未加载,缺少依赖或初始化错误:{err}") class DiscordModule(_ModuleBase, _MessageBase[Discord]): def init_module(self) -> None: """ 初始化模块 """ if not Discord: logger.error("Discord 依赖未就绪(需要安装 discord.py==2.6.4),模块未启动") return self.stop() super().init_service(service_name=Discord.__name__.lower(), service_type=Discord) self._channel = MessageChannel.Discord @staticmethod def get_name() -> str: return "Discord" @staticmethod def get_type() -> ModuleType: """ 获取模块类型 """ return ModuleType.Notification @staticmethod def get_subtype() -> MessageChannel: """ 获取模块子类型 """ return MessageChannel.Discord @staticmethod def get_priority() -> int: """ 获取模块优先级,数字越小优先级越高,只有同一接口下优先级才生效 """ return 4 def stop(self): """ 停止模块 """ for client in self.get_instances().values(): client.stop() def test(self) -> Optional[Tuple[bool, str]]: """ 测试模块连接性 """ if not self.get_instances(): return None for name, client in self.get_instances().items(): state = client.get_state() if not state: return False, f"Discord {name} Bot 未就绪" return True, "" def init_setting(self) -> Tuple[str, Union[str, bool]]: pass def message_parser(self, source: str, body: Any, form: Any, args: Any) -> Optional[CommingMessage]: """ 解析消息内容,返回字典,注意以下约定值: userid: 用户ID username: 用户名 text: 内容 :param source: 消息来源 :param body: 请求体 :param form: 表单 :param args: 参数 :return: 渠道、消息体 """ client_config = self.get_config(source) if not client_config: return None try: msg_json: dict = json.loads(body) except Exception as e: logger.debug(f"解析 Discord 消息失败:{str(e)}") return None if not msg_json: return None msg_type = msg_json.get("type") userid = msg_json.get("userid") username = msg_json.get("username") if msg_type == "interaction": callback_data = msg_json.get("callback_data") message_id = msg_json.get("message_id") chat_id = msg_json.get("chat_id") if callback_data and userid: logger.info(f"收到来自 {client_config.name} 的 Discord 按钮回调:" f"userid={userid}, username={username}, callback_data={callback_data}") return CommingMessage( channel=MessageChannel.Discord, source=client_config.name, userid=userid, username=username, text=f"CALLBACK:{callback_data}", is_callback=True, callback_data=callback_data, message_id=message_id, chat_id=str(chat_id) if chat_id else None ) return None if msg_type == "message": text = msg_json.get("text") chat_id = msg_json.get("chat_id") if text and userid: logger.info(f"收到来自 {client_config.name} 的 Discord 消息:" f"userid={userid}, username={username}, text={text}") return CommingMessage(channel=MessageChannel.Discord, source=client_config.name, userid=userid, username=username, text=text, chat_id=str(chat_id) if chat_id else None) return None def post_message(self, message: Notification, **kwargs) -> None: """ 发送通知消息 :param message: 消息通知对象 """ # DEBUG: Log entry and configs configs = self.get_configs() logger.debug(f"[Discord] post_message 被调用,message.source={message.source}, " f"message.userid={message.userid}, message.channel={message.channel}") logger.debug(f"[Discord] 当前配置数量: {len(configs)}, 配置名称: {list(configs.keys())}") logger.debug(f"[Discord] 当前实例数量: {len(self.get_instances())}, 实例名称: {list(self.get_instances().keys())}") if not configs: logger.warning("[Discord] get_configs() 返回空,没有可用的 Discord 配置") return for conf in configs.values(): logger.debug(f"[Discord] 检查配置: name={conf.name}, type={conf.type}, enabled={conf.enabled}") if not self.check_message(message, conf.name): logger.debug(f"[Discord] check_message 返回 False,跳过配置: {conf.name}") continue logger.debug(f"[Discord] check_message 通过,准备发送到: {conf.name}") targets = message.targets userid = message.userid if not userid and targets is not None: userid = targets.get('discord_userid') if not userid: logger.warn("用户没有指定 Discord 用户ID,消息无法发送") return client: Discord = self.get_instance(conf.name) logger.debug(f"[Discord] get_instance('{conf.name}') 返回: {client is not None}") if client: logger.debug(f"[Discord] 调用 client.send_msg, userid={userid}, title={message.title[:50] if message.title else None}...") result = client.send_msg(title=message.title, text=message.text, image=message.image, userid=userid, link=message.link, buttons=message.buttons, original_message_id=message.original_message_id, original_chat_id=message.original_chat_id, mtype=message.mtype) logger.debug(f"[Discord] send_msg 返回结果: {result}") else: logger.warning(f"[Discord] 未找到配置 '{conf.name}' 对应的 Discord 客户端实例") def post_medias_message(self, message: Notification, medias: List[MediaInfo]) -> None: """ 发送媒体信息选择列表 :param message: 消息体 :param medias: 媒体信息 :return: 成功或失败 """ for conf in self.get_configs().values(): if not self.check_message(message, conf.name): continue client: Discord = self.get_instance(conf.name) if client: client.send_medias_msg(title=message.title, medias=medias, userid=message.userid, buttons=message.buttons, original_message_id=message.original_message_id, original_chat_id=message.original_chat_id) def post_torrents_message(self, message: Notification, torrents: List[Context]) -> None: """ 发送种子信息选择列表 :param message: 消息体 :param torrents: 种子信息 :return: 成功或失败 """ for conf in self.get_configs().values(): if not self.check_message(message, conf.name): continue client: Discord = self.get_instance(conf.name) if client: client.send_torrents_msg(title=message.title, torrents=torrents, userid=message.userid, buttons=message.buttons, original_message_id=message.original_message_id, original_chat_id=message.original_chat_id) def delete_message(self, channel: MessageChannel, source: str, message_id: str, chat_id: Optional[str] = None) -> bool: """ 删除消息 :param channel: 消息渠道 :param source: 指定的消息源 :param message_id: 消息ID(Slack中为时间戳) :param chat_id: 聊天ID(频道ID) :return: 删除是否成功 """ success = False for conf in self.get_configs().values(): if channel != self._channel: break if source != conf.name: continue client: Discord = self.get_instance(conf.name) if client: result = client.delete_msg(message_id=message_id, chat_id=chat_id) if result: success = True return success ================================================ FILE: app/modules/discord/discord.py ================================================ import asyncio import re import threading from typing import Optional, List, Dict, Any, Tuple, Union from urllib.parse import quote import discord from discord import app_commands import httpx from app.core.config import settings from app.core.context import MediaInfo, Context from app.core.metainfo import MetaInfo from app.log import logger from app.schemas.types import NotificationType from app.utils.string import StringUtils # Discord embed 字段解析白名单 # 只有这些消息类型会使用复杂的字段解析逻辑 PARSE_FIELD_TYPES = { NotificationType.Download, # 资源下载 NotificationType.Organize, # 整理入库 NotificationType.Subscribe, # 订阅 NotificationType.Manual, # 手动处理 } class Discord: """ Discord Bot 通知与交互实现(基于 discord.py 2.6.4) """ def __init__(self, DISCORD_BOT_TOKEN: Optional[str] = None, DISCORD_GUILD_ID: Optional[Union[str, int]] = None, DISCORD_CHANNEL_ID: Optional[Union[str, int]] = None, **kwargs): logger.debug(f"[Discord] 初始化 Discord 实例: name={kwargs.get('name')}, " f"GUILD_ID={DISCORD_GUILD_ID}, CHANNEL_ID={DISCORD_CHANNEL_ID}, " f"TOKEN={'已配置' if DISCORD_BOT_TOKEN else '未配置'}") if not DISCORD_BOT_TOKEN: logger.error("Discord Bot Token 未配置!") return self._token = DISCORD_BOT_TOKEN self._guild_id = self._to_int(DISCORD_GUILD_ID) self._channel_id = self._to_int(DISCORD_CHANNEL_ID) logger.debug(f"[Discord] 解析后的 ID: _guild_id={self._guild_id}, _channel_id={self._channel_id}") base_ds_url = f"http://127.0.0.1:{settings.PORT}/api/v1/message/" self._ds_url = f"{base_ds_url}?token={settings.API_TOKEN}" if kwargs.get("name"): # URL encode the source name to handle special characters in config names encoded_name = quote(kwargs.get('name'), safe='') self._ds_url = f"{self._ds_url}&source={encoded_name}" logger.debug(f"[Discord] 消息回调 URL: {self._ds_url}") intents = discord.Intents.default() intents.message_content = True intents.messages = True intents.guilds = True self._client: Optional[discord.Client] = discord.Client( intents=intents, proxy=settings.PROXY_HOST ) self._tree: Optional[app_commands.CommandTree] = None self._loop: asyncio.AbstractEventLoop = asyncio.new_event_loop() self._thread: Optional[threading.Thread] = None self._ready_event = threading.Event() self._user_dm_cache: Dict[str, discord.DMChannel] = {} self._user_chat_mapping: Dict[str, str] = {} # userid -> chat_id mapping for reply targeting self._broadcast_channel = None self._bot_user_id: Optional[int] = None self._register_events() self._start() @staticmethod def _to_int(val: Optional[Union[str, int]]) -> Optional[int]: try: return int(val) if val is not None and str(val).strip() else None except ValueError: return None def _register_events(self): @self._client.event async def on_ready(): self._bot_user_id = self._client.user.id if self._client.user else None self._ready_event.set() logger.info(f"Discord Bot 已登录:{self._client.user}") @self._client.event async def on_message(message: discord.Message): if message.author.bot: return if not self._should_process_message(message): return # Update user-chat mapping for reply targeting self._update_user_chat_mapping(str(message.author.id), str(message.channel.id)) cleaned_text = self._clean_bot_mention(message.content or "") username = message.author.display_name or message.author.global_name or message.author.name payload = { "type": "message", "userid": str(message.author.id), "username": username, "user_tag": str(message.author), "text": cleaned_text, "message_id": str(message.id), "chat_id": str(message.channel.id), "channel_type": "dm" if isinstance(message.channel, discord.DMChannel) else "guild" } await self._post_to_ds(payload) @self._client.event async def on_interaction(interaction: discord.Interaction): if interaction.type == discord.InteractionType.component: data = interaction.data or {} callback_data = data.get("custom_id") if not callback_data: return try: await interaction.response.defer(ephemeral=True) except Exception as e: logger.error(f"处理 Discord 交互响应失败:{e}") # Update user-chat mapping for reply targeting if interaction.user and interaction.channel: self._update_user_chat_mapping(str(interaction.user.id), str(interaction.channel.id)) username = (interaction.user.display_name or interaction.user.global_name or interaction.user.name) \ if interaction.user else None payload = { "type": "interaction", "userid": str(interaction.user.id) if interaction.user else None, "username": username, "user_tag": str(interaction.user) if interaction.user else None, "callback_data": callback_data, "message_id": str(interaction.message.id) if interaction.message else None, "chat_id": str(interaction.channel.id) if interaction.channel else None } await self._post_to_ds(payload) def _start(self): if self._thread: return def runner(): asyncio.set_event_loop(self._loop) try: self._loop.create_task(self._client.start(self._token)) self._loop.run_forever() except Exception as err: logger.error(f"Discord Bot 启动失败:{err}") finally: try: self._loop.run_until_complete(self._client.close()) except Exception as err: logger.debug(f"Discord Bot 关闭失败:{err}") self._thread = threading.Thread(target=runner, daemon=True) self._thread.start() def stop(self): if not self._client or not self._loop or not self._thread: return try: asyncio.run_coroutine_threadsafe(self._client.close(), self._loop).result(timeout=10) except Exception as err: logger.error(f"关闭 Discord Bot 失败:{err}") finally: try: self._loop.call_soon_threadsafe(self._loop.stop) except Exception as err: logger.error(f"停止 Discord 事件循环失败:{err}") self._ready_event.clear() def get_state(self) -> bool: return self._ready_event.is_set() and self._client is not None def send_msg(self, title: str, text: Optional[str] = None, image: Optional[str] = None, userid: Optional[str] = None, link: Optional[str] = None, buttons: Optional[List[List[dict]]] = None, original_message_id: Optional[Union[int, str]] = None, original_chat_id: Optional[str] = None, mtype: Optional['NotificationType'] = None) -> Optional[bool]: logger.debug(f"[Discord] send_msg 被调用: userid={userid}, title={title[:50] if title else None}...") logger.debug(f"[Discord] get_state() = {self.get_state()}, " f"_ready_event.is_set() = {self._ready_event.is_set()}, " f"_client = {self._client is not None}") if not self.get_state(): logger.warning("[Discord] get_state() 返回 False,Bot 未就绪,无法发送消息") return False if not title and not text: logger.warn("标题和内容不能同时为空") return False try: logger.debug(f"[Discord] 准备异步发送消息...") future = asyncio.run_coroutine_threadsafe( self._send_message(title=title, text=text, image=image, userid=userid, link=link, buttons=buttons, original_message_id=original_message_id, original_chat_id=original_chat_id, mtype=mtype), self._loop) result = future.result(timeout=30) logger.debug(f"[Discord] 异步发送完成,结果: {result}") return result except Exception as err: logger.error(f"发送 Discord 消息失败:{err}") return False def send_medias_msg(self, medias: List[MediaInfo], userid: Optional[str] = None, title: Optional[str] = None, buttons: Optional[List[List[dict]]] = None, original_message_id: Optional[Union[int, str]] = None, original_chat_id: Optional[str] = None) -> Optional[bool]: if not self.get_state() or not medias: return False title = title or "媒体列表" try: future = asyncio.run_coroutine_threadsafe( self._send_list_message( embeds=self._build_media_embeds(medias, title), userid=userid, buttons=self._build_default_buttons(len(medias)) if not buttons else buttons, fallback_buttons=buttons, original_message_id=original_message_id, original_chat_id=original_chat_id ), self._loop ) return future.result(timeout=30) except Exception as err: logger.error(f"发送 Discord 媒体列表失败:{err}") return False def send_torrents_msg(self, torrents: List[Context], userid: Optional[str] = None, title: Optional[str] = None, buttons: Optional[List[List[dict]]] = None, original_message_id: Optional[Union[int, str]] = None, original_chat_id: Optional[str] = None) -> Optional[bool]: if not self.get_state() or not torrents: return False title = title or "种子列表" try: future = asyncio.run_coroutine_threadsafe( self._send_list_message( embeds=self._build_torrent_embeds(torrents, title), userid=userid, buttons=self._build_default_buttons(len(torrents)) if not buttons else buttons, fallback_buttons=buttons, original_message_id=original_message_id, original_chat_id=original_chat_id ), self._loop ) return future.result(timeout=30) except Exception as err: logger.error(f"发送 Discord 种子列表失败:{err}") return False def delete_msg(self, message_id: Union[str, int], chat_id: Optional[str] = None) -> Optional[bool]: if not self.get_state(): return False try: future = asyncio.run_coroutine_threadsafe( self._delete_message(message_id=message_id, chat_id=chat_id), self._loop ) return future.result(timeout=15) except Exception as err: logger.error(f"删除 Discord 消息失败:{err}") return False async def _send_message(self, title: str, text: Optional[str], image: Optional[str], userid: Optional[str], link: Optional[str], buttons: Optional[List[List[dict]]], original_message_id: Optional[Union[int, str]], original_chat_id: Optional[str], mtype: Optional['NotificationType'] = None) -> bool: logger.debug(f"[Discord] _send_message: userid={userid}, original_chat_id={original_chat_id}") channel = await self._resolve_channel(userid=userid, chat_id=original_chat_id) logger.debug(f"[Discord] _resolve_channel 返回: {channel}, type={type(channel)}") if not channel: logger.error("未找到可用的 Discord 频道或私聊") return False embed = self._build_embed(title=title, text=text, image=image, link=link, mtype=mtype) view = self._build_view(buttons=buttons, link=link) content = None if original_message_id and original_chat_id: logger.debug(f"[Discord] 编辑现有消息: message_id={original_message_id}") return await self._edit_message(chat_id=original_chat_id, message_id=original_message_id, content=content, embed=embed, view=view) logger.debug(f"[Discord] 发送新消息到频道: {channel}") try: await channel.send(content=content, embed=embed, view=view) logger.debug("[Discord] 消息发送成功") return True except Exception as e: logger.error(f"[Discord] 发送消息到频道失败: {e}") return False async def _send_list_message(self, embeds: List[discord.Embed], userid: Optional[str], buttons: Optional[List[List[dict]]], fallback_buttons: Optional[List[List[dict]]], original_message_id: Optional[Union[int, str]], original_chat_id: Optional[str]) -> bool: channel = await self._resolve_channel(userid=userid, chat_id=original_chat_id) if not channel: logger.error("未找到可用的 Discord 频道或私聊") return False view = self._build_view(buttons=buttons if buttons else fallback_buttons) embeds = embeds[:10] if embeds else [] # Discord 单条消息最多 10 个 embed if original_message_id and original_chat_id: return await self._edit_message(chat_id=original_chat_id, message_id=original_message_id, content=None, embed=None, view=view, embeds=embeds) await channel.send(embed=embeds[0] if len(embeds) == 1 else None, embeds=embeds if len(embeds) > 1 else None, view=view) return True async def _edit_message(self, chat_id: Union[str, int], message_id: Union[str, int], content: Optional[str], embed: Optional[discord.Embed], view: Optional[discord.ui.View], embeds: Optional[List[discord.Embed]] = None) -> bool: channel = await self._resolve_channel(chat_id=str(chat_id)) if not channel: logger.error(f"未找到要编辑的 Discord 频道:{chat_id}") return False try: message = await channel.fetch_message(int(message_id)) kwargs: Dict[str, Any] = {"content": content, "view": view} if embeds: if len(embeds) == 1: kwargs["embed"] = embeds[0] else: kwargs["embeds"] = embeds elif embed: kwargs["embed"] = embed await message.edit(**kwargs) return True except Exception as err: logger.error(f"编辑 Discord 消息失败:{err}") return False async def _delete_message(self, message_id: Union[str, int], chat_id: Optional[str]) -> bool: channel = await self._resolve_channel(chat_id=chat_id) if not channel: logger.error("删除 Discord 消息时未找到频道") return False try: message = await channel.fetch_message(int(message_id)) await message.delete() return True except Exception as err: logger.error(f"删除 Discord 消息失败:{err}") return False @staticmethod def _build_embed(title: str, text: Optional[str], image: Optional[str], link: Optional[str], mtype: Optional['NotificationType'] = None) -> discord.Embed: fields: List[Dict[str, str]] = [] desc_lines: List[str] = [] should_parse_fields = mtype in PARSE_FIELD_TYPES if mtype else False def _collect_spans(s: str, left: str, right: str) -> List[Tuple[int, int]]: spans: List[Tuple[int, int]] = [] start = 0 while True: l_idx = s.find(left, start) if l_idx == -1: break r_idx = s.find(right, l_idx + 1) if r_idx == -1: break spans.append((l_idx, r_idx)) start = r_idx + 1 return spans def _find_colon_index(s: str, m: re.Match) -> Optional[int]: segment = s[m.start():m.end()] for i, ch in enumerate(segment): if ch in (":", ":"): return m.start() + i return None if text: # 处理上游未反序列化的 "\n" 等转义换行,避免被当成普通字符 if "\\n" in text or "\\r" in text: text = text.replace("\\r\\n", "\n").replace("\\n", "\n").replace("\\r", "\n") if not should_parse_fields: desc_lines.append(text.strip()) else: # 匹配形如 "字段:值" 的片段,字段名不允许包含常见分隔符; # 下一个字段需以顿号/逗号/分号等分隔开,且不能是 URL 协议开头,避免值里出现 URL 的":" 被误拆 # 字段名允许 emoji 等 Unicode 字符,但排除空白/分隔符/冒号 name_re = r"[^\s::,,。;;、]+" pair_pattern = re.compile( rf"({name_re})[::](.*?)(?=(?:[,,。;;、]+\s*(?!https?://|ftp://|ftps://|magnet:){name_re}[::])|$)", re.IGNORECASE, ) for line in text.splitlines(): line = line.strip() if not line: continue matches = list(pair_pattern.finditer(line)) if matches: book_spans = _collect_spans(line, "《", "》") + _collect_spans(line, "【", "】") if book_spans: has_book_colon = False for m in matches: colon_idx = _find_colon_index(line, m) if colon_idx is not None and any(l < colon_idx < r for l, r in book_spans): has_book_colon = True break if has_book_colon: desc_lines.append(line) continue # 若整行只是 URL/时间等自然包含":"的内容,则不当作字段 url_like_names = {"http", "https", "ftp", "ftps", "magnet"} if all(m.group(1).lower() in url_like_names or m.group(1).isdigit() for m in matches): desc_lines.append(line) continue last_end = 0 for m in matches: # 追加匹配前的非空文本到描述 prefix = line[last_end:m.start()].strip(" ,,;;。、") # 仅当前缀不全是分隔符/空白时才记录 if prefix and prefix.strip(" ,,;;。、"): desc_lines.append(prefix) name = m.group(1).strip() value = m.group(2).strip(" ,,;;。、\t") or "-" if name: fields.append({"name": name, "value": value, "inline": False}) last_end = m.end() # 匹配末尾后的文本 suffix = line[last_end:].strip(" ,,;;。、") if suffix and suffix.strip(" ,,;;。、"): desc_lines.append(suffix) else: desc_lines.append(line) description = "\n".join(desc_lines).strip() if not description and not fields and text: description = text.strip() embed = discord.Embed( title=title, url=link or "https://github.com/jxxghp/MoviePilot", description=description if description else None, color=0xE67E22 ) for field in fields: embed.add_field(name=field["name"], value=field["value"], inline=False) if image: embed.set_image(url=image) return embed @staticmethod def _build_media_embeds(medias: List[MediaInfo], title: str) -> List[discord.Embed]: embeds: List[discord.Embed] = [] for index, media in enumerate(medias[:10], start=1): overview = media.get_overview_string(80) desc_parts = [ f"{media.type.value} | {media.vote_star}" if media.vote_star else media.type.value, overview ] embed = discord.Embed( title=f"{index}. {media.title_year}", url=media.detail_link or discord.Embed.Empty, description="\n".join([p for p in desc_parts if p]), color=0x5865F2 ) if media.get_poster_image(): embed.set_thumbnail(url=media.get_poster_image()) embeds.append(embed) if embeds: embeds[0].set_author(name=title) return embeds @staticmethod def _build_torrent_embeds(torrents: List[Context], title: str) -> List[discord.Embed]: embeds: List[discord.Embed] = [] for index, context in enumerate(torrents[:10], start=1): torrent = context.torrent_info meta = MetaInfo(torrent.title, torrent.description) title_text = f"{meta.season_episode} {meta.resource_term} {meta.video_term} {meta.release_group}" title_text = re.sub(r"\s+", " ", title_text).strip() detail = [ f"{torrent.site_name} | {StringUtils.str_filesize(torrent.size)} | {torrent.volume_factor} | {torrent.seeders}↑", meta.resource_term, meta.video_term ] embed = discord.Embed( title=f"{index}. {title_text or torrent.title}", url=torrent.page_url or discord.Embed.Empty, description="\n".join([d for d in detail if d]), color=0x00A86B ) poster = getattr(torrent, "poster", None) if poster: embed.set_thumbnail(url=poster) embeds.append(embed) if embeds: embeds[0].set_author(name=title) return embeds @staticmethod def _build_default_buttons(count: int) -> List[List[dict]]: buttons: List[List[dict]] = [] max_rows = 5 max_per_row = 5 capped = min(count, max_rows * max_per_row) for idx in range(1, capped + 1): row_idx = (idx - 1) // max_per_row if len(buttons) <= row_idx: buttons.append([]) buttons[row_idx].append({"text": f"选择 {idx}", "callback_data": str(idx)}) if count > capped: logger.warn(f"按钮数量超过 Discord 限制,仅展示前 {capped} 个") return buttons @staticmethod def _build_view(buttons: Optional[List[List[dict]]], link: Optional[str] = None) -> Optional[discord.ui.View]: has_buttons = buttons and any(buttons) if not has_buttons and not link: return None view = discord.ui.View(timeout=None) if buttons: for row_index, button_row in enumerate(buttons[:5]): for button in button_row[:5]: if "url" in button: btn = discord.ui.Button(label=button.get("text", "链接"), url=button["url"], style=discord.ButtonStyle.link) else: custom_id = (button.get("callback_data") or button.get("text") or f"btn-{row_index}")[:99] btn = discord.ui.Button(label=button.get("text", "选择")[:80], custom_id=custom_id, style=discord.ButtonStyle.primary) view.add_item(btn) elif link: view.add_item(discord.ui.Button(label="查看详情", url=link, style=discord.ButtonStyle.link)) return view async def _resolve_channel(self, userid: Optional[str] = None, chat_id: Optional[str] = None): """ Resolve the channel to send messages to. Priority order: 1. `chat_id` (original channel where user sent the message) - for contextual replies 2. `userid` mapping (channel where user last sent a message) - for contextual replies 3. Configured `_channel_id` (broadcast channel) - for system notifications 4. Any available text channel in configured guild - fallback 5. `userid` (DM) - for private conversations as a final fallback """ logger.debug(f"[Discord] _resolve_channel: userid={userid}, chat_id={chat_id}, " f"_channel_id={self._channel_id}, _guild_id={self._guild_id}") # Priority 1: Use explicit chat_id (reply to the same channel where user sent message) if chat_id: logger.debug(f"[Discord] 尝试通过 chat_id={chat_id} 获取原始频道") channel = self._client.get_channel(int(chat_id)) if channel: logger.debug(f"[Discord] 通过 get_channel 找到频道: {channel}") return channel try: channel = await self._client.fetch_channel(int(chat_id)) logger.debug(f"[Discord] 通过 fetch_channel 找到频道: {channel}") return channel except Exception as err: logger.warn(f"通过 chat_id 获取 Discord 频道失败:{err}") # Priority 2: Use user-chat mapping (reply to where the user last sent a message) if userid: mapped_chat_id = self._get_user_chat_id(str(userid)) if mapped_chat_id: logger.debug(f"[Discord] 从用户映射获取 chat_id={mapped_chat_id}") channel = self._client.get_channel(int(mapped_chat_id)) if channel: logger.debug(f"[Discord] 通过映射找到频道: {channel}") return channel try: channel = await self._client.fetch_channel(int(mapped_chat_id)) logger.debug(f"[Discord] 通过 fetch_channel 找到映射频道: {channel}") return channel except Exception as err: logger.warn(f"通过映射的 chat_id 获取 Discord 频道失败:{err}") # Priority 3: Use configured broadcast channel (for system notifications) if self._broadcast_channel: logger.debug(f"[Discord] 使用缓存的广播频道: {self._broadcast_channel}") return self._broadcast_channel if self._channel_id: logger.debug(f"[Discord] 尝试通过配置的 _channel_id={self._channel_id} 获取频道") channel = self._client.get_channel(self._channel_id) if not channel: try: channel = await self._client.fetch_channel(self._channel_id) except Exception as err: logger.warn(f"通过配置的频道ID获取 Discord 频道失败:{err}") channel = None self._broadcast_channel = channel if channel: logger.debug(f"[Discord] 通过配置的频道ID找到频道: {channel}") return channel # Priority 4: Find any available text channel in guild (fallback) logger.debug(f"[Discord] 尝试在 Guild 中寻找可用频道") target_guilds = [] if self._guild_id: guild = self._client.get_guild(self._guild_id) if guild: target_guilds.append(guild) else: target_guilds = list(self._client.guilds) logger.debug(f"[Discord] 目标 Guilds 数量: {len(target_guilds)}") for guild in target_guilds: for channel in guild.text_channels: if guild.me and channel.permissions_for(guild.me).send_messages: logger.debug(f"[Discord] 在 Guild 中找到可用频道: {channel}") self._broadcast_channel = channel return channel # Priority 5: Fallback to DM (only if no channel available) if userid: logger.debug(f"[Discord] 回退到私聊: userid={userid}") dm = await self._get_dm_channel(str(userid)) if dm: logger.debug(f"[Discord] 获取到私聊频道: {dm}") return dm else: logger.debug(f"[Discord] 无法获取用户 {userid} 的私聊频道") return None async def _get_dm_channel(self, userid: str) -> Optional[discord.DMChannel]: logger.debug(f"[Discord] _get_dm_channel: userid={userid}") if userid in self._user_dm_cache: logger.debug(f"[Discord] 从缓存获取私聊频道: {self._user_dm_cache.get(userid)}") return self._user_dm_cache.get(userid) try: logger.debug(f"[Discord] 尝试获取/创建用户 {userid} 的私聊频道") user_obj = self._client.get_user(int(userid)) logger.debug(f"[Discord] get_user 结果: {user_obj}") if not user_obj: user_obj = await self._client.fetch_user(int(userid)) logger.debug(f"[Discord] fetch_user 结果: {user_obj}") if not user_obj: logger.debug(f"[Discord] 无法找到用户 {userid}") return None dm = user_obj.dm_channel logger.debug(f"[Discord] 用户现有 dm_channel: {dm}") if not dm: dm = await user_obj.create_dm() logger.debug(f"[Discord] 创建新的 dm_channel: {dm}") if dm: self._user_dm_cache[userid] = dm return dm except Exception as err: logger.error(f"获取 Discord 私聊失败:{err}") return None def _update_user_chat_mapping(self, userid: str, chat_id: str) -> None: """ Update user-chat mapping for reply targeting. This ensures replies go to the same channel where the user sent the message. :param userid: User ID :param chat_id: Channel/Chat ID where the user sent the message """ if userid and chat_id: self._user_chat_mapping[userid] = chat_id logger.debug(f"[Discord] 更新用户频道映射: userid={userid} -> chat_id={chat_id}") def _get_user_chat_id(self, userid: str) -> Optional[str]: """ Get the chat ID where the user last sent a message. :param userid: User ID :return: Chat ID or None if not found """ return self._user_chat_mapping.get(userid) def _should_process_message(self, message: discord.Message) -> bool: if isinstance(message.channel, discord.DMChannel): return True content = message.content or "" # 仅处理 @Bot 或斜杠命令 if self._client.user and self._client.user.mentioned_in(message): return True if content.startswith("/"): return True return False def _clean_bot_mention(self, content: str) -> str: if not content: return "" if self._bot_user_id: mention_pattern = rf"<@!?{self._bot_user_id}>" content = re.sub(mention_pattern, "", content).strip() return content async def _post_to_ds(self, payload: Dict[str, Any]) -> None: try: proxy = None if settings.PROXY: proxy = settings.PROXY.get("https") or settings.PROXY.get("http") async with httpx.AsyncClient(timeout=10, verify=False, proxy=proxy) as client: await client.post(self._ds_url, json=payload) except Exception as err: logger.error(f"转发 Discord 消息失败:{err}") ================================================ FILE: app/modules/douban/__init__.py ================================================ import re from typing import List, Optional, Tuple, Union import cn2an import zhconv from app import schemas from app.core.config import settings from app.core.context import MediaInfo from app.core.meta import MetaBase from app.core.metainfo import MetaInfo from app.log import logger from app.modules import _ModuleBase from app.modules.douban.apiv2 import DoubanApi from app.modules.douban.douban_cache import DoubanCache from app.modules.douban.scraper import DoubanScraper from app.schemas import MediaPerson, APIRateLimitException from app.schemas.types import MediaType, ModuleType, MediaRecognizeType from app.utils.common import retry from app.utils.http import RequestUtils from app.utils.limit import rate_limit_exponential class DoubanModule(_ModuleBase): doubanapi: DoubanApi = None scraper: DoubanScraper = None cache: DoubanCache = None def init_module(self) -> None: self.doubanapi = DoubanApi() self.scraper = DoubanScraper() self.cache = DoubanCache() def stop(self): self.doubanapi.close() def test(self) -> Tuple[bool, str]: """ 测试模块连接性 """ ret = RequestUtils().get_res("https://movie.douban.com/") if ret is None: return False, "豆瓣网络连接失败" return True, "" def init_setting(self) -> Tuple[str, Union[str, bool]]: pass @staticmethod def get_name() -> str: return "豆瓣" @staticmethod def get_type() -> ModuleType: """ 获取模块类型 """ return ModuleType.MediaRecognize @staticmethod def get_subtype() -> MediaRecognizeType: """ 获取模块子类型 """ return MediaRecognizeType.Douban @staticmethod def get_priority() -> int: """ 获取模块优先级,数字越小优先级越高,只有同一接口下优先级才生效 """ return 2 def _recognize_media_core(self, meta: MetaBase = None, mtype: MediaType = None, doubanid: Optional[str] = None, cache: Optional[bool] = True, douban_info_func=None, match_doubaninfo_func=None, **kwargs) -> Optional[MediaInfo]: """ 识别媒体信息的核心逻辑 :param meta: 识别的元数据 :param mtype: 识别的媒体类型,与doubanid配套 :param doubanid: 豆瓣ID :param cache: 是否使用缓存 :param douban_info_func: 获取豆瓣信息的函数 :param match_doubaninfo_func: 匹配豆瓣信息的函数 :return: 识别的媒体信息,包括剧集信息 """ if not doubanid and not meta: return None if meta and not doubanid \ and settings.RECOGNIZE_SOURCE != "douban": return None if not meta: # 未提供元数据时,直接查询豆瓣信息,不使用缓存 cache_info = {} elif not meta.name: logger.error("识别媒体信息时未提供元数据名称") return None else: # 读取缓存 if mtype: meta.type = mtype if doubanid: meta.doubanid = doubanid cache_info = self.cache.get(meta) # 识别豆瓣信息 if not cache_info or not cache: # 缓存没有或者强制不使用缓存 if doubanid: # 直接查询详情 info = douban_info_func(doubanid=doubanid, mtype=mtype or meta.type) elif meta: info = {} # 简体名称 zh_name = zhconv.convert(meta.cn_name, "zh-hans") if meta.cn_name else None # 使用中英文名分别识别,去重去空,但要保持顺序 names = list(dict.fromkeys([k for k in [meta.cn_name, zh_name, meta.en_name] if k])) for name in names: if meta.begin_season: logger.info(f"正在识别 {name} 第{meta.begin_season}季 ...") else: logger.info(f"正在识别 {name} ...") # 匹配豆瓣信息 match_info = match_doubaninfo_func(name=name, mtype=mtype or meta.type, year=meta.year, season=meta.begin_season) if match_info: # 匹配到豆瓣信息 info = douban_info_func( doubanid=match_info.get("id"), mtype=mtype or meta.type ) if info: break else: logger.error("识别媒体信息时未提供元数据或豆瓣ID") return None # 保存到缓存 if meta and cache: self.cache.update(meta, info) else: # 使用缓存信息 if cache_info.get("title"): logger.info(f"{meta.name} 使用豆瓣识别缓存:{cache_info.get('title')}") info = douban_info_func(mtype=cache_info.get("type"), doubanid=cache_info.get("id")) else: logger.info(f"{meta.name} 使用豆瓣识别缓存:无法识别") info = None if info: # 赋值TMDB信息并返回 mediainfo = MediaInfo(douban_info=info) if meta: logger.info(f"{meta.name} 豆瓣识别结果:{mediainfo.type.value} " f"{mediainfo.title_year} " f"{mediainfo.douban_id}") else: logger.info(f"{doubanid} 豆瓣识别结果:{mediainfo.type.value} " f"{mediainfo.title_year}") return mediainfo else: logger.info(f"{meta.name if meta else doubanid} 未匹配到豆瓣媒体信息") return None async def _async_recognize_media_core(self, meta: MetaBase = None, mtype: MediaType = None, doubanid: Optional[str] = None, cache: Optional[bool] = True, async_douban_info_func=None, async_match_doubaninfo_func=None, **kwargs) -> Optional[MediaInfo]: """ 识别媒体信息的核心逻辑(异步版本) :param meta: 识别的元数据 :param mtype: 识别的媒体类型,与doubanid配套 :param doubanid: 豆瓣ID :param cache: 是否使用缓存 :param async_douban_info_func: 获取豆瓣信息的异步函数 :param async_match_doubaninfo_func: 匹配豆瓣信息的异步函数 :return: 识别的媒体信息,包括剧集信息 """ if not doubanid and not meta: return None if meta and not doubanid \ and settings.RECOGNIZE_SOURCE != "douban": return None if not meta: # 未提供元数据时,直接查询豆瓣信息,不使用缓存 cache_info = {} elif not meta.name: logger.error("识别媒体信息时未提供元数据名称") return None else: # 读取缓存 if mtype: meta.type = mtype if doubanid: meta.doubanid = doubanid cache_info = self.cache.get(meta) # 识别豆瓣信息 if not cache_info or not cache: # 缓存没有或者强制不使用缓存 if doubanid: # 直接查询详情 info = await async_douban_info_func(doubanid=doubanid, mtype=mtype or meta.type) elif meta: info = {} # 简体名称 zh_name = zhconv.convert(meta.cn_name, "zh-hans") if meta.cn_name else None # 使用中英文名分别识别,去重去空,但要保持顺序 names = list(dict.fromkeys([k for k in [meta.cn_name, zh_name, meta.en_name] if k])) for name in names: if meta.begin_season: logger.info(f"正在识别 {name} 第{meta.begin_season}季 ...") else: logger.info(f"正在识别 {name} ...") # 匹配豆瓣信息 match_info = await async_match_doubaninfo_func(name=name, mtype=mtype or meta.type, year=meta.year, season=meta.begin_season) if match_info: # 匹配到豆瓣信息 info = await async_douban_info_func( doubanid=match_info.get("id"), mtype=mtype or meta.type ) if info: break else: logger.error("识别媒体信息时未提供元数据或豆瓣ID") return None # 保存到缓存 if meta and cache: self.cache.update(meta, info) else: # 使用缓存信息 if cache_info.get("title"): logger.info(f"{meta.name} 使用豆瓣识别缓存:{cache_info.get('title')}") info = await async_douban_info_func(mtype=cache_info.get("type"), doubanid=cache_info.get("id")) else: logger.info(f"{meta.name} 使用豆瓣识别缓存:无法识别") info = None if info: # 赋值TMDB信息并返回 mediainfo = MediaInfo(douban_info=info) if meta: logger.info(f"{meta.name} 豆瓣识别结果:{mediainfo.type.value} " f"{mediainfo.title_year} " f"{mediainfo.douban_id}") else: logger.info(f"{doubanid} 豆瓣识别结果:{mediainfo.type.value} " f"{mediainfo.title_year}") return mediainfo else: logger.info(f"{meta.name if meta else doubanid} 未匹配到豆瓣媒体信息") return None def recognize_media(self, meta: MetaBase = None, mtype: MediaType = None, doubanid: Optional[str] = None, cache: Optional[bool] = True, **kwargs) -> Optional[MediaInfo]: """ 识别媒体信息 :param meta: 识别的元数据 :param mtype: 识别的媒体类型,与doubanid配套 :param doubanid: 豆瓣ID :param cache: 是否使用缓存 :return: 识别的媒体信息,包括剧集信息 """ return self._recognize_media_core( meta=meta, mtype=mtype, doubanid=doubanid, cache=cache, douban_info_func=self.douban_info, match_doubaninfo_func=self.match_doubaninfo, **kwargs ) async def async_recognize_media(self, meta: MetaBase = None, mtype: MediaType = None, doubanid: Optional[str] = None, cache: Optional[bool] = True, **kwargs) -> Optional[MediaInfo]: """ 识别媒体信息(异步版本) :param meta: 识别的元数据 :param mtype: 识别的媒体类型,与doubanid配套 :param doubanid: 豆瓣ID :param cache: 是否使用缓存 :return: 识别的媒体信息,包括剧集信息 """ return await self._async_recognize_media_core( meta=meta, mtype=mtype, doubanid=doubanid, cache=cache, async_douban_info_func=self.async_douban_info, async_match_doubaninfo_func=self.async_match_doubaninfo, **kwargs ) @rate_limit_exponential(source="douban_info") def douban_info(self, doubanid: str, mtype: MediaType = None, raise_exception: bool = True) -> Optional[dict]: """ 获取豆瓣信息 :param doubanid: 豆瓣ID :param mtype: 媒体类型 :param raise_exception: 触发速率限制时是否抛出异常 :return: 豆瓣信息 """ """ { "rating": { "count": 287365, "max": 10, "star_count": 3.5, "value": 6.6 }, "lineticket_url": "", "controversy_reason": "", "pubdate": [ "2021-10-29(中国大陆)" ], "last_episode_number": null, "interest_control_info": null, "pic": { "large": "https://img9.doubanio.com/view/photo/m_ratio_poster/public/p2707553644.webp", "normal": "https://img9.doubanio.com/view/photo/s_ratio_poster/public/p2707553644.webp" }, "vendor_count": 6, "body_bg_color": "f4f5f9", "is_tv": false, "head_info": null, "album_no_interact": false, "ticket_price_info": "", "webisode_count": 0, "year": "2021", "card_subtitle": "2021 / 英国 美国 / 动作 惊悚 冒险 / 凯瑞·福永 / 丹尼尔·克雷格 蕾雅·赛杜", "forum_info": null, "webisode": null, "id": "20276229", "gallery_topic_count": 0, "languages": [ "英语", "法语", "意大利语", "俄语", "西班牙语" ], "genres": [ "动作", "惊悚", "冒险" ], "review_count": 926, "title": "007:无暇赴死", "intro": "世界局势波诡云谲,再度出山的邦德(丹尼尔·克雷格 饰)面临有史以来空前的危机,传奇特工007的故事在本片中达到高潮。新老角色集结亮相,蕾雅·赛杜回归,二度饰演邦女郎玛德琳。系列最恐怖反派萨芬(拉米·马雷克 饰)重磅登场,毫不留情地展示了自己狠辣的一面,不仅揭开了玛德琳身上隐藏的秘密,还酝酿着危及数百万人性命的阴谋,幽灵党的身影也似乎再次浮出水面。半路杀出的新00号特工(拉什纳·林奇 饰)与神秘女子(安娜·德·阿玛斯 饰)看似与邦德同阵作战,但其真实目的依然成谜。关乎邦德生死的新仇旧怨接踵而至,暗潮汹涌之下他能否拯救世界?", "interest_cmt_earlier_tip_title": "发布于上映前", "has_linewatch": true, "ugc_tabs": [ { "source": "reviews", "type": "review", "title": "影评" }, { "source": "forum_topics", "type": "forum", "title": "讨论" } ], "forum_topic_count": 857, "ticket_promo_text": "", "webview_info": {}, "is_released": true, "actors": [ { "name": "丹尼尔·克雷格", "roles": [ "演员", "制片人", "配音" ], "title": "丹尼尔·克雷格(同名)英国,英格兰,柴郡,切斯特影视演员", "url": "https://movie.douban.com/celebrity/1025175/", "user": null, "character": "饰 詹姆斯·邦德 James Bond 007", "uri": "douban://douban.com/celebrity/1025175?subject_id=27230907", "avatar": { "large": "https://qnmob3.doubanio.com/view/celebrity/raw/public/p42588.jpg?imageView2/2/q/80/w/600/h/3000/format/webp", "normal": "https://qnmob3.doubanio.com/view/celebrity/raw/public/p42588.jpg?imageView2/2/q/80/w/200/h/300/format/webp" }, "sharing_url": "https://www.douban.com/doubanapp/dispatch?uri=/celebrity/1025175/", "type": "celebrity", "id": "1025175", "latin_name": "Daniel Craig" } ], "interest": null, "vendor_icons": [ "https://img9.doubanio.com/f/frodo/fbc90f355fc45d5d2056e0d88c697f9414b56b44/pics/vendors/tencent.png", "https://img2.doubanio.com/f/frodo/8286b9b5240f35c7e59e1b1768cd2ccf0467cde5/pics/vendors/migu_video.png", "https://img9.doubanio.com/f/frodo/88a62f5e0cf9981c910e60f4421c3e66aac2c9bc/pics/vendors/bilibili.png" ], "episodes_count": 0, "color_scheme": { "is_dark": true, "primary_color_light": "868ca5", "_base_color": [ 0.6333333333333333, 0.18867924528301885, 0.20784313725490197 ], "secondary_color": "f4f5f9", "_avg_color": [ 0.059523809523809625, 0.09790209790209795, 0.5607843137254902 ], "primary_color_dark": "676c7f" }, "type": "movie", "null_rating_reason": "", "linewatches": [ { "url": "http://v.youku.com/v_show/id_XNTIwMzM2NDg5Mg==.html?tpa=dW5pb25faWQ9MzAwMDA4XzEwMDAwMl8wMl8wMQ&refer=esfhz_operation.xuka.xj_00003036_000000_FNZfau_19010900", "source": { "literal": "youku", "pic": "https://img1.doubanio.com/img/files/file-1432869267.png", "name": "优酷视频" }, "source_uri": "youku://play?vid=XNTIwMzM2NDg5Mg==&source=douban&refer=esfhz_operation.xuka.xj_00003036_000000_FNZfau_19010900", "free": false }, ], "info_url": "https://www.douban.com/doubanapp//h5/movie/20276229/desc", "tags": [], "durations": [ "163分钟" ], "comment_count": 97204, "cover": { "description": "", "author": { "loc": { "id": "108288", "name": "北京", "uid": "beijing" }, "kind": "user", "name": "雨落下", "reg_time": "2020-08-11 16:22:48", "url": "https://www.douban.com/people/221011676/", "uri": "douban://douban.com/user/221011676", "id": "221011676", "avatar_side_icon_type": 3, "avatar_side_icon_id": "234", "avatar": "https://img2.doubanio.com/icon/up221011676-2.jpg", "is_club": false, "type": "user", "avatar_side_icon": "https://img2.doubanio.com/view/files/raw/file-1683625971.png", "uid": "221011676" }, "url": "https://movie.douban.com/photos/photo/2707553644/", "image": { "large": { "url": "https://img9.doubanio.com/view/photo/l/public/p2707553644.webp", "width": 1082, "height": 1600, "size": 0 }, "raw": null, "small": { "url": "https://img9.doubanio.com/view/photo/s/public/p2707553644.webp", "width": 405, "height": 600, "size": 0 }, "normal": { "url": "https://img9.doubanio.com/view/photo/m/public/p2707553644.webp", "width": 405, "height": 600, "size": 0 }, "is_animated": false }, "uri": "douban://douban.com/photo/2707553644", "create_time": "2021-10-26 15:05:01", "position": 0, "owner_uri": "douban://douban.com/movie/20276229", "type": "photo", "id": "2707553644", "sharing_url": "https://www.douban.com/doubanapp/dispatch?uri=/photo/2707553644/" }, "cover_url": "https://img9.doubanio.com/view/photo/m_ratio_poster/public/p2707553644.webp", "restrictive_icon_url": "", "header_bg_color": "676c7f", "is_douban_intro": false, "ticket_vendor_icons": [ "https://img9.doubanio.com/view/dale-online/dale_ad/public/0589a62f2f2d7c2.jpg" ], "honor_infos": [], "sharing_url": "https://movie.douban.com/subject/20276229/", "subject_collections": [], "wechat_timeline_share": "screenshot", "countries": [ "英国", "美国" ], "url": "https://movie.douban.com/subject/20276229/", "release_date": null, "original_title": "No Time to Die", "uri": "douban://douban.com/movie/20276229", "pre_playable_date": null, "episodes_info": "", "subtype": "movie", "directors": [ { "name": "凯瑞·福永", "roles": [ "导演", "制片人", "编剧", "摄影", "演员" ], "title": "凯瑞·福永(同名)美国,加利福尼亚州,奥克兰影视演员", "url": "https://movie.douban.com/celebrity/1009531/", "user": null, "character": "导演", "uri": "douban://douban.com/celebrity/1009531?subject_id=27215222", "avatar": { "large": "https://qnmob3.doubanio.com/view/celebrity/raw/public/p1392285899.57.jpg?imageView2/2/q/80/w/600/h/3000/format/webp", "normal": "https://qnmob3.doubanio.com/view/celebrity/raw/public/p1392285899.57.jpg?imageView2/2/q/80/w/200/h/300/format/webp" }, "sharing_url": "https://www.douban.com/doubanapp/dispatch?uri=/celebrity/1009531/", "type": "celebrity", "id": "1009531", "latin_name": "Cary Fukunaga" } ], "is_show": false, "in_blacklist": false, "pre_release_desc": "", "video": null, "aka": [ "007:生死有时(港)", "007:生死交战(台)", "007:间不容死", "邦德25", "007:没空去死(豆友译名)", "James Bond 25", "Never Dream of Dying", "Shatterhand" ], "is_restrictive": false, "trailer": { "sharing_url": "https://www.douban.com/doubanapp/dispatch?uri=/movie/20276229/trailer%3Ftrailer_id%3D282585%26trailer_type%3DA", "video_url": "https://vt1.doubanio.com/202310011325/3b1f5827e91dde7826dc20930380dfc2/view/movie/M/402820585.mp4", "title": "中国预告片:终极决战版 (中文字幕)", "uri": "douban://douban.com/movie/20276229/trailer?trailer_id=282585&trailer_type=A", "cover_url": "https://img1.doubanio.com/img/trailer/medium/2712944408.jpg", "term_num": 0, "n_comments": 21, "create_time": "2021-11-01", "subject_title": "007:无暇赴死", "file_size": 10520074, "runtime": "00:42", "type": "A", "id": "282585", "desc": "" }, "interest_cmt_earlier_tip_desc": "该短评的发布时间早于公开上映时间,作者可能通过其他渠道提前观看,请谨慎参考。其评分将不计入总评分。" } """ def __douban_tv(): """ 获取豆瓣剧集信息 """ info = self.doubanapi.tv_detail(doubanid) if info: if "subject_ip_rate_limit" in info.get("msg", ""): msg = f"触发豆瓣IP速率限制,错误信息:{info} ..." logger.warn(msg) raise APIRateLimitException(msg) celebrities = self.doubanapi.tv_celebrities(doubanid) if celebrities: info["directors"] = celebrities.get("directors") info["actors"] = celebrities.get("actors") return info def __douban_movie(): """ 获取豆瓣电影信息 """ info = self.doubanapi.movie_detail(doubanid) if info: if "subject_ip_rate_limit" in info.get("msg", ""): msg = f"触发豆瓣IP速率限制,错误信息:{info} ..." logger.warn(msg) raise APIRateLimitException(msg) celebrities = self.doubanapi.movie_celebrities(doubanid) if celebrities: info["directors"] = celebrities.get("directors") info["actors"] = celebrities.get("actors") return info if not doubanid: return None logger.info(f"开始获取豆瓣信息:{doubanid} ...") if mtype == MediaType.TV: return __douban_tv() elif mtype == MediaType.MOVIE: return __douban_movie() else: return __douban_movie() or __douban_tv() @rate_limit_exponential(source="douban_info") async def async_douban_info(self, doubanid: str, mtype: MediaType = None, raise_exception: bool = True) -> Optional[dict]: """ 获取豆瓣信息(异步版本) :param doubanid: 豆瓣ID :param mtype: 媒体类型 :param raise_exception: 触发速率限制时是否抛出异常 :return: 豆瓣信息 """ async def __async_douban_tv(): """ 获取豆瓣剧集信息(异步版本) """ info = await self.doubanapi.async_tv_detail(doubanid) if info: if "subject_ip_rate_limit" in info.get("msg", ""): msg = f"触发豆瓣IP速率限制,错误信息:{info} ..." logger.warn(msg) raise APIRateLimitException(msg) celebrities = await self.doubanapi.async_tv_celebrities(doubanid) if celebrities: info["directors"] = celebrities.get("directors") info["actors"] = celebrities.get("actors") return info async def __async_douban_movie(): """ 获取豆瓣电影信息(异步版本) """ info = await self.doubanapi.async_movie_detail(doubanid) if info: if "subject_ip_rate_limit" in info.get("msg", ""): msg = f"触发豆瓣IP速率限制,错误信息:{info} ..." logger.warn(msg) raise APIRateLimitException(msg) celebrities = await self.doubanapi.async_movie_celebrities(doubanid) if celebrities: info["directors"] = celebrities.get("directors") info["actors"] = celebrities.get("actors") return info if not doubanid: return None logger.info(f"开始获取豆瓣信息:{doubanid} ...") if mtype == MediaType.TV: return await __async_douban_tv() elif mtype == MediaType.MOVIE: return await __async_douban_movie() else: movie_result = await __async_douban_movie() if movie_result: return movie_result return await __async_douban_tv() def douban_discover(self, mtype: MediaType, sort: str, tags: str, page: int = 1, count: int = 30) -> Optional[List[MediaInfo]]: """ 发现豆瓣电影、剧集 :param mtype: 媒体类型 :param sort: 排序方式 :param tags: 标签 :param page: 页码 :param count: 数量 :return: 媒体信息列表 """ logger.info(f"开始发现豆瓣 {mtype.value} ...") if mtype == MediaType.MOVIE: infos = self.doubanapi.movie_recommend(start=(page - 1) * count, count=count, sort=sort, tags=tags) else: infos = self.doubanapi.tv_recommend(start=(page - 1) * count, count=count, sort=sort, tags=tags) if infos and infos.get("items"): medias = [MediaInfo(douban_info=info) for info in infos.get("items")] return [media for media in medias if media.poster_path and "movie_large.jpg" not in media.poster_path and "tv_normal.png" not in media.poster_path and "movie_large.jpg" not in media.poster_path and "tv_normal.jpg" not in media.poster_path and "tv_large.jpg" not in media.poster_path] return [] async def async_douban_discover(self, mtype: MediaType, sort: str, tags: str, page: int = 1, count: int = 30) -> Optional[List[MediaInfo]]: """ 发现豆瓣电影、剧集(异步版本) :param mtype: 媒体类型 :param sort: 排序方式 :param tags: 标签 :param page: 页码 :param count: 数量 :return: 媒体信息列表 """ logger.info(f"开始发现豆瓣 {mtype.value} ...") if mtype == MediaType.MOVIE: infos = await self.doubanapi.async_movie_recommend(start=(page - 1) * count, count=count, sort=sort, tags=tags) else: infos = await self.doubanapi.async_tv_recommend(start=(page - 1) * count, count=count, sort=sort, tags=tags) if infos and infos.get("items"): medias = [MediaInfo(douban_info=info) for info in infos.get("items")] return [media for media in medias if media.poster_path and "movie_large.jpg" not in media.poster_path and "tv_normal.png" not in media.poster_path and "movie_large.jpg" not in media.poster_path and "tv_normal.jpg" not in media.poster_path and "tv_large.jpg" not in media.poster_path] return [] def movie_showing(self, page: int = 1, count: int = 30) -> List[MediaInfo]: """ 获取正在上映的电影 """ infos = self.doubanapi.movie_showing(start=(page - 1) * count, count=count) if infos and infos.get("subject_collection_items"): return [MediaInfo(douban_info=info) for info in infos.get("subject_collection_items")] return [] async def async_movie_showing(self, page: int = 1, count: int = 30) -> List[MediaInfo]: """ 获取正在上映的电影(异步版本) """ infos = await self.doubanapi.async_movie_showing(start=(page - 1) * count, count=count) if infos and infos.get("subject_collection_items"): return [MediaInfo(douban_info=info) for info in infos.get("subject_collection_items")] return [] def tv_weekly_chinese(self, page: int = 1, count: int = 30) -> List[MediaInfo]: """ 获取豆瓣本周口碑国产剧 """ infos = self.doubanapi.tv_chinese_best_weekly(start=(page - 1) * count, count=count) if infos: return [MediaInfo(douban_info=info) for info in infos.get("subject_collection_items")] return [] async def async_tv_weekly_chinese(self, page: int = 1, count: int = 30) -> List[MediaInfo]: """ 获取豆瓣本周口碑国产剧(异步版本) """ infos = await self.doubanapi.async_tv_chinese_best_weekly(start=(page - 1) * count, count=count) if infos: return [MediaInfo(douban_info=info) for info in infos.get("subject_collection_items")] return [] def tv_weekly_global(self, page: int = 1, count: int = 30) -> List[MediaInfo]: """ 获取豆瓣本周口碑外国剧 """ infos = self.doubanapi.tv_global_best_weekly(start=(page - 1) * count, count=count) if infos and infos.get("subject_collection_items"): return [MediaInfo(douban_info=info) for info in infos.get("subject_collection_items")] return [] async def async_tv_weekly_global(self, page: int = 1, count: int = 30) -> List[MediaInfo]: """ 获取豆瓣本周口碑外国剧(异步版本) """ infos = await self.doubanapi.async_tv_global_best_weekly(start=(page - 1) * count, count=count) if infos and infos.get("subject_collection_items"): return [MediaInfo(douban_info=info) for info in infos.get("subject_collection_items")] return [] def tv_animation(self, page: int = 1, count: int = 30) -> List[MediaInfo]: """ 获取豆瓣动画剧 """ infos = self.doubanapi.tv_animation(start=(page - 1) * count, count=count) if infos and infos.get("subject_collection_items"): return [MediaInfo(douban_info=info) for info in infos.get("subject_collection_items")] return [] async def async_tv_animation(self, page: int = 1, count: int = 30) -> List[MediaInfo]: """ 获取豆瓣动画剧(异步版本) """ infos = await self.doubanapi.async_tv_animation(start=(page - 1) * count, count=count) if infos and infos.get("subject_collection_items"): return [MediaInfo(douban_info=info) for info in infos.get("subject_collection_items")] return [] def movie_hot(self, page: int = 1, count: int = 30) -> List[MediaInfo]: """ 获取豆瓣热门电影 """ infos = self.doubanapi.movie_hot_gaia(start=(page - 1) * count, count=count) if infos and infos.get("subject_collection_items"): return [MediaInfo(douban_info=info) for info in infos.get("subject_collection_items")] return [] async def async_movie_hot(self, page: int = 1, count: int = 30) -> List[MediaInfo]: """ 获取豆瓣热门电影(异步版本) """ infos = await self.doubanapi.async_movie_hot_gaia(start=(page - 1) * count, count=count) if infos and infos.get("subject_collection_items"): return [MediaInfo(douban_info=info) for info in infos.get("subject_collection_items")] return [] def tv_hot(self, page: int = 1, count: int = 30) -> List[MediaInfo]: """ 获取豆瓣热门剧集 """ infos = self.doubanapi.tv_hot(start=(page - 1) * count, count=count) if infos and infos.get("subject_collection_items"): return [MediaInfo(douban_info=info) for info in infos.get("subject_collection_items")] return [] async def async_tv_hot(self, page: int = 1, count: int = 30) -> List[MediaInfo]: """ 获取豆瓣热门剧集(异步版本) """ infos = await self.doubanapi.async_tv_hot(start=(page - 1) * count, count=count) if infos and infos.get("subject_collection_items"): return [MediaInfo(douban_info=info) for info in infos.get("subject_collection_items")] return [] def search_medias(self, meta: MetaBase) -> Optional[List[MediaInfo]]: """ 搜索媒体信息 :param meta: 识别的元数据 :reutrn: 媒体信息 """ if settings.SEARCH_SOURCE and "douban" not in settings.SEARCH_SOURCE: return None if not meta.name: return [] result = self.doubanapi.search(meta.name) if not result or not result.get("items"): return [] # 返回数据 ret_medias = [] for item_obj in result.get("items"): if meta.type and meta.type != MediaType.UNKNOWN and meta.type.value != item_obj.get("type_name"): continue if item_obj.get("type_name") not in (MediaType.TV.value, MediaType.MOVIE.value): continue if meta.name not in item_obj.get("target", {}).get("title"): continue ret_medias.append(MediaInfo(douban_info=item_obj.get("target"))) # 将搜索词中的季写入标题中 if ret_medias and meta.begin_season: # 小写数据转大写 season_str = cn2an.an2cn(meta.begin_season, "low") for media in ret_medias: if media.type == MediaType.TV: media.title = f"{media.title} 第{season_str}季" media.season = meta.begin_season return ret_medias async def async_search_medias(self, meta: MetaBase) -> Optional[List[MediaInfo]]: """ 搜索媒体信息(异步版本) :param meta: 识别的元数据 :reutrn: 媒体信息 """ if settings.SEARCH_SOURCE and "douban" not in settings.SEARCH_SOURCE: return None if not meta.name: return [] result = await self.doubanapi.async_search(meta.name) if not result or not result.get("items"): return [] # 返回数据 ret_medias = [] for item_obj in result.get("items"): if meta.type and meta.type != MediaType.UNKNOWN and meta.type.value != item_obj.get("type_name"): continue if item_obj.get("type_name") not in (MediaType.TV.value, MediaType.MOVIE.value): continue if meta.name not in item_obj.get("target", {}).get("title"): continue ret_medias.append(MediaInfo(douban_info=item_obj.get("target"))) # 将搜索词中的季写入标题中 if ret_medias and meta.begin_season: # 小写数据转大写 season_str = cn2an.an2cn(meta.begin_season, "low") for media in ret_medias: if media.type == MediaType.TV: media.title = f"{media.title} 第{season_str}季" media.season = meta.begin_season return ret_medias def search_persons(self, name: str) -> Optional[List[MediaPerson]]: """ 搜索人物信息 """ if settings.SEARCH_SOURCE and "douban" not in settings.SEARCH_SOURCE: return None if not name: return [] result = self.doubanapi.person_search(keyword=name) if result and result.get('items'): return [MediaPerson(source='douban', **{ 'id': item.get('target_id'), 'name': item.get('target', {}).get('title'), 'url': item.get('target', {}).get('url'), 'images': item.get('target', {}).get('cover', {}), 'avatar': (item.get('target', {}).get('cover_img', {}).get('url') or '').replace("/l/public/", "/s/public/"), }) for item in result.get('items') if name in item.get('target', {}).get('title')] return [] async def async_search_persons(self, name: str) -> Optional[List[MediaPerson]]: """ 搜索人物信息(异步版本) """ if settings.SEARCH_SOURCE and "douban" not in settings.SEARCH_SOURCE: return None if not name: return [] result = await self.doubanapi.async_person_search(keyword=name) if result and result.get('items'): return [MediaPerson(source='douban', **{ 'id': item.get('target_id'), 'name': item.get('target', {}).get('title'), 'url': item.get('target', {}).get('url'), 'images': item.get('target', {}).get('cover', {}), 'avatar': (item.get('target', {}).get('cover_img', {}).get('url') or '').replace("/l/public/", "/s/public/"), }) for item in result.get('items') if name in item.get('target', {}).get('title')] return [] @staticmethod def _process_imdbid_result(result: dict, imdbid: str) -> Optional[dict]: """ 处理IMDBID查询结果 :param result: IMDBID查询返回的结果 :param imdbid: IMDB ID :return: 处理后的结果,None表示无结果 """ if result: doubanid = result.get("id") if doubanid: if not str(doubanid).isdigit(): doubanid = re.search(r"\d+", doubanid).group(0) result["id"] = doubanid logger.info(f"{imdbid} 查询到豆瓣信息:{result.get('title')}") return result return None return None @staticmethod def _process_search_results(result: dict, name: str, mtype: MediaType = None, year: str = None, season: int = None) -> dict: """ 处理搜索结果并进行匹配 :param result: 搜索返回的结果 :param name: 搜索名称 :param mtype: 媒体类型 :param year: 年份 :param season: 季号 :return: 匹配到的豆瓣信息 """ if not result: logger.warn(f"未找到 {name} 的豆瓣信息") return {} # 触发rate limit检查 if "search_access_rate_limit" in result.values(): msg = f"触发豆瓣API速率限制,错误信息:{result} ..." logger.warn(msg) raise APIRateLimitException(msg) if not result.get("items"): logger.warn(f"未找到 {name} 的豆瓣信息") return {} for item_obj in result.get("items"): type_name = item_obj.get("type_name") if type_name not in [MediaType.TV.value, MediaType.MOVIE.value]: continue if mtype and mtype.value != type_name: continue if mtype and mtype == MediaType.TV and not season: season = 1 item = item_obj.get("target") title = item.get("title") if not title: continue meta = MetaInfo(title) if type_name == MediaType.TV.value: meta.type = MediaType.TV meta.begin_season = meta.begin_season or 1 if meta.name == name \ and ((not season and not meta.begin_season) or meta.begin_season == season) \ and (not year or item.get('year') == year): logger.info(f"{name} 匹配到豆瓣信息:{item.get('id')} {item.get('title')}") return item return {} @retry(Exception, 5, 3, 3, logger=logger) @rate_limit_exponential(source="match_doubaninfo") def match_doubaninfo(self, name: str, imdbid: str = None, mtype: MediaType = None, year: str = None, season: int = None, raise_exception: bool = False) -> dict: """ 搜索和匹配豆瓣信息 :param name: 名称 :param imdbid: IMDB ID :param mtype: 类型 :param year: 年份 :param season: 季号 :param raise_exception: 触发速率限制时是否抛出异常 """ if imdbid: # 优先使用IMDBID查询 logger.info(f"开始使用IMDBID {imdbid} 查询豆瓣信息 ...") result = self.doubanapi.imdbid(imdbid) processed_result = self._process_imdbid_result(result, imdbid) if processed_result: return processed_result # 搜索 logger.info(f"开始使用名称 {name} 匹配豆瓣信息 ...") result = self.doubanapi.search(f"{name} {year or ''}".strip()) return self._process_search_results(result, name, mtype, year, season) @retry(Exception, 5, 3, 3, logger=logger) @rate_limit_exponential(source="match_doubaninfo") async def async_match_doubaninfo(self, name: str, imdbid: str = None, mtype: MediaType = None, year: str = None, season: int = None, raise_exception: bool = False) -> dict: """ 搜索和匹配豆瓣信息(异步版本) :param name: 名称 :param imdbid: IMDB ID :param mtype: 类型 :param year: 年份 :param season: 季号 :param raise_exception: 触发速率限制时是否抛出异常 """ if imdbid: # 优先使用IMDBID查询 logger.info(f"开始使用IMDBID {imdbid} 查询豆瓣信息 ...") result = await self.doubanapi.async_imdbid(imdbid) processed_result = self._process_imdbid_result(result, imdbid) if processed_result: return processed_result # 搜索 logger.info(f"开始使用名称 {name} 匹配豆瓣信息 ...") result = await self.doubanapi.async_search(f"{name} {year or ''}".strip()) return self._process_search_results(result, name, mtype, year, season) def movie_top250(self, page: int = 1, count: int = 30) -> List[MediaInfo]: """ 获取豆瓣电影TOP250 """ infos = self.doubanapi.movie_top250(start=(page - 1) * count, count=count) if infos and infos.get("subject_collection_items"): return [MediaInfo(douban_info=info) for info in infos.get("subject_collection_items")] return [] async def async_movie_top250(self, page: int = 1, count: int = 30) -> List[MediaInfo]: """ 获取豆瓣电影TOP250(异步版本) """ infos = await self.doubanapi.async_movie_top250(start=(page - 1) * count, count=count) if infos and infos.get("subject_collection_items"): return [MediaInfo(douban_info=info) for info in infos.get("subject_collection_items")] return [] def metadata_nfo(self, mediainfo: MediaInfo, season: int = None, **kwargs) -> Optional[str]: """ 获取NFO文件内容文本 :param mediainfo: 媒体信息 :param season: 季号 """ if settings.SCRAP_SOURCE != "douban": return None return self.scraper.get_metadata_nfo(mediainfo=mediainfo, season=season) def metadata_img(self, mediainfo: MediaInfo, season: int = None, episode: int = None) -> Optional[dict]: """ 获取图片名称和url :param mediainfo: 媒体信息 :param season: 季号 :param episode: 集号 """ if settings.SCRAP_SOURCE != "douban": return None return self.scraper.get_metadata_img(mediainfo=mediainfo, season=season, episode=episode) @staticmethod def _validate_douban_obtain_images_params(mediainfo: MediaInfo) -> Optional[MediaInfo]: """ 验证豆瓣 obtain_images 参数 :param mediainfo: 媒体信息 :return: None 表示不处理,MediaInfo 表示继续处理 """ if settings.RECOGNIZE_SOURCE != "douban": return None if not mediainfo.douban_id: return None if mediainfo.backdrop_path: # 没有图片缺失 return mediainfo return None @staticmethod def _process_douban_images(mediainfo: MediaInfo, info: dict) -> MediaInfo: """ 处理豆瓣图片数据 :param mediainfo: 媒体信息 :param info: 图片信息 :return: 更新后的媒体信息 """ if not info: return mediainfo images = info.get("photos") # 背景图 if images: backdrop = images[0].get("image", {}).get("large") or {} if backdrop: mediainfo.backdrop_path = backdrop.get("url") return mediainfo def obtain_images(self, mediainfo: MediaInfo) -> Optional[MediaInfo]: """ 补充抓取媒体信息图片 :param mediainfo: 识别的媒体信息 :return: 更新后的媒体信息 """ # 验证参数 result = self._validate_douban_obtain_images_params(mediainfo) if result is not None: return result # 调用图片接口 if mediainfo.type == MediaType.MOVIE: info = self.doubanapi.movie_photos(mediainfo.douban_id) else: info = self.doubanapi.tv_photos(mediainfo.douban_id) # 处理图片数据 return self._process_douban_images(mediainfo, info) async def async_obtain_images(self, mediainfo: MediaInfo) -> Optional[MediaInfo]: """ 补充抓取媒体信息图片(异步版本) :param mediainfo: 识别的媒体信息 :return: 更新后的媒体信息 """ # 验证参数 result = self._validate_douban_obtain_images_params(mediainfo) if result is not None: return result # 调用图片接口 if mediainfo.type == MediaType.MOVIE: info = await self.doubanapi.async_movie_photos(mediainfo.douban_id) else: info = await self.doubanapi.async_tv_photos(mediainfo.douban_id) # 处理图片数据 return self._process_douban_images(mediainfo, info) def clear_cache(self): """ 清除缓存 """ logger.info("开始清除豆瓣缓存 ...") self.doubanapi.clear_cache() self.cache.clear() logger.info("豆瓣缓存清除完成") def douban_movie_credits(self, doubanid: str) -> List[schemas.MediaPerson]: """ 根据豆瓣ID查询电影演职员表 :param doubanid: 豆瓣ID """ result = self.doubanapi.movie_celebrities(subject_id=doubanid) return self._process_celebrity_data(result) def douban_tv_credits(self, doubanid: str) -> List[schemas.MediaPerson]: """ 根据豆瓣ID查询电视剧演职员表 :param doubanid: 豆瓣ID """ result = self.doubanapi.tv_celebrities(subject_id=doubanid) return self._process_celebrity_data(result) def douban_movie_recommend(self, doubanid: str) -> List[MediaInfo]: """ 根据豆瓣ID查询推荐电影 :param doubanid: 豆瓣ID """ recommend = self.doubanapi.movie_recommendations(subject_id=doubanid) if recommend: return [MediaInfo(douban_info=info) for info in recommend] return [] def douban_tv_recommend(self, doubanid: str) -> List[MediaInfo]: """ 根据豆瓣ID查询推荐电视剧 :param doubanid: 豆瓣ID """ recommend = self.doubanapi.tv_recommendations(subject_id=doubanid) if recommend: return [MediaInfo(douban_info=info) for info in recommend] return [] def douban_person_detail(self, person_id: int) -> schemas.MediaPerson: """ 获取人物详细信息 :param person_id: 豆瓣人物ID """ detail = self.doubanapi.person_detail(person_id) if detail: also_known_as = [] infos = detail.get("extra", {}).get("info") if infos: also_known_as = [":".join(info) for info in infos] image = detail.get("cover_img", {}).get("url") if image: image = image.replace("/l/public/", "/s/public/") return schemas.MediaPerson(source='douban', **{ "id": detail.get("id"), "name": detail.get("title"), "avatar": image, "biography": detail.get("extra", {}).get("short_info"), "also_known_as": also_known_as, }) return schemas.MediaPerson(source='douban') def douban_person_credits(self, person_id: int, page: int = 1) -> List[MediaInfo]: """ 根据TMDBID查询人物参演作品 :param person_id: 人物ID :param page: 页码 """ # 获取人物参演作品集 personinfo = self.doubanapi.person_detail(person_id) if not personinfo: return [] collection_id = None for module in personinfo.get("modules"): if module.get("type") == "work_collections": collection_id = module.get("payload", {}).get("id") # 查询作品集内容 if collection_id: collections = self.doubanapi.person_work(subject_id=collection_id, start=(page - 1) * 20, count=20) if collections: works = collections.get("works") return [MediaInfo(douban_info=work.get("subject")) for work in works] return [] @staticmethod def _process_celebrity_data(result: dict) -> List[schemas.MediaPerson]: """ 处理演职员表数据的公共方法 :param result: API返回的演职员表数据 :return: 处理后的演员列表 """ if not result: return [] ret_list = result.get("actors") or [] if ret_list: # 更新豆瓣演员信息中的ID,从URI中提取'douban://douban.com/celebrity/1316132?subject_id=27503705' subject_id for doubaninfo in ret_list: doubaninfo['id'] = doubaninfo.get('uri', '').split('?subject_id=')[-1] return [schemas.MediaPerson(source='douban', **doubaninfo) for doubaninfo in ret_list] return [] async def async_douban_movie_credits(self, doubanid: str) -> List[schemas.MediaPerson]: """ 根据豆瓣ID查询电影演职员表(异步版本) :param doubanid: 豆瓣ID """ result = await self.doubanapi.async_movie_celebrities(subject_id=doubanid) return self._process_celebrity_data(result) async def async_douban_tv_credits(self, doubanid: str) -> List[schemas.MediaPerson]: """ 根据豆瓣ID查询电视剧演职员表(异步版本) :param doubanid: 豆瓣ID """ result = await self.doubanapi.async_tv_celebrities(subject_id=doubanid) return self._process_celebrity_data(result) async def async_douban_movie_recommend(self, doubanid: str) -> List[MediaInfo]: """ 根据豆瓣ID查询推荐电影(异步版本) :param doubanid: 豆瓣ID """ recommend = await self.doubanapi.async_movie_recommendations(subject_id=doubanid) if recommend: return [MediaInfo(douban_info=info) for info in recommend] return [] async def async_douban_tv_recommend(self, doubanid: str) -> List[MediaInfo]: """ 根据豆瓣ID查询推荐电视剧(异步版本) :param doubanid: 豆瓣ID """ recommend = await self.doubanapi.async_tv_recommendations(subject_id=doubanid) if recommend: return [MediaInfo(douban_info=info) for info in recommend] return [] async def async_douban_person_detail(self, person_id: int) -> schemas.MediaPerson: """ 获取人物详细信息(异步版本) :param person_id: 豆瓣人物ID """ detail = await self.doubanapi.async_person_detail(person_id) if detail: also_known_as = [] infos = detail.get("extra", {}).get("info") if infos: also_known_as = [":".join(info) for info in infos] image = detail.get("cover_img", {}).get("url") if image: image = image.replace("/l/public/", "/s/public/") return schemas.MediaPerson(source='douban', **{ "id": detail.get("id"), "name": detail.get("title"), "avatar": image, "biography": detail.get("extra", {}).get("short_info"), "also_known_as": also_known_as, }) return schemas.MediaPerson(source='douban') async def async_douban_person_credits(self, person_id: int, page: int = 1) -> List[MediaInfo]: """ 根据豆瓣ID查询人物参演作品(异步版本) :param person_id: 人物ID :param page: 页码 """ # 获取人物参演作品集 personinfo = await self.doubanapi.async_person_detail(person_id) if not personinfo: return [] collection_id = None for module in personinfo.get("modules"): if module.get("type") == "work_collections": collection_id = module.get("payload", {}).get("id") # 查询作品集内容 if collection_id: collections = await self.doubanapi.async_person_work(subject_id=collection_id, start=(page - 1) * 20, count=20) if collections: works = collections.get("works") return [MediaInfo(douban_info=work.get("subject")) for work in works] return [] ================================================ FILE: app/modules/douban/apiv2.py ================================================ # -*- coding: utf-8 -*- import base64 import hashlib import hmac from datetime import datetime from random import choice from typing import Optional, Union from urllib import parse import httpx import requests from app.core.cache import cached from app.core.config import settings from app.utils.http import RequestUtils, AsyncRequestUtils from app.utils.singleton import WeakSingleton class DoubanApi(metaclass=WeakSingleton): _urls = { # 搜索类 # sort=U:近期热门 T:标记最多 S:评分最高 R:最新上映 # 聚合搜索 "search": "/search/weixin", "search_agg": "/search", "search_subject": "/search/subjects", "imdbid": "/movie/imdb/%s", # 电影探索 # sort=U:综合排序 T:近期热度 S:高分优先 R:首播时间 "movie_recommend": "/movie/recommend", # 电视剧探索 "tv_recommend": "/tv/recommend", # 搜索 "movie_tag": "/movie/tag", "tv_tag": "/tv/tag", "movie_search": "/search/movie", "tv_search": "/search/movie", "book_search": "/search/book", "group_search": "/search/group", # 各类主题合集 # 正在上映 "movie_showing": "/subject_collection/movie_showing/items", # 热门电影 "movie_hot_gaia": "/subject_collection/movie_hot_gaia/items", # 即将上映 "movie_soon": "/subject_collection/movie_soon/items", # TOP250 "movie_top250": "/subject_collection/movie_top250/items", # 高分经典科幻片榜 "movie_scifi": "/subject_collection/movie_scifi/items", # 高分经典喜剧片榜 "movie_comedy": "/subject_collection/movie_comedy/items", # 高分经典动作片榜 "movie_action": "/subject_collection/movie_action/items", # 高分经典爱情片榜 "movie_love": "/subject_collection/movie_love/items", # 热门剧集 "tv_hot": "/subject_collection/tv_hot/items", # 国产剧 "tv_domestic": "/subject_collection/tv_domestic/items", # 美剧 "tv_american": "/subject_collection/tv_american/items", # 本剧 "tv_japanese": "/subject_collection/tv_japanese/items", # 韩剧 "tv_korean": "/subject_collection/tv_korean/items", # 动画 "tv_animation": "/subject_collection/tv_animation/items", # 综艺 "tv_variety_show": "/subject_collection/tv_variety_show/items", # 华语口碑周榜 "tv_chinese_best_weekly": "/subject_collection/tv_chinese_best_weekly/items", # 全球口碑周榜 "tv_global_best_weekly": "/subject_collection/tv_global_best_weekly/items", # 执门综艺 "show_hot": "/subject_collection/show_hot/items", # 国内综艺 "show_domestic": "/subject_collection/show_domestic/items", # 国外综艺 "show_foreign": "/subject_collection/show_foreign/items", "book_bestseller": "/subject_collection/book_bestseller/items", "book_top250": "/subject_collection/book_top250/items", # 虚构类热门榜 "book_fiction_hot_weekly": "/subject_collection/book_fiction_hot_weekly/items", # 非虚构类热门 "book_nonfiction_hot_weekly": "/subject_collection/book_nonfiction_hot_weekly/items", # 音乐 "music_single": "/subject_collection/music_single/items", # rank list "movie_rank_list": "/movie/rank_list", "movie_year_ranks": "/movie/year_ranks", "book_rank_list": "/book/rank_list", "tv_rank_list": "/tv/rank_list", # movie info "movie_detail": "/movie/", "movie_rating": "/movie/%s/rating", "movie_photos": "/movie/%s/photos", "movie_trailers": "/movie/%s/trailers", "movie_interests": "/movie/%s/interests", "movie_reviews": "/movie/%s/reviews", "movie_recommendations": "/movie/%s/recommendations", "movie_celebrities": "/movie/%s/celebrities", # tv info "tv_detail": "/tv/", "tv_rating": "/tv/%s/rating", "tv_photos": "/tv/%s/photos", "tv_trailers": "/tv/%s/trailers", "tv_interests": "/tv/%s/interests", "tv_reviews": "/tv/%s/reviews", "tv_recommendations": "/tv/%s/recommendations", "tv_celebrities": "/tv/%s/celebrities", # book info "book_detail": "/book/", "book_rating": "/book/%s/rating", "book_interests": "/book/%s/interests", "book_reviews": "/book/%s/reviews", "book_recommendations": "/book/%s/recommendations", # music info "music_detail": "/music/", "music_rating": "/music/%s/rating", "music_interests": "/music/%s/interests", "music_reviews": "/music/%s/reviews", "music_recommendations": "/music/%s/recommendations", # doulist "doulist": "/doulist/", "doulist_items": "/doulist/%s/items", # person "person_detail": "/elessar/subject/", "person_work": "/elessar/work_collections/%s/works", } _user_agents = [ "api-client/1 com.douban.frodo/7.22.0.beta9(231) Android/23 product/Mate 40 vendor/HUAWEI model/Mate 40 brand/HUAWEI rom/android network/wifi platform/AndroidPad" "api-client/1 com.douban.frodo/7.18.0(230) Android/22 product/MI 9 vendor/Xiaomi model/MI 9 brand/Android rom/miui6 network/wifi platform/mobile nd/1", "api-client/1 com.douban.frodo/7.1.0(205) Android/29 product/perseus vendor/Xiaomi model/Mi MIX 3 rom/miui6 network/wifi platform/mobile nd/1", "api-client/1 com.douban.frodo/7.3.0(207) Android/22 product/MI 9 vendor/Xiaomi model/MI 9 brand/Android rom/miui6 network/wifi platform/mobile nd/1"] _api_secret_key = "bf7dddc7c9cfe6f7" _api_key = "0dad551ec0f84ed02907ff5c42e8ec70" _api_key2 = "0ab215a8b1977939201640fa14c66bab" _base_url = "https://frodo.douban.com/api/v2" _api_url = "https://api.douban.com/v2" def __init__(self): self._session = requests.Session() @classmethod def __sign(cls, url: str, ts: str, method='GET') -> str: """ 签名 """ url_path = parse.urlparse(url).path raw_sign = '&'.join([method.upper(), parse.quote(url_path, safe=''), ts]) return base64.b64encode( hmac.new( cls._api_secret_key.encode(), raw_sign.encode(), hashlib.sha1 ).digest() ).decode() def __invoke_recommend(self, url: str, **kwargs) -> dict: """ 推荐/发现类API """ return self.__invoke(url, **kwargs) async def __async_invoke_recommend(self, url: str, **kwargs) -> dict: """ 推荐/发现类API(异步版本) """ return await self.__async_invoke(url, **kwargs) def __invoke_search(self, url: str, **kwargs) -> dict: """ 搜索类API """ return self.__invoke(url, **kwargs) async def __async_invoke_search(self, url: str, **kwargs) -> dict: """ 搜索类API(异步版本) """ return await self.__async_invoke(url, **kwargs) def _prepare_get_request(self, url: str, **kwargs) -> tuple[str, dict]: """ 准备GET请求的URL和参数 """ req_url = self._base_url + url params: dict = {'apiKey': self._api_key} if kwargs: params.update(kwargs) ts = params.pop( '_ts', datetime.strftime(datetime.now(), '%Y%m%d') ) params.update({ 'os_rom': 'android', 'apiKey': self._api_key, '_ts': ts, '_sig': self.__sign(url=req_url, ts=ts) }) return req_url, params @staticmethod def _handle_response(resp: Union[requests.Response, httpx.Response]) -> dict: """ 处理HTTP响应 """ return resp.json() if resp is not None else None @cached(maxsize=settings.CONF.douban, ttl=settings.CONF.meta, skip_none=True, shared_key="get") def __invoke(self, url: str, **kwargs) -> dict: """ GET请求 """ req_url, params = self._prepare_get_request(url, **kwargs) resp = RequestUtils( ua=choice(self._user_agents), session=self._session ).get_res(url=req_url, params=params) return self._handle_response(resp) @cached(maxsize=settings.CONF.douban, ttl=settings.CONF.meta, skip_none=True, shared_key="get") async def __async_invoke(self, url: str, **kwargs) -> dict: """ GET请求(异步版本) """ req_url, params = self._prepare_get_request(url, **kwargs) resp = await AsyncRequestUtils( ua=choice(self._user_agents) ).get_res(url=req_url, params=params) return self._handle_response(resp) def _prepare_post_request(self, url: str, **kwargs) -> tuple[str, dict]: """ 准备POST请求的URL和参数 """ req_url = self._api_url + url params = {'apikey': self._api_key2} if kwargs: params.update(kwargs) if '_ts' in params: params.pop('_ts') return req_url, params @cached(maxsize=settings.CONF.douban, ttl=settings.CONF.meta, skip_none=True, shared_key="post") def __post(self, url: str, **kwargs) -> dict: """ POST请求 esponse = requests.post( url="https://api.douban.com/v2/movie/imdb/tt29139455", headers={ "Content-Type": "application/x-www-form-urlencoded; charset=utf-8", "Cookie": "bid=J9zb1zA5sJc", }, data={ "apikey": "0ab215a8b1977939201640fa14c66bab", } ) """ req_url, params = self._prepare_post_request(url, **kwargs) resp = RequestUtils( ua=settings.NORMAL_USER_AGENT, session=self._session, ).post_res(url=req_url, data=params) return self._handle_response(resp) @cached(maxsize=settings.CONF.douban, ttl=settings.CONF.meta, skip_none=True, shared_key="post") async def __async_post(self, url: str, **kwargs) -> dict: """ POST请求(异步版本) """ req_url, params = self._prepare_post_request(url, **kwargs) resp = await AsyncRequestUtils( ua=settings.NORMAL_USER_AGENT ).post_res(url=req_url, data=params) return self._handle_response(resp) def imdbid(self, imdbid: str, ts=datetime.strftime(datetime.now(), '%Y%m%d')): """ IMDBID搜索 """ return self.__post(self._urls["imdbid"] % imdbid, _ts=ts) async def async_imdbid(self, imdbid: str, ts=datetime.strftime(datetime.now(), '%Y%m%d')): """ IMDBID搜索(异步版本) """ return await self.__async_post(self._urls["imdbid"] % imdbid, _ts=ts) def search(self, keyword: str, start: Optional[int] = 0, count: Optional[int] = 20, ts=datetime.strftime(datetime.now(), '%Y%m%d')) -> dict: """ 关键字搜索 """ return self.__invoke_search(self._urls["search"], q=keyword, start=start, count=count, _ts=ts) async def async_search(self, keyword: str, start: Optional[int] = 0, count: Optional[int] = 20, ts=datetime.strftime(datetime.now(), '%Y%m%d')) -> dict: """ 关键字搜索(异步版本) """ return await self.__async_invoke_search(self._urls["search"], q=keyword, start=start, count=count, _ts=ts) def movie_search(self, keyword: str, start: Optional[int] = 0, count: Optional[int] = 20, ts=datetime.strftime(datetime.now(), '%Y%m%d')): """ 电影搜索 """ return self.__invoke_search(self._urls["movie_search"], q=keyword, start=start, count=count, _ts=ts) async def async_movie_search(self, keyword: str, start: Optional[int] = 0, count: Optional[int] = 20, ts=datetime.strftime(datetime.now(), '%Y%m%d')): """ 电影搜索(异步版本) """ return await self.__async_invoke_search(self._urls["movie_search"], q=keyword, start=start, count=count, _ts=ts) def tv_search(self, keyword: str, start: Optional[int] = 0, count: Optional[int] = 20, ts=datetime.strftime(datetime.now(), '%Y%m%d')): """ 电视搜索 """ return self.__invoke_search(self._urls["tv_search"], q=keyword, start=start, count=count, _ts=ts) async def async_tv_search(self, keyword: str, start: Optional[int] = 0, count: Optional[int] = 20, ts=datetime.strftime(datetime.now(), '%Y%m%d')): """ 电视搜索(异步版本) """ return await self.__async_invoke_search(self._urls["tv_search"], q=keyword, start=start, count=count, _ts=ts) def book_search(self, keyword: str, start: Optional[int] = 0, count: Optional[int] = 20, ts=datetime.strftime(datetime.now(), '%Y%m%d')): """ 书籍搜索 """ return self.__invoke_search(self._urls["book_search"], q=keyword, start=start, count=count, _ts=ts) async def async_book_search(self, keyword: str, start: Optional[int] = 0, count: Optional[int] = 20, ts=datetime.strftime(datetime.now(), '%Y%m%d')): """ 书籍搜索(异步版本) """ return await self.__async_invoke_search(self._urls["book_search"], q=keyword, start=start, count=count, _ts=ts) def group_search(self, keyword: str, start: Optional[int] = 0, count: Optional[int] = 20, ts=datetime.strftime(datetime.now(), '%Y%m%d')): """ 小组搜索 """ return self.__invoke_search(self._urls["group_search"], q=keyword, start=start, count=count, _ts=ts) async def async_group_search(self, keyword: str, start: Optional[int] = 0, count: Optional[int] = 20, ts=datetime.strftime(datetime.now(), '%Y%m%d')): """ 小组搜索(异步版本) """ return await self.__async_invoke_search(self._urls["group_search"], q=keyword, start=start, count=count, _ts=ts) def person_search(self, keyword: str, start: Optional[int] = 0, count: Optional[int] = 20, ts=datetime.strftime(datetime.now(), '%Y%m%d')): """ 人物搜索 """ return self.__invoke_search(self._urls["search_subject"], type="person", q=keyword, start=start, count=count, _ts=ts) async def async_person_search(self, keyword: str, start: Optional[int] = 0, count: Optional[int] = 20, ts=datetime.strftime(datetime.now(), '%Y%m%d')): """ 人物搜索(异步版本) """ return await self.__async_invoke_search(self._urls["search_subject"], type="person", q=keyword, start=start, count=count, _ts=ts) def movie_showing(self, start: Optional[int] = 0, count: Optional[int] = 20, ts=datetime.strftime(datetime.now(), '%Y%m%d')): """ 正在热映 """ return self.__invoke_recommend(self._urls["movie_showing"], start=start, count=count, _ts=ts) async def async_movie_showing(self, start: Optional[int] = 0, count: Optional[int] = 20, ts=datetime.strftime(datetime.now(), '%Y%m%d')): """ 正在热映(异步版本) """ return await self.__async_invoke_recommend(self._urls["movie_showing"], start=start, count=count, _ts=ts) def movie_soon(self, start: Optional[int] = 0, count: Optional[int] = 20, ts=datetime.strftime(datetime.now(), '%Y%m%d')): """ 即将上映 """ return self.__invoke_recommend(self._urls["movie_soon"], start=start, count=count, _ts=ts) async def async_movie_soon(self, start: Optional[int] = 0, count: Optional[int] = 20, ts=datetime.strftime(datetime.now(), '%Y%m%d')): """ 即将上映(异步版本) """ return await self.__async_invoke_recommend(self._urls["movie_soon"], start=start, count=count, _ts=ts) def movie_hot_gaia(self, start: Optional[int] = 0, count: Optional[int] = 20, ts=datetime.strftime(datetime.now(), '%Y%m%d')): """ 热门电影 """ return self.__invoke_recommend(self._urls["movie_hot_gaia"], start=start, count=count, _ts=ts) async def async_movie_hot_gaia(self, start: Optional[int] = 0, count: Optional[int] = 20, ts=datetime.strftime(datetime.now(), '%Y%m%d')): """ 热门电影(异步版本) """ return await self.__async_invoke_recommend(self._urls["movie_hot_gaia"], start=start, count=count, _ts=ts) def tv_hot(self, start: Optional[int] = 0, count: Optional[int] = 20, ts=datetime.strftime(datetime.now(), '%Y%m%d')): """ 热门剧集 """ return self.__invoke_recommend(self._urls["tv_hot"], start=start, count=count, _ts=ts) async def async_tv_hot(self, start: Optional[int] = 0, count: Optional[int] = 20, ts=datetime.strftime(datetime.now(), '%Y%m%d')): """ 热门剧集(异步版本) """ return await self.__async_invoke_recommend(self._urls["tv_hot"], start=start, count=count, _ts=ts) def tv_animation(self, start: Optional[int] = 0, count: Optional[int] = 20, ts=datetime.strftime(datetime.now(), '%Y%m%d')): """ 动画 """ return self.__invoke_recommend(self._urls["tv_animation"], start=start, count=count, _ts=ts) async def async_tv_animation(self, start: Optional[int] = 0, count: Optional[int] = 20, ts=datetime.strftime(datetime.now(), '%Y%m%d')): """ 动画(异步版本) """ return await self.__async_invoke_recommend(self._urls["tv_animation"], start=start, count=count, _ts=ts) def tv_variety_show(self, start: Optional[int] = 0, count: Optional[int] = 20, ts=datetime.strftime(datetime.now(), '%Y%m%d')): """ 综艺 """ return self.__invoke_recommend(self._urls["tv_variety_show"], start=start, count=count, _ts=ts) async def async_tv_variety_show(self, start: Optional[int] = 0, count: Optional[int] = 20, ts=datetime.strftime(datetime.now(), '%Y%m%d')): """ 综艺(异步版本) """ return await self.__async_invoke_recommend(self._urls["tv_variety_show"], start=start, count=count, _ts=ts) def tv_rank_list(self, start: Optional[int] = 0, count: Optional[int] = 20, ts=datetime.strftime(datetime.now(), '%Y%m%d')): """ 电视剧排行榜 """ return self.__invoke_recommend(self._urls["tv_rank_list"], start=start, count=count, _ts=ts) async def async_tv_rank_list(self, start: Optional[int] = 0, count: Optional[int] = 20, ts=datetime.strftime(datetime.now(), '%Y%m%d')): """ 电视剧排行榜(异步版本) """ return await self.__async_invoke_recommend(self._urls["tv_rank_list"], start=start, count=count, _ts=ts) def show_hot(self, start: Optional[int] = 0, count: Optional[int] = 20, ts=datetime.strftime(datetime.now(), '%Y%m%d')): """ 综艺热门 """ return self.__invoke_recommend(self._urls["show_hot"], start=start, count=count, _ts=ts) async def async_show_hot(self, start: Optional[int] = 0, count: Optional[int] = 20, ts=datetime.strftime(datetime.now(), '%Y%m%d')): """ 综艺热门(异步版本) """ return await self.__async_invoke_recommend(self._urls["show_hot"], start=start, count=count, _ts=ts) def movie_detail(self, subject_id: str): """ 电影详情 """ return self.__invoke_search(self._urls["movie_detail"] + subject_id) async def async_movie_detail(self, subject_id: str): """ 电影详情(异步版本) """ return await self.__async_invoke_search(self._urls["movie_detail"] + subject_id) def movie_celebrities(self, subject_id: str): """ 电影演职员 """ return self.__invoke_search(self._urls["movie_celebrities"] % subject_id) async def async_movie_celebrities(self, subject_id: str): """ 电影演职员(异步版本) """ return await self.__async_invoke_search(self._urls["movie_celebrities"] % subject_id) def tv_detail(self, subject_id: str): """ 电视剧详情 """ return self.__invoke_search(self._urls["tv_detail"] + subject_id) async def async_tv_detail(self, subject_id: str): """ 电视剧详情(异步版本) """ return await self.__async_invoke_search(self._urls["tv_detail"] + subject_id) def tv_celebrities(self, subject_id: str): """ 电视剧演职员 """ return self.__invoke_search(self._urls["tv_celebrities"] % subject_id) async def async_tv_celebrities(self, subject_id: str): """ 电视剧演职员(异步版本) """ return await self.__async_invoke_search(self._urls["tv_celebrities"] % subject_id) def book_detail(self, subject_id: str): """ 书籍详情 """ return self.__invoke_search(self._urls["book_detail"] + subject_id) async def async_book_detail(self, subject_id: str): """ 书籍详情(异步版本) """ return await self.__async_invoke_search(self._urls["book_detail"] + subject_id) def movie_top250(self, start: Optional[int] = 0, count: Optional[int] = 20, ts=datetime.strftime(datetime.now(), '%Y%m%d')): """ 电影TOP250 """ return self.__invoke_recommend(self._urls["movie_top250"], start=start, count=count, _ts=ts) async def async_movie_top250(self, start: Optional[int] = 0, count: Optional[int] = 20, ts=datetime.strftime(datetime.now(), '%Y%m%d')): """ 电影TOP250(异步版本) """ return await self.__async_invoke_recommend(self._urls["movie_top250"], start=start, count=count, _ts=ts) def movie_recommend(self, tags='', sort='R', start: Optional[int] = 0, count: Optional[int] = 20, ts=datetime.strftime(datetime.now(), '%Y%m%d')): """ 电影探索 """ return self.__invoke_recommend(self._urls["movie_recommend"], tags=tags, sort=sort, start=start, count=count, _ts=ts) async def async_movie_recommend(self, tags='', sort='R', start: Optional[int] = 0, count: Optional[int] = 20, ts=datetime.strftime(datetime.now(), '%Y%m%d')): """ 电影探索(异步版本) """ return await self.__async_invoke_recommend(self._urls["movie_recommend"], tags=tags, sort=sort, start=start, count=count, _ts=ts) def tv_recommend(self, tags='', sort='R', start: Optional[int] = 0, count: Optional[int] = 20, ts=datetime.strftime(datetime.now(), '%Y%m%d')): """ 电视剧探索 """ return self.__invoke_recommend(self._urls["tv_recommend"], tags=tags, sort=sort, start=start, count=count, _ts=ts) async def async_tv_recommend(self, tags='', sort='R', start: Optional[int] = 0, count: Optional[int] = 20, ts=datetime.strftime(datetime.now(), '%Y%m%d')): """ 电视剧探索(异步版本) """ return await self.__async_invoke_recommend(self._urls["tv_recommend"], tags=tags, sort=sort, start=start, count=count, _ts=ts) def tv_chinese_best_weekly(self, start: Optional[int] = 0, count: Optional[int] = 20, ts=datetime.strftime(datetime.now(), '%Y%m%d')): """ 华语口碑周榜 """ return self.__invoke_recommend(self._urls["tv_chinese_best_weekly"], start=start, count=count, _ts=ts) async def async_tv_chinese_best_weekly(self, start: Optional[int] = 0, count: Optional[int] = 20, ts=datetime.strftime(datetime.now(), '%Y%m%d')): """ 华语口碑周榜(异步版本) """ return await self.__async_invoke_recommend(self._urls["tv_chinese_best_weekly"], start=start, count=count, _ts=ts) def tv_global_best_weekly(self, start: Optional[int] = 0, count: Optional[int] = 20, ts=datetime.strftime(datetime.now(), '%Y%m%d')): """ 全球口碑周榜 """ return self.__invoke_recommend(self._urls["tv_global_best_weekly"], start=start, count=count, _ts=ts) async def async_tv_global_best_weekly(self, start: Optional[int] = 0, count: Optional[int] = 20, ts=datetime.strftime(datetime.now(), '%Y%m%d')): """ 全球口碑周榜(异步版本) """ return await self.__async_invoke_recommend(self._urls["tv_global_best_weekly"], start=start, count=count, _ts=ts) def doulist_detail(self, subject_id: str): """ 豆列详情 :param subject_id: 豆列id """ return self.__invoke_search(self._urls["doulist"] + subject_id) async def async_doulist_detail(self, subject_id: str): """ 豆列详情(异步版本) :param subject_id: 豆列id """ return await self.__async_invoke_search(self._urls["doulist"] + subject_id) def doulist_items(self, subject_id: str, start: Optional[int] = 0, count: Optional[int] = 20, ts=datetime.strftime(datetime.now(), '%Y%m%d')): """ 豆列列表 :param subject_id: 豆列id :param start: 开始 :param count: 数量 :param ts: 时间戳 """ return self.__invoke_search(self._urls["doulist_items"] % subject_id, start=start, count=count, _ts=ts) async def async_doulist_items(self, subject_id: str, start: Optional[int] = 0, count: Optional[int] = 20, ts=datetime.strftime(datetime.now(), '%Y%m%d')): """ 豆列列表(异步版本) :param subject_id: 豆列id :param start: 开始 :param count: 数量 :param ts: 时间戳 """ return await self.__async_invoke_search(self._urls["doulist_items"] % subject_id, start=start, count=count, _ts=ts) def movie_recommendations(self, subject_id: str, start: Optional[int] = 0, count: Optional[int] = 20, ts=datetime.strftime(datetime.now(), '%Y%m%d')): """ 电影推荐 :param subject_id: 电影id :param start: 开始 :param count: 数量 :param ts: 时间戳 """ return self.__invoke_recommend(self._urls["movie_recommendations"] % subject_id, start=start, count=count, _ts=ts) async def async_movie_recommendations(self, subject_id: str, start: Optional[int] = 0, count: Optional[int] = 20, ts=datetime.strftime(datetime.now(), '%Y%m%d')): """ 电影推荐(异步版本) :param subject_id: 电影id :param start: 开始 :param count: 数量 :param ts: 时间戳 """ return await self.__async_invoke_recommend(self._urls["movie_recommendations"] % subject_id, start=start, count=count, _ts=ts) def tv_recommendations(self, subject_id: str, start: Optional[int] = 0, count: Optional[int] = 20, ts=datetime.strftime(datetime.now(), '%Y%m%d')): """ 电视剧推荐 :param subject_id: 电视剧id :param start: 开始 :param count: 数量 :param ts: 时间戳 """ return self.__invoke_recommend(self._urls["tv_recommendations"] % subject_id, start=start, count=count, _ts=ts) async def async_tv_recommendations(self, subject_id: str, start: Optional[int] = 0, count: Optional[int] = 20, ts=datetime.strftime(datetime.now(), '%Y%m%d')): """ 电视剧推荐(异步版本) :param subject_id: 电视剧id :param start: 开始 :param count: 数量 :param ts: 时间戳 """ return await self.__async_invoke_recommend(self._urls["tv_recommendations"] % subject_id, start=start, count=count, _ts=ts) def movie_photos(self, subject_id: str, start: Optional[int] = 0, count: Optional[int] = 20, ts=datetime.strftime(datetime.now(), '%Y%m%d')): """ 电影剧照 :param subject_id: 电影id :param start: 开始 :param count: 数量 :param ts: 时间戳 """ return self.__invoke_search(self._urls["movie_photos"] % subject_id, start=start, count=count, _ts=ts) async def async_movie_photos(self, subject_id: str, start: Optional[int] = 0, count: Optional[int] = 20, ts=datetime.strftime(datetime.now(), '%Y%m%d')): """ 电影剧照(异步版本) :param subject_id: 电影id :param start: 开始 :param count: 数量 :param ts: 时间戳 """ return await self.__async_invoke_search(self._urls["movie_photos"] % subject_id, start=start, count=count, _ts=ts) def tv_photos(self, subject_id: str, start: Optional[int] = 0, count: Optional[int] = 20, ts=datetime.strftime(datetime.now(), '%Y%m%d')): """ 电视剧剧照 :param subject_id: 电视剧id :param start: 开始 :param count: 数量 :param ts: 时间戳 """ return self.__invoke_search(self._urls["tv_photos"] % subject_id, start=start, count=count, _ts=ts) async def async_tv_photos(self, subject_id: str, start: Optional[int] = 0, count: Optional[int] = 20, ts=datetime.strftime(datetime.now(), '%Y%m%d')): """ 电视剧剧照(异步版本) :param subject_id: 电视剧id :param start: 开始 :param count: 数量 :param ts: 时间戳 """ return await self.__async_invoke_search(self._urls["tv_photos"] % subject_id, start=start, count=count, _ts=ts) def person_detail(self, subject_id: int): """ 用户详情 :param subject_id: 人物 id :return: """ return self.__invoke_search(self._urls["person_detail"] + str(subject_id)) async def async_person_detail(self, subject_id: int): """ 用户详情(异步版本) :param subject_id: 人物 id :return: """ return await self.__async_invoke_search(self._urls["person_detail"] + str(subject_id)) def person_work(self, subject_id: int, start: Optional[int] = 0, count: Optional[int] = 20, sort_by: Optional[str] = "time", collection_title: Optional[str] = "影视", ts=datetime.strftime(datetime.now(), '%Y%m%d')): """ 用户作品集 :param subject_id: work_collection id :param start: 开始页 :param count: 数量 :param sort_by: collection or time or vote :param collection_title: 影视 or 图书 or 音乐 :param ts: 时间戳 :return: """ return self.__invoke_search(self._urls["person_work"] % subject_id, sortby=sort_by, collection_title=collection_title, start=start, count=count, _ts=ts) async def async_person_work(self, subject_id: int, start: Optional[int] = 0, count: Optional[int] = 20, sort_by: Optional[str] = "time", collection_title: Optional[str] = "影视", ts=datetime.strftime(datetime.now(), '%Y%m%d')): """ 用户作品集(异步版本) :param subject_id: work_collection id :param start: 开始页 :param count: 数量 :param sort_by: collection or time or vote :param collection_title: 影视 or 图书 or 音乐 :param ts: 时间戳 :return: """ return await self.__async_invoke_search(self._urls["person_work"] % subject_id, sortby=sort_by, collection_title=collection_title, start=start, count=count, _ts=ts) def clear_cache(self): """ 清空LRU缓存 """ self.__invoke.cache_clear() self.__post.cache_clear() def close(self): if self._session: self._session.close() ================================================ FILE: app/modules/douban/douban_cache.py ================================================ import pickle import traceback from pathlib import Path from threading import RLock from typing import Optional from app.core.cache import TTLCache from app.core.config import settings from app.core.meta import MetaBase from app.core.metainfo import MetaInfo from app.log import logger from app.schemas.types import MediaType from app.utils.singleton import WeakSingleton lock = RLock() class DoubanCache(metaclass=WeakSingleton): """ 豆瓣缓存数据 { "id": '', "title": '', "year": '', "type": MediaType } """ # TMDB缓存过期 _douban_cache_expire: bool = True def __init__(self): self.maxsize = settings.CONF.douban self.ttl = settings.CONF.meta self.region = "__douban_cache__" self._meta_filepath = settings.TEMP_PATH / self.region # 初始化缓存 self._cache = TTLCache(region=self.region, maxsize=self.maxsize, ttl=self.ttl) # 非Redis加载本地缓存数据 if not self._cache.is_redis(): for key, value in self.__load(self._meta_filepath).items(): self._cache.set(key, value) def clear(self): """ 清空所有豆瓣缓存 """ with lock: self._cache.clear() @staticmethod def __get_key(meta: MetaBase) -> str: """ 获取缓存KEY """ return f"[{meta.type.value if meta.type else '未知'}]" \ f"{meta.doubanid or meta.name}-{meta.year}-{meta.begin_season}" def get(self, meta: MetaBase): """ 根据KEY值获取缓存值 """ key = self.__get_key(meta) with lock: return self._cache.get(key) or {} def delete(self, key: str) -> dict: """ 删除缓存信息 @param key: 缓存key @return: 被删除的缓存内容 """ with lock: redis_data = self._cache.get(key) if redis_data: self._cache.delete(key) return redis_data return {} def modify(self, key: str, title: str) -> dict: """ 修改缓存信息 @param key: 缓存key @param title: 标题 @return: 被修改后缓存内容 """ with lock: redis_data = self._cache.get(key) if redis_data: redis_data["title"] = title self._cache.set(key, redis_data) return redis_data return {} @staticmethod def __load(path: Path) -> dict: """ 从文件中加载缓存 """ try: if path.exists(): with open(path, 'rb') as f: data = pickle.load(f) return data except Exception as e: logger.error(f"加载缓存失败: {str(e)} - {traceback.format_exc()}") return {} def update(self, meta: MetaBase, info: dict) -> None: """ 新增或更新缓存条目 """ if info: # 缓存标题 cache_title = info.get("title") # 缓存年份 cache_year = info.get('year') # 类型 if isinstance(info.get('media_type'), MediaType): mtype = info.get('media_type') elif info.get("type"): mtype = MediaType.MOVIE if info.get("type") == "movie" else MediaType.TV else: meta = MetaInfo(cache_title) if meta.begin_season: mtype = MediaType.TV else: mtype = MediaType.MOVIE # 海报 poster_path = info.get("pic", {}).get("large") if not poster_path and info.get("cover_url"): poster_path = info.get("cover_url") if not poster_path and info.get("cover"): poster_path = info.get("cover").get("url") with lock: self._cache.set(self.__get_key(meta), { "id": info.get("id"), "type": mtype, "year": cache_year, "title": cache_title, "poster_path": poster_path }) elif info is not None: # None时不缓存,此时代表网络错误,允许重复请求 with lock: self._cache.set(self.__get_key(meta), { "id": 0 }) def save(self, force: Optional[bool] = False) -> None: """ 保存缓存数据到文件 """ # Redis不需要保存到本地文件 if self._cache.is_redis(): return # 本地文件 meta_data = self.__load(self._meta_filepath) # 当前缓存数据(去除无法识别) new_meta_data = {k: v for k, v in self._cache.items() if v.get("id")} if not force \ and meta_data.keys() == new_meta_data.keys(): return # 写入本地 with open(self._meta_filepath, 'wb') as f: pickle.dump(new_meta_data, f, pickle.HIGHEST_PROTOCOL) # noqa def __del__(self): self.save() ================================================ FILE: app/modules/douban/scraper.py ================================================ from pathlib import Path from typing import Optional from xml.dom import minidom from app.core.context import MediaInfo from app.schemas.types import MediaType from app.utils.dom import DomUtils class DoubanScraper: _force_nfo = False _force_img = False def get_metadata_nfo(self, mediainfo: MediaInfo, season: Optional[int] = None) -> Optional[str]: """ 获取NFO文件内容文本 :param mediainfo: 媒体信息 :param season: 季号 """ if mediainfo.type == MediaType.MOVIE: # 电影元数据文件 doc = self.__gen_movie_nfo_file(mediainfo=mediainfo) else: if season is not None: # 季元数据文件 doc = self.__gen_tv_season_nfo_file(mediainfo=mediainfo, season=season) else: # 电视剧元数据文件 doc = self.__gen_tv_nfo_file(mediainfo=mediainfo) if doc: return doc.toprettyxml(indent=" ", encoding="utf-8") # noqa return None @staticmethod def get_metadata_img(mediainfo: MediaInfo, season: Optional[int] = None, episode: Optional[int] = None) -> Optional[dict]: """ 获取图片内容 :param mediainfo: 媒体信息 :param season: 季号 :param episode: 集号 """ ret_dict = {} if season is not None: # 豆瓣无季图片 return {} if episode: # 豆瓣无集图片 return {} if mediainfo.poster_path: ret_dict[f"poster{Path(mediainfo.poster_path).suffix}"] = mediainfo.poster_path if mediainfo.backdrop_path: ret_dict[f"backdrop{Path(mediainfo.backdrop_path).suffix}"] = mediainfo.backdrop_path return ret_dict @staticmethod def __gen_common_nfo(mediainfo: MediaInfo, doc: minidom.Document, root: minidom.Node): # 简介 xplot = DomUtils.add_node(doc, root, "plot") xplot.appendChild(doc.createCDATASection(mediainfo.overview or "")) xoutline = DomUtils.add_node(doc, root, "outline") xoutline.appendChild(doc.createCDATASection(mediainfo.overview or "")) # 导演 for director in mediainfo.directors: DomUtils.add_node(doc, root, "director", director.get("name") or "") # 演员 for actor in mediainfo.actors: xactor = DomUtils.add_node(doc, root, "actor") DomUtils.add_node(doc, xactor, "name", actor.get("name") or "") DomUtils.add_node(doc, xactor, "type", "Actor") DomUtils.add_node(doc, xactor, "role", actor.get("character") or actor.get("role") or "") DomUtils.add_node(doc, xactor, "thumb", actor.get('avatar', {}).get('normal')) DomUtils.add_node(doc, xactor, "profile", actor.get('url')) # 评分 DomUtils.add_node(doc, root, "rating", mediainfo.vote_average or "0") return doc def __gen_movie_nfo_file(self, mediainfo: MediaInfo) -> minidom.Document: """ 生成电影的NFO描述文件 :param mediainfo: 豆瓣信息 """ # 开始生成XML doc = minidom.Document() root = DomUtils.add_node(doc, doc, "movie") # 公共部分 doc = self.__gen_common_nfo(mediainfo=mediainfo, doc=doc, root=root) # 标题 DomUtils.add_node(doc, root, "title", mediainfo.title or "") # 年份 DomUtils.add_node(doc, root, "year", mediainfo.year or "") return doc def __gen_tv_nfo_file(self, mediainfo: MediaInfo) -> minidom.Document: """ 生成电视剧的NFO描述文件 :param mediainfo: 媒体信息 """ # 开始生成XML doc = minidom.Document() root = DomUtils.add_node(doc, doc, "tvshow") # 公共部分 doc = self.__gen_common_nfo(mediainfo=mediainfo, doc=doc, root=root) # 标题 DomUtils.add_node(doc, root, "title", mediainfo.title or "") # 年份 DomUtils.add_node(doc, root, "year", mediainfo.year or "") DomUtils.add_node(doc, root, "season", "-1") DomUtils.add_node(doc, root, "episode", "-1") return doc @staticmethod def __gen_tv_season_nfo_file(mediainfo: MediaInfo, season: int) -> minidom.Document: """ 生成电视剧季的NFO描述文件 :param mediainfo: 媒体信息 :param season: 季号 """ doc = minidom.Document() root = DomUtils.add_node(doc, doc, "season") # 简介 xplot = DomUtils.add_node(doc, root, "plot") xplot.appendChild(doc.createCDATASection(mediainfo.overview or "")) xoutline = DomUtils.add_node(doc, root, "outline") xoutline.appendChild(doc.createCDATASection(mediainfo.overview or "")) # 标题 DomUtils.add_node(doc, root, "title", "季 %s" % season) # 发行日期 DomUtils.add_node(doc, root, "premiered", mediainfo.release_date or "") DomUtils.add_node(doc, root, "releasedate", mediainfo.release_date or "") # 发行年份 DomUtils.add_node(doc, root, "year", mediainfo.release_date[:4] if mediainfo.release_date else "") # seasonnumber DomUtils.add_node(doc, root, "seasonnumber", str(season)) return doc ================================================ FILE: app/modules/emby/__init__.py ================================================ from typing import Any, Generator, List, Optional, Tuple, Union from app import schemas from app.core.context import MediaInfo from app.core.event import eventmanager from app.log import logger from app.modules import _MediaServerBase, _ModuleBase from app.modules.emby.emby import Emby from app.schemas.types import MediaType, ModuleType, ChainEventType, MediaServerType class EmbyModule(_ModuleBase, _MediaServerBase[Emby]): def init_module(self) -> None: """ 初始化模块 """ super().init_service(service_name=Emby.__name__.lower(), service_type=lambda conf: Emby(**conf.config, sync_libraries=conf.sync_libraries)) @staticmethod def get_name() -> str: return "Emby" @staticmethod def get_type() -> ModuleType: """ 获取模块类型 """ return ModuleType.MediaServer @staticmethod def get_subtype() -> MediaServerType: """ 获取模块子类型 """ return MediaServerType.Emby @staticmethod def get_priority() -> int: """ 获取模块优先级,数字越小优先级越高,只有同一接口下优先级才生效 """ return 1 def stop(self): pass def test(self) -> Optional[Tuple[bool, str]]: """ 测试模块连接性 """ if not self.get_instances(): return None for name, server in self.get_instances().items(): if server.is_inactive(): server.reconnect() if not server.get_user(): return False, f"无法连接Emby服务器:{name}" return True, "" def init_setting(self) -> Tuple[str, Union[str, bool]]: pass def scheduler_job(self) -> None: """ 定时任务,每10分钟调用一次 """ # 定时重连 for name, server in self.get_instances().items(): if server.is_inactive(): logger.info(f"Emby服务器 {name} 连接断开,尝试重连 ...") server.reconnect() def user_authenticate(self, credentials: schemas.AuthCredentials, service_name: Optional[str] = None) \ -> Optional[schemas.AuthCredentials]: """ 使用Emby用户辅助完成用户认证 :param credentials: 认证数据 :param service_name: 指定要认证的媒体服务器名称,若为 None 则认证所有服务 :return: 认证数据 """ # Emby认证 if not credentials or credentials.grant_type != "password": return None # 确定要认证的服务器列表 if service_name: # 如果指定了服务名,获取该服务实例 servers = [(service_name, server)] if (server := self.get_instance(service_name)) else [] else: # 如果没有指定服务名,遍历所有服务 servers = self.get_instances().items() # 遍历要认证的服务器 for name, server in servers: # 触发认证拦截事件 intercept_event = eventmanager.send_event( etype=ChainEventType.AuthIntercept, data=schemas.AuthInterceptCredentials(username=credentials.username, channel=self.get_name(), service=name, status="triggered") ) if intercept_event and intercept_event.event_data: intercept_data: schemas.AuthInterceptCredentials = intercept_event.event_data if intercept_data.cancel: continue token = server.authenticate(credentials.username, credentials.password) if token: credentials.channel = self.get_name() credentials.service = name credentials.token = token return credentials return None def webhook_parser(self, body: Any, form: Any, args: Any) -> Optional[schemas.WebhookEventInfo]: """ 解析Webhook报文体 :param body: 请求体 :param form: 请求表单 :param args: 请求参数 :return: 字典,解析为消息时需要包含:title、text、image """ source = args.get("source") if source: server: Emby = self.get_instance(source) if not server: return None result = server.get_webhook_message(form, args) if result: result.server_name = source return result for server in self.get_instances().values(): if server: result = server.get_webhook_message(form, args) if result: return result return None def media_exists(self, mediainfo: MediaInfo, itemid: Optional[str] = None, server: Optional[str] = None) -> Optional[schemas.ExistMediaInfo]: """ 判断媒体文件是否存在 :param mediainfo: 识别的媒体信息 :param itemid: 媒体服务器ItemID :param server: 媒体服务器名称 :return: 如不存在返回None,存在时返回信息,包括每季已存在所有集{type: movie/tv, seasons: {season: [episodes]}} """ if server: servers = [(server, self.get_instance(server))] else: servers = self.get_instances().items() for name, s in servers: if not s: continue if mediainfo.type == MediaType.MOVIE: if itemid: movie = s.get_iteminfo(itemid) if movie: logger.info(f"媒体库 {name} 中找到了 {movie}") return schemas.ExistMediaInfo( type=MediaType.MOVIE, server_type="emby", server=name, itemid=movie.item_id ) movies = s.get_movies(title=mediainfo.title, year=mediainfo.year, tmdb_id=mediainfo.tmdb_id) if not movies: logger.info(f"{mediainfo.title_year} 没有在媒体库 {name} 中") continue else: logger.info(f"媒体库 {name} 中找到了 {movies}") return schemas.ExistMediaInfo( type=MediaType.MOVIE, server_type="emby", server=name, itemid=movies[0].item_id ) else: itemid, tvs = s.get_tv_episodes(title=mediainfo.title, year=mediainfo.year, tmdb_id=mediainfo.tmdb_id, item_id=itemid) if not tvs: logger.info(f"{mediainfo.title_year} 没有在媒体库 {name} 中") continue else: logger.info(f"{mediainfo.title_year} 在媒体库 {name} 中找到了这些季集:{tvs}") return schemas.ExistMediaInfo( type=MediaType.TV, seasons=tvs, server_type="emby", server=name, itemid=itemid ) return None def media_statistic(self, server: Optional[str] = None) -> Optional[List[schemas.Statistic]]: """ 媒体数量统计 """ if server: server_obj: Emby = self.get_instance(server) if not server_obj: return None servers = [server_obj] else: servers = self.get_instances().values() media_statistics = [] for s in servers: media_statistic = s.get_medias_count() if not media_statistic: continue media_statistic.user_count = s.get_user_count() media_statistics.append(media_statistic) return media_statistics def mediaserver_librarys(self, server: str, username: Optional[str] = None, hidden: Optional[bool] = False) -> Optional[List[schemas.MediaServerLibrary]]: """ 媒体库列表 """ server_obj: Emby = self.get_instance(server) if server_obj: return server_obj.get_librarys(username=username, hidden=hidden) return None def mediaserver_items(self, server: str, library_id: Union[str, int], start_index: Optional[int] = 0, limit: Optional[int] = -1) -> Optional[Generator]: """ 获取媒体服务器项目列表,支持分页和不分页逻辑,默认不分页获取所有数据 :param server: 媒体服务器名称 :param library_id: 媒体库ID,用于标识要获取的媒体库 :param start_index: 起始索引,用于分页获取数据。默认为 0,即从第一个项目开始获取 :param limit: 每次请求的最大项目数,用于分页。如果为 None 或 -1,则表示一次性获取所有数据,默认为 -1 :return: 返回一个生成器对象,用于逐步获取媒体服务器中的项目 """ server_obj: Emby = self.get_instance(server) if server_obj: return server_obj.get_items(library_id, start_index, limit) return None def mediaserver_iteminfo(self, server: str, item_id: str) -> Optional[schemas.MediaServerItem]: """ 媒体库项目详情 """ server_obj: Emby = self.get_instance(server) if server_obj: return server_obj.get_iteminfo(item_id) return None def mediaserver_tv_episodes(self, server: str, item_id: Union[str, int]) -> Optional[List[schemas.MediaServerSeasonInfo]]: """ 获取剧集信息 """ server_obj: Emby = self.get_instance(server) if not server_obj: return None _, seasoninfo = server_obj.get_tv_episodes(item_id=item_id) if not seasoninfo: return [] return [schemas.MediaServerSeasonInfo( season=season, episodes=episodes ) for season, episodes in seasoninfo.items()] def mediaserver_playing(self, server: str, count: Optional[int] = 20, username: Optional[str] = None) -> List[schemas.MediaServerPlayItem]: """ 获取媒体服务器正在播放信息 """ server_obj: Emby = self.get_instance(server) if not server_obj: return [] return server_obj.get_resume(num=count, username=username) def mediaserver_play_url(self, server: str, item_id: Union[str, int]) -> Optional[str]: """ 获取媒体库播放地址 """ server_obj: Emby = self.get_instance(server) if not server_obj: return None return server_obj.get_play_url(item_id) def mediaserver_latest(self, server: Optional[str] = None, count: Optional[int] = 20, username: Optional[str] = None) -> List[schemas.MediaServerPlayItem]: """ 获取媒体服务器最新入库条目 """ server_obj: Emby = self.get_instance(server) if not server_obj: return [] return server_obj.get_latest(num=count, username=username) def mediaserver_latest_images(self, server: Optional[str] = None, count: Optional[int] = 10, username: Optional[str] = None, remote: Optional[bool] = False ) -> List[str]: """ 获取媒体服务器最新入库条目的图片 :param server: 媒体服务器名称 :param count: 获取数量 :param username: 用户名 :param remote: True为外网链接, False为内网链接 :return: 图片链接列表 """ server_obj: Emby = self.get_instance(server) if not server_obj: return [] links = [] items: List[schemas.MediaServerPlayItem] = self.mediaserver_latest(server=server, count=count, username=username) for item in items: if item.BackdropImageTags: image_url = server_obj.get_backdrop_url(item_id=item.id, image_tag=item.BackdropImageTags[0], remote=remote) if image_url: links.append(image_url) return links ================================================ FILE: app/modules/emby/emby.py ================================================ import json import re import traceback from datetime import datetime from pathlib import Path from typing import List, Optional, Union, Dict, Generator, Tuple, Any from requests import Response from app import schemas from app.core.config import settings from app.log import logger from app.schemas import MediaServerItem from app.schemas.types import MediaType from app.utils.http import RequestUtils from app.utils.url import UrlUtils class Emby: _host: Optional[str] = None _playhost: Optional[str] = None _apikey: Optional[str] = None _sync_libraries: List[str] = [] user: Optional[Union[str, int]] = None _username: Optional[str] = None def __init__(self, host: Optional[str] = None, apikey: Optional[str] = None, play_host: Optional[str] = None, username: Optional[str] = None, sync_libraries: list = None, **kwargs): if not host or not apikey: logger.error("Emby服务器配置不完整!") return self._host = host if self._host: self._host = UrlUtils.standardize_base_url(self._host) self._playhost = play_host if self._playhost: self._playhost = UrlUtils.standardize_base_url(self._playhost) self._apikey = apikey self._username = username self.user = self.get_user(username or settings.SUPERUSER) self.folders = self.get_emby_folders() self.serverid = self.get_server_id() self._sync_libraries = sync_libraries or [] def is_inactive(self) -> bool: """ 判断是否需要重连 """ if not self._host or not self._apikey: return False return True if not self.user else False def reconnect(self): """ 重连 """ self.user = self.get_user() self.folders = self.get_emby_folders() def get_emby_folders(self) -> List[dict]: """ 获取Emby媒体库路径列表 """ if not self._host or not self._apikey: return [] url = f"{self._host}emby/Library/SelectableMediaFolders" params = { 'api_key': self._apikey } try: res = RequestUtils().get_res(url, params) if res: return res.json() else: logger.error(f"Library/SelectableMediaFolders 未获取到返回数据") return [] except Exception as e: logger.error(f"连接Library/SelectableMediaFolders 出错:" + str(e)) return [] def get_emby_virtual_folders(self) -> List[dict]: """ 获取Emby媒体库所有路径列表(包含共享路径) """ if not self._host or not self._apikey: return [] url = f"{self._host}emby/Library/VirtualFolders/Query" params = { 'api_key': self._apikey } try: res = RequestUtils().get_res(url, params) if res: library_items = res.json().get("Items") librarys = [] for library_item in library_items: library_id = library_item.get('ItemId') library_name = library_item.get('Name') pathInfos = library_item.get('LibraryOptions', {}).get('PathInfos') library_paths = [] for path in pathInfos: if path.get('NetworkPath'): library_paths.append(path.get('NetworkPath')) else: library_paths.append(path.get('Path')) if library_name and library_paths: librarys.append({ 'Id': library_id, 'Name': library_name, 'Path': library_paths }) return librarys else: logger.error(f"Library/VirtualFolders/Query 未获取到返回数据") return [] except Exception as e: logger.error(f"连接Library/VirtualFolders/Query 出错:" + str(e)) return [] def __get_emby_librarys(self, username: Optional[str] = None) -> List[dict]: """ 获取Emby媒体库列表 """ if not self._host or not self._apikey: return [] if username: user = self.get_user(username) else: user = self.user url = f"{self._host}emby/Users/{user}/Views" params = {"api_key": self._apikey} try: res = RequestUtils().get_res(url, params) if res: return res.json().get("Items") else: logger.error(f"User/Views 未获取到返回数据") return [] except Exception as e: logger.error(f"连接User/Views 出错:" + str(e)) return [] def get_librarys(self, username: Optional[str] = None, hidden: Optional[bool] = False) -> List[ schemas.MediaServerLibrary]: """ 获取媒体服务器所有媒体库列表 """ if not self._host or not self._apikey: return [] libraries = [] for library in self.__get_emby_librarys(username) or []: if hidden and self._sync_libraries and "all" not in self._sync_libraries \ and library.get("Id") not in self._sync_libraries: continue if library.get("CollectionType") == "movies": library_type = MediaType.MOVIE.value elif library.get("CollectionType") == "tvshows": library_type = MediaType.TV.value else: library_type = MediaType.UNKNOWN.value image = self.__get_local_image_by_id(library.get("Id")) libraries.append( schemas.MediaServerLibrary( server="emby", id=library.get("Id"), name=library.get("Name"), path=library.get("Path"), type=library_type, image=image, link=f'{self._playhost or self._host}web/index.html' f'#!/videos?serverId={self.serverid}&parentId={library.get("Id")}', server_type="emby" ) ) return libraries def get_user(self, user_name: Optional[str] = None) -> Optional[Union[str, int]]: """ 获得管理员用户 """ if not self._host or not self._apikey: return None url = f"{self._host}Users" params = { "api_key": self._apikey } try: res = RequestUtils().get_res(url, params) if res: users = res.json() # 先查询是否有与当前用户名称匹配的 if user_name: for user in users: if user.get("Name") == user_name: return user.get("Id") # 查询管理员 for user in users: if user.get("Policy", {}).get("IsAdministrator"): return user.get("Id") else: logger.error(f"Users 未获取到返回数据") except Exception as e: logger.error(f"连接Users出错:" + str(e)) return None def authenticate(self, username: str, password: str) -> Optional[str]: """ 用户认证 :param username: 用户名 :param password: 密码 :return: 认证token """ if not self._host or not self._apikey: return None url = f"{self._host}emby/Users/AuthenticateByName" try: res = RequestUtils(headers={ 'X-Emby-Authorization': f'MediaBrowser Client="MoviePilot", ' f'Device="requests", ' f'DeviceId="1", ' f'Version="1.0.0", ' f'Token="{self._apikey}"', 'Content-Type': 'application/json', "Accept": "application/json" }).post_res( url=url, data=json.dumps({ "Username": username, "Pw": password }) ) if res: auth_token = res.json().get("AccessToken") if auth_token: logger.info(f"用户 {username} Emby认证成功") return auth_token else: logger.error(f"Users/AuthenticateByName 未获取到返回数据") except Exception as e: logger.error(f"连接Users/AuthenticateByName出错:" + str(e)) return None def get_server_id(self) -> Optional[str]: """ 获得服务器信息 """ if not self._host or not self._apikey: return None url = f"{self._host}System/Info" params = { 'api_key': self._apikey } try: res = RequestUtils().get_res(url, params) if res: return res.json().get("Id") else: logger.error(f"System/Info 未获取到返回数据") except Exception as e: logger.error(f"连接System/Info出错:" + str(e)) return None def get_user_count(self) -> int: """ 获得用户数量 """ if not self._host or not self._apikey: return 0 url = f"{self._host}emby/Users/Query" params = { 'api_key': self._apikey } try: res = RequestUtils().get_res(url, params) if res: return res.json().get("TotalRecordCount") else: logger.error(f"Users/Query 未获取到返回数据") return 0 except Exception as e: logger.error(f"连接Users/Query出错:" + str(e)) return 0 def get_medias_count(self) -> schemas.Statistic: """ 获得电影、电视剧、动漫媒体数量 :return: MovieCount SeriesCount SongCount """ if not self._host or not self._apikey: return schemas.Statistic() url = f"{self._host}emby/Items/Counts" params = { 'api_key': self._apikey } try: res = RequestUtils().get_res(url, params) if res: result = res.json() return schemas.Statistic( movie_count=result.get("MovieCount") or 0, tv_count=result.get("SeriesCount") or 0, episode_count=result.get("EpisodeCount") or 0 ) else: logger.error(f"Items/Counts 未获取到返回数据") return schemas.Statistic() except Exception as e: logger.error(f"连接Items/Counts出错:" + str(e)) return schemas.Statistic() def __get_emby_series_id_by_name(self, name: str, year: str) -> Optional[str]: """ 根据名称查询Emby中剧集的SeriesId :param name: 标题 :param year: 年份 :return: None 表示连不通,""表示未找到,找到返回ID """ if not self._host or not self._apikey: return None url = f"{self._host}emby/Items" params = { "IncludeItemTypes": "Series", "Fields": "ProductionYear", "StartIndex": 0, "Recursive": "true", "SearchTerm": name, "Limit": 10, "IncludeSearchTypes": "false", "api_key": self._apikey } try: res = RequestUtils().get_res(url, params) if res: res_items = res.json().get("Items") if res_items: for res_item in res_items: if res_item.get('Name') == name and ( not year or str(res_item.get('ProductionYear')) == str(year)): return res_item.get('Id') except Exception as e: logger.error(f"连接Items出错:" + str(e)) return None return "" def get_movies(self, title: str, year: Optional[str] = None, tmdb_id: Optional[int] = None) -> Optional[List[schemas.MediaServerItem]]: """ 根据标题和年份,检查电影是否在Emby中存在,存在则返回列表 :param title: 标题 :param year: 年份,可以为空,为空时不按年份过滤 :param tmdb_id: TMDB ID :return: 含title、year属性的字典列表 """ if not self._host or not self._apikey: return None url = f"{self._host}emby/Items" params = { "IncludeItemTypes": "Movie", "Fields": "ProviderIds,OriginalTitle,ProductionYear,Path,UserDataPlayCount,UserDataLastPlayedDate,ParentId", "StartIndex": 0, "Recursive": "true", "SearchTerm": title, "Limit": 10, "IncludeSearchTypes": "false", "api_key": self._apikey } try: res = RequestUtils().get_res(url, params) if res: res_items = res.json().get("Items") if res_items: ret_movies = [] for item in res_items: if not item: continue mediaserver_item = self.__format_item_info(item) if mediaserver_item: if (not tmdb_id or mediaserver_item.tmdbid == tmdb_id) and \ mediaserver_item.title == title and \ (not year or str(mediaserver_item.year) == str(year)): ret_movies.append(mediaserver_item) return ret_movies except Exception as e: logger.error(f"连接Items出错:" + str(e)) return None return [] def get_tv_episodes(self, item_id: Optional[str] = None, title: Optional[str] = None, year: Optional[str] = None, tmdb_id: Optional[int] = None, season: Optional[int] = None ) -> Tuple[Optional[str], Optional[Dict[int, List[int]]]]: """ 根据标题和年份和季,返回Emby中的剧集列表 :param item_id: Emby中的ID :param title: 标题 :param year: 年份 :param tmdb_id: TMDBID :param season: 季 :return: 每一季的已有集数 """ if not self._host or not self._apikey: return None, None # 电视剧 if not item_id: item_id = self.__get_emby_series_id_by_name(title, year) if item_id is None: return None, None if not item_id: return None, {} # 验证tmdbid是否相同 item_info = self.get_iteminfo(item_id) if item_info: if tmdb_id and item_info.tmdbid: if str(tmdb_id) != str(item_info.tmdbid): return None, {} # 查集的信息 if season is None: season = None try: url = f"{self._host}emby/Shows/{item_id}/Episodes" params = { "Season": season, "IsMissing": "false", "api_key": self._apikey } res_json = RequestUtils().get_res(url, params) if res_json: tv_item = res_json.json() res_items = tv_item.get("Items") season_episodes = {} for res_item in res_items: season_index = res_item.get("ParentIndexNumber") if season_index is None: continue if season is not None and season != season_index: continue episode_index = res_item.get("IndexNumber") if episode_index is None: continue if season_index not in season_episodes: season_episodes[season_index] = [] season_episodes[season_index].append(episode_index) # 返回 return item_id, season_episodes except Exception as e: logger.error(f"连接Shows/Id/Episodes出错:" + str(e)) return None, None return None, {} def get_remote_image_by_id(self, item_id: str, image_type: str) -> Optional[str]: """ 根据ItemId从Emby查询TMDB的图片地址 :param item_id: 在Emby中的ID :param image_type: 图片的类弄地,poster或者backdrop等 :return: 图片对应在TMDB中的URL """ if not self._host or not self._apikey: return None url = f"{self._host}emby/Items/{item_id}/RemoteImages" params = { "api_key": self._apikey } try: res = RequestUtils(timeout=10).get_res(url, params) if res: images = res.json().get("Images") if images: for image in images: if image.get("ProviderName") == "TheMovieDb" and image.get("Type") == image_type: return image.get("Url") # 数据为空 logger.info(f"Items/RemoteImages 未获取到返回数据,采用本地图片") return self.generate_external_image_link(item_id, image_type) except Exception as e: logger.error(f"连接Items/Id/RemoteImages出错:" + str(e)) return None def generate_external_image_link(self, item_id: str, image_type: str) -> Optional[str]: """ 根据ItemId和imageType查询本地对应图片 :param item_id: 在Emby中的ID :param image_type: 图片类型,如Backdrop、Primary :return: 图片对应在外网播放器中的URL """ if not self._playhost: logger.error("Emby外网播放地址未能获取或为空") return None url = f"{self._playhost}Items/{item_id}/Images/{image_type}" try: res = RequestUtils().get_res(url) if res and res.status_code != 404: logger.info(f"影片图片链接:{res.url}") return res.url else: logger.info("Items/Id/Images 未获取到返回数据或无该影片{}图片".format(image_type)) return None except Exception as e: logger.error(f"连接Items/Id/Images出错:" + str(e)) return None def __refresh_emby_library_by_id(self, item_id: str) -> bool: """ 通知Emby刷新一个项目的媒体库 """ if not self._host or not self._apikey: return False url = f"{self._host}emby/Items/{item_id}/Refresh" params = { "Recursive": "true", "api_key": self._apikey } try: res = RequestUtils().post_res(url, params=params) if res: return True else: logger.info(f"刷新媒体库对象 {item_id} 失败,无法连接Emby!") except Exception as e: logger.error(f"连接Items/Id/Refresh出错:" + str(e)) return False return False def refresh_root_library(self) -> bool: """ 通知Emby刷新整个媒体库 """ if not self._host or not self._apikey: return False url = f"{self._host}emby/Library/Refresh" params = { "api_key": self._apikey } try: res = RequestUtils().post_res(url, params=params) if res: return True else: logger.info(f"刷新媒体库失败,无法连接Emby!") except Exception as e: logger.error(f"连接Library/Refresh出错:" + str(e)) return False return False def refresh_library_by_items(self, items: List[schemas.RefreshMediaItem]) -> Optional[bool]: """ 按类型、名称、年份来刷新媒体库 :param items: 已识别的需要刷新媒体库的媒体信息列表 """ if not items: return False # 收集要刷新的媒体库信息 logger.info(f"开始刷新Emby媒体库...") library_ids = [] for item in items: library_id = self.__get_emby_library_id_by_item(item) if library_id and library_id not in library_ids: library_ids.append(library_id) # 开始刷新媒体库 if "/" in library_ids: return self.refresh_root_library() for library_id in library_ids: if library_id != "/": return self.__refresh_emby_library_by_id(library_id) logger.info(f"Emby媒体库刷新完成") return True def __get_emby_library_id_by_item(self, item: schemas.RefreshMediaItem) -> Optional[str]: """ 根据媒体信息查询在哪个媒体库,返回要刷新的位置的ID :param item: {title, year, type, category, target_path} """ if not item.title or not item.year or not item.type: return None if item.type != MediaType.MOVIE.value: item_id = self.__get_emby_series_id_by_name(item.title, item.year) if item_id: # 存在电视剧,则直接刷新这个电视剧就行 return item_id else: if self.get_movies(item.title, item.year): # 已存在,不用刷新 return None # 查找需要刷新的媒体库ID item_path = Path(item.target_path) # 匹配子目录 for folder in self.folders: for subfolder in folder.get("SubFolders"): try: # 匹配子目录 subfolder_path = Path(subfolder.get("Path")) if item_path.is_relative_to(subfolder_path): return folder.get("Id") except Exception as err: logger.debug(f"匹配子目录出错:{err} - {traceback.format_exc()}") # 如果找不到,只要路径中有分类目录名就命中 for folder in self.folders: for subfolder in folder.get("SubFolders"): if subfolder.get("Path") and re.search(r"[/\\]%s" % item.category, subfolder.get("Path")): return folder.get("Id") # 刷新根目录 return "/" @staticmethod def __format_item_info(item) -> Optional[schemas.MediaServerItem]: """ 格式化item """ try: user_data = item.get("UserData", {}) if not user_data: user_state = None else: resume = item.get("UserData", {}).get("PlaybackPositionTicks") and item.get("UserData", {}).get( "PlaybackPositionTicks") > 0 last_played_date = item.get("UserData", {}).get("LastPlayedDate") if last_played_date is not None and "." in last_played_date: last_played_date = last_played_date.split(".")[0] user_state = schemas.MediaServerItemUserState( played=item.get("UserData", {}).get("Played"), resume=resume, last_played_date=datetime.strptime(last_played_date, "%Y-%m-%dT%H:%M:%S").strftime( "%Y-%m-%d %H:%M:%S") if last_played_date else None, play_count=item.get("UserData", {}).get("PlayCount"), percentage=item.get("UserData", {}).get("PlayedPercentage"), ) tmdbid = item.get("ProviderIds", {}).get("Tmdb") return schemas.MediaServerItem( server="emby", library=item.get("ParentId"), item_id=item.get("Id"), item_type=item.get("Type"), title=item.get("Name"), original_title=item.get("OriginalTitle"), year=item.get("ProductionYear"), tmdbid=int(tmdbid) if tmdbid else None, imdbid=item.get("ProviderIds", {}).get("Imdb"), tvdbid=item.get("ProviderIds", {}).get("Tvdb"), path=item.get("Path"), user_state=user_state ) except Exception as e: logger.error(e) return None def get_iteminfo(self, itemid: str) -> Optional[schemas.MediaServerItem]: """ 获取单个项目详情 """ if not itemid: return None if not self._host or not self._apikey: return None url = f"{self._host}emby/Users/{self.user}/Items/{itemid}" params = { "api_key": self._apikey } try: res = RequestUtils().get_res(url, params) if res and res.status_code == 200: iteminfo = self.__format_item_info(res.json()) return iteminfo except Exception as e: logger.error(f"连接/Users/{self.user}/Items/{itemid}出错:" + str(e)) return None def get_items(self, parent: Union[str, int], start_index: Optional[int] = 0, limit: Optional[int] = -1) -> Generator[MediaServerItem | None | Any, Any, None]: """ 获取媒体服务器项目列表,支持分页和不分页逻辑,默认不分页获取所有数据 :param parent: 媒体库ID,用于标识要获取的媒体库 :param start_index: 起始索引,用于分页获取数据。默认为 0,即从第一个项目开始获取 :param limit: 每次请求的最大项目数,用于分页。如果为 None 或 -1,则表示一次性获取所有数据,默认为 -1 :return: 返回一个生成器对象,用于逐步获取媒体服务器中的项目 """ if not parent or not self._host or not self._apikey: return None url = f"{self._host}emby/Users/{self.user}/Items" params = { "ParentId": parent, "api_key": self._apikey, "Fields": "ProviderIds,OriginalTitle,ProductionYear,Path,UserDataPlayCount,UserDataLastPlayedDate,ParentId" } if limit is not None and limit != -1: params.update({ "StartIndex": start_index, "Limit": limit }) try: res = RequestUtils().get_res(url, params) if not res or res.status_code != 200: return None items = res.json().get("Items") or [] for item in items: if not item: continue if "Folder" in item.get("Type"): for items in self.get_items(parent=item.get('Id')): yield items elif item.get("Type") in ["Movie", "Series"]: yield self.__format_item_info(item) except Exception as e: logger.error(f"连接Users/Items出错:" + str(e)) return None def get_webhook_message(self, form: any, args: dict) -> Optional[schemas.WebhookEventInfo]: """ 解析Emby Webhook报文 电影: { "Title": "admin 在 Microsoft Edge Windows 上停止播放 蜘蛛侠:纵横宇宙", "Date": "2023-08-19T00:49:07.8523469Z", "Event": "playback.stop", "User": { "Name": "admin", "Id": "e6a9dd89fd954d689870e7e0e3e72947" }, "Item": { "Name": "蜘蛛侠:纵横宇宙", "OriginalTitle": "Spider-Man: Across the Spider-Verse", "ServerId": "f40a5bd0c6b64051bdbed00580fa1118", "Id": "240270", "DateCreated": "2023-06-21T21:01:27.0000000Z", "Container": "mp4", "SortName": "蜘蛛侠:纵横宇宙", "PremiereDate": "2023-05-30T16:00:00.0000000Z", "ExternalUrls": [ { "Name": "IMDb", "Url": "https://www.imdb.com/title/tt9362722" }, { "Name": "TheMovieDb", "Url": "https://www.themoviedb.org/movie/569094" }, { "Name": "Trakt", "Url": "https://trakt.tv/search/tmdb/569094?id_type=movie" } ], "Path": "\\\\10.10.10.10\\Video\\电影\\动画电影\\蜘蛛侠:纵横宇宙 (2023)\\蜘蛛侠:纵横宇宙 (2023).mp4", "OfficialRating": "PG", "Overview": "讲述了新生代蜘蛛侠迈尔斯(沙梅克·摩尔 Shameik Moore 配音)携手蜘蛛格温(海莉·斯坦菲尔德 Hailee Steinfeld 配音),穿越多元宇宙踏上更宏大的冒险征程的故事。面临每个蜘蛛侠都会失去至亲的宿命,迈尔斯誓言打破命运魔咒,找到属于自己的英雄之路。而这个决定和蜘蛛侠2099(奥斯卡·伊萨克 Oscar Is aac 配音)所领军的蜘蛛联盟产生了极大冲突,一场以一敌百的蜘蛛侠大内战即将拉响!", "Taglines": [], "Genres": [ "动作", "冒险", "动画", "科幻" ], "CommunityRating": 8.7, "RunTimeTicks": 80439590000, "Size": 3170164641, "FileName": "蜘蛛侠:纵横宇宙 (2023).mp4", "Bitrate": 3152840, "PlayAccess": "Full", "ProductionYear": 2023, "RemoteTrailers": [ { "Url": "https://www.youtube.com/watch?v=BbXJ3_AQE_o" }, { "Url": "https://www.youtube.com/watch?v=cqGjhVJWtEg" }, { "Url": "https://www.youtube.com/watch?v=shW9i6k8cB0" }, { "Url": "https://www.youtube.com/watch?v=Etv-L2JKCWk" }, { "Url": "https://www.youtube.com/watch?v=yFrxzaBLDQM" } ], "ProviderIds": { "Tmdb": "569094", "Imdb": "tt9362722" }, "IsFolder": false, "ParentId": "240253", "Type": "Movie", "Studios": [ { "Name": "Columbia Pictures", "Id": 1252 }, { "Name": "Sony Pictures Animation", "Id": 1814 }, { "Name": "Lord Miller", "Id": 240307 }, { "Name": "Pascal Pictures", "Id": 60101 }, { "Name": "Arad Productions", "Id": 67372 } ], "GenreItems": [ { "Name": "动作", "Id": 767 }, { "Name": "冒险", "Id": 818 }, { "Name": "动画", "Id": 1382 }, { "Name": "科幻", "Id": 709 } ], "TagItems": [], "PrimaryImageAspectRatio": 0.7012622720897616, "ImageTags": { "Primary": "c080830ff3c964a775dd0b011b675a29", "Art": "a418b990ca0df95838884b5951883ad5", "Logo": "1782310274c108e85d02d2b0b1c7249c", "Thumb": "29d499a96b7da07cd1cf37edb58507a8", "Banner": "bec236365d57f7f646d8fda16fce2ecb", "Disc": "3e32d87be8655f52bcf43bd34ee94c2b" }, "BackdropImageTags": [ "13acab1246c95a6fbdee22cf65edf3f0" ], "MediaType": "Video", "Width": 1920, "Height": 820 }, "Server": { "Name": "PN41", "Id": "f40a5bd0c6b64051bdbed00580fa1118", "Version": "4.7.13.0" }, "Session": { "RemoteEndPoint": "10.10.10.253", "Client": "Emby Web", "DeviceName": "Microsoft Edge Windows", "DeviceId": "30239450-1748-4855-9799-de3544fc2744", "ApplicationVersion": "4.7.13.0", "Id": "c336b028b893558b333d1a49238b7db1" }, "PlaybackInfo": { "PlayedToCompletion": false, "PositionTicks": 17431791950, "PlaylistIndex": 0, "PlaylistLength": 1 } } 电视剧: { "Title": "admin 在 Microsoft Edge Windows 上开始播放 长风渡 - S1, Ep11 - 第 11 集", "Date": "2023-08-19T00:52:20.5200050Z", "Event": "playback.start", "User": { "Name": "admin", "Id": "e6a9dd89fd954d689870e7e0e3e72947" }, "Item": { "Name": "第 11 集", "ServerId": "f40a5bd0c6b64051bdbed00580fa1118", "Id": "240252", "DateCreated": "2023-06-21T10:51:06.0000000Z", "Container": "mp4", "SortName": "第 11 集", "PremiereDate": "2023-06-20T16:00:00.0000000Z", "ExternalUrls": [ { "Name": "Trakt", "Url": "https://trakt.tv/search/tmdb/4533239?id_type=episode" } ], "Path": "\\\\10.10.10.10\\Video\\电视剧\\国产剧\\长风渡 (2023)\\Season 1\\长风渡 - S01E11 - 第 11 集.mp4", "Taglines": [], "Genres": [], "RunTimeTicks": 28021450000, "Size": 707122056, "FileName": "长风渡 - S01E11 - 第 11 集.mp4", "Bitrate": 2018802, "PlayAccess": "Full", "ProductionYear": 2023, "IndexNumber": 11, "ParentIndexNumber": 1, "RemoteTrailers": [], "ProviderIds": { "Tmdb": "4533239" }, "IsFolder": false, "ParentId": "240203", "Type": "Episode", "Studios": [], "GenreItems": [], "TagItems": [], "ParentLogoItemId": "240202", "ParentBackdropItemId": "240202", "ParentBackdropImageTags": [ "7dd568c67721c1f184b281001ced2f8e" ], "SeriesName": "长风渡", "SeriesId": "240202", "SeasonId": "240203", "PrimaryImageAspectRatio": 2.4, "SeriesPrimaryImageTag": "e91c822173e9bcbf7a0efa7d1c16f6bd", "SeasonName": "季 1", "ImageTags": { "Primary": "d6bf1d76150cd86fdff746e4353569ee" }, "BackdropImageTags": [], "ParentLogoImageTag": "51cf6b2661c3c9cef3796abafd6a1694", "MediaType": "Video", "Width": 1920, "Height": 800 }, "Server": { "Name": "PN41", "Id": "f40a5bd0c6b64051bdbed00580fa1118", "Version": "4.7.13.0" }, "Session": { "RemoteEndPoint": "10.10.10.253", "Client": "Emby Web", "DeviceName": "Microsoft Edge Windows", "DeviceId": "30239450-1748-4855-9799-de3544fc2744", "ApplicationVersion": "4.7.13.0", "Id": "c336b028b893558b333d1a49238b7db1" }, "PlaybackInfo": { "PositionTicks": 14256663550, "PlaylistIndex": 10, "PlaylistLength": 40 } } """ if not form and not args: return None try: if form and form.get("data"): result = form.get("data") else: result = json.dumps(dict(args)) message = json.loads(result) except Exception as e: logger.debug(f"解析emby webhook报文出错:" + str(e)) return None eventType = message.get('Event') if not eventType: return None logger.debug(f"接收到emby webhook:{message}") eventItem = schemas.WebhookEventInfo(event=eventType, channel="emby") if message.get('Item'): eventItem.media_type = message.get('Item', {}).get('Type') if message.get('Item', {}).get('Type') == 'Episode' \ or message.get('Item', {}).get('Type') == 'Series' \ or message.get('Item', {}).get('Type') == 'Season': eventItem.item_type = "TV" if message.get('Item', {}).get('SeriesName') \ and message.get('Item', {}).get('ParentIndexNumber') \ and message.get('Item', {}).get('IndexNumber'): eventItem.item_name = "%s %s%s %s" % ( message.get('Item', {}).get('SeriesName'), "S" + str(message.get('Item', {}).get('ParentIndexNumber')), "E" + str(message.get('Item', {}).get('IndexNumber')), message.get('Item', {}).get('Name')) elif message.get('Item', {}).get('SeriesName'): eventItem.item_name = "%s %s" % ( message.get('Item', {}).get('SeriesName'), message.get('Item', {}).get('Name')) else: eventItem.item_name = message.get('Item', {}).get('Name') eventItem.item_id = message.get('Item', {}).get('SeriesId') eventItem.season_id = message.get('Item', {}).get('ParentIndexNumber') eventItem.episode_id = message.get('Item', {}).get('IndexNumber') elif message.get('Item', {}).get('Type') == 'Audio': eventItem.item_type = "AUD" album = message.get('Item', {}).get('Album') file_name = message.get('Item', {}).get('FileName') eventItem.item_name = album eventItem.overview = file_name eventItem.item_id = message.get('Item', {}).get('AlbumId') else: eventItem.item_type = "MOV" eventItem.item_name = "%s %s" % ( message.get('Item', {}).get('Name'), "(" + str(message.get('Item', {}).get('ProductionYear')) + ")") eventItem.item_id = message.get('Item', {}).get('Id') eventItem.item_path = message.get('Item', {}).get('Path') eventItem.tmdb_id = message.get('Item', {}).get('ProviderIds', {}).get('Tmdb') if message.get('Item', {}).get('Overview') and len(message.get('Item', {}).get('Overview')) > 100: eventItem.overview = str(message.get('Item', {}).get('Overview'))[:100] + "..." else: eventItem.overview = message.get('Item', {}).get('Overview') eventItem.percentage = message.get('TranscodingInfo', {}).get('CompletionPercentage') if not eventItem.percentage: if message.get('PlaybackInfo', {}).get('PositionTicks') and message.get('Item', {}).get('RunTimeTicks'): eventItem.percentage = message.get('PlaybackInfo', {}).get('PositionTicks') / \ message.get('Item', {}).get('RunTimeTicks') * 100 if message.get('Session'): eventItem.ip = message.get('Session').get('RemoteEndPoint') eventItem.device_name = message.get('Session').get('DeviceName') eventItem.client = message.get('Session').get('Client') if message.get("User"): eventItem.user_name = message.get("User").get('Name') if message.get("item_isvirtual"): eventItem.item_isvirtual = message.get("item_isvirtual") eventItem.item_type = message.get("item_type") eventItem.item_name = message.get("item_name") eventItem.item_path = message.get("item_path") eventItem.tmdb_id = message.get("tmdb_id") eventItem.season_id = message.get("season_id") eventItem.episode_id = message.get("episode_id") # 获取消息图片 if eventItem.item_id: # 根据返回的item_id去调用媒体服务器获取 eventItem.image_url = self.get_remote_image_by_id(item_id=eventItem.item_id, image_type="Backdrop") eventItem.json_object = message return eventItem def get_data(self, url: str) -> Optional[Response]: """ 自定义URL从媒体服务器获取数据,其中[HOST]、[APIKEY]、[USER]会被替换成实际的值 :param url: 请求地址 """ if not self._host or not self._apikey: return None url = url.replace("[HOST]", self._host or '') \ .replace("[APIKEY]", self._apikey or '') \ .replace("[USER]", self.user or '') try: return RequestUtils(content_type="application/json").get_res(url=url) except Exception as e: logger.error(f"连接Emby出错:" + str(e)) return None def post_data(self, url: str, data: Optional[str] = None, headers: dict = None) -> Optional[Response]: """ 自定义URL从媒体服务器获取数据,其中[HOST]、[APIKEY]、[USER]会被替换成实际的值 :param url: 请求地址 :param data: 请求数据 :param headers: 请求头 """ if not self._host or not self._apikey: return None url = url.replace("[HOST]", self._host or '') \ .replace("[APIKEY]", self._apikey or '') \ .replace("[USER]", self.user or '') try: return RequestUtils( headers=headers, ).post_res(url=url, data=data) except Exception as e: logger.error(f"连接Emby出错:" + str(e)) return None def get_play_url(self, item_id: str) -> str: """ 拼装媒体播放链接 :param item_id: 媒体的的ID """ return f"{self._playhost or self._host}web/index.html#!" \ f"/item?id={item_id}&context=home&serverId={self.serverid}" def get_backdrop_url(self, item_id: str, image_tag: str, remote: Optional[bool] = False) -> str: """ 获取Emby的Backdrop图片地址 :param: item_id: 在Emby中的ID :param: image_tag: 图片的tag :param: remote 是否远程使用,TG微信等客户端调用应为True """ if not self._host or not self._apikey: return "" if not image_tag or not item_id: return "" if remote: host_url = self._playhost or self._host else: host_url = self._host return f"{host_url}Items/{item_id}/" \ f"Images/Backdrop?tag={image_tag}&api_key={self._apikey}" def __get_local_image_by_id(self, item_id: str) -> str: """ 根据ItemId从媒体服务器查询本地图片地址 :param: item_id: 在Emby中的ID :param: remote 是否远程使用,TG微信等客户端调用应为True :param: inner 是否NT内部调用,为True是会使用NT中转 """ if not self._host or not self._apikey: return "" return "%sItems/%s/Images/Primary" % (self._host, item_id) def get_resume(self, num: Optional[int] = 12, username: Optional[str] = None) -> Optional[ List[schemas.MediaServerPlayItem]]: """ 获得继续观看 """ if not self._host or not self._apikey: return None if username: user = self.get_user(username) else: user = self.user url = f"{self._host}Users/{user}/Items/Resume" params = { "Limit": 100, "MediaTypes": "Video", "Fields": "ProductionYear,Path", "api_key": self._apikey, } try: res = RequestUtils().get_res(url, params) if res: result = res.json().get("Items") or [] ret_resume = [] # 用户媒体库文件夹列表(排除黑名单) library_folders = self.get_user_library_folders() for item in result: if len(ret_resume) == num: break if item.get("Type") not in ["Movie", "Episode"]: continue item_path = item.get("Path") if item_path and library_folders and not any( str(item_path).startswith(folder) for folder in library_folders): continue item_type = MediaType.MOVIE.value if item.get("Type") == "Movie" else MediaType.TV.value link = self.get_play_url(item.get("Id")) if item_type == MediaType.MOVIE.value: title = item.get("Name") subtitle = str(item.get("ProductionYear")) if item.get("ProductionYear") else None else: title = f'{item.get("SeriesName")}' subtitle = f'S{item.get("ParentIndexNumber")}:{item.get("IndexNumber")} - {item.get("Name")}' if item_type == MediaType.MOVIE.value: if item.get("BackdropImageTags"): image = self.get_backdrop_url(item_id=item.get("Id"), image_tag=item.get("BackdropImageTags")[0]) else: image = self.__get_local_image_by_id(item.get("Id")) else: image = self.get_backdrop_url(item_id=item.get("SeriesId"), image_tag=item.get("SeriesPrimaryImageTag")) if not image: image = self.__get_local_image_by_id(item.get("SeriesId")) ret_resume.append(schemas.MediaServerPlayItem( id=item.get("Id"), title=title, subtitle=subtitle, type=item_type, image=image, link=link, percent=item.get("UserData", {}).get("PlayedPercentage"), server_type='emby' )) return ret_resume else: logger.error(f"Users/Items/Resume 未获取到返回数据") except Exception as e: logger.error(f"连接Users/Items/Resume出错:" + str(e)) return [] def get_latest(self, num: Optional[int] = 20, username: Optional[str] = None) -> Optional[ List[schemas.MediaServerPlayItem]]: """ 获得最近更新 """ if not self._host or not self._apikey: return None if username: user = self.get_user(username) else: user = self.user url = f"{self._host}Users/{user}/Items/Latest" params = { "Limit": 100, "MediaTypes": "Video", "Fields": "ProductionYear,Path,BackdropImageTags", "api_key": self._apikey } try: res = RequestUtils().get_res(url, params) if res: result = res.json() or [] ret_latest = [] # 用户媒体库文件夹列表(排除黑名单) library_folders = self.get_user_library_folders() for item in result: if len(ret_latest) == num: break if item.get("Type") not in ["Movie", "Series"]: continue item_path = item.get("Path") if item_path and library_folders and not any( str(item_path).startswith(folder) for folder in library_folders): continue item_type = MediaType.MOVIE.value if item.get("Type") == "Movie" else MediaType.TV.value link = self.get_play_url(item.get("Id")) image = self.__get_local_image_by_id(item_id=item.get("Id")) ret_latest.append(schemas.MediaServerPlayItem( id=item.get("Id"), title=item.get("Name"), subtitle=str(item.get("ProductionYear")) if item.get("ProductionYear") else None, type=item_type, image=image, link=link, BackdropImageTags=item.get("BackdropImageTags"), server_type='emby' )) return ret_latest else: logger.error(f"Users/Items/Latest 未获取到返回数据") except Exception as e: logger.error(f"连接Users/Items/Latest出错:" + str(e)) return [] def get_user_library_folders(self): """ 获取Emby媒体库文件夹列表(排除黑名单) """ if not self._host or not self._apikey: return [] library_folders = [] for library in self.get_emby_virtual_folders() or []: if self._sync_libraries and library.get("Id") not in self._sync_libraries: continue library_folders += [folder for folder in library.get("Path")] return library_folders ================================================ FILE: app/modules/fanart/__init__.py ================================================ import re from typing import Optional, Tuple, Union from app.core.cache import cached from app.core.context import MediaInfo, settings from app.log import logger from app.modules import _ModuleBase from app.schemas.types import MediaType, ModuleType, OtherModulesType from app.utils.http import RequestUtils class FanartModule(_ModuleBase): """ { "name": "The Wheel of Time", "thetvdb_id": "355730", "tvposter": [ { "id": "174068", "url": "http://assets.fanart.tv/fanart/tv/355730/tvposter/the-wheel-of-time-64b009de9548d.jpg", "lang": "en", "likes": "3" }, { "id": "176424", "url": "http://assets.fanart.tv/fanart/tv/355730/tvposter/the-wheel-of-time-64de44fe42073.jpg", "lang": "00", "likes": "3" }, { "id": "176407", "url": "http://assets.fanart.tv/fanart/tv/355730/tvposter/the-wheel-of-time-64dde63c7c941.jpg", "lang": "en", "likes": "0" }, { "id": "177321", "url": "http://assets.fanart.tv/fanart/tv/355730/tvposter/the-wheel-of-time-64eda10599c3d.jpg", "lang": "cz", "likes": "0" }, { "id": "155050", "url": "http://assets.fanart.tv/fanart/tv/355730/tvposter/the-wheel-of-time-6313adbd1fd58.jpg", "lang": "pl", "likes": "0" }, { "id": "140198", "url": "http://assets.fanart.tv/fanart/tv/355730/tvposter/the-wheel-of-time-61a0d7b11952e.jpg", "lang": "en", "likes": "0" }, { "id": "140034", "url": "http://assets.fanart.tv/fanart/tv/355730/tvposter/the-wheel-of-time-619e65b73871d.jpg", "lang": "en", "likes": "0" } ], "hdtvlogo": [ { "id": "139835", "url": "http://assets.fanart.tv/fanart/tv/355730/hdtvlogo/the-wheel-of-time-6197d9392faba.png", "lang": "en", "likes": "3" }, { "id": "140039", "url": "http://assets.fanart.tv/fanart/tv/355730/hdtvlogo/the-wheel-of-time-619e87941a128.png", "lang": "pt", "likes": "3" }, { "id": "140092", "url": "http://assets.fanart.tv/fanart/tv/355730/hdtvlogo/the-wheel-of-time-619fa2347bada.png", "lang": "en", "likes": "3" }, { "id": "164312", "url": "http://assets.fanart.tv/fanart/tv/355730/hdtvlogo/the-wheel-of-time-63c8185cb8824.png", "lang": "hu", "likes": "1" }, { "id": "139827", "url": "http://assets.fanart.tv/fanart/tv/355730/hdtvlogo/the-wheel-of-time-6197539658a9e.png", "lang": "en", "likes": "1" }, { "id": "177214", "url": "http://assets.fanart.tv/fanart/tv/355730/hdtvlogo/the-wheel-of-time-64ebae44c23a6.png", "lang": "cz", "likes": "0" }, { "id": "177215", "url": "http://assets.fanart.tv/fanart/tv/355730/hdtvlogo/the-wheel-of-time-64ebae472deef.png", "lang": "cz", "likes": "0" }, { "id": "156163", "url": "http://assets.fanart.tv/fanart/tv/355730/hdtvlogo/the-wheel-of-time-63316bef1ff9d.png", "lang": "cz", "likes": "0" }, { "id": "155051", "url": "http://assets.fanart.tv/fanart/tv/355730/hdtvlogo/the-wheel-of-time-6313add04ca92.png", "lang": "pl", "likes": "0" }, { "id": "152668", "url": "http://assets.fanart.tv/fanart/tv/355730/hdtvlogo/the-wheel-of-time-62ced3775a40a.png", "lang": "pl", "likes": "0" }, { "id": "142266", "url": "http://assets.fanart.tv/fanart/tv/355730/hdtvlogo/the-wheel-of-time-61ccd93eeac2b.png", "lang": "de", "likes": "0" } ], "hdclearart": [ { "id": "164313", "url": "http://assets.fanart.tv/fanart/tv/355730/hdclearart/the-wheel-of-time-63c81871c982c.png", "lang": "en", "likes": "3" }, { "id": "140284", "url": "http://assets.fanart.tv/fanart/tv/355730/hdclearart/the-wheel-of-time-61a2128ed1df2.png", "lang": "pt", "likes": "3" }, { "id": "139828", "url": "http://assets.fanart.tv/fanart/tv/355730/hdclearart/the-wheel-of-time-61975401e894c.png", "lang": "en", "likes": "1" }, { "id": "164314", "url": "http://assets.fanart.tv/fanart/tv/355730/hdclearart/the-wheel-of-time-63c8188488a5f.png", "lang": "hu", "likes": "1" }, { "id": "177322", "url": "http://assets.fanart.tv/fanart/tv/355730/hdclearart/the-wheel-of-time-64eda135933b6.png", "lang": "cz", "likes": "0" }, { "id": "142267", "url": "http://assets.fanart.tv/fanart/tv/355730/hdclearart/the-wheel-of-time-61ccda9918c5c.png", "lang": "de", "likes": "0" } ], "seasonposter": [ { "id": "140199", "url": "http://assets.fanart.tv/fanart/tv/355730/seasonposter/the-wheel-of-time-61a0d7c2976de.jpg", "lang": "en", "likes": "1", "season": "1" }, { "id": "176395", "url": "http://assets.fanart.tv/fanart/tv/355730/seasonposter/the-wheel-of-time-64dd80b3d79a9.jpg", "lang": "en", "likes": "0", "season": "1" }, { "id": "140035", "url": "http://assets.fanart.tv/fanart/tv/355730/seasonposter/the-wheel-of-time-619e65c4d5357.jpg", "lang": "en", "likes": "0", "season": "1" } ], "tvthumb": [ { "id": "140242", "url": "http://assets.fanart.tv/fanart/tv/355730/tvthumb/the-wheel-of-time-61a1813035506.jpg", "lang": "en", "likes": "1" }, { "id": "177323", "url": "http://assets.fanart.tv/fanart/tv/355730/tvthumb/the-wheel-of-time-64eda15b6dce6.jpg", "lang": "cz", "likes": "0" }, { "id": "176399", "url": "http://assets.fanart.tv/fanart/tv/355730/tvthumb/the-wheel-of-time-64dd85c9b618c.jpg", "lang": "en", "likes": "0" }, { "id": "152669", "url": "http://assets.fanart.tv/fanart/tv/355730/tvthumb/the-wheel-of-time-62ced53d16574.jpg", "lang": "pl", "likes": "0" }, { "id": "141983", "url": "http://assets.fanart.tv/fanart/tv/355730/tvthumb/the-wheel-of-time-61c6d04a6d701.jpg", "lang": "en", "likes": "0" } ], "showbackground": [ { "id": "177324", "url": "http://assets.fanart.tv/fanart/tv/355730/showbackground/the-wheel-of-time-64eda1833ccb1.jpg", "lang": "", "likes": "0", "season": "all" }, { "id": "141986", "url": "http://assets.fanart.tv/fanart/tv/355730/showbackground/the-wheel-of-time-61c6d08f7c7e2.jpg", "lang": "", "likes": "0", "season": "all" }, { "id": "139868", "url": "http://assets.fanart.tv/fanart/tv/355730/showbackground/the-wheel-of-time-6198ce358b98a.jpg", "lang": "", "likes": "0", "season": "all" } ], "seasonthumb": [ { "id": "176396", "url": "http://assets.fanart.tv/fanart/tv/355730/seasonthumb/the-wheel-of-time-64dd80c8593f9.jpg", "lang": "en", "likes": "0", "season": "1" }, { "id": "176400", "url": "http://assets.fanart.tv/fanart/tv/355730/seasonthumb/the-wheel-of-time-64dd85da7c5e9.jpg", "lang": "en", "likes": "0", "season": "0" } ], "tvbanner": [ { "id": "176397", "url": "http://assets.fanart.tv/fanart/tv/355730/tvbanner/the-wheel-of-time-64dd80da9a255.jpg", "lang": "en", "likes": "0" }, { "id": "176401", "url": "http://assets.fanart.tv/fanart/tv/355730/tvbanner/the-wheel-of-time-64dd85e8904ea.jpg", "lang": "en", "likes": "0" }, { "id": "141988", "url": "http://assets.fanart.tv/fanart/tv/355730/tvbanner/the-wheel-of-time-61c6d34bceb5f.jpg", "lang": "en", "likes": "0" }, { "id": "141984", "url": "http://assets.fanart.tv/fanart/tv/355730/tvbanner/the-wheel-of-time-61c6d06c1c21c.jpg", "lang": "en", "likes": "0" } ], "seasonbanner": [ { "id": "176398", "url": "http://assets.fanart.tv/fanart/tv/355730/seasonbanner/the-wheel-of-time-64dd80e7dbd9f.jpg", "lang": "en", "likes": "0", "season": "1" }, { "id": "176402", "url": "http://assets.fanart.tv/fanart/tv/355730/seasonbanner/the-wheel-of-time-64dd85fb4f1b1.jpg", "lang": "en", "likes": "0", "season": "0" } ] } """ # 代理 _proxies: dict = settings.PROXY # Fanart Api _movie_url: str = f'https://webservice.fanart.tv/v3/movies/%s?api_key={settings.FANART_API_KEY}' _tv_url: str = f'https://webservice.fanart.tv/v3/tv/%s?api_key={settings.FANART_API_KEY}' def init_module(self) -> None: pass def stop(self): pass def test(self) -> Tuple[bool, str]: """ 测试模块连接性 """ ret = RequestUtils().get_res("https://webservice.fanart.tv") if ret and ret.status_code == 200: return True, "" elif ret: return False, f"无法连接fanart,错误码:{ret.status_code}" return False, "fanart网络连接失败" def init_setting(self) -> Tuple[str, Union[str, bool]]: return "FANART_API_KEY", True @staticmethod def get_name() -> str: return "Fanart" @staticmethod def get_type() -> ModuleType: """ 获取模块类型 """ return ModuleType.Other @staticmethod def get_subtype() -> OtherModulesType: """ 获取模块子类型 """ return OtherModulesType.Fanart @staticmethod def get_priority() -> int: """ 获取模块优先级,数字越小优先级越高,只有同一接口下优先级才生效 """ return 0 def obtain_images(self, mediainfo: MediaInfo) -> Optional[MediaInfo]: """ 获取图片 :param mediainfo: 识别的媒体信息 :return: 更新后的媒体信息 """ if not settings.FANART_ENABLE: return None if not mediainfo.tmdb_id and not mediainfo.tvdb_id: return None if mediainfo.type == MediaType.MOVIE: result = self.__request_fanart(mediainfo.type, mediainfo.tmdb_id) else: if mediainfo.tvdb_id: result = self.__request_fanart(mediainfo.type, mediainfo.tvdb_id) else: logger.info(f"{mediainfo.title_year} 没有tvdbid,无法获取fanart图片") return None if not result or result.get('status') == 'error': logger.warn(f"没有获取到 {mediainfo.title_year} 的fanart图片数据") return None # 获取所有图片 for name, images in result.items(): if not images: continue if not isinstance(images, list): continue # 图片属性xx_path image_name = self.__name(name) if image_name.startswith("season"): # 季图片,图片格式seasonxx-xxxx/season-specials-xxxx for image_obj in images: image_season = image_obj.get('season') if image_season is not None: # 包括poster,thumb,banner if image_season == '0': season_image = f"season-specials-{image_name[6:]}" else: season_image = f"season{str(image_season).rjust(2, '0')}-{image_name[6:]}" # 设置图片,没有图片才设置 if not mediainfo.get_image(season_image): mediainfo.set_image(season_image, image_obj.get('url')) else: # 其他图片,优先环境变量指定语言,再like最多 def __pick_best_image(_images): lang_env = settings.FANART_LANG if lang_env: langs = [lang.strip() for lang in lang_env.split(",") if lang.strip()] for lang in langs: lang_images = [img for img in _images if img.get('lang') == lang] if lang_images: lang_images.sort(key=lambda x: int(x.get('likes', 0)), reverse=True) return lang_images[0] # 没设置或没找到,按原逻辑 zh、en、like最多 zh_images = [img for img in _images if img.get('lang') == 'zh'] if zh_images: zh_images.sort(key=lambda x: int(x.get('likes', 0)), reverse=True) return zh_images[0] en_images = [img for img in _images if img.get('lang') == 'en'] if en_images: en_images.sort(key=lambda x: int(x.get('likes', 0)), reverse=True) return en_images[0] _images.sort(key=lambda x: int(x.get('likes', 0)), reverse=True) return _images[0] image_obj = __pick_best_image(images) # 设置图片,没有图片才设置 if not mediainfo.get_image(image_name): mediainfo.set_image(image_name, image_obj.get('url')) return mediainfo @staticmethod def __name(fanart_name: str) -> str: """ 转换Fanart图片的名字 """ words_to_remove = r'tv|movie|hdmovie|hdtv|show|hd' pattern = re.compile(words_to_remove, re.IGNORECASE) result = re.sub(pattern, '', fanart_name) return result @classmethod @cached(maxsize=settings.CONF.fanart, ttl=settings.CONF.meta, shared_key="get") def __request_fanart(cls, media_type: MediaType, queryid: Union[str, int]) -> Optional[dict]: if media_type == MediaType.MOVIE: image_url = cls._movie_url % queryid else: image_url = cls._tv_url % queryid try: ret = RequestUtils(proxies=cls._proxies, timeout=10).get_res(image_url, raise_exception=True) if ret: return ret.json() else: logger.debug(f"未能获取到 {queryid} 的Fanart图片") return {} except Exception as err: logger.error(f"获取{queryid}的Fanart图片失败:{str(err)}") return None def clear_cache(self): """ 清除缓存 """ logger.info(f"开始清除{self.get_name()}缓存 ...") self.__request_fanart.cache_clear() logger.info(f"{self.get_name()}缓存清除完成") ================================================ FILE: app/modules/filemanager/__init__.py ================================================ from pathlib import Path from typing import Optional, List, Tuple, Union, Dict, Callable from app.chain.tmdb import TmdbChain from app.core.config import settings from app.core.context import MediaInfo from app.core.meta import MetaBase from app.core.metainfo import MetaInfo from app.helper.directory import DirectoryHelper from app.helper.message import MessageHelper from app.helper.module import ModuleHelper from app.log import logger from app.modules import _ModuleBase from app.modules.filemanager.storages import StorageBase from app.modules.filemanager.transhandler import TransHandler from app.schemas import TransferInfo, ExistMediaInfo, TmdbEpisode, TransferDirectoryConf, FileItem, StorageUsage from app.schemas.types import MediaType, ModuleType, OtherModulesType from app.utils.system import SystemUtils class FileManagerModule(_ModuleBase): """ 文件整理模块 """ _storage_schemas = [] _support_storages = [] def __init__(self): super().__init__() self.directoryhelper = DirectoryHelper() self.messagehelper = MessageHelper() def init_module(self) -> None: # 加载模块 self._storage_schemas = ModuleHelper.load('app.modules.filemanager.storages', filter_func=lambda _, obj: hasattr(obj, 'schema') and obj.schema) # 获取存储类型 self._support_storages = [storage.schema.value for storage in self._storage_schemas if storage.schema] @staticmethod def get_name() -> str: return "文件整理" @staticmethod def get_type() -> ModuleType: """ 获取模块类型 """ return ModuleType.Other @staticmethod def get_subtype() -> OtherModulesType: """ 获取模块子类型 """ return OtherModulesType.FileManager @staticmethod def get_priority() -> int: """ 获取模块优先级,数字越小优先级越高,只有同一接口下优先级才生效 """ return 4 def stop(self): pass def test(self) -> Tuple[bool, str]: """ 测试模块连接性 """ # 检查目录 dirs = self.directoryhelper.get_dirs() if not dirs: return False, "未设置任何目录" for d in dirs: # 下载目录 download_path = d.download_path if not download_path: return False, f"{d.name} 的下载目录未设置" if d.storage == "local" and not Path(download_path).exists(): return False, f"{d.name} 的下载目录 {download_path} 不存在" # 仅在启用整理时检查媒体库目录 library_path = d.library_path if d.transfer_type: if not library_path: return False, f"{d.name} 的媒体库目录未设置" if d.library_storage == "local" and not Path(library_path).exists(): return False, f"{d.name} 的媒体库目录 {library_path} 不存在" # 硬链接 if d.transfer_type == "link" \ and d.storage == "local" \ and d.library_storage == "local" \ and not SystemUtils.is_same_disk(Path(download_path), Path(library_path)): return False, f"{d.name} 的下载目录 {download_path} 与媒体库目录 {library_path} 不在同一磁盘,无法硬链接" # 存储 storage_oper = self.__get_storage_oper(d.storage) if storage_oper: if not storage_oper.check(): return False, f"{d.name} 的存储测试不通过" if d.transfer_type and d.transfer_type not in storage_oper.support_transtype(): return False, f"{d.name} 的存储不支持 {d.transfer_type} 整理方式" return True, "" def __get_storage_oper(self, _storage: str, _func: Optional[str] = None) -> Optional[StorageBase]: """ 获取存储操作对象 """ for storage_schema in self._storage_schemas: if storage_schema.schema \ and storage_schema.schema.value == _storage \ and (not _func or hasattr(storage_schema, _func)): return storage_schema() return None def init_setting(self) -> Tuple[str, Union[str, bool]]: pass def support_transtype(self, storage: str) -> Optional[dict]: """ 支持的整理方式 """ if storage not in self._support_storages: return None storage_oper = self.__get_storage_oper(storage) if not storage_oper: logger.error(f"不支持 {storage} 的整理方式获取") return None return storage_oper.support_transtype() @staticmethod def recommend_name(meta: MetaBase, mediainfo: MediaInfo) -> Optional[str]: """ 获取重命名后的名称 :param meta: 元数据 :param mediainfo: 媒体信息 :return: 重命名后的名称(含目录) """ handler = TransHandler() # 重命名格式 rename_format = settings.RENAME_FORMAT(mediainfo.type) # 获取集信息 episodes_info: Optional[List[TmdbEpisode]] = None if mediainfo.type == MediaType.TV: # 判断注意season为0的情况 season_num = mediainfo.season if season_num is None and meta.season_seq: if meta.season_seq.isdigit(): season_num = int(meta.season_seq) # 默认值1 if season_num is None: season_num = 1 episodes_info = TmdbChain().tmdb_episodes( tmdbid=mediainfo.tmdb_id, season=season_num, episode_group=mediainfo.episode_group, ) # 获取重命名后的名称 path = handler.get_rename_path( template_string=rename_format, rename_dict=handler.get_naming_dict(meta=meta, mediainfo=mediainfo, episodes_info=episodes_info, file_ext=Path(meta.title).suffix) ) return path.as_posix() if path else "" def save_config(self, storage: str, conf: Dict) -> None: """ 保存存储配置 """ storage_oper = self.__get_storage_oper(storage) if not storage_oper: logger.error(f"不支持 {storage} 的配置保存") return storage_oper.set_config(conf) def reset_config(self, storage: str) -> None: """ 重置存储配置 """ storage_oper = self.__get_storage_oper(storage) if not storage_oper: logger.error(f"不支持 {storage} 的重置存储配置") return storage_oper.reset_config() def generate_qrcode(self, storage: str) -> Optional[Tuple[dict, str]]: """ 生成二维码 """ storage_oper = self.__get_storage_oper(storage, "generate_qrcode") if not storage_oper: logger.error(f"不支持 {storage} 的二维码生成") return None return storage_oper.generate_qrcode() def generate_auth_url(self, storage: str) -> Optional[Tuple[dict, str]]: """ 生成 OAuth2 授权 URL """ storage_oper = self.__get_storage_oper(storage, "generate_auth_url") if not storage_oper: logger.error(f"不支持 {storage} 的 OAuth2 授权") return {}, f"不支持 {storage} 的 OAuth2 授权" return storage_oper.generate_auth_url() def check_login(self, storage: str, **kwargs) -> Optional[Dict[str, str]]: """ 登录确认 """ storage_oper = self.__get_storage_oper(storage, "check_login") if not storage_oper: logger.error(f"不支持 {storage} 的登录确认") return None return storage_oper.check_login(**kwargs) def list_files(self, fileitem: FileItem, recursion: Optional[bool] = False) -> Optional[List[FileItem]]: """ 浏览文件 :param fileitem: 源文件 :param recursion: 是否递归,此时只浏览文件 :return: 文件项列表 """ if fileitem.storage not in self._support_storages: return None storage_oper = self.__get_storage_oper(fileitem.storage) if not storage_oper: logger.error(f"不支持 {fileitem.storage} 的文件浏览") return None def __get_files(_item: FileItem, _r: Optional[bool] = False): """ 递归处理 """ _items = storage_oper.list(_item) if _items: if _r: for t in _items: if t.type == "dir": __get_files(t, _r) else: result.append(t) else: result.extend(_items) # 返回结果 result = [] __get_files(fileitem, recursion) return result def any_files(self, fileitem: FileItem, extensions: list = None) -> Optional[bool]: """ 查询当前目录下是否存在指定扩展名任意文件 """ if fileitem.storage not in self._support_storages: return None storage_oper = self.__get_storage_oper(fileitem.storage) if not storage_oper: logger.error(f"不支持 {fileitem.storage} 的文件浏览") return None def __any_file(_item: FileItem): """ 递归处理 """ _items = storage_oper.list(_item) if _items: if not extensions: return True for t in _items: if (t.type == "file" and t.extension and f".{t.extension.lower()}" in extensions): return True elif t.type == "dir": if __any_file(t): return True return False # 返回结果 return __any_file(fileitem) def create_folder(self, fileitem: FileItem, name: str) -> Optional[FileItem]: """ 创建目录 :param fileitem: 源文件 :param name: 目录名 :return: 创建的目录 """ if fileitem.storage not in self._support_storages: return None storage_oper = self.__get_storage_oper(fileitem.storage) if not storage_oper: logger.error(f"不支持 {fileitem.storage} 的目录创建") return None return storage_oper.create_folder(fileitem, name) def delete_file(self, fileitem: FileItem) -> Optional[bool]: """ 删除文件或目录 """ if fileitem.storage not in self._support_storages: return None storage_oper = self.__get_storage_oper(fileitem.storage) if not storage_oper: logger.error(f"不支持 {fileitem.storage} 的删除处理") return False return storage_oper.delete(fileitem) def rename_file(self, fileitem: FileItem, name: str) -> Optional[bool]: """ 重命名文件或目录 """ if fileitem.storage not in self._support_storages: return None storage_oper = self.__get_storage_oper(fileitem.storage) if not storage_oper: logger.error(f"不支持 {fileitem.storage} 的重命名处理") return False return storage_oper.rename(fileitem, name) def download_file(self, fileitem: FileItem, path: Path = None) -> Optional[Path]: """ 下载文件 """ if fileitem.storage not in self._support_storages: return None storage_oper = self.__get_storage_oper(fileitem.storage) if not storage_oper: logger.error(f"不支持 {fileitem.storage} 的下载处理") return None return storage_oper.download(fileitem, path=path) def upload_file(self, fileitem: FileItem, path: Path, new_name: Optional[str] = None) -> Optional[FileItem]: """ 上传文件 """ if fileitem.storage not in self._support_storages: return None storage_oper = self.__get_storage_oper(fileitem.storage) if not storage_oper: logger.error(f"不支持 {fileitem.storage} 的上传处理") return None return storage_oper.upload(fileitem, path, new_name) def get_file_item(self, storage: str, path: Path) -> Optional[FileItem]: """ 根据路径获取文件项 """ if storage not in self._support_storages: return None storage_oper = self.__get_storage_oper(storage) if not storage_oper: logger.error(f"不支持 {storage} 的文件获取") return None return storage_oper.get_item(path) def get_parent_item(self, fileitem: FileItem) -> Optional[FileItem]: """ 获取上级目录项 """ if fileitem.storage not in self._support_storages: return None storage_oper = self.__get_storage_oper(fileitem.storage) if not storage_oper: logger.error(f"不支持 {fileitem.storage} 的文件获取") return None return storage_oper.get_parent(fileitem) def snapshot_storage(self, storage: str, path: Path, last_snapshot_time: float = None, max_depth: int = 5) -> Optional[Dict[str, Dict]]: """ 快照存储 :param storage: 存储类型 :param path: 路径 :param last_snapshot_time: 上次快照时间,用于增量快照 :param max_depth: 最大递归深度,避免过深遍历 """ if storage not in self._support_storages: return None storage_oper = self.__get_storage_oper(storage) if not storage_oper: logger.error(f"不支持 {storage} 的快照处理") return None return storage_oper.snapshot(path, last_snapshot_time=last_snapshot_time, max_depth=max_depth) def storage_usage(self, storage: str) -> Optional[StorageUsage]: """ 存储使用情况 """ if storage not in self._support_storages: return None storage_oper = self.__get_storage_oper(storage) if not storage_oper: logger.error(f"不支持 {storage} 的存储使用情况") return None return storage_oper.usage() def transfer(self, fileitem: FileItem, meta: MetaBase, mediainfo: MediaInfo, target_directory: TransferDirectoryConf = None, target_storage: Optional[str] = None, target_path: Path = None, transfer_type: Optional[str] = None, scrape: Optional[bool] = None, library_type_folder: Optional[bool] = None, library_category_folder: Optional[bool] = None, episodes_info: List[TmdbEpisode] = None, source_oper: Callable = None, target_oper: Callable = None) -> TransferInfo: """ 文件整理 :param fileitem: 文件信息 :param meta: 预识别的元数据 :param mediainfo: 识别的媒体信息 :param target_directory: 目标目录配置 :param target_storage: 目标存储 :param target_path: 目标路径 :param transfer_type: 转移模式 :param scrape: 是否刮削元数据 :param library_type_folder: 是否按媒体类型创建目录 :param library_category_folder: 是否按媒体类别创建目录 :param episodes_info: 当前季的全部集信息 :param source_oper: 源存储操作对象 :param target_oper: 目标存储操作对象 :return: {path, target_path, message} """ handler = TransHandler() # 检查目录路径 if fileitem.storage == "local" and not Path(fileitem.path).exists(): return TransferInfo(success=False, fileitem=fileitem, message=f"{fileitem.path} 不存在") # 目标路径不能是文件 if target_path and target_path.is_file(): logger.error(f"整理目标路径 {target_path} 是一个文件") return TransferInfo(success=False, fileitem=fileitem, message=f"{target_path} 不是有效目录") # 获取目标路径 if target_directory: # 目标媒体库目录未设置 if not target_directory.library_path: logger.error(f"目标媒体库目录未设置,无法整理文件,源路径:{fileitem.path}") return TransferInfo(success=False, fileitem=fileitem, message="目标媒体库目录未设置") # 整理方式 if not transfer_type: transfer_type = target_directory.transfer_type # 目标存储 if not target_storage: target_storage = target_directory.library_storage # 是否需要重命名 need_rename = target_directory.renaming # 是否需要通知 need_notify = target_directory.notify # 覆盖模式 overwrite_mode = target_directory.overwrite_mode # 是否需要刮削 need_scrape = target_directory.scraping if scrape is None else scrape # 拼装媒体库一、二级子目录 target_path = handler.get_dest_dir(mediainfo=mediainfo, target_dir=target_directory, need_type_folder=library_type_folder, need_category_folder=library_category_folder) elif target_path: need_scrape = scrape or False need_rename = True need_notify = False overwrite_mode = "never" # 手动整理的场景,有自定义目标路径 target_path = handler.get_dest_path(mediainfo=mediainfo, target_path=target_path, need_type_folder=library_type_folder, need_category_folder=library_category_folder) else: # 未找到有效的媒体库目录 logger.error( f"{mediainfo.type.value if mediainfo.type else '未知类型'} {mediainfo.title_year} 未找到有效的媒体库目录,无法整理文件,源路径:{fileitem.path}") return TransferInfo(success=False, fileitem=fileitem, message="未找到有效的媒体库目录") # 整理方式 if not transfer_type: logger.error(f"{target_directory.name} 未设置整理方式") return TransferInfo(success=False, fileitem=fileitem, message=f"{target_directory.name} 未设置整理方式") # 源操作对象 if not source_oper: source_oper = self.__get_storage_oper(fileitem.storage) if not source_oper: return TransferInfo(success=False, message=f"不支持的存储类型:{fileitem.storage}", fileitem=fileitem, fail_list=[fileitem.path], transfer_type=transfer_type, need_notify=need_notify ) # 目的操作对象 if not target_oper: if not target_storage: target_storage = fileitem.storage target_oper = self.__get_storage_oper(target_storage) if not target_oper: return TransferInfo(success=False, message=f"不支持的存储类型:{target_storage}", fileitem=fileitem, fail_list=[fileitem.path], transfer_type=transfer_type, need_notify=need_notify) # 整理 logger.info(f"获取整理目标路径:【{target_storage}】{target_path}") return handler.transfer_media(fileitem=fileitem, in_meta=meta, mediainfo=mediainfo, target_storage=target_storage, target_path=target_path, transfer_type=transfer_type, need_scrape=need_scrape, need_rename=need_rename, need_notify=need_notify, overwrite_mode=overwrite_mode, episodes_info=episodes_info, source_oper=source_oper, target_oper=target_oper) def media_files(self, mediainfo: MediaInfo) -> List[FileItem]: """ 获取对应媒体的媒体库文件列表 :param mediainfo: 媒体信息 """ handler = TransHandler() ret_fileitems = [] # 检查本地媒体库 dest_dirs = DirectoryHelper().get_library_dirs() # 检查每一个媒体库目录 for dest_dir in dest_dirs: # 存储 storage_oper = self.__get_storage_oper(dest_dir.library_storage) if not storage_oper: continue # 媒体分类路径 dir_path = handler.get_dest_dir(mediainfo=mediainfo, target_dir=dest_dir) # 重命名格式 rename_format = settings.RENAME_FORMAT(mediainfo.type) # 元数据补上常用属性,尽可能确保重命名后的路径不出现空白 meta = MetaInfo(mediainfo.title) if meta.type == MediaType.UNKNOWN and mediainfo.type is not None: meta.type = mediainfo.type if meta.year is None: meta.year = mediainfo.year if meta.begin_season is None: meta.begin_season = 1 if meta.begin_episode is None: meta.begin_episode = 1 # 获取路径(重命名路径) target_path = handler.get_rename_path( path=dir_path, template_string=rename_format, rename_dict=handler.get_naming_dict(meta=meta, mediainfo=mediainfo) ) # 获取重命名后的媒体文件根路径 media_path = DirectoryHelper.get_media_root_path( rename_format, rename_path=target_path ) if not media_path: # 忽略 continue if dir_path != media_path and dir_path.is_relative_to(media_path): # 兜底检查,避免不必要的扫盘 logger.warn(f"{media_path} 是媒体库目录 {dir_path} 的父目录,忽略获取媒体文件列表,请检查重命名格式!") continue # 检索媒体文件 fileitem = storage_oper.get_item(media_path) if not fileitem: continue try: media_files = self.list_files(fileitem, True) except Exception as e: logger.debug(f"获取媒体文件列表失败:{str(e)}") continue if media_files: for media_file in media_files: if f".{media_file.extension.lower()}" in settings.RMT_MEDIAEXT: if media_file not in ret_fileitems: ret_fileitems.append(media_file) return ret_fileitems def media_exists(self, mediainfo: MediaInfo, **kwargs) -> Optional[ExistMediaInfo]: """ 判断媒体文件是否存在于文件系统(网盘或本地文件),只支持标准媒体库结构 :param mediainfo: 识别的媒体信息 :return: 如不存在返回None,存在时返回信息,包括每季已存在所有集{type: movie/tv, seasons: {season: [episodes]}} """ if not settings.LOCAL_EXISTS_SEARCH: return None logger.debug(f"正在本地媒体库中查找 {mediainfo.title_year}...") # 检查媒体库 fileitems = self.media_files(mediainfo) if not fileitems: logger.debug(f"{mediainfo.title_year} 不在本地媒体库中") return None if mediainfo.type == MediaType.MOVIE: # 电影存在任何文件为存在 logger.info(f"{mediainfo.title_year} 在本地文件系统中找到了") return ExistMediaInfo(type=MediaType.MOVIE) else: # 电视剧检索集数 seasons: Dict[int, list] = {} for fileitem in fileitems: file_meta = MetaInfo(fileitem.basename) season_index = file_meta.begin_season or 1 episode_index = file_meta.begin_episode if not episode_index: continue if season_index not in seasons: seasons[season_index] = [] if episode_index not in seasons[season_index]: seasons[season_index].append(episode_index) # 返回剧集情况 logger.info(f"{mediainfo.title_year} 在本地文件系统中找到了这些季集:{seasons}") return ExistMediaInfo(type=MediaType.TV, seasons=seasons) ================================================ FILE: app/modules/filemanager/storages/__init__.py ================================================ from abc import ABCMeta, abstractmethod from pathlib import Path from typing import Optional, List, Dict, Tuple, Callable, Union from tqdm import tqdm from app import schemas from app.helper.progress import ProgressHelper from app.helper.storage import StorageHelper from app.log import logger from app.utils.crypto import HashUtils def transfer_process(path: str) -> Callable[[int | float], None]: """ 传输进度回调 """ pbar = tqdm(total=100, desc="进度", unit="%") progress = ProgressHelper(HashUtils.md5(path)) progress.start() def update_progress(percent: Union[int, float]) -> None: """ 更新进度百分比 """ percent_value = round(percent, 2) if isinstance(percent, float) else percent pbar.n = percent_value # 更新进度 pbar.refresh() progress.update(value=percent_value, text=f"{path} 进度:{percent_value}%") # 完成时结束 if percent_value >= 100: progress.end() pbar.close() return update_progress class StorageBase(metaclass=ABCMeta): """ 存储基类 """ schema = None transtype = {} snapshot_check_folder_modtime = True def __init__(self): self.storagehelper = StorageHelper() @abstractmethod def init_storage(self): """ 初始化 """ pass def generate_qrcode(self, *args, **kwargs) -> Optional[Tuple[dict, str]]: pass def generate_auth_url(self, *args, **kwargs) -> Optional[Tuple[dict, str]]: """ 生成 OAuth2 授权 URL """ return {}, "此存储不支持 OAuth2 授权" def check_login(self, *args, **kwargs) -> Optional[Dict[str, str]]: pass def get_config(self) -> Optional[schemas.StorageConf]: """ 获取配置 """ return self.storagehelper.get_storage(self.schema.value) def get_conf(self) -> dict: """ 获取配置 """ conf = self.get_config() return conf.config if conf else {} def set_config(self, conf: dict): """ 设置配置 """ self.storagehelper.set_storage(self.schema.value, conf) self.init_storage() def support_transtype(self) -> dict: """ 支持的整理方式 """ return self.transtype def is_support_transtype(self, transtype: str) -> bool: """ 是否支持整理方式 """ return transtype in self.transtype def reset_config(self): """ 重置置配置 """ self.storagehelper.reset_storage(self.schema.value) self.init_storage() @abstractmethod def check(self) -> bool: """ 检查存储是否可用 """ pass @abstractmethod def list(self, fileitem: schemas.FileItem) -> List[schemas.FileItem]: """ 浏览文件 """ pass @abstractmethod def create_folder(self, fileitem: schemas.FileItem, name: str) -> Optional[schemas.FileItem]: """ 创建目录 :param fileitem: 父目录 :param name: 目录名 """ pass @abstractmethod def get_folder(self, path: Path) -> Optional[schemas.FileItem]: """ 获取目录,如目录不存在则创建 """ pass @abstractmethod def get_item(self, path: Path) -> Optional[schemas.FileItem]: """ 获取文件或目录,不存在返回None """ pass def get_parent(self, fileitem: schemas.FileItem) -> Optional[schemas.FileItem]: """ 获取父目录 """ return self.get_item(Path(fileitem.path).parent) @abstractmethod def delete(self, fileitem: schemas.FileItem) -> bool: """ 删除文件 """ pass @abstractmethod def rename(self, fileitem: schemas.FileItem, name: str) -> bool: """ 重命名文件 """ pass @abstractmethod def download(self, fileitem: schemas.FileItem, path: Path = None) -> Path: """ 下载文件,保存到本地,返回本地临时文件地址 :param fileitem: 文件项 :param path: 文件保存路径 """ pass @abstractmethod def upload(self, fileitem: schemas.FileItem, path: Path, new_name: Optional[str] = None) -> Optional[schemas.FileItem]: """ 上传文件 :param fileitem: 上传目录项 :param path: 本地文件路径 :param new_name: 上传后文件名 """ pass @abstractmethod def detail(self, fileitem: schemas.FileItem) -> Optional[schemas.FileItem]: """ 获取文件详情 """ pass @abstractmethod def copy(self, fileitem: schemas.FileItem, path: Path, new_name: str) -> bool: """ 复制文件 :param fileitem: 文件项 :param path: 目标目录 :param new_name: 新文件名 """ pass @abstractmethod def move(self, fileitem: schemas.FileItem, path: Path, new_name: str) -> bool: """ 移动文件 :param fileitem: 文件项 :param path: 目标目录 :param new_name: 新文件名 """ pass @abstractmethod def link(self, fileitem: schemas.FileItem, target_file: Path) -> bool: """ 硬链接文件 """ pass @abstractmethod def softlink(self, fileitem: schemas.FileItem, target_file: Path) -> bool: """ 软链接文件 """ pass @abstractmethod def usage(self) -> Optional[schemas.StorageUsage]: """ 存储使用情况 """ pass def snapshot(self, path: Path, last_snapshot_time: float = None, max_depth: int = 5) -> Dict[str, Dict]: """ 快照文件系统,输出所有层级文件信息(不含目录) :param path: 路径 :param last_snapshot_time: 上次快照时间,用于增量快照 :param max_depth: 最大递归深度,避免过深遍历 """ files_info = {} def __snapshot_file(_fileitm: schemas.FileItem, current_depth: int = 0): """ 递归获取文件信息 """ try: if _fileitm.type == "dir": # 检查递归深度限制 if current_depth >= max_depth: return # 增量检查:如果目录修改时间早于上次快照,跳过 if (self.snapshot_check_folder_modtime and last_snapshot_time and _fileitm.modify_time and _fileitm.modify_time <= last_snapshot_time): return # 遍历子文件 sub_files = self.list(_fileitm) for sub_file in sub_files: __snapshot_file(sub_file, current_depth + 1) else: # 记录文件的完整信息用于比对(始终包含所有文件,由 compare_snapshots 负责检测变化) files_info[_fileitm.path] = { 'size': _fileitm.size or 0, 'modify_time': getattr(_fileitm, 'modify_time', 0), 'type': _fileitm.type } except Exception as e: logger.debug(f"Snapshot error for {_fileitm.path}: {e}") fileitem = self.get_item(path) if not fileitem: return {} __snapshot_file(fileitem) return files_info ================================================ FILE: app/modules/filemanager/storages/alipan.py ================================================ import base64 import hashlib import secrets import threading import time from pathlib import Path from typing import List, Optional, Tuple, Union import requests from app import schemas from app.core.config import settings, global_vars from app.log import logger from app.modules.filemanager import StorageBase from app.modules.filemanager.storages import transfer_process from app.schemas.types import StorageSchema from app.utils.http import RequestUtils from app.utils.singleton import WeakSingleton from app.utils.string import StringUtils lock = threading.Lock() class NoCheckInException(Exception): pass class SessionInvalidException(Exception): pass class AliPan(StorageBase, metaclass=WeakSingleton): """ 阿里云盘相关操作 """ # 存储类型 schema = StorageSchema.Alipan # 支持的整理方式 transtype = {"move": "移动", "copy": "复制"} # 基础url base_url = "https://openapi.alipan.com" # 阿里云盘目录时间不随子文件变更而更新,默认关闭目录修改时间检查 snapshot_check_folder_modtime = settings.ALIPAN_SNAPSHOT_CHECK_FOLDER_MODTIME # 文件块大小,默认10MB chunk_size = 10 * 1024 * 1024 def __init__(self): super().__init__() self._auth_state = {} self.session = requests.Session() self._init_session() def _init_session(self): """ 初始化带速率限制的会话 """ self.session.headers.update({"Content-Type": "application/json"}) def _check_session(self): """ 检查会话是否过期 """ if not self.access_token: raise NoCheckInException("【阿里云盘】请先扫码登录!") @property def _default_drive_id(self) -> str: """ 获取默认存储桶ID """ conf = self.get_conf() drive_id = ( conf.get("resource_drive_id") or conf.get("backup_drive_id") or conf.get("default_drive_id") ) if not drive_id: raise NoCheckInException("【阿里云盘】请先扫码登录!") return drive_id @property def access_token(self) -> Optional[str]: """ 访问token """ with lock: tokens = self.get_conf() refresh_token = tokens.get("refresh_token") expires_in = tokens.get("expires_in", 0) refresh_time = tokens.get("refresh_time", 0) if expires_in and refresh_time + expires_in < int(time.time()): tokens = self.__refresh_access_token(refresh_token) if tokens: self.set_config({"refresh_time": int(time.time()), **tokens}) access_token = tokens.get("access_token") if access_token: self.session.headers.update({"Authorization": f"Bearer {access_token}"}) return access_token def generate_qrcode(self) -> Tuple[dict, str]: """ 实现PKCE规范的设备授权二维码生成 """ # 生成PKCE参数 code_verifier = secrets.token_urlsafe(96)[:128] # 请求设备码 resp = self.session.post( f"{self.base_url}/oauth/authorize/qrcode", json={ "client_id": settings.ALIPAN_APP_ID, "scopes": [ "user:base", "file:all:read", "file:all:write", "file:share:write", ], "code_challenge": code_verifier, "code_challenge_method": "plain", }, ) if resp is None: return {}, "网络错误" result = resp.json() if result.get("code"): return {}, result.get("message") # 持久化验证参数 self._auth_state = {"sid": result.get("sid"), "code_verifier": code_verifier} # 生成二维码内容 return {"codeUrl": result.get("qrCodeUrl")}, "" def check_login(self) -> Optional[Tuple[dict, str]]: """ 改进的带PKCE校验的登录状态检查 """ _status_text = { "WaitLogin": "等待登录", "ScanSuccess": "扫码成功", "LoginSuccess": "登录成功", "QRCodeExpired": "二维码过期", } if not self._auth_state: return {}, "生成二维码失败" try: resp = self.session.get( f"{self.base_url}/oauth/qrcode/{self._auth_state['sid']}/status" ) if resp is None: return {}, "网络错误" result = resp.json() # 扫码结果 status = result.get("status") if status == "LoginSuccess": authCode = result.get("authCode") self._auth_state["authCode"] = authCode tokens = self.__get_access_token() if tokens: self.set_config({"refresh_time": int(time.time()), **tokens}) self.__get_drive_id() return {"status": status, "tip": _status_text.get(status, "未知错误")}, "" except Exception as e: return {}, str(e) def __get_access_token(self) -> dict: """ 确认登录后,获取相关token """ if not self._auth_state: raise SessionInvalidException("【阿里云盘】请先生成二维码") resp = self.session.post( f"{self.base_url}/oauth/access_token", json={ "client_id": settings.ALIPAN_APP_ID, "grant_type": "authorization_code", "code": self._auth_state["authCode"], "code_verifier": self._auth_state["code_verifier"], }, ) if resp is None: raise SessionInvalidException("【阿里云盘】获取 access_token 失败") result = resp.json() if result.get("code"): raise Exception( f"【阿里云盘】{result.get('code')} - {result.get('message')}!" ) return result def __refresh_access_token(self, refresh_token: str) -> Optional[dict]: """ 刷新access_token """ if not refresh_token: raise SessionInvalidException("【阿里云盘】会话失效,请重新扫码登录!") resp = self.session.post( f"{self.base_url}/oauth/access_token", json={ "client_id": settings.ALIPAN_APP_ID, "grant_type": "refresh_token", "refresh_token": refresh_token, }, ) if resp is None: logger.error( f"【阿里云盘】刷新 access_token 失败:refresh_token={refresh_token}" ) return None result = resp.json() if result.get("code"): logger.warn( f"【阿里云盘】刷新 access_token 失败:{result.get('code')} - {result.get('message')}!" ) return result def __get_drive_id(self): """ 获取默认存储桶ID """ resp = self.session.post(f"{self.base_url}/adrive/v1.0/user/getDriveInfo") if resp is None: logger.error("获取默认存储桶ID失败") return None result = resp.json() if result.get("code"): logger.warn( f"获取默认存储ID失败:{result.get('code')} - {result.get('message')}!" ) return None # 保存用户参数 """ user_id string 是 用户ID,具有唯一性 name string 是 昵称 avatar string 是 头像地址 default_drive_id string 是 默认drive resource_drive_id string 否 资源库。用户选择了授权才会返回 backup_drive_id string 否 备份盘。用户选择了授权才会返回 """ conf = self.get_conf() conf.update(result) self.set_config(conf) return None def _request_api( self, method: str, endpoint: str, result_key: Optional[str] = None, **kwargs ) -> Optional[Union[dict, list]]: """ 带错误处理和速率限制的API请求 """ # 检查会话 self._check_session() # 错误日志控制 no_error_log = kwargs.pop("no_error_log", False) try: resp = self.session.request(method, f"{self.base_url}{endpoint}", **kwargs) except requests.exceptions.RequestException as e: logger.error(f"【阿里云盘】{method} 请求 {endpoint} 网络错误: {str(e)}") return None if resp is None: logger.warn(f"【阿里云盘】{method} 请求 {endpoint} 失败!") return None # 处理速率限制 if resp.status_code == 429: reset_time = int(resp.headers.get("X-RateLimit-Reset", 60)) time.sleep(reset_time + 5) return self._request_api(method, endpoint, result_key, **kwargs) # 返回数据 ret_data = resp.json() if ret_data.get("code"): if not no_error_log: logger.warn( f"【阿里云盘】{method} {endpoint} 返回:{ret_data.get('code')} {ret_data.get('message')}" ) if result_key: return ret_data.get(result_key) return ret_data def __get_fileitem(self, fileinfo: dict, parent: str = "/") -> schemas.FileItem: """ 获取文件信息 """ if not fileinfo: return schemas.FileItem() if not parent.endswith("/"): parent += "/" if fileinfo.get("type") == "folder": return schemas.FileItem( storage=self.schema.value, fileid=fileinfo.get("file_id"), parent_fileid=fileinfo.get("parent_file_id"), type="dir", path=f"{parent}{fileinfo.get('name')}" + "/", name=fileinfo.get("name"), basename=fileinfo.get("name"), size=fileinfo.get("size"), modify_time=StringUtils.str_to_timestamp(fileinfo.get("updated_at")), drive_id=fileinfo.get("drive_id"), ) else: return schemas.FileItem( storage=self.schema.value, fileid=fileinfo.get("file_id"), parent_fileid=fileinfo.get("parent_file_id"), type="file", path=f"{parent}{fileinfo.get('name')}", name=fileinfo.get("name"), basename=Path(fileinfo.get("name")).stem, size=fileinfo.get("size"), extension=fileinfo.get("file_extension"), modify_time=StringUtils.str_to_timestamp(fileinfo.get("updated_at")), thumbnail=fileinfo.get("thumbnail"), drive_id=fileinfo.get("drive_id"), ) @staticmethod def _calc_sha1(filepath: Path, size: Optional[int] = None) -> str: """ 计算文件SHA1(符合阿里云盘规范) size: 前多少字节 """ sha1 = hashlib.sha1() with open(filepath, "rb") as f: if size: chunk = f.read(size) sha1.update(chunk) else: while chunk := f.read(8192): sha1.update(chunk) return sha1.hexdigest() def init_storage(self): pass def list(self, fileitem: schemas.FileItem) -> List[schemas.FileItem]: """ 目录遍历实现 """ if fileitem.type == "file": item = self.detail(fileitem) if item: return [item] return [] if fileitem.path == "/": parent_file_id = "root" drive_id = self._default_drive_id else: parent_file_id = fileitem.fileid drive_id = fileitem.drive_id items = [] next_marker = None while True: resp = self._request_api( "POST", "/adrive/v1.0/openFile/list", json={ "drive_id": drive_id, "limit": 100, "marker": next_marker, "parent_file_id": parent_file_id, }, ) if resp is None: raise FileNotFoundError(f"【阿里云盘】{fileitem.path} 检索出错!") if not resp: break next_marker = resp.get("next_marker") for item in resp.get("items", []): items.append(self.__get_fileitem(item, parent=str(fileitem.path))) if len(resp.get("items")) < 100: break return items def _delay_get_item(self, path: Path) -> Optional[schemas.FileItem]: """ 自动延迟重试 get_item 模块 """ for _ in range(2): time.sleep(2) fileitem = self.get_item(path) if fileitem: return fileitem return None def create_folder( self, parent_item: schemas.FileItem, name: str ) -> Optional[schemas.FileItem]: """ 创建目录 """ resp = self._request_api( "POST", "/adrive/v1.0/openFile/create", json={ "drive_id": parent_item.drive_id, "parent_file_id": parent_item.fileid or "root", "name": name, "type": "folder", }, ) if not resp: return None if resp.get("code"): logger.warn(f"【阿里云盘】创建目录失败: {resp.get('message')}") return None # 缓存新目录 new_path = Path(parent_item.path) / name return self._delay_get_item(new_path) @staticmethod def _calculate_pre_hash(file_path: Path): """ 计算文件前1KB的SHA1作为pre_hash """ sha1 = hashlib.sha1() with open(file_path, "rb") as f: data = f.read(1024) sha1.update(data) return sha1.hexdigest() def _calculate_proof_code(self, file_path: Path): """ 计算秒传所需的proof_code """ file_size = file_path.stat().st_size if file_size == 0: return "" # Step 1-3: 计算access_token的MD5并取前16位 md5 = hashlib.md5(self.access_token.encode()).hexdigest() hex_str = md5[:16] # Step 4: 转换为无符号int64 try: tmp_int = int(hex_str, 16) except ValueError: raise ValueError( "【阿里云盘】Invalid hex string for proof code calculation" ) # Step 5-7: 计算读取范围 index = tmp_int % file_size start = index end = index + 8 if end > file_size: end = file_size # Step 8: 读取文件范围数据并编码 with open(file_path, "rb") as f: f.seek(start) chunk = f.read(end - start) return base64.b64encode(chunk).decode() @staticmethod def _calculate_content_hash(file_path: Path): """ 计算整个文件的SHA1作为content_hash """ sha1 = hashlib.sha1() with open(file_path, "rb") as f: while True: chunk = f.read(8192) if not chunk: break sha1.update(chunk) return sha1.hexdigest() def _create_file( self, drive_id: str, parent_file_id: str, file_name: str, file_path: Path, check_name_mode="refuse", chunk_size: int = 1 * 1024 * 1024 * 1024, ): """ 创建文件请求,尝试秒传 """ file_size = file_path.stat().st_size pre_hash = self._calculate_pre_hash(file_path) num_parts = (file_size + chunk_size - 1) // chunk_size # 构建分片信息 part_info_list = [{"part_number": i + 1} for i in range(num_parts)] # 确定是否能秒传 data = { "drive_id": drive_id, "parent_file_id": parent_file_id, "name": file_name, "type": "file", "check_name_mode": check_name_mode, "size": file_size, "pre_hash": pre_hash, "part_info_list": part_info_list, } resp = self._request_api("POST", "/adrive/v1.0/openFile/create", json=data) if not resp: raise Exception("【阿里云盘】创建文件失败!") if resp.get("code") == "PreHashMatched": # 可以秒传 proof_code = self._calculate_proof_code(file_path) content_hash = self._calculate_content_hash(file_path) data.pop("pre_hash") data.update( { "proof_code": proof_code, "proof_version": "v1", "content_hash": content_hash, "content_hash_name": "sha1", } ) resp = self._request_api("POST", "/adrive/v1.0/openFile/create", json=data) if not resp: raise Exception("【阿里云盘】创建文件失败!") if resp.get("code"): raise Exception(resp.get("message")) return resp def _refresh_upload_urls( self, drive_id: str, file_id: str, upload_id: str, part_numbers: List[int] ): """ 刷新分片上传地址 """ data = { "drive_id": drive_id, "file_id": file_id, "upload_id": upload_id, "part_info_list": [{"part_number": num} for num in part_numbers], } resp = self._request_api( "POST", "/adrive/v1.0/openFile/getUploadUrl", json=data ) if not resp: raise Exception("【阿里云盘】刷新分片上传地址失败!") if resp.get("code"): raise Exception(resp.get("message")) return resp.get("part_info_list", []) @staticmethod def _upload_part(upload_url: str, data: bytes): """ 上传单个分片 """ return requests.put(upload_url, data=data, timeout=60.0) def _list_uploaded_parts(self, drive_id: str, file_id: str, upload_id: str) -> dict: """ 获取已上传分片列表 """ data = {"drive_id": drive_id, "file_id": file_id, "upload_id": upload_id} resp = self._request_api( "POST", "/adrive/v1.0/openFile/listUploadedParts", json=data ) if not resp: raise Exception("【阿里云盘】获取已上传分片失败!") if resp.get("code"): raise Exception(resp.get("message")) return resp def _complete_upload(self, drive_id: str, file_id: str, upload_id: str): """标记上传完成""" data = {"drive_id": drive_id, "file_id": file_id, "upload_id": upload_id} resp = self._request_api("POST", "/adrive/v1.0/openFile/complete", json=data) if not resp: raise Exception("【阿里云盘】完成上传失败!") if resp.get("code"): raise Exception(resp.get("message")) return resp def upload( self, target_dir: schemas.FileItem, local_path: Path, new_name: Optional[str] = None, ) -> Optional[schemas.FileItem]: """ 文件上传:分片、支持秒传 """ target_name = new_name or local_path.name target_path = Path(target_dir.path) / target_name file_size = local_path.stat().st_size # 1. 创建文件并检查秒传 chunk_size = 10 * 1024 * 1024 # 分片大小 10M create_res = self._create_file( drive_id=target_dir.drive_id, parent_file_id=target_dir.fileid, file_name=target_name, file_path=local_path, chunk_size=chunk_size, ) if create_res.get("rapid_upload", False): logger.info(f"【阿里云盘】{target_name} 秒传完成!") return self._delay_get_item(target_path) if create_res.get("exist", False): logger.info(f"【阿里云盘】{target_name} 已存在") return self.get_item(target_path) # 2. 准备分片上传参数 file_id = create_res.get("file_id") if not file_id: logger.warn(f"【阿里云盘】创建 {target_name} 文件失败!") return None upload_id = create_res.get("upload_id") part_info_list = create_res.get("part_info_list") uploaded_parts = set() # 3. 获取已上传分片 uploaded_info = self._list_uploaded_parts( drive_id=target_dir.drive_id, file_id=file_id, upload_id=upload_id ) for part in uploaded_info.get("uploaded_parts", []): uploaded_parts.add(part["part_number"]) # 4. 初始化进度条 logger.info( f"【阿里云盘】开始上传: {local_path} -> {target_path},分片数:{len(part_info_list)}" ) progress_callback = transfer_process(local_path.as_posix()) # 5. 分片上传循环 uploaded_size = 0 with open(local_path, "rb") as f: for part_info in part_info_list: if global_vars.is_transfer_stopped(local_path.as_posix()): logger.info(f"【阿里云盘】{target_name} 上传已取消!") return None # 计算分片参数 part_num = part_info["part_number"] start = (part_num - 1) * chunk_size end = min(start + chunk_size, file_size) current_chunk_size = end - start # 更新进度条(已存在的分片) if part_num in uploaded_parts: uploaded_size += current_chunk_size progress_callback((uploaded_size * 100) / file_size) continue # 准备分片数据 f.seek(start) data = f.read(current_chunk_size) # 上传分片(带重试逻辑) success = False for attempt in range(3): # 最大重试次数 try: # 获取当前上传地址(可能刷新) if attempt > 0: new_urls = self._refresh_upload_urls( drive_id=target_dir.drive_id, file_id=file_id, upload_id=upload_id, part_numbers=[part_num], ) upload_url = new_urls[0]["upload_url"] else: upload_url = part_info["upload_url"] # 执行上传 logger.info( f"【阿里云盘】开始 第{attempt + 1}次 上传 {target_name} 分片 {part_num} ..." ) response = self._upload_part(upload_url=upload_url, data=data) if response is None: continue if response.status_code == 200: success = True break else: logger.warn( f"【阿里云盘】{target_name} 分片 {part_num} 第 {attempt + 1} 次上传失败:{response.text}!" ) except Exception as e: logger.warn( f"【阿里云盘】{target_name} 分片 {part_num} 上传异常: {str(e)}!" ) # 处理上传结果 if success: uploaded_parts.add(part_num) uploaded_size += current_chunk_size progress_callback((uploaded_size * 100) / file_size) else: raise Exception( f"【阿里云盘】{target_name} 分片 {part_num} 上传失败!" ) # 6. 关闭进度条 progress_callback(100) # 7. 完成上传 result = self._complete_upload( drive_id=target_dir.drive_id, file_id=file_id, upload_id=upload_id ) if not result: raise Exception("【阿里云盘】完成上传失败!") if result.get("code"): logger.warn( f"【阿里云盘】{target_name} 上传失败:{result.get('message')}!" ) return self.__get_fileitem(result, parent=target_dir.path) def download(self, fileitem: schemas.FileItem, path: Path = None) -> Optional[Path]: """ 带实时进度显示的下载 """ download_info = self._request_api( "POST", "/adrive/v1.0/openFile/getDownloadUrl", json={ "drive_id": fileitem.drive_id, "file_id": fileitem.fileid, }, ) if not download_info: logger.error(f"【阿里云盘】获取下载链接失败: {fileitem.name}") return None download_url = download_info.get("url") if not download_url: logger.error(f"【阿里云盘】下载链接为空: {fileitem.name}") return None local_path = (path or settings.TEMP_PATH) / fileitem.name # 获取文件大小 file_size = fileitem.size # 初始化进度条 logger.info(f"【阿里云盘】开始下载: {fileitem.name} -> {local_path}") progress_callback = transfer_process(Path(fileitem.path).as_posix()) try: # 构建请求头,包含必要的认证信息 headers = { "User-Agent": settings.NORMAL_USER_AGENT, "Referer": "https://www.aliyundrive.com/", "Accept": "*/*", "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", "Accept-Encoding": "gzip, deflate, br", "Connection": "keep-alive", "Sec-Fetch-Dest": "empty", "Sec-Fetch-Mode": "cors", "Sec-Fetch-Site": "cross-site", } # 如果有access_token,添加到请求头 if self.access_token: headers["Authorization"] = f"Bearer {self.access_token}" request_utils = RequestUtils(headers=headers) with request_utils.get_stream(download_url, raise_exception=True) as r: r.raise_for_status() downloaded_size = 0 with open(local_path, "wb") as f: for chunk in r.iter_content(chunk_size=self.chunk_size): if global_vars.is_transfer_stopped(fileitem.path): logger.info(f"【阿里云盘】{fileitem.path} 下载已取消!") return None if chunk: f.write(chunk) # 更新进度 downloaded_size += len(chunk) if file_size: progress = (downloaded_size * 100) / file_size progress_callback(progress) # 完成下载 progress_callback(100) logger.info(f"【阿里云盘】下载完成: {fileitem.name}") return local_path except Exception as e: logger.error(f"【阿里云盘】下载失败: {fileitem.name} - {str(e)}") if local_path.exists(): local_path.unlink() return None def check(self) -> bool: return self.access_token is not None def delete(self, fileitem: schemas.FileItem) -> bool: """ 删除文件/目录 """ try: self._request_api( "POST", "/adrive/v1.0/openFile/recyclebin/trash", json={"drive_id": fileitem.drive_id, "file_id": fileitem.fileid}, ) return True except requests.exceptions.HTTPError: return False def rename(self, fileitem: schemas.FileItem, name: str) -> bool: """ 重命名文件/目录 """ resp = self._request_api( "POST", "/adrive/v1.0/openFile/update", json={ "drive_id": fileitem.drive_id, "file_id": fileitem.fileid, "name": name, }, ) if not resp: return False if resp.get("code"): logger.warn(f"【阿里云盘】重命名失败: {resp.get('message')}") return False return True def get_item(self, path: Path, drive_id: str = None) -> Optional[schemas.FileItem]: """ 获取指定路径的文件/目录项 """ try: resp = self._request_api( "POST", "/adrive/v1.0/openFile/get_by_path", json={ "drive_id": drive_id or self._default_drive_id, "file_path": path.as_posix(), }, no_error_log=True, ) if not resp: return None if resp.get("code"): logger.debug(f"【阿里云盘】获取文件信息失败: {resp.get('message')}") return None return self.__get_fileitem(resp, parent=str(path.parent)) except Exception as e: logger.debug(f"【阿里云盘】获取文件信息失败: {str(e)}") return None def get_folder(self, path: Path) -> Optional[schemas.FileItem]: """ 获取指定路径的文件夹,如不存在则创建 """ def __find_dir( _fileitem: schemas.FileItem, _name: str ) -> Optional[schemas.FileItem]: """ 查找下级目录中匹配名称的目录 """ for sub_folder in self.list(_fileitem): if sub_folder.type != "dir": continue if sub_folder.name == _name: return sub_folder return None # 是否已存在 folder = self.get_item(path) if folder: return folder # 逐级查找和创建目录 fileitem = schemas.FileItem( storage=self.schema.value, path="/", drive_id=self._default_drive_id ) for part in path.parts[1:]: dir_file = __find_dir(fileitem, part) if dir_file: fileitem = dir_file else: dir_file = self.create_folder(fileitem, part) if not dir_file: logger.warn(f"【阿里云盘】创建目录 {fileitem.path}{part} 失败!") return None fileitem = dir_file return fileitem def detail(self, fileitem: schemas.FileItem) -> Optional[schemas.FileItem]: """ 获取文件/目录详细信息 """ return self.get_item(Path(fileitem.path)) def copy(self, fileitem: schemas.FileItem, path: Path, new_name: str) -> bool: """ 复制文件到指定路径 :param fileitem: 要复制的文件项 :param path: 目标目录路径 :param new_name: 新文件名 """ dest_fileitem = self.get_item(path, drive_id=fileitem.drive_id) if not dest_fileitem or dest_fileitem.type != "dir": logger.warn(f"【阿里云盘】目标路径 {path} 不存在或不是目录!") return False resp = self._request_api( "POST", "/adrive/v1.0/openFile/copy", json={ "drive_id": fileitem.drive_id, "file_id": fileitem.fileid, "to_drive_id": fileitem.drive_id, "to_parent_file_id": dest_fileitem.fileid, }, ) if not resp: return False if resp.get("code"): logger.warn(f"【阿里云盘】复制文件失败: {resp.get('message')}") return False # 重命名 new_path = Path(path) / fileitem.name new_file = self._delay_get_item(new_path) self.rename(new_file, new_name) return True def move(self, fileitem: schemas.FileItem, path: Path, new_name: str) -> bool: """ 移动文件到指定路径 :param fileitem: 要移动的文件项 :param path: 目标目录路径 :param new_name: 新文件名 """ src_fid = fileitem.fileid target_fileitem = self.get_item(path, drive_id=fileitem.drive_id) if not target_fileitem or target_fileitem.type != "dir": logger.warn(f"【阿里云盘】目标路径 {path} 不存在或不是目录!") return False resp = self._request_api( "POST", "/adrive/v1.0/openFile/move", json={ "drive_id": fileitem.drive_id, "file_id": src_fid, "to_parent_file_id": target_fileitem.fileid, "new_name": new_name, }, ) if not resp: return False if resp.get("code"): logger.warn(f"【阿里云盘】移动文件失败: {resp.get('message')}") return False return True def link(self, fileitem: schemas.FileItem, target_file: Path) -> bool: pass def softlink(self, fileitem: schemas.FileItem, target_file: Path) -> bool: pass def usage(self) -> Optional[schemas.StorageUsage]: """ 获取带有企业级配额信息的存储使用情况 """ try: resp = self._request_api("POST", "/adrive/v1.0/user/getSpaceInfo") if not resp: return None space = resp.get("personal_space_info") or {} total_size = space.get("total_size") or 0 used_size = space.get("used_size") or 0 return schemas.StorageUsage( total=total_size, available=total_size - used_size ) except NoCheckInException: return None except SessionInvalidException: return None ================================================ FILE: app/modules/filemanager/storages/alist.py ================================================ import json import time from datetime import datetime from pathlib import Path from typing import Optional, List from app import schemas from app.core.cache import cached from app.core.config import settings, global_vars from app.log import logger from app.modules.filemanager.storages import StorageBase, transfer_process from app.schemas.exception import OperationInterrupted from app.schemas.types import StorageSchema from app.utils.http import RequestUtils from app.utils.singleton import WeakSingleton from app.utils.url import UrlUtils class Alist(StorageBase, metaclass=WeakSingleton): """ Openlist相关操作 API 文档:https://fox.oplist.org/ """ # 存储类型 schema = StorageSchema.Alist # 支持的整理方式 transtype = { "copy": "复制", "move": "移动", } # 快照检查目录修改时间 snapshot_check_folder_modtime = settings.OPENLIST_SNAPSHOT_CHECK_FOLDER_MODTIME def __init__(self): super().__init__() def init_storage(self): """ 初始化 """ self.__generate_token.cache_clear() # noqa def _delay_get_item( self, path: Path, /, refresh: bool = False ) -> Optional[schemas.FileItem]: """ 自动延迟重试 get_item 模块 :param path: 文件路径 :param refresh: 是否刷新 :return: 文件项 """ for _ in range(2): time.sleep(2) fileitem = self.get_item(path=path, refresh=refresh) if fileitem: return fileitem return None @property def __get_base_url(self) -> str: """ 获取基础URL """ url = self.get_conf().get("url") if url is None: return "" return UrlUtils.standardize_base_url(self.get_conf().get("url")) def __get_api_url(self, path: str) -> str: """ 获取API URL :param path: API路径 :return: API URL """ return UrlUtils.adapt_request_url(self.__get_base_url, path) @property def __get_valuable_toke(self) -> str: """ 获取一个可用的token 如果设置永久令牌则返回永久令牌 否则使用账号密码生成临时令牌 """ return self.__generate_token() @cached(maxsize=1, ttl=60 * 60 * 24 * 2 - 60 * 5, skip_empty=True) def __generate_token(self) -> str: """ 如果设置永久令牌则返回永久令牌,否则使用账号密码生成一个临时 token 缓存2天,提前5分钟更新 """ conf = self.get_conf() token = conf.get("token") if token: return str(token) resp = RequestUtils(headers={"Content-Type": "application/json"}).post_res( self.__get_api_url("/api/auth/login"), data=json.dumps( { "username": conf.get("username"), "password": conf.get("password"), } ), ) """ { "username": "{{alist_username}}", "password": "{{alist_password}}" } ====================================== { "code": 200, "message": "success", "data": { "token": "abcd" } } """ if resp is None: logger.warning("【OpenList】请求登录失败,无法连接alist服务") return "" if resp.status_code != 200: logger.warning( f"【OpenList】更新令牌请求发送失败,状态码:{resp.status_code}" ) return "" result = resp.json() if result["code"] != 200: logger.critical(f"【OpenList】更新令牌,错误信息:{result['message']}") return "" logger.debug("【OpenList】AList获取令牌成功") return result["data"]["token"] def __get_header_with_token(self) -> dict: """ 获取带有token的header """ return {"Authorization": self.__get_valuable_toke} def check(self) -> bool: """ 检查存储是否可用 """ return True if self.__generate_token() else False def list( self, fileitem: schemas.FileItem, password: Optional[str] = "", page: int = 1, per_page: int = 0, refresh: bool = False, ) -> List[schemas.FileItem]: """ 浏览文件 :param fileitem: 文件项 :param password: 路径密码 :param page: 页码 :param per_page: 每页数量 :param refresh: 是否刷新 :return: 文件列表 """ if fileitem.type == "file": item = self.get_item(Path(fileitem.path)) if item: return [item] return [] resp = RequestUtils(headers=self.__get_header_with_token()).post_res( self.__get_api_url("/api/fs/list"), json={ "path": fileitem.path, "password": password, "page": page, "per_page": per_page, "refresh": refresh, }, ) """ { "path": "/t", "password": "", "page": 1, "per_page": 0, "refresh": false } ====================================== { "code": 200, "message": "success", "data": { "content": [ { "name": "Alist V3.md", "size": 1592, "is_dir": false, "modified": "2024-05-17T13:47:55.4174917+08:00", "created": "2024-05-17T13:47:47.5725906+08:00", "sign": "", "thumb": "", "type": 4, "hashinfo": "null", "hash_info": null } ], "total": 1, "readme": "", "header": "", "write": true, "provider": "Local" } } """ if resp is None: logger.warn( f"【OpenList】请求获取目录 {fileitem.path} 的文件列表失败,无法连接alist服务" ) return [] if resp.status_code != 200: logger.warn( f"【OpenList】请求获取目录 {fileitem.path} 的文件列表失败,状态码:{resp.status_code}" ) return [] result = resp.json() if result["code"] != 200: logger.warn( f"【OpenList】获取目录 {fileitem.path} 的文件列表失败,错误信息:{result['message']}" ) return [] return [ schemas.FileItem( storage=self.schema.value, type="dir" if item["is_dir"] else "file", path=(Path(fileitem.path) / item["name"]).as_posix() + ("/" if item["is_dir"] else ""), name=item["name"], basename=Path(item["name"]).stem, extension=Path(item["name"]).suffix[1:] if not item["is_dir"] else None, size=item["size"] if not item["is_dir"] else None, modify_time=self.__parse_timestamp(item["modified"]), thumbnail=item["thumb"], ) for item in result["data"]["content"] or [] ] def create_folder( self, fileitem: schemas.FileItem, name: str ) -> Optional[schemas.FileItem]: """ 创建目录 :param fileitem: 父目录 :param name: 目录名 :return: 目录项 """ path = Path(fileitem.path) / name resp = RequestUtils(headers=self.__get_header_with_token()).post_res( self.__get_api_url("/api/fs/mkdir"), json={"path": path.as_posix()}, ) """ { "path": "/tt" } ====================================== { "code": 200, "message": "success", "data": null } """ if resp is None: logger.warn(f"【OpenList】请求创建目录 {path} 失败,无法连接alist服务") return None if resp.status_code != 200: logger.warn( f"【OpenList】请求创建目录 {path} 失败,状态码:{resp.status_code}" ) return None result = resp.json() if result["code"] != 200: logger.warn( f"【OpenList】创建目录 {path} 失败,错误信息:{result['message']}" ) return None return self._delay_get_item(path, refresh=True) def get_folder(self, path: Path) -> Optional[schemas.FileItem]: """ 获取目录,如目录不存在则创建 :param path: 目录路径 :return: 目录项 """ folder = self.get_item(path) if folder: return folder if not folder: folder = self.create_folder( schemas.FileItem( storage=self.schema.value, type="dir", path=path.parent.as_posix(), name=path.name, basename=path.stem, ), path.name, ) return folder def get_item( self, path: Path, password: Optional[str] = "", page: int = 1, per_page: int = 0, refresh: bool = False, ) -> Optional[schemas.FileItem]: """ 获取文件或目录,不存在返回None :param path: 文件路径 :param password: 路径密码 :param page: 页码 :param per_page: 每页数量 :param refresh: 是否刷新 :return: 文件项 """ resp = RequestUtils(headers=self.__get_header_with_token()).post_res( self.__get_api_url("/api/fs/get"), json={ "path": path.as_posix(), "password": password, "page": page, "per_page": per_page, "refresh": refresh, }, ) """ { "path": "/t", "password": "", "page": 1, "per_page": 0, "refresh": false } ====================================== { "code": 200, "message": "success", "data": { "name": "Alist V3.md", "size": 2618, "is_dir": false, "modified": "2024-05-17T16:05:36.4651534+08:00", "created": "2024-05-17T16:05:29.2001008+08:00", "sign": "", "thumb": "", "type": 4, "hashinfo": "null", "hash_info": null, "raw_url": "http://127.0.0.1:5244/p/local/Alist%20V3.md", "readme": "", "header": "", "provider": "Local", "related": null } } """ if resp is None: logger.warn(f"【OpenList】请求获取文件 {path} 失败,无法连接alist服务") return None if resp.status_code != 200: logger.warn( f"【OpenList】请求获取文件 {path} 失败,状态码:{resp.status_code}" ) return None result = resp.json() if result["code"] != 200: logger.debug( f"【OpenList】获取文件 {path} 失败,错误信息:{result['message']}" ) return None return schemas.FileItem( storage=self.schema.value, type="dir" if result["data"]["is_dir"] else "file", path=path.as_posix() + ("/" if result["data"]["is_dir"] else ""), name=result["data"]["name"], basename=Path(result["data"]["name"]).stem, extension=Path(result["data"]["name"]).suffix[1:], size=result["data"]["size"], modify_time=self.__parse_timestamp(result["data"]["modified"]), thumbnail=result["data"]["thumb"], ) def get_parent(self, fileitem: schemas.FileItem) -> Optional[schemas.FileItem]: """ 获取父目录 :param fileitem: 文件项 :return: 父目录项 """ return self.get_folder(Path(fileitem.path).parent) def __is_empty_dir(self, fileitem: schemas.FileItem) -> bool: """ 判断目录是否为空 :param fileitem: 文件项 :return: 是否为空目录 """ if fileitem.type != "dir": return False # 获取目录内容 items = self.list(fileitem) return len(items) == 0 def delete(self, fileitem: schemas.FileItem) -> bool: """ 删除文件或目录,空目录用专用API :param fileitem: 文件项 :return: 是否删除成功 """ # 如果是空目录,优先用 remove_empty_directory if fileitem.type == "dir" and self.__is_empty_dir(fileitem): resp = RequestUtils(headers=self.__get_header_with_token()).post_res( self.__get_api_url("/api/fs/remove_empty_directory"), json={ "src_dir": fileitem.path, }, ) if resp is None: logger.warn( f"【OpenList】请求删除空目录 {fileitem.path} 失败,无法连接alist服务" ) return False if resp.status_code != 200: logger.warn( f"【OpenList】请求删除空目录 {fileitem.path} 失败,状态码:{resp.status_code}" ) return False result = resp.json() if result["code"] != 200: logger.warn( f"【OpenList】删除空目录 {fileitem.path} 失败,错误信息:{result['message']}" ) return False return True # 其它情况(文件或非空目录) resp = RequestUtils(headers=self.__get_header_with_token()).post_res( self.__get_api_url("/api/fs/remove"), json={ "dir": Path(fileitem.path).parent.as_posix(), "names": [fileitem.name], }, ) if resp is None: logger.warn( f"【OpenList】请求删除文件 {fileitem.path} 失败,无法连接alist服务" ) return False if resp.status_code != 200: logger.warn( f"【OpenList】请求删除文件 {fileitem.path} 失败,状态码:{resp.status_code}" ) return False result = resp.json() if result["code"] != 200: logger.warn( f"【OpenList】删除文件 {fileitem.path} 失败,错误信息:{result['message']}" ) return False return True def rename(self, fileitem: schemas.FileItem, name: str) -> bool: """ 重命名文件 :param fileitem: 文件项 :param name: 新文件名 :return: 是否重命名成功 """ resp = RequestUtils(headers=self.__get_header_with_token()).post_res( self.__get_api_url("/api/fs/rename"), json={ "name": name, "path": fileitem.path, }, ) """ { "name": "test3", "path": "/阿里云盘/test2" } ====================================== { "code": 200, "message": "success", "data": null } """ if not resp: logger.warn( f"【OpenList】请求重命名文件 {fileitem.path} 失败,无法连接alist服务" ) return False if resp.status_code != 200: logger.warn( f"【OpenList】请求重命名文件 {fileitem.path} 失败,状态码:{resp.status_code}" ) return False result = resp.json() if result["code"] != 200: logger.warn( f"【OpenList】重命名文件 {fileitem.path} 失败,错误信息:{result['message']}" ) return False return True def download( self, fileitem: schemas.FileItem, path: Path = None, password: Optional[str] = "", ) -> Optional[Path]: """ 下载文件,保存到本地,返回本地临时文件地址 :param fileitem: 文件项 :param path: 文件保存路径 :param password: 文件密码 :return: 本地临时文件地址 """ resp = RequestUtils(headers=self.__get_header_with_token()).post_res( self.__get_api_url("/api/fs/get"), json={ "path": fileitem.path, "password": password, "page": 1, "per_page": 0, "refresh": False, }, ) """ { "code": 200, "message": "success", "data": { "name": "[ANi]輝夜姬想讓人告白~天才們的戀愛頭腦戰~[01][1080P][Baha][WEB-DL].mp4", "size": 924933111, "is_dir": false, "modified": "1970-01-01T00:00:00Z", "created": "1970-01-01T00:00:00Z", "sign": "1v0xkMQz_uG8fkEOQ7-l58OnbB-g4GkdBlUBcrsApCQ=:0", "thumb": "", "type": 2, "hashinfo": "null", "hash_info": null, "raw_url": "xxxxxx", "readme": "", "header": "", "provider": "UrlTree", "related": null } } """ if not resp: logger.warn(f"【OpenList】请求获取文件 {path} 失败,无法连接alist服务") return None if resp.status_code != 200: logger.warn( f"【OpenList】请求获取文件 {path} 失败,状态码:{resp.status_code}" ) return None result = resp.json() if result["code"] != 200: logger.warn( f"【OpenList】获取文件 {path} 失败,错误信息:{result['message']}" ) return None if result["data"]["raw_url"]: download_url = result["data"]["raw_url"] else: download_url = UrlUtils.adapt_request_url( self.__get_base_url, f"/d{fileitem.path}" ) if result["data"]["sign"]: download_url = download_url + "?sign=" + result["data"]["sign"] if not path: local_path = settings.TEMP_PATH / fileitem.name else: local_path = path / fileitem.name request_utils = RequestUtils(headers=self.__get_header_with_token()) try: with request_utils.get_stream(download_url, raise_exception=True) as r: r.raise_for_status() with open(local_path, "wb") as f: for chunk in r.iter_content(chunk_size=8192): if global_vars.is_transfer_stopped(fileitem.path): logger.info(f"【OpenList】{fileitem.path} 下载已取消!") return None f.write(chunk) except Exception as e: logger.error(f"【OpenList】下载文件 {fileitem.path} 失败:{e}") if local_path.exists(): return local_path return local_path def upload( self, fileitem: schemas.FileItem, path: Path, new_name: Optional[str] = None, task: bool = False, ) -> Optional[schemas.FileItem]: """ 上传文件(带进度) :param fileitem: 上传目录项 :param path: 本地文件路径 :param new_name: 上传后文件名 :param task: 是否为任务,默认为False避免未完成上传时对文件进行操作 :return: 上传后的文件项 """ try: # 获取文件大小 target_name = new_name or path.name target_path = Path(fileitem.path) / target_name # 初始化进度回调 progress_callback = transfer_process(path.as_posix()) # 准备上传请求 encoded_path = UrlUtils.quote(target_path.as_posix()) headers = self.__get_header_with_token() headers.setdefault("Content-Type", "application/octet-stream") headers.setdefault("As-Task", str(task).lower()) headers.setdefault("File-Path", encoded_path) # 创建自定义的文件流,支持进度回调 class ProgressFileReader: def __init__(self, file_path: Path, callback): self.file = open(file_path, "rb") self.callback = callback self.uploaded_size = 0 self.file_size = file_path.stat().st_size def __len__(self) -> int: return self.file_size def read(self, size=-1): if global_vars.is_transfer_stopped(path.as_posix()): logger.info(f"【OpenList】{path} 上传已取消!") raise OperationInterrupted(f"Upload cancelled: {path}") chunk = self.file.read(size) if chunk: self.uploaded_size += len(chunk) if self.callback: percent = (self.uploaded_size * 100) / self.file_size self.callback(percent) return chunk def close(self): self.file.close() # 使用自定义文件流上传 progress_reader = ProgressFileReader(path, progress_callback) try: resp = RequestUtils(headers=headers, timeout=6000).put_res( self.__get_api_url("/api/fs/put"), data=progress_reader, ) except OperationInterrupted: return None finally: progress_reader.close() if resp is None: logger.warn(f"【OpenList】请求上传文件 {path} 失败") return None if resp.status_code != 200: logger.warn( f"【OpenList】请求上传文件 {path} 失败,状态码:{resp.status_code}" ) return None # 完成上传 progress_callback(100) # 获取上传后的文件项 new_item = self._delay_get_item(target_path, refresh=True) if new_item and new_name and new_name != path.name: if self.rename(new_item, new_name): return self._delay_get_item( Path(new_item.path).with_name(new_name), refresh=True ) return new_item except Exception as e: logger.error(f"【OpenList】上传文件 {path} 失败:{e}") return None def detail(self, fileitem: schemas.FileItem) -> Optional[schemas.FileItem]: """ 获取文件详情 """ return self.get_item(Path(fileitem.path)) def copy(self, fileitem: schemas.FileItem, path: Path, new_name: str) -> bool: """ 复制文件 :param fileitem: 文件项 :param path: 目标目录 :param new_name: 新文件名 :return: 是否复制成功 """ resp = RequestUtils(headers=self.__get_header_with_token()).post_res( self.__get_api_url("/api/fs/copy"), json={ "src_dir": Path(fileitem.path).parent.as_posix(), "dst_dir": path.as_posix(), "names": [fileitem.name], }, ) """ { "src_dir": "string", "dst_dir": "string", "names": [ "string" ] } ====================================== { "code": 200, "message": "success", "data": null } """ if resp is None: logger.warn( f"【OpenList】请求复制文件 {fileitem.path} 失败,无法连接alist服务" ) return False if resp.status_code != 200: logger.warn( f"【OpenList】请求复制文件 {fileitem.path} 失败,状态码:{resp.status_code}" ) return False result = resp.json() if result["code"] != 200: logger.warn( f"【OpenList】复制文件 {fileitem.path} 失败,错误信息:{result['message']}" ) return False # 重命名 if fileitem.name != new_name: new_item = self._delay_get_item(path / fileitem.name, refresh=True) if new_item: self.rename(new_item, new_name) return True def move(self, fileitem: schemas.FileItem, path: Path, new_name: str) -> bool: """ 移动文件 :param fileitem: 文件项 :param path: 目标目录 :param new_name: 新文件名 :return: 是否移动成功 """ # 先重命名 if fileitem.name != new_name: self.rename(fileitem, new_name) resp = RequestUtils(headers=self.__get_header_with_token()).post_res( self.__get_api_url("/api/fs/move"), json={ "src_dir": Path(fileitem.path).parent.as_posix(), "dst_dir": path.as_posix(), "names": [new_name], }, ) """ { "src_dir": "string", "dst_dir": "string", "names": [ "string" ] } ====================================== { "code": 200, "message": "success", "data": null } """ if resp is None: logger.warn( f"【OpenList】请求移动文件 {fileitem.path} 失败,无法连接alist服务" ) return False if resp.status_code != 200: logger.warn( f"【OpenList】请求移动文件 {fileitem.path} 失败,状态码:{resp.status_code}" ) return False result = resp.json() if result["code"] != 200: logger.warn( f"【OpenList】移动文件 {fileitem.path} 失败,错误信息:{result['message']}" ) return False return True def link(self, fileitem: schemas.FileItem, target_file: Path) -> bool: """ 硬链接文件 """ pass def softlink(self, fileitem: schemas.FileItem, target_file: Path) -> bool: """ 软链接文件 """ pass def usage(self) -> Optional[schemas.StorageUsage]: """ 存储使用情况 """ pass @staticmethod def __parse_timestamp(time_str: str) -> float: """ 直接使用 ISO 8601 格式解析时间 """ return datetime.fromisoformat(time_str).timestamp() ================================================ FILE: app/modules/filemanager/storages/local.py ================================================ import shutil from pathlib import Path from typing import Optional, List from app import schemas from app.core.config import global_vars from app.helper.directory import DirectoryHelper from app.log import logger from app.modules.filemanager.storages import StorageBase, transfer_process from app.schemas.types import StorageSchema from app.utils.system import SystemUtils class LocalStorage(StorageBase): """ 本地文件操作 """ # 存储类型 schema = StorageSchema.Local # 支持的整理方式 transtype = { "copy": "复制", "move": "移动", "link": "硬链接", "softlink": "软链接" } # 文件块大小,默认10MB chunk_size = 10 * 1024 * 1024 def init_storage(self): """ 初始化 """ pass def check(self) -> bool: """ 检查存储是否可用 """ return True def __get_fileitem(self, path: Path) -> schemas.FileItem: """ 获取文件项 """ return schemas.FileItem( storage=self.schema.value, type="file", path=path.as_posix(), name=path.name, basename=path.stem, extension=path.suffix[1:], size=path.stat().st_size, modify_time=path.stat().st_mtime, ) def __get_diritem(self, path: Path) -> schemas.FileItem: """ 获取目录项 """ return schemas.FileItem( storage=self.schema.value, type="dir", path=path.as_posix() + "/", name=path.name, basename=path.stem, modify_time=path.stat().st_mtime, ) def list(self, fileitem: schemas.FileItem) -> List[schemas.FileItem]: """ 浏览文件 """ # 返回结果 ret_items = [] path = fileitem.path if not fileitem.path or fileitem.path == "/": if SystemUtils.is_windows(): partitions = SystemUtils.get_windows_drives() or ["C:/"] for partition in partitions: ret_items.append(schemas.FileItem( storage=self.schema.value, type="dir", path=partition + "/", name=partition, basename=partition )) return ret_items else: path = "/" else: if SystemUtils.is_windows(): path = path.lstrip("/") elif not path.startswith("/"): path = "/" + path # 遍历目录 path_obj = Path(path) if not path_obj.exists(): logger.warn(f"【本地】目录不存在:{path}") return [] # 如果是文件 if path_obj.is_file(): ret_items.append(self.__get_fileitem(path_obj)) return ret_items # 扁历所有目录 for item in SystemUtils.list_sub_directory(path_obj): ret_items.append(self.__get_diritem(item)) # 遍历所有文件,不含子目录 for item in SystemUtils.list_sub_file(path_obj): ret_items.append(self.__get_fileitem(item)) return ret_items def create_folder(self, fileitem: schemas.FileItem, name: str) -> Optional[schemas.FileItem]: """ 创建目录 :param fileitem: 父目录 :param name: 目录名 """ if not fileitem.path: return None path_obj = Path(fileitem.path) / name if not path_obj.exists(): path_obj.mkdir(parents=True, exist_ok=True) return self.__get_diritem(path_obj) def get_folder(self, path: Path) -> Optional[schemas.FileItem]: """ 获取目录 """ if not path.exists(): path.mkdir(parents=True, exist_ok=True) return self.__get_diritem(path) def get_item(self, path: Path) -> Optional[schemas.FileItem]: """ 获取文件或目录,不存在返回None """ if not path.exists(): return None if path.is_file(): return self.__get_fileitem(path) return self.__get_diritem(path) def detail(self, fileitem: schemas.FileItem) -> Optional[schemas.FileItem]: """ 获取文件详情 """ path_obj = Path(fileitem.path) if not path_obj.exists(): return None return self.__get_fileitem(path_obj) def delete(self, fileitem: schemas.FileItem) -> bool: """ 删除文件 """ if not fileitem.path: return False path_obj = Path(fileitem.path) if not path_obj.exists(): return True try: if path_obj.is_file(): path_obj.unlink() else: shutil.rmtree(path_obj, ignore_errors=True) except Exception as e: logger.error(f"【本地】删除文件失败:{e}") return False return True def rename(self, fileitem: schemas.FileItem, name: str) -> bool: """ 重命名文件 """ path_obj = Path(fileitem.path) if not path_obj.exists(): return False try: path_obj.rename(path_obj.parent / name) except Exception as e: logger.error(f"【本地】重命名文件失败:{e}") return False return True def download(self, fileitem: schemas.FileItem, path: Path = None) -> Optional[Path]: """ 下载文件 """ return Path(fileitem.path) def _copy_with_progress(self, src: Path, dest: Path): """ 分块复制文件并回调进度 """ total_size = src.stat().st_size copied_size = 0 progress_callback = transfer_process(src.as_posix()) try: with open(src, "rb") as fsrc, open(dest, "wb") as fdst: while True: if global_vars.is_transfer_stopped(src.as_posix()): logger.info(f"【本地】{src} 复制已取消!") return False buf = fsrc.read(self.chunk_size) if not buf: break fdst.write(buf) copied_size += len(buf) # 更新进度 if progress_callback: percent = copied_size / total_size * 100 progress_callback(percent) # 保留文件时间戳、权限等信息 shutil.copystat(src, dest) return True except Exception as e: logger.error(f"【本地】复制文件 {src} 失败:{e}") return False finally: progress_callback(100) def upload( self, fileitem: schemas.FileItem, path: Path, new_name: Optional[str] = None ) -> Optional[schemas.FileItem]: """ 上传文件(带进度) """ try: dir_path = Path(fileitem.path) target_path = dir_path / (new_name or path.name) if self._copy_with_progress(path, target_path): # 上传删除源文件 path.unlink() return self.get_item(target_path) except Exception as err: logger.error(f"【本地】移动文件失败:{err}") return None @staticmethod def __should_show_progress(src: Path, dest: Path): """ 是否显示进度条 """ src_isnetwork = SystemUtils.is_network_filesystem(src) dest_isnetwork = SystemUtils.is_network_filesystem(dest) if src_isnetwork and dest_isnetwork and SystemUtils.is_same_disk(src, dest): return True return False def copy( self, fileitem: schemas.FileItem, path: Path, new_name: str ) -> bool: """ 复制文件(带进度) """ try: src = Path(fileitem.path) dest = path / new_name if self.__should_show_progress(src, dest): if self._copy_with_progress(src, dest): return True else: code, message = SystemUtils.copy(src, dest) if code == 0: return True else: logger.error(f"【本地】复制文件失败:{message}") except Exception as err: logger.error(f"【本地】复制文件失败:{err}") return False def move( self, fileitem: schemas.FileItem, path: Path, new_name: str ) -> bool: """ 移动文件(带进度) """ try: src = Path(fileitem.path) dest = path / new_name if src == dest: # 目标和源文件相同,直接返回成功,不做任何操作 return True if self.__should_show_progress(src, dest): if self._copy_with_progress(src, dest): # 复制成功删除源文件 src.unlink() return True else: code, message = SystemUtils.move(src, dest) if code == 0: return True else: logger.error(f"【本地】移动文件失败:{message}") except Exception as err: logger.error(f"【本地】移动文件失败:{err}") return False def link(self, fileitem: schemas.FileItem, target_file: Path) -> bool: """ 硬链接文件 """ file_path = Path(fileitem.path) code, message = SystemUtils.link(file_path, target_file) if code != 0: logger.error(f"【本地】硬链接文件失败:{message}") return False return True def softlink(self, fileitem: schemas.FileItem, target_file: Path) -> bool: """ 软链接文件 """ file_path = Path(fileitem.path) code, message = SystemUtils.softlink(file_path, target_file) if code != 0: logger.error(f"【本地】软链接文件失败:{message}") return False return True def usage(self) -> Optional[schemas.StorageUsage]: """ 存储使用情况 """ directory_helper = DirectoryHelper() total_storage, free_storage = SystemUtils.space_usage( [Path(d.download_path) for d in directory_helper.get_local_download_dirs() if d.download_path] + [Path(d.library_path) for d in directory_helper.get_local_library_dirs() if d.library_path] ) return schemas.StorageUsage( total=total_storage, available=free_storage ) ================================================ FILE: app/modules/filemanager/storages/rclone.py ================================================ import json import subprocess from pathlib import Path from typing import Optional, List from app import schemas from app.core.config import settings from app.log import logger from app.modules.filemanager.storages import StorageBase, transfer_process from app.schemas.types import StorageSchema from app.utils.string import StringUtils from app.utils.system import SystemUtils class Rclone(StorageBase): """ rclone相关操作 """ # 存储类型 schema = StorageSchema.Rclone # 支持的整理方式 transtype = { "move": "移动", "copy": "复制" } snapshot_check_folder_modtime = settings.RCLONE_SNAPSHOT_CHECK_FOLDER_MODTIME def init_storage(self): """ 初始化 """ pass def set_config(self, conf: dict): """ 设置配置 """ super().set_config(conf) filepath = conf.get("filepath") if not filepath: logger.warn("【rclone】保存配置失败:未设置配置文件路径") logger.info(f"【rclone】配置写入文件:{filepath}") path = Path(filepath) if not path.parent.exists(): path.parent.mkdir(parents=True, exist_ok=True) path.write_text(conf.get('content'), encoding='utf-8') @staticmethod def __get_hidden_shell(): if SystemUtils.is_windows(): st = subprocess.STARTUPINFO() st.dwFlags = subprocess.STARTF_USESHOWWINDOW st.wShowWindow = subprocess.SW_HIDE return st else: return None @staticmethod def __parse_rclone_progress(line: str) -> Optional[float]: """ 解析rclone进度输出 """ if not line: return None line = line.strip() # 检查是否包含百分比 if '%' not in line: return None try: # 尝试多种进度输出格式 if 'ETA' in line: # 格式: "Transferred: 1.234M / 5.678M, 22%, 1.234MB/s, ETA 2m3s" percent_str = line.split('%')[0].split()[-1] return float(percent_str) elif 'Transferred:' in line and '100%' in line: # 传输完成 return 100.0 else: # 其他包含百分比的格式 parts = line.split() for part in parts: if '%' in part: percent_str = part.replace('%', '') return float(percent_str) except (ValueError, IndexError): pass return None def __get_rcloneitem(self, item: dict, parent: Optional[str] = "/") -> schemas.FileItem: """ 获取rclone文件项 """ if not item: return schemas.FileItem() if item.get("IsDir"): return schemas.FileItem( storage=self.schema.value, type="dir", path=f"{parent}{item.get('Name')}" + "/", name=item.get("Name"), basename=item.get("Name"), modify_time=StringUtils.str_to_timestamp(item.get("ModTime")) ) else: return schemas.FileItem( storage=self.schema.value, type="file", path=f"{parent}{item.get('Name')}", name=item.get("Name"), basename=Path(item.get("Name")).stem, extension=Path(item.get("Name")).suffix[1:], size=item.get("Size"), modify_time=StringUtils.str_to_timestamp(item.get("ModTime")) ) def check(self) -> bool: """ 检查存储是否可用 """ try: retcode = subprocess.run( ['rclone', 'lsf', 'MP:'], startupinfo=self.__get_hidden_shell() ).returncode if retcode == 0: return True except Exception as err: logger.error(f"【rclone】存储检查失败:{err}") return False def list(self, fileitem: schemas.FileItem) -> List[schemas.FileItem]: """ 浏览文件 """ if fileitem.type == "file": return [fileitem] try: ret = subprocess.run( [ 'rclone', 'lsjson', f'MP:{fileitem.path}' ], capture_output=True, startupinfo=self.__get_hidden_shell() ) if ret.returncode == 0: items = json.loads(ret.stdout) return [self.__get_rcloneitem(item, parent=fileitem.path) for item in items] except Exception as err: logger.error(f"【rclone】浏览文件失败:{err}") return [] def create_folder(self, fileitem: schemas.FileItem, name: str) -> Optional[schemas.FileItem]: """ 创建目录 :param fileitem: 父目录 :param name: 目录名 """ try: retcode = subprocess.run( [ 'rclone', 'mkdir', f'MP:{Path(fileitem.path) / name}' ], startupinfo=self.__get_hidden_shell() ).returncode if retcode == 0: return self.get_item(Path(fileitem.path) / name) except Exception as err: logger.error(f"【rclone】创建目录失败:{err}") return None def get_folder(self, path: Path) -> Optional[schemas.FileItem]: """ 根据文件路程获取目录,不存在则创建 """ def __find_dir(_fileitem: schemas.FileItem, _name: str) -> Optional[schemas.FileItem]: """ 查找下级目录中匹配名称的目录 """ for sub_folder in self.list(_fileitem): if sub_folder.type != "dir": continue if sub_folder.name == _name: return sub_folder return None # 是否已存在 folder = self.get_item(path) if folder: return folder # 逐级查找和创建目录 fileitem = schemas.FileItem(storage=self.schema.value, path="/") for part in path.parts[1:]: dir_file = __find_dir(fileitem, part) if dir_file: fileitem = dir_file else: dir_file = self.create_folder(fileitem, part) if not dir_file: logger.warn(f"【rclone】创建目录 {fileitem.path}{part} 失败!") return None fileitem = dir_file return fileitem def get_item(self, path: Path) -> Optional[schemas.FileItem]: """ 获取文件或目录,不存在返回None """ try: ret = subprocess.run( [ 'rclone', 'lsjson', f'MP:{path.parent}' ], capture_output=True, startupinfo=self.__get_hidden_shell() ) if ret.returncode == 0: items = json.loads(ret.stdout) for item in items: if item.get("Name") == path.name: return self.__get_rcloneitem(item, parent=str(path.parent) + "/") return None except Exception as err: logger.debug(f"【rclone】获取文件项失败:{err}") return None def delete(self, fileitem: schemas.FileItem) -> bool: """ 删除文件 """ try: retcode = subprocess.run( [ 'rclone', 'deletefile', f'MP:{fileitem.path}' ], startupinfo=self.__get_hidden_shell() ).returncode if retcode == 0: return True except Exception as err: logger.error(f"【rclone】删除文件失败:{err}") return False def rename(self, fileitem: schemas.FileItem, name: str) -> bool: """ 重命名文件 """ try: retcode = subprocess.run( [ 'rclone', 'moveto', f'MP:{fileitem.path}', f'MP:{Path(fileitem.path).parent / name}' ], startupinfo=self.__get_hidden_shell() ).returncode if retcode == 0: return True except Exception as err: logger.error(f"【rclone】重命名文件失败:{err}") return False def download(self, fileitem: schemas.FileItem, path: Path = None) -> Optional[Path]: """ 带实时进度显示的下载 """ local_path = (path or settings.TEMP_PATH) / fileitem.name # 初始化进度条 logger.info(f"【rclone】开始下载: {fileitem.name} -> {local_path}") progress_callback = transfer_process(Path(fileitem.path).as_posix()) try: # 使用rclone的进度显示功能 process = subprocess.Popen( [ 'rclone', 'copyto', '--progress', # 启用进度显示 '--stats', '1s', # 每秒更新一次统计信息 f'MP:{fileitem.path}', f'{local_path}' ], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, startupinfo=self.__get_hidden_shell(), universal_newlines=True, bufsize=1 ) # 监控进度输出 last_progress = 0 for line in process.stdout: if line: # 解析rclone的进度输出 progress = self.__parse_rclone_progress(line) if progress is not None and progress > last_progress: progress_callback(progress) last_progress = progress if progress >= 100: break # 等待进程完成 retcode = process.wait() if retcode == 0: logger.info(f"【rclone】下载完成: {fileitem.name}") return local_path else: logger.error(f"【rclone】下载失败: {fileitem.name}") return None except Exception as err: logger.error(f"【rclone】下载失败: {fileitem.name} - {err}") # 删除可能部分下载的文件 if local_path.exists(): local_path.unlink() return None def upload(self, fileitem: schemas.FileItem, path: Path, new_name: Optional[str] = None) -> Optional[schemas.FileItem]: """ 带实时进度显示的上传 :param fileitem: 上传目录项 :param path: 本地文件路径 :param new_name: 上传后文件名 """ target_name = new_name or path.name new_path = Path(fileitem.path) / target_name # 初始化进度条 logger.info(f"【rclone】开始上传: {path} -> {new_path}") progress_callback = transfer_process(path.as_posix()) try: # 使用rclone的进度显示功能 process = subprocess.Popen( [ 'rclone', 'copyto', '--progress', # 启用进度显示 '--stats', '1s', # 每秒更新一次统计信息 path.as_posix(), f'MP:{new_path}' ], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, startupinfo=self.__get_hidden_shell(), universal_newlines=True, bufsize=1 ) # 监控进度输出 last_progress = 0 for line in process.stdout: if line: # 解析rclone的进度输出 progress = self.__parse_rclone_progress(line) if progress is not None and progress > last_progress: progress_callback(progress) last_progress = progress if progress >= 100: break # 等待进程完成 retcode = process.wait() if retcode == 0: logger.info(f"【rclone】上传完成: {target_name}") return self.get_item(new_path) else: logger.error(f"【rclone】上传失败: {target_name}") return None except Exception as err: logger.error(f"【rclone】上传失败: {target_name} - {err}") return None def detail(self, fileitem: schemas.FileItem) -> Optional[schemas.FileItem]: """ 获取文件详情 """ try: ret = subprocess.run( [ 'rclone', 'lsjson', f'MP:{fileitem.path}' ], capture_output=True, startupinfo=self.__get_hidden_shell() ) if ret.returncode == 0: items = json.loads(ret.stdout) return self.__get_rcloneitem(items[0]) except Exception as err: logger.error(f"【rclone】获取文件详情失败:{err}") return None def move(self, fileitem: schemas.FileItem, path: Path, new_name: str) -> bool: """ 移动文件 :param fileitem: 文件项 :param path: 目标目录 :param new_name: 新文件名 """ target_path = path / new_name # 初始化进度条 logger.info(f"【rclone】开始移动: {fileitem.path} -> {target_path}") progress_callback = transfer_process(Path(fileitem.path).as_posix()) try: # 使用rclone的进度显示功能 process = subprocess.Popen( [ 'rclone', 'moveto', '--progress', # 启用进度显示 '--stats', '1s', # 每秒更新一次统计信息 f'MP:{fileitem.path}', f'MP:{target_path}' ], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, startupinfo=self.__get_hidden_shell(), universal_newlines=True, bufsize=1 ) # 监控进度输出 last_progress = 0 for line in process.stdout: if line: # 解析rclone的进度输出 progress = self.__parse_rclone_progress(line) if progress is not None and progress > last_progress: progress_callback(progress) last_progress = progress if progress >= 100: break # 等待进程完成 retcode = process.wait() if retcode == 0: logger.info(f"【rclone】移动完成: {fileitem.name}") return True else: logger.error(f"【rclone】移动失败: {fileitem.name}") return False except Exception as err: logger.error(f"【rclone】移动失败: {fileitem.name} - {err}") return False def copy(self, fileitem: schemas.FileItem, path: Path, new_name: str) -> bool: """ 复制文件 :param fileitem: 文件项 :param path: 目标目录 :param new_name: 新文件名 """ target_path = path / new_name # 初始化进度条 logger.info(f"【rclone】开始复制: {fileitem.path} -> {target_path}") progress_callback = transfer_process(Path(fileitem.path).as_posix()) try: # 使用rclone的进度显示功能 process = subprocess.Popen( [ 'rclone', 'copyto', '--progress', # 启用进度显示 '--stats', '1s', # 每秒更新一次统计信息 f'MP:{fileitem.path}', f'MP:{target_path}' ], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, startupinfo=self.__get_hidden_shell(), universal_newlines=True, bufsize=1 ) # 监控进度输出 last_progress = 0 for line in process.stdout: if line: # 解析rclone的进度输出 progress = self.__parse_rclone_progress(line) if progress is not None and progress > last_progress: progress_callback(progress) last_progress = progress if progress >= 100: break # 等待进程完成 retcode = process.wait() if retcode == 0: logger.info(f"【rclone】复制完成: {fileitem.name}") return True else: logger.error(f"【rclone】复制失败: {fileitem.name}") return False except Exception as err: logger.error(f"【rclone】复制失败: {fileitem.name} - {err}") return False def link(self, fileitem: schemas.FileItem, target_file: Path) -> bool: pass def softlink(self, fileitem: schemas.FileItem, target_file: Path) -> bool: pass def usage(self) -> Optional[schemas.StorageUsage]: """ 存储使用情况 """ conf = self.get_config() if not conf: return None file_path = conf.config.get("filepath") if not file_path or not Path(file_path).exists(): return None # 读取rclone文件,检查是否有[MP]节点配置 with open(file_path, "r", encoding="utf-8") as f: lines = f.readlines() if not lines: return None if not any("[MP]" in line.strip() for line in lines): return None try: ret = subprocess.run( [ 'rclone', 'about', 'MP:/', '--json' ], capture_output=True, startupinfo=self.__get_hidden_shell() ) if ret.returncode == 0: items = json.loads(ret.stdout) return schemas.StorageUsage( total=items.get("total"), available=items.get("free") ) except Exception as err: logger.error(f"【rclone】获取存储使用情况失败:{err}") return None ================================================ FILE: app/modules/filemanager/storages/smb.py ================================================ import threading import time from pathlib import Path from typing import List, Optional, Union import smbclient from smbclient import ClientConfig, register_session, reset_connection_cache from smbprotocol.exceptions import ( SMBException, SMBResponseException, SMBAuthenticationError, ) from app import schemas from app.core.config import settings, global_vars from app.log import logger from app.modules.filemanager import StorageBase from app.modules.filemanager.storages import transfer_process from app.schemas.types import StorageSchema from app.utils.singleton import WeakSingleton lock = threading.Lock() class SMBConnectionError(Exception): """ SMB 连接错误 """ pass class SMB(StorageBase, metaclass=WeakSingleton): """ SMB网络挂载存储相关操作 - 使用 smbclient 高级接口 """ # 存储类型 schema = StorageSchema.SMB # 支持的整理方式 transtype = { "move": "移动", "copy": "复制", "link": "硬链接", } # 文件块大小,默认10MB chunk_size = 10 * 1024 * 1024 def __init__(self): super().__init__() self._connected = False self._server_path = None self._host = None self._username = None self._password = None self._init_connection() def _init_connection(self): """ 初始化SMB连接配置 """ try: conf = self.get_conf() if not conf: return self._host = conf.get("host") self._username = conf.get("username") self._password = conf.get("password") domain = conf.get("domain", "") share = conf.get("share", "") port = conf.get("port", 445) if not all([self._host, share]): logger.error("【SMB】缺少必要的连接参数:host 和 share") return # 构建服务器路径 self._server_path = f"\\\\{self._host}\\{share}" # 配置全局客户端设置 ClientConfig( username=self._username, password=self._password, domain=domain if domain else None, connection_timeout=60, port=port, auth_protocol="negotiate", # 使用协商认证 require_secure_negotiate=False, # 匿名访问时可能需要关闭安全协商 ) # 注册会话以启用连接池 register_session( self._host, username=self._username, password=self._password, port=port, encrypt=False, # 根据需要启用加密 connection_timeout=60, ) # 测试连接 self._test_connection() self._connected = True # 判断是否为匿名访问 if self._is_anonymous_access(): logger.info(f"【SMB】匿名连接成功:{self._server_path}") else: logger.info( f"【SMB】认证连接成功:{self._server_path} (用户:{self._username})" ) except Exception as e: logger.error(f"【SMB】连接初始化失败:{e}") self._connected = False def _test_connection(self): """ 测试SMB连接 """ try: # 尝试列出根目录来测试连接 smbclient.listdir(self._server_path) except SMBAuthenticationError as e: raise SMBConnectionError(f"SMB认证失败:{e}") except SMBResponseException as e: raise SMBConnectionError(f"SMB响应错误:{e}") except SMBException as e: raise SMBConnectionError(f"SMB连接错误:{e}") except Exception as e: raise SMBConnectionError(f"连接测试失败:{e}") def _is_anonymous_access(self) -> bool: """ 检查是否为匿名访问 """ return not self._username and not self._password def _check_connection(self): """ 检查SMB连接状态 """ if not self._connected or not self._server_path: raise SMBConnectionError("【SMB】连接未建立或已断开,请检查配置!") def _normalize_path(self, path: Union[str, Path]) -> str: """ 标准化路径格式为SMB路径 """ path_str = str(path) # 处理根路径 if path_str in ["/", "\\"]: return self._server_path # 去除前导斜杠 if path_str.startswith("/"): path_str = path_str[1:] # 构建完整的SMB路径 if path_str: return f"{self._server_path}\\{path_str.replace('/', '\\')}" else: return self._server_path def _create_fileitem( self, stat_result, file_path: str, name: str ) -> schemas.FileItem: """ 创建文件项 """ try: # 检查是否为目录 is_directory = smbclient.path.isdir(file_path) # 处理路径 relative_path = file_path.replace(self._server_path, "").replace("\\", "/") if not relative_path.startswith("/"): relative_path = "/" + relative_path if is_directory and not relative_path.endswith("/"): relative_path += "/" # 获取时间戳 try: modify_time = int(stat_result.st_mtime) except (AttributeError, TypeError): modify_time = int(time.time()) if is_directory: return schemas.FileItem( storage=self.schema.value, type="dir", path=relative_path, name=name, basename=name, modify_time=modify_time, ) else: return schemas.FileItem( storage=self.schema.value, type="file", path=relative_path, name=name, basename=Path(name).stem, extension=Path(name).suffix[1:] if Path(name).suffix else None, size=getattr(stat_result, "st_size", 0), modify_time=modify_time, ) except Exception as e: logger.error(f"【SMB】创建文件项失败:{e}") # 返回基本的文件项信息 return schemas.FileItem( storage=self.schema.value, type="file", path=file_path.replace(self._server_path, "").replace("\\", "/"), name=name, basename=Path(name).stem, modify_time=int(time.time()), ) def init_storage(self): """ 初始化存储 """ # 重置连接缓存 reset_connection_cache() self._init_connection() def check(self) -> bool: """ 检查存储是否可用 """ if not self._connected: return False try: self._test_connection() return True except Exception as e: logger.debug(f"【SMB】连接检查失败:{e}") self._connected = False return False def list(self, fileitem: schemas.FileItem) -> List[schemas.FileItem]: """ 浏览文件 """ try: self._check_connection() if fileitem.type == "file": item = self.detail(fileitem) if item: return [item] return [] # 构建SMB路径 smb_path = self._normalize_path(fileitem.path.rstrip("/")) # 列出目录内容 try: entries = smbclient.listdir(smb_path) except SMBResponseException as e: logger.error(f"【SMB】列出目录失败: {smb_path} - {e}") return [] except SMBException as e: logger.error(f"【SMB】列出目录失败: {smb_path} - {e}") return [] items = [] for entry in entries: if entry in [".", ".."]: continue entry_path = f"{smb_path}\\{entry}" try: stat_result = smbclient.stat(entry_path) item = self._create_fileitem(stat_result, entry_path, entry) items.append(item) except Exception as e: logger.debug(f"【SMB】获取文件信息失败: {entry_path} - {e}") continue return items except Exception as e: logger.error(f"【SMB】列出文件失败: {e}") return [] def create_folder( self, fileitem: schemas.FileItem, name: str ) -> Optional[schemas.FileItem]: """ 创建目录 """ try: self._check_connection() parent_path = self._normalize_path(fileitem.path.rstrip("/")) new_path = f"{parent_path}\\{name}" # 创建目录 smbclient.mkdir(new_path) # 返回创建的目录信息 return schemas.FileItem( storage=self.schema.value, type="dir", path=f"{fileitem.path.rstrip('/')}/{name}/", name=name, basename=name, modify_time=int(time.time()), ) except Exception as e: logger.error(f"【SMB】创建目录失败: {e}") return None def get_folder(self, path: Path) -> Optional[schemas.FileItem]: """ 获取目录,如目录不存在则创建 """ # 检查目录是否存在 folder = self.get_item(path) if folder: return folder # 逐级创建目录 parts = path.parts current_path = Path("/") for part in parts[1:]: # 跳过根目录 current_path = current_path / part folder = self.get_item(current_path) if not folder: parent_folder = self.get_item(current_path.parent) if not parent_folder: logger.error(f"【SMB】父目录不存在: {current_path.parent}") return None folder = self.create_folder(parent_folder, part) if not folder: return None return folder def get_item(self, path: Path) -> Optional[schemas.FileItem]: """ 获取文件或目录,不存在返回None """ try: self._check_connection() # 处理根目录 if str(path) == "/": return schemas.FileItem( storage=self.schema.value, type="dir", path="/", name="", basename="", modify_time=int(time.time()), ) smb_path = self._normalize_path(str(path).rstrip("/")) # 检查路径是否存在 if not smbclient.path.exists(smb_path): return None stat_result = smbclient.stat(smb_path) file_name = Path(path).name return self._create_fileitem(stat_result, smb_path, file_name) except Exception as e: logger.debug(f"【SMB】获取文件项失败: {e}") return None def detail(self, fileitem: schemas.FileItem) -> Optional[schemas.FileItem]: """ 获取文件详情 """ return self.get_item(Path(fileitem.path)) def delete(self, fileitem: schemas.FileItem) -> bool: """ 删除文件或目录 """ try: self._check_connection() smb_path = self._normalize_path(fileitem.path.rstrip("/")) logger.info(f"【SMB】开始删除: {fileitem.path} (类型: {fileitem.type})") # 先检查路径是否存在 if not smbclient.path.exists(smb_path): logger.warn(f"【SMB】路径不存在,跳过删除: {fileitem.path}") return True if fileitem.type == "dir": # 递归删除目录及其内容 logger.debug(f"【SMB】递归删除目录: {smb_path}") self._recursive_delete(smb_path) else: # 删除文件 logger.debug(f"【SMB】删除文件: {smb_path}") smbclient.remove(smb_path) logger.info(f"【SMB】删除成功: {fileitem.path}") return True except SMBConnectionError as e: logger.error(f"【SMB】删除失败 - 连接错误: {fileitem.path} - {e}") return False except SMBResponseException as e: logger.error(f"【SMB】删除失败 - SMB响应错误: {fileitem.path} - {e}") return False except SMBException as e: logger.error(f"【SMB】删除失败 - SMB错误: {fileitem.path} - {e}") return False except Exception as e: logger.error(f"【SMB】删除失败 - 未知错误: {fileitem.path} - {e}") return False def _recursive_delete(self, smb_path: str): """ 递归删除目录及其所有内容 """ try: # 检查路径是否存在 if not smbclient.path.exists(smb_path): logger.debug(f"【SMB】路径不存在,跳过删除: {smb_path}") return # 如果是文件,直接删除 if smbclient.path.isfile(smb_path): logger.debug(f"【SMB】删除文件: {smb_path}") smbclient.remove(smb_path) return # 如果是目录,先删除其内容 if smbclient.path.isdir(smb_path): logger.debug(f"【SMB】开始删除目录内容: {smb_path}") try: # 列出目录内容 entries = smbclient.listdir(smb_path) logger.debug(f"【SMB】目录 {smb_path} 包含 {len(entries)} 个项目") for entry in entries: if entry in [".", ".."]: continue entry_path = f"{smb_path}\\{entry}" logger.debug(f"【SMB】递归删除子项: {entry_path}") # 递归删除子项 self._recursive_delete(entry_path) # 删除空目录 logger.debug(f"【SMB】删除空目录: {smb_path}") smbclient.rmdir(smb_path) logger.debug(f"【SMB】目录删除成功: {smb_path}") except SMBResponseException as e: # 如果目录不为空,尝试强制删除 logger.warn(f"【SMB】目录不为空,尝试强制删除: {smb_path} - {e}") # 使用remove方法尝试删除(某些SMB服务器支持) try: smbclient.remove(smb_path) logger.info(f"【SMB】强制删除目录成功: {smb_path}") except Exception as remove_error: # 如果还是失败,记录错误并抛出异常 logger.error( f"【SMB】无法删除非空目录: {smb_path} - {remove_error}" ) raise SMBConnectionError( f"无法删除非空目录 {smb_path}: {remove_error}" ) except SMBException as e: logger.error(f"【SMB】SMB操作失败: {smb_path} - {e}") raise SMBConnectionError(f"SMB操作失败 {smb_path}: {e}") except SMBConnectionError: # 重新抛出SMB连接错误 raise except Exception as e: logger.error(f"【SMB】递归删除失败: {smb_path} - {e}") raise SMBConnectionError(f"递归删除失败 {smb_path}: {e}") def rename(self, fileitem: schemas.FileItem, name: str) -> bool: """ 重命名文件 """ try: self._check_connection() old_path = self._normalize_path(fileitem.path.rstrip("/")) parent_path = Path(fileitem.path).parent new_path = self._normalize_path(str(parent_path / name)) # 重命名 smbclient.rename(old_path, new_path) logger.info(f"【SMB】重命名成功: {fileitem.path} -> {name}") return True except Exception as e: logger.error(f"【SMB】重命名失败: {e}") return False def download(self, fileitem: schemas.FileItem, path: Path = None) -> Optional[Path]: """ 带实时进度显示的下载 """ local_path = (path or settings.TEMP_PATH) / fileitem.name smb_path = self._normalize_path(fileitem.path) try: self._check_connection() # 确保本地目录存在 local_path.parent.mkdir(parents=True, exist_ok=True) # 获取文件大小 file_size = fileitem.size # 初始化进度条 logger.info(f"【SMB】开始下载: {fileitem.name} -> {local_path}") progress_callback = transfer_process(Path(fileitem.path).as_posix()) # 使用更高效的文件传输方式 with smbclient.open_file(smb_path, mode="rb") as src_file: with open(local_path, "wb") as dst_file: downloaded_size = 0 while True: if global_vars.is_transfer_stopped(fileitem.path): logger.info(f"【SMB】{fileitem.path} 下载已取消!") return None chunk = src_file.read(self.chunk_size) if not chunk: break dst_file.write(chunk) downloaded_size += len(chunk) # 更新进度 if file_size: progress = (downloaded_size * 100) / file_size progress_callback(progress) # 完成下载 progress_callback(100) logger.info(f"【SMB】下载完成: {fileitem.name}") return local_path except Exception as e: logger.error(f"【SMB】下载失败: {fileitem.name} - {e}") # 删除可能部分下载的文件 if local_path.exists(): local_path.unlink() return None def upload( self, fileitem: schemas.FileItem, path: Path, new_name: Optional[str] = None ) -> Optional[schemas.FileItem]: """ 带实时进度显示的上传 """ target_name = new_name or path.name target_path = Path(fileitem.path) / target_name smb_path = self._normalize_path(str(target_path)) try: self._check_connection() # 获取文件大小 file_size = path.stat().st_size # 初始化进度条 logger.info(f"【SMB】开始上传: {path} -> {target_path}") progress_callback = transfer_process(path.as_posix()) # 使用更高效的文件传输方式 with open(path, "rb") as src_file: with smbclient.open_file(smb_path, mode="wb") as dst_file: uploaded_size = 0 while True: if global_vars.is_transfer_stopped(path.as_posix()): logger.info(f"【SMB】{path} 上传已取消!") return None chunk = src_file.read(self.chunk_size) if not chunk: break dst_file.write(chunk) uploaded_size += len(chunk) # 更新进度 if file_size: progress = (uploaded_size * 100) / file_size progress_callback(progress) # 完成上传 progress_callback(100) logger.info(f"【SMB】上传完成: {target_name}") # 返回上传后的文件信息 return self.get_item(target_path) except Exception as e: logger.error(f"【SMB】上传失败: {target_name} - {e}") return None def copy(self, fileitem: schemas.FileItem, path: Path, new_name: str) -> bool: """ 复制文件 """ try: # 下载到临时文件 temp_file = self.download(fileitem) if not temp_file: return False # 获取目标目录 target_folder = self.get_item(path) if not target_folder: return False # 上传到目标位置 result = self.upload(target_folder, temp_file, new_name) # 删除临时文件 if temp_file.exists(): temp_file.unlink() return result is not None except Exception as e: logger.error(f"【SMB】复制失败: {e}") return False def move(self, fileitem: schemas.FileItem, path: Path, new_name: str) -> bool: """ 移动文件 """ try: # 先复制 if not self.copy(fileitem, path, new_name): return False # 再删除原文件 if not self.delete(fileitem): logger.warn(f"【SMB】删除原文件失败: {fileitem.path}") return False return True except Exception as e: logger.error(f"【SMB】移动失败: {e}") return False def link(self, fileitem: schemas.FileItem, target_file: Path) -> bool: """ 硬链接文件 Samba服务器需要开启 unix extensions 支持 """ try: self._check_connection() src_path = self._normalize_path(fileitem.path) dst_path = self._normalize_path(target_file) # 检查源文件是否存在 if not smbclient.path.exists(src_path): raise FileNotFoundError(f"源文件不存在: {src_path}") # 确保目标路径的父目录存在 dst_parent = "\\".join(dst_path.rsplit("\\", 1)[:-1]) if dst_parent and not smbclient.path.exists(dst_parent): logger.info(f"【SMB】创建目标目录: {dst_parent}") smbclient.makedirs(dst_parent, exist_ok=True) # 尝试创建硬链接 smbclient.link(src_path, dst_path) logger.info(f"【SMB】硬链接创建成功: {src_path} -> {dst_path}") return True except SMBResponseException as e: # SMB协议错误,可能不支持硬链接 logger.error(f"【SMB】创建硬链接失败(当前Samba服务器可能不支持硬链接): {e}") return False except Exception as e: logger.error(f"【SMB】创建硬链接失败: {e}") return False def softlink(self, fileitem: schemas.FileItem, target_file: Path) -> bool: pass def usage(self) -> Optional[schemas.StorageUsage]: """ 存储使用情况 """ try: self._check_connection() volume_stat = smbclient.stat_volume(self._server_path) return schemas.StorageUsage( total=volume_stat.total_size, available=volume_stat.caller_available_size, ) except Exception as e: logger.error(f"【SMB】获取存储使用情况失败: {e}") return None def __del__(self): """ 析构函数,清理连接 """ try: if self._connected: reset_connection_cache() except Exception as e: logger.debug(f"【SMB】清理连接失败: {e}") ================================================ FILE: app/modules/filemanager/storages/u115.py ================================================ import base64 import secrets import time from pathlib import Path from threading import Lock from typing import List, Optional, Tuple, Union from hashlib import sha256 import oss2 import httpx from oss2 import SizedFileAdapter, determine_part_size from oss2.models import PartInfo from cryptography.hazmat.primitives import hashes from app import schemas from app.core.config import settings, global_vars from app.log import logger from app.modules.filemanager import StorageBase from app.modules.filemanager.storages import transfer_process from app.schemas.types import StorageSchema from app.utils.singleton import WeakSingleton from app.utils.string import StringUtils from app.utils.limit import QpsRateLimiter, RateStats lock = Lock() class NoCheckInException(Exception): pass class U115Pan(StorageBase, metaclass=WeakSingleton): """ 115相关操作 """ # 存储类型 schema = StorageSchema.U115 # 支持的整理方式 transtype = {"move": "移动", "copy": "复制"} # 基础url base_url = "https://proapi.115.com" # 文件块大小,默认10MB chunk_size = 10 * 1024 * 1024 # 下载接口单独限流 download_endpoint = "/open/ufile/downurl" # 风控触发后休眠时间(秒) limit_sleep_seconds = 3600 def __init__(self): super().__init__() self._auth_state = {} self.session = httpx.Client(follow_redirects=True, timeout=20.0) self._init_session() # 接口限流 self._download_limiter = QpsRateLimiter(1) self._api_limiter = QpsRateLimiter(3) self._limit_until = 0.0 self._limit_lock = Lock() # 总体 QPS/QPM/QPH 统计 self._rate_stats = RateStats(source="115") def _init_session(self): """ 初始化带速率限制的会话 """ self.session.headers.update( { "User-Agent": "W115Storage/2.0", "Accept-Encoding": "gzip, deflate", "Content-Type": "application/x-www-form-urlencoded", } ) def _check_session(self): """ 检查会话是否过期 """ if not self.access_token: raise NoCheckInException("【115】请先扫码登录!") @property def access_token(self) -> Optional[str]: """ 访问token """ with lock: tokens = self.get_conf() refresh_token = tokens.get("refresh_token") if not refresh_token: return None expires_in = tokens.get("expires_in", 0) refresh_time = tokens.get("refresh_time", 0) if expires_in and refresh_time + expires_in < int(time.time()): tokens = self.__refresh_access_token(refresh_token) if tokens: self.set_config({"refresh_time": int(time.time()), **tokens}) else: return None access_token = tokens.get("access_token") if access_token: self.session.headers.update({"Authorization": f"Bearer {access_token}"}) return access_token def generate_auth_url(self) -> Tuple[dict, str]: """ 生成 OAuth2 授权 URL """ try: resp = self.session.get(f"{settings.U115_AUTH_SERVER}/u115/auth_url") if resp is None: return {}, "无法连接到授权服务器" result = resp.json() if not result.get("success"): return {}, result.get("message", "获取授权URL失败") data = result.get("data", {}) auth_url = data.get("auth_url") state = data.get("state") if not auth_url or not state: return {}, "授权服务器返回数据不完整" self._auth_state = {"state": state} return {"authUrl": auth_url, "state": state}, "" except Exception as e: logger.error(f"【115】获取授权 URL 失败: {str(e)}") return {}, f"获取授权 URL 失败: {str(e)}" def generate_qrcode(self) -> Tuple[dict, str]: """ 实现PKCE规范的设备授权二维码生成 """ # 生成PKCE参数 code_verifier = secrets.token_urlsafe(96)[:128] code_challenge = base64.b64encode( sha256(code_verifier.encode("utf-8")).digest() ).decode("utf-8") # 请求设备码 resp = self.session.post( "https://passportapi.115.com/open/authDeviceCode", data={ "client_id": settings.U115_APP_ID, "code_challenge": code_challenge, "code_challenge_method": "sha256", }, ) if resp is None: return {}, "网络错误" result = resp.json() if result.get("code") != 0: return {}, result.get("message") # 持久化验证参数 self._auth_state = { "code_verifier": code_verifier, "uid": result["data"]["uid"], "time": result["data"]["time"], "sign": result["data"]["sign"], } # 生成二维码内容 return {"codeContent": result["data"]["qrcode"]}, "" def check_login(self) -> Optional[Tuple[dict, str]]: """ 检查授权状态 """ if self._auth_state and self._auth_state.get("state"): return self.__check_oauth_login() if not self._auth_state: return {}, "生成二维码失败" try: resp = self.session.get( "https://qrcodeapi.115.com/get/status/", params={ "uid": self._auth_state["uid"], "time": self._auth_state["time"], "sign": self._auth_state["sign"], }, ) if resp is None: return {}, "网络错误" result = resp.json() if result.get("code") != 0 or not result.get("data"): return {}, result.get("message") if result["data"]["status"] == 2: tokens = self.__get_access_token() self.set_config({"refresh_time": int(time.time()), **tokens}) return { "status": result["data"]["status"], "tip": result["data"]["msg"], }, "" except Exception as e: return {}, str(e) def __check_oauth_login(self) -> Tuple[dict, str]: """ 检查 OAuth2 授权状态 """ state = self._auth_state.get("state") if not state: return {}, "state为空" try: resp = self.session.get( f"{settings.U115_AUTH_SERVER}/u115/token", params={"state": state} ) if resp is None: return {}, "无法连接到授权服务器" result = resp.json() status = result.get("status", "pending") if status == "completed": data = result.get("data", {}) if data: self.set_config( { "refresh_time": int(time.time()), "access_token": data.get("access_token"), "refresh_token": data.get("refresh_token"), "expires_in": data.get("expires_in"), } ) self._auth_state = {} return {"status": 2, "tip": "授权成功"}, "" return {}, "授权服务器返回数据不完整" elif status == "expired": self._auth_state = {} return {"status": -1, "tip": result.get("message", "授权已过期")}, "" else: return {"status": 0, "tip": "等待用户授权"}, "" except Exception as e: logger.error(f"【115】检查授权状态失败: {str(e)}") return {}, f"检查授权状态失败: {str(e)}" def __get_access_token(self) -> dict: """ 确认登录后,获取相关token """ if not self._auth_state: raise Exception("【115】请先生成二维码") resp = self.session.post( "https://passportapi.115.com/open/deviceCodeToToken", data={ "uid": self._auth_state["uid"], "code_verifier": self._auth_state["code_verifier"], }, ) if resp is None: raise Exception("获取 access_token 失败") result = resp.json() if result.get("code") != 0: raise Exception(result.get("message")) return result["data"] def __refresh_access_token(self, refresh_token: str) -> Optional[dict]: """ 刷新access_token """ resp = self.session.post( "https://passportapi.115.com/open/refreshToken", data={"refresh_token": refresh_token}, ) if resp is None: logger.error( f"【115】刷新 access_token 失败:refresh_token={refresh_token}" ) return None result = resp.json() if result.get("code") != 0: logger.warn( f"【115】刷新 access_token 失败:{result.get('code')} - {result.get('message')}!" ) return None return result.get("data") def _request_api( self, method: str, endpoint: str, result_key: Optional[str] = None, **kwargs ) -> Optional[Union[dict, list]]: """ 带错误处理和速率限制的API请求 """ # 检查会话 self._check_session() # 错误日志标志 no_error_log = kwargs.pop("no_error_log", False) # 重试次数 retry_times = kwargs.pop("retry_limit", 3) # 按接口类型限流 if endpoint == self.download_endpoint: self._download_limiter.acquire() else: self._api_limiter.acquire() self._rate_stats.record() # 风控冷却期间阻止所有接口调用,统一等待 with self._limit_lock: wait_until = self._limit_until if wait_until > time.time(): wait_secs = wait_until - time.time() logger.info( f"【115】风控冷却中,本请求等待 {wait_secs:.0f} 秒后再调用接口..." ) time.sleep(wait_secs) try: resp = self.session.request(method, f"{self.base_url}{endpoint}", **kwargs) except httpx.RequestError as e: logger.error(f"【115】{method} 请求 {endpoint} 网络错误: {str(e)}") return None if resp is None: logger.warn(f"【115】{method} 请求 {endpoint} 失败!") return None kwargs["retry_limit"] = retry_times if resp.status_code == 429: self._rate_stats.log_stats("warning") if retry_times <= 0: logger.error( f"【115】{method} 请求 {endpoint} 触发限流(429),重试次数用尽!" ) return None with self._limit_lock: self._limit_until = max( self._limit_until, time.time() + self.limit_sleep_seconds, ) logger.warning( f"【115】触发限流(429),全体接口进入风控冷却 {self.limit_sleep_seconds} 秒,随后重试..." ) time.sleep(self.limit_sleep_seconds) kwargs["retry_limit"] = retry_times - 1 kwargs["no_error_log"] = no_error_log return self._request_api(method, endpoint, result_key, **kwargs) # 处理请求错误 try: resp.raise_for_status() except httpx.HTTPStatusError as e: if retry_times <= 0: logger.error( f"【115】{method} 请求 {endpoint} 错误 {e},重试次数用尽!" ) return None kwargs["retry_limit"] = retry_times - 1 kwargs["no_error_log"] = no_error_log sleep_duration = 2 ** (5 - retry_times + 1) logger.info( f"【115】{method} 请求 {endpoint} 错误 {e},等待 {sleep_duration} 秒后重试..." ) time.sleep(sleep_duration) return self._request_api(method, endpoint, result_key, **kwargs) # 返回数据 ret_data = resp.json() if ret_data.get("code") not in (0, 20004): error_msg = ret_data.get("message", "") if not no_error_log: logger.warn(f"【115】{method} 请求 {endpoint} 出错:{error_msg}") if "已达到当前访问上限" in error_msg: self._rate_stats.log_stats("warning") if retry_times <= 0: logger.error( f"【115】{method} 请求 {endpoint} 触发风控(访问上限),重试次数用尽!" ) return None with self._limit_lock: self._limit_until = max( self._limit_until, time.time() + self.limit_sleep_seconds, ) logger.warning( f"【115】触发风控(访问上限),全体接口进入风控冷却 {self.limit_sleep_seconds} 秒,随后重试..." ) time.sleep(self.limit_sleep_seconds) kwargs["retry_limit"] = retry_times - 1 kwargs["no_error_log"] = no_error_log return self._request_api(method, endpoint, result_key, **kwargs) return None if result_key: return ret_data.get(result_key) return ret_data @staticmethod def _calc_sha1(filepath: Path, size: Optional[int] = None) -> str: """ 计算文件SHA1(符合115规范) size: 前多少字节 """ sha1 = hashes.Hash(hashes.SHA1()) with open(filepath, "rb") as f: if size: chunk = f.read(size) sha1.update(chunk) else: while chunk := f.read(8192): sha1.update(chunk) return sha1.finalize().hex() def init_storage(self): pass def list(self, fileitem: schemas.FileItem) -> List[schemas.FileItem]: """ 目录遍历实现 """ if fileitem.type == "file": item = self.detail(fileitem) if item: return [item] return [] if fileitem.path == "/": cid = "0" else: cid = fileitem.fileid if not cid: _fileitem = self.get_item(Path(fileitem.path)) if not _fileitem: logger.warn(f"【115】获取目录 {fileitem.path} 失败!") return [] cid = _fileitem.fileid items = [] offset = 0 while True: resp = self._request_api( "GET", "/open/ufile/files", "data", params={ "cid": int(cid), "limit": 1000, "offset": offset, "cur": True, "show_dir": 1, }, ) if resp is None: raise FileNotFoundError(f"【115】{fileitem.path} 检索出错!") if not resp: break for item in resp: parent_path = Path(fileitem.path) # noqa item_name = item["fn"] full_path = parent_path / item_name items.append( schemas.FileItem( storage=self.schema.value, fileid=str(item["fid"]), parent_fileid=cid, name=item["fn"], basename=Path(item["fn"]).stem, extension=item["ico"] if item["fc"] == "1" else None, type="dir" if item["fc"] == "0" else "file", path=full_path.as_posix() + ("/" if item["fc"] == "0" else ""), size=item["fs"] if item["fc"] == "1" else None, modify_time=item["upt"], pickcode=item["pc"], ) ) if len(resp) < 1000: break offset += len(resp) return items def create_folder( self, parent_item: schemas.FileItem, name: str ) -> Optional[schemas.FileItem]: """ 创建目录 """ new_path = Path(parent_item.path) / name resp = self._request_api( "POST", "/open/folder/add", data={ "pid": 0 if parent_item.path == "/" else int(parent_item.fileid or 0), "file_name": name, }, ) if not resp: return None if not resp.get("state"): if resp.get("code") == 20004: # 目录已存在 return self.get_item(new_path) logger.warn(f"【115】创建目录失败: {resp.get('error')}") return None return schemas.FileItem( storage=self.schema.value, fileid=str(resp["data"]["file_id"]), path=new_path.as_posix() + "/", name=name, basename=name, type="dir", modify_time=int(time.time()), ) def upload( self, target_dir: schemas.FileItem, local_path: Path, new_name: Optional[str] = None, ) -> Optional[schemas.FileItem]: """ 实现带秒传、断点续传和二次认证的文件上传 """ def encode_callback(cb: str) -> str: return oss2.utils.b64encode_as_string(cb) target_name = new_name or local_path.name target_path = Path(target_dir.path) / target_name # 计算文件特征值 file_size = local_path.stat().st_size file_sha1 = self._calc_sha1(local_path) file_preid = self._calc_sha1(local_path, 128 * 1024 * 1024) # 获取目标目录CID target_cid = target_dir.fileid target_param = f"U_1_{target_cid}" # Step 1: 初始化上传 init_data = { "file_name": target_name, "file_size": file_size, "target": target_param, "fileid": file_sha1, "preid": file_preid, } init_resp = self._request_api("POST", "/open/upload/init", data=init_data) if not init_resp: return None if not init_resp.get("state"): logger.warn(f"【115】初始化上传失败: {init_resp.get('error')}") return None # 结果 init_result = init_resp.get("data") logger.debug(f"【115】上传 Step 1 初始化结果: {init_result}") # 回调信息 bucket_name = init_result.get("bucket") object_name = init_result.get("object") callback = init_result.get("callback") # 二次认证信息 sign_check = init_result.get("sign_check") pick_code = init_result.get("pick_code") sign_key = init_result.get("sign_key") # Step 2: 处理二次认证 if init_result.get("code") in [700, 701] and sign_check: sign_checks = sign_check.split("-") start = int(sign_checks[0]) end = int(sign_checks[1]) # 计算指定区间的SHA1 # sign_check (用下划线隔开,截取上传文内容的sha1)(单位是byte): "2392148-2392298" with open(local_path, "rb") as f: # 取2392148-2392298之间的内容(包含2392148、2392298)的sha1 f.seek(start) chunk = f.read(end - start + 1) sha1 = hashes.Hash(hashes.SHA1()) sha1.update(chunk) sign_val = sha1.finalize().hex().upper() # 重新初始化请求 # sign_key,sign_val(根据sign_check计算的值大写的sha1值) init_data.update( {"pick_code": pick_code, "sign_key": sign_key, "sign_val": sign_val} ) init_resp = self._request_api("POST", "/open/upload/init", data=init_data) if not init_resp: return None if not init_resp.get("state"): logger.warn(f"【115】上传二次认证失败: {init_resp.get('error')}") return None # 二次认证结果 init_result = init_resp.get("data") logger.debug(f"【115】上传 Step 2 二次认证结果: {init_result}") if not pick_code: pick_code = init_result.get("pick_code") if not bucket_name: bucket_name = init_result.get("bucket") if not object_name: object_name = init_result.get("object") if not callback: callback = init_result.get("callback") # Step 3: 秒传 if init_result.get("status") == 2: logger.info(f"【115】{target_name} 秒传成功") file_id = init_result.get("file_id", None) if file_id: logger.debug(f"【115】{target_name} 使用秒传返回ID获取文件信息") time.sleep(2) info_resp = self._request_api( "GET", "/open/folder/get_info", "data", params={"file_id": int(file_id)}, ) if info_resp: return schemas.FileItem( storage=self.schema.value, fileid=str(info_resp["file_id"]), path=target_path.as_posix() + ("/" if info_resp["file_category"] == "0" else ""), type="file" if info_resp["file_category"] == "1" else "dir", name=info_resp["file_name"], basename=Path(info_resp["file_name"]).stem, extension=Path(info_resp["file_name"]).suffix[1:] if info_resp["file_category"] == "1" else None, pickcode=info_resp["pick_code"], size=StringUtils.num_filesize(info_resp["size"]) if info_resp["file_category"] == "1" else None, modify_time=info_resp["utime"], ) return self.get_item(target_path) # Step 4: 获取上传凭证 token_resp = self._request_api("GET", "/open/upload/get_token", "data") if not token_resp: logger.warn("【115】获取上传凭证失败") return None logger.debug(f"【115】上传 Step 4 获取上传凭证结果: {token_resp}") # 上传凭证 endpoint = token_resp.get("endpoint") AccessKeyId = token_resp.get("AccessKeyId") AccessKeySecret = token_resp.get("AccessKeySecret") SecurityToken = token_resp.get("SecurityToken") # Step 5: 断点续传 resume_resp = self._request_api( "POST", "/open/upload/resume", "data", data={ "file_size": file_size, "target": target_param, "fileid": file_sha1, "pick_code": pick_code, }, ) if resume_resp: logger.debug(f"【115】上传 Step 5 断点续传结果: {resume_resp}") if resume_resp.get("callback"): callback = resume_resp["callback"] # Step 6: 对象存储上传 auth = oss2.StsAuth( access_key_id=AccessKeyId, access_key_secret=AccessKeySecret, security_token=SecurityToken, ) bucket = oss2.Bucket(auth, endpoint, bucket_name) # noqa # determine_part_size方法用于确定分片大小,设置分片大小为 10M part_size = determine_part_size(file_size, preferred_size=10 * 1024 * 1024) # 初始化进度条 logger.info( f"【115】开始上传: {local_path} -> {target_path},分片大小:{StringUtils.str_filesize(part_size)}" ) progress_callback = transfer_process(local_path.as_posix()) # 初始化分片 upload_id = bucket.init_multipart_upload( object_name, params={"encoding-type": "url", "sequential": ""} ).upload_id parts = [] # 逐个上传分片 with open(local_path, "rb") as fileobj: part_number = 1 offset = 0 while offset < file_size: if global_vars.is_transfer_stopped(local_path.as_posix()): logger.info(f"【115】{local_path} 上传已取消!") return None num_to_upload = min(part_size, file_size - offset) # 调用SizedFileAdapter(fileobj, size)方法会生成一个新的文件对象,重新计算起始追加位置。 logger.info( f"【115】开始上传 {target_name} 分片 {part_number}: {offset} -> {offset + num_to_upload}" ) result = bucket.upload_part( object_name, upload_id, part_number, data=SizedFileAdapter(fileobj, num_to_upload), ) parts.append(PartInfo(part_number, result.etag)) logger.info(f"【115】{target_name} 分片 {part_number} 上传完成") offset += num_to_upload part_number += 1 # 更新进度 progress = (offset * 100) / file_size progress_callback(progress) # 完成上传 progress_callback(100) # 请求头 headers = { "X-oss-callback": encode_callback(callback["callback"]), "x-oss-callback-var": encode_callback(callback["callback_var"]), "x-oss-forbid-overwrite": "false", } try: result = bucket.complete_multipart_upload( object_name, upload_id, parts, headers=headers ) if result.status == 200: logger.debug( f"【115】上传 Step 6 回调结果:{result.resp.response.json()}" ) logger.info(f"【115】{target_name} 上传成功") else: logger.warn(f"【115】{target_name} 上传失败,错误码: {result.status}") return None except oss2.exceptions.OssError as e: if e.code == "FileAlreadyExists": logger.warn(f"【115】{target_name} 已存在") else: logger.error( f"【115】{target_name} 上传失败: {e.status}, 错误码: {e.code}, 详情: {e.message}" ) return None # 返回结果 return self.get_item(target_path) def download(self, fileitem: schemas.FileItem, path: Path = None) -> Optional[Path]: """ 带实时进度显示的下载 """ detail = self.get_item(Path(fileitem.path)) if not detail: logger.error(f"【115】获取文件详情失败: {fileitem.name}") return None download_info = self._request_api( "POST", "/open/ufile/downurl", "data", data={"pick_code": detail.pickcode} ) if not download_info: logger.error(f"【115】获取下载链接失败: {fileitem.name}") return None download_url = list(download_info.values())[0].get("url", {}).get("url") if not download_url: logger.error(f"【115】下载链接为空: {fileitem.name}") return None local_path = (path or settings.TEMP_PATH) / fileitem.name # 获取文件大小 file_size = detail.size # 初始化进度条 logger.info(f"【115】开始下载: {fileitem.name} -> {local_path}") progress_callback = transfer_process(Path(fileitem.path).as_posix()) try: with self.session.stream("GET", download_url) as r: r.raise_for_status() downloaded_size = 0 with open(local_path, "wb") as f: for chunk in r.iter_bytes(chunk_size=self.chunk_size): if global_vars.is_transfer_stopped(fileitem.path): logger.info(f"【115】{fileitem.path} 下载已取消!") r.close() return None f.write(chunk) downloaded_size += len(chunk) if file_size: progress = (downloaded_size * 100) / file_size progress_callback(progress) # 完成下载 progress_callback(100) logger.info(f"【115】下载完成: {fileitem.name}") except httpx.RequestError as e: logger.error(f"【115】下载网络错误: {fileitem.name} - {str(e)}") # 删除可能部分下载的文件 if local_path.exists(): local_path.unlink() return None except Exception as e: logger.error(f"【115】下载失败: {fileitem.name} - {str(e)}") # 删除可能部分下载的文件 if local_path.exists(): local_path.unlink() return None return local_path def check(self) -> bool: return self.access_token is not None def delete(self, fileitem: schemas.FileItem) -> bool: """ 删除文件/目录 """ try: self._request_api( "POST", "/open/ufile/delete", data={"file_ids": int(fileitem.fileid)} ) return True except httpx.HTTPError: return False def rename(self, fileitem: schemas.FileItem, name: str) -> bool: """ 重命名文件/目录 """ resp = self._request_api( "POST", "/open/ufile/update", data={"file_id": int(fileitem.fileid), "file_name": name}, ) if not resp: return False if resp["state"]: return True return False def get_item(self, path: Path) -> Optional[schemas.FileItem]: """ 获取指定路径的文件/目录项 """ try: resp = self._request_api( "POST", "/open/folder/get_info", "data", data={"path": path.as_posix()}, no_error_log=True, ) if not resp: return None return schemas.FileItem( storage=self.schema.value, fileid=str(resp["file_id"]), path=path.as_posix() + ("/" if resp["file_category"] == "0" else ""), type="file" if resp["file_category"] == "1" else "dir", name=resp["file_name"], basename=Path(resp["file_name"]).stem, extension=Path(resp["file_name"]).suffix[1:] if resp["file_category"] == "1" else None, pickcode=resp["pick_code"], size=resp["size_byte"] if resp["file_category"] == "1" else None, modify_time=resp["utime"], ) except Exception as e: logger.debug(f"【115】获取文件信息失败: {str(e)}") return None def get_folder(self, path: Path) -> Optional[schemas.FileItem]: """ 获取指定路径的文件夹,如不存在则创建 """ def __find_dir( _fileitem: schemas.FileItem, _name: str ) -> Optional[schemas.FileItem]: """ 查找下级目录中匹配名称的目录 """ for sub_folder in self.list(_fileitem): if sub_folder.type != "dir": continue if sub_folder.name == _name: return sub_folder return None # 是否已存在 folder = self.get_item(path) if folder: return folder # 逐级查找和创建目录 fileitem = schemas.FileItem(storage=self.schema.value, path="/") for part in path.parts[1:]: dir_file = __find_dir(fileitem, part) if dir_file: fileitem = dir_file else: dir_file = self.create_folder(fileitem, part) if not dir_file: logger.warn(f"【115】创建目录 {fileitem.path}{part} 失败!") return None fileitem = dir_file return fileitem def detail(self, fileitem: schemas.FileItem) -> Optional[schemas.FileItem]: """ 获取文件/目录详细信息 """ return self.get_item(Path(fileitem.path)) def copy(self, fileitem: schemas.FileItem, path: Path, new_name: str) -> bool: """ 复制 """ if fileitem.fileid is None: fileitem = self.get_item(Path(fileitem.path)) if not fileitem: logger.warn(f"【115】获取文件 {fileitem.path} 失败!") return False dest_fileitem = self.get_item(path) if not dest_fileitem or dest_fileitem.type != "dir": logger.warn(f"【115】目标路径 {path} 不是一个有效的目录!") return False resp = self._request_api( "POST", "/open/ufile/copy", data={ "file_id": int(fileitem.fileid), "pid": int(dest_fileitem.fileid), }, ) if not resp: return False if resp["state"]: new_path = Path(path) / fileitem.name new_item = self.get_item(new_path) if not new_item: return False if self.rename(new_item, new_name): return True return False def move(self, fileitem: schemas.FileItem, path: Path, new_name: str) -> bool: """ 移动 """ if fileitem.fileid is None: fileitem = self.get_item(Path(fileitem.path)) if not fileitem: logger.warn(f"【115】获取文件 {fileitem.path} 失败!") return False dest_fileitem = self.get_item(path) if not dest_fileitem or dest_fileitem.type != "dir": logger.warn(f"【115】目标路径 {path} 不是一个有效的目录!") return False resp = self._request_api( "POST", "/open/ufile/move", data={ "file_ids": int(fileitem.fileid), "to_cid": int(dest_fileitem.fileid), }, ) if not resp: return False if resp["state"]: new_path = Path(path) / fileitem.name new_file = self.get_item(new_path) if not new_file: return False if self.rename(new_file, new_name): return True return False def link(self, fileitem: schemas.FileItem, target_file: Path) -> bool: pass def softlink(self, fileitem: schemas.FileItem, target_file: Path) -> bool: pass def usage(self) -> Optional[schemas.StorageUsage]: """ 存储使用情况 """ try: resp = self._request_api("GET", "/open/user/info", "data") if not resp: return None space = resp["rt_space_info"] return schemas.StorageUsage( total=space["all_total"]["size"], available=space["all_remain"]["size"] ) except NoCheckInException: return None ================================================ FILE: app/modules/filemanager/transhandler.py ================================================ import re from pathlib import Path from typing import Optional, List, Tuple from jinja2 import Template from app.core.config import settings from app.core.context import MediaInfo from app.core.event import eventmanager from app.core.meta import MetaBase from app.core.metainfo import MetaInfoPath from app.helper.directory import DirectoryHelper from app.helper.message import TemplateHelper from app.log import logger from app.modules.filemanager.storages import StorageBase from app.schemas import TransferInfo, TmdbEpisode, TransferDirectoryConf, FileItem, TransferInterceptEventData, \ TransferRenameEventData from app.schemas.types import MediaType, ChainEventType from app.utils.system import SystemUtils class TransHandler: """ 文件转移整理类 """ def __init__(self): pass @staticmethod def __update_result(result: TransferInfo, **kwargs): """ 更新结果 """ # 设置值 for key, value in kwargs.items(): if hasattr(result, key): current_value = getattr(result, key) if current_value is None: current_value = value elif isinstance(current_value, list): if isinstance(value, list): current_value.extend(value) else: current_value.append(value) elif isinstance(current_value, dict): if isinstance(value, dict): current_value.update(value) else: current_value[key] = value elif isinstance(current_value, bool): current_value = value elif isinstance(current_value, int): current_value += (value or 0) else: current_value = value setattr(result, key, current_value) def transfer_media(self, fileitem: FileItem, in_meta: MetaBase, mediainfo: MediaInfo, target_storage: str, target_path: Path, transfer_type: str, source_oper: StorageBase, target_oper: StorageBase, need_scrape: Optional[bool] = False, need_rename: Optional[bool] = True, need_notify: Optional[bool] = True, overwrite_mode: Optional[str] = None, episodes_info: List[TmdbEpisode] = None ) -> TransferInfo: """ 识别并整理一个文件或者一个目录下的所有文件 :param fileitem: 整理的文件对象,可能是一个文件也可以是一个目录 :param in_meta:预识别元数据 :param mediainfo: 媒体信息 :param target_storage: 目标存储 :param target_path: 目标路径 :param transfer_type: 文件整理方式 :param source_oper: 源存储操作对象 :param target_oper: 目标存储操作对象 :param need_scrape: 是否需要刮削 :param need_rename: 是否需要重命名 :param need_notify: 是否需要通知 :param overwrite_mode: 覆盖模式 :param episodes_info: 当前季的全部集信息 :return: TransferInfo、错误信息 """ def __is_subtitle_file(_fileitem: FileItem) -> bool: """ 判断是否为字幕文件 :param _fileitem: 文件项 :return: True/False """ if not _fileitem.extension: return False if f".{_fileitem.extension.lower()}" in settings.RMT_SUBEXT: return True return False def __is_extra_file(_fileitem: FileItem) -> bool: """ 判断是否为附加文件 :param _fileitem: 文件项 :return: True/False """ if not _fileitem.extension: return False if f".{_fileitem.extension.lower()}" in (settings.RMT_SUBEXT + settings.RMT_AUDIOEXT): return True return False # 整理结果 result = TransferInfo() try: # 重命名格式 rename_format = settings.RENAME_FORMAT(mediainfo.type) # 判断是否为文件夹 if fileitem.type == "dir": # 整理整个目录,一般为蓝光原盘 if need_rename: new_path = self.get_rename_path( path=target_path, template_string=rename_format, rename_dict=self.get_naming_dict(meta=in_meta, mediainfo=mediainfo) ) new_path = DirectoryHelper.get_media_root_path( rename_format, rename_path=new_path ) if not new_path: self.__update_result( result=result, success=False, message="重命名格式无效", fileitem=fileitem, transfer_type=transfer_type, need_notify=need_notify, ) return result else: new_path = target_path / fileitem.name # 原盘大小只计算STREAM目录内的文件大小 if stream_fileitem := source_oper.get_item( Path(fileitem.path) / "BDMV" / "STREAM" ): fileitem.size = sum( file.size for file in source_oper.list(stream_fileitem) or [] ) # 整理目录 new_diritem, errmsg = self.__transfer_dir(fileitem=fileitem, mediainfo=mediainfo, source_oper=source_oper, target_oper=target_oper, target_storage=target_storage, target_path=new_path, transfer_type=transfer_type, result=result) if not new_diritem: logger.error(f"文件夹 {fileitem.path} 整理失败:{errmsg}") self.__update_result(result=result, success=False, message=errmsg, fileitem=fileitem, transfer_type=transfer_type, need_notify=need_notify) return result logger.info(f"文件夹 {fileitem.path} 整理成功") # 返回整理后的路径 self.__update_result(result=result, success=True, fileitem=fileitem, target_item=new_diritem, target_diritem=new_diritem, need_scrape=need_scrape, need_notify=need_notify, transfer_type=transfer_type) return result else: # 整理单个文件 if mediainfo.type == MediaType.TV: # 电视剧 if in_meta.begin_episode is None: logger.warn(f"文件 {fileitem.path} 整理失败:未识别到文件集数") self.__update_result(result=result, success=False, message="未识别到文件集数", fileitem=fileitem, fail_list=[fileitem.path], transfer_type=transfer_type, need_notify=need_notify) return result # 文件结束季为空 in_meta.end_season = None # 文件总季数为1 if in_meta.total_season: in_meta.total_season = 1 # 文件不可能超过2集 if in_meta.total_episode > 2: in_meta.total_episode = 1 in_meta.end_episode = None # 目的文件名 if need_rename: new_file = self.get_rename_path( path=target_path, template_string=rename_format, rename_dict=self.get_naming_dict( meta=in_meta, mediainfo=mediainfo, episodes_info=episodes_info, file_ext=f".{fileitem.extension}" ) ) # 针对字幕文件,文件名中补充额外标识信息 if __is_subtitle_file(fileitem): new_file = self.__rename_subtitles(fileitem, new_file) # 文件目录 folder_path = DirectoryHelper.get_media_root_path( rename_format, rename_path=new_file ) if not folder_path: self.__update_result( result=result, success=False, message="重命名格式无效", fileitem=fileitem, fail_list=[fileitem.path], transfer_type=transfer_type, need_notify=need_notify, ) return result else: new_file = target_path / fileitem.name folder_path = target_path # 目标目录 target_diritem = target_oper.get_folder(folder_path) if not target_diritem: logger.error(f"目标目录 {folder_path} 获取失败") self.__update_result(result=result, success=False, message=f"目标目录 {folder_path} 获取失败", fileitem=fileitem, fail_list=[fileitem.path], transfer_type=transfer_type, need_notify=need_notify) return result # 判断是否要覆盖,附加文件强制覆盖 overflag = False if not __is_extra_file(fileitem): # 目标文件 target_item = target_oper.get_item(new_file) if target_item: # 目标文件已存在 target_file = new_file if target_storage == "local" and new_file.is_symlink(): target_file = new_file.readlink() if not target_file.exists(): overflag = True if not overflag: # 目标文件已存在 logger.info( f"目的文件系统中已经存在同名文件 {target_file},当前整理覆盖模式设置为 {overwrite_mode}") if overwrite_mode == 'always': # 总是覆盖同名文件 overflag = True elif overwrite_mode == 'size': # 存在时大覆盖小 if target_item.size < fileitem.size: logger.info(f"目标文件文件大小更小,将覆盖:{new_file}") overflag = True else: self.__update_result(result=result, success=False, message=f"媒体库存在同名文件,且质量更好", fileitem=fileitem, target_item=target_item, target_diritem=target_diritem, fail_list=[fileitem.path], transfer_type=transfer_type, need_notify=need_notify) return result elif overwrite_mode == 'never': # 存在不覆盖 self.__update_result(result=result, success=False, message=f"媒体库存在同名文件,当前覆盖模式为不覆盖", fileitem=fileitem, target_item=target_item, target_diritem=target_diritem, fail_list=[fileitem.path], transfer_type=transfer_type, need_notify=need_notify) return result elif overwrite_mode == 'latest': # 仅保留最新版本 logger.info(f"当前整理覆盖模式设置为仅保留最新版本,将覆盖:{new_file}") overflag = True else: if overwrite_mode == 'latest': # 文件不存在,但仅保留最新版本 logger.info( f"当前整理覆盖模式设置为 {overwrite_mode},仅保留最新版本,正在删除已有版本文件 ...") self.__delete_version_files(target_oper, new_file) else: # 附加文件 总是需要覆盖 overflag = True # 整理文件 new_item, err_msg = self.__transfer_file(fileitem=fileitem, mediainfo=mediainfo, target_storage=target_storage, target_file=new_file, transfer_type=transfer_type, over_flag=overflag, source_oper=source_oper, target_oper=target_oper, result=result) if not new_item: logger.error(f"文件 {fileitem.path} 整理失败:{err_msg}") self.__update_result(result=result, success=False, message=err_msg, fileitem=fileitem, fail_list=[fileitem.path], transfer_type=transfer_type, need_notify=need_notify) return result logger.info(f"文件 {fileitem.path} 整理成功") self.__update_result(result=result, success=True, fileitem=fileitem, target_item=new_item, target_diritem=target_diritem, need_scrape=need_scrape, transfer_type=transfer_type, need_notify=need_notify) return result except Exception as e: logger.error(f"媒体整理出错:{e}") return TransferInfo(success=False, message=str(e)) @staticmethod def __transfer_command(fileitem: FileItem, target_storage: str, source_oper: StorageBase, target_oper: StorageBase, target_file: Path, transfer_type: str, ) -> Tuple[Optional[FileItem], str]: """ 处理单个文件 :param fileitem: 源文件 :param target_storage: 目标存储 :param source_oper: 源存储操作对象 :param target_oper: 目标存储操作对象 :param target_file: 目标文件路径 :param transfer_type: 整理方式 """ def __get_targetitem(_path: Path) -> FileItem: """ 获取文件信息 """ return FileItem( storage=target_storage, path=_path.as_posix(), name=_path.name, basename=_path.stem, type="file", size=_path.stat().st_size, extension=_path.suffix.lstrip('.'), modify_time=_path.stat().st_mtime ) if (fileitem.storage != target_storage and fileitem.storage != "local" and target_storage != "local"): return None, f"不支持 {fileitem.storage} 到 {target_storage} 的文件整理" if fileitem.storage == "local" and target_storage == "local": # 创建目录 if not target_file.parent.exists(): target_file.parent.mkdir(parents=True, exist_ok=True) # 本地到本地 if transfer_type == "copy": state = source_oper.copy(fileitem, target_file.parent, target_file.name) elif transfer_type == "move": state = source_oper.move(fileitem, target_file.parent, target_file.name) elif transfer_type == "link": state = source_oper.link(fileitem, target_file) elif transfer_type == "softlink": state = source_oper.softlink(fileitem, target_file) else: return None, f"不支持的整理方式:{transfer_type}" if state: return __get_targetitem(target_file), "" else: return None, f"{fileitem.path} {transfer_type} 失败" elif fileitem.storage == "local" and target_storage != "local": # 本地到网盘 filepath = Path(fileitem.path) if not filepath.exists(): return None, f"文件 {filepath} 不存在" if transfer_type == "copy": # 复制 # 根据目的路径创建文件夹 target_fileitem = target_oper.get_folder(target_file.parent) if target_fileitem: # 上传文件 new_item = target_oper.upload(target_fileitem, filepath, target_file.name) if new_item: return new_item, "" else: return None, f"{fileitem.path} 上传 {target_storage} 失败" else: return None, f"【{target_storage}】{target_file.parent} 目录获取失败" elif transfer_type == "move": # 移动 # 根据目的路径获取文件夹 target_fileitem = target_oper.get_folder(target_file.parent) if target_fileitem: # 上传文件 new_item = target_oper.upload(target_fileitem, filepath, target_file.name) if new_item: # 删除源文件 source_oper.delete(fileitem) return new_item, "" else: return None, f"{fileitem.path} 上传 {target_storage} 失败" else: return None, f"【{target_storage}】{target_file.parent} 目录获取失败" elif fileitem.storage != "local" and target_storage == "local": # 网盘到本地 if target_file.exists(): logger.warn(f"文件已存在:{target_file}") return __get_targetitem(target_file), "" # 网盘到本地 if transfer_type in ["copy", "move"]: # 下载 tmp_file = source_oper.download(fileitem=fileitem, path=target_file.parent) if tmp_file: # 创建目录 if not target_file.parent.exists(): target_file.parent.mkdir(parents=True, exist_ok=True) # 将tmp_file移动后target_file SystemUtils.move(tmp_file, target_file) if transfer_type == "move": # 删除源文件 source_oper.delete(fileitem) return __get_targetitem(target_file), "" else: return None, f"{fileitem.path} {fileitem.storage} 下载失败" elif fileitem.storage == target_storage: # 同一网盘 if not source_oper.is_support_transtype(transfer_type): return None, f"存储 {fileitem.storage} 不支持 {transfer_type} 整理方式" if transfer_type == "copy": # 复制文件到新目录 target_fileitem = target_oper.get_folder(target_file.parent) if target_fileitem: if source_oper.copy(fileitem, Path(target_fileitem.path), target_file.name): return target_oper.get_item(target_file), "" else: return None, f"【{target_storage}】{fileitem.path} 复制文件失败" else: return None, f"【{target_storage}】{target_file.parent} 目录获取失败" elif transfer_type == "move": # 移动文件到新目录 target_fileitem = target_oper.get_folder(target_file.parent) if target_fileitem: if source_oper.move(fileitem, Path(target_fileitem.path), target_file.name): return target_oper.get_item(target_file), "" else: return None, f"【{target_storage}】{fileitem.path} 移动文件失败" else: return None, f"【{target_storage}】{target_file.parent} 目录获取失败" elif transfer_type == "link": if source_oper.link(fileitem, target_file): return target_oper.get_item(target_file), "" else: return None, f"【{target_storage}】{fileitem.path} 创建硬链接失败" else: return None, f"不支持的整理方式:{transfer_type}" return None, "未知错误" @staticmethod def __rename_subtitles(sub_item: FileItem, new_file: Path) -> Path: """ 重命名字幕文件,补充附加信息 """ # 字幕正则式 _zhcn_sub_re = r"([.\[(\s](((zh[-_])?(cn|ch[si]|sg|sc))|zho?" \ r"|chinese|(cn|ch[si]|sg|zho?)[-_&]?(cn|ch[si]|sg|zho?|eng|jap|ja|jpn)" \ r"|eng[-_&]?(cn|ch[si]|sg|zho?)|(jap|ja|jpn)[-_&]?(cn|ch[si]|sg|zho?)" \ r"|简[体中]?)[.\])\s])" \ r"|([\u4e00-\u9fa5]{0,3}[中双][\u4e00-\u9fa5]{0,2}[字文语][\u4e00-\u9fa5]{0,3})" \ r"|简体|简中|JPSC|sc_jp" \ r"|(? Tuple[Optional[FileItem], str]: """ 整理整个文件夹 :param fileitem: 源文件 :param mediainfo: 媒体信息 :param source_oper: 源存储操作对象 :param target_oper: 目标存储操作对象 :param transfer_type: 整理方式 :param target_storage: 目标存储 :param target_path: 目标路径 """ logger.info(f"正在整理目录:{fileitem.path} 到 {target_path}") target_item = target_oper.get_folder(target_path) if not target_item: return None, f"获取目标目录失败:{target_path}" event_data = TransferInterceptEventData( fileitem=fileitem, mediainfo=mediainfo, target_storage=target_storage, target_path=target_path, transfer_type=transfer_type ) event = eventmanager.send_event(ChainEventType.TransferIntercept, event_data) if event and event.event_data: event_data = event.event_data # 如果事件被取消,跳过文件整理 if event_data.cancel: logger.debug( f"Transfer dir canceled by event: {event_data.source}," f"Reason: {event_data.reason}") return None, event_data.reason # 处理所有文件 state, errmsg = self.__transfer_dir_files(fileitem=fileitem, target_storage=target_storage, source_oper=source_oper, target_oper=target_oper, target_path=target_path, transfer_type=transfer_type, result=result) if state: return target_item, errmsg else: return None, errmsg def __transfer_dir_files(self, fileitem: FileItem, target_storage: str, source_oper: StorageBase, target_oper: StorageBase, transfer_type: str, target_path: Path, result: TransferInfo) -> Tuple[bool, str]: """ 按目录结构整理目录下所有文件 :param fileitem: 源文件 :param target_storage: 目标存储 :param source_oper: 源存储操作对象 :param target_oper: 目标存储操作对象 :param target_path: 目标路径 :param transfer_type: 整理方式 """ file_list: List[FileItem] = source_oper.list(fileitem) # 整理文件 for item in file_list: if item.type == "dir": # 递归整理目录 new_path = target_path / item.name state, errmsg = self.__transfer_dir_files(fileitem=item, target_storage=target_storage, source_oper=source_oper, target_oper=target_oper, transfer_type=transfer_type, target_path=new_path, result=result) if not state: return False, errmsg else: # 整理文件 new_file = target_path / item.name new_item, errmsg = self.__transfer_command(fileitem=item, target_storage=target_storage, source_oper=source_oper, target_oper=target_oper, target_file=new_file, transfer_type=transfer_type) if not new_item: return False, errmsg self.__update_result( result=result, file_list=[item.path], file_list_new=[new_item.path], ) # 返回成功 return True, "" def __transfer_file(self, fileitem: FileItem, mediainfo: MediaInfo, source_oper: StorageBase, target_oper: StorageBase, target_storage: str, target_file: Path, transfer_type: str, result: TransferInfo, over_flag: Optional[bool] = False) -> Tuple[Optional[FileItem], str]: """ 整理一个文件,同时处理其他相关文件 :param fileitem: 原文件 :param mediainfo: 媒体信息 :param source_oper: 源存储操作对象 :param target_oper: 目标存储操作对象 :param target_storage: 目标存储 :param target_file: 新文件 :param transfer_type: 整理方式 :param over_flag: 是否覆盖,为True时会先删除再整理 :param source_oper: 源存储操作对象 :param target_oper: 目标存储操作对象 """ logger.info(f"正在整理文件:【{fileitem.storage}】{fileitem.path} 到 【{target_storage}】{target_file}," f"操作类型:{transfer_type}") event_data = TransferInterceptEventData( fileitem=fileitem, mediainfo=mediainfo, target_storage=target_storage, target_path=target_file, transfer_type=transfer_type, options={ "over_flag": over_flag } ) event = eventmanager.send_event(ChainEventType.TransferIntercept, event_data) if event and event.event_data: event_data = event.event_data # 如果事件被取消,跳过文件整理 if event_data.cancel: logger.debug( f"Transfer file canceled by event: {event_data.source}," f"Reason: {event_data.reason}") return None, event_data.reason if target_storage == "local" and (target_file.exists() or target_file.is_symlink()): if not over_flag: logger.warn(f"文件已存在:{target_file}") return None, f"{target_file} 已存在" else: logger.info(f"正在删除已存在的文件:{target_file}") target_file.unlink() else: exists_item = target_oper.get_item(target_file) if exists_item: if not over_flag: logger.warn(f"文件已存在:【{target_storage}】{target_file}") return None, f"【{target_storage}】{target_file} 已存在" else: logger.info(f"正在删除已存在的文件:【{target_storage}】{target_file}") target_oper.delete(exists_item) # 执行文件整理命令 new_item, errmsg = self.__transfer_command(fileitem=fileitem, target_storage=target_storage, source_oper=source_oper, target_oper=target_oper, target_file=target_file, transfer_type=transfer_type) if new_item: self.__update_result( result=result, file_list=[fileitem.path], file_list_new=[new_item.path], file_count=1, total_size=fileitem.size, ) return new_item, errmsg return None, errmsg @staticmethod def get_dest_path(mediainfo: MediaInfo, target_path: Path, need_type_folder: Optional[bool] = False, need_category_folder: Optional[bool] = False): """ 获取目标路径 """ if need_type_folder and mediainfo.type: target_path = target_path / mediainfo.type.value if need_category_folder and mediainfo.category: target_path = target_path / mediainfo.category return target_path @staticmethod def get_dest_dir(mediainfo: MediaInfo, target_dir: TransferDirectoryConf, need_type_folder: Optional[bool] = None, need_category_folder: Optional[bool] = None) -> Path: """ 根据设置并装媒体库目录 :param mediainfo: 媒体信息 :param target_dir: 媒体库根目录 :param need_type_folder: 是否需要按媒体类型创建目录 :param need_category_folder: 是否需要按媒体类别创建目录 """ if need_type_folder is None: need_type_folder = target_dir.library_type_folder if need_category_folder is None: need_category_folder = target_dir.library_category_folder if not target_dir.media_type and need_type_folder and mediainfo.type: # 一级自动分类 library_dir = Path(target_dir.library_path) / mediainfo.type.value elif target_dir.media_type and need_type_folder: # 一级手动分类 library_dir = Path(target_dir.library_path) / target_dir.media_type else: library_dir = Path(target_dir.library_path) if not target_dir.media_category and need_category_folder and mediainfo.category: # 二级自动分类 library_dir = library_dir / mediainfo.category elif target_dir.media_category and need_category_folder: # 二级手动分类 library_dir = library_dir / target_dir.media_category return library_dir @staticmethod def get_naming_dict(meta: MetaBase, mediainfo: MediaInfo, file_ext: Optional[str] = None, episodes_info: List[TmdbEpisode] = None) -> dict: """ 根据媒体信息,返回Format字典 :param meta: 文件元数据 :param mediainfo: 识别的媒体信息 :param file_ext: 文件扩展名 :param episodes_info: 当前季的全部集信息 """ return TemplateHelper().builder.build(meta=meta, mediainfo=mediainfo, file_extension=file_ext, episodes_info=episodes_info) @staticmethod def __delete_version_files(storage_oper: StorageBase, path: Path) -> bool: """ 删除目录下的所有版本文件 :param storage_oper: 存储操作对象 :param path: 目录路径 """ # 存储 if not storage_oper: return False # 识别文件中的季集信息 meta = MetaInfoPath(path) season = meta.season episode = meta.episode logger.warn(f"正在删除目标目录中其它版本的文件:{path.parent}") # 获取父目录 parent_item = storage_oper.get_item(path.parent) if not parent_item: logger.warn(f"目录 {path.parent} 不存在") return False # 检索媒体文件 media_files = storage_oper.list(parent_item) if not media_files: logger.info(f"目录 {path.parent} 中没有文件") return False # 删除文件 for media_file in media_files: media_path = Path(media_file.path) if media_path == path: continue if media_file.type != "file": continue # 当前只有视频文件需要保留最新版本,其余格式无需处理,以避免误删 (issue 5449) if f".{media_file.extension.lower()}" not in settings.RMT_MEDIAEXT: continue # 识别文件中的季集信息 filemeta = MetaInfoPath(media_path) # 相同季集的文件才删除 if filemeta.season != season or filemeta.episode != episode: continue logger.info(f"正在删除文件:{media_file.name}") storage_oper.delete(media_file) return True @staticmethod def get_rename_path(template_string: str, rename_dict: dict, path: Path = None) -> Path: """ 生成重命名后的完整路径,支持智能重命名事件 :param template_string: Jinja2 模板字符串 :param rename_dict: 渲染上下文,用于替换模板中的变量 :param path: 可选的基础路径,如果提供,将在其基础上拼接生成的路径 :return: 生成的完整路径 """ # 创建jinja2模板对象 template = Template(template_string) # 渲染生成的字符串 render_str = template.render(rename_dict) logger.debug(f"Initial render string: {render_str}") # 发送智能重命名事件 event_data = TransferRenameEventData( template_string=template_string, rename_dict=rename_dict, render_str=render_str, path=path ) event = eventmanager.send_event(ChainEventType.TransferRename, event_data) # 检查事件返回的结果 if event and event.event_data: event_data: TransferRenameEventData = event.event_data if event_data.updated and event_data.updated_str: logger.debug(f"Render string updated by event: " f"{render_str} -> {event_data.updated_str} (source: {event_data.source})") render_str = event_data.updated_str # 目的路径 if path: return path / render_str else: return Path(render_str) ================================================ FILE: app/modules/filter/RuleParser.py ================================================ import threading from pyparsing import Forward, Literal, Word, alphas, infixNotation, opAssoc, alphanums, Combine, nums, ParseResults class RuleParser: _lock = threading.Lock() _thread_local = threading.local() def __init__(self): """ 定义语法规则 """ with self._lock: if not hasattr(self._thread_local, 'initialized'): # 表达式 expr: Forward = Forward() # 原子 atom: Combine = Combine(Word(alphas, alphanums) | (Word(nums) + Word(alphas, alphanums))) # 逻辑非操作符 operator_not: Literal = Literal('!').setParseAction(lambda t: 'not') # 逻辑或操作符 operator_or: Literal = Literal('|').setParseAction(lambda t: 'or') # 逻辑与操作符 operator_and: Literal = Literal('&').setParseAction(lambda t: 'and') # 定义表达式的语法规则 expr <<= (operator_not + expr) | atom | ('(' + expr + ')') # 运算符优先级 self.expr = infixNotation(expr, [(operator_not, 1, opAssoc.RIGHT), (operator_and, 2, opAssoc.LEFT), (operator_or, 2, opAssoc.LEFT)]) self._thread_local.expr = self.expr self._thread_local.initialized = True else: self.expr = self._thread_local.expr def parse(self, expression: str) -> ParseResults: """ 解析给定的表达式。 参数: expression -- 要解析的表达式 返回: 解析结果 """ return self.expr.parseString(expression) if __name__ == '__main__': # 测试代码 expression_str = """ SPECSUB & CNVOI & 4K & !BLU & !REMUX & !WEBDL & 60FPS & !DOLBY & !SDR & !3D > CNSUB & CNVOI & 4K & !BLU & !REMUX & !WEBDL & 60FPS & !DOLBY & !SDR & !3D > SPECSUB & 4K & !BLU & !REMUX & !WEBDL & 60FPS & !DOLBY & !SDR & !3D > CNSUB & 4K & !BLU & !REMUX & !WEBDL & 60FPS & !DOLBY & !SDR & !3D > SPECSUB & CNVOI & 4K & !BLU & !REMUX & !WEBDL & !DOLBY & HDR & !3D > CNSUB & CNVOI & 4K & !BLU & !REMUX & !WEBDL & !DOLBY & HDR & !3D > SPECSUB & CNVOI & 4K & !BLU & !REMUX & !WEBDL & !DOLBY & !3D > CNSUB & CNVOI & 4K & !BLU & !REMUX & !WEBDL & !DOLBY & !3D > SPECSUB & 4K & !BLU & !REMUX & !WEBDL & !DOLBY & HDR & !3D > CNSUB & 4K & !BLU & !REMUX & !WEBDL & !DOLBY & HDR & !3D > SPECSUB & 4K & !BLU & !REMUX & !WEBDL & !DOLBY & !3D > CNSUB & 4K & !BLU & !REMUX & !WEBDL & !DOLBY & !3D > SPECSUB & CNVOI & 4K & WEBDL & 60FPS & !DOLBY & !SDR & !3D > CNSUB & CNVOI & 4K & WEBDL & 60FPS & !DOLBY & !SDR & !3D > SPECSUB & 4K & WEBDL & 60FPS & !DOLBY & !SDR & !3D > CNSUB & 4K & WEBDL & 60FPS & !DOLBY & !SDR & !3D > SPECSUB & CNVOI & 4K & WEBDL & !DOLBY & HDR & !3D > CNSUB & CNVOI & 4K & WEBDL & !DOLBY & HDR & !3D > SPECSUB & CNVOI & 4K & WEBDL & !DOLBY & !3D > CNSUB & CNVOI & 4K & WEBDL & !DOLBY & !3D > SPECSUB & 4K & WEBDL & !DOLBY & HDR & !3D > CNSUB & 4K & WEBDL & !DOLBY & HDR & !3D > SPECSUB & 4K & WEBDL & !DOLBY & !3D > CNSUB & 4K & WEBDL & !DOLBY & !3D > SPECSUB & CNVOI & 4K & !BLU & !WEBDL & !DOLBY & HDR & !3D > CNSUB & CNVOI & 4K & !BLU & !WEBDL & !DOLBY & HDR & !3D > SPECSUB & CNVOI & 4K & !BLU & !WEBDL & !DOLBY & !3D > CNSUB & CNVOI & 4K & !BLU & !WEBDL & !DOLBY & !3D > SPECSUB & 4K & !BLU & !WEBDL & !DOLBY & HDR & !3D > CNSUB & 4K & !BLU & !WEBDL & !DOLBY & HDR & !3D > SPECSUB & 4K & !BLU & !WEBDL & !DOLBY & !SDR & !3D > CNSUB & 4K & !BLU & !WEBDL & !DOLBY & !SDR & !3D > 4K & !BLU & !REMUX & !DOLBY & HDR & !3D > 4K & !BLURAY & !REMUX & !DOLBY & !3D > SPECSUB & 1080P & !BLU & !REMUX & !WEBDL & !DOLBY & HDR & !3D > CNSUB & 1080P & !BLU & !REMUX & !WEBDL & !DOLBY & HDR & !3D > SPECSUB & 1080P & !BLU & !REMUX & !WEBDL & !DOLBY & !3D > CNSUB & 1080P & !BLU & !REMUX & !WEBDL & !DOLBY & !3D > SPECSUB & 1080P & !BLU & !WEBDL & !DOLBY & HDR & !3D > CNSUB & 1080P & !BLU & !WEBDL & !DOLBY & HDR & !3D > SPECSUB & 1080P & !BLU & !WEBDL & !DOLBY & !3D > CNSUB & 1080P & !BLU & !WEBDL & !DOLBY & !3D > SPECSUB & 1080P & WEBDL & !DOLBY & HDR & !3D > CNSUB & 1080P & WEBDL & !DOLBY & HDR & !3D > SPECSUB & 1080P & WEBDL & !DOLBY & !3D > CNSUB & 1080P & WEBDL & !DOLBY & !3D > 1080P & !BLU & !REMUX & !DOLBY & HDR & !3D > 1080P & !BLU & !REMUX & !DOLBY & !3D """ for exp in expression_str.split('>'): parsed_expr = RuleParser().parse(exp.strip()) print(parsed_expr.asList()) ================================================ FILE: app/modules/filter/__init__.py ================================================ import re from typing import List, Tuple, Union, Dict, Optional from app.core.context import TorrentInfo, MediaInfo from app.core.metainfo import MetaInfo from app.helper.rule import RuleHelper from app.log import logger from app.modules import _ModuleBase from app.modules.filter.RuleParser import RuleParser from app.schemas.types import ModuleType, OtherModulesType, SystemConfigKey from app.utils.string import StringUtils class FilterModule(_ModuleBase): CONFIG_WATCH = {SystemConfigKey.CustomFilterRules.value} # 规则解析器 parser: RuleParser = None # 媒体信息 media: MediaInfo = None # 内置规则集 rule_set: Dict[str, dict] = { # 蓝光原盘 "BLU": { "include": [r'(?i)(\bBlu-?Ray\b.*\b(?:VC-?1|AVC|MPEG-?2)\b|\b(?:UHD|4K|2160p)\b(?:.*Blu-?Ray)?.*\b(?:HEVC|H\.?265)\b|\bBlu-?Ray\b.*\b(?:UHD|4K|2160p)\b.*\b(?:HEVC|H\.?265)\b|\b(?:COMPLETE|FULL)\b.*\b(?:(?:UHD|4K|2160p)\b.*)?Blu-?Ray\b|\b(BD25|BD50|BD66|BD100|BDMV|MiniBD)\b)'], "exclude": [r'(?i)(\b[XH]\.?264\b|\b[XH]\.?265\b|\bWEB-?DL\b|\bWEB-?RIP\b|\bHDTV(?:RIP)?\b|\bREMUX\b|\bBDRip\b|\bBRRip\b|\bHDRip\b|\bENCODE\b|\b(? None: self.parser = RuleParser() self.__init_custom_rules() def __init_custom_rules(self): """ 加载用户自定义规则,如跟内置规则冲突,以用户自定义规则为准 """ custom_rules = self.rulehelper.get_custom_rules() for rule in custom_rules: logger.info(f"加载自定义规则 {rule.id} - {rule.name}") self.rule_set[rule.id] = rule.model_dump() @staticmethod def get_name() -> str: return "过滤器" @staticmethod def get_type() -> ModuleType: """ 获取模块类型 """ return ModuleType.Other @staticmethod def get_subtype() -> OtherModulesType: """ 获取模块子类型 """ return OtherModulesType.Filter @staticmethod def get_priority() -> int: """ 获取模块优先级,数字越小优先级越高,只有同一接口下优先级才生效 """ return 4 def stop(self): pass def test(self): pass def init_setting(self) -> Tuple[str, Union[str, bool]]: pass def filter_torrents(self, rule_groups: List[str], torrent_list: List[TorrentInfo], mediainfo: MediaInfo = None) -> List[TorrentInfo]: """ 过滤种子资源 :param rule_groups: 过滤规则组名称列表 :param torrent_list: 资源列表 :param mediainfo: 媒体信息 :return: 过滤后的资源列表,添加资源优先级 """ if not rule_groups: return torrent_list self.media = mediainfo # 查询规则表详情 groups = self.rulehelper.get_rule_group_by_media(media=mediainfo, group_names=rule_groups) if groups: for group in groups: # 过滤种子 torrent_list = self.__filter_torrents( rule_string=group.rule_string, rule_name=group.name, torrent_list=torrent_list ) return torrent_list def __filter_torrents(self, rule_string: str, rule_name: str, torrent_list: List[TorrentInfo]) -> List[TorrentInfo]: """ 过滤种子 """ # 返回种子列表 ret_torrents = [] for torrent in torrent_list: # 能命中优先级的才返回 if not self.__get_order(torrent, rule_string): logger.debug(f"种子 {torrent.site_name} - {torrent.title} {torrent.description or ''} " f"不匹配 {rule_name} 过滤规则") continue ret_torrents.append(torrent) return ret_torrents def __get_order(self, torrent: TorrentInfo, rule_str: str) -> Optional[TorrentInfo]: """ 获取种子匹配的规则优先级,值越大越优先,未匹配时返回None """ # 多级规则 rule_groups = rule_str.split('>') # 优先级 res_order = 100 # 是否匹配 matched = False for rule_group in rule_groups: # 解析规则组 parsed_group = self.parser.parse(rule_group.strip()) if self.__match_group(torrent, parsed_group.as_list()[0]): # 出现匹配时中断 matched = True logger.debug(f"种子 {torrent.site_name} - {torrent.title} 优先级为 {100 - res_order + 1}") torrent.pri_order = res_order break # 优先级降低,继续匹配 res_order -= 1 return None if not matched else torrent def __match_group(self, torrent: TorrentInfo, rule_group: Union[list, str]) -> Optional[bool]: """ 判断种子是否匹配规则组 """ if not isinstance(rule_group, list): # 不是列表,说明是规则名称 return self.__match_rule(torrent, rule_group) elif isinstance(rule_group, list) and len(rule_group) == 1: # 只有一个规则项 return self.__match_group(torrent, rule_group[0]) elif rule_group[0] == "not": # 非操作 return not self.__match_group(torrent, rule_group[1:]) elif rule_group[1] == "and": # 与操作 return self.__match_group(torrent, rule_group[0]) and self.__match_group(torrent, rule_group[2:]) elif rule_group[1] == "or": # 或操作 return self.__match_group(torrent, rule_group[0]) or self.__match_group(torrent, rule_group[2:]) def __match_rule(self, torrent: TorrentInfo, rule_name: str) -> bool: """ 判断种子是否匹配规则项 """ if not self.rule_set.get(rule_name): # 规则不存在 logger.debug(f"规则 {rule_name} 不存在") return False # TMDB规则 tmdb = self.rule_set[rule_name].get("tmdb") # 符合TMDB规则的直接返回True,即不过滤 if tmdb and self.__match_tmdb(tmdb): logger.debug(f"种子 {torrent.site_name} - {torrent.title} 符合 {rule_name} 的TMDB规则,匹配成功") return True # 匹配项:标题、副标题、标签 content = f"{torrent.title} {torrent.description} {' '.join(torrent.labels or [])}" # 只匹配指定关键字 match_content = [] matchs = self.rule_set[rule_name].get("match") or [] if matchs: for match in matchs: if not hasattr(torrent, match): continue match_value = getattr(torrent, match) if not match_value: continue if isinstance(match_value, list): match_content.extend(match_value) else: match_content.append(match_value) if match_content: content = " ".join(match_content) # 包含规则项 includes = self.rule_set[rule_name].get("include") or [] if not isinstance(includes, list): includes = [includes] # 排除规则项 excludes = self.rule_set[rule_name].get("exclude") or [] if not isinstance(excludes, list): excludes = [excludes] # 大小范围规则项 size_range = self.rule_set[rule_name].get("size_range") # 做种人数规则项 seeders = self.rule_set[rule_name].get("seeders") # FREE规则 downloadvolumefactor = self.rule_set[rule_name].get("downloadvolumefactor") # 发布时间规则 pubdate: str = self.rule_set[rule_name].get("publish_time") if includes and not any(re.search(r"%s" % include, content, re.IGNORECASE) for include in includes): # 未发现任何包含项 logger.debug(f"种子 {torrent.site_name} - {torrent.title} 不包含任何项 {includes}") return False for exclude in excludes: if re.search(r"%s" % exclude, content, re.IGNORECASE): # 发现排除项 logger.debug(f"种子 {torrent.site_name} - {torrent.title} 包含 {exclude}") return False if size_range: if not self.__match_size(torrent, size_range): # 大小范围不匹配 logger.debug(f"种子 {torrent.site_name} - {torrent.title} 大小 " f"{StringUtils.str_filesize(torrent.size)} 不在范围 {size_range}MB") return False if seeders: if torrent.seeders < int(seeders): # 做种人数不匹配 logger.debug(f"种子 {torrent.site_name} - {torrent.title} 做种人数 {torrent.seeders} 小于 {seeders}") return False if downloadvolumefactor is not None: if torrent.downloadvolumefactor != downloadvolumefactor: # FREE规则不匹配 logger.debug( f"种子 {torrent.site_name} - {torrent.title} FREE值 {torrent.downloadvolumefactor} 不是 {downloadvolumefactor}") return False if pubdate: # 种子发布时间 pub_minutes = torrent.pub_minutes() # 发布时间规则 pub_times = [float(t) for t in pubdate.split("-")] if len(pub_times) == 1: # 发布时间小于规则 if pub_minutes < pub_times[0]: logger.debug(f"种子 {torrent.site_name} - {torrent.title} 发布时间 {pub_minutes} 小于 {pub_times[0]}") return False else: # 区间 if not (pub_times[0] <= pub_minutes <= pub_times[1]): logger.debug(f"种子 {torrent.site_name} - {torrent.title} 发布时间 {pub_minutes} 不在 {pub_times[0]}-{pub_times[1]} 时间区间") return False return True def __match_tmdb(self, tmdb: dict) -> bool: """ 判断种子是否匹配TMDB规则 """ def __get_media_value(key: str): try: return getattr(self.media, key) except ValueError: return "" if not self.media: return False for attr, value in tmdb.items(): if not value: continue # 获取media信息的值 info_value = __get_media_value(attr) if not info_value: # 没有该值,不匹配 return False elif attr == "production_countries": # 国家信息 info_values = [str(val.get("iso_3166_1")).upper() for val in info_value] else: # media信息转化为数组 if isinstance(info_value, list): info_values = [str(val).upper() for val in info_value] else: info_values = [str(info_value).upper()] # 过滤值转化为数组 if value.find(",") != -1: values = [str(val).upper() for val in value.split(",") if val] else: values = [str(value).upper()] # 没有交集为不匹配 if not set(values).intersection(set(info_values)): return False return True @staticmethod def __match_size(torrent: TorrentInfo, size_range: str) -> bool: """ 判断种子是否匹配大小范围(MB),剧集拆分为每集大小 """ if not size_range: return True # 集数 meta = MetaInfo(title=torrent.title, subtitle=torrent.description) episode_count = meta.total_episode or 1 # 每集大小 torrent_size = torrent.size / episode_count # 大小范围 size_range = size_range.strip() if size_range.find("-") != -1: # 区间 size_min, size_max = size_range.split("-") size_min = float(size_min.strip()) * 1024 * 1024 size_max = float(size_max.strip()) * 1024 * 1024 if size_min <= torrent_size <= size_max: return True elif size_range.startswith(">"): # 大于 size_min = float(size_range[1:].strip()) * 1024 * 1024 if torrent_size >= size_min: return True elif size_range.startswith("<"): # 小于 size_max = float(size_range[1:].strip()) * 1024 * 1024 if torrent_size <= size_max: return True return False ================================================ FILE: app/modules/indexer/__init__.py ================================================ from datetime import datetime from typing import List, Optional, Tuple, Union from app.core.context import TorrentInfo from app.db.site_oper import SiteOper from app.helper.module import ModuleHelper from app.helper.sites import SitesHelper # noqa from app.log import logger from app.modules import _ModuleBase from app.modules.indexer.parser import SiteParserBase from app.modules.indexer.spider import SiteSpider from app.modules.indexer.spider.haidan import HaiDanSpider from app.modules.indexer.spider.hddolby import HddolbySpider from app.modules.indexer.spider.mtorrent import MTorrentSpider from app.modules.indexer.spider.rousi import RousiSpider from app.modules.indexer.spider.tnode import TNodeSpider from app.modules.indexer.spider.torrentleech import TorrentLeech from app.modules.indexer.spider.yema import YemaSpider from app.schemas import SiteUserData from app.schemas.types import MediaType, ModuleType, OtherModulesType from app.utils.string import StringUtils class IndexerModule(_ModuleBase): """ 索引模块 """ _site_schemas = [] def init_module(self) -> None: # 加载模块 self._site_schemas = ModuleHelper.load( 'app.modules.indexer.parser', filter_func=lambda _, obj: hasattr(obj, 'schema') and getattr(obj, 'schema') is not None) pass @staticmethod def get_name() -> str: return "站点索引" @staticmethod def get_type() -> ModuleType: """ 获取模块类型 """ return ModuleType.Indexer @staticmethod def get_subtype() -> OtherModulesType: """ 获取模块子类型 """ return OtherModulesType.Indexer @staticmethod def get_priority() -> int: """ 获取模块优先级,数字越小优先级越高,只有同一接口下优先级才生效 """ return 0 def stop(self): pass def test(self) -> Tuple[bool, str]: """ 测试模块连接性 """ sites = SitesHelper().get_indexers() if not sites: return False, "未配置站点或未通过用户认证" return True, "" def init_setting(self) -> Tuple[str, Union[str, bool]]: pass @staticmethod def __search_check(site: dict, search_word: Optional[str] = None) -> bool: """ 检查是否可以执行搜索 """ # 可能为关键字或ttxxxx if search_word \ and site.get('language') == "en" \ and StringUtils.is_chinese(search_word): # 不支持中文 logger.warn(f"{site.get('name')} 不支持中文搜索") return False # 站点流控 state, msg = SitesHelper().check(StringUtils.get_url_domain(site.get("domain"))) if state: logger.warn(msg) return False return True @staticmethod def __clear_search_text(text: Optional[str]) -> Optional[str]: """ 清理搜索文本 :param text: 需要清理的文本 :return: 清理后的文本 """ if not text: return text # 去除特殊字符和多余空格 return StringUtils.clear(text, replace_word=" ", allow_space=True) @staticmethod def __indexer_statistic(site: dict, error_flag: bool = False, seconds: int = 0) -> None: """ 索引器统计 """ domain = StringUtils.get_url_domain(site.get("domain")) if error_flag: SiteOper().fail(domain) else: SiteOper().success(domain=domain, seconds=seconds) @staticmethod async def __async_indexer_statistic(site: dict, error_flag: bool = False, seconds: int = 0) -> None: """ 异步索引器统计 """ domain = StringUtils.get_url_domain(site.get("domain")) if error_flag: await SiteOper().async_fail(domain) else: await SiteOper().async_success(domain=domain, seconds=seconds) @staticmethod def __parse_result(site: dict, result_array: list, seconds: int) -> TorrentInfo: """ 解析搜索结果为 TorrentInfo 对象 """ if not result_array or len(result_array) == 0: logger.warn(f"{site.get('name')} 未搜索到数据,耗时 {seconds} 秒") return [] logger.info( f"{site.get('name')} 搜索完成,耗时 {seconds} 秒,返回数据:{len(result_array)}") return [TorrentInfo(site=site.get("id"), site_name=site.get("name"), site_cookie=site.get("cookie"), site_ua=site.get("ua"), site_proxy=site.get("proxy"), site_order=site.get("pri"), site_downloader=site.get("downloader"), **result) for result in result_array] def search_torrents(self, site: dict, keyword: str = None, mtype: MediaType = None, cat: Optional[str] = None, page: Optional[int] = 0) -> List[TorrentInfo]: """ 搜索一个站点 :param site: 站点 :param keyword: 搜索关键词 :param mtype: 媒体类型 :param cat: 分类 :param page: 页码 :return: 资源列表 """ # 索引结果 result = [] # 开始计时 start_time = datetime.now() # 错误标志 error_flag = False # 检查是否可以执行搜索 if not self.__search_check(site, keyword): return [] # 去除搜索关键字中的特殊字符 search_word = self.__clear_search_text(keyword) # 开始搜索 try: if site.get('parser') == "TNodeSpider": error_flag, result = TNodeSpider(site).search( keyword=search_word, page=page ) elif site.get('parser') == "TorrentLeech": error_flag, result = TorrentLeech(site).search( keyword=search_word, page=page ) elif site.get('parser') == "mTorrent": error_flag, result = MTorrentSpider(site).search( keyword=search_word, mtype=mtype, page=page ) elif site.get('parser') == "Yema": error_flag, result = YemaSpider(site).search( keyword=search_word, mtype=mtype, page=page ) elif site.get('parser') == "Haidan": error_flag, result = HaiDanSpider(site).search( keyword=search_word, mtype=mtype ) elif site.get('parser') == "HDDolby": error_flag, result = HddolbySpider(site).search( keyword=search_word, mtype=mtype, page=page ) elif site.get('parser') == "RousiPro": error_flag, result = RousiSpider(site).search( keyword=search_word, mtype=mtype, cat=cat, page=page ) else: error_flag, result = self.__spider_search( search_word=search_word, indexer=site, mtype=mtype, cat=cat, page=page ) except Exception as err: logger.error(f"{site.get('name')} 搜索出错:{str(err)}") # 索引花费的时间 seconds = (datetime.now() - start_time).seconds # 统计索引情况 self.__indexer_statistic(site=site, error_flag=error_flag, seconds=seconds) # 返回结果 return self.__parse_result( site=site, result_array=result, seconds=seconds ) async def async_search_torrents(self, site: dict, keyword: str = None, mtype: MediaType = None, cat: Optional[str] = None, page: Optional[int] = 0) -> List[TorrentInfo]: """ 异步搜索一个站点 :param site: 站点 :param keyword: 搜索关键词 :param mtype: 媒体类型 :param cat: 分类 :param page: 页码 :return: 资源列表 """ # 索引结果 result = [] # 开始计时 start_time = datetime.now() # 错误标志 error_flag = False # 检查是否可以执行搜索 if not self.__search_check(site, keyword): return [] # 去除搜索关键字中的特殊字符 search_word = self.__clear_search_text(keyword) # 开始搜索 try: if site.get('parser') == "TNodeSpider": error_flag, result = await TNodeSpider(site).async_search( keyword=search_word, page=page ) elif site.get('parser') == "TorrentLeech": error_flag, result = await TorrentLeech(site).async_search( keyword=search_word, page=page ) elif site.get('parser') == "mTorrent": error_flag, result = await MTorrentSpider(site).async_search( keyword=search_word, mtype=mtype, page=page ) elif site.get('parser') == "Yema": error_flag, result = await YemaSpider(site).async_search( keyword=search_word, mtype=mtype, page=page ) elif site.get('parser') == "Haidan": error_flag, result = await HaiDanSpider(site).async_search( keyword=search_word, mtype=mtype ) elif site.get('parser') == "HDDolby": error_flag, result = await HddolbySpider(site).async_search( keyword=search_word, mtype=mtype, page=page ) elif site.get('parser') == "RousiPro": error_flag, result = await RousiSpider(site).async_search( keyword=search_word, mtype=mtype, cat=cat, page=page ) else: error_flag, result = await self.__async_spider_search( search_word=search_word, indexer=site, mtype=mtype, cat=cat, page=page ) except Exception as err: logger.error(f"{site.get('name')} 搜索出错:{str(err)}") # 索引花费的时间 seconds = (datetime.now() - start_time).seconds # 统计索引情况 await self.__async_indexer_statistic(site=site, error_flag=error_flag, seconds=seconds) # 返回结果 return self.__parse_result( site=site, result_array=result, seconds=seconds ) @staticmethod def __spider_search(indexer: dict, search_word: Optional[str] = None, mtype: MediaType = None, cat: Optional[str] = None, page: Optional[int] = 0) -> Tuple[bool, List[dict]]: """ 根据关键字搜索单个站点 :param: indexer: 站点配置 :param: search_word: 关键字 :param: cat: 分类 :param: page: 页码 :param: mtype: 媒体类型 :param: timeout: 超时时间 :return: 是否发生错误, 种子列表 """ _spider = SiteSpider(indexer=indexer, keyword=search_word, mtype=mtype, cat=cat, page=page) try: return _spider.is_error, _spider.get_torrents() finally: del _spider @staticmethod async def __async_spider_search(indexer: dict, search_word: Optional[str] = None, mtype: MediaType = None, cat: Optional[str] = None, page: Optional[int] = 0) -> Tuple[bool, List[dict]]: """ 异步根据关键字搜索单个站点 :param: indexer: 站点配置 :param: search_word: 关键字 :param: cat: 分类 :param: page: 页码 :param: mtype: 媒体类型 :param: timeout: 超时时间 :return: 是否发生错误, 种子列表 """ _spider = SiteSpider(indexer=indexer, keyword=search_word, mtype=mtype, cat=cat, page=page) try: result = await _spider.async_get_torrents() return _spider.is_error, result finally: del _spider def refresh_torrents(self, site: dict, keyword: Optional[str] = None, cat: Optional[str] = None, page: Optional[int] = 0) -> Optional[List[TorrentInfo]]: """ 获取站点最新一页的种子,多个站点需要多线程处理 :param site: 站点 :param keyword: 关键字 :param cat: 分类 :param page: 页码 :reutrn: 种子资源列表 """ return self.search_torrents(site=site, keyword=keyword, cat=cat, page=page) async def async_refresh_torrents(self, site: dict, keyword: Optional[str] = None, cat: Optional[str] = None, page: Optional[int] = 0) -> Optional[List[TorrentInfo]]: """ 异步获取站点最新一页的种子,多个站点需要多线程处理 :param site: 站点 :param keyword: 关键字 :param cat: 分类 :param page: 页码 :reutrn: 种子资源列表 """ return await self.async_search_torrents(site=site, keyword=keyword, cat=cat, page=page) def refresh_userdata(self, site: dict) -> Optional[SiteUserData]: """ 刷新站点的用户数据 :param site: 站点 :return: 用户数据 """ def __get_site_obj() -> Optional[SiteParserBase]: """ 获取站点解析器 """ for site_schema in self._site_schemas: if site_schema.schema and site_schema.schema.value == site.get("schema"): return site_schema( site_name=site.get("name"), url=site.get("url"), site_cookie=site.get("cookie"), apikey=site.get("apikey"), token=site.get("token"), ua=site.get("ua"), proxy=site.get("proxy")) return None site_obj = __get_site_obj() if not site_obj: if not site.get("public"): logger.warn(f"站点 {site.get('name')} 未找到站点解析器,schema:{site.get('schema')}") return None # 获取用户数据 try: logger.info(f"站点 {site.get('name')} 开始以 {site.get('schema')} 模型解析数据...") site_obj.parse() logger.debug(f"站点 {site.get('name')} 数据解析完成") return SiteUserData( domain=StringUtils.get_url_domain(site.get("url")), userid=site_obj.userid, username=site_obj.username, user_level=site_obj.user_level, join_at=site_obj.join_at, upload=site_obj.upload, download=site_obj.download, ratio=site_obj.ratio, bonus=site_obj.bonus, seeding=site_obj.seeding, seeding_size=site_obj.seeding_size, seeding_info=site_obj.seeding_info.copy() if site_obj.seeding_info else [], leeching=site_obj.leeching, leeching_size=site_obj.leeching_size, message_unread=site_obj.message_unread, message_unread_contents=site_obj.message_unread_contents.copy() if site_obj.message_unread_contents else [], updated_day=datetime.now().strftime('%Y-%m-%d'), err_msg=site_obj.err_msg ) finally: site_obj.clear() ================================================ FILE: app/modules/indexer/parser/__init__.py ================================================ # -*- coding: utf-8 -*- import json import re from abc import ABCMeta, abstractmethod from enum import Enum from typing import Optional from urllib.parse import urljoin, urlsplit from requests import Session from app.core.config import settings from app.helper.cloudflare import under_challenge from app.log import logger from app.utils.http import RequestUtils from app.utils.site import SiteUtils # 站点框架 class SiteSchema(Enum): DiscuzX = "DiscuzX" Gazelle = "Gazelle" Ipt = "IPTorrents" NexusPhp = "NexusPhp" NexusProject = "NexusProject" NexusRabbit = "NexusRabbit" NexusHhanclub = "NexusHhanclub" NexusAudiences = "NexusAudiences" SmallHorse = "Small Horse" Unit3d = "Unit3d" TorrentLeech = "TorrentLeech" FileList = "FileList" TNode = "TNode" MTorrent = "MTorrent" Yema = "Yema" HDDolby = "HDDolby" Zhixing = "Zhixing" Bitpt = "Bitpt" RousiPro = "RousiPro" class SiteParserBase(metaclass=ABCMeta): # 站点模版 schema = None # 请求模式 cookie/apikey request_mode = "cookie" def __init__(self, site_name: str, url: str, site_cookie: str, apikey: str, token: str, session: Session = None, ua: Optional[str] = None, emulate: bool = False, proxy: bool = None): super().__init__() # 站点信息 self.apikey = apikey self.token = token self._site_name = site_name self._site_url = url __split_url = urlsplit(url) self._site_domain = __split_url.netloc self._base_url = f"{__split_url.scheme}://{__split_url.netloc}" self._site_cookie = site_cookie self._session = session if session else None self._ua = ua self._emulate = emulate self._proxy = proxy self._index_html = "" # 用户信息 self.username = None self.userid = None self.user_level = None self.join_at = None self.bonus = 0.0 # 流量信息 self.upload = 0 self.download = 0 self.ratio = 0 # 做种信息 self.seeding = 0 self.leeching = 0 self.seeding_size = 0 self.leeching_size = 0 self.uploaded = 0 self.completed = 0 self.incomplete = 0 self.uploaded_size = 0 self.completed_size = 0 self.incomplete_size = 0 # 做种人数, 种子大小 self.seeding_info = [] # 未读消息 self.message_unread = 0 self.message_unread_contents = [] self.message_read_force = False # 全局附加请求头 self._addition_headers = None # 用户基础信息页面 self._user_basic_page = None # 用户基础信息参数 self._user_basic_params = None # 用户基础信息请求头 self._user_basic_headers = None # 用户详情信息页面 self._user_detail_page = "userdetails.php?id=" # 用户详情信息参数 self._user_detail_params = None # 用户详情信息请求头 self._user_detail_headers = None # 用户流量信息页面 self._user_traffic_page = "index.php" # 用户流量信息参数 self._user_traffic_params = None # 用户流量信息请求头 self._user_traffic_headers = None # 用户未读消息页面 self._user_mail_unread_page = "messages.php?action=viewmailbox&box=1&unread=yes" # 系统未读消息页面 self._sys_mail_unread_page = "messages.php?action=viewmailbox&box=-2&unread=yes" # 未读消息数参数 self._mail_unread_params = None # 未读消息数请求头 self._mail_unread_headers = None # 未读消息内容参数 self._mail_content_params = None # 未读消息内容请求头 self._mail_content_headers = None # 用户做种信息页面 self._torrent_seeding_page = "getusertorrentlistajax.php?userid=" # 用户做种信息参数 self._torrent_seeding_params = None # 用户做种信息请求头 self._torrent_seeding_headers = None # 错误信息 self.err_msg = None def site_schema(self) -> SiteSchema: """ 站点解析模型 :return: 站点解析模型 """ return self.schema def parse(self): """ 解析站点信息 :return: """ try: # Cookie模式时,获取站点首页html if self.request_mode == "apikey": if not self.apikey and not self.token: logger.warn(f"{self._site_name} 未设置cookie 或 apikey/token,跳过后续操作") return self._index_html = {} else: # 检查是否已经登录 self._index_html = self._get_page_content(url=self._site_url) if not self._parse_logged_in(self._index_html): return # 解析站点页面 self._parse_site_page(self._index_html) # 解析用户基础信息 if self._user_basic_page: self._parse_user_base_info( self._get_page_content( url=urljoin(self._base_url, self._user_basic_page), params=self._user_basic_params, headers=self._user_basic_headers ) ) else: self._parse_user_base_info(self._index_html) # 解析用户详细信息 if self._user_detail_page: self._parse_user_detail_info( self._get_page_content( url=urljoin(self._base_url, self._user_detail_page), params=self._user_detail_params, headers=self._user_detail_headers ) ) # 解析用户未读消息 if settings.SITE_MESSAGE: self._pase_unread_msgs() # 解析用户上传、下载、分享率等信息 if self._user_traffic_page: self._parse_user_traffic_info( self._get_page_content( url=urljoin(self._base_url, self._user_traffic_page), params=self._user_traffic_params, headers=self._user_traffic_headers ) ) # 解析用户做种信息 self._parse_seeding_pages() finally: # 关闭连接 self.close() def _pase_unread_msgs(self): """ 解析所有未读消息标题和内容 :return: """ unread_msg_links = [] if self.message_unread > 0 or self.message_read_force: links = {self._user_mail_unread_page, self._sys_mail_unread_page} for link in links: if not link: continue msg_links = [] next_page = self._parse_message_unread_links( self._get_page_content( url=urljoin(self._base_url, link), params=self._mail_unread_params, headers=self._mail_unread_headers ), msg_links) while next_page: next_page = self._parse_message_unread_links( self._get_page_content( url=urljoin(self._base_url, next_page), params=self._mail_unread_params, headers=self._mail_unread_headers ), msg_links ) unread_msg_links.extend(msg_links) # 重新更新未读消息数(99999表示有消息但数量未知) if unread_msg_links and not self.message_unread: self.message_unread = len(unread_msg_links) # 解析未读消息内容 for msg_link in unread_msg_links: logger.debug(f"{self._site_name} 信息链接 {msg_link}") head, date, content = self._parse_message_content( self._get_page_content( urljoin(self._base_url, msg_link), params=self._mail_content_params, headers=self._mail_content_headers ) ) logger.debug(f"{self._site_name} 标题 {head} 时间 {date} 内容 {content}") self.message_unread_contents.append((head, date, content)) def _parse_seeding_pages(self): """ 解析做种页面 """ if self._torrent_seeding_page: # 第一页 next_page = self._parse_user_torrent_seeding_info( self._get_page_content( url=urljoin(self._base_url, self._torrent_seeding_page), params=self._torrent_seeding_params, headers=self._torrent_seeding_headers ) ) # 其他页处理 while next_page is not None and next_page is not False: next_page = self._parse_user_torrent_seeding_info( self._get_page_content( url=urljoin(urljoin(self._base_url, self._torrent_seeding_page), next_page), params=self._torrent_seeding_params, headers=self._torrent_seeding_headers ), multi_page=True) @staticmethod def _prepare_html_text(html_text): """ 处理掉HTML中的干扰部分 """ return re.sub(r"#\d+", "", re.sub(r"\d+px", "", html_text)) @abstractmethod def _parse_message_unread_links(self, html_text: str, msg_links: list) -> Optional[str]: """ 获取未阅读消息链接 :param html_text: :return: """ pass def _get_page_content(self, url: str, params: dict = None, headers: dict = None): """ 获取页面内容 :param url: 网页地址 :param params: post参数 :param headers: 额外的请求头 :return: """ req_headers = None proxies = settings.PROXY if self._proxy else None if self._ua or headers or self._addition_headers: if self.request_mode == "apikey": req_headers = {} else: req_headers = { "User-Agent": f"{self._ua}" } if headers: req_headers.update(headers) else: req_headers.update({ "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8", }) if self._addition_headers: req_headers.update(self._addition_headers) if self.request_mode == "apikey": # 使用apikey请求,通过请求头传递 cookie = None session = None else: # 使用cookie请求 cookie = self._site_cookie session = self._session if params: if req_headers.get("Content-Type") == "application/json": res = RequestUtils(cookies=cookie, session=session, timeout=60, proxies=proxies, headers=req_headers).post_res(url=url, json=params) else: res = RequestUtils(cookies=cookie, session=session, timeout=60, proxies=proxies, headers=req_headers).post_res(url=url, data=params) else: res = RequestUtils(cookies=cookie, session=session, timeout=60, proxies=proxies, headers=req_headers).get_res(url=url) if res is not None and res.status_code in (200, 500, 403): if req_headers and "application/json" in str(req_headers.get("Accept")): try: return json.dumps(res.json()) except (json.JSONDecodeError, ValueError) as e: logger.error(f"{self._site_name} API响应JSON解析失败: {e}") return "" else: # 如果cloudflare 有防护,尝试使用浏览器仿真 if under_challenge(res.text): logger.warn( f"{self._site_name} 检测到Cloudflare,请更新Cookie和UA") return "" return RequestUtils.get_decoded_html_content(res, settings.ENCODING_DETECTION_PERFORMANCE_MODE, settings.ENCODING_DETECTION_MIN_CONFIDENCE) return "" @abstractmethod def _parse_site_page(self, html_text: str): """ 解析站点相关信息页面 :param html_text: :return: """ pass @abstractmethod def _parse_user_base_info(self, html_text: str): """ 解析用户基础信息 :param html_text: :return: """ pass def _parse_logged_in(self, html_text): """ 解析用户是否已经登陆 :param html_text: :return: True/False """ logged_in = SiteUtils.is_logged_in(html_text) if not logged_in: self.err_msg = "未检测到已登陆,请检查cookies是否过期" logger.warn(f"{self._site_name} 未登录,跳过后续操作") return logged_in @abstractmethod def _parse_user_traffic_info(self, html_text: str): """ 解析用户的上传,下载,分享率等信息 :param html_text: :return: """ pass @abstractmethod def _parse_user_torrent_seeding_info(self, html_text: str, multi_page: bool = False) -> Optional[str]: """ 解析用户的做种相关信息 :param html_text: :param multi_page: 是否多页数据 :return: 下页地址 """ pass @abstractmethod def _parse_user_detail_info(self, html_text: str): """ 解析用户的详细信息 加入时间/等级/魔力值等 :param html_text: :return: """ pass @abstractmethod def _parse_message_content(self, html_text): """ 解析短消息内容 :param html_text: :return: head: message, date: time, content: message content """ pass def close(self): """ 关闭会话 """ if self._session: self._session.close() self._session = None def clear(self): """ 清除当前解析器的所有信息 """ self._index_html = "" self.seeding_info.clear() self.message_unread_contents.clear() def to_dict(self): """ 转化为字典 """ attributes = [ attr for attr in dir(self) if not callable(getattr(self, attr)) and not attr.startswith("_") ] return { attr: getattr(self, attr).value if isinstance(getattr(self, attr), SiteSchema) else getattr(self, attr) for attr in attributes } ================================================ FILE: app/modules/indexer/parser/bitpt.py ================================================ # # 极速之星 https://bitpt.cn/ # author: ThedoRap # time: 2025-10-02 # # -*- coding: utf-8 -*- import re from typing import Optional, Tuple from urllib.parse import urljoin, urlencode from bs4 import BeautifulSoup from app.modules.indexer.parser import SiteParserBase, SiteSchema from app.utils.string import StringUtils class BitptSiteUserInfo(SiteParserBase): schema = SiteSchema.Bitpt def _parse_site_page(self, html_text: str): self._user_basic_page = "userdetails.php?uid={uid}" self._user_detail_page = None self._user_basic_params = {} self._user_traffic_page = None self._sys_mail_unread_page = None self._user_mail_unread_page = None self._mail_unread_params = {} self._torrent_seeding_base = "browse.php" self._torrent_seeding_params = {"t": "myseed", "st": "2", "d": "desc"} self._torrent_seeding_headers = {} self._addition_headers = {} def _parse_logged_in(self, html_text): soup = BeautifulSoup(html_text, 'html.parser') return bool(soup.find(id='userinfotop')) def _parse_user_base_info(self, html_text: str): if not html_text: return None soup = BeautifulSoup(html_text, 'html.parser') table = soup.find('table', class_='frmtable') if not table: return rows = table.find_all('tr') info_dict = {} for row in rows: cells = row.find_all('td') if len(cells) == 2: key = cells[0].text.strip() value = cells[1].text.strip() info_dict[key] = value self.userid = info_dict.get('UID') self.username = info_dict.get('用户名').split('\xa0')[0] if '用户名' in info_dict else None self.user_level = info_dict.get('用户级别') if '用户级别' in info_dict else None self.join_at = StringUtils.unify_datetime_str(info_dict.get('注册时间')) if '注册时间' in info_dict else None self.upload = StringUtils.num_filesize(info_dict.get('上传流量')) if '上传流量' in info_dict else 0 self.download = StringUtils.num_filesize(info_dict.get('下载流量')) if '下载流量' in info_dict else 0 self.ratio = float(info_dict.get('共享率')) if '共享率' in info_dict else 0 bonus_str = info_dict.get('星辰', '') self.bonus = float(re.search(r'累计([\d\.]+)', bonus_str).group(1)) if re.search(r'累计([\d\.]+)', bonus_str) else 0 self.message_unread = 0 if hasattr(self, '_torrent_seeding_base') and self._torrent_seeding_base: self.seeding = 0 self.seeding_size = 0 else: seeding_info = soup.find('div', style="margin:0 auto;width:90%;font-size:14px;margin-top:10px;margin-bottom:10px;text-align:center;") if seeding_info: seeding_link = seeding_info.find_all('a')[1].text if len(seeding_info.find_all('a')) > 1 else '' match = re.search(r'当前上传的种子\((\d+)个, 共([\d\.]+ [KMGT]B)\)', seeding_link) if match: self.seeding = int(match.group(1)) self.seeding_size = StringUtils.num_filesize(match.group(2)) else: self.seeding = 0 self.seeding_size = 0 def _parse_user_traffic_info(self, html_text: str): pass def _parse_user_detail_info(self, html_text: str): pass def _parse_user_torrent_seeding_page_info(self, html_text: str) -> Tuple[int, int]: if not html_text: return 0, 0 soup = BeautifulSoup(html_text, 'html.parser') torrent_table = soup.find('table', class_='torrenttable') if not torrent_table: return 0, 0 rows = torrent_table.find_all('tr') if len(rows) <= 1: return 0, 0 torrents = [row for row in rows[1:] if 'btr' in row.get('class', [])] page_seeding = 0 page_seeding_size = 0 for torrent in torrents: size_td = torrent.find('td', class_='r') if size_td: size_a = size_td.find('a') size_text = size_a.text.strip() if size_a else size_td.text.strip() if size_text: page_seeding += 1 page_seeding_size += StringUtils.num_filesize(size_text) return page_seeding, page_seeding_size def _parse_message_unread_links(self, html_text: str, msg_links: list) -> Optional[str]: pass def _parse_message_content(self, html_text) -> Tuple[Optional[str], Optional[str], Optional[str]]: pass def _parse_user_torrent_seeding_info(self, html_text: str): pass def parse(self): super().parse() if self._index_html: soup = BeautifulSoup(self._index_html, 'html.parser') user_link = soup.find('a', href=re.compile(r'userdetails\.php\?uid=\d+')) if user_link: uid_match = re.search(r'uid=(\d+)', user_link['href']) if uid_match: self.userid = uid_match.group(1) if self.userid and self._user_basic_page: basic_url = self._user_basic_page.format(uid=self.userid) basic_html = self._get_page_content(url=urljoin(self._base_url, basic_url)) self._parse_user_base_info(basic_html) if hasattr(self, '_torrent_seeding_base') and self._torrent_seeding_base: seeding_base_url = urljoin(self._base_url, self._torrent_seeding_base) params = self._torrent_seeding_params.copy() page_num = 1 while True: params['p'] = page_num query_string = urlencode(params) full_url = f"{seeding_base_url}?{query_string}" seeding_html = self._get_page_content(url=full_url) page_seeding, page_seeding_size = self._parse_user_torrent_seeding_page_info(seeding_html) self.seeding += page_seeding self.seeding_size += page_seeding_size if page_seeding == 0: break page_num += 1 # 🔑 最终对外统一转字符串 self.userid = str(self.userid or "") self.username = str(self.username or "") self.user_level = str(self.user_level or "") self.join_at = str(self.join_at or "") self.upload = str(self.upload or 0) self.download = str(self.download or 0) self.ratio = str(self.ratio or 0) self.bonus = str(self.bonus or 0.0) self.message_unread = str(self.message_unread or 0) self.seeding = str(self.seeding or 0) self.seeding_size = str(self.seeding_size or 0) ================================================ FILE: app/modules/indexer/parser/discuz.py ================================================ # -*- coding: utf-8 -*- import re from typing import Optional from lxml import etree from app.modules.indexer.parser import SiteParserBase, SiteSchema from app.utils.string import StringUtils class DiscuzUserInfo(SiteParserBase): schema = SiteSchema.DiscuzX def _parse_user_base_info(self, html_text: str): html_text = self._prepare_html_text(html_text) html = etree.HTML(html_text) try: user_info = html.xpath('//a[contains(@href, "&uid=")]') if user_info: user_id_match = re.search(r"&uid=(\d+)", user_info[0].attrib['href']) if user_id_match and user_id_match.group().strip(): self.userid = user_id_match.group(1) self._torrent_seeding_page = f"forum.php?&mod=torrents&cat_5up=on" self._user_detail_page = user_info[0].attrib['href'] self.username = user_info[0].text.strip() finally: if html is not None: del html def _parse_site_page(self, html_text: str): pass def _parse_user_detail_info(self, html_text: str): """ 解析用户额外信息,加入时间,等级 :param html_text: :return: """ html = etree.HTML(html_text) try: if not StringUtils.is_valid_html_element(html): return None # 用户等级 user_levels_text = html.xpath('//a[contains(@href, "usergroup")]/text()') if user_levels_text: self.user_level = user_levels_text[-1].strip() # 加入日期 join_at_text = html.xpath('//li[em[text()="注册时间"]]/text()') if join_at_text: self.join_at = StringUtils.unify_datetime_str(join_at_text[0].strip()) # 分享率 ratio_text = html.xpath('//li[contains(.//text(), "分享率")]//text()') if ratio_text: ratio_match = re.search(r"\(([\d,.]+)\)", ratio_text[0]) if ratio_match and ratio_match.group(1).strip(): self.bonus = StringUtils.str_float(ratio_match.group(1)) # 积分 bouns_text = html.xpath('//li[em[text()="积分"]]/text()') if bouns_text: self.bonus = StringUtils.str_float(bouns_text[0].strip()) # 上传 upload_text = html.xpath('//li[em[contains(text(),"上传量")]]/text()') if upload_text: self.upload = StringUtils.num_filesize(upload_text[0].strip().split('/')[-1]) # 下载 download_text = html.xpath('//li[em[contains(text(),"下载量")]]/text()') if download_text: self.download = StringUtils.num_filesize(download_text[0].strip().split('/')[-1]) finally: if html is not None: del html def _parse_user_torrent_seeding_info(self, html_text: str, multi_page: bool = False) -> Optional[str]: """ 做种相关信息 :param html_text: :param multi_page: 是否多页数据 :return: 下页地址 """ html = etree.HTML(html_text) try: if not StringUtils.is_valid_html_element(html): return None size_col = 3 seeders_col = 4 # 搜索size列 if html.xpath('//tr[position()=1]/td[.//img[@class="size"] and .//img[@alt="size"]]'): size_col = len(html.xpath('//tr[position()=1]/td[.//img[@class="size"] ' 'and .//img[@alt="size"]]/preceding-sibling::td')) + 1 # 搜索seeders列 if html.xpath('//tr[position()=1]/td[.//img[@class="seeders"] and .//img[@alt="seeders"]]'): seeders_col = len(html.xpath('//tr[position()=1]/td[.//img[@class="seeders"] ' 'and .//img[@alt="seeders"]]/preceding-sibling::td')) + 1 page_seeding = 0 page_seeding_size = 0 page_seeding_info = [] seeding_sizes = html.xpath(f'//tr[position()>1]/td[{size_col}]') seeding_seeders = html.xpath(f'//tr[position()>1]/td[{seeders_col}]//text()') if seeding_sizes and seeding_seeders: page_seeding = len(seeding_sizes) for i in range(0, len(seeding_sizes)): size = StringUtils.num_filesize(seeding_sizes[i].xpath("string(.)").strip()) seeders = StringUtils.str_int(seeding_seeders[i]) page_seeding_size += size page_seeding_info.append([seeders, size]) self.seeding += page_seeding self.seeding_size += page_seeding_size self.seeding_info.extend(page_seeding_info) # 是否存在下页数据 next_page = None next_page_text = html.xpath('//a[contains(.//text(), "下一页") or contains(.//text(), "下一頁")]/@href') if next_page_text: next_page = next_page_text[-1].strip() finally: if html is not None: del html return next_page def _parse_user_traffic_info(self, html_text: str): pass def _parse_message_unread_links(self, html_text: str, msg_links: list) -> Optional[str]: return None def _parse_message_content(self, html_text): return None, None, None ================================================ FILE: app/modules/indexer/parser/file_list.py ================================================ # -*- coding: utf-8 -*- import re from typing import Optional from lxml import etree from app.modules.indexer.parser import SiteParserBase, SiteSchema from app.utils.string import StringUtils class FileListSiteUserInfo(SiteParserBase): schema = SiteSchema.FileList def _parse_site_page(self, html_text: str): html_text = self._prepare_html_text(html_text) user_detail = re.search(r"userdetails.php\?id=(\d+)", html_text) if user_detail and user_detail.group().strip(): self._user_detail_page = user_detail.group().strip().lstrip('/') self.userid = user_detail.group(1) self._torrent_seeding_page = f"snatchlist.php?id={self.userid}&action=torrents&type=seeding" def _parse_user_base_info(self, html_text: str): html_text = self._prepare_html_text(html_text) html = etree.HTML(html_text) try: ret = html.xpath(f'//a[contains(@href, "userdetails") and contains(@href, "{self.userid}")]//text()') if ret: self.username = str(ret[0]) finally: if html is not None: del html def _parse_user_traffic_info(self, html_text: str): """ 上传/下载/分享率 [做种数/魔力值] :param html_text: :return: """ return def _parse_user_detail_info(self, html_text: str): html_text = self._prepare_html_text(html_text) html = etree.HTML(html_text) try: upload_html = html.xpath('//table//tr/td[text()="Uploaded"]/following-sibling::td//text()') if upload_html: self.upload = StringUtils.num_filesize(upload_html[0]) download_html = html.xpath('//table//tr/td[text()="Downloaded"]/following-sibling::td//text()') if download_html: self.download = StringUtils.num_filesize(download_html[0]) ratio_html = html.xpath('//table//tr/td[text()="Share ratio"]/following-sibling::td//text()') if ratio_html: share_ratio = StringUtils.str_float(ratio_html[0]) else: share_ratio = 0 self.ratio = 0 if self.download == 0 else share_ratio seed_html = html.xpath('//table//tr/td[text()="Seed bonus"]/following-sibling::td//text()') if seed_html: self.seeding = StringUtils.str_int(seed_html[1]) self.seeding_size = StringUtils.num_filesize(seed_html[3]) user_level_html = html.xpath('//table//tr/td[text()="Class"]/following-sibling::td//text()') if user_level_html: self.user_level = user_level_html[0].strip() join_at_html = html.xpath('//table//tr/td[contains(text(), "Join")]/following-sibling::td//text()') if join_at_html: join_at = (join_at_html[0].split("("))[0].strip() self.join_at = StringUtils.unify_datetime_str(join_at) bonus_html = html.xpath('//a[contains(@href, "shop.php")]') if bonus_html: self.bonus = StringUtils.str_float(bonus_html[0].xpath("string(.)").strip()) finally: if html is not None: del html def _parse_user_torrent_seeding_info(self, html_text: str, multi_page: Optional[bool] = False) -> Optional[str]: """ 做种相关信息 :param html_text: :param multi_page: 是否多页数据 :return: 下页地址 """ html = etree.HTML(html_text) try: if not StringUtils.is_valid_html_element(html): return None size_col = 6 seeders_col = 7 page_seeding_size = 0 page_seeding_info = [] seeding_sizes = html.xpath(f'//table/tr[position()>1]/td[{size_col}]') seeding_seeders = html.xpath(f'//table/tr[position()>1]/td[{seeders_col}]') if seeding_sizes and seeding_seeders: for i in range(0, len(seeding_sizes)): size = StringUtils.num_filesize(seeding_sizes[i].xpath("string(.)").strip()) seeders = StringUtils.str_int(seeding_seeders[i].xpath("string(.)").strip()) page_seeding_size += size page_seeding_info.append([seeders, size]) self.seeding_info.extend(page_seeding_info) # 是否存在下页数据 next_page = None finally: if html is not None: del html return next_page def _parse_message_unread_links(self, html_text: str, msg_links: list) -> Optional[str]: return None def _parse_message_content(self, html_text): return None, None, None ================================================ FILE: app/modules/indexer/parser/gazelle.py ================================================ # -*- coding: utf-8 -*- import re from typing import Optional from lxml import etree from app.modules.indexer.parser import SiteParserBase, SiteSchema from app.utils.string import StringUtils class GazelleSiteUserInfo(SiteParserBase): schema = SiteSchema.Gazelle def _parse_user_base_info(self, html_text: str): html_text = self._prepare_html_text(html_text) html = etree.HTML(html_text) try: tmps = html.xpath('//a[contains(@href, "user.php?id=") or contains(@href, "user?id=")]') if tmps: user_id_match = re.search(r"user(?:\.php)?\?id=(\d+)", tmps[0].attrib['href']) if user_id_match and user_id_match.group().strip(): self.userid = user_id_match.group(1) self._torrent_seeding_page = f"torrents.php?type=seeding&userid={self.userid}" self._user_detail_page = f"user.php?id={self.userid}" self.username = tmps[0].text.strip() tmps = html.xpath('//*[@id="header-uploaded-value"]/@data-value') if tmps: self.upload = StringUtils.num_filesize(tmps[0]) else: tmps = html.xpath('//li[@id="stats_seeding"]/span/text()') if tmps: self.upload = StringUtils.num_filesize(tmps[0]) tmps = html.xpath('//*[@id="header-downloaded-value"]/@data-value') if tmps: self.download = StringUtils.num_filesize(tmps[0]) else: tmps = html.xpath('//li[@id="stats_leeching"]/span/text()') if tmps: self.download = StringUtils.num_filesize(tmps[0]) self.ratio = 0.0 if self.download <= 0.0 else round(self.upload / self.download, 3) tmps = html.xpath('//a[contains(@href, "bonus")]/@data-tooltip') if tmps: bonus_match = re.search(r"([\d,.]+)", tmps[0]) if bonus_match and bonus_match.group(1).strip(): self.bonus = StringUtils.str_float(bonus_match.group(1)) else: tmps = html.xpath('//a[contains(@href, "bonus")]') if tmps: bonus_text = tmps[0].xpath("string(.)") bonus_match = re.search(r"([\d,.]+)", bonus_text) if bonus_match and bonus_match.group(1).strip(): self.bonus = StringUtils.str_float(bonus_match.group(1)) finally: if html is not None: del html def _parse_site_page(self, html_text: str): pass def _parse_user_detail_info(self, html_text: str): """ 解析用户额外信息,加入时间,等级 :param html_text: :return: """ html = etree.HTML(html_text) try: if not StringUtils.is_valid_html_element(html): return None # 用户等级 user_levels_text = html.xpath('//*[@id="class-value"]/@data-value') if user_levels_text: self.user_level = user_levels_text[0].strip() else: user_levels_text = html.xpath('//li[contains(text(), "用户等级")]/text()') if user_levels_text: self.user_level = user_levels_text[0].split(':')[1].strip() # 加入日期 join_at_text = html.xpath('//*[@id="join-date-value"]/@data-value') if join_at_text: self.join_at = StringUtils.unify_datetime_str(join_at_text[0].strip()) else: join_at_text = html.xpath( '//div[contains(@class, "box_userinfo_stats")]//li[contains(text(), "加入时间")]/span/text()') if join_at_text: self.join_at = StringUtils.unify_datetime_str(join_at_text[0].strip()) finally: if html is not None: del html def _parse_user_torrent_seeding_info(self, html_text: str, multi_page: Optional[bool] = False) -> Optional[str]: """ 做种相关信息 :param html_text: :param multi_page: 是否多页数据 :return: 下页地址 """ html = etree.HTML(html_text) try: if not StringUtils.is_valid_html_element(html): return None size_col = 3 # 搜索size列 if html.xpath('//table[contains(@id, "torrent")]//tr[1]/td'): size_col = len(html.xpath('//table[contains(@id, "torrent")]//tr[1]/td')) - 3 # 搜索seeders列 seeders_col = size_col + 2 page_seeding = 0 page_seeding_size = 0 page_seeding_info = [] seeding_sizes = html.xpath(f'//table[contains(@id, "torrent")]//tr[position()>1]/td[{size_col}]') seeding_seeders = html.xpath(f'//table[contains(@id, "torrent")]//tr[position()>1]/td[{seeders_col}]/text()') if seeding_sizes and seeding_seeders: page_seeding = len(seeding_sizes) for i in range(0, len(seeding_sizes)): size = StringUtils.num_filesize(seeding_sizes[i].xpath("string(.)").strip()) seeders = int(seeding_seeders[i]) page_seeding_size += size page_seeding_info.append([seeders, size]) if multi_page: self.seeding += page_seeding self.seeding_size += page_seeding_size self.seeding_info.extend(page_seeding_info) else: if not self.seeding: self.seeding = page_seeding if not self.seeding_size: self.seeding_size = page_seeding_size if not self.seeding_info: self.seeding_info = page_seeding_info # 是否存在下页数据 next_page = None next_page_text = html.xpath('//a[contains(.//text(), "Next") or contains(.//text(), "下一页") or contains(@title, "下一页") or contains(@title, "Next")]/@href') if next_page_text: next_page = next_page_text[-1].strip() finally: if html is not None: del html return next_page def _parse_user_traffic_info(self, html_text: str): pass def _parse_message_unread_links(self, html_text: str, msg_links: list) -> Optional[str]: return None def _parse_message_content(self, html_text): return None, None, None ================================================ FILE: app/modules/indexer/parser/hddolby.py ================================================ # -*- coding: utf-8 -*- import json from typing import Optional, Tuple from app.modules.indexer.parser import SiteParserBase, SiteSchema from app.utils.string import StringUtils class HDDolbySiteUserInfo(SiteParserBase): schema = SiteSchema.HDDolby request_mode = "apikey" # 用户级别字典 HDDolby_sysRoleList = { "0": "Peasant", "1": "User", "2": "Power User", "3": "Elite User", "4": "Crazy User", "5": "Insane User", "6": "Veteran User", "7": "Extreme User", "8": "Ultimate User", "9": "Nexus Master", "10": "VIP", "11": "Retiree", "12": "Helper", "13": "Seeder", "14": "Transferrer", "15": "Uploader", "16": "Torrent Manager", "17": "Forum Moderator", "18": "Coder", "19": "Moderator", "20": "Administrator", "21": "Sysop", "22": "Staff Leader", } def _parse_site_page(self, html_text: str): """ 获取站点页面地址 """ # 更换api地址 self._base_url = f"https://api.{StringUtils.get_url_domain(self._base_url)}" self._user_traffic_page = None self._user_detail_page = None self._user_basic_page = "api/v1/user/data" self._user_basic_params = {} self._user_basic_headers = { "Content-Type": "application/json", "Accept": "application/json, text/plain, */*" } self._sys_mail_unread_page = None self._user_mail_unread_page = None self._mail_unread_params = {} self._torrent_seeding_page = "api/v1/user/peers" self._torrent_seeding_params = {} self._torrent_seeding_headers = { "Content-Type": "application/json", "Accept": "application/json, text/plain, */*" } self._addition_headers = { "x-api-key": self.apikey, } def _parse_logged_in(self, html_text): """ 判断是否登录成功, 通过判断是否存在用户信息 暂时跳过检测,待后续优化 :param html_text: :return: """ return True def _parse_user_base_info(self, html_text: str): """ 解析用户基本信息,这里把_parse_user_traffic_info和_parse_user_detail_info合并到这里 """ if not html_text: return None detail = json.loads(html_text) if not detail or detail.get("status") != 0: return user_infos = detail.get("data") """ { "id": "1", "added": "2019-03-03 15:30:36", "last_access": "2025-02-18 19:48:04", "class": "22", "uploaded": "852071699418375", "downloaded": "1885536536176", "seedbonus": "99774808.0", "sebonus": "3739023.7", "unread_messages": "0", } """ if not user_infos: return user_info = user_infos[0] self.userid = user_info.get("id") self.username = user_info.get("username") self.user_level = self.HDDolby_sysRoleList.get(user_info.get("class") or "1") self.join_at = user_info.get("added") self.upload = int(user_info.get("uploaded") or '0') self.download = int(user_info.get("downloaded") or '0') self.ratio = round(self.upload / self.download, 2) if self.download else 0 self.bonus = float(user_info.get("seedbonus") or "0") self.message_unread = int(user_info.get("unread_messages") or '0') def _parse_user_traffic_info(self, html_text: str): """ 解析用户流量信息 """ pass def _parse_user_detail_info(self, html_text: str): """ 解析用户详细信息 """ pass def _parse_user_torrent_seeding_info(self, html_text: str, multi_page: Optional[bool] = False) -> Optional[str]: """ 解析用户做种信息 """ if not html_text: return None seeding_info = json.loads(html_text) if not seeding_info or seeding_info.get("status") != 0: return None torrents = seeding_info.get("data", []) page_seeding_size = 0 page_seeding_info = [] for info in torrents: size = info.get("size") seeder = info.get("seeders") or 1 page_seeding_size += size page_seeding_info.append([seeder, size]) self.seeding += len(torrents) self.seeding_size += page_seeding_size self.seeding_info.extend(page_seeding_info) return None def _parse_message_unread_links(self, html_text: str, msg_links: list) -> Optional[str]: """ 解析未读消息链接,这里直接读出详情 """ pass def _parse_message_content(self, html_text) -> Tuple[Optional[str], Optional[str], Optional[str]]: """ 解析消息内容 """ pass ================================================ FILE: app/modules/indexer/parser/ipt_project.py ================================================ # -*- coding: utf-8 -*- import re from typing import Optional from lxml import etree from app.modules.indexer.parser import SiteParserBase, SiteSchema from app.utils.string import StringUtils class IptSiteUserInfo(SiteParserBase): schema = SiteSchema.Ipt def _parse_user_base_info(self, html_text: str): html_text = self._prepare_html_text(html_text) html = etree.HTML(html_text) try: tmps = html.xpath('//a[contains(@href, "/u/")]//text()') tmps_id = html.xpath('//a[contains(@href, "/u/")]/@href') if tmps: self.username = str(tmps[-1]) if tmps_id: user_id_match = re.search(r"/u/(\d+)", tmps_id[0]) if user_id_match and user_id_match.group().strip(): self.userid = user_id_match.group(1) self._user_detail_page = f"user.php?u={self.userid}" self._torrent_seeding_page = f"peers?u={self.userid}" tmps = html.xpath('//div[@class = "stats"]/div/div') if tmps: self.upload = StringUtils.num_filesize(str(tmps[0].xpath('span/text()')[1]).strip()) self.download = StringUtils.num_filesize(str(tmps[0].xpath('span/text()')[2]).strip()) self.seeding = StringUtils.str_int(tmps[0].xpath('a')[2].xpath('text()')[0]) self.leeching = StringUtils.str_int(tmps[0].xpath('a')[2].xpath('text()')[1]) self.ratio = StringUtils.str_float(str(tmps[0].xpath('span/text()')[0]).strip().replace('-', '0')) self.bonus = StringUtils.str_float(tmps[0].xpath('a')[3].xpath('text()')[0]) finally: if html is not None: del html def _parse_site_page(self, html_text: str): pass def _parse_user_detail_info(self, html_text: str): html = etree.HTML(html_text) try: if not StringUtils.is_valid_html_element(html): return user_levels_text = html.xpath('//tr/th[text()="Class"]/following-sibling::td[1]/text()') if user_levels_text: self.user_level = user_levels_text[0].strip() # 加入日期 join_at_text = html.xpath('//tr/th[text()="Join date"]/following-sibling::td[1]/text()') if join_at_text: self.join_at = StringUtils.unify_datetime_str(join_at_text[0].split(' (')[0]) finally: if html is not None: del html def _parse_user_torrent_seeding_info(self, html_text: str, multi_page: bool = False) -> Optional[str]: html = etree.HTML(html_text) try: if not StringUtils.is_valid_html_element(html): return None # seeding start seeding_end_pos = 3 if html.xpath('//tr/td[text() = "Leechers"]'): seeding_end_pos = len(html.xpath('//tr/td[text() = "Leechers"]/../preceding-sibling::tr')) + 1 seeding_end_pos = seeding_end_pos - 3 page_seeding = 0 page_seeding_size = 0 seeding_torrents = html.xpath('//tr/td[text() = "Seeders"]/../following-sibling::tr/td[position()=6]/text()') if seeding_torrents: page_seeding = seeding_end_pos for per_size in seeding_torrents[:seeding_end_pos]: if '(' in per_size and ')' in per_size: per_size = per_size.split('(')[-1] per_size = per_size.split(')')[0] page_seeding_size += StringUtils.num_filesize(per_size) self.seeding = page_seeding self.seeding_size = page_seeding_size finally: if html is not None: del html def _parse_user_traffic_info(self, html_text: str): pass def _parse_message_unread_links(self, html_text: str, msg_links: list) -> Optional[str]: return None def _parse_message_content(self, html_text): return None, None, None ================================================ FILE: app/modules/indexer/parser/mtorrent.py ================================================ # -*- coding: utf-8 -*- import json from typing import Optional, Tuple from urllib.parse import urljoin from app.log import logger from app.modules.indexer.parser import SiteParserBase, SiteSchema from app.utils.string import StringUtils class MTorrentSiteUserInfo(SiteParserBase): schema = SiteSchema.MTorrent request_mode = "apikey" # 用户级别字典 MTeam_sysRoleList = { "1": "User", "2": "Power User", "3": "Elite User", "4": "Crazy User", "5": "Insane User", "6": "Veteran User", "7": "Extreme User", "8": "Ultimate User", "9": "Nexus Master", "10": "VIP", "11": "Retiree", "12": "Uploader", "13": "Moderator", "14": "Administrator", "15": "Sysop", "16": "Staff", "17": "Offer memberStaff", "18": "Bet memberStaff", } def _parse_site_page(self, html_text: str): """ 获取站点页面地址 """ # 更换api地址 self._base_url = f"https://api.{StringUtils.get_url_domain(self._base_url)}" self._user_traffic_page = None self._user_detail_page = None self._user_basic_page = "api/member/profile" self._user_basic_params = { "uid": self.userid } self._sys_mail_unread_page = None self._user_mail_unread_page = "api/msg/search" self._mail_unread_params = { "keyword": "", "box": "-2", "type": "pageNumber", "pageSize": 100 } self._torrent_seeding_page = "api/member/getUserTorrentList" self._torrent_seeding_headers = { "Content-Type": "application/json", "Accept": "application/json, text/plain, */*" } self._addition_headers = { "x-api-key": self.apikey, } def _parse_logged_in(self, html_text): """ 判断是否登录成功, 通过判断是否存在用户信息 暂时跳过检测,待后续优化 :param html_text: :return: """ return True def _parse_user_base_info(self, html_text: str): """ 解析用户基本信息,这里把_parse_user_traffic_info和_parse_user_detail_info合并到这里 """ if not html_text: return None detail = json.loads(html_text) if not detail or detail.get("code") != "0": return user_info = detail.get("data", {}) self.userid = user_info.get("id") self.username = user_info.get("username") self.user_level = self.MTeam_sysRoleList.get(user_info.get("role") or "1") self.join_at = user_info.get("memberStatus", {}).get("createdDate") self.upload = int(user_info.get("memberCount", {}).get("uploaded") or '0') self.download = int(user_info.get("memberCount", {}).get("downloaded") or '0') self.ratio = user_info.get("memberCount", {}).get("shareRate") or 0 self.bonus = user_info.get("memberCount", {}).get("bonus") or 0 self.message_read_force = True self._torrent_seeding_params = { "pageNumber": 1, "pageSize": 200, "type": "SEEDING", "userid": self.userid } def _parse_user_traffic_info(self, html_text: str): """ 解析用户流量信息 """ pass def _parse_user_detail_info(self, html_text: str): """ 解析用户详细信息 """ pass def _parse_user_torrent_seeding_info(self, html_text: str, multi_page: Optional[bool] = False) -> Optional[str]: """ 解析用户做种信息 """ if not html_text: return None seeding_info = json.loads(html_text) if not seeding_info or seeding_info.get("code") != "0": return None torrents = seeding_info.get("data", {}).get("data", []) page_seeding_size = 0 page_seeding_info = [] for info in torrents: torrent = info.get("torrent", {}) size = int(torrent.get("size") or '0') seeders = int(torrent.get("source") or '0') page_seeding_size += size page_seeding_info.append([seeders, size]) self.seeding += len(torrents) self.seeding_size += page_seeding_size self.seeding_info.extend(page_seeding_info) # 查询总做种数 seeder_count = 0 try: result = self._get_page_content( url=urljoin(self._base_url, "api/tracker/myPeerStatus"), params={"uid": self.userid}, ) if result: seeder_info = json.loads(result) seeder_count = int(seeder_info.get("data", {}).get("seeder") or 0) except Exception as e: logger.error(f"获取做种数失败: {str(e)}") if not seeder_count: return None if self.seeding >= seeder_count: return None # 还有下一页 self._torrent_seeding_params["pageNumber"] += 1 return "" def _parse_message_unread_links(self, html_text: str, msg_links: list) -> Optional[str]: """ 解析未读消息链接,这里直接读出详情 """ if not html_text: return None messages_info = json.loads(html_text) if not messages_info or messages_info.get("code") != "0": return None messages = messages_info.get("data", {}).get("data", []) for message in messages: if not message.get("unread"): continue head = message.get("title") date = message.get("createdDate") content = message.get("context") if head and date and content: self.message_unread_contents.append((head, date, content)) # 设置已读 self._get_page_content( url=urljoin(self._base_url, f"api/msg/markRead"), params={"msgId": message.get("id")} ) # 是否存在下页数据 return None def _parse_message_content(self, html_text) -> Tuple[Optional[str], Optional[str], Optional[str]]: """ 解析消息内容 """ pass ================================================ FILE: app/modules/indexer/parser/nexus_audiences.py ================================================ # -*- coding: utf-8 -*- from urllib.parse import urljoin from lxml import etree from app.modules.indexer.parser import SiteSchema from app.modules.indexer.parser.nexus_php import NexusPhpSiteUserInfo from app.utils.string import StringUtils class NexusAudiencesSiteUserInfo(NexusPhpSiteUserInfo): schema = SiteSchema.NexusAudiences def _parse_seeding_pages(self): if not self._torrent_seeding_page: return self._torrent_seeding_headers = {"Referer": urljoin(self._base_url, self._user_detail_page)} html_text = self._get_page_content( url=urljoin(self._base_url, self._torrent_seeding_page), params=self._torrent_seeding_params, headers=self._torrent_seeding_headers ) if not html_text: return html = etree.HTML(html_text) try: if not StringUtils.is_valid_html_element(html): return total_row = html.xpath('//table[@class="table table-bordered"]//tr[td[1][normalize-space()="Total"]]') if not total_row: return seeding_count = total_row[0].xpath('./td[2]/text()') seeding_size = total_row[0].xpath('./td[3]/text()') self.seeding = StringUtils.str_int(seeding_count[0]) if seeding_count else 0 self.seeding_size = StringUtils.num_filesize(seeding_size[0].strip()) if seeding_size else 0 finally: if html is not None: del html ================================================ FILE: app/modules/indexer/parser/nexus_hhanclub.py ================================================ # -*- coding: utf-8 -*- import re from lxml import etree from app.modules.indexer.parser import SiteSchema from app.modules.indexer.parser.nexus_php import NexusPhpSiteUserInfo from app.utils.string import StringUtils class NexusHhanclubSiteUserInfo(NexusPhpSiteUserInfo): schema = SiteSchema.NexusHhanclub def _parse_user_traffic_info(self, html_text): super()._parse_user_traffic_info(html_text) html_text = self._prepare_html_text(html_text) html = etree.HTML(html_text) try: # 上传、下载、分享率 upload_match = re.search(r"[_<>/a-zA-Z-=\"'\s#;]+([\d,.\s]+[KMGTPI]*B)", html.xpath('//*[@id="user-info-panel"]/div[2]/div[2]/div[4]/text()')[0]) download_match = re.search(r"[_<>/a-zA-Z-=\"'\s#;]+([\d,.\s]+[KMGTPI]*B)", html.xpath('//*[@id="user-info-panel"]/div[2]/div[2]/div[5]/text()')[0]) ratio_match = re.search(r"分享率][::_<>/a-zA-Z-=\"'\s#;]+([\d,.\s]+)", html.xpath('//*[@id="user-info-panel"]/div[2]/div[1]/div[1]/div/text()')[0]) # 计算分享率 self.upload = StringUtils.num_filesize(upload_match.group(1).strip()) if upload_match else 0 self.download = StringUtils.num_filesize(download_match.group(1).strip()) if download_match else 0 # 优先使用页面上的分享率 calc_ratio = 0.0 if self.download <= 0.0 else round(self.upload / self.download, 3) self.ratio = StringUtils.str_float(ratio_match.group(1)) if ( ratio_match and ratio_match.group(1).strip()) else calc_ratio finally: if html is not None: del html def _parse_user_detail_info(self, html_text: str): """ 解析用户额外信息,加入时间,等级 :param html_text: :return: """ super()._parse_user_detail_info(html_text) html = etree.HTML(html_text) try: if not StringUtils.is_valid_html_element(html): return # 加入时间 join_at_text = html.xpath('//span[contains(text(), "加入日期")]/following-sibling::span/span/@title') if join_at_text: self.join_at = StringUtils.unify_datetime_str(join_at_text[0].strip()) finally: if html is not None: del html def _get_user_level(self, html): super()._get_user_level(html) user_level_path = html.xpath('//b[contains(@class, "_Name")]/text()') if user_level_path: self.user_level = user_level_path[0] ================================================ FILE: app/modules/indexer/parser/nexus_php.py ================================================ # -*- coding: utf-8 -*- import re from typing import Optional from lxml import etree from app.log import logger from app.modules.indexer.parser import SiteParserBase, SiteSchema from app.utils.string import StringUtils class NexusPhpSiteUserInfo(SiteParserBase): schema = SiteSchema.NexusPhp def _parse_site_page(self, html_text: str): html_text = self._prepare_html_text(html_text) user_detail = re.search(r"userdetails.php\?id=(\d+)", html_text) if user_detail and user_detail.group().strip(): self._user_detail_page = user_detail.group().strip().lstrip('/') self.userid = user_detail.group(1) self._torrent_seeding_page = f"getusertorrentlistajax.php?userid={self.userid}&type=seeding" else: user_detail = re.search(r"(userdetails)", html_text) if user_detail and user_detail.group().strip(): self._user_detail_page = user_detail.group().strip().lstrip('/') self.userid = None self._torrent_seeding_page = None def _parse_message_unread(self, html_text): """ 解析未读短消息数量 :param html_text: :return: """ html = etree.HTML(html_text) try: if not StringUtils.is_valid_html_element(html): return message_labels = html.xpath('//a[@href="messages.php"]/..') message_labels.extend(html.xpath('//a[contains(@href, "messages.php")]/..')) if message_labels: message_text = message_labels[0].xpath("string(.)") logger.debug(f"{self._site_name} 消息原始信息 {message_text}") message_unread_match = re.findall(r"[^Date](信息箱\s*|\((?![^)]*:)|你有\xa0)(\d+)", message_text) if message_unread_match and len(message_unread_match[-1]) == 2: self.message_unread = StringUtils.str_int(message_unread_match[-1][1]) elif message_text.isdigit(): self.message_unread = StringUtils.str_int(message_text) finally: if html is not None: del html def _parse_user_base_info(self, html_text: str): """ 解析用户基本信息 """ # 合并解析,减少额外请求调用 self._parse_user_traffic_info(html_text) self._user_traffic_page = None self._parse_message_unread(html_text) html = etree.HTML(html_text) try: if not StringUtils.is_valid_html_element(html): return ret = html.xpath(f'//a[contains(@href, "userdetails") and contains(@href, "{self.userid}")]//b//text()') if ret: self.username = str(ret[0]) return ret = html.xpath(f'//a[contains(@href, "userdetails") and contains(@href, "{self.userid}")]//text()') if ret: self.username = str(ret[0]) ret = html.xpath('//a[contains(@href, "userdetails")]//strong//text()') finally: if html is not None: del html if ret: self.username = str(ret[0]) return def _parse_user_traffic_info(self, html_text): """ 解析用户流量信息 """ html_text = self._prepare_html_text(html_text) upload_match = re.search(r"[^总]上[传傳]量?[::_<>/a-zA-Z-=\"'\s#;]+([\d,.\s]+[KMGTPI]*B)", html_text, re.IGNORECASE) self.upload = StringUtils.num_filesize(upload_match.group(1).strip()) if upload_match else 0 download_match = re.search(r"[^总子影力]下[载載]量?[::_<>/a-zA-Z-=\"'\s#;]+([\d,.\s]+[KMGTPI]*B)", html_text, re.IGNORECASE) self.download = StringUtils.num_filesize(download_match.group(1).strip()) if download_match else 0 ratio_match = re.search(r"分享率[::_<>/a-zA-Z-=\"'\s#;]+([\d,.\s]+)", html_text) # 计算分享率 calc_ratio = 0.0 if self.download <= 0.0 else round(self.upload / self.download, 3) # 优先使用页面上的分享率 self.ratio = StringUtils.str_float(ratio_match.group(1)) if ( ratio_match and ratio_match.group(1).strip()) else calc_ratio leeching_match = re.search(r"(Torrents leeching|下载中)[\u4E00-\u9FA5\D\s]+(\d+)[\s\S]+<", html_text) self.leeching = StringUtils.str_int(leeching_match.group(2)) if leeching_match and leeching_match.group( 2).strip() else 0 html = etree.HTML(html_text) try: has_ucoin, self.bonus = self._parse_ucoin(html) if has_ucoin: return tmps = html.xpath('//a[contains(@href,"mybonus")]/text()') if html else None if tmps: bonus_text = str(tmps[0]).strip() bonus_match = re.search(r"([\d,.]+)", bonus_text) if bonus_match and bonus_match.group(1).strip(): self.bonus = StringUtils.str_float(bonus_match.group(1)) return bonus_match = re.search(r"mybonus.[\[\]::<>/a-zA-Z_\-=\"'\s#;.(使用魔力值豆]+\s*([\d,.]+)[<()&\s]", html_text) try: if bonus_match and bonus_match.group(1).strip(): self.bonus = StringUtils.str_float(bonus_match.group(1)) return bonus_match = re.search(r"[魔力值|\]][\[\]::<>/a-zA-Z_\-=\"'\s#;]+\s*([\d,.]+|\"[\d,.]+\")[<>()&\s]", html_text, flags=re.S) if bonus_match and bonus_match.group(1).strip(): self.bonus = StringUtils.str_float(bonus_match.group(1).strip('"')) except Exception as err: logger.error(f"{self._site_name} 解析魔力值出错, 错误信息: {str(err)}") finally: if html is not None: del html @staticmethod def _parse_ucoin(html): """ 解析ucoin, 统一转换为铜币 :param html: :return: """ if StringUtils.is_valid_html_element(html): gold, silver, copper = None, None, None golds = html.xpath('//span[@class = "ucoin-symbol ucoin-gold"]//text()') if golds: gold = StringUtils.str_float(str(golds[-1])) silvers = html.xpath('//span[@class = "ucoin-symbol ucoin-silver"]//text()') if silvers: silver = StringUtils.str_float(str(silvers[-1])) coppers = html.xpath('//span[@class = "ucoin-symbol ucoin-copper"]//text()') if coppers: copper = StringUtils.str_float(str(coppers[-1])) if gold or silver or copper: gold = gold if gold else 0 silver = silver if silver else 0 copper = copper if copper else 0 return True, gold * 100 * 100 + silver * 100 + copper return False, 0.0 def _parse_user_torrent_seeding_info(self, html_text: str, multi_page: Optional[bool] = False) -> Optional[str]: """ 做种相关信息 :param html_text: :param multi_page: 是否多页数据 :return: 下页地址 """ html = etree.HTML(str(html_text).replace(r'\/', '/')) try: if not StringUtils.is_valid_html_element(html): return None # 首页存在扩展链接,使用扩展链接 seeding_url_text = html.xpath('//a[contains(@href,"torrents.php") ' 'and contains(@href,"seeding")]/@href') if multi_page is False and seeding_url_text and seeding_url_text[0].strip(): self._torrent_seeding_page = seeding_url_text[0].strip() return self._torrent_seeding_page size_col = 3 seeders_col = 4 # 搜索size列 size_col_xpath = '//tr[position()=1]/' \ 'td[(img[@class="size"] and img[@alt="size"])' \ ' or (text() = "大小")' \ ' or (a/img[@class="size" and @alt="size"])]' if html.xpath(size_col_xpath): size_col = len(html.xpath(f'{size_col_xpath}/preceding-sibling::td')) + 1 # 搜索seeders列 seeders_col_xpath = '//tr[position()=1]/' \ 'td[(img[@class="seeders"] and img[@alt="seeders"])' \ ' or (text() = "在做种")' \ ' or (a/img[@class="seeders" and @alt="seeders"])]' if html.xpath(seeders_col_xpath): seeders_col = len(html.xpath(f'{seeders_col_xpath}/preceding-sibling::td')) + 1 page_seeding = 0 page_seeding_size = 0 page_seeding_info = [] # 如果 table class="torrents",则增加table[@class="torrents"] table_class = '//table[@class="torrents"]' if html.xpath('//table[@class="torrents"]') else '' seeding_sizes = html.xpath(f'{table_class}//tr[position()>1]/td[{size_col}]') seeding_seeders = html.xpath(f'{table_class}//tr[position()>1]/td[{seeders_col}]/b/a/text()') if not seeding_seeders: seeding_seeders = html.xpath(f'{table_class}//tr[position()>1]/td[{seeders_col}]//text()') if seeding_sizes and seeding_seeders: page_seeding = len(seeding_sizes) for i in range(0, len(seeding_sizes)): size = StringUtils.num_filesize(seeding_sizes[i].xpath("string(.)").strip()) seeders = StringUtils.str_int(seeding_seeders[i]) page_seeding_size += size page_seeding_info.append([seeders, size]) self.seeding += page_seeding self.seeding_size += page_seeding_size self.seeding_info.extend(page_seeding_info) # 是否存在下页数据 next_page = None next_page_text = html.xpath( '//a[contains(.//text(), "下一页") or contains(.//text(), "下一頁") or contains(.//text(), ">")]/@href') # 防止识别到详情页 while next_page_text: next_page = next_page_text.pop().strip() if not next_page.startswith('details.php'): break next_page = None # fix up page url if next_page: if self.userid not in next_page: next_page = f'{next_page}&userid={self.userid}&type=seeding' finally: if html is not None: del html return next_page def _parse_user_detail_info(self, html_text: str): """ 解析用户额外信息,加入时间,等级 :param html_text: :return: """ html = etree.HTML(html_text) try: if not StringUtils.is_valid_html_element(html): return self._get_user_level(html) self._fixup_traffic_info(html) # 加入日期 join_at_text = html.xpath( '//tr/td[text()="加入日期" or text()="注册日期" or *[text()="加入日期"]]/following-sibling::td[1]//text()' '|//div/b[text()="加入日期"]/../text()') if join_at_text: self.join_at = StringUtils.unify_datetime_str(join_at_text[0].split(' (')[0].strip()) # 做种体积 & 做种数 # seeding 页面获取不到的话,此处再获取一次 seeding_sizes = html.xpath('//tr/td[text()="当前上传"]/following-sibling::td[1]//' 'table[tr[1][td[4 and text()="尺寸"]]]//tr[position()>1]/td[4]') seeding_seeders = html.xpath('//tr/td[text()="当前上传"]/following-sibling::td[1]//' 'table[tr[1][td[5 and text()="做种者"]]]//tr[position()>1]/td[5]//text()') tmp_seeding = len(seeding_sizes) tmp_seeding_size = 0 tmp_seeding_info = [] for i in range(0, len(seeding_sizes)): size = StringUtils.num_filesize(seeding_sizes[i].xpath("string(.)").strip()) seeders = StringUtils.str_int(seeding_seeders[i]) tmp_seeding_size += size tmp_seeding_info.append([seeders, size]) if not self.seeding_size: self.seeding_size = tmp_seeding_size if not self.seeding: self.seeding = tmp_seeding if not self.seeding_info: self.seeding_info = tmp_seeding_info seeding_sizes = html.xpath('//tr/td[text()="做种统计"]/following-sibling::td[1]//text()') if seeding_sizes: seeding_match = re.search(r"总做种数:\s+(\d+)", seeding_sizes[0], re.IGNORECASE) seeding_size_match = re.search(r"总做种体积:\s+([\d,.\s]+[KMGTPI]*B)", seeding_sizes[0], re.IGNORECASE) tmp_seeding = StringUtils.str_int(seeding_match.group(1)) if ( seeding_match and seeding_match.group(1)) else 0 tmp_seeding_size = StringUtils.num_filesize( seeding_size_match.group(1).strip()) if seeding_size_match else 0 if not self.seeding_size: self.seeding_size = tmp_seeding_size if not self.seeding: self.seeding = tmp_seeding self._fixup_torrent_seeding_page(html) finally: if html is not None: del html def _fixup_torrent_seeding_page(self, html): """ 修正种子页面链接 :param html: :return: """ # 单独的种子页面 seeding_url_text = html.xpath('//a[contains(@href,"getusertorrentlist.php") ' 'and contains(@href,"seeding")]/@href') if seeding_url_text: self._torrent_seeding_page = seeding_url_text[0].strip() # 从JS调用种获取用户ID seeding_url_text = html.xpath('//a[contains(@href, "javascript: getusertorrentlistajax") ' 'and contains(@href,"seeding")]/@href') csrf_text = html.xpath('//meta[@name="x-csrf"]/@content') if not self._torrent_seeding_page and seeding_url_text: user_js = re.search(r"javascript: getusertorrentlistajax\(\s*'(\d+)", seeding_url_text[0]) if user_js and user_js.group(1).strip(): self.userid = user_js.group(1).strip() self._torrent_seeding_page = f"getusertorrentlistajax.php?userid={self.userid}&type=seeding" elif seeding_url_text and csrf_text: if csrf_text[0].strip(): self._torrent_seeding_page \ = f"ajax_getusertorrentlist.php" self._torrent_seeding_params = {'userid': self.userid, 'type': 'seeding', 'csrf': csrf_text[0].strip()} # 分类做种模式 # 临时屏蔽 # seeding_url_text = html.xpath('//tr/td[text()="当前做种"]/following-sibling::td[1]' # '/table//td/a[contains(@href,"seeding")]/@href') # if seeding_url_text: # self._torrent_seeding_page = seeding_url_text def _get_user_level(self, html): # 等级 获取同一行等级数据,图片格式等级,取title信息,否则取文本信息 user_levels_text = html.xpath('//tr/td[text()="等級" or text()="等级" or *[text()="等级"]]/' 'following-sibling::td[1]/img[1]/@title') if user_levels_text: self.user_level = user_levels_text[0].strip() return user_levels_text = html.xpath('//tr/td[text()="等級" or text()="等级"]/' 'following-sibling::td[1 and not(img)]' '|//tr/td[text()="等級" or text()="等级"]/' 'following-sibling::td[1 and img[not(@title)]]') if user_levels_text: self.user_level = user_levels_text[0].xpath("string(.)").strip() return user_levels_text = html.xpath('//tr/td[text()="等級" or text()="等级"]/' 'following-sibling::td[1]') if user_levels_text: self.user_level = user_levels_text[0].xpath("string(.)").strip() return user_levels_text = html.xpath('//a[contains(@href, "userdetails")]/text()') if not self.user_level and user_levels_text: for user_level_text in user_levels_text: user_level_match = re.search(r"\[(.*)]", user_level_text) if user_level_match and user_level_match.group(1).strip(): self.user_level = user_level_match.group(1).strip() break def _parse_message_unread_links(self, html_text: str, msg_links: list) -> Optional[str]: html = etree.HTML(html_text) try: if not StringUtils.is_valid_html_element(html): return None message_links = html.xpath('//tr[not(./td/img[@alt="Read"])]/td/a[contains(@href, "viewmessage")]/@href') msg_links.extend(message_links) # 是否存在下页数据 next_page = None next_page_text = html.xpath('//a[contains(.//text(), "下一页") or contains(.//text(), "下一頁")]/@href') if next_page_text: next_page = next_page_text[-1].strip() finally: if html is not None: del html return next_page def _parse_message_content(self, html_text): html = etree.HTML(html_text) try: if not StringUtils.is_valid_html_element(html): return None, None, None # 标题 message_head_text = None message_head = html.xpath('//h1/text()' '|//div[@class="layui-card-header"]/span[1]/text()') if message_head: message_head_text = message_head[-1].strip() # 消息时间 message_date_text = None message_date = html.xpath('//h1/following-sibling::table[.//tr/td[@class="colhead"]]//tr[2]/td[2]' '|//div[@class="layui-card-header"]/span[2]/span[2]') if message_date: message_date_text = message_date[0].xpath("string(.)").strip() # 消息内容 message_content_text = None message_content = html.xpath('//h1/following-sibling::table[.//tr/td[@class="colhead"]]//tr[3]/td' '|//div[contains(@class,"layui-card-body")]') if message_content: message_content_text = message_content[0].xpath("string(.)").strip() finally: if html is not None: del html return message_head_text, message_date_text, message_content_text def _fixup_traffic_info(self, html): # fixup bonus if not self.bonus: bonus_text = html.xpath('//tr/td[text()="魔力值" or text()="猫粮"]/following-sibling::td[1]/text()') if bonus_text: self.bonus = StringUtils.str_float(bonus_text[0].strip()) ================================================ FILE: app/modules/indexer/parser/nexus_project.py ================================================ # -*- coding: utf-8 -*- import re from app.modules.indexer.parser import SiteSchema from app.modules.indexer.parser.nexus_php import NexusPhpSiteUserInfo class NexusProjectSiteUserInfo(NexusPhpSiteUserInfo): schema = SiteSchema.NexusProject def _parse_site_page(self, html_text: str): html_text = self._prepare_html_text(html_text) user_detail = re.search(r"userdetails.php\?id=(\d+)", html_text) if user_detail and user_detail.group().strip(): self._user_detail_page = user_detail.group().strip().lstrip('/') self.userid = user_detail.group(1) self._torrent_seeding_page = f"viewusertorrents.php?id={self.userid}&show=seeding" ================================================ FILE: app/modules/indexer/parser/nexus_rabbit.py ================================================ # -*- coding: utf-8 -*- import re import json from typing import Optional from lxml import etree from urllib.parse import urljoin from app.log import logger from app.modules.indexer.parser import SiteSchema from app.modules.indexer.parser import SiteParserBase from app.utils.string import StringUtils class NexusRabbitSiteUserInfo(SiteParserBase): schema = SiteSchema.NexusRabbit def _parse_site_page(self, html_text: str): html_text = self._prepare_html_text(html_text) user_detail = re.search(r"user.php\?id=(\d+)", html_text) if not (user_detail and user_detail.group().strip()): return self.userid = user_detail.group(1) self._user_detail_page = f"user.php?id={self.userid}" self._user_traffic_page = None self._torrent_seeding_page = "api/general" self._torrent_seeding_params = { "page": 1, "limit": 5000000, "action": "userTorrentsList", "data": {"type": "seeding", "id": int(self.userid)}, } self._torrent_seeding_headers = { "Content-Type": "application/json", "Accept": "application/json, text/plain, */*", "X-Requested-With": "XMLHttpRequest", # 必须要加上这一条,不然返回的是空数据 } self._user_mail_unread_page = None self._sys_mail_unread_page = "api/general" self._mail_unread_params = { "page": 1, "limit": 5000000, "action": "getMessageIn", } self._mail_unread_headers = { "Content-Type": "application/json", "Accept": "application/json, text/plain, */*", "X-Requested-With": "XMLHttpRequest", } def _parse_user_torrent_seeding_info( self, html_text: str, multi_page: bool = False ) -> Optional[str]: """ 做种相关信息 :param html_text: :param multi_page: 是否多页数据 :return: 下页地址 """ try: torrents = json.loads(html_text).get("data", []) except Exception as e: logger.error(f"解析做种信息失败: {str(e)}") return None seeding_size = 0 seeding_info = [] for torrent in torrents: seeders = int(torrent.get("seeders", 0)) size = StringUtils.num_filesize(torrent.get("size")) seeding_size += size seeding_info.append([seeders, size]) self.seeding = len(torrents) self.seeding_size = seeding_size self.seeding_info = seeding_info def _parse_message_unread_links( self, html_text: str, msg_links: list ) -> str | None: unread_ids = [] try: messages = json.loads(html_text).get("data", []) except Exception as e: logger.error(f"解析未读消息失败: {e}") return None for msg in messages: msg_id, msg_unread = msg.get("id"), msg.get("unread") if not (msg_id and msg_unread) or msg_unread == "no": continue unread_ids.append(msg_id) head, date, content = msg.get("subject"), msg.get("added"), msg.get("msg") if head and date and content: self.message_unread_contents.append((head, date, content)) self.message_unread = len(unread_ids) if unread_ids: self._get_page_content( url=urljoin(self._base_url, "api/general?loading=true"), params={"action": "readMessage", "data": {"ids": unread_ids}}, headers={ "Content-Type": "application/json", "Accept": "application/json, text/plain, */*", "X-Requested-With": "XMLHttpRequest", }, ) return None def _parse_user_base_info(self, html_text: str): """只有奶糖余额才需要在 base 中获取,其它均可以在详情页拿到""" html = etree.HTML(html_text) try: if not StringUtils.is_valid_html_element(html): return bonus = html.xpath( '//div[contains(text(), "奶糖余额")]/following-sibling::div[1]/text()' ) if bonus: self.bonus = StringUtils.str_float(bonus[0].strip()) finally: if html is not None: del html def _parse_user_detail_info(self, html_text: str): html = etree.HTML(html_text) try: if not StringUtils.is_valid_html_element(html): return # 缩小一下查找范围,所有的信息都在这个 div 里 user_info = html.xpath('//div[contains(@class, "layui-hares-user-info-right")]') if not user_info: return user_info = user_info[0] # 用户名 if username := user_info.xpath( './/span[contains(text(), "用户名")]/a/span/text()' ): self.username = username[0].strip() # 等级 if user_level := user_info.xpath('.//span[contains(text(), "等级")]/b/text()'): self.user_level = user_level[0].strip() # 加入日期 if join_date := user_info.xpath('.//span[contains(text(), "注册日期")]/text()'): join_date = join_date[0].strip().split("\r")[0].removeprefix("注册日期:") self.join_at = StringUtils.unify_datetime_str(join_date) # 上传量 if upload := user_info.xpath('.//span[contains(text(), "上传量")]/text()'): self.upload = StringUtils.num_filesize( upload[0].strip().removeprefix("上传量:") ) # 下载量 if download := user_info.xpath('.//span[contains(text(), "下载量")]/text()'): self.download = StringUtils.num_filesize( download[0].strip().removeprefix("下载量:") ) # 分享率 if ratio := user_info.xpath('.//span[contains(text(), "分享率")]/em/text()'): self.ratio = StringUtils.str_float(ratio[0].strip()) finally: if html is not None: del html def _parse_message_content(self, html_text): """ 解析短消息内容,已经在 _parse_message_unread_links 内实现,重载防止 abstractmethod 报错 :param html_text: :return: head: message, date: time, content: message content """ pass def _parse_user_traffic_info(self, html_text: str): """ 解析用户的上传,下载,分享率等信息,已经在 _parse_user_detail_info 内实现,重载防止 abstractmethod 报错 :param html_text: :return: """ pass ================================================ FILE: app/modules/indexer/parser/rousi.py ================================================ # -*- coding: utf-8 -*- import json from urllib.parse import urljoin from typing import Optional, Tuple from app.log import logger from app.core.config import settings from app.utils.http import RequestUtils from app.utils.string import StringUtils from app.modules.indexer.parser import SiteParserBase, SiteSchema class RousiSiteUserInfo(SiteParserBase): """ Rousi.pro 站点解析器 使用 API v1 接口,通过 Passkey (Bearer Token) 进行认证 """ schema = SiteSchema.RousiPro request_mode = "apikey" def _parse_site_page(self, html_text: str): """ 配置 API 请求地址和请求头 使用 API v1 的 /profile 接口获取用户信息 """ self._base_url = f"https://{StringUtils.get_url_domain(self._site_url)}" self._user_basic_page = "api/v1/profile?include_fields[user]=seeding_leeching_data" self._user_basic_params = {} self._user_basic_headers = { "Content-Type": "application/json", "Accept": "application/json", "Authorization": f"Bearer {self.apikey}" } # Rousi.pro API v1 在单个接口返回所有信息,无需额外页面 self._user_traffic_page = None self._user_detail_page = None self._torrent_seeding_page = None self._user_mail_unread_page = None self._sys_mail_unread_page = None def _parse_logged_in(self, html_text): """ 判断是否登录成功 API 认证模式下,通过 HTTP 状态码判断,此处始终返回 True """ return True def _parse_user_base_info(self, html_text: str): """ 解析用户基本信息 通过 API v1 接口获取用户完整信息,包括上传下载量、做种数据等 API 响应示例: { "code": 0, "message": "success", "data": { "id": 1, "username": "example", "level_text": "Lv.5", "registered_at": "2024-01-01T00:00:00Z", "uploaded": 1073741824, "downloaded": 536870912, "ratio": 2.0, "karma": 1000.5, "seeding_leeching_data": { "seeding_count": 10, "seeding_size": 10737418240, "leeching_count": 2, "leeching_size": 2147483648 } } } """ if not html_text: return try: data = json.loads(html_text) except json.JSONDecodeError: logger.error(f"{self._site_name} JSON 解析失败") return if not data or data.get("code") != 0: self.err_msg = data.get("message", "未知错误") logger.warn(f"{self._site_name} API 错误: {self.err_msg}") return user_info = data.get("data") if not user_info: return # 基本信息 self.userid = user_info.get("id") self.username = user_info.get("username") self.user_level = user_info.get("level_text") or user_info.get("role_text") # 注册时间:统一格式为 YYYY-MM-DD HH:MM:SS join_at = StringUtils.unify_datetime_str(user_info.get("registered_at")) if join_at: # 确保格式为 YYYY-MM-DD HH:MM:SS (19位) if len(join_at) >= 19: self.join_at = join_at[:19] else: self.join_at = join_at # 流量信息 self.upload = int(user_info.get("uploaded") or 0) self.download = int(user_info.get("downloaded") or 0) self.ratio = round(float(user_info.get("ratio") or 0), 2) # 魔力值(站点称为 karma) self.bonus = float(user_info.get("karma") or 0) # 做种/下载中数据 sl_data = user_info.get("seeding_leeching_data", {}) self.seeding = int(sl_data.get("seeding_count") or 0) self.seeding_size = int(sl_data.get("seeding_size") or 0) self.leeching = int(sl_data.get("leeching_count") or 0) self.leeching_size = int(sl_data.get("leeching_size") or 0) def _parse_user_traffic_info(self, html_text: str): """ 解析用户流量信息 Rousi.pro API v1 在 _parse_user_base_info 中已完成所有解析,此方法无需实现 """ pass def _parse_user_detail_info(self, html_text: str): """ 解析用户详细信息 Rousi.pro API v1 在 _parse_user_base_info 中已完成所有解析,此方法无需实现 """ pass def _parse_user_torrent_seeding_info(self, html_text: str, multi_page: Optional[bool] = False) -> Optional[str]: """ 解析用户做种信息 Rousi.pro API v1 在 _parse_user_base_info 中已通过 seeding_leeching_data 获取做种数据 :param html_text: 页面内容 :param multi_page: 是否多页数据 :return: 下页地址(无下页返回 None) """ return None def _parse_message_unread_links(self, html_text: str, msg_links: list) -> Optional[str]: """ 解析未读消息链接 Rousi.pro API v1 暂未提供消息相关接口 :param html_text: 页面内容 :param msg_links: 消息链接列表 :return: 下页地址(无下页返回 None) """ return None def _parse_message_content(self, html_text) -> Tuple[Optional[str], Optional[str], Optional[str]]: """ 解析消息内容 Rousi.pro API v1 暂未提供消息相关接口 :param html_text: 页面内容 :return: (标题, 日期, 内容) """ return None, None, None def _pase_unread_msgs(self): """ 解析所有未读消息标题和内容 Rousi.pro API v1 暂未提供消息相关接口,暂时以网页接口实现 :return: """ if not self.token: logger.warn(f"{self._site_name} 站点未配置 Authorization 请求头,跳过消息解析") return headers = { "User-Agent": self._ua, "Accept": "application/json, text/plain, */*", "Authorization": self.token if self.token.startswith("Bearer ") else f"Bearer {self.token}" } def __get_message_list(page: int): params = { "page": page, "page_size": 100, "unread_only": "true" } res = RequestUtils( headers=headers, timeout=60, proxies=settings.PROXY if self._proxy else None ).get_res( url=urljoin(self._base_url, "api/messages"), params=params ) if not res or res.status_code != 200 or res.json().get("code", -1) != 0: logger.warn(f"{self._site_name} 站点解析消息失败,状态码: {res.status_code if res else '无响应'}") return { "messages": [], "total_pages": 0 } return res.json().get("data") # 分页获取所有未读消息 page = 0 res = __get_message_list(page) page += 1 messages = res.get("messages", []) total_pages = res.get("total_pages", 0) while page < total_pages: res = __get_message_list(page) messages.extend(res.get("messages", [])) page += 1 self.message_unread = len(messages) for messsage in messages: head = messsage.get("title") date = StringUtils.unify_datetime_str(messsage.get("created_at")) content = messsage.get("content") logger.debug(f"{self._site_name} 标题 {head} 时间 {date} 内容 {content}") self.message_unread_contents.append((head, date, content)) # 更新消息为已读 RequestUtils( headers=headers, timeout=60, proxies=settings.PROXY if self._proxy else None ).post_res( url=urljoin(self._base_url, "api/messages/read-all") ) ================================================ FILE: app/modules/indexer/parser/small_horse.py ================================================ # -*- coding: utf-8 -*- import re from typing import Optional from lxml import etree from app.modules.indexer.parser import SiteParserBase, SiteSchema from app.utils.string import StringUtils class SmallHorseSiteUserInfo(SiteParserBase): schema = SiteSchema.SmallHorse def _parse_site_page(self, html_text: str): html_text = self._prepare_html_text(html_text) user_detail = re.search(r"user.php\?id=(\d+)", html_text) if user_detail and user_detail.group().strip(): self._user_detail_page = user_detail.group().strip().lstrip('/') self.userid = user_detail.group(1) self._torrent_seeding_page = f"torrents.php?type=seeding&userid={self.userid}" self._user_traffic_page = f"user.php?id={self.userid}" def _parse_user_base_info(self, html_text: str): html_text = self._prepare_html_text(html_text) html = etree.HTML(html_text) try: ret = html.xpath('//a[contains(@href, "user.php")]//text()') if ret: self.username = str(ret[0]) finally: if html is not None: del html def _parse_user_traffic_info(self, html_text: str): """ 上传/下载/分享率 [做种数/魔力值] :param html_text: :return: """ html_text = self._prepare_html_text(html_text) html = etree.HTML(html_text) try: tmps = html.xpath('//ul[@class = "stats nobullet"]') if tmps: if tmps[1].xpath("li") and tmps[1].xpath("li")[0].xpath("span//text()"): self.join_at = StringUtils.unify_datetime_str(tmps[1].xpath("li")[0].xpath("span//text()")[0]) self.upload = StringUtils.num_filesize(str(tmps[1].xpath("li")[2].xpath("text()")[0]).split(":")[1].strip()) self.download = StringUtils.num_filesize( str(tmps[1].xpath("li")[3].xpath("text()")[0]).split(":")[1].strip()) if tmps[1].xpath("li")[4].xpath("span//text()"): self.ratio = StringUtils.str_float(str(tmps[1].xpath("li")[4].xpath("span//text()")[0]).replace('∞', '0')) else: self.ratio = StringUtils.str_float(str(tmps[1].xpath("li")[5].xpath("text()")[0]).split(":")[1]) self.bonus = StringUtils.str_float(str(tmps[1].xpath("li")[5].xpath("text()")[0]).split(":")[1]) self.user_level = str(tmps[3].xpath("li")[0].xpath("text()")[0]).split(":")[1].strip() self.leeching = StringUtils.str_int( (tmps[4].xpath("li")[6].xpath("text()")[0]).split(":")[1].replace("[", "")) finally: if html is not None: del html def _parse_user_detail_info(self, html_text: str): pass def _parse_user_torrent_seeding_info(self, html_text: str, multi_page: Optional[bool] = False) -> Optional[str]: """ 做种相关信息 :param html_text: :param multi_page: 是否多页数据 :return: 下页地址 """ html = etree.HTML(html_text) try: if not StringUtils.is_valid_html_element(html): return None size_col = 6 seeders_col = 8 page_seeding = 0 page_seeding_size = 0 page_seeding_info = [] seeding_sizes = html.xpath(f'//table[@id="torrent_table"]//tr[position()>1]/td[{size_col}]') seeding_seeders = html.xpath(f'//table[@id="torrent_table"]//tr[position()>1]/td[{seeders_col}]') if seeding_sizes and seeding_seeders: page_seeding = len(seeding_sizes) for i in range(0, len(seeding_sizes)): size = StringUtils.num_filesize(seeding_sizes[i].xpath("string(.)").strip()) seeders = StringUtils.str_int(seeding_seeders[i].xpath("string(.)").strip()) page_seeding_size += size page_seeding_info.append([seeders, size]) self.seeding += page_seeding self.seeding_size += page_seeding_size self.seeding_info.extend(page_seeding_info) # 是否存在下页数据 next_page = None next_pages = html.xpath('//ul[@class="pagination"]/li[contains(@class,"active")]/following-sibling::li') if next_pages and len(next_pages) > 1: page_num = next_pages[0].xpath("string(.)").strip() if page_num.isdigit(): next_page = f"{self._torrent_seeding_page}&page={page_num}" finally: if html is not None: del html return next_page def _parse_message_unread_links(self, html_text: str, msg_links: list) -> Optional[str]: return None def _parse_message_content(self, html_text): return None, None, None ================================================ FILE: app/modules/indexer/parser/tnode.py ================================================ # -*- coding: utf-8 -*- import json import re from typing import Optional from app.log import logger from app.modules.indexer.parser import SiteParserBase, SiteSchema from app.utils.string import StringUtils class TNodeSiteUserInfo(SiteParserBase): schema = SiteSchema.TNode def _parse_site_page(self, html_text: str): html_text = self._prepare_html_text(html_text) # csrf_token = re.search(r'', html_text) if csrf_token: self._addition_headers = {'X-CSRF-TOKEN': csrf_token.group(1)} self._user_detail_page = "api/user/getMainInfo" self._torrent_seeding_page = "api/user/listTorrentActivity?id=&type=seeding&page=1&size=20000" def _parse_logged_in(self, html_text): """ 判断是否登录成功, 通过判断是否存在用户信息 暂时跳过检测,待后续优化 :param html_text: :return: """ return True def _parse_user_base_info(self, html_text: str): self.username = self.userid def _parse_user_traffic_info(self, html_text: str): pass def _parse_user_detail_info(self, html_text: str): try: detail = json.loads(html_text) except json.JSONDecodeError: return if detail.get("status") != 200: return user_info = detail.get("data", {}) self.userid = user_info.get("id") self.username = user_info.get("username") self.user_level = user_info.get("class", {}).get("name") self.join_at = user_info.get("regTime", 0) self.join_at = StringUtils.unify_datetime_str(str(self.join_at)) self.upload = user_info.get("upload") self.download = user_info.get("download") self.ratio = 0 if self.download <= 0 else round(self.upload / self.download, 3) self.bonus = user_info.get("bonus") self.message_unread = user_info.get("unreadAdmin", 0) + user_info.get("unreadInbox", 0) + user_info.get( "unreadSystem", 0) pass def _parse_user_torrent_seeding_info(self, html_text: str, multi_page: Optional[bool] = False) -> Optional[str]: """ 解析用户做种信息 """ try: seeding_info = json.loads(html_text) except json.JSONDecodeError as e: logger.warning(f"{self._site_name}: Failed to decode seeding info JSON: {e}") return None if not isinstance(seeding_info, dict): logger.warning(f"{self._site_name}: Seeding info payload is not a dictionary") return None if seeding_info.get("status") != 200: return None torrents = seeding_info.get("data", {}).get("torrents", []) page_seeding_size = 0 page_seeding_info = [] for torrent in torrents: size = torrent.get("size", 0) seeders = torrent.get("seeding", 0) page_seeding_size += size page_seeding_info.append([seeders, size]) self.seeding += len(torrents) self.seeding_size += page_seeding_size self.seeding_info.extend(page_seeding_info) # 是否存在下页数据 next_page = None return next_page def _parse_message_unread_links(self, html_text: str, msg_links: list) -> Optional[str]: return None def _parse_message_content(self, html_text): """ 系统信息 api/message/listSystem?page=1&size=20 收件箱信息 api/message/listInbox?page=1&size=20 管理员信息 api/message/listAdmin?page=1&size=20 :param html_text: :return: """ return None, None, None ================================================ FILE: app/modules/indexer/parser/torrent_leech.py ================================================ # -*- coding: utf-8 -*- import re from typing import Optional from lxml import etree from app.modules.indexer.parser import SiteParserBase, SiteSchema from app.utils.string import StringUtils class TorrentLeechSiteUserInfo(SiteParserBase): schema = SiteSchema.TorrentLeech def _parse_site_page(self, html_text: str): html_text = self._prepare_html_text(html_text) user_detail = re.search(r"/profile/([^/]+)/", html_text) if user_detail and user_detail.group().strip(): self._user_detail_page = user_detail.group().strip().lstrip('/') self.userid = user_detail.group(1) self._user_traffic_page = f"profile/{self.userid}/view" self._torrent_seeding_page = f"profile/{self.userid}/seeding" def _parse_user_base_info(self, html_text: str): self.username = self.userid def _parse_user_traffic_info(self, html_text: str): """ 上传/下载/分享率 [做种数/魔力值] :param html_text: :return: """ html_text = self._prepare_html_text(html_text) html = etree.HTML(html_text) try: upload_html = html.xpath('//div[contains(@class,"profile-uploaded")]//span/text()') if upload_html: self.upload = StringUtils.num_filesize(upload_html[0]) download_html = html.xpath('//div[contains(@class,"profile-downloaded")]//span/text()') if download_html: self.download = StringUtils.num_filesize(download_html[0]) ratio_html = html.xpath('//div[contains(@class,"profile-ratio")]//span/text()') if ratio_html: self.ratio = StringUtils.str_float(ratio_html[0].replace('∞', '0')) user_level_html = html.xpath('//table[contains(@class, "profileViewTable")]' '//tr/td[text()="Class"]/following-sibling::td/text()') if user_level_html: self.user_level = user_level_html[0].strip() join_at_html = html.xpath('//table[contains(@class, "profileViewTable")]' '//tr/td[text()="Registration date"]/following-sibling::td/text()') if join_at_html: self.join_at = StringUtils.unify_datetime_str(join_at_html[0].strip()) bonus_html = html.xpath('//span[contains(@class, "total-TL-points")]/text()') if bonus_html: self.bonus = StringUtils.str_float(bonus_html[0].strip()) finally: if html is not None: del html def _parse_user_detail_info(self, html_text: str): pass def _parse_user_torrent_seeding_info(self, html_text: str, multi_page: Optional[bool] = False) -> Optional[str]: """ 做种相关信息 :param html_text: :param multi_page: 是否多页数据 :return: 下页地址 """ html = etree.HTML(html_text) try: if not StringUtils.is_valid_html_element(html): return None size_col = 2 seeders_col = 7 page_seeding = 0 page_seeding_size = 0 page_seeding_info = [] seeding_sizes = html.xpath(f'//tbody/tr/td[{size_col}]') seeding_seeders = html.xpath(f'//tbody/tr/td[{seeders_col}]/text()') if seeding_sizes and seeding_seeders: page_seeding = len(seeding_sizes) for i in range(0, len(seeding_sizes)): size = StringUtils.num_filesize(seeding_sizes[i].xpath("string(.)").strip()) seeders = StringUtils.str_int(seeding_seeders[i]) page_seeding_size += size page_seeding_info.append([seeders, size]) self.seeding += page_seeding self.seeding_size += page_seeding_size self.seeding_info.extend(page_seeding_info) # 是否存在下页数据 next_page = None finally: if html is not None: del html return next_page def _parse_message_unread_links(self, html_text: str, msg_links: list) -> Optional[str]: return None def _parse_message_content(self, html_text): return None, None, None ================================================ FILE: app/modules/indexer/parser/unit3d.py ================================================ # -*- coding: utf-8 -*- import re from typing import Optional from lxml import etree from app.modules.indexer.parser import SiteParserBase, SiteSchema from app.utils.string import StringUtils class Unit3dSiteUserInfo(SiteParserBase): schema = SiteSchema.Unit3d def _parse_user_base_info(self, html_text: str): html_text = self._prepare_html_text(html_text) html = etree.HTML(html_text) try: tmps = html.xpath('//a[contains(@href, "/users/") and contains(@href, "settings")]/@href') if tmps: user_name_match = re.search(r"/users/(.+)/settings", tmps[0]) if user_name_match and user_name_match.group().strip(): self.username = user_name_match.group(1) self._torrent_seeding_page = f"/users/{self.username}/active?perPage=100&client=&seeding=include" self._user_detail_page = f"/users/{self.username}" tmps = html.xpath('//a[contains(@href, "bonus/earnings")]') if tmps: bonus_text = tmps[0].xpath("string(.)") bonus_match = re.search(r"([\d,.]+)", bonus_text) if bonus_match and bonus_match.group(1).strip(): self.bonus = StringUtils.str_float(bonus_match.group(1)) finally: if html is not None: del html def _parse_site_page(self, html_text: str): pass def _parse_user_detail_info(self, html_text: str): """ 解析用户额外信息,加入时间,等级 :param html_text: :return: """ html = etree.HTML(html_text) try: if not StringUtils.is_valid_html_element(html): return None # 用户等级 user_levels_text = html.xpath('//div[contains(@class, "content")]//span[contains(@class, "badge-user")]/text()') if user_levels_text: self.user_level = user_levels_text[0].strip() # 加入日期 join_at_text = html.xpath('//div[contains(@class, "content")]//h4[contains(text(), "注册日期") ' 'or contains(text(), "註冊日期") ' 'or contains(text(), "Registration date")]/text()') if join_at_text: self.join_at = StringUtils.unify_datetime_str( join_at_text[0].replace('注册日期', '').replace('註冊日期', '').replace('Registration date', '')) finally: if html is not None: del html def _parse_user_torrent_seeding_info(self, html_text: str, multi_page: Optional[bool] = False) -> Optional[str]: """ 做种相关信息 :param html_text: :param multi_page: 是否多页数据 :return: 下页地址 """ html = etree.HTML(html_text) try: if not StringUtils.is_valid_html_element(html): return None size_col = 9 seeders_col = 2 # 搜索size列 if html.xpath('//thead//th[contains(@class,"size")]'): size_col = len(html.xpath('//thead//th[contains(@class,"size")][1]/preceding-sibling::th')) + 1 # 搜索seeders列 if html.xpath('//thead//th[contains(@class,"seeders")]'): seeders_col = len(html.xpath('//thead//th[contains(@class,"seeders")]/preceding-sibling::th')) + 1 page_seeding = 0 page_seeding_size = 0 page_seeding_info = [] seeding_sizes = html.xpath(f'//tr[position()]/td[{size_col}]') seeding_seeders = html.xpath(f'//tr[position()]/td[{seeders_col}]') if seeding_sizes and seeding_seeders: page_seeding = len(seeding_sizes) for i in range(0, len(seeding_sizes)): size = StringUtils.num_filesize(seeding_sizes[i].xpath("string(.)").strip()) seeders = StringUtils.str_int(seeding_seeders[i].xpath("string(.)").strip()) page_seeding_size += size page_seeding_info.append([seeders, size]) self.seeding += page_seeding self.seeding_size += page_seeding_size self.seeding_info.extend(page_seeding_info) # 是否存在下页数据 next_page = None next_pages = html.xpath('//ul[@class="pagination"]/li[contains(@class,"active")]/following-sibling::li') if next_pages and len(next_pages) > 1: page_num = next_pages[0].xpath("string(.)").strip() if page_num.isdigit(): next_page = f"{self._torrent_seeding_page}&page={page_num}" finally: if html is not None: del html return next_page def _parse_user_traffic_info(self, html_text: str): html_text = self._prepare_html_text(html_text) upload_match = re.search(r"[^总]上[传傳]量?[::_<>/a-zA-Z-=\"'\s#;]+([\d,.\s]+[KMGTPI]*B)", html_text, re.IGNORECASE) self.upload = StringUtils.num_filesize(upload_match.group(1).strip()) if upload_match else 0 download_match = re.search(r"[^总子影力]下[载載]量?[::_<>/a-zA-Z-=\"'\s#;]+([\d,.\s]+[KMGTPI]*B)", html_text, re.IGNORECASE) self.download = StringUtils.num_filesize(download_match.group(1).strip()) if download_match else 0 ratio_match = re.search(r"分享率[::_<>/a-zA-Z-=\"'\s#;]+([\d,.\s]+)", html_text) self.ratio = StringUtils.str_float(ratio_match.group(1)) if ( ratio_match and ratio_match.group(1).strip()) else 0.0 def _parse_message_unread_links(self, html_text: str, msg_links: list) -> Optional[str]: return None def _parse_message_content(self, html_text): return None, None, None ================================================ FILE: app/modules/indexer/parser/yema.py ================================================ # -*- coding: utf-8 -*- import json from typing import Optional, Tuple from app.modules.indexer.parser import SiteParserBase, SiteSchema from app.utils.string import StringUtils class TYemaSiteUserInfo(SiteParserBase): schema = SiteSchema.Yema def _parse_site_page(self, html_text: str): """ 获取站点页面地址 """ self._user_traffic_page = None self._user_detail_page = None self._user_basic_page = "api/consumer/fetchSelfDetail" self._user_basic_params = {} self._sys_mail_unread_page = None self._user_mail_unread_page = None self._mail_unread_params = {} self._torrent_seeding_page = "/api/userTorrent/fetchSeedTorrentInfo" self._torrent_seeding_params = { # 虽然这个参数是无意义的,但这个 API 必须用 POST "status": "seeding" } self._torrent_seeding_headers = {} self._addition_headers = { "Content-Type": "application/json", "Accept": "application/json, text/plain, */*", } def _parse_logged_in(self, html_text): """ 判断是否登录成功, 通过判断是否存在用户信息 暂时跳过检测,待后续优化 :param html_text: :return: """ return True def _parse_user_base_info(self, html_text: str): """ 解析用户基本信息,这里把_parse_user_traffic_info和_parse_user_detail_info合并到这里 """ if not html_text: return None detail = json.loads(html_text) if not detail or not detail.get("success"): return user_info = detail.get("data", {}) self.userid = user_info.get("id") self.username = user_info.get("name") self.user_level = str(user_info.get("level")) if user_info.get("level") is not None else None self.join_at = StringUtils.unify_datetime_str(user_info.get("registerTime")) self.upload = user_info.get('uploadSize') # 使用 promotionDownloadSize 获取真实下载量(考虑促销因素) if "promotionDownloadSize" in user_info: self.download = user_info.get('promotionDownloadSize') else: self.download = user_info.get('downloadSize') self.ratio = round(self.upload / (self.download or 1), 2) self.bonus = user_info.get("bonus") self.message_unread = 0 def _parse_user_traffic_info(self, html_text: str): """ 解析用户流量信息 """ pass def _parse_user_detail_info(self, html_text: str): """ 解析用户详细信息 """ pass def _parse_user_torrent_seeding_info(self, html_text: str, multi_page: Optional[bool] = False) -> Optional[str]: """ 解析用户做种信息 """ if not html_text: return None seeding_info = json.loads(html_text) if not seeding_info or not seeding_info.get("success") or not seeding_info.get("data"): return None torrents = seeding_info.get("data") self.seeding += torrents.get("num") self.seeding_size += torrents.get("fileSize") # 是否存在下页数据 next_page = None return next_page def _parse_message_unread_links(self, html_text: str, msg_links: list) -> Optional[str]: """ 解析未读消息链接,这里直接读出详情 """ pass def _parse_message_content(self, html_text) -> Tuple[Optional[str], Optional[str], Optional[str]]: """ 解析消息内容 """ pass ================================================ FILE: app/modules/indexer/parser/zhixing.py ================================================ # # 知行 http://pt.zhixing.bjtu.edu.cn/ # author: ThedoRap # time: 2025-10-02 # # -*- coding: utf-8 -*- import re from typing import Optional, Tuple from app.modules.indexer.parser import SiteParserBase, SiteSchema from app.utils.string import StringUtils from bs4 import BeautifulSoup from urllib.parse import urljoin class ZhixingSiteUserInfo(SiteParserBase): schema = SiteSchema.Zhixing def _parse_site_page(self, html_text: str): """ 获取站点页面地址 """ self._user_basic_page = "user/{uid}/" self._user_detail_page = None self._user_basic_params = {} self._user_traffic_page = None self._sys_mail_unread_page = None self._user_mail_unread_page = None self._mail_unread_params = {} self._torrent_seeding_base = "user/{uid}/seeding" self._torrent_seeding_params = {} self._torrent_seeding_headers = {} self._addition_headers = {} def _parse_logged_in(self, html_text): """ 判断是否登录成功, 通过判断是否存在用户信息 """ soup = BeautifulSoup(html_text, 'html.parser') return bool(soup.find(id='um')) def _parse_user_base_info(self, html_text: str): """ 解析用户基本信息,这里把_parse_user_traffic_info和_parse_user_detail_info合并到这里 """ if not html_text: return None soup = BeautifulSoup(html_text, 'html.parser') details_tabs = soup.find_all('div', class_='user-details-tabs') info_dict = {} for tab in details_tabs: for p in tab.find_all('p'): text = p.text.strip() if ':' in text: parts = text.split(':', 1) elif ':' in text: parts = text.split(':', 1) else: continue if len(parts) == 2: key = parts[0].strip() value_text = parts[1].strip() value = re.split(r'\s*\(', value_text)[0].strip().split('查看')[0].strip() info_dict[key] = value self._basic_info = info_dict # Save for fallback self.userid = info_dict.get('UID') self.username = info_dict.get('用户名') self.user_level = info_dict.get('用户组') self.join_at = StringUtils.unify_datetime_str(info_dict.get('注册时间')) if '注册时间' in info_dict else None def num_filesize_safe(s: str): if s: s = s.strip() if re.match(r'^\d+(\.\d+)?$', s): s += ' B' return StringUtils.num_filesize(s) if s else 0 self.upload = num_filesize_safe(info_dict.get('上传流量')) if '上传流量' in info_dict else 0 self.download = num_filesize_safe(info_dict.get('下载流量')) if '下载流量' in info_dict else 0 self.ratio = float(info_dict.get('共享率')) if '共享率' in info_dict else 0 self.bonus = float(info_dict.get('保种积分')) if '保种积分' in info_dict else 0.0 self.message_unread = 0 # 暂无消息解析 # Temporarily set seeding from basic, will override or fallback later self.seeding = int(info_dict.get('当前保种数量')) if '当前保种数量' in info_dict else 0 self.seeding_size = num_filesize_safe(info_dict.get('当前保种容量')) if '当前保种容量' in info_dict else 0 def _parse_user_traffic_info(self, html_text: str): pass def _parse_user_detail_info(self, html_text: str): pass def _parse_user_torrent_seeding_page_info(self, html_text: str) -> Tuple[int, int]: """ 解析用户做种信息单页,返回本页数量和大小 """ if not html_text: return 0, 0 soup = BeautifulSoup(html_text, 'html.parser') torrents = soup.find_all('tr', id=re.compile(r'^t\d+')) page_seeding = 0 page_seeding_size = 0 for torrent in torrents: size_td = torrent.find('td', class_='r') if size_td: size_text = size_td.find('a').text if size_td.find('a') else size_td.text.strip() page_seeding += 1 page_seeding_size += StringUtils.num_filesize(size_text) return page_seeding, page_seeding_size def _parse_message_unread_links(self, html_text: str, msg_links: list) -> Optional[str]: pass def _parse_message_content(self, html_text) -> Tuple[Optional[str], Optional[str], Optional[str]]: pass def _parse_user_torrent_seeding_info(self, html_text: str): """ 占位,避免抽象类报错 """ pass def parse(self): """ 解析站点信息 """ super().parse() # 先从首页解析userid if self._index_html: soup = BeautifulSoup(self._index_html, 'html.parser') user_link = soup.find('a', href=re.compile(r'/user/\d+/')) if user_link: uid_match = re.search(r'/user/(\d+)/', user_link['href']) if uid_match: self.userid = uid_match.group(1) # 如果有userid,则格式化页面 if self.userid: if self._user_basic_page: basic_url = self._user_basic_page.format(uid=self.userid) basic_html = self._get_page_content(url=urljoin(self._base_url, basic_url)) self._parse_user_base_info(basic_html) if hasattr(self, '_torrent_seeding_base') and self._torrent_seeding_base: self.seeding = 0 # Reset to sum from pages self.seeding_size = 0 seeding_base = self._torrent_seeding_base.format(uid=self.userid) seeding_base_url = urljoin(self._base_url, seeding_base) page_num = 1 while True: seeding_url = f"{seeding_base_url}/p{page_num}" seeding_html = self._get_page_content(url=seeding_url) page_seeding, page_seeding_size = self._parse_user_torrent_seeding_page_info(seeding_html) self.seeding += page_seeding self.seeding_size += page_seeding_size if page_seeding == 0: break page_num += 1 # Fallback to basic if no seeding found from pages if self.seeding == 0 and hasattr(self, '_basic_info'): def num_filesize_safe(s: str): if s: s = s.strip() if re.match(r'^\d+(\.\d+)?$', s): s += ' B' return StringUtils.num_filesize(s) if s else 0 self.seeding = int(self._basic_info.get('当前保种数量', 0)) self.seeding_size = num_filesize_safe(self._basic_info.get('当前保种容量', '')) # 🔑 最终对外统一转字符串,避免 join 报错 self.userid = str(self.userid or "") self.username = str(self.username or "") self.user_level = str(self.user_level or "") self.join_at = str(self.join_at or "") self.upload = str(self.upload or 0) self.download = str(self.download or 0) self.ratio = str(self.ratio or 0) self.bonus = str(self.bonus or 0.0) self.message_unread = str(self.message_unread or 0) self.seeding = str(self.seeding or 0) self.seeding_size = str(self.seeding_size or 0) ================================================ FILE: app/modules/indexer/spider/__init__.py ================================================ import datetime import re import traceback from typing import Any, Optional from typing import List from urllib.parse import quote, urlencode, urlparse, parse_qs from fastapi.concurrency import run_in_threadpool from jinja2 import Template from pyquery import PyQuery from app.core.config import settings from app.log import logger from app.schemas.types import MediaType from app.utils.http import RequestUtils, AsyncRequestUtils from app.utils.string import StringUtils class SiteSpider: """ 站点爬虫 """ @property def __class__(self): return object @property def __dict__(self): return {} @property def __dir__(self): raise AttributeError(f"Cannot read protected attribute!") def __init__(self, indexer: dict, keyword: Optional[str] = None, mtype: MediaType = None, cat: Optional[str] = None, page: Optional[int] = 0, referer: Optional[str] = None): """ 设置查询参数 :param indexer: 索引器 :param keyword: 搜索关键字,如果数组则为批量搜索 :param mtype: 媒体类型 :param cat: 搜索分类 :param page: 页码 :param referer: Referer """ if not indexer: return self.keyword = keyword self.cat = cat self.mtype = mtype self.indexerid = indexer.get('id') self.indexername = indexer.get('name') self.search = indexer.get('search') self.batch = indexer.get('batch') self.browse = indexer.get('browse') self.category = indexer.get('category') self.list = indexer.get('torrents').get('list', {}) self.fields = indexer.get('torrents').get('fields') if not keyword and self.browse: self.list = self.browse.get('list') or self.list self.fields = self.browse.get('fields') or self.fields self.domain = indexer.get('domain') self.result_num = int(indexer.get('result_num') or 100) self._timeout = int(indexer.get('timeout') or 15) self.page = page if self.domain and not str(self.domain).endswith("/"): self.domain = self.domain + "/" self.ua = indexer.get('ua') or settings.USER_AGENT self.proxies = settings.PROXY if indexer.get('proxy') else None self.proxy_server = settings.PROXY_SERVER if indexer.get('proxy') else None self.cookie = indexer.get('cookie') self.referer = referer # 初始化属性 self.is_error = False self.torrents_info = {} self.torrents_info_array = [] def __get_search_url(self): """ 获取搜索URL """ # 种子搜索相对路径 paths = self.search.get('paths', []) torrentspath = "" if len(paths) == 1: torrentspath = paths[0].get('path', '') else: for path in paths: if path.get("type") == "all" and not self.mtype: torrentspath = path.get('path') break elif path.get("type") == "movie" and self.mtype == MediaType.MOVIE: torrentspath = path.get('path') break elif path.get("type") == "tv" and self.mtype == MediaType.TV: torrentspath = path.get('path') break # 精确搜索 if self.keyword: if isinstance(self.keyword, list): # 批量查询 if self.batch: delimiter = self.batch.get('delimiter') or ' ' space_replace = self.batch.get('space_replace') or ' ' search_word = delimiter.join([str(k).replace(' ', space_replace) for k in self.keyword]) else: search_word = " ".join(self.keyword) # 查询模式:或 search_mode = "1" else: # 单个查询 search_word = self.keyword # 查询模式与 search_mode = "0" # 搜索URL indexer_params = self.search.get("params", {}).copy() if indexer_params: search_area = indexer_params.get('search_area') # search_area非0表示支持imdbid搜索 if (search_area and (not self.keyword or not self.keyword.startswith('tt'))): # 支持imdbid搜索,但关键字不是imdbid时,不启用imdbid搜索 indexer_params.pop('search_area') # 变量字典 inputs_dict = { "keyword": search_word } # 查询参数,默认查询标题 params = { "search_mode": search_mode, "search_area": 0, "page": self.page or 0, "notnewword": 1 } # 额外参数 for key, value in indexer_params.items(): params.update({ "%s" % key: str(value).format(**inputs_dict) }) # 分类条件 if self.category: if self.mtype == MediaType.TV: cats = self.category.get("tv") or [] elif self.mtype == MediaType.MOVIE: cats = self.category.get("movie") or [] else: cats = (self.category.get("movie") or []) + (self.category.get("tv") or []) allowed_cats = set(self.cat.split(',')) if self.cat else None for cat in cats: if allowed_cats and str(cat.get('id')) not in allowed_cats: continue if self.category.get("field"): value = params.get(self.category.get("field"), "") params.update({ "%s" % self.category.get("field"): value + self.category.get("delimiter", ' ') + cat.get("id") }) else: params.update({ "cat%s" % cat.get("id"): 1 }) searchurl = self.domain + torrentspath + "?" + urlencode(params) else: # 变量字典 inputs_dict = { "keyword": quote(search_word), "page": self.page or 0 } # 无额外参数 searchurl = self.domain + str(torrentspath).format(**inputs_dict) # 列表浏览 else: # 变量字典 inputs_dict = { "page": self.page or 0, "keyword": "" } # 有单独浏览路径 if self.browse: torrentspath = self.browse.get("path") if self.browse.get("start"): start_page = int(self.browse.get("start")) + int(self.page or 0) inputs_dict.update({ "page": start_page }) elif self.page: torrentspath = torrentspath + f"?page={self.page}" # 搜索Url searchurl = self.domain + str(torrentspath).format(**inputs_dict) return searchurl def get_torrents(self) -> List[dict]: """ 开始请求 """ if not self.search or not self.domain: return [] # 获取搜索URL searchurl = self.__get_search_url() logger.info(f"开始请求:{searchurl}") # requests请求 ret = RequestUtils( ua=self.ua, cookies=self.cookie, timeout=self._timeout, referer=self.referer, proxies=self.proxies ).get_res(searchurl, allow_redirects=True) # 解析返回 return self.parse( RequestUtils.get_decoded_html_content( ret, performance_mode=settings.ENCODING_DETECTION_PERFORMANCE_MODE, confidence_threshold=settings.ENCODING_DETECTION_MIN_CONFIDENCE ) ) async def async_get_torrents(self) -> List[dict]: """ 异步请求 """ if not self.search or not self.domain: return [] # 获取搜索URL searchurl = self.__get_search_url() logger.info(f"开始异步请求:{searchurl}") # httpx请求 ret = await AsyncRequestUtils( ua=self.ua, cookies=self.cookie, timeout=self._timeout, referer=self.referer, proxies=self.proxies ).get_res(searchurl, allow_redirects=True) # 解析返回 return await run_in_threadpool( self.parse, RequestUtils.get_decoded_html_content( ret, performance_mode=settings.ENCODING_DETECTION_PERFORMANCE_MODE, confidence_threshold=settings.ENCODING_DETECTION_MIN_CONFIDENCE ) ) def __get_title(self, torrent: Any): # title default text if 'title' not in self.fields: return selector = self.fields.get('title', {}) if 'selector' in selector: self.torrents_info['title'] = self._safe_query(torrent, selector) elif 'text' in selector: render_dict = {} if "title_default" in self.fields: title_default_selector = self.fields.get('title_default', {}) title_default = self._safe_query(torrent, title_default_selector) render_dict.update({'title_default': title_default}) if "title_optional" in self.fields: title_optional_selector = self.fields.get('title_optional', {}) title_optional = self._safe_query(torrent, title_optional_selector) render_dict.update({'title_optional': title_optional}) self.torrents_info['title'] = Template(selector.get('text')).render(fields=render_dict) self.torrents_info['title'] = self.__filter_text(self.torrents_info.get('title'), selector.get('filters')) def __get_description(self, torrent: Any): # description text if 'description' not in self.fields: return selector = self.fields.get('description', {}) if "selector" in selector or "selectors" in selector: # 对于selectors情况,需要特殊处理selector_config desc_selector = selector.copy() if "selectors" in selector and "selector" not in selector: desc_selector["selector"] = selector.get("selectors", "") self.torrents_info['description'] = self._safe_query(torrent, desc_selector) elif "text" in selector: render_dict = {} if "tags" in self.fields: tags_selector = self.fields.get('tags', {}) tag = self._safe_query(torrent, tags_selector) render_dict.update({'tags': tag}) if "subject" in self.fields: subject_selector = self.fields.get('subject', {}) subject = self._safe_query(torrent, subject_selector) render_dict.update({'subject': subject}) if "description_free_forever" in self.fields: description_free_forever_selector = self.fields.get("description_free_forever", {}) description_free_forever = self._safe_query(torrent, description_free_forever_selector) render_dict.update({"description_free_forever": description_free_forever}) if "description_normal" in self.fields: description_normal_selector = self.fields.get("description_normal", {}) description_normal = self._safe_query(torrent, description_normal_selector) render_dict.update({"description_normal": description_normal}) self.torrents_info['description'] = Template(selector.get('text')).render(fields=render_dict) self.torrents_info['description'] = self.__filter_text(self.torrents_info.get('description'), selector.get('filters')) def __get_detail(self, torrent: Any): # details page text if 'details' not in self.fields: return selector = self.fields.get('details', {}) item = self._safe_query(torrent, selector) detail_link = self.__filter_text(item, selector.get('filters')) if detail_link: if not detail_link.startswith("http"): if detail_link.startswith("//"): self.torrents_info['page_url'] = self.domain.split(":")[0] + ":" + detail_link elif detail_link.startswith("/"): self.torrents_info['page_url'] = self.domain + detail_link[1:] else: self.torrents_info['page_url'] = self.domain + detail_link else: self.torrents_info['page_url'] = detail_link def __get_download(self, torrent: Any): # download link text if 'download' not in self.fields: return selector = self.fields.get('download', {}) item = self._safe_query(torrent, selector) download_link = self.__filter_text(item, selector.get('filters')) if download_link: if not download_link.startswith("http") \ and not download_link.startswith("magnet"): _scheme, _domain = StringUtils.get_url_netloc(self.domain) if _domain in download_link: if download_link.startswith("/"): self.torrents_info['enclosure'] = f"{_scheme}:{download_link}" else: self.torrents_info['enclosure'] = f"{_scheme}://{download_link}" else: if download_link.startswith("/"): self.torrents_info['enclosure'] = f"{self.domain}{download_link[1:]}" else: self.torrents_info['enclosure'] = f"{self.domain}{download_link}" else: self.torrents_info['enclosure'] = download_link def __get_imdbid(self, torrent: Any): # imdbid if "imdbid" not in self.fields: return selector = self.fields.get('imdbid', {}) item = self._safe_query(torrent, selector) self.torrents_info['imdbid'] = self.__filter_text(item, selector.get('filters')) def __get_size(self, torrent: Any): # torrent size int if 'size' not in self.fields: return selector = self.fields.get('size', {}) item = self._safe_query(torrent, selector) if item: size_val = item.replace("\n", "").strip() size_val = self.__filter_text(size_val, selector.get('filters')) self.torrents_info['size'] = StringUtils.num_filesize(size_val) else: self.torrents_info['size'] = 0 def __get_leechers(self, torrent: Any): # torrent leechers int if 'leechers' not in self.fields: return selector = self.fields.get('leechers', {}) item = self._safe_query(torrent, selector) if item: peers_val = item.split("/")[0] peers_val = peers_val.replace(",", "") peers_val = self.__filter_text(peers_val, selector.get('filters')) self.torrents_info['peers'] = int(peers_val) if peers_val and peers_val.isdigit() else 0 else: self.torrents_info['peers'] = 0 def __get_seeders(self, torrent: Any): # torrent seeders int if 'seeders' not in self.fields: return selector = self.fields.get('seeders', {}) item = self._safe_query(torrent, selector) if item: seeders_val = item.split("/")[0] seeders_val = seeders_val.replace(",", "") seeders_val = self.__filter_text(seeders_val, selector.get('filters')) self.torrents_info['seeders'] = int(seeders_val) if seeders_val and seeders_val.isdigit() else 0 else: self.torrents_info['seeders'] = 0 def __get_grabs(self, torrent: Any): # torrent grabs int if 'grabs' not in self.fields: return selector = self.fields.get('grabs', {}) item = self._safe_query(torrent, selector) if item: grabs_val = item.split("/")[0] grabs_val = grabs_val.replace(",", "") grabs_val = self.__filter_text(grabs_val, selector.get('filters')) self.torrents_info['grabs'] = int(grabs_val) if grabs_val and grabs_val.isdigit() else 0 else: self.torrents_info['grabs'] = 0 def __get_pubdate(self, torrent: Any): # torrent pubdate yyyy-mm-dd hh:mm:ss if 'date_added' not in self.fields: return selector = self.fields.get('date_added', {}) pubdate_str = self._safe_query(torrent, selector) if pubdate_str: pubdate_str = pubdate_str.replace('\n', ' ').strip() self.torrents_info['pubdate'] = self.__filter_text(pubdate_str, selector.get('filters')) if self.torrents_info.get('pubdate'): try: if not isinstance(self.torrents_info['pubdate'], datetime.datetime): datetime.datetime.strptime(str(self.torrents_info['pubdate']), '%Y-%m-%d %H:%M:%S') except (ValueError, TypeError): self.torrents_info['pubdate'] = StringUtils.unify_datetime_str(str(self.torrents_info['pubdate'])) def __get_date_elapsed(self, torrent: Any): # torrent date elapsed text if 'date_elapsed' not in self.fields: return selector = self.fields.get('date_elapsed', {}) date_elapsed = self._safe_query(torrent, selector) self.torrents_info['date_elapsed'] = self.__filter_text(date_elapsed, selector.get('filters')) def __get_downloadvolumefactor(self, torrent: Any): # downloadvolumefactor int selector = self.fields.get('downloadvolumefactor', {}) if not selector: return self.torrents_info['downloadvolumefactor'] = 1 if 'case' in selector: for downloadvolumefactorselector in list(selector.get('case', {}).keys()): downloadvolumefactor = torrent(downloadvolumefactorselector) try: if len(downloadvolumefactor) > 0: self.torrents_info['downloadvolumefactor'] = selector.get('case', {}).get( downloadvolumefactorselector) break finally: downloadvolumefactor.clear() del downloadvolumefactor elif "selector" in selector: item = self._safe_query(torrent, selector) if item: downloadvolumefactor = re.search(r'(\d+\.?\d*)', item) if downloadvolumefactor: self.torrents_info['downloadvolumefactor'] = int(downloadvolumefactor.group(1)) def __get_uploadvolumefactor(self, torrent: Any): # uploadvolumefactor int selector = self.fields.get('uploadvolumefactor', {}) if not selector: return self.torrents_info['uploadvolumefactor'] = 1 if 'case' in selector: for uploadvolumefactorselector in list(selector.get('case', {}).keys()): uploadvolumefactor = torrent(uploadvolumefactorselector) try: if len(uploadvolumefactor) > 0: self.torrents_info['uploadvolumefactor'] = selector.get('case', {}).get( uploadvolumefactorselector) break finally: uploadvolumefactor.clear() del uploadvolumefactor elif "selector" in selector: item = self._safe_query(torrent, selector) if item: uploadvolumefactor = re.search(r'(\d+\.?\d*)', item) if uploadvolumefactor: self.torrents_info['uploadvolumefactor'] = int(uploadvolumefactor.group(1)) def __get_labels(self, torrent: Any): # labels ['label1', 'label2'] if 'labels' not in self.fields: return selector = self.fields.get('labels', {}) if not selector.get('selector'): self.torrents_info['labels'] = [] return # labels需要特殊处理,因为它返回的是列表 labels = torrent(selector.get("selector", "")).clone() try: self.__remove(labels, selector) items = self.__attribute_or_text(labels, selector) if items: self.torrents_info['labels'] = [item for item in items if item] else: self.torrents_info['labels'] = [] finally: labels.clear() del labels def __get_free_date(self, torrent: Any): # free date yyyy-mm-dd hh:mm:ss if 'freedate' not in self.fields: return selector = self.fields.get('freedate', {}) freedate = self._safe_query(torrent, selector) self.torrents_info['freedate'] = self.__filter_text(freedate, selector.get('filters')) def __get_hit_and_run(self, torrent: Any): # hitandrun True/False if 'hr' not in self.fields: return selector = self.fields.get('hr', {}) hit_and_run = torrent(selector.get('selector', '')) try: if hit_and_run: self.torrents_info['hit_and_run'] = True else: self.torrents_info['hit_and_run'] = False finally: hit_and_run.clear() del hit_and_run def __get_category(self, torrent: Any): # category 电影/电视剧 if 'category' not in self.fields: return selector = self.fields.get('category', {}) category_value = self._safe_query(torrent, selector) category_value = self.__filter_text(category_value, selector.get('filters')) if category_value and self.category: tv_cats = [str(cat.get("id")) for cat in self.category.get("tv") or []] movie_cats = [str(cat.get("id")) for cat in self.category.get("movie") or []] if category_value in tv_cats \ and category_value not in movie_cats: self.torrents_info['category'] = MediaType.TV.value elif category_value in movie_cats: self.torrents_info['category'] = MediaType.MOVIE.value else: self.torrents_info['category'] = MediaType.UNKNOWN.value else: self.torrents_info['category'] = MediaType.UNKNOWN.value def _safe_query(self, torrent: Any, selector_config: Optional[dict]) -> Optional[str]: """ 安全地执行PyQuery查询并自动清理资源 :param torrent: PyQuery对象 :param selector_config: 选择器配置 :return: 处理后的结果 """ if not selector_config or not selector_config.get('selector'): return None query_obj = torrent(selector_config.get('selector', '')).clone() try: self.__remove(query_obj, selector_config) items = self.__attribute_or_text(query_obj, selector_config) return self.__index(items, selector_config) finally: query_obj.clear() del query_obj def get_info(self, torrent: Any) -> dict: """ 解析单条种子数据 """ # 每次调用时重新初始化,避免数据累积 self.torrents_info = {} try: # 标题 self.__get_title(torrent) # 描述 self.__get_description(torrent) # 详情页面 self.__get_detail(torrent) # 下载链接 self.__get_download(torrent) # 完成数 self.__get_grabs(torrent) # 下载数 self.__get_leechers(torrent) # 做种数 self.__get_seeders(torrent) # 大小 self.__get_size(torrent) # IMDBID self.__get_imdbid(torrent) # 下载系数 self.__get_downloadvolumefactor(torrent) # 上传系数 self.__get_uploadvolumefactor(torrent) # 发布时间 self.__get_pubdate(torrent) # 已发布时间 self.__get_date_elapsed(torrent) # 免费载止时间 self.__get_free_date(torrent) # 标签 self.__get_labels(torrent) # HR self.__get_hit_and_run(torrent) # 分类 self.__get_category(torrent) # 返回当前种子信息的副本,而不是引用 return self.torrents_info.copy() if self.torrents_info else {} except Exception as err: logger.error("%s 搜索出现错误:%s" % (self.indexername, str(err))) return {} finally: self.torrents_info.clear() @staticmethod def __filter_text(text: Optional[str], filters: Optional[List[dict]]) -> str: """ 对文件进行处理 """ if not text or not filters or not isinstance(filters, list): return text if not isinstance(text, str): text = str(text) for filter_item in filters: if not text: break method_name = filter_item.get("name") try: args = filter_item.get("args") if method_name == "re_search" and isinstance(args, list): rematch = re.search(r"%s" % args[0], text) if rematch: text = rematch.group(args[-1]) elif method_name == "split" and isinstance(args, list): text = text.split(r"%s" % args[0])[args[-1]] elif method_name == "replace" and isinstance(args, list): text = text.replace(r"%s" % args[0], r"%s" % args[-1]) elif method_name == "dateparse" and isinstance(args, str): text = text.replace("\n", " ").strip() text = datetime.datetime.strptime(text, r"%s" % args) elif method_name == "strip": text = text.strip() elif method_name == "appendleft": text = f"{args}{text}" elif method_name == "querystring": parsed_url = urlparse(str(text)) query_params = parse_qs(parsed_url.query) param_value = query_params.get(args) text = param_value[0] if param_value else '' except Exception as err: logger.debug(f'过滤器 {method_name} 处理失败:{str(err)} - {traceback.format_exc()}') return text.strip() @staticmethod def __remove(item: Any, selector: Optional[dict]): """ 移除元素 """ if selector and "remove" in selector: removelist = selector.get('remove', '').split(', ') for v in removelist: item.remove(v) @staticmethod def __attribute_or_text(item: Any, selector: Optional[dict]) -> list: if not selector: return item if not item: return [] if 'attribute' in selector: items = [i.attr(selector.get('attribute')) for i in item.items() if i] else: items = [i.text() for i in item.items() if i] return items @staticmethod def __index(items: Optional[list], selector: Optional[dict]) -> Optional[str]: if not items: return None if selector: if "contents" in selector \ and len(items) > int(selector.get("contents")): item = items[0].split("\n")[selector.get("contents")] elif "index" in selector \ and len(items) > int(selector.get("index")): item = items[int(selector.get("index"))] else: item = items[0] else: item = items[0] return item def parse(self, html_text: str) -> List[dict]: """ 解析整个页面 """ if not html_text: self.is_error = True return [] # 清空旧结果 self.torrents_info_array = [] html_doc = None try: # 解析站点文本对象 html_doc = PyQuery(html_text) # 种子筛选器 torrents_selector = self.list.get('selector', '') # 遍历种子html列表 for i, torn in enumerate(html_doc(torrents_selector)): if i >= int(self.result_num): break # 创建临时PyQuery对象进行解析 torrent_query = PyQuery(torn) try: # 直接获取种子信息,避免深拷贝 torrent_info = self.get_info(torrent_query) if torrent_info: # 浅拷贝即可,减少内存使用 self.torrents_info_array.append(torrent_info) finally: # 显式删除临时PyQuery对象 torrent_query.clear() del torrent_query # 返回数组的副本,防止被后续清理操作影响 return self.torrents_info_array.copy() except Exception as err: self.is_error = True logger.warn(f"错误:{self.indexername} {str(err)}") return [] finally: # 清理种子缓存 self.torrents_info_array.clear() # 清理HTML文档对象 if html_doc is not None: html_doc.clear() del html_doc # 清理html_text引用 del html_text ================================================ FILE: app/modules/indexer/spider/haidan.py ================================================ import urllib.parse from typing import Tuple, List from app.core.config import settings from app.db.systemconfig_oper import SystemConfigOper from app.log import logger from app.schemas import MediaType from app.utils.http import RequestUtils, AsyncRequestUtils from app.utils.string import StringUtils class HaiDanSpider: """ haidan.video API """ _indexerid = None _domain = None _url = None _name = "" _proxy = None _cookie = None _ua = None _size = 100 _searchurl = "%storrents.php" _detailurl = "%sdetails.php?group_id=%s&torrent_id=%s" _timeout = 15 # 电影分类 _movie_category = ['401', '404', '405'] _tv_category = ['402', '403', '404', '405'] # 足销状态 1-普通,2-免费,3-2X,4-2X免费,5-50%,6-2X50%,7-30% _dl_state = { "1": 1, "2": 0, "3": 1, "4": 0, "5": 0.5, "6": 0.5, "7": 0.3 } _up_state = { "1": 1, "2": 1, "3": 2, "4": 2, "5": 1, "6": 2, "7": 1 } def __init__(self, indexer: dict): self.systemconfig = SystemConfigOper() if indexer: self._indexerid = indexer.get('id') self._url = indexer.get('domain') self._domain = StringUtils.get_url_domain(self._url) self._searchurl = self._searchurl % self._url self._name = indexer.get('name') if indexer.get('proxy'): self._proxy = settings.PROXY self._cookie = indexer.get('cookie') self._ua = indexer.get('ua') self._timeout = indexer.get('timeout') or 15 def __get_params(self, keyword: str, mtype: MediaType = None) -> dict: """ 获取请求参数 """ def __dict_to_query(_params: dict): """ 将数组转换为逗号分隔的字符串 """ for key, value in _params.items(): if isinstance(value, list): _params[key] = ','.join(map(str, value)) return urllib.parse.urlencode(_params) if not mtype: categories = [] elif mtype == MediaType.TV: categories = self._tv_category else: categories = self._movie_category # 搜索类型 if keyword and keyword.startswith('tt'): search_area = '4' else: search_area = '0' return __dict_to_query({ "isapi": "1", "search_area": search_area, # 0-标题 1-简介(较慢)3-发种用户名 4-IMDb "search": keyword, "search_mode": "0", # 0-与 1-或 2-精准 "cat": categories }) def __parse_result(self, result: dict): """ 解析结果 """ torrents = [] data = result.get('data') or {} for tid, item in data.items(): category_value = result.get('category') if category_value in self._tv_category \ and category_value not in self._movie_category: category = MediaType.TV.value elif category_value in self._movie_category: category = MediaType.MOVIE.value else: category = MediaType.UNKNOWN.value torrent = { 'title': item.get('name'), 'description': item.get('small_descr'), 'enclosure': item.get('url'), 'pubdate': StringUtils.format_timestamp(item.get('added')), 'size': int(item.get('size') or '0'), 'seeders': int(item.get('seeders') or '0'), 'peers': int(item.get("leechers") or '0'), 'grabs': int(item.get("times_completed") or '0'), 'downloadvolumefactor': self.__get_downloadvolumefactor(item.get('sp_state')), 'uploadvolumefactor': self.__get_uploadvolumefactor(item.get('sp_state')), 'page_url': self._detailurl % (self._url, item.get('group_id'), tid), 'labels': [], 'category': category } torrents.append(torrent) return torrents def search(self, keyword: str, mtype: MediaType = None) -> Tuple[bool, List[dict]]: """ 搜索 """ # 检查cookie if not self._cookie: return True, [] # 获取参数 params_str = self.__get_params(keyword, mtype) # 发送请求 res = RequestUtils( cookies=self._cookie, ua=self._ua, proxies=self._proxy, timeout=self._timeout ).get_res(url=f"{self._searchurl}?{params_str}") if res and res.status_code == 200: result = res.json() code = result.get('code') if code != 0: logger.warn(f"{self._name} 搜索失败:{result.get('msg')}") return True, [] return False, self.__parse_result(result) elif res is not None: logger.warn(f"{self._name} 搜索失败,错误码:{res.status_code}") return True, [] else: logger.warn(f"{self._name} 搜索失败,无法连接 {self._domain}") return True, [] async def async_search(self, keyword: str, mtype: MediaType = None) -> Tuple[bool, List[dict]]: """ 异步搜索 """ # 检查cookie if not self._cookie: return True, [] # 获取参数 params_str = self.__get_params(keyword, mtype) # 发送请求 res = await AsyncRequestUtils( cookies=self._cookie, ua=self._ua, proxies=self._proxy, timeout=self._timeout ).get_res(url=f"{self._searchurl}?{params_str}") if res and res.status_code == 200: result = res.json() code = result.get('code') if code != 0: logger.warn(f"{self._name} 搜索失败:{result.get('msg')}") return True, [] return False, self.__parse_result(result) elif res is not None: logger.warn(f"{self._name} 搜索失败,错误码:{res.status_code}") return True, [] else: logger.warn(f"{self._name} 搜索失败,无法连接 {self._domain}") return True, [] def __get_downloadvolumefactor(self, discount: str) -> float: """ 获取下载系数 """ if discount: return self._dl_state.get(discount, 1) return 1 def __get_uploadvolumefactor(self, discount: str) -> float: """ 获取上传系数 """ if discount: return self._up_state.get(discount, 1) return 1 ================================================ FILE: app/modules/indexer/spider/hddolby.py ================================================ from typing import Tuple, List, Optional from app.core.config import settings from app.db.systemconfig_oper import SystemConfigOper from app.log import logger from app.schemas import MediaType from app.utils.http import RequestUtils, AsyncRequestUtils from app.utils.string import StringUtils class HddolbySpider: """ HDDolby API """ _indexerid = None _domain = None _domain_host = None _name = "" _proxy = None _cookie = None _ua = None _apikey = None _size = 40 _pageurl = None _timeout = 15 _searchurl = None # 分类 _movie_category = [401, 405] _tv_category = [402, 403, 404, 405] # 标签 _labels = { "gf": "官方", "gy": "国语", "yy": "粤语", "ja": "日语", "ko": "韩语", "zz": "中文字幕", "jz": "禁转", "xz": "限转", "diy": "DIY", "sf": "首发", "yq": "应求", "m0": "零魔", "yc": "原创", "gz": "官字", "db": "Dolby Vision", "hdr10": "HDR10", "hdrm": "HDR10+", "tx": "特效", "lz": "连载", "wj": "完结", "hdrv": "HDR Vivid", "hlg": "HLG", "hq": "高码率", "hfr": "高帧率", } def __init__(self, indexer: dict): self.systemconfig = SystemConfigOper() if indexer: self._indexerid = indexer.get('id') self._domain = indexer.get('domain') self._domain_host = StringUtils.get_url_domain(self._domain) self._name = indexer.get('name') if indexer.get('proxy'): self._proxy = settings.PROXY self._cookie = indexer.get('cookie') self._ua = indexer.get('ua') self._apikey = indexer.get('apikey') self._timeout = indexer.get('timeout') or 15 self._searchurl = f"https://api.{self._domain_host}/api/v1/torrent/search" self._pageurl = f"{self._domain}details.php?id=%s&hit=1" def __get_params(self, keyword: str, mtype: MediaType = None, page: Optional[int] = 0) -> dict: """ 获取请求参数 """ if mtype == MediaType.TV: categories = self._tv_category elif mtype == MediaType.MOVIE: categories = self._movie_category else: categories = list(set(self._movie_category + self._tv_category)) # 输入参数 return { "keyword": keyword, "page_number": page, "page_size": 100, "categories": categories, "visible": 1, } def __parse_result(self, results: List[dict]) -> List[dict]: """ 解析搜索结果 """ torrents = [] if not results: return [] for result in results: """ { "id": 120202, "promotion_time_type": 0, "promotion_until": "0000-00-00 00:00:00", "category": 402, "medium": 6, "codec": 1, "standard": 2, "team": 10, "audiocodec": 14, "leechers": 0, "seeders": 1, "name": "[DBY] Lost S06 2010 Complete 1080p Netflix WEB-DL AVC DDP5.1-DBTV", "small_descr": "lost ", "times_completed": 0, "size": 33665425886, "added": "2025-02-18 19:47:56", "url": 0, "hr": 0, "tmdb_type": "tv", "tmdb_id": 4607, "imdb_id": null, "tags": "gf" } """ # 类别 category_value = result.get('category') if category_value in self._tv_category: category = MediaType.TV.value elif category_value in self._movie_category: category = MediaType.MOVIE.value else: category = MediaType.UNKNOWN.value # 标签 torrentLabelIds = result.get('tags', "").split(";") or [] torrentLabels = [] for labelId in torrentLabelIds: if self._labels.get(labelId) is not None: torrentLabels.append(self._labels.get(labelId)) # 种子信息 torrent = { 'title': result.get('name'), 'description': result.get('small_descr'), 'enclosure': self.__get_download_url(result.get('id'), result.get('downhash')), 'pubdate': result.get('added'), 'size': result.get('size'), 'seeders': result.get('seeders'), 'peers': result.get('leechers'), 'grabs': result.get('times_completed'), 'downloadvolumefactor': self.__get_downloadvolumefactor(result.get('promotion_time_type')), 'uploadvolumefactor': self.__get_uploadvolumefactor(result.get('promotion_time_type')), 'freedate': result.get('promotion_until'), 'page_url': self._pageurl % result.get('id'), 'labels': torrentLabels, 'category': category } torrents.append(torrent) return torrents def search(self, keyword: str, mtype: MediaType = None, page: Optional[int] = 0) -> Tuple[bool, List[dict]]: """ 搜索 """ # 准备参数 params = self.__get_params(keyword, mtype, page) # 发送请求 res = RequestUtils( headers={ "Content-Type": "application/json", "Accept": "application/json, text/plain, */*", "x-api-key": self._apikey }, cookies=self._cookie, proxies=self._proxy, referer=f"{self._domain}", timeout=self._timeout ).post_res(url=self._searchurl, json=params) if res and res.status_code == 200: result = res.json() if result.get("error"): logger.warn(f"{self._name} 搜索失败,错误信息:{result.get('error').get('message')}") return True, [] return False, self.__parse_result(result.get('data')) elif res is not None: logger.warn(f"{self._name} 搜索失败,错误码:{res.status_code}") return True, [] else: logger.warn(f"{self._name} 搜索失败,无法连接 {self._domain}") return True, [] async def async_search(self, keyword: str, mtype: MediaType = None, page: Optional[int] = 0) -> Tuple[bool, List[dict]]: """ 异步搜索 """ # 准备参数 params = self.__get_params(keyword, mtype, page) # 发送请求 res = await AsyncRequestUtils( headers={ "Content-Type": "application/json", "Accept": "application/json, text/plain, */*", "x-api-key": self._apikey }, cookies=self._cookie, proxies=self._proxy, referer=f"{self._domain}", timeout=self._timeout ).post_res(url=self._searchurl, json=params) if res and res.status_code == 200: result = res.json() if result.get("error"): logger.warn(f"{self._name} 搜索失败,错误信息:{result.get('error').get('message')}") return True, [] return False, self.__parse_result(result.get('data')) elif res is not None: logger.warn(f"{self._name} 搜索失败,错误码:{res.status_code}") return True, [] else: logger.warn(f"{self._name} 搜索失败,无法连接 {self._domain}") return True, [] @staticmethod def __get_downloadvolumefactor(discount: int) -> float: """ 获取下载系数 """ discount_dict = { 2: 0, 5: 0.5, 6: 1, 7: 0.3 } if discount: return discount_dict.get(discount, 1) return 1 @staticmethod def __get_uploadvolumefactor(discount: int) -> float: """ 获取上传系数 """ discount_dict = { 3: 2, 4: 2, 6: 2 } if discount: return discount_dict.get(discount, 1) return 1 def __get_download_url(self, torrent_id: int, downhash: str) -> str: """ 获取下载链接,返回base64编码的json字符串及URL """ return f"{self._domain}download.php?id={torrent_id}&downhash={downhash}" ================================================ FILE: app/modules/indexer/spider/mtorrent.py ================================================ import base64 import json import re from typing import Tuple, List, Optional from urllib.parse import urlparse from app.core.config import settings from app.db.systemconfig_oper import SystemConfigOper from app.log import logger from app.schemas import MediaType from app.utils.http import RequestUtils, AsyncRequestUtils from app.utils.string import StringUtils class MTorrentSpider: """ mTorrent API """ _indexerid = None _domain = None _url = None _name = "" _proxy = None _cookie = None _ua = None _size = 100 _searchurl = "https://api.%s/api/torrent/search" _downloadurl = "https://api.%s/api/torrent/genDlToken" _subtitle_list_url = "https://api.%s/api/subtitle/list" _subtitle_genlink_url = "https://api.%s/api/subtitle/genlink" _subtitle_download_url ="https://api.%s/api/subtitle/dlV2?credential=%s" _pageurl = "%sdetail/%s" _timeout = 15 # 电影分类 _movie_category = ['401', '419', '420', '421', '439', '405', '404'] _tv_category = ['403', '402', '435', '438', '404', '405'] # API KEY _apikey = None # JWT Token _token = None # 标签 _labels = { "0": "", "1": "DIY", "2": "国配", "3": "DIY 国配", "4": "中字", "5": "DIY 中字", "6": "国配 中字", "7": "DIY 国配 中字" } def __init__(self, indexer: dict): self.systemconfig = SystemConfigOper() if indexer: self._indexerid = indexer.get('id') self._url = indexer.get('domain') self._domain = StringUtils.get_url_domain(self._url) self._searchurl = self._searchurl % self._domain self._name = indexer.get('name') if indexer.get('proxy'): self._proxy = settings.PROXY self._cookie = indexer.get('cookie') self._ua = indexer.get('ua') self._apikey = indexer.get('apikey') self._token = indexer.get('token') self._timeout = indexer.get('timeout') or 15 def __get_params(self, keyword: str, mtype: MediaType = None, page: Optional[int] = 0) -> dict: """ 获取请求参数 """ if not mtype: categories = [] elif mtype == MediaType.TV: categories = self._tv_category else: categories = self._movie_category # mtorrent搜索imdb需要输入完整imdb链接,参见 https://wiki.m-team.cc/zh-tw/imdbtosearch if keyword and keyword.startswith("tt"): keyword = f"https://www.imdb.com/title/{keyword}" return { "keyword": keyword, "categories": categories, "pageNumber": int(page) + 1, "pageSize": self._size, "visible": 1 } def __parse_result(self, results: List[dict]): """ 解析搜索结果 """ torrents = [] if not results: return torrents for result in results: category_value = result.get('category') if category_value in self._tv_category \ and category_value not in self._movie_category: category = MediaType.TV.value elif category_value in self._movie_category: category = MediaType.MOVIE.value else: category = MediaType.UNKNOWN.value # 处理馒头新版标签 labels = [] labels_new = result.get('labelsNew') if labels_new: # 新版标签本身就是list labels = labels_new else: # 旧版标签 labels_value = self._labels.get(result.get('labels') or "0") or "" if labels_value: labels = labels_value.split() status = result.get('status', {}) torrent = { 'title': result.get('name'), 'description': result.get('smallDescr'), 'enclosure': self.__get_download_url(result.get('id')), 'pubdate': StringUtils.format_timestamp(result.get('createdDate')), 'size': int(result.get('size') or '0'), 'seeders': int(status.get("seeders") or '0'), 'peers': int(status.get("leechers") or '0'), 'grabs': int(status.get("timesCompleted") or '0'), 'downloadvolumefactor': self.__get_downloadvolumefactor(status.get("discount")), 'uploadvolumefactor': self.__get_uploadvolumefactor(status.get("discount")), 'page_url': self._pageurl % (self._url, result.get('id')), 'imdbid': self.__find_imdbid(result.get('imdb')), 'labels': labels, 'category': category } if discount_end_time := status.get('discountEndTime'): torrent['freedate'] = StringUtils.format_timestamp(discount_end_time) # 解析全站促销时的规则(当前馒头只有下载促销) if promotion_rule := status.get("promotionRule"): discount = promotion_rule.get("discount", "NORMAL") torrent["downloadvolumefactor"] = self.__get_downloadvolumefactor(discount) if end_time := promotion_rule.get("endTime"): torrent["freedate"] = StringUtils.format_timestamp(end_time) if mall_single_free := status.get("mallSingleFree"): if mall_single_free.get("status") == "ONGOING": torrent["downloadvolumefactor"] = self.__get_downloadvolumefactor("FREE") if end_date := mall_single_free.get("endDate"): torrent["freedate"] = StringUtils.format_timestamp(end_date) torrents.append(torrent) return torrents def search(self, keyword: str, mtype: MediaType = None, page: Optional[int] = 0) -> Tuple[bool, List[dict]]: """ 搜索 """ # 检查ApiKey if not self._apikey: return True, [] # 获取请求参数 params = self.__get_params(keyword, mtype, page) # 发送请求 res = RequestUtils( headers={ "Content-Type": "application/json", "User-Agent": f"{self._ua}", "x-api-key": self._apikey }, proxies=self._proxy, referer=f"{self._domain}browse", timeout=self._timeout ).post_res(url=self._searchurl, json=params) if res and res.status_code == 200: results = res.json().get('data', {}).get("data") or [] return False, self.__parse_result(results) elif res is not None: logger.warn(f"{self._name} 搜索失败,错误码:{res.status_code}") return True, [] else: logger.warn(f"{self._name} 搜索失败,无法连接 {self._domain}") return True, [] async def async_search(self, keyword: str, mtype: MediaType = None, page: Optional[int] = 0) -> Tuple[bool, List[dict]]: """ 搜索 """ # 检查ApiKey if not self._apikey: return True, [] # 获取请求参数 params = self.__get_params(keyword, mtype, page) # 发送请求 res = await AsyncRequestUtils( headers={ "Content-Type": "application/json", "User-Agent": f"{self._ua}", "x-api-key": self._apikey }, proxies=self._proxy, referer=f"{self._domain}browse", timeout=self._timeout ).post_res(url=self._searchurl, json=params) if res and res.status_code == 200: results = res.json().get('data', {}).get("data") or [] return False, self.__parse_result(results) elif res is not None: logger.warn(f"{self._name} 搜索失败,错误码:{res.status_code}") return True, [] else: logger.warn(f"{self._name} 搜索失败,无法连接 {self._domain}") return True, [] @staticmethod def __find_imdbid(imdb: str) -> str: """ 从imdb链接中提取imdbid """ if imdb: m = re.search(r"tt\d+", imdb) if m: return m.group(0) return "" @staticmethod def __get_downloadvolumefactor(discount: str) -> float: """ 获取下载系数 """ discount_dict = { "FREE": 0, "PERCENT_50": 0.5, "PERCENT_70": 0.3, "_2X_FREE": 0, "_2X_PERCENT_50": 0.5 } if discount: return discount_dict.get(discount, 1) return 1 @staticmethod def __get_uploadvolumefactor(discount: str) -> float: """ 获取上传系数 """ uploadvolumefactor_dict = { "_2X": 2.0, "_2X_FREE": 2.0, "_2X_PERCENT_50": 2.0 } if discount: return uploadvolumefactor_dict.get(discount, 1) return 1 def __get_download_url(self, torrent_id: str) -> str: """ 获取下载链接,返回base64编码的json字符串及URL """ url = self._downloadurl % self._domain params = { 'method': 'post', 'cookie': False, 'params': { 'id': torrent_id }, 'header': { 'User-Agent': f'{self._ua}', 'Accept': 'application/json, text/plain, */*', 'x-api-key': self._apikey }, 'proxy': True if self._proxy else False, 'result': 'data' } # base64编码 base64_str = base64.b64encode(json.dumps(params).encode('utf-8')).decode('utf-8') return f"[{base64_str}]{url}" def get_subtitle_links(self, page_url: str) -> List[str]: """ 获取指定页面的字幕下载链接 :param page_url: 种子详情页网址 :type page_url: str :return: 字幕下载链接 :rtype: List[str] """ if not page_url: return [] # 从馒头的详情页网址中提取种子id torrent_id = urlparse(page_url).path.rsplit("/", 1)[-1].strip() if not torrent_id: return [] return self.get_subtitle_links_by_id(torrent_id) def get_subtitle_links_by_id(self, torrent_id: str) -> List[str]: """ 获取指定种子的字幕下载链接 :param torrent_id: 种子ID :type torrent_id: str :return: 字幕下载链接 :rtype: List[str] """ results = [] try: for subtitle_id in self.__subtitle_ids(torrent_id) or []: if link := self.__subtitle_genlink(subtitle_id): results.append(link) except Exception as e: logger.error(f"{self._name} 获取字幕失败:{e}") return results def __subtitle_ids(self, torrent_id: str) -> Optional[List[str]]: """ 获取指定种子的字幕列表 :param torrent_id: 种子ID :type torrent_id: str :return: 字幕ID :rtype: List[str] | None """ url = self._subtitle_list_url % self._domain # 发送请求 res = RequestUtils( headers={ "Accept": "application/json, text/plain, */*", "User-Agent": f"{self._ua}", "x-api-key": self._apikey, }, proxies=self._proxy, timeout=self._timeout, ).post_res(url, data={"id": torrent_id}) if res and res.status_code == 200: result = res.json() if int(result.get("code", -1)) == 0: return [item["id"] for item in result.get("data", []) if "id" in item] else: logger.warn( f"{self._name} 获取字幕列表失败,返回:{result.get("message", "未知")}" ) return None elif res is not None: logger.warn(f"{self._name} 获取字幕列表失败,错误码:{res.status_code}") return None else: logger.warn(f"{self._name} 获取字幕列表失败,无法连接 {self._domain}") return None def __subtitle_genlink(self, subtitle_id: str) -> Optional[str]: """ 获取字幕下载链接 :param subtitle_id: 字幕ID :type subtitle_id: str :return: 下载链接 :rtype: str | None """ url = self._subtitle_genlink_url % self._domain # 发送请求 res = RequestUtils( headers={ "Accept": "application/json, text/plain, */*", "User-Agent": f"{self._ua}", "x-api-key": self._apikey, }, proxies=self._proxy, timeout=self._timeout, ).post_res(url, data={"id": subtitle_id}) if res and res.status_code == 200: result = res.json() if int(result.get("code", -1)) == 0 and isinstance(result.get("data"), str): return self._subtitle_download_url % (self._domain, result["data"]) else: logger.warn( f"{self._name} 获取字幕下载链接失败,返回:{result.get("message", "未知")}" ) return None elif res is not None: logger.warn(f"{self._name} 获取字幕下载链接失败,错误码:{res.status_code}") return None else: logger.warn(f"{self._name} 获取字幕下载链接失败,无法连接 {self._domain}") return None ================================================ FILE: app/modules/indexer/spider/rousi.py ================================================ import base64 import json from typing import List, Optional, Tuple from app.core.config import settings from app.db.systemconfig_oper import SystemConfigOper from app.log import logger from app.schemas import MediaType from app.utils.http import RequestUtils, AsyncRequestUtils from app.utils.string import StringUtils class RousiSpider: """ Rousi.pro API v1 Spider 使用 API v1 接口进行种子搜索 - 认证方式:Bearer Token (Passkey) - 搜索接口:/api/v1/torrents - 详情接口:/api/v1/torrents/:id """ _indexerid = None _domain = None _url = None _name = "" _proxy = None _cookie = None _ua = None _size = 100 _searchurl = "https://%s/api/v1/torrents" _downloadurl = "https://%s/api/v1/torrents/%s" _timeout = 15 # 分类定义 # API 不支持多分类搜索,每次只使用一个分类 _movie_category = 'movie' _tv_category = 'tv' # API KEY _apikey = None def __init__(self, indexer: dict): self.systemconfig = SystemConfigOper() if indexer: self._indexerid = indexer.get('id') self._url = indexer.get('domain') self._domain = StringUtils.get_url_domain(self._url) self._searchurl = self._searchurl % self._domain self._downloadurl = self._downloadurl % (self._domain, "%s") self._name = indexer.get('name') if indexer.get('proxy'): self._proxy = settings.PROXY self._cookie = indexer.get('cookie') self._ua = indexer.get('ua') self._apikey = indexer.get('apikey') self._timeout = indexer.get('timeout') or 15 def __get_params(self, keyword: str, mtype: MediaType = None, cat: Optional[str] = None, page: Optional[int] = 0) -> dict: """ 构建 API 请求参数 :param keyword: 搜索关键词 :param mtype: 媒体类型 (MOVIE/TV) :param cat: 用户选择的分类 ID(逗号分隔的字符串) :param page: 页码(从 0 开始,API 需要从 1 开始) :return: 请求参数字典 """ params = { "page": int(page) + 1, "page_size": self._size } if keyword: params["keyword"] = keyword # API 不支持多分类搜索,只使用单个 category 参数 # 优先使用用户选择的分类,如果用户未选择则根据 mtype 推断 if cat: # 用户选择了特定分类,需要将分类 ID 映射回 API 的 category name category_names = self.__get_category_names_by_ids(cat) if category_names: # 如果用户选择了多个分类,只取第一个 params["category"] = category_names[0] elif mtype: # 用户未选择分类,根据媒体类型推断 if mtype == MediaType.MOVIE: params["category"] = self._movie_category elif mtype == MediaType.TV: params["category"] = self._tv_category return params def __get_category_names_by_ids(self, cat: str) -> Optional[list]: """ 根据用户选择的分类 ID 获取 API 的 category names :param cat: 用户选择的分类 ID(逗号分隔的多个ID,如 "1,2,3") :return: API 的 category names 列表(如 ["movie", "tv", "documentary"]) """ if not cat: return None # ID 到 category name 的映射 id_to_name = { '1': 'movie', '2': 'tv', '3': 'documentary', '4': 'animation', '6': 'variety' } # 分割多个分类 ID 并映射为 category names cat_ids = [c.strip() for c in cat.split(',') if c.strip()] category_names = [id_to_name.get(cat_id) for cat_id in cat_ids if cat_id in id_to_name] return category_names if category_names else None def __process_response(self, res) -> Tuple[bool, List[dict]]: """ 处理 API 响应 :param res: 请求响应对象 :return: (是否发生错误, 种子列表) """ if res and res.status_code == 200: try: data = res.json() if data.get('code') == 0: results = data.get('data', {}).get('torrents', []) return False, self.__parse_result(results) else: logger.warn(f"{self._name} 搜索失败,错误信息:{data.get('message')}") return True, [] except Exception as e: logger.warn(f"{self._name} 解析响应失败:{e}") return True, [] elif res is not None: logger.warn(f"{self._name} 搜索失败,HTTP 错误码:{res.status_code}") return True, [] else: logger.warn(f"{self._name} 搜索失败,无法连接 {self._domain}") return True, [] def __parse_result(self, results: List[dict]) -> List[dict]: """ 解析搜索结果 将 API 返回的种子数据转换为 MoviePilot 标准格式 :param results: API 返回的种子列表 :return: 标准化的种子信息列表 """ torrents = [] if not results: return torrents for result in results: # 解析分类信息 raw_cat = result.get('category') cat_val = None category = MediaType.UNKNOWN.value if isinstance(raw_cat, dict): cat_val = raw_cat.get('slug') or raw_cat.get('name') elif isinstance(raw_cat, str): cat_val = raw_cat if cat_val: cat_val = str(cat_val).lower() if cat_val == self._movie_category: category = MediaType.MOVIE.value elif cat_val == self._tv_category: category = MediaType.TV.value else: category = MediaType.UNKNOWN.value # 解析促销信息 # API 后端已处理全站促销优先级,直接使用返回的 promotion 数据 downloadvolumefactor = 1.0 uploadvolumefactor = 1.0 freedate = None promotion = result.get('promotion') if promotion and promotion.get('is_active'): downloadvolumefactor = float(promotion.get('down_multiplier', 1.0)) uploadvolumefactor = float(promotion.get('up_multiplier', 1.0)) # 促销到期时间,格式化为 YYYY-MM-DD HH:MM:SS if promotion.get('until'): freedate = StringUtils.unify_datetime_str(promotion.get('until')) torrent = { 'title': result.get('title'), 'description': result.get('subtitle'), 'enclosure': self.__get_download_url(result.get('id')), 'pubdate': StringUtils.unify_datetime_str(result.get('created_at')), 'size': int(result.get('size') or 0), 'seeders': int(result.get('seeders') or 0), 'peers': int(result.get('leechers') or 0), 'grabs': int(result.get('downloads') or 0), 'downloadvolumefactor': downloadvolumefactor, 'uploadvolumefactor': uploadvolumefactor, 'freedate': freedate, 'page_url': f"https://{self._domain}/torrent/{result.get('uuid')}", 'labels': [], 'category': category } torrents.append(torrent) return torrents def search(self, keyword: str, mtype: MediaType = None, cat: Optional[str] = None, page: Optional[int] = 0) -> Tuple[bool, List[dict]]: """ 同步搜索种子 :param keyword: 搜索关键词 :param mtype: 媒体类型 (MOVIE/TV) :param cat: 用户选择的分类 ID(逗号分隔) :param page: 页码(从 0 开始) :return: (是否发生错误, 种子列表) """ if not self._apikey: logger.warn(f"{self._name} 未配置 API Key (Passkey)") return True, [] params = self.__get_params(keyword, mtype, cat, page) headers = { "Authorization": f"Bearer {self._apikey}", "Accept": "application/json" } res = RequestUtils( headers=headers, proxies=self._proxy, timeout=self._timeout ).get_res(url=self._searchurl, params=params) return self.__process_response(res) async def async_search(self, keyword: str, mtype: MediaType = None, cat: Optional[str] = None, page: Optional[int] = 0) -> Tuple[bool, List[dict]]: """ 异步搜索种子 :param keyword: 搜索关键词 :param mtype: 媒体类型 (MOVIE/TV) :param cat: 用户选择的分类 ID(逗号分隔) :param page: 页码(从 0 开始) :return: (是否发生错误, 种子列表) """ if not self._apikey: logger.warn(f"{self._name} 未配置 API Key (Passkey)") return True, [] params = self.__get_params(keyword, mtype, cat, page) headers = { "Authorization": f"Bearer {self._apikey}", "Accept": "application/json" } res = await AsyncRequestUtils( headers=headers, proxies=self._proxy, timeout=self._timeout ).get_res(url=self._searchurl, params=params) return self.__process_response(res) def __get_download_url(self, torrent_id: int) -> str: """ 构建种子下载链接 使用 base64 编码的方式告诉 MoviePilot 如何获取真实下载地址 MoviePilot 会先请求详情接口,然后从响应中提取 data.download_url :param torrent_id: 种子 ID :return: base64 编码的请求配置字符串 + 详情接口 URL """ url = self._downloadurl % torrent_id # MoviePilot 会解析这个特殊格式的 URL: # 1. 使用指定的 method 和 header 请求 URL # 2. 从 JSON 响应中提取 result 指定的字段值作为真实下载地址 params = { 'method': 'get', 'header': { 'Authorization': f'Bearer {self._apikey}', 'Accept': 'application/json' }, 'result': 'data.download_url' } base64_str = base64.b64encode(json.dumps(params).encode('utf-8')).decode('utf-8') return f"[{base64_str}]{url}" ================================================ FILE: app/modules/indexer/spider/tnode.py ================================================ import re from typing import Tuple, List, Optional from app.core.cache import cached from app.core.config import settings from app.log import logger from app.utils.http import RequestUtils, AsyncRequestUtils from app.utils.singleton import SingletonClass from app.utils.string import StringUtils class TNodeSpider(metaclass=SingletonClass): _size = 100 _timeout = 15 _proxy = None _baseurl = "%sapi/torrent/advancedSearch" _downloadurl = "%sapi/torrent/download/%s" _pageurl = "%storrent/info/%s" def __init__(self, indexer: dict): if indexer: self._indexerid = indexer.get('id') self._domain = indexer.get('domain') self._searchurl = self._baseurl % self._domain self._name = indexer.get('name') if indexer.get('proxy'): self._proxy = settings.PROXY self._cookie = indexer.get('cookie') self._ua = indexer.get('ua') self._timeout = indexer.get('timeout') or 15 @cached(region="indexer_spider", maxsize=1, ttl=60 * 60 * 24, skip_empty=True, shared_key="get_token") def __get_token(self) -> Optional[str]: if not self._domain: return res = RequestUtils(ua=self._ua, cookies=self._cookie, proxies=self._proxy, timeout=self._timeout).get_res(url=self._domain) if res and res.status_code == 200: csrf_token = re.search(r'', res.text) if csrf_token: return csrf_token.group(1) return None @cached(region="indexer_spider", maxsize=1, ttl=60 * 60 * 24, skip_empty=True, shared_key="get_token") async def __async_get_token(self) -> Optional[str]: if not self._domain: return res = await AsyncRequestUtils(ua=self._ua, cookies=self._cookie, proxies=self._proxy, timeout=self._timeout).get_res(url=self._domain) if res and res.status_code == 200: csrf_token = re.search(r'', res.text) if csrf_token: return csrf_token.group(1) return None def __get_params(self, keyword: str = None, page: Optional[int] = 0) -> dict: """ 获取搜索参数 """ search_type = "imdbid" if (keyword and keyword.startswith('tt')) else "title" return { "page": int(page) + 1, "size": self._size, "type": search_type, "keyword": keyword or "", "sorter": "id", "order": "desc", "tags": [], "category": [501, 502, 503, 504], "medium": [], "videoCoding": [], "audioCoding": [], "resolution": [], "group": [] } def __parse_result(self, results: List[dict]) -> List[dict]: """ 解析搜索结果 """ torrents = [] if not results: return torrents for result in results: torrent = { 'title': result.get('title'), 'description': result.get('subtitle'), 'enclosure': self._downloadurl % (self._domain, result.get('id')), 'pubdate': StringUtils.format_timestamp(result.get('upload_time')), 'size': result.get('size'), 'seeders': result.get('seeding'), 'peers': result.get('leeching'), 'grabs': result.get('complete'), 'downloadvolumefactor': result.get('downloadRate'), 'uploadvolumefactor': result.get('uploadRate'), 'page_url': self._pageurl % (self._domain, result.get('id')), 'imdbid': result.get('imdb') } torrents.append(torrent) return torrents def search(self, keyword: str, page: Optional[int] = 0) -> Tuple[bool, List[dict]]: """ 搜索 """ # 获取token _token = self.__get_token() if not _token: logger.warn(f"{self._name} 未获取到token,无法搜索") return True, [] # 获取请求参数 params = self.__get_params(keyword, page) # 发送请求 res = RequestUtils( headers={ 'X-CSRF-TOKEN': _token, "Content-Type": "application/json; charset=utf-8", "User-Agent": f"{self._ua}" }, cookies=self._cookie, proxies=self._proxy, timeout=self._timeout ).post_res(url=self._searchurl, json=params) if res and res.status_code == 200: results = res.json().get('data', {}).get("torrents") or [] return False, self.__parse_result(results) elif res is not None: logger.warn(f"{self._name} 搜索失败,错误码:{res.status_code}") return True, [] else: logger.warn(f"{self._name} 搜索失败,无法连接 {self._domain}") return True, [] async def async_search(self, keyword: str, page: Optional[int] = 0) -> Tuple[bool, List[dict]]: """ 异步搜索 """ # 获取token _token = await self.__async_get_token() if not _token: logger.warn(f"{self._name} 未获取到token,无法搜索") return True, [] # 获取请求参数 params = self.__get_params(keyword, page) # 发送请求 res = await AsyncRequestUtils( headers={ 'x-csrf-token': _token, "Content-Type": "application/json; charset=utf-8", "User-Agent": f"{self._ua}" }, cookies=self._cookie, proxies=self._proxy, timeout=self._timeout ).post_res(url=self._searchurl, json=params) if res and res.status_code == 200: results = res.json().get('data', {}).get("torrents") or [] return False, self.__parse_result(results) elif res is not None: logger.warn(f"{self._name} 搜索失败,错误码:{res.status_code}") return True, [] else: logger.warn(f"{self._name} 搜索失败,无法连接 {self._domain}") return True, [] ================================================ FILE: app/modules/indexer/spider/torrentleech.py ================================================ from typing import List, Tuple, Optional from urllib.parse import quote from app.core.config import settings from app.log import logger from app.utils.http import RequestUtils, AsyncRequestUtils from app.utils.string import StringUtils class TorrentLeech: _indexer = None _proxy = None _size = 100 _searchurl = "%storrents/browse/list/query/%s" _browseurl = "%storrents/browse/list/page/2%s" _downloadurl = "%sdownload/%s/%s" _pageurl = "%storrent/%s" _timeout = 15 def __init__(self, indexer: dict): self._indexer = indexer if indexer.get('proxy'): self._proxy = settings.PROXY self._timeout = indexer.get('timeout') or 15 def __parse_result(self, results: List[dict]) -> List[dict]: """ 解析搜索结果 """ torrents = [] if not results: return torrents for result in results: torrent = { 'title': result.get('name'), 'enclosure': self._downloadurl % (self._indexer.get('domain'), result.get('fid'), result.get('filename')), 'pubdate': StringUtils.format_timestamp(result.get('addedTimestamp')), 'size': result.get('size'), 'seeders': result.get('seeders'), 'peers': result.get('leechers'), 'grabs': result.get('completed'), 'downloadvolumefactor': result.get('download_multiplier'), 'uploadvolumefactor': 1, 'page_url': self._pageurl % (self._indexer.get('domain'), result.get('fid')), 'imdbid': result.get('imdbID') } torrents.append(torrent) return torrents def search(self, keyword: str, page: Optional[int] = 0) -> Tuple[bool, List[dict]]: """ 搜索种子 """ if StringUtils.is_chinese(keyword): # 不支持中文 return True, [] if keyword: url = self._searchurl % (self._indexer.get('domain'), quote(keyword)) else: url = self._browseurl % (self._indexer.get('domain'), int(page) + 1) res = RequestUtils( headers={ "Content-Type": "application/json; charset=utf-8", "User-Agent": f"{self._indexer.get('ua')}", }, cookies=self._indexer.get('cookie'), proxies=self._proxy, timeout=self._timeout ).get_res(url) if res and res.status_code == 200: results = res.json().get('torrentList') or [] return False, self.__parse_result(results) elif res is not None: logger.warn(f"{self._indexer.get('name')} 搜索失败,错误码:{res.status_code}") return True, [] else: logger.warn(f"{self._indexer.get('name')} 搜索失败,无法连接 {self._indexer.get('domain')}") return True, [] async def async_search(self, keyword: str, page: Optional[int] = 0) -> Tuple[bool, List[dict]]: """ 异步搜索种子 """ if StringUtils.is_chinese(keyword): # 不支持中文 return True, [] if keyword: url = self._searchurl % (self._indexer.get('domain'), quote(keyword)) else: url = self._browseurl % (self._indexer.get('domain'), int(page) + 1) res = await AsyncRequestUtils( headers={ "Content-Type": "application/json; charset=utf-8", "User-Agent": f"{self._indexer.get('ua')}", }, cookies=self._indexer.get('cookie'), proxies=self._proxy, timeout=self._timeout ).get_res(url) if res and res.status_code == 200: results = res.json().get('torrentList') or [] return False, self.__parse_result(results) elif res is not None: logger.warn(f"{self._indexer.get('name')} 搜索失败,错误码:{res.status_code}") return True, [] else: logger.warn(f"{self._indexer.get('name')} 搜索失败,无法连接 {self._indexer.get('domain')}") return True, [] ================================================ FILE: app/modules/indexer/spider/yema.py ================================================ from typing import Tuple, List, Optional from app.core.config import settings from app.db.systemconfig_oper import SystemConfigOper from app.log import logger from app.schemas import MediaType from app.utils.http import RequestUtils, AsyncRequestUtils from app.utils.string import StringUtils class YemaSpider: """ YemaPT API """ _indexerid = None _domain = None _name = "" _proxy = None _cookie = None _ua = None _size = 40 _searchurl = "%sapi/torrent/fetchOpenTorrentList" _downloadurl = "%sapi/torrent/download?id=%s" _pageurl = "%s#/torrent/detail/%s/" _timeout = 15 # 分类 _movie_category = [4] _tv_category = [5, 13, 14, 17, 15, 6, 16] # 标签 https://wiki.yemapt.org/developer/constants _labels = { "1": "禁转", "2": "首发", "3": "官方", "4": "自制", "5": "国语", "6": "中字", "7": "粤语", "8": "英字", "9": "HDR10", "10": "杜比视界", "11": "分集", "12": "完结", } def __init__(self, indexer: dict): self.systemconfig = SystemConfigOper() if indexer: self._indexerid = indexer.get('id') self._domain = indexer.get('domain') self._searchurl = self._searchurl % self._domain self._name = indexer.get('name') if indexer.get('proxy'): self._proxy = settings.PROXY self._cookie = indexer.get('cookie') self._ua = indexer.get('ua') self._timeout = indexer.get('timeout') or 15 def __get_params(self, keyword: str = None, page: Optional[int] = 0) -> dict: """ 获取搜索参数 """ params = { "pageParam": { "current": page + 1, "pageSize": self._size, "total": self._size }, "sorter": {} } if keyword: params.update({ "keyword": keyword, }) return params def __parse_result(self, results: List[dict]) -> List[dict]: """ 解析搜索结果 """ torrents = [] if not results: return torrents for result in results: category_value = result.get('categoryId') if category_value in self._tv_category: category = MediaType.TV.value elif category_value in self._movie_category: category = MediaType.MOVIE.value else: category = MediaType.UNKNOWN.value pass torrentLabelIds = result.get('tagList', []) or [] torrentLabels = [] for labelId in torrentLabelIds: if self._labels.get(labelId) is not None: torrentLabels.append(self._labels.get(labelId)) pass pass torrent = { 'title': result.get('showName'), 'description': result.get('shortDesc'), 'enclosure': self.__get_download_url(result.get('id')), 'pubdate': StringUtils.unify_datetime_str(result.get('listingTime')), 'size': result.get('fileSize'), 'seeders': result.get('seedNum'), 'peers': result.get('leechNum'), 'grabs': result.get('completedNum'), 'downloadvolumefactor': self.__get_downloadvolumefactor(result.get('downloadPromotion')), 'uploadvolumefactor': self.__get_uploadvolumefactor(result.get('uploadPromotion')), 'freedate': StringUtils.unify_datetime_str(result.get('downloadPromotionEndTime')), 'page_url': self._pageurl % (self._domain, result.get('id')), 'labels': torrentLabels, 'category': category } torrents.append(torrent) return torrents def search(self, keyword: str, mtype: MediaType = None, page: Optional[int] = 0) -> Tuple[bool, List[dict]]: """ 搜索 """ res = RequestUtils( headers={ "Content-Type": "application/json", "User-Agent": f"{self._ua}", "Accept": "application/json, text/plain, */*" }, cookies=self._cookie, proxies=self._proxy, referer=f"{self._domain}", timeout=self._timeout ).post_res(url=self._searchurl, json=self.__get_params(keyword, page)) if res and res.status_code == 200: results = res.json().get('data', []) or [] return False, self.__parse_result(results) elif res is not None: logger.warn(f"{self._name} 搜索失败,错误码:{res.status_code}") return True, [] else: logger.warn(f"{self._name} 搜索失败,无法连接 {self._domain}") return True, [] async def async_search(self, keyword: str, mtype: MediaType = None, page: Optional[int] = 0) -> Tuple[bool, List[dict]]: """ 异步搜索 """ res = await AsyncRequestUtils( headers={ "Content-Type": "application/json", "User-Agent": f"{self._ua}", "Accept": "application/json, text/plain, */*" }, cookies=self._cookie, proxies=self._proxy, referer=f"{self._domain}", timeout=self._timeout ).post_res(url=self._searchurl, json=self.__get_params(keyword, page)) if res and res.status_code == 200: results = res.json().get('data', []) or [] return False, self.__parse_result(results) elif res is not None: logger.warn(f"{self._name} 搜索失败,错误码:{res.status_code}") return True, [] else: logger.warn(f"{self._name} 搜索失败,无法连接 {self._domain}") return True, [] @staticmethod def __get_downloadvolumefactor(discount: str) -> float: """ 获取下载系数 """ discount_dict = { "free": 0, "half": 0.5, "none": 1 } if discount: return discount_dict.get(discount, 1) return 1 @staticmethod def __get_uploadvolumefactor(discount: str) -> float: """ 获取上传系数 """ discount_dict = { "none": 1, "one_half": 1.5, "double_upload": 2 } if discount: return discount_dict.get(discount, 1) return 1 def __get_download_url(self, torrent_id: str) -> str: """ 获取下载链接 """ return self._downloadurl % (self._domain, torrent_id) ================================================ FILE: app/modules/jellyfin/__init__.py ================================================ from typing import Any, Generator, List, Optional, Tuple, Union from app import schemas from app.core.context import MediaInfo from app.core.event import eventmanager from app.log import logger from app.modules import _MediaServerBase, _ModuleBase from app.modules.jellyfin.jellyfin import Jellyfin from app.schemas import AuthCredentials, AuthInterceptCredentials from app.schemas.types import MediaType, ModuleType, ChainEventType, MediaServerType class JellyfinModule(_ModuleBase, _MediaServerBase[Jellyfin]): def init_module(self) -> None: """ 初始化模块 """ super().init_service(service_name=Jellyfin.__name__.lower(), service_type=lambda conf: Jellyfin(**conf.config, sync_libraries=conf.sync_libraries)) @staticmethod def get_name() -> str: return "Jellyfin" @staticmethod def get_type() -> ModuleType: """ 获取模块类型 """ return ModuleType.MediaServer @staticmethod def get_subtype() -> MediaServerType: """ 获取模块子类型 """ return MediaServerType.Jellyfin @staticmethod def get_priority() -> int: """ 获取模块优先级,数字越小优先级越高,只有同一接口下优先级才生效 """ return 2 def init_setting(self) -> Tuple[str, Union[str, bool]]: pass def scheduler_job(self) -> None: """ 定时任务,每10分钟调用一次 """ # 定时重连 for name, server in self.get_instances().items(): if server.is_inactive(): logger.info(f"Jellyfin {name} 服务器连接断开,尝试重连 ...") server.reconnect() def stop(self): pass def test(self) -> Optional[Tuple[bool, str]]: """ 测试模块连接性 """ if not self.get_instances(): return None for name, server in self.get_instances().items(): if server.is_inactive(): server.reconnect() if not server.get_user(): return False, f"无法连接Jellyfin服务器:{name}" return True, "" def user_authenticate(self, credentials: AuthCredentials, service_name: Optional[str] = None) \ -> Optional[AuthCredentials]: """ 使用Jellyfin用户辅助完成用户认证 :param credentials: 认证数据 :param service_name: 指定要认证的媒体服务器名称,若为 None 则认证所有服务 :return: 认证数据 """ # Jellyfin认证 if not credentials or credentials.grant_type != "password": return None # 确定要认证的服务器列表 if service_name: # 如果指定了服务名,获取该服务实例 servers = [(service_name, server)] if (server := self.get_instance(service_name)) else [] else: # 如果没有指定服务名,遍历所有服务 servers = self.get_instances().items() # 遍历要认证的服务器 for name, server in servers: # 触发认证拦截事件 intercept_event = eventmanager.send_event( etype=ChainEventType.AuthIntercept, data=AuthInterceptCredentials(username=credentials.username, channel=self.get_name(), service=name, status="triggered") ) if intercept_event and intercept_event.event_data: intercept_data: AuthInterceptCredentials = intercept_event.event_data if intercept_data.cancel: continue token = server.authenticate(credentials.username, credentials.password) if token: credentials.channel = self.get_name() credentials.service = name credentials.token = token return credentials return None def webhook_parser(self, body: Any, form: Any, args: Any) -> Optional[schemas.WebhookEventInfo]: """ 解析Webhook报文体 :param body: 请求体 :param form: 请求表单 :param args: 请求参数 :return: 字典,解析为消息时需要包含:title、text、image """ source = args.get("source") if source: server: Jellyfin = self.get_instance(source) if not server: return None result = server.get_webhook_message(body) if result: result.server_name = source return result for server in self.get_instances().values(): if server: result = server.get_webhook_message(body) if result: return result return None def media_exists(self, mediainfo: MediaInfo, itemid: Optional[str] = None, server: Optional[str] = None) -> Optional[schemas.ExistMediaInfo]: """ 判断媒体文件是否存在 :param mediainfo: 识别的媒体信息 :param itemid: 媒体服务器ItemID :param server: 媒体服务器名称 :return: 如不存在返回None,存在时返回信息,包括每季已存在所有集{type: movie/tv, seasons: {season: [episodes]}} """ if server: servers = [(server, self.get_instance(server))] else: servers = self.get_instances().items() for name, s in servers: if not s: continue if mediainfo.type == MediaType.MOVIE: if itemid: movie = s.get_iteminfo(itemid) if movie: logger.info(f"媒体库 {name} 中找到了 {movie}") return schemas.ExistMediaInfo( type=MediaType.MOVIE, server_type="jellyfin", server=name, itemid=movie.item_id ) movies = s.get_movies(title=mediainfo.title, year=mediainfo.year, tmdb_id=mediainfo.tmdb_id) if not movies: logger.info(f"{mediainfo.title_year} 没有在媒体库 {name} 中") continue else: logger.info(f"媒体库 {name} 中找到了 {movies}") return schemas.ExistMediaInfo( type=MediaType.MOVIE, server_type="jellyfin", server=name, itemid=movies[0].item_id ) else: itemid, tvs = s.get_tv_episodes(title=mediainfo.title, year=mediainfo.year, tmdb_id=mediainfo.tmdb_id, item_id=itemid) if not tvs: logger.info(f"{mediainfo.title_year} 没有在媒体库 {name} 中") continue else: logger.info(f"{mediainfo.title_year} 在媒体库 {name} 中找到了这些季集:{tvs}") return schemas.ExistMediaInfo( type=MediaType.TV, seasons=tvs, server_type="jellyfin", server=name, itemid=itemid ) return None def media_statistic(self, server: Optional[str] = None) -> Optional[List[schemas.Statistic]]: """ 媒体数量统计 """ if server: server_obj: Jellyfin = self.get_instance(server) if not server_obj: return None servers = [server_obj] else: servers = self.get_instances().values() media_statistics = [] for s in servers: media_statistic = s.get_medias_count() if not media_statistic: continue media_statistic.user_count = s.get_user_count() media_statistics.append(media_statistic) return media_statistics def mediaserver_librarys(self, server: Optional[str] = None, username: Optional[str] = None, hidden: Optional[bool] = False) -> Optional[List[schemas.MediaServerLibrary]]: """ 媒体库列表 """ server_obj: Jellyfin = self.get_instance(server) if server_obj: return server_obj.get_librarys(username=username, hidden=hidden) return None def mediaserver_items(self, server: str, library_id: Union[str, int], start_index: Optional[int] = 0, limit: Optional[int] = -1) -> Optional[Generator]: """ 获取媒体服务器项目列表,支持分页和不分页逻辑,默认不分页获取所有数据 :param server: 媒体服务器名称 :param library_id: 媒体库ID,用于标识要获取的媒体库 :param start_index: 起始索引,用于分页获取数据。默认为 0,即从第一个项目开始获取 :param limit: 每次请求的最大项目数,用于分页。如果为 None 或 -1,则表示一次性获取所有数据,默认为 -1 :return: 返回一个生成器对象,用于逐步获取媒体服务器中的项目 """ server_obj: Jellyfin = self.get_instance(server) if server_obj: return server_obj.get_items(library_id, start_index, limit) return None def mediaserver_iteminfo(self, server: str, item_id: str) -> Optional[schemas.MediaServerItem]: """ 媒体库项目详情 """ server_obj: Jellyfin = self.get_instance(server) if server_obj: return server_obj.get_iteminfo(item_id) return None def mediaserver_tv_episodes(self, server: str, item_id: Union[str, int]) -> Optional[List[schemas.MediaServerSeasonInfo]]: """ 获取剧集信息 """ server_obj: Jellyfin = self.get_instance(server) if not server_obj: return None _, seasoninfo = server_obj.get_tv_episodes(item_id=item_id) if not seasoninfo: return [] return [schemas.MediaServerSeasonInfo( season=season, episodes=episodes ) for season, episodes in seasoninfo.items()] def mediaserver_playing(self, server: str, count: Optional[int] = 20, username: Optional[str] = None) -> List[schemas.MediaServerPlayItem]: """ 获取媒体服务器正在播放信息 """ server_obj: Jellyfin = self.get_instance(server) if not server_obj: return [] return server_obj.get_resume(num=count, username=username) def mediaserver_play_url(self, server: str, item_id: Union[str, int]) -> Optional[str]: """ 获取媒体库播放地址 """ server_obj: Jellyfin = self.get_instance(server) if not server_obj: return None return server_obj.get_play_url(item_id) def mediaserver_latest(self, server: Optional[str] = None, count: Optional[int] = 20, username: Optional[str] = None) -> List[schemas.MediaServerPlayItem]: """ 获取媒体服务器最新入库条目 """ server_obj: Jellyfin = self.get_instance(server) if not server_obj: return [] return server_obj.get_latest(num=count, username=username) def mediaserver_latest_images(self, server: Optional[str] = None, count: Optional[int] = 20, username: Optional[str] = None, remote: Optional[bool] = False, ) -> List[str]: """ 获取媒体服务器最新入库条目的图片 :param server: 媒体服务器名称 :param count: 获取数量 :param username: 用户名 :param remote: True为外网链接, False为内网链接 :return: 图片链接列表 """ server_obj: Jellyfin = self.get_instance(server) if not server: return [] links = [] items: List[schemas.MediaServerPlayItem] = self.mediaserver_latest(server=server, count=count, username=username) for item in items: if item.BackdropImageTags: image_url = server_obj.get_backdrop_url(item_id=item.id, image_tag=item.BackdropImageTags[0], remote=remote) if image_url: links.append(image_url) return links ================================================ FILE: app/modules/jellyfin/jellyfin.py ================================================ import json from datetime import datetime from typing import List, Union, Optional, Dict, Generator, Tuple, Any from requests import Response from app import schemas from app.core.config import settings from app.log import logger from app.schemas import MediaType from app.utils.http import RequestUtils from app.utils.url import UrlUtils from app.schemas import MediaServerItem class Jellyfin: _host: Optional[str] = None _apikey: Optional[str] = None _playhost: Optional[str] = None _sync_libraries: List[str] = [] user: Optional[Union[str, int]] = None def __init__(self, host: Optional[str] = None, apikey: Optional[str] = None, play_host: Optional[str] = None, sync_libraries: list = None, **kwargs): if not host or not apikey: logger.error("Jellyfin服务器配置不完整!!") return self._host = host if self._host: self._host = UrlUtils.standardize_base_url(self._host) self._playhost = play_host if self._playhost: self._playhost = UrlUtils.standardize_base_url(self._playhost) self._apikey = apikey self.user = self.get_user(settings.SUPERUSER) self.serverid = self.get_server_id() self._sync_libraries = sync_libraries or [] def is_inactive(self) -> bool: """ 判断是否需要重连 """ if not self._host or not self._apikey: return False return True if not self.user else False def reconnect(self): """ 重连 """ self.user = self.get_user() self.serverid = self.get_server_id() def get_jellyfin_folders(self) -> List[dict]: """ 获取Jellyfin媒体库路径列表 """ if not self._host or not self._apikey: return [] url = f"{self._host}Library/SelectableMediaFolders" params = { 'api_key': self._apikey } try: res = RequestUtils().get_res(url, params) if res: return res.json() else: logger.error(f"Library/SelectableMediaFolders 未获取到返回数据") return [] except Exception as e: logger.error(f"连接Library/SelectableMediaFolders 出错:" + str(e)) return [] def get_jellyfin_virtual_folders(self) -> List[dict]: """ 获取Jellyfin媒体库所有路径列表(包含共享路径) """ if not self._host or not self._apikey: return [] url = f"{self._host}Library/VirtualFolders" params = { 'api_key': self._apikey } try: res = RequestUtils().get_res(url, params) if res: library_items = res.json() librarys = [] for library_item in library_items: library_id = library_item.get('ItemId') library_name = library_item.get('Name') pathInfos = library_item.get('LibraryOptions', {}).get('PathInfos') library_paths = [] for path in pathInfos: if path.get('NetworkPath'): library_paths.append(path.get('NetworkPath')) else: library_paths.append(path.get('Path')) if library_name and library_paths: librarys.append({ 'Id': library_id, 'Name': library_name, 'Path': library_paths }) return librarys else: logger.error(f"Library/VirtualFolders 未获取到返回数据") return [] except Exception as e: logger.error(f"连接Library/VirtualFolders 出错:" + str(e)) return [] def __get_jellyfin_librarys(self, username: Optional[str] = None) -> List[dict]: """ 获取Jellyfin媒体库的信息 """ if not self._host or not self._apikey: return [] if username: user = self.get_user(username) else: user = self.user url = f"{self._host}Users/{user}/Views" params = {"api_key": self._apikey} try: res = RequestUtils().get_res(url, params) if res: return res.json().get("Items") else: logger.error(f"Users/Views 未获取到返回数据") return [] except Exception as e: logger.error(f"连接Users/Views 出错:" + str(e)) return [] def get_librarys(self, username: Optional[str] = None, hidden: Optional[bool] = False) -> List[schemas.MediaServerLibrary]: """ 获取媒体服务器所有媒体库列表 """ if not self._host or not self._apikey: return [] libraries = [] for library in self.__get_jellyfin_librarys(username) or []: if hidden and self._sync_libraries and "all" not in self._sync_libraries \ and library.get("Id") not in self._sync_libraries: continue if library.get("CollectionType") == "movies": library_type = MediaType.MOVIE.value link = f"{self._playhost or self._host}web/index.html#!" \ f"/movies.html?topParentId={library.get('Id')}" elif library.get("CollectionType") == "tvshows": library_type = MediaType.TV.value link = f"{self._playhost or self._host}web/index.html#!" \ f"/tv.html?topParentId={library.get('Id')}" else: library_type = MediaType.UNKNOWN.value link = f"{self._playhost or self._host}web/index.html#!" \ f"/library.html?topParentId={library.get('Id')}" image = self.__get_local_image_by_id(library.get("Id")) libraries.append( schemas.MediaServerLibrary( server="jellyfin", id=library.get("Id"), name=library.get("Name"), path=library.get("Path"), type=library_type, image=image, link=link, server_type="jellyfin" )) return libraries def get_user_count(self) -> int: """ 获得用户数量 """ if not self._host or not self._apikey: return 0 url = f"{self._host}Users" params = { "api_key": self._apikey } try: res = RequestUtils().get_res(url, params) if res: return len(res.json()) else: logger.error(f"Users 未获取到返回数据") return 0 except Exception as e: logger.error(f"连接Users出错:" + str(e)) return 0 def get_user(self, user_name: Optional[str] = None) -> Optional[Union[str, int]]: """ 获得管理员用户 """ if not self._host or not self._apikey: return None url = f"{self._host}Users" params = { "api_key": self._apikey } try: res = RequestUtils().get_res(url, params) if res: users = res.json() # 先查询是否有与当前用户名称匹配的 if user_name: for user in users: if user.get("Name") == user_name: return user.get("Id") # 查询管理员 for user in users: if user.get("Policy", {}).get("IsAdministrator"): return user.get("Id") else: logger.error(f"Users 未获取到返回数据") except Exception as e: logger.error(f"连接Users出错:" + str(e)) return None def authenticate(self, username: str, password: str) -> Optional[str]: """ 用户认证 :param username: 用户名 :param password: 密码 :return: 认证成功返回token,否则返回None """ if not self._host or not self._apikey: return None url = f"{self._host}Users/authenticatebyname" try: res = RequestUtils(headers={ 'X-Emby-Authorization': f'MediaBrowser Client="MoviePilot", ' f'Device="requests", ' f'DeviceId="1", ' f'Version="1.0.0", ' f'Token="{self._apikey}"', 'Content-Type': 'application/json', "Accept": "application/json" }).post_res( url=url, data=json.dumps({ "Username": username, "Pw": password }) ) if res: auth_token = res.json().get("AccessToken") if auth_token: logger.info(f"用户 {username} Jellyfin认证成功") return auth_token else: logger.error(f"Users/AuthenticateByName 未获取到返回数据") except Exception as e: logger.error(f"连接Users/AuthenticateByName出错:" + str(e)) return None def get_server_id(self) -> Optional[str]: """ 获得服务器信息 """ if not self._host or not self._apikey: return None url = f"{self._host}System/Info" params = { 'api_key': self._apikey } try: res = RequestUtils().get_res(url, params) if res: return res.json().get("Id") else: logger.error(f"System/Info 未获取到返回数据") except Exception as e: logger.error(f"连接System/Info出错:" + str(e)) return None def get_medias_count(self) -> schemas.Statistic: """ 获得电影、电视剧、动漫媒体数量 :return: MovieCount SeriesCount SongCount """ if not self._host or not self._apikey: return schemas.Statistic() url = f"{self._host}Items/Counts" params = { 'api_key': self._apikey } try: res = RequestUtils().get_res(url, params) if res: result = res.json() return schemas.Statistic( movie_count=result.get("MovieCount") or 0, tv_count=result.get("SeriesCount") or 0, episode_count=result.get("EpisodeCount") or 0 ) else: logger.error(f"Items/Counts 未获取到返回数据") return schemas.Statistic() except Exception as e: logger.error(f"连接Items/Counts出错:" + str(e)) return schemas.Statistic() def __get_jellyfin_series_id_by_name(self, name: str, year: str) -> Optional[str]: """ 根据名称查询Jellyfin中剧集的SeriesId """ if not self._host or not self._apikey or not self.user: return None url = f"{self._host}Users/{self.user}/Items" params = { "IncludeItemTypes": "Series", "Recursive": "true", "searchTerm": name, "Limit": 10, "api_key": self._apikey } try: res = RequestUtils().get_res(url, params) if res: res_items = res.json().get("Items") if res_items: for res_item in res_items: if res_item.get('Name') == name and ( not year or str(res_item.get('ProductionYear')) == str(year)): return res_item.get('Id') except Exception as e: logger.error(f"连接Items出错:" + str(e)) return None return "" def get_movies(self, title: str, year: Optional[str] = None, tmdb_id: Optional[int] = None) -> Optional[List[schemas.MediaServerItem]]: """ 根据标题和年份,检查电影是否在Jellyfin中存在,存在则返回列表 :param title: 标题 :param year: 年份,为空则不过滤 :param tmdb_id: TMDB ID :return: 含title、year属性的字典列表 """ if not self._host or not self._apikey or not self.user: return None url = f"{self._host}Users/{self.user}/Items" params = { "IncludeItemTypes": "Movie", "Fields": "ProviderIds,OriginalTitle,ProductionYear,Path,UserDataPlayCount,UserDataLastPlayedDate,ParentId", "StartIndex": 0, "Recursive": "true", "searchTerm": title, "Limit": 10, "api_key": self._apikey } try: res = RequestUtils().get_res(url, params) if res: res_items = res.json().get("Items") if res_items: ret_movies = [] for item in res_items: if not item: continue mediaserver_item = self.__format_item_info(item) if mediaserver_item: if (not tmdb_id or mediaserver_item.tmdbid == tmdb_id) and \ mediaserver_item.title == title and \ (not year or str(mediaserver_item.year) == str(year)): ret_movies.append(mediaserver_item) return ret_movies except Exception as e: logger.error(f"连接Items出错:" + str(e)) return None return [] def get_tv_episodes(self, item_id: Optional[str] = None, title: Optional[str] = None, year: Optional[str] = None, tmdb_id: Optional[int] = None, season: Optional[int] = None) -> Tuple[Optional[str], Optional[Dict[int, list]]]: """ 根据标题和年份和季,返回Jellyfin中的剧集列表 :param item_id: Jellyfin中的Id :param title: 标题 :param year: 年份 :param tmdb_id: TMDBID :param season: 季 :return: 集号的列表 """ if not self._host or not self._apikey or not self.user: return None, None # 查TVID if not item_id: item_id = self.__get_jellyfin_series_id_by_name(title, year) if item_id is None: return None, None if not item_id: return None, {} # 验证tmdbid是否相同 item_info = self.get_iteminfo(item_id) if item_info: if tmdb_id and item_info.tmdbid: if str(tmdb_id) != str(item_info.tmdbid): return None, {} if season is None: season = None url = f"{self._host}Shows/{item_id}/Episodes" params = { "season": season, "userId": self.user, "isMissing": "false", "api_key": self._apikey } try: res_json = RequestUtils().get_res(url, params) if res_json: tv_info = res_json.json() res_items = tv_info.get("Items") # 返回的季集信息 season_episodes = {} for res_item in res_items: season_index = res_item.get("ParentIndexNumber") if season_index is None: continue if season is not None and season != season_index: continue episode_index = res_item.get("IndexNumber") if episode_index is None: continue if not season_episodes.get(season_index): season_episodes[season_index] = [] season_episodes[season_index].append(episode_index) return item_id, season_episodes except Exception as e: logger.error(f"连接Shows/Id/Episodes出错:" + str(e)) return None, None return None, {} def get_remote_image_by_id(self, item_id: str, image_type: str) -> Optional[str]: """ 根据ItemId从Jellyfin查询TMDB图片地址 :param item_id: 在Jellyfin中的ID :param image_type: 图片的类弄地,poster或者backdrop等 :return: 图片对应在TMDB中的URL """ if not self._host or not self._apikey: return None url = f"{self._host}Items/{item_id}/RemoteImages" params = {"api_key": self._apikey} try: res = RequestUtils(timeout=10).get_res(url, params) if res: images = res.json().get("Images") for image in images: if image.get("ProviderName") == "TheMovieDb" and image.get("Type") == image_type: return image.get("Url") # return images[0].get("Url") # 首选无则返回第一张 else: logger.info(f"Items/RemoteImages 未获取到返回数据,采用本地图片") return self.generate_image_link(item_id, image_type, True) except Exception as e: logger.error(f"连接Items/Id/RemoteImages出错:" + str(e)) return None return None def get_item_path_by_id(self, item_id: str) -> Optional[str]: """ 根据ItemId查询所在的Path :param item_id: 在Jellyfin中的ID :return: Path """ if not self._host or not self._apikey: return None url = f"{self._host}Items/{item_id}/PlaybackInfo" params = {"api_key": self._apikey} try: res = RequestUtils(timeout=10).get_res(url, params) if res: media_sources = res.json().get("MediaSources") if media_sources: return media_sources[0].get("Path") else: logger.error("Items/Id/PlaybackInfo 未获取到返回数据,不设置 Path") return None except Exception as e: logger.error("连接Items/Id/PlaybackInfo出错:" + str(e)) return None return None def generate_image_link(self, item_id: str, image_type: str, host_type: bool) -> Optional[str]: """ 根据ItemId和imageType查询本地对应图片 :param item_id: 在Jellyfin中的ID :param image_type: 图片类型,如Backdrop、Primary :param host_type: True为外网链接, False为内网链接 :return: 图片对应在host_type的播放器中的URL """ if not self._playhost: logger.error("Jellyfin外网播放地址未能获取或为空") return None # 检测是否为TV _parent_id = self.get_itemId_ancestors(item_id, 0, "ParentBackdropItemId") if _parent_id: item_id = _parent_id _host = self._host if host_type: _host = self._playhost url = f"{_host}Items/{item_id}/Images/{image_type}" try: res = RequestUtils().get_res(url) if res and res.status_code != 404: logger.info(f"影片图片链接:{res.url}") return res.url else: logger.error("Items/Id/Images 未获取到返回数据或无该影片{}图片".format(image_type)) return None except Exception as e: logger.error(f"连接Items/Id/Images出错:" + str(e)) return None def get_itemId_ancestors(self, item_id: str, index: int, key: str) -> Optional[Union[str, list, int, dict, bool]]: """ 获得itemId的父item :param item_id: 在Jellyfin中剧集的ID (S01E02的E02的item_id) :param index: 第几个json对象 :param key: 需要得到父item中的键值对 :return key对应类型的值 """ url = f"{self._host}Items/{item_id}/Ancestors" params = { "api_key": self._apikey } try: res = RequestUtils().get_res(url, params) if res: return res.json()[index].get(key) else: logger.error(f"Items/Id/Ancestors 未获取到返回数据") return None except Exception as e: logger.error(f"连接Items/Id/Ancestors出错:" + str(e)) return None def refresh_root_library(self) -> Optional[bool]: """ 通知Jellyfin刷新整个媒体库 """ if not self._host or not self._apikey: return False url = f"{self._host}Library/Refresh" params = { "api_key": self._apikey } try: res = RequestUtils().post_res(url, params=params) if res: return True else: logger.info(f"刷新媒体库失败,无法连接Jellyfin!") except Exception as e: logger.error(f"连接Library/Refresh出错:" + str(e)) return False def get_webhook_message(self, body: any) -> Optional[schemas.WebhookEventInfo]: """ 解析Jellyfin报文 { "ServerId": "d79d3a6261614419a114595a585xxxxx", "ServerName": "nyanmisaka-jellyfin1", "ServerVersion": "10.8.10", "ServerUrl": "http://xxxxxxxx:8098", "NotificationType": "PlaybackStart", "Timestamp": "2023-09-10T08:35:25.3996506+00:00", "UtcTimestamp": "2023-09-10T08:35:25.3996527Z", "Name": "慕灼华逃婚离开", "Overview": "慕灼华假装在读书,她害怕大娘子说她不务正业。", "Tagline": "", "ItemId": "4b92551344f53b560fb55cd6700xxxxx", "ItemType": "Episode", "RunTimeTicks": 27074985984, "RunTime": "00:45:07", "Year": 2023, "SeriesName": "灼灼风流", "SeasonNumber": 1, "SeasonNumber00": "01", "SeasonNumber000": "001", "EpisodeNumber": 1, "EpisodeNumber00": "01", "EpisodeNumber000": "001", "Provider_tmdb": "229210", "Video_0_Title": "4K HEVC SDR", "Video_0_Type": "Video", "Video_0_Codec": "hevc", "Video_0_Profile": "Main", "Video_0_Level": 150, "Video_0_Height": 2160, "Video_0_Width": 3840, "Video_0_AspectRatio": "16:9", "Video_0_Interlaced": false, "Video_0_FrameRate": 25, "Video_0_VideoRange": "SDR", "Video_0_ColorSpace": "bt709", "Video_0_ColorTransfer": "bt709", "Video_0_ColorPrimaries": "bt709", "Video_0_PixelFormat": "yuv420p", "Video_0_RefFrames": 1, "Audio_0_Title": "AAC - Stereo - Default", "Audio_0_Type": "Audio", "Audio_0_Language": "und", "Audio_0_Codec": "aac", "Audio_0_Channels": 2, "Audio_0_Bitrate": 125360, "Audio_0_SampleRate": 48000, "Audio_0_Default": true, "PlaybackPositionTicks": 1000000, "PlaybackPosition": "00:00:00", "MediaSourceId": "4b92551344f53b560fb55cd6700ebc86", "IsPaused": false, "IsAutomated": false, "DeviceId": "TW96aWxsxxxxxjA", "DeviceName": "Edge Chromium", "ClientName": "Jellyfin Web", "NotificationUsername": "Jeaven", "UserId": "9783d2432b0d40a8a716b6aa46xxxxx" } """ if not body: return None try: message = json.loads(body) except Exception as e: logger.debug(f"解析Jellyfin Webhook报文出错:" + str(e)) return None if not message: return None logger.debug(f"接收到jellyfin webhook:{message}") eventType = message.get('NotificationType') if not eventType: return None eventItem = schemas.WebhookEventInfo( event=eventType, channel="jellyfin" ) eventItem.item_id = message.get('ItemId') eventItem.tmdb_id = message.get('Provider_tmdb') eventItem.overview = message.get('Overview') eventItem.item_favorite = message.get('Favorite') eventItem.save_reason = message.get('SaveReason') eventItem.device_name = message.get('DeviceName') eventItem.user_name = message.get('NotificationUsername') eventItem.client = message.get('ClientName') eventItem.media_type = message.get('ItemType') if message.get("ItemType") == "Episode" \ or message.get("ItemType") == "Series" \ or message.get("ItemType") == "Season": # 剧集 eventItem.item_type = "TV" eventItem.season_id = message.get('SeasonNumber') eventItem.episode_id = message.get('EpisodeNumber') eventItem.item_name = "%s %s%s %s" % ( message.get('SeriesName'), "S" + str(eventItem.season_id), "E" + str(eventItem.episode_id), message.get('Name')) elif message.get("ItemType") == 'Audio': # 音乐 eventItem.item_type = "AUD" eventItem.item_name = message.get('Album') eventItem.overview = message.get('Name') eventItem.item_id = message.get('ItemId') else: # 电影 eventItem.item_type = "MOV" eventItem.item_name = "%s %s" % ( message.get('Name'), "(" + str(message.get('Year')) + ")") playback_position_ticks = message.get('PlaybackPositionTicks') runtime_ticks = message.get('RunTimeTicks') if playback_position_ticks is not None and runtime_ticks is not None: eventItem.percentage = playback_position_ticks / runtime_ticks * 100 # 获取消息图片 if eventItem.item_id: # 根据返回的item_id去调用媒体服务器获取 eventItem.image_url = self.get_remote_image_by_id( item_id=eventItem.item_id, image_type="Backdrop" ) # jellyfin 的 webhook 不含 item_path,需要单独获取 eventItem.item_path = self.get_item_path_by_id(eventItem.item_id) eventItem.json_object = message return eventItem @staticmethod def __format_item_info(item) -> Optional[schemas.MediaServerItem]: """ 格式化item """ try: user_data = item.get("UserData", {}) if not user_data: user_state = None else: resume = item.get("UserData", {}).get("PlaybackPositionTicks") and item.get("UserData", {}).get( "PlaybackPositionTicks") > 0 last_played_date = item.get("UserData", {}).get("LastPlayedDate") if last_played_date is not None and "." in last_played_date: last_played_date = last_played_date.split(".")[0] user_state = schemas.MediaServerItemUserState( played=item.get("UserData", {}).get("Played"), resume=resume, last_played_date=datetime.strptime(last_played_date, "%Y-%m-%dT%H:%M:%S").strftime( "%Y-%m-%d %H:%M:%S") if last_played_date else None, play_count=item.get("UserData", {}).get("PlayCount"), percentage=item.get("UserData", {}).get("PlayedPercentage"), ) tmdbid = item.get("ProviderIds", {}).get("Tmdb") return schemas.MediaServerItem( server="jellyfin", library=item.get("ParentId"), item_id=item.get("Id"), item_type=item.get("Type"), title=item.get("Name"), original_title=item.get("OriginalTitle"), year=item.get("ProductionYear"), tmdbid=int(tmdbid) if tmdbid else None, imdbid=item.get("ProviderIds", {}).get("Imdb"), tvdbid=item.get("ProviderIds", {}).get("Tvdb"), path=item.get("Path"), user_state=user_state ) except Exception as e: logger.error(e) return None def get_iteminfo(self, itemid: str) -> Optional[schemas.MediaServerItem]: """ 获取单个项目详情 """ if not itemid: return None if not self._host or not self._apikey: return None url = f"{self._host}Users/{self.user}/Items/{itemid}" params = { "api_key": self._apikey } try: res = RequestUtils().get_res(url, params) if res and res.status_code == 200: return self.__format_item_info(res.json()) except Exception as e: logger.error(f"连接Users/{self.user}/Items/{itemid}:" + str(e)) return None def get_items(self, parent: Union[str, int], start_index: Optional[int] = 0, limit: Optional[int] = -1) \ -> Generator[MediaServerItem | None | Any, Any, None]: """ 获取媒体服务器项目列表,支持分页和不分页逻辑,默认不分页获取所有数据 :param parent: 媒体库ID,用于标识要获取的媒体库 :param start_index: 起始索引,用于分页获取数据。默认为 0,即从第一个项目开始获取 :param limit: 每次请求的最大项目数,用于分页。如果为 None 或 -1,则表示一次性获取所有数据,默认为 -1 :return: 返回一个生成器对象,用于逐步获取媒体服务器中的项目 """ if not parent or not self._host or not self._apikey: return None url = f"{self._host}Users/{self.user}/Items" params = { "ParentId": parent, "api_key": self._apikey, "Fields": "ProviderIds,OriginalTitle,ProductionYear,Path,UserDataPlayCount,UserDataLastPlayedDate,ParentId", } if limit is not None and limit != -1: params.update({ "StartIndex": start_index, "Limit": limit }) try: res = RequestUtils().get_res(url, params) if not res or res.status_code != 200: return None items = res.json().get("Items") or [] for item in items: if not item: continue if "Folder" in item.get("Type"): for items in self.get_items(item.get("Id")): yield items elif item.get("Type") in ["Movie", "Series"]: yield self.__format_item_info(item) except Exception as e: logger.error(f"连接Users/Items出错:" + str(e)) def get_data(self, url: str) -> Optional[Response]: """ 自定义URL从媒体服务器获取数据,其中[HOST]、[APIKEY]、[USER]会被替换成实际的值 :param url: 请求地址 """ if not self._host or not self._apikey: return None url = url.replace("[HOST]", self._host or '') \ .replace("[APIKEY]", self._apikey or '') \ .replace("[USER]", self.user or '') try: return RequestUtils(accept_type="application/json").get_res(url=url) except Exception as e: logger.error(f"连接Jellyfin出错:" + str(e)) return None def post_data(self, url: str, data: Optional[str] = None, headers: dict = None) -> Optional[Response]: """ 自定义URL从媒体服务器获取数据,其中[HOST]、[APIKEY]、[USER]会被替换成实际的值 :param url: 请求地址 :param data: 请求数据 :param headers: 请求头 """ if not self._host or not self._apikey: return None url = url.replace("[HOST]", self._host or '') \ .replace("[APIKEY]", self._apikey or '') \ .replace("[USER]", self.user or '') try: return RequestUtils( headers=headers ).post_res(url=url, data=data) except Exception as e: logger.error(f"连接Jellyfin出错:" + str(e)) return None def get_play_url(self, item_id: str) -> str: """ 拼装媒体播放链接 :param item_id: 媒体的的ID """ return f"{self._playhost or self._host}web/index.html#!" \ f"/details?id={item_id}&serverId={self.serverid}" def __get_local_image_by_id(self, item_id: str) -> str: """ 根据ItemId从媒体服务器查询有声书图片地址 :param: item_id: 在Jellyfin中的ID :param: remote 是否远程使用,TG微信等客户端调用应为True :param: inner 是否NT内部调用,为True是会使用NT中转 """ if not self._host or not self._apikey: return "" return "%sItems/%s/Images/Primary" % (self._host, item_id) def get_backdrop_url(self, item_id: str, image_tag: str, remote: Optional[bool] = False) -> str: """ 获取Backdrop图片地址 :param: item_id: 在Jellyfin中的ID :param: image_tag: 图片的tag :param: remote 是否远程使用,TG微信等客户端调用应为True """ if not self._host or not self._apikey: return "" if not image_tag or not item_id: return "" if remote: host_url = self._playhost or self._host else: host_url = self._host return f"{host_url}Items/{item_id}/" \ f"Images/Backdrop?tag={image_tag}&api_key={self._apikey}" def get_resume(self, num: Optional[int] = 12, username: Optional[str] = None) -> Optional[List[schemas.MediaServerPlayItem]]: """ 获得继续观看 """ if not self._host or not self._apikey: return None if username: user = self.get_user(username) else: user = self.user url = f"{self._host}Users/{user}/Items/Resume" params = { "Limit": 100, "MediaTypes": "Video", "Fields": "ProductionYear,Path", "api_key": self._apikey, } try: res = RequestUtils().get_res(url, params) if res: result = res.json().get("Items") or [] ret_resume = [] # 用户媒体库文件夹列表(排除黑名单) library_folders = self.get_user_library_folders() for item in result: if len(ret_resume) == num: break if item.get("Type") not in ["Movie", "Episode"]: continue item_path = item.get("Path") if item_path and library_folders and not any( str(item_path).startswith(folder) for folder in library_folders): continue item_type = MediaType.MOVIE.value if item.get("Type") == "Movie" else MediaType.TV.value link = self.get_play_url(item.get("Id")) if item.get("BackdropImageTags"): image = self.get_backdrop_url(item_id=item.get("Id"), image_tag=item.get("BackdropImageTags")[0]) else: image = self.__get_local_image_by_id(item.get("Id")) # 小部分剧集无[xxx-S01E01-thumb.jpg]图片 image_res = RequestUtils().get_res(image) if not image_res or image_res.status_code == 404: image = self.generate_image_link(item.get("Id"), "Backdrop", False) if item_type == MediaType.MOVIE.value: title = item.get("Name") subtitle = str(item.get("ProductionYear")) if item.get("ProductionYear") else None else: title = f'{item.get("SeriesName")}' subtitle = f'S{item.get("ParentIndexNumber")}:{item.get("IndexNumber")} - {item.get("Name")}' ret_resume.append(schemas.MediaServerPlayItem( id=item.get("Id"), title=title, subtitle=subtitle, type=item_type, image=image, link=link, percent=item.get("UserData", {}).get("PlayedPercentage"), server_type='jellyfin', )) return ret_resume else: logger.error(f"Users/Items/Resume 未获取到返回数据") except Exception as e: logger.error(f"连接Users/Items/Resume出错:" + str(e)) return [] def get_latest(self, num=20, username: Optional[str] = None) -> Optional[List[schemas.MediaServerPlayItem]]: """ 获得最近更新 """ if not self._host or not self._apikey: return None if username: user = self.get_user(username) else: user = self.user url = f"{self._host}Users/{user}/Items/Latest" params = { "Limit": 100, "MediaTypes": "Video", "Fields": "ProductionYear,Path,BackdropImageTags", "api_key": self._apikey, } try: res = RequestUtils().get_res(url, params) if res: result = res.json() or [] ret_latest = [] # 用户媒体库文件夹列表(排除黑名单) library_folders = self.get_user_library_folders() for item in result: if len(ret_latest) == num: break if item.get("Type") not in ["Movie", "Series"]: continue item_path = item.get("Path") if item_path and library_folders and not any( str(item_path).startswith(folder) for folder in library_folders): continue item_type = MediaType.MOVIE.value if item.get("Type") == "Movie" else MediaType.TV.value link = self.get_play_url(item.get("Id")) image = self.__get_local_image_by_id(item_id=item.get("Id")) ret_latest.append(schemas.MediaServerPlayItem( id=item.get("Id"), title=item.get("Name"), subtitle=str(item.get("ProductionYear")) if item.get("ProductionYear") else None, type=item_type, image=image, link=link, BackdropImageTags=item.get("BackdropImageTags"), server_type='jellyfin' )) return ret_latest else: logger.error(f"Users/Items/Latest 未获取到返回数据") except Exception as e: logger.error(f"连接Users/Items/Latest出错:" + str(e)) return [] def get_user_library_folders(self): """ 获取Jellyfin媒体库文件夹列表(排除黑名单) """ if not self._host or not self._apikey: return [] library_folders = [] for library in self.get_jellyfin_virtual_folders() or []: if self._sync_libraries and library.get("Id") not in self._sync_libraries: continue library_folders += [folder for folder in library.get("Path")] return library_folders ================================================ FILE: app/modules/plex/__init__.py ================================================ from typing import Optional, Tuple, Union, Any, List, Generator from app import schemas from app.core.context import MediaInfo from app.core.event import eventmanager from app.log import logger from app.modules import _ModuleBase, _MediaServerBase from app.modules.plex.plex import Plex from app.schemas import AuthCredentials, AuthInterceptCredentials from app.schemas.types import MediaType, ModuleType, ChainEventType, MediaServerType class PlexModule(_ModuleBase, _MediaServerBase[Plex]): def init_module(self) -> None: """ 初始化模块 """ super().init_service(service_name=Plex.__name__.lower(), service_type=lambda conf: Plex(**conf.config, sync_libraries=conf.sync_libraries)) @staticmethod def get_name() -> str: return "Plex" @staticmethod def get_type() -> ModuleType: """ 获取模块类型 """ return ModuleType.MediaServer @staticmethod def get_subtype() -> MediaServerType: """ 获取模块子类型 """ return MediaServerType.Plex @staticmethod def get_priority() -> int: """ 获取模块优先级,数字越小优先级越高,只有同一接口下优先级才生效 """ return 3 def stop(self): """ 停止模块服务 """ for server in self.get_instances().values(): if server: server.close() def test(self) -> Optional[Tuple[bool, str]]: """ 测试模块连接性 """ if not self.get_instances(): return None for name, server in self.get_instances().items(): if server.is_inactive(): server.reconnect() if not server.get_librarys(): return False, f"无法连接Plex服务器:{name}" return True, "" def init_setting(self) -> Tuple[str, Union[str, bool]]: pass def scheduler_job(self) -> None: """ 定时任务,每10分钟调用一次 """ # 定时重连 for name, server in self.get_instances().items(): if server.is_inactive(): logger.info(f"Plex {name} 服务器连接断开,尝试重连 ...") server.reconnect() def user_authenticate(self, credentials: AuthCredentials, service_name: Optional[str] = None) \ -> Optional[AuthCredentials]: """ 使用Plex用户辅助完成用户认证 :param credentials: 认证数据 :param service_name: 指定要认证的媒体服务器名称,若为 None 则认证所有服务 :return: 认证数据 """ # Plex认证 if not credentials or credentials.grant_type != "password": return None # 确定要认证的服务器列表 if service_name: # 如果指定了服务名,获取该服务实例 servers = [(service_name, server)] if (server := self.get_instance(service_name)) else [] else: # 如果没有指定服务名,遍历所有服务 servers = self.get_instances().items() # 遍历要认证的服务器 for name, server in servers: # 触发认证拦截事件 intercept_event = eventmanager.send_event( etype=ChainEventType.AuthIntercept, data=AuthInterceptCredentials(username=credentials.username, channel=self.get_name(), service=name, status="triggered") ) if intercept_event and intercept_event.event_data: intercept_data: AuthInterceptCredentials = intercept_event.event_data if intercept_data.cancel: continue auth_result = server.authenticate(credentials.username, credentials.password) if auth_result: token, username = auth_result credentials.channel = self.get_name() credentials.service = name credentials.token = token # Plex 传入可能为邮箱,这里调整为用户名返回 credentials.username = username return credentials return None def webhook_parser(self, body: Any, form: Any, args: Any) -> Optional[schemas.WebhookEventInfo]: """ 解析Webhook报文体 :param body: 请求体 :param form: 请求表单 :param args: 请求参数 :return: 字典,解析为消息时需要包含:title、text、image """ source = args.get("source") if source: server: Plex = self.get_instance(source) if not server: return None result = server.get_webhook_message(form) if result: result.server_name = source return result for server in self.get_instances().values(): if server: result = server.get_webhook_message(form) if result: return result return None def media_exists(self, mediainfo: MediaInfo, itemid: Optional[str] = None, server: Optional[str] = None) -> Optional[schemas.ExistMediaInfo]: """ 判断媒体文件是否存在 :param mediainfo: 识别的媒体信息 :param itemid: 媒体服务器ItemID :param server: 媒体服务器名称 :return: 如不存在返回None,存在时返回信息,包括每季已存在所有集{type: movie/tv, seasons: {season: [episodes]}} """ if server: servers = [(server, self.get_instance(server))] else: servers = self.get_instances().items() for name, s in servers: if not s: continue if mediainfo.type == MediaType.MOVIE: if itemid: movie = s.get_iteminfo(itemid) if movie: logger.info(f"媒体库 {name} 中找到了 {movie}") return schemas.ExistMediaInfo( type=MediaType.MOVIE, server_type="plex", server=name, itemid=movie.item_id ) movies = s.get_movies(title=mediainfo.title, original_title=mediainfo.original_title, year=mediainfo.year, tmdb_id=mediainfo.tmdb_id) if not movies: logger.info(f"{mediainfo.title_year} 没有在媒体库 {name} 中") continue else: logger.info(f"媒体库 {name} 中找到了 {movies}") return schemas.ExistMediaInfo( type=MediaType.MOVIE, server_type="plex", server=name, itemid=movies[0].item_id ) else: item_id, tvs = s.get_tv_episodes(title=mediainfo.title, original_title=mediainfo.original_title, year=mediainfo.year, tmdb_id=mediainfo.tmdb_id, item_id=itemid) if not tvs: logger.info(f"{mediainfo.title_year} 没有在媒体库 {name} 中") continue else: logger.info(f"{mediainfo.title_year} 在媒体库 {name} 中找到了这些季集:{tvs}") return schemas.ExistMediaInfo( type=MediaType.TV, seasons=tvs, server_type="plex", server=name, itemid=item_id ) return None def media_statistic(self, server: Optional[str] = None) -> Optional[List[schemas.Statistic]]: """ 媒体数量统计 """ if server: server_obj: Plex = self.get_instance(server) if not server_obj: return None servers = [server_obj] else: servers = self.get_instances().values() media_statistics = [] for s in servers: media_statistic = s.get_medias_count() if not media_statistic: continue media_statistic.user_count = 1 media_statistics.append(media_statistic) return media_statistics def mediaserver_librarys(self, server: Optional[str] = None, hidden: Optional[bool] = False, **kwargs) -> Optional[List[schemas.MediaServerLibrary]]: """ 媒体库列表 """ server_obj: Plex = self.get_instance(server) if server_obj: return server_obj.get_librarys(hidden) return None def mediaserver_items(self, server: str, library_id: Union[str, int], start_index: Optional[int] = 0, limit: Optional[int] = -1) -> Optional[Generator]: """ 获取媒体服务器项目列表,支持分页和不分页逻辑,默认不分页获取所有数据 :param server: 媒体服务器名称 :param library_id: 媒体库ID,用于标识要获取的媒体库 :param start_index: 起始索引,用于分页获取数据。默认为 0,即从第一个项目开始获取 :param limit: 每次请求的最大项目数,用于分页。如果为 None 或 -1,则表示一次性获取所有数据,默认为 -1 :return: 返回一个生成器对象,用于逐步获取媒体服务器中的项目 """ server_obj: Plex = self.get_instance(server) if server_obj: return server_obj.get_items(library_id, start_index, limit) return None def mediaserver_iteminfo(self, server: str, item_id: str) -> Optional[schemas.MediaServerItem]: """ 媒体库项目详情 """ server_obj: Plex = self.get_instance(server) if server_obj: return server_obj.get_iteminfo(item_id) return None def mediaserver_tv_episodes(self, server: str, item_id: Union[str, int]) -> Optional[List[schemas.MediaServerSeasonInfo]]: """ 获取剧集信息 """ server_obj: Plex = self.get_instance(server) if not server_obj: return None _, seasoninfo = server_obj.get_tv_episodes(item_id=item_id) if not seasoninfo: return [] return [schemas.MediaServerSeasonInfo( season=season, episodes=episodes ) for season, episodes in seasoninfo.items()] def mediaserver_playing(self, server: str, count: Optional[int] = 20, **kwargs) -> List[schemas.MediaServerPlayItem]: """ 获取媒体服务器正在播放信息 """ server_obj: Plex = self.get_instance(server) if not server_obj: return [] return server_obj.get_resume(num=count) def mediaserver_latest(self, server: Optional[str] = None, count: Optional[int] = 20, **kwargs) -> List[schemas.MediaServerPlayItem]: """ 获取媒体服务器最新入库条目 """ server_obj: Plex = self.get_instance(server) if not server_obj: return [] return server_obj.get_latest(num=count) def mediaserver_latest_images(self, server: Optional[str] = None, count: Optional[int] = 20, username: Optional[str] = None, **kwargs ) -> List[str]: """ 获取媒体服务器最新入库条目的图片 :param server: 媒体服务器名称 :param count: 获取数量 :param username: 用户名 :return: 图片链接列表 """ server_obj: Plex = self.get_instance(server) if not server_obj: return [] links = [] items: List[schemas.MediaServerPlayItem] = self.mediaserver_latest(server=server, count=count, username=username) for item in items: link = server_obj.get_remote_image_by_id(item_id=item.id, image_type="Backdrop", plex_url=False) if link: links.append(link) return links def mediaserver_play_url(self, server: str, item_id: Union[str, int]) -> Optional[str]: """ 获取媒体库播放地址 """ server_obj: Plex = self.get_instance(server) if not server_obj: return None return server_obj.get_play_url(item_id) ================================================ FILE: app/modules/plex/plex.py ================================================ import json from pathlib import Path from typing import List, Optional, Dict, Tuple, Generator, Any, Union from urllib.parse import quote_plus from plexapi import media from plexapi.myplex import MyPlexAccount from plexapi.server import PlexServer from requests import Response, Session from app import schemas from app.core.cache import cached from app.log import logger from app.schemas import MediaType from app.utils.http import RequestUtils from app.utils.url import UrlUtils from app.schemas import MediaServerItem class Plex: _plex = None _session = None _sync_libraries: List[str] = [] def __init__(self, host: Optional[str] = None, token: Optional[str] = None, play_host: Optional[str] = None, sync_libraries: list = None, **kwargs): if not host or not token: logger.error("Plex服务器配置不完整!") return self._host = host if self._host: self._host = UrlUtils.standardize_base_url(self._host) self._playhost = play_host if self._playhost: self._playhost = UrlUtils.standardize_base_url(self._playhost) self._token = token if self._host and self._token: try: self._plex = PlexServer(self._host, self._token) self._libraries = self._plex.library.sections() except Exception as e: self._plex = None logger.error(f"Plex服务器连接失败:{str(e)}") self._session = self.__adapt_plex_session() self._sync_libraries = sync_libraries or [] def is_inactive(self) -> bool: """ 判断是否需要重连 """ if not self._host or not self._token: return False return True if not self._plex else False def reconnect(self): """ 重连 """ try: self._plex = PlexServer(self._host, self._token) self._libraries = self._plex.library.sections() except Exception as e: self._plex = None logger.error(f"Plex服务器连接失败:{str(e)}") def authenticate(self, username: str, password: str) -> Optional[Tuple[str, str]]: """ 用户认证 :param username: 用户名 :param password: 密码 :return: 认证成功返回 (token, 用户名),否则返回 None """ if not username or not password: return None try: account = MyPlexAccount(username=username, password=password, remember=False) if account: plex = PlexServer(self._host, account.authToken) if not plex: return None return account.authToken, account.username except Exception as e: # 处理认证失败或网络错误等情况 logger.error(f"Authentication failed: {e}") return None @cached(maxsize=32, ttl=86400) def __get_library_images(self, library_key: str, mtype: int) -> Optional[List[str]]: """ 获取媒体服务器最近添加的媒体的图片列表 param: library_key param: type type的含义: 1 电影 2 剧集 详见 plexapi/utils.py中SEARCHTYPES的定义 """ if not self._plex: return None # 返回结果 poster_urls = {} # 页码计数 container_start = 0 # 需要的总条数/每页的条数 total_size = 4 # 如果总数不足,接续获取下一页 while len(poster_urls) < total_size: items = self._plex.fetchItems(f"/hubs/home/recentlyAdded?type={mtype}§ionID={library_key}", container_start=container_start, container_size=8, maxresults=8) for item in items: if item.type == "episode": # 如果是剧集的单集,则去找上级的图片 if item.parentThumb is not None: poster_urls[item.parentThumb] = None else: # 否则就用自己的图片 if item.thumb is not None: poster_urls[item.thumb] = None if len(poster_urls) == total_size: break if len(items) < total_size: break container_start += total_size return [f"{self._host.rstrip('/') + url}?X-Plex-Token={self._token}" for url in list(poster_urls.keys())[:total_size]] def get_librarys(self, hidden: Optional[bool] = False) -> List[schemas.MediaServerLibrary]: """ 获取媒体服务器所有媒体库列表 """ if not self._plex: return [] try: self._libraries = self._plex.library.sections() except Exception as err: logger.error(f"获取媒体服务器所有媒体库列表出错:{str(err)}") return [] libraries = [] for library in self._libraries: if hidden and self._sync_libraries and "all" not in self._sync_libraries \ and str(library.key) not in self._sync_libraries: continue if library.type == "movie": library_type = MediaType.MOVIE.value image_list = self.__get_library_images(library.key, 1) elif library.type == "show": library_type = MediaType.TV.value image_list = self.__get_library_images(library.key, 2) else: continue libraries.append( schemas.MediaServerLibrary( id=library.key, name=library.title, path=library.locations, type=library_type, image_list=image_list, link=f"{self._playhost or self._host}web/index.html#!/media/{self._plex.machineIdentifier}" f"/com.plexapp.plugins.library?source={library.key}&X-Plex-Token={self._token}", server_type='plex' ) ) return libraries def get_medias_count(self) -> schemas.Statistic: """ 获得电影、电视剧、动漫媒体数量 :return: movie_count tv_count episode_count """ if not self._plex: return schemas.Statistic() sections = self._plex.library.sections() movie_count = tv_count = episode_count = 0 # 媒体库白名单 allow_library = [str(lib.id) for lib in self.get_librarys(hidden=True)] for sec in sections: if str(sec.key) not in allow_library: continue if sec.type == "movie": movie_count += sec.totalSize if sec.type == "show": tv_count += sec.totalSize episode_count += sec.totalViewSize(libtype="episode") return schemas.Statistic( movie_count=movie_count, tv_count=tv_count, episode_count=episode_count ) def get_movies(self, title: str, original_title: Optional[str] = None, year: Optional[str] = None, tmdb_id: Optional[int] = None) -> Optional[List[schemas.MediaServerItem]]: """ 根据标题和年份,检查电影是否在Plex中存在,存在则返回列表 :param title: 标题 :param original_title: 原产地标题 :param year: 年份,为空则不过滤 :param tmdb_id: TMDB ID :return: 含title、year属性的字典列表 """ if not self._plex: return None ret_movies = [] if year: movies = self._plex.library.search(title=title, year=year, libtype="movie") # 根据原标题再查一遍 if original_title and str(original_title) != str(title): movies.extend(self._plex.library.search(title=original_title, year=year, libtype="movie")) else: movies = self._plex.library.search(title=title, libtype="movie") if original_title and str(original_title) != str(title): movies.extend(self._plex.library.search(title=original_title, libtype="movie")) for item in set(movies): ids = self.__get_ids(item.guids) if tmdb_id and ids['tmdb_id']: if str(ids['tmdb_id']) != str(tmdb_id): continue path = None if item.locations: path = item.locations[0] ret_movies.append( schemas.MediaServerItem( server="plex", library=item.librarySectionID, item_id=item.key, item_type=item.type, title=item.title, original_title=item.originalTitle, year=item.year, tmdbid=ids['tmdb_id'], imdbid=ids['imdb_id'], tvdbid=ids['tvdb_id'], path=path, ) ) return ret_movies def get_tv_episodes(self, item_id: Optional[str] = None, title: Optional[str] = None, original_title: Optional[str] = None, year: Optional[str] = None, tmdb_id: Optional[int] = None, season: Optional[int] = None) -> Tuple[Optional[str], Optional[Dict[int, list]]]: """ 根据标题、年份、季查询电视剧所有集信息 :param item_id: 媒体ID :param title: 标题 :param original_title: 原产地标题 :param year: 年份,可以为空,为空时不按年份过滤 :param tmdb_id: TMDB ID :param season: 季号,数字 :return: 所有集的列表 """ if not self._plex: return None, {} if item_id: videos = self.__fetch_item(item_id) else: # 兼容年份为空的场景 kwargs = {"year": year} if year else {} # 根据标题和年份模糊搜索,该结果不够准确 videos = self._plex.library.search(title=title, libtype="show", **kwargs) if (not videos and original_title and str(original_title) != str(title)): videos = self._plex.library.search(title=original_title, libtype="show", **kwargs) if not videos: return None, {} if isinstance(videos, list): videos = videos[0] video_tmdbid = self.__get_ids(videos.guids).get('tmdb_id') if tmdb_id and video_tmdbid: if str(video_tmdbid) != str(tmdb_id): return None, {} episodes = videos.episodes() season_episodes = {} for episode in episodes: if season is not None and episode.seasonNumber != int(season): continue if episode.seasonNumber not in season_episodes: season_episodes[episode.seasonNumber] = [] season_episodes[episode.seasonNumber].append(episode.index) return videos.key, season_episodes def get_remote_image_by_id(self, item_id: str, image_type: str, depth: Optional[int] = 0, plex_url: Optional[bool] = True) -> Optional[str]: """ 根据ItemId从Plex查询图片地址 :param item_id: 在Plex中的ID :param image_type: 图片的类型,Poster或者Backdrop等 :param depth: 当前递归深度,默认为0 :param plex_url: 是否返回Plex的URL,默认为True(仅在配置了外网地址和Token时有效) :return: 图片对应在plex服务器或TMDB中的URL """ if not self._plex or depth > 2 or not item_id: return None try: image_url = None ekey = item_id item = self._plex.fetchItem(ekey=ekey) if not item: return None # 如果配置了外网播放地址以及Token,则默认从Plex媒体服务器获取图片,否则返回有外网地址的图片资源 # Plex外网播放地址这个框里目前可以填两种地址 # 1. Plex的官方转发地址https://app.plex.tv, 2. 自己处理的端口转发地址 # 如果使用的是1的官方转发地址,那么就不能走这个逻辑,因为官方转发地址无法获取到图片 if (self._playhost and "app.plex.tv" not in self._playhost and self._token and plex_url): query = {"X-Plex-Token": self._token} if image_type == "Poster": if item.thumb: image_url = UrlUtils.combine_url(host=self._playhost, path=item.thumb, query=query) else: # 默认使用art也就是Backdrop进行处理 if item.art: image_url = UrlUtils.combine_url(host=self._playhost, path=item.art, query=query) # 这里对episode进行特殊处理,实际上episode的Backdrop是Poster # 也有个别情况,比如机智的凡人小子episode就是Poster,因此这里把episode的优先级降低,默认还是取art if not image_url and item.TYPE == "episode" and item.thumb: image_url = UrlUtils.combine_url(host=self._playhost, path=item.thumb, query=query) else: if image_type == "Poster": images = self._plex.fetchItems(ekey=f"{ekey}/posters", cls=media.Poster) else: # 默认使用art也就是Backdrop进行处理 images = self._plex.fetchItems(ekey=f"{ekey}/arts", cls=media.Art) # 这里对episode进行特殊处理,实际上episode的Backdrop是Poster # 也有个别情况,比如机智的凡人小子episode就是Poster,因此这里把episode的优先级降低,默认还是取art if not images and item.TYPE == "episode": images = self._plex.fetchItems(ekey=f"{ekey}/posters", cls=media.Poster) for image in images: if hasattr(image, "key") and image.key.startswith("http"): image_url = image.key break # 如果最后还是找不到,则递归父级进行查找 if not image_url and hasattr(item, "parentKey"): return self.get_remote_image_by_id(item_id=item.parentKey, image_type=image_type, depth=depth + 1) return image_url except Exception as e: logger.error(f"获取封面出错:" + str(e)) return None def refresh_root_library(self) -> bool: """ 通知Plex刷新整个媒体库 """ if not self._plex: return False return self._plex.library.update() def refresh_library_by_items(self, items: List[schemas.RefreshMediaItem]) -> Optional[bool]: """ 按路径刷新媒体库 item: target_path """ if not self._plex: return False result_dict = {} for item in items: file_path = item.target_path lib_key, path = self.__find_librarie(file_path, self._libraries) # 如果存在同一剧集的多集,key(path)相同会合并 if path: result_dict[path.as_posix()] = lib_key else: result_dict[""] = lib_key if "" in result_dict: # 如果有匹配失败的,刷新整个库 self._plex.library.update() else: # 否则一个一个刷新 for path, lib_key in result_dict.items(): logger.info(f"刷新媒体库:{lib_key} - {path}") self._plex.query(f'/library/sections/{lib_key}/refresh?path={quote_plus(Path(path).parent.as_posix())}') return None return None @staticmethod def __find_librarie(path: Path, libraries: List[Any]) -> Tuple[str, Optional[Path]]: """ 判断这个path属于哪个媒体库 多个媒体库配置的目录不应有重复和嵌套, """ def is_subpath(_path: Path, _parent: Path) -> bool: """ 判断_path是否是_parent的子目录下 """ _path = _path.resolve() _parent = _parent.resolve() return _path.parts[:len(_parent.parts)] == _parent.parts if path is None: return "", None try: for lib in libraries: if hasattr(lib, "locations") and lib.locations: for location in lib.locations: if is_subpath(path, Path(location)): return lib.key, path except Exception as err: logger.error(f"查找媒体库出错:{str(err)}") return "", None def get_iteminfo(self, itemid: str) -> Optional[schemas.MediaServerItem]: """ 获取单个项目详情 """ if not self._plex: return None try: item = self.__fetch_item(itemid) return self.__build_media_server_item(item) except Exception as err: logger.error(f"获取项目详情出错:{str(err)}") return None @staticmethod def __get_ids(guids: List[Any]) -> dict: def parse_tmdb_id(value: str) -> tuple[bool, int]: """尝试将TMDB ID字符串转换为整数。如果成功,返回(True, int),失败则返回(False, None)。""" try: int_value = int(value) return True, int_value except ValueError: return False, None guid_mapping = { "imdb://": "imdb_id", "tmdb://": "tmdb_id", "tvdb://": "tvdb_id" } ids = {varname: None for varname in guid_mapping.values()} for guid in guids: guid_id = guid['id'] if isinstance(guid, dict) else guid.id for prefix, varname in guid_mapping.items(): if guid_id.startswith(prefix): clean_id = guid_id[len(prefix):] if varname == "tmdb_id": # tmdb_id为int,Plex可能存在脏数据,特别处理tmdb_id success, parsed_id = parse_tmdb_id(clean_id) if success: ids[varname] = parsed_id else: ids[varname] = clean_id break return ids def __fetch_item(self, item_id: Union[int, str]): """ 根据给定的item_id获取媒体项 :param item_id: 媒体项的ID,可以是整数或字符串,如果是字符串且表示为数字,将会被转换为整数 """ if isinstance(item_id, str) and item_id.isdigit(): item_id = int(item_id) return self._plex.fetchItem(item_id) def __build_media_server_item(self, item) -> Optional[schemas.MediaServerItem]: """ 构造MediaServerItem :param item: Plex媒体项目 :return: MediaServerItem """ if not item: return None ids = self.__get_ids(item.guids) path = item.locations[0] if item.locations else None playback_position = getattr(item, "viewOffset", None) or 0 duration = getattr(item, "duration", None) or 0 percentage = (playback_position / duration * 100) if duration > 0 else None played = getattr(item, "isPlayed", None) or False play_count = getattr(item, "viewCount", None) or 0 last_played_date = getattr(item, "lastViewedAt", None) user_state = schemas.MediaServerItemUserState( played=played, resume=playback_position > 0, last_played_date=last_played_date.isoformat() if last_played_date and hasattr(last_played_date, 'isoformat') else None, play_count=play_count, percentage=percentage, ) return schemas.MediaServerItem( server="plex", library=item.librarySectionID, item_id=item.key, item_type=item.type, title=item.title, original_title=item.originalTitle, year=item.year, tmdbid=ids.get("tmdb_id"), imdbid=ids.get("imdb_id"), tvdbid=ids.get("tvdb_id"), path=path, user_state=user_state, ) def get_items(self, parent: Union[str, int], start_index: Optional[int] = 0, limit: Optional[int] = -1) \ -> Generator[MediaServerItem | None, Any, None]: """ 获取媒体服务器项目列表,支持分页和不分页逻辑,默认不分页获取所有数据 :param parent: 媒体库ID,用于标识要获取的媒体库 :param start_index: 起始索引,用于分页获取数据。默认为 0,即从第一个项目开始获取 :param limit: 每次请求的最大项目数,用于分页。如果为 None 或 -1,则表示一次性获取所有数据,默认为 -1 :return: 返回一个生成器对象,用于逐步获取媒体服务器中的项目 """ if not parent or not self._plex: return None try: section = self._plex.library.sectionByID(int(parent)) if section: if limit is None or limit == -1: items = section.all() else: items = section.all(container_start=start_index, container_size=limit, maxresults=limit) for item in items: try: if not item: continue yield self.__build_media_server_item(item) except Exception as e: logger.error(f"处理媒体项目时出错:{str(e)}, 跳过此项目") continue except Exception as err: logger.error(f"获取媒体库列表出错:{str(err)}") return None def get_webhook_message(self, form: any) -> Optional[schemas.WebhookEventInfo]: """ 解析Plex报文 eventItem 字段的含义 event 事件类型 item_type 媒体类型 TV,MOV item_name TV:琅琊榜 S1E6 剖心明志 虎口脱险 MOV:猪猪侠大冒险(2001) overview 剧情描述 { "event": "media.scrobble", "user": false, "owner": true, "Account": { "id": 31646104, "thumb": "https://plex.tv/users/xx", "title": "播放" }, "Server": { "title": "Media-Server", "uuid": "xxxx" }, "Player": { "local": false, "publicAddress": "xx.xx.xx.xx", "title": "MagicBook", "uuid": "wu0uoa1ujfq90t0c5p9f7fw0" }, "Metadata": { "librarySectionType": "show", "ratingKey": "40294", "key": "/library/metadata/40294", "parentRatingKey": "40291", "grandparentRatingKey": "40275", "guid": "plex://episode/615580a9fa828e7f1a0caabd", "parentGuid": "plex://season/615580a9fa828e7f1a0caab8", "grandparentGuid": "plex://show/60e81fd8d8000e002d7d2976", "type": "episode", "title": "The World's Strongest Senior", "titleSort": "World's Strongest Senior", "grandparentKey": "/library/metadata/40275", "parentKey": "/library/metadata/40291", "librarySectionTitle": "动漫剧集", "librarySectionID": 7, "librarySectionKey": "/library/sections/7", "grandparentTitle": "范马刃牙", "parentTitle": "Combat Shadow Fighting Saga / Great Prison Battle Saga", "originalTitle": "Baki Hanma", "contentRating": "TV-MA", "summary": "The world is shaken by news", "index": 1, "parentIndex": 1, "audienceRating": 8.5, "viewCount": 1, "lastViewedAt": 1694320444, "year": 2021, "thumb": "/library/metadata/40294/thumb/1693544504", "art": "/library/metadata/40275/art/1693952979", "parentThumb": "/library/metadata/40291/thumb/1691115271", "grandparentThumb": "/library/metadata/40275/thumb/1693952979", "grandparentArt": "/library/metadata/40275/art/1693952979", "duration": 1500000, "originallyAvailableAt": "2021-09-30", "addedAt": 1691115281, "updatedAt": 1693544504, "audienceRatingImage": "themoviedb://image.rating", "Guid": [ { "id": "imdb://tt14765720" }, { "id": "tmdb://3087250" }, { "id": "tvdb://8530933" } ], "Rating": [ { "image": "themoviedb://image.rating", "value": 8.5, "type": "audience" } ], "Director": [ { "id": 115144, "filter": "director=115144", "tag": "Keiya Saito", "tagKey": "5f401c8d04a86500409ea6c1" } ], "Writer": [ { "id": 115135, "filter": "writer=115135", "tag": "Tatsuhiko Urahata", "tagKey": "5d7768e07a53e9001e6db1ce", "thumb": "https://metadata-static.plex.tv/f/people/f6f90dc89fa87d459f85d40a09720c05.jpg" } ] } } """ if not form: return None payload = form.get("payload") if not payload: return None try: message = json.loads(payload) except Exception as e: logger.debug(f"解析plex webhook出错:{str(e)}") return None eventType = message.get('event') if not eventType: return None logger.debug(f"接收到plex webhook:{message}") eventItem = schemas.WebhookEventInfo(event=eventType, channel="plex") if message.get('Metadata'): if message.get('Metadata', {}).get('type') == 'episode': eventItem.item_type = "TV" eventItem.item_name = "%s %s%s %s" % ( message.get('Metadata', {}).get('grandparentTitle'), "S" + str(message.get('Metadata', {}).get('parentIndex')), "E" + str(message.get('Metadata', {}).get('index')), message.get('Metadata', {}).get('title')) eventItem.item_id = message.get('Metadata', {}).get('key') eventItem.season_id = message.get('Metadata', {}).get('parentIndex') eventItem.episode_id = message.get('Metadata', {}).get('index') if (message.get('Metadata', {}).get('summary') and len(message.get('Metadata', {}).get('summary')) > 100): eventItem.overview = str(message.get('Metadata', {}).get('summary'))[:100] + "..." else: eventItem.overview = message.get('Metadata', {}).get('summary') else: eventItem.item_type = "MOV" if message.get('Metadata', {}).get('type') == 'movie' else "SHOW" eventItem.item_name = "%s %s" % ( message.get('Metadata', {}).get('title'), "(" + str(message.get('Metadata', {}).get('year')) + ")") eventItem.item_id = message.get('Metadata', {}).get('key') if len(message.get('Metadata', {}).get('summary')) > 100: eventItem.overview = str(message.get('Metadata', {}).get('summary'))[:100] + "..." else: eventItem.overview = message.get('Metadata', {}).get('summary') if message.get('Player'): eventItem.ip = message.get('Player').get('publicAddress') eventItem.client = message.get('Player').get('title') # 这里给个空,防止拼消息的时候出现None eventItem.device_name = ' ' if message.get('Account'): eventItem.user_name = message.get("Account").get('title') # 获取消息图片 if eventItem.item_id: # 根据返回的item_id去调用媒体服务器获取 eventItem.image_url = self.get_remote_image_by_id(item_id=eventItem.item_id, image_type="Backdrop") eventItem.json_object = message return eventItem def get_plex(self): """ 获取plex对象,以便直接操作 """ return self._plex def get_play_url(self, item_id: str) -> str: """ 拼装媒体播放链接 :param item_id: 媒体的的ID """ return f'{self._playhost or self._host}web/index.html#!/server/{self._plex.machineIdentifier}/details?key={item_id}&X-Plex-Token={self._token}' def get_resume(self, num: Optional[int] = 12) -> Optional[List[schemas.MediaServerPlayItem]]: """ 获取继续观看的媒体 """ if not self._plex: return [] # 媒体库白名单 allow_library = ",".join(map(str, (lib.id for lib in self.get_librarys(hidden=True)))) params = {"contentDirectoryID": allow_library} items = self._plex.fetchItems("/hubs/continueWatching/items", container_start=0, container_size=num, maxresults=num, params=params) ret_resume = [] for item in items: item_type = MediaType.MOVIE.value if item.TYPE == "movie" else MediaType.TV.value if item_type == MediaType.MOVIE.value: title = item.title subtitle = str(item.year) if item.year else None else: title = item.grandparentTitle subtitle = f"S{item.parentIndex}:E{item.index} - {item.title}" link = self.get_play_url(item.key) image = item.artUrl ret_resume.append(schemas.MediaServerPlayItem( id=item.key, title=title, subtitle=subtitle, type=item_type, image=image, link=link, percent=item.viewOffset / item.duration * 100 if item.viewOffset and item.duration else 0, server_type='plex' )) return ret_resume[:num] def get_latest(self, num: Optional[int] = 20) -> Optional[List[schemas.MediaServerPlayItem]]: """ 获取最近添加媒体 """ if not self._plex: return None # 请求参数(除黑名单) allow_library = ",".join(map(str, (lib.id for lib in self.get_librarys(hidden=True)))) params = { "contentDirectoryID": allow_library, "count": num, "excludeContinueWatching": 1 } ret_resume = [] sub_result = [] offset = 0 while True: if len(ret_resume) >= num: break # 获取所有资料库 hubs = self._plex.fetchItems( '/hubs/promoted', container_start=offset, container_size=num, maxresults=num, params=params ) if len(hubs) == 0: break # 合并排序 for hub in hubs: for item in hub.items(): sub_result.append(item) sub_result.sort(key=lambda x: x.addedAt, reverse=True) for item in sub_result: if len(ret_resume) >= num: break item_type, title, image = "", "", "" if item.TYPE == "movie": item_type = MediaType.MOVIE.value title = item.title image = item.posterUrl elif item.TYPE == "season": item_type = MediaType.TV.value title = "%s 第%s季" % (item.parentTitle, item.index) image = item.posterUrl elif item.TYPE == "episode": item_type = MediaType.TV.value title = "%s 第%s季 第%s集" % (item.grandparentTitle, item.parentIndex, item.index) thumb = (item.parentThumb or item.grandparentThumb or '').lstrip('/') image = (self._host + thumb + f"?X-Plex-Token={self._token}") elif item.TYPE == "show": item_type = MediaType.TV.value title = "%s 共%s季" % (item.title, item.seasonCount) image = item.posterUrl link = self.get_play_url(item.key) ret_resume.append(schemas.MediaServerPlayItem( id=item.key, title=title, subtitle=str(item.year) if item.year else None, type=item_type, image=image, link=link, server_type='plex' )) offset += num return ret_resume[:num] def get_data(self, endpoint: str, **kwargs) -> Optional[Response]: """ 自定义从媒体服务器获取数据 :param endpoint: 端点 :param kwargs: 其他请求参数,如headers, cookies, proxies等 """ return self.__request(method="get", endpoint=endpoint, **kwargs) def post_data(self, endpoint: str, **kwargs) -> Optional[Response]: """ 自定义从媒体服务器获取数据 :param endpoint: 端点 :param kwargs: 其他请求参数,如headers, cookies, proxies等 """ return self.__request(method="post", endpoint=endpoint, **kwargs) def put_data(self, endpoint: str, **kwargs) -> Optional[Response]: """ 自定义从媒体服务器获取数据 :param endpoint: 端点 :param kwargs: 其他请求参数,如headers, cookies, proxies等 """ return self.__request(method="put", endpoint=endpoint, **kwargs) def __request(self, method: str, endpoint: str, **kwargs) -> Optional[Response]: """ 自定义从媒体服务器获取数据 :param method: HTTP方法,如 get, post, put 等 :param endpoint: 端点 :param kwargs: 其他请求参数,如headers, cookies, proxies等 """ if not self._session: return None try: url = UrlUtils.adapt_request_url(host=self._host, endpoint=endpoint) kwargs.setdefault("headers", self.__get_request_headers()) kwargs.setdefault("raise_exception", True) request_method = getattr(RequestUtils(session=self._session), f"{method}_res", None) if request_method: return request_method(url=url, **kwargs) else: logger.error(f"方法 {method} 不存在") return None except Exception as e: logger.error(f"连接Plex出错:" + str(e)) return None def __get_request_headers(self) -> dict: """获取请求头""" return { "X-Plex-Token": self._token, "Accept": "application/json", "Content-Type": "application/json" } def __adapt_plex_session(self) -> Session: """ 创建并配置一个针对Plex服务的requests.Session实例 这个会话包括特定的头部信息,用于处理所有的Plex请求 """ # 设置请求头部,通常包括验证令牌和接受/内容类型头部 headers = self.__get_request_headers() session = Session() session.headers = headers return session def close(self): if self._session: self._session.close() ================================================ FILE: app/modules/postgresql/__init__.py ================================================ from typing import Tuple, Union from app.core.config import settings from app.db import SessionFactory from app.modules import _ModuleBase from app.schemas.types import ModuleType, OtherModulesType from sqlalchemy import text class PostgreSQLModule(_ModuleBase): """ PostgreSQL 数据库模块 """ def init_module(self) -> None: pass @staticmethod def get_name() -> str: return "PostgreSQL" @staticmethod def get_type() -> ModuleType: """ 获取模块类型 """ return ModuleType.Other @staticmethod def get_subtype() -> OtherModulesType: """ 获取模块子类型 """ return OtherModulesType.PostgreSQL @staticmethod def get_priority() -> int: """ 获取模块优先级,数字越小优先级越高,只有同一接口下优先级才生效 """ return 0 def init_setting(self) -> Tuple[str, Union[str, bool]]: pass def stop(self) -> None: pass def test(self): """ 测试模块连接性 """ if settings.DB_TYPE != "postgresql": return None # 测试数据库连接 db = SessionFactory() try: db.execute(text("SELECT 1")) except Exception as e: return False, f"PostgreSQL连接失败:{e}" finally: db.close() return True, "" ================================================ FILE: app/modules/qbittorrent/__init__.py ================================================ from pathlib import Path from typing import Set, Tuple, Optional, Union, List, Dict from qbittorrentapi import TorrentFilesList from torrentool.torrent import Torrent from app import schemas from app.core.cache import FileCache from app.core.config import settings from app.core.metainfo import MetaInfo from app.log import logger from app.modules import _ModuleBase, _DownloaderBase from app.modules.qbittorrent.qbittorrent import Qbittorrent from app.schemas import TransferTorrent, DownloadingTorrent from app.schemas.types import TorrentStatus, ModuleType, DownloaderType from app.utils.string import StringUtils class QbittorrentModule(_ModuleBase, _DownloaderBase[Qbittorrent]): def init_module(self) -> None: """ 初始化模块 """ super().init_service(service_name=Qbittorrent.__name__.lower(), service_type=Qbittorrent) @staticmethod def get_name() -> str: return "Qbittorrent" @staticmethod def get_type() -> ModuleType: """ 获取模块类型 """ return ModuleType.Downloader @staticmethod def get_subtype() -> DownloaderType: """ 获取模块子类型 """ return DownloaderType.Qbittorrent @staticmethod def get_priority() -> int: """ 获取模块优先级,数字越小优先级越高,只有同一接口下优先级才生效 """ return 1 def stop(self): pass def test(self) -> Optional[Tuple[bool, str]]: """ 测试模块连接性 """ if not self.get_instances(): return None for name, server in self.get_instances().items(): if server.is_inactive(): server.reconnect() if not server.transfer_info(): return False, f"无法连接Qbittorrent下载器:{name}" return True, "" def init_setting(self) -> Tuple[str, Union[str, bool]]: pass def scheduler_job(self) -> None: """ 定时任务,每10分钟调用一次 """ for name, server in self.get_instances().items(): if server.is_inactive(): logger.info(f"Qbittorrent下载器 {name} 连接断开,尝试重连 ...") server.reconnect() def download(self, content: Union[Path, str, bytes], download_dir: Path, cookie: str, episodes: Set[int] = None, category: Optional[str] = None, label: Optional[str] = None, downloader: Optional[str] = None) -> Optional[Tuple[Optional[str], Optional[str], Optional[str], str]]: """ 根据种子文件,选择并添加下载任务 :param content: 种子文件地址或者磁力链接或者种子内容 :param download_dir: 下载目录 :param cookie: cookie :param episodes: 需要下载的集数 :param category: 分类 :param label: 标签 :param downloader: 下载器 :return: 下载器名称、种子Hash、种子文件布局、错误原因 """ def __get_torrent_info() -> Tuple[Optional[Torrent], Optional[bytes]]: """ 获取种子名称 """ torrent_info, torrent_content = None, None try: if isinstance(content, Path): if content.exists(): torrent_content = content.read_bytes() else: # 读取缓存的种子文件 torrent_content = FileCache().get(content.as_posix(), region="torrents") else: torrent_content = content if torrent_content: # 检查是否为磁力链接 if StringUtils.is_magnet_link(torrent_content): return None, torrent_content else: torrent_info = Torrent.from_string(torrent_content) return torrent_info, torrent_content except Exception as e: logger.error(f"获取种子名称失败:{e}") return None, None if not content: return None, None, None, "下载内容为空" # 读取种子的名称 torrent_from_file, content = __get_torrent_info() # 检查是否为磁力链接 is_magnet = isinstance(content, str) and content.startswith("magnet:") or isinstance(content, bytes) and content.startswith( b"magnet:") if not torrent_from_file and not is_magnet: return None, None, None, f"添加种子任务失败:无法读取种子文件" # 获取下载器 server: Qbittorrent = self.get_instance(downloader) if not server: return None # 生成随机Tag tag = StringUtils.generate_random_str(10) if label: tags = label.split(',') + [tag] elif settings.TORRENT_TAG: tags = [tag, settings.TORRENT_TAG] else: tags = [tag] # 如果要选择文件则先暂停 is_paused = True if episodes else False # 添加任务 state = server.add_torrent( content=content, download_dir=self.normalize_path(download_dir, downloader), is_paused=is_paused, tag=tags, cookie=cookie, category=category, ignore_category_check=False ) # 获取种子内容布局: `Original: 原始, Subfolder: 创建子文件夹, NoSubfolder: 不创建子文件夹` torrent_layout = server.get_content_layout() if not state: # 查询所有下载器的种子 torrents, error = server.get_torrents() if error: return None, None, None, "无法连接qbittorrent下载器" if torrents: try: for torrent in torrents: # 名称与大小相等则认为是同一个种子 if torrent.get("name") == getattr(torrent_from_file, 'name', '') \ and torrent.get("total_size") == getattr(torrent_from_file, 'total_size', 0): torrent_hash = torrent.get("hash") torrent_tags = [str(tag).strip() for tag in torrent.get("tags").split(',')] logger.warn(f"下载器中已存在该种子任务:{torrent_hash} - {torrent.get('name')}") # 给种子打上标签 if "已整理" in torrent_tags: server.remove_torrents_tag(ids=torrent_hash, tag=['已整理']) if settings.TORRENT_TAG and settings.TORRENT_TAG not in torrent_tags: logger.info(f"给种子 {torrent_hash} 打上标签:{settings.TORRENT_TAG}") server.set_torrents_tag(ids=torrent_hash, tags=[settings.TORRENT_TAG]) return downloader or self.get_default_config_name(), torrent_hash, torrent_layout, f"下载任务已存在" finally: torrents.clear() del torrents return None, None, None, f"添加种子任务失败:{content}" else: # 获取种子Hash torrent_hash = server.get_torrent_id_by_tag(tags=tag) if not torrent_hash: return None, None, None, f"下载任务添加成功,但获取Qbittorrent任务信息失败:{content}" else: if is_paused: # 种子文件 torrent_files = server.get_files(torrent_hash) if not torrent_files: return downloader or self.get_default_config_name(), torrent_hash, torrent_layout, "获取种子文件失败,下载任务可能在暂停状态" # 不需要的文件ID file_ids = [] # 需要的集清单 sucess_epidised = [] try: for torrent_file in torrent_files: file_id = torrent_file.get("id") file_name = torrent_file.get("name") meta_info = MetaInfo(file_name) if not meta_info.episode_list \ or not set(meta_info.episode_list).issubset(episodes): file_ids.append(file_id) else: sucess_epidised = list(set(sucess_epidised).union(set(meta_info.episode_list))) finally: torrent_files.clear() del torrent_files if sucess_epidised and file_ids: # 选择文件 server.set_files(torrent_hash=torrent_hash, file_ids=file_ids, priority=0) # 开始任务 if server.is_force_resume(): # 强制继续 server.torrents_set_force_start(torrent_hash) else: server.start_torrents(torrent_hash) return downloader or self.get_default_config_name(), torrent_hash, torrent_layout, f"添加下载成功,已选择集数:{sucess_epidised}" else: if server.is_force_resume(): server.torrents_set_force_start(torrent_hash) return downloader or self.get_default_config_name(), torrent_hash, torrent_layout, "添加下载成功" def list_torrents(self, status: TorrentStatus = None, hashs: Union[list, str] = None, downloader: Optional[str] = None ) -> Optional[List[Union[TransferTorrent, DownloadingTorrent]]]: """ 获取下载器种子列表 :param status: 种子状态 :param hashs: 种子Hash :param downloader: 下载器 :return: 下载器中符合状态的种子列表 """ # 获取下载器 if downloader: server: Qbittorrent = self.get_instance(downloader) if not server: return None servers = {downloader: server} else: servers: Dict[str, Qbittorrent] = self.get_instances() ret_torrents = [] if hashs: # 按Hash获取 for name, server in servers.items(): torrents, _ = server.get_torrents(ids=hashs, tags=settings.TORRENT_TAG) or [] try: for torrent in torrents: content_path = torrent.get("content_path") if content_path: torrent_path = Path(content_path) else: torrent_path = Path(torrent.get('save_path')) / torrent.get('name') ret_torrents.append(TransferTorrent( downloader=name, title=torrent.get('name'), path=torrent_path, hash=torrent.get('hash'), size=torrent.get('total_size'), tags=torrent.get('tags'), progress=torrent.get('progress') * 100, state="paused" if torrent.get('state') in ("paused", "pausedDL") else "downloading", )) finally: torrents.clear() del torrents elif status == TorrentStatus.TRANSFER: # 获取已完成且未整理的 for name, server in servers.items(): torrents = server.get_completed_torrents(tags=settings.TORRENT_TAG) or [] try: for torrent in torrents: tags = torrent.get("tags") or [] if "已整理" in tags: continue # 内容路径 content_path = torrent.get("content_path") if content_path: torrent_path = Path(content_path) else: torrent_path = torrent.get('save_path') / torrent.get('name') ret_torrents.append(TransferTorrent( downloader=name, title=torrent.get('name'), path=torrent_path, hash=torrent.get('hash'), tags=torrent.get('tags') )) finally: torrents.clear() del torrents elif status == TorrentStatus.DOWNLOADING: # 获取正在下载的任务 for name, server in servers.items(): torrents = server.get_downloading_torrents(tags=settings.TORRENT_TAG) or [] try: for torrent in torrents: meta = MetaInfo(torrent.get('name')) ret_torrents.append(DownloadingTorrent( downloader=name, hash=torrent.get('hash'), title=torrent.get('name'), name=meta.name, year=meta.year, season_episode=meta.season_episode, progress=torrent.get('progress') * 100, size=torrent.get('total_size'), state="paused" if torrent.get('state') in ("paused", "pausedDL") else "downloading", dlspeed=StringUtils.str_filesize(torrent.get('dlspeed')), upspeed=StringUtils.str_filesize(torrent.get('upspeed')), left_time=StringUtils.str_secends( (torrent.get('total_size') - torrent.get('completed')) / torrent.get( 'dlspeed')) if torrent.get( 'dlspeed') > 0 else '' )) finally: torrents.clear() del torrents else: return None return ret_torrents # noqa def transfer_completed(self, hashs: str, downloader: Optional[str] = None) -> None: """ 转移完成后的处理 :param hashs: 种子Hash :param downloader: 下载器 """ server: Qbittorrent = self.get_instance(downloader) if not server: return None server.set_torrents_tag(ids=hashs, tags=['已整理']) return None def remove_torrents(self, hashs: Union[str, list], delete_file: Optional[bool] = True, downloader: Optional[str] = None) -> Optional[bool]: """ 删除下载器种子 :param hashs: 种子Hash :param delete_file: 是否删除文件 :param downloader: 下载器 :return: bool """ server: Qbittorrent = self.get_instance(downloader) if not server: return None return server.delete_torrents(delete_file=delete_file, ids=hashs) def start_torrents(self, hashs: Union[list, str], downloader: Optional[str] = None) -> Optional[bool]: """ 开始下载 :param hashs: 种子Hash :param downloader: 下载器 :return: bool """ server: Qbittorrent = self.get_instance(downloader) if not server: return None return server.start_torrents(ids=hashs) def stop_torrents(self, hashs: Union[list, str], downloader: Optional[str] = None) -> Optional[bool]: """ 停止下载 :param hashs: 种子Hash :param downloader: 下载器 :return: bool """ server: Qbittorrent = self.get_instance(downloader) if not server: return None return server.stop_torrents(ids=hashs) def torrent_files(self, tid: str, downloader: Optional[str] = None) -> Optional[TorrentFilesList]: """ 获取种子文件列表 """ server: Qbittorrent = self.get_instance(downloader) if not server: return None return server.get_files(tid=tid) def downloader_info(self, downloader: Optional[str] = None) -> Optional[List[schemas.DownloaderInfo]]: """ 下载器信息 """ if downloader: server: Qbittorrent = self.get_instance(downloader) if not server: return None servers = [server] else: servers = self.get_instances().values() # 调用Qbittorrent API查询实时信息 ret_info = [] for server in servers: info = server.transfer_info() if not info: continue ret_info.append(schemas.DownloaderInfo( download_speed=info.get("dl_info_speed"), upload_speed=info.get("up_info_speed"), download_size=info.get("dl_info_data"), upload_size=info.get("up_info_data") )) return ret_info ================================================ FILE: app/modules/qbittorrent/qbittorrent.py ================================================ import time import traceback from typing import Optional, Union, Tuple, List import qbittorrentapi from qbittorrentapi import TorrentDictionary, TorrentFilesList from qbittorrentapi.client import Client from qbittorrentapi.transfer import TransferInfoDictionary from app.log import logger from app.utils.string import StringUtils class Qbittorrent: """ qbittorrent下载器 """ def __init__(self, host: Optional[str] = None, port: int = None, username: Optional[str] = None, password: Optional[str] = None, category: Optional[bool] = False, sequentail: Optional[bool] = False, force_resume: Optional[bool] = False, first_last_piece=False, **kwargs): """ 若不设置参数,则创建配置文件设置的下载器 """ self.qbc = None if host and port: self._host, self._port = host, port elif host: self._host, self._port = StringUtils.get_domain_address(address=host, prefix=True) else: logger.error("Qbittorrent配置不完整!") return self._username = username self._password = password self._category = category self._sequentail = sequentail self._force_resume = force_resume self._first_last_piece = first_last_piece self.qbc = self.__login_qbittorrent() def is_inactive(self) -> bool: """ 判断是否需要重连 """ if not self._host or not self._port: return False return True if not self.qbc else False def reconnect(self): """ 重连 """ self.qbc = self.__login_qbittorrent() def __login_qbittorrent(self) -> Optional[Client]: """ 连接qbittorrent :return: qbittorrent对象 """ if not self._host or not self._port: return None try: # 登录 logger.info(f"正在连接 qbittorrent:{self._host}:{self._port}") qbt = qbittorrentapi.Client(host=self._host, port=self._port, username=self._username, password=self._password, VERIFY_WEBUI_CERTIFICATE=False, REQUESTS_ARGS={'timeout': (15, 60)}) try: qbt.auth_log_in() except (qbittorrentapi.LoginFailed, qbittorrentapi.Forbidden403Error) as e: logger.error(f"qbittorrent 登录失败:{str(e).strip() or '请检查用户名和密码是否正确'}") return None except Exception as e: stack_trace = "".join(traceback.format_exception(None, e, e.__traceback__))[:2000] logger.error(f"qbittorrent 登录失败:{str(e)}\n{stack_trace}") return None return qbt except Exception as err: logger.error(f"qbittorrent 连接出错:{str(err)}") return None def get_torrents(self, ids: Optional[Union[str, list]] = None, status: Optional[str] = None, tags: Optional[Union[str, list]] = None) -> Tuple[List[TorrentDictionary], bool]: """ 获取种子列表 return: 种子列表, 是否发生异常 """ if not self.qbc: return [], True try: torrents = self.qbc.torrents_info(torrent_hashes=ids, status_filter=status) if tags: results = [] if not isinstance(tags, list): tags = tags.split(',') try: for torrent in torrents: torrent_tags = [str(tag).strip() for tag in torrent.get("tags").split(',')] if set(tags).issubset(set(torrent_tags)): results.append(torrent) finally: torrents.clear() del torrents return results, False return torrents or [], False except Exception as err: logger.error(f"获取种子列表出错:{str(err)}") return [], True def get_completed_torrents(self, ids: Union[str, list] = None, tags: Union[str, list] = None) -> Optional[List[TorrentDictionary]]: """ 获取已完成的种子 return: 种子列表, 如发生异常则返回None """ if not self.qbc: return None # completed会包含移动状态 改为获取seeding状态 包含活动上传, 正在做种, 及强制做种 torrents, error = self.get_torrents(status="seeding", ids=ids, tags=tags) return None if error else torrents or [] def get_downloading_torrents(self, ids: Union[str, list] = None, tags: Union[str, list] = None) -> Optional[List[TorrentDictionary]]: """ 获取正在下载的种子 return: 种子列表, 如发生异常则返回None """ if not self.qbc: return None torrents, error = self.get_torrents(ids=ids, status="downloading", tags=tags) return None if error else torrents or [] def delete_torrents_tag(self, ids: Union[str, list], tag: Union[str, list]) -> bool: """ 删除Tag :param ids: 种子Hash列表 :param tag: 标签内容 """ if not self.qbc: return False try: self.qbc.torrents_delete_tags(torrent_hashes=ids, tags=tag) return True except Exception as err: logger.error(f"删除种子Tag出错:{str(err)}") return False def remove_torrents_tag(self, ids: Union[str, list], tag: Union[str, list]) -> bool: """ 移除种子Tag :param ids: 种子Hash列表 :param tag: 标签内容 """ if not self.qbc: return False try: self.qbc.torrents_remove_tags(torrent_hashes=ids, tags=tag) return True except Exception as err: logger.error(f"移除种子Tag出错:{str(err)}") return False def set_torrents_tag(self, ids: Union[str, list], tags: list): """ 设置种子状态为已整理,以及是否强制做种 """ if not self.qbc: return try: # 打标签 self.qbc.torrents_add_tags(tags=tags, torrent_hashes=ids) except Exception as err: logger.error(f"设置种子Tag出错:{str(err)}") def is_force_resume(self) -> bool: """ 是否支持强制作种 """ return self._force_resume def torrents_set_force_start(self, ids: Union[str, list]): """ 设置强制作种 """ if not self.qbc: return if not self._force_resume: return try: self.qbc.torrents_set_force_start(enable=True, torrent_hashes=ids) except Exception as err: logger.error(f"设置强制作种出错:{str(err)}") def __get_last_add_torrentid_by_tag(self, tags: Union[str, list], status: Optional[str] = None) -> Optional[str]: """ 根据种子的下载链接获取下载中或暂停的钟子的ID :return: 种子ID """ try: torrents, _ = self.get_torrents(status=status, tags=tags) except Exception as err: logger.error(f"获取种子列表出错:{str(err)}") return None if torrents: return torrents[0].get("hash") else: return None def get_torrent_id_by_tag(self, tags: Union[str, list], status: Optional[str] = None) -> Optional[str]: """ 通过标签多次尝试获取刚添加的种子ID,并移除标签 """ torrent_id = None # QB添加下载后需要时间,重试10次每次等待3秒 for i in range(1, 10): time.sleep(3) torrent_id = self.__get_last_add_torrentid_by_tag(tags=tags, status=status) if torrent_id is None: continue else: self.delete_torrents_tag(torrent_id, tags) break return torrent_id def add_torrent(self, content: Union[str, bytes], is_paused: Optional[bool] = False, download_dir: Optional[str] = None, tag: Union[str, list] = None, category: Optional[str] = None, cookie: Optional[str] = None, **kwargs ) -> bool: """ 添加种子 :param content: 种子urls或文件内容 :param is_paused: 添加后暂停 :param tag: 标签 :param category: 种子分类 :param download_dir: 下载路径 :param cookie: 站点Cookie用于辅助下载种子 :param kwargs: 可选参数,如 ignore_category_check 以及 QB相关参数 :return: bool """ if not self.qbc or not content: return False # 下载内容 if isinstance(content, str): urls = content torrent_files = None else: urls = None torrent_files = content # 保存目录 if download_dir: save_path = download_dir else: save_path = None # 标签 if tag: tags = tag else: tags = None # 如果忽略分类检查,则直接使用传入的分类值,否则,仅在分类存在且启用了自动管理时才传递参数 ignore_category_check = kwargs.pop("ignore_category_check", True) if ignore_category_check: is_auto = self._category else: if category and self._category: is_auto = True else: is_auto = False category = None try: # 添加下载 qbc_ret = self.qbc.torrents_add(urls=urls, torrent_files=torrent_files, save_path=save_path, is_paused=is_paused, tags=tags, use_auto_torrent_management=is_auto, is_sequential_download=self._sequentail, is_first_last_piece_priority=self._first_last_piece, cookie=cookie, category=category, **kwargs) return True if qbc_ret and str(qbc_ret).find("Ok") != -1 else False except Exception as err: logger.error(f"添加种子出错:{str(err)}") return False def start_torrents(self, ids: Union[str, list]) -> bool: """ 启动种子 """ if not self.qbc: return False try: self.qbc.torrents_resume(torrent_hashes=ids) return True except Exception as err: logger.error(f"启动种子出错:{str(err)}") return False def stop_torrents(self, ids: Union[str, list]) -> bool: """ 暂停种子 """ if not self.qbc: return False try: self.qbc.torrents_pause(torrent_hashes=ids) return True except Exception as err: logger.error(f"暂停种子出错:{str(err)}") return False def delete_torrents(self, delete_file: bool, ids: Union[str, list]) -> bool: """ 删除种子 """ if not self.qbc: return False if not ids: return False try: self.qbc.torrents_delete(delete_files=delete_file, torrent_hashes=ids) return True except Exception as err: logger.error(f"删除种子出错:{str(err)}") return False def get_files(self, tid: str) -> Optional[TorrentFilesList]: """ 获取种子文件清单 """ if not self.qbc: return None try: return self.qbc.torrents_files(torrent_hash=tid) except Exception as err: logger.error(f"获取种子文件列表出错:{str(err)}") return None def set_files(self, **kwargs) -> bool: """ 设置下载文件的状态,priority为0为不下载,priority为1为下载 """ if not self.qbc: return False if not kwargs.get("torrent_hash") or not kwargs.get("file_ids"): return False try: self.qbc.torrents_file_priority(torrent_hash=kwargs.get("torrent_hash"), file_ids=kwargs.get("file_ids"), priority=kwargs.get("priority")) return True except Exception as err: logger.error(f"设置种子文件状态出错:{str(err)}") return False def transfer_info(self) -> Optional[TransferInfoDictionary]: """ 获取传输信息 """ if not self.qbc: return None try: return self.qbc.transfer_info() except Exception as err: logger.error(f"获取传输信息出错:{str(err)}") return None def set_speed_limit(self, download_limit: float = None, upload_limit: float = None) -> bool: """ 设置速度限制 :param download_limit: 下载速度限制,单位KB/s :param upload_limit: 上传速度限制,单位kB/s """ if not self.qbc: return False download_limit = download_limit * 1024 upload_limit = upload_limit * 1024 try: self.qbc.transfer.upload_limit = int(upload_limit) self.qbc.transfer.download_limit = int(download_limit) return True except Exception as err: logger.error(f"设置速度限制出错:{str(err)}") return False def get_speed_limit(self) -> Optional[Tuple[float, float]]: """ 获取QB速度 :return: 返回download_limit 和upload_limit ,默认是0 """ if not self.qbc: return None download_limit = 0 upload_limit = 0 try: download_limit = self.qbc.transfer.download_limit upload_limit = self.qbc.transfer.upload_limit except Exception as err: logger.error(f"获取速度限制出错:{str(err)}") return download_limit / 1024, upload_limit / 1024 def recheck_torrents(self, ids: Union[str, list]) -> bool: """ 重新校验种子 """ if not self.qbc: return False try: self.qbc.torrents_recheck(torrent_hashes=ids) return True except Exception as err: logger.error(f"重新校验种子出错:{str(err)}") return False def update_tracker(self, hash_string: str, tracker_list: list) -> bool: """ 添加tracker """ if not self.qbc: return False try: self.qbc.torrents_add_trackers(torrent_hash=hash_string, urls=tracker_list) return True except Exception as err: logger.error(f"修改tracker出错:{str(err)}") return False def get_content_layout(self) -> Optional[str]: """ 获取内容布局 """ if not self.qbc: return None # 获取下载器全局设置 application = self.qbc.application.preferences # 获取种子内容布局: `Original: 原始, Subfolder: 创建子文件夹, NoSubfolder: 不创建子文件夹` return application.get("torrent_content_layout", "Original") ================================================ FILE: app/modules/qqbot/__init__.py ================================================ """ QQ Bot 通知模块 基于 QQ 开放平台,支持主动消息推送和 Gateway 接收消息 注意:用户/群需曾与机器人交互过才能收到主动消息,且每月有配额限制 """ import json from typing import Optional, List, Tuple, Union, Any from app.core.context import MediaInfo, Context from app.log import logger from app.modules import _ModuleBase, _MessageBase from app.modules.qqbot.qqbot import QQBot from app.schemas import CommingMessage, MessageChannel, Notification from app.schemas.types import ModuleType class QQBotModule(_ModuleBase, _MessageBase[QQBot]): """QQ Bot 通知模块""" def init_module(self) -> None: super().init_service(service_name=QQBot.__name__.lower(), service_type=QQBot) self._channel = MessageChannel.QQ @staticmethod def get_name() -> str: return "QQ" @staticmethod def get_type() -> ModuleType: return ModuleType.Notification @staticmethod def get_subtype() -> MessageChannel: return MessageChannel.QQ @staticmethod def get_priority() -> int: return 10 def stop(self) -> None: for client in self.get_instances().values(): if hasattr(client, "stop"): client.stop() def test(self) -> Optional[Tuple[bool, str]]: if not self.get_instances(): return None for name, client in self.get_instances().items(): if not client.get_state(): return False, f"QQ Bot {name} 未就绪" return True, "" def init_setting(self) -> Tuple[str, Union[str, bool]]: pass def message_parser( self, source: str, body: Any, form: Any, args: Any ) -> Optional[CommingMessage]: """ 解析 Gateway 转发的 QQ 消息 body 格式: {"type": "C2C_MESSAGE_CREATE"|"GROUP_AT_MESSAGE_CREATE", "content": "...", "author": {...}, "id": "...", ...} """ client_config = self.get_config(source) if not client_config: return None try: if isinstance(body, bytes): msg_body = json.loads(body) elif isinstance(body, dict): msg_body = body else: return None except (json.JSONDecodeError, TypeError) as err: logger.debug(f"解析 QQ 消息失败: {err}") return None msg_type = msg_body.get("type") content = (msg_body.get("content") or "").strip() if not content: return None if msg_type == "C2C_MESSAGE_CREATE": author = msg_body.get("author", {}) user_openid = author.get("user_openid", "") if not user_openid: return None logger.info(f"收到 QQ 私聊消息: userid={user_openid}, text={content[:50]}...") return CommingMessage( channel=MessageChannel.QQ, source=client_config.name, userid=user_openid, username=user_openid, text=content, ) elif msg_type == "GROUP_AT_MESSAGE_CREATE": author = msg_body.get("author", {}) member_openid = author.get("member_openid", "") group_openid = msg_body.get("group_openid", "") # 群聊用 group:group_openid 作为 userid,便于回复时识别 userid = f"group:{group_openid}" if group_openid else member_openid logger.info(f"收到 QQ 群消息: group={group_openid}, userid={member_openid}, text={content[:50]}...") return CommingMessage( channel=MessageChannel.QQ, source=client_config.name, userid=userid, username=member_openid or group_openid, text=content, ) return None def post_message(self, message: Notification, **kwargs) -> None: for conf in self.get_configs().values(): if not self.check_message(message, conf.name): continue targets = message.targets userid = message.userid if not userid and targets: userid = targets.get("qq_userid") or targets.get("qq_openid") if not userid: userid = targets.get("qq_group_openid") or targets.get("qq_group") if userid: userid = f"group:{userid}" # 无 userid 且无默认配置时,由 client 向曾发过消息的用户/群广播 client: QQBot = self.get_instance(conf.name) if client: client.send_msg( title=message.title, text=message.text, image=message.image, link=message.link, userid=userid, targets=targets, ) def post_medias_message(self, message: Notification, medias: List[MediaInfo]) -> None: for conf in self.get_configs().values(): if not self.check_message(message, conf.name): continue targets = message.targets userid = message.userid if not userid and targets: userid = targets.get("qq_userid") or targets.get("qq_openid") if not userid: g = targets.get("qq_group_openid") or targets.get("qq_group") if g: userid = f"group:{g}" client: QQBot = self.get_instance(conf.name) if client: client.send_medias_msg( medias=medias, userid=userid, title=message.title, link=message.link, targets=targets, ) def post_torrents_message( self, message: Notification, torrents: List[Context] ) -> None: for conf in self.get_configs().values(): if not self.check_message(message, conf.name): continue targets = message.targets userid = message.userid if not userid and targets: userid = targets.get("qq_userid") or targets.get("qq_openid") if not userid: g = targets.get("qq_group_openid") or targets.get("qq_group") if g: userid = f"group:{g}" client: QQBot = self.get_instance(conf.name) if client: client.send_torrents_msg( torrents=torrents, userid=userid, title=message.title, link=message.link, targets=targets, ) ================================================ FILE: app/modules/qqbot/api.py ================================================ """ QQ Bot API - Python 实现 参考 QQ 开放平台官方 API: https://bot.q.qq.com/wiki/develop/api/ """ import time from typing import Optional, Literal from app.log import logger from app.utils.http import RequestUtils API_BASE = "https://api.sgroup.qq.com" TOKEN_URL = "https://bots.qq.com/app/getAppAccessToken" # Token 缓存 _cached_token: Optional[dict] = None def get_access_token(app_id: str, client_secret: str) -> str: """ 获取 AccessToken(带缓存,提前 5 分钟刷新) """ global _cached_token now_ms = int(time.time() * 1000) if _cached_token and now_ms < _cached_token["expires_at"] - 5 * 60 * 1000 and _cached_token["app_id"] == app_id: return _cached_token["token"] if _cached_token and _cached_token["app_id"] != app_id: _cached_token = None try: resp = RequestUtils(timeout=30).post_res( TOKEN_URL, json={"appId": app_id, "clientSecret": client_secret}, # QQ API 使用 camelCase headers={"Content-Type": "application/json"}, ) if not resp or not resp.json(): raise ValueError("Failed to get access_token: empty response") data = resp.json() token = data.get("access_token") expires_in = data.get("expires_in", 7200) if not token: raise ValueError(f"Failed to get access_token: {data}") # expires_in 可能为字符串,统一转为 int expires_in = int(expires_in) if expires_in is not None else 7200 _cached_token = { "token": token, "expires_at": now_ms + expires_in * 1000, "app_id": app_id, } logger.debug(f"QQ API: Token cached for app_id={app_id}") return token except Exception as e: logger.error(f"QQ API: get_access_token failed: {e}") raise def clear_token_cache() -> None: """清除 Token 缓存""" global _cached_token _cached_token = None def _api_request( access_token: str, method: str, path: str, body: Optional[dict] = None, timeout: int = 30, ) -> dict: """通用 API 请求""" url = f"{API_BASE}{path}" headers = { "Authorization": f"QQBot {access_token}", "Content-Type": "application/json", } try: if method.upper() == "GET": resp = RequestUtils(timeout=timeout).get_res(url, headers=headers) else: resp = RequestUtils(timeout=timeout).post_res( url, json=body or {}, headers=headers ) if not resp: raise ValueError("Empty response") data = resp.json() status = getattr(resp, "status_code", 0) if status and status >= 400: raise ValueError(f"API Error [{path}]: {data.get('message', data)}") return data except Exception as e: logger.error(f"QQ API: {method} {path} failed: {e}") raise def send_proactive_c2c_message( access_token: str, openid: str, content: str, use_markdown: bool = False, ) -> dict: """ 主动发送 C2C 单聊消息(不需要 msg_id) 注意:每月限 4 条/用户,且用户必须曾与机器人交互过 :param access_token: 访问令牌 :param openid: 用户 openid :param content: 消息内容 :param use_markdown: 是否使用 Markdown 格式(需机器人开通 Markdown 能力) """ if not content or not content.strip(): raise ValueError("主动消息内容不能为空") content = content.strip() body = {"markdown": {"content": content}, "msg_type": 2} if use_markdown else {"content": content, "msg_type": 0} return _api_request( access_token, "POST", f"/v2/users/{openid}/messages", body ) def send_proactive_group_message( access_token: str, group_openid: str, content: str, use_markdown: bool = False, ) -> dict: """ 主动发送群聊消息(不需要 msg_id) 注意:每月限 4 条/群,且群必须曾与机器人交互过 :param access_token: 访问令牌 :param group_openid: 群聊 openid :param content: 消息内容 :param use_markdown: 是否使用 Markdown 格式(需机器人开通 Markdown 能力) """ if not content or not content.strip(): raise ValueError("主动消息内容不能为空") content = content.strip() body = {"markdown": {"content": content}, "msg_type": 2} if use_markdown else {"content": content, "msg_type": 0} return _api_request( access_token, "POST", f"/v2/groups/{group_openid}/messages", body ) def send_c2c_message( access_token: str, openid: str, content: str, msg_id: Optional[str] = None, ) -> dict: """被动回复 C2C 单聊消息(1 小时内最多 4 次)""" body = {"content": content, "msg_type": 0, "msg_seq": 1} if msg_id: body["msg_id"] = msg_id return _api_request( access_token, "POST", f"/v2/users/{openid}/messages", body ) def send_group_message( access_token: str, group_openid: str, content: str, msg_id: Optional[str] = None, ) -> dict: """被动回复群聊消息(1 小时内最多 4 次)""" body = {"content": content, "msg_type": 0, "msg_seq": 1} if msg_id: body["msg_id"] = msg_id return _api_request( access_token, "POST", f"/v2/groups/{group_openid}/messages", body ) def get_gateway_url(access_token: str) -> str: """ 获取 WebSocket Gateway URL """ data = _api_request(access_token, "GET", "/gateway") url = data.get("url") if not url: raise ValueError("Gateway URL not found in response") return url def send_message( access_token: str, target: str, content: str, msg_type: Literal["c2c", "group"] = "c2c", msg_id: Optional[str] = None, ) -> dict: """ 统一发送接口 :param access_token: 访问令牌 :param target: openid(c2c)或 group_openid(group) :param content: 消息内容 :param msg_type: c2c 单聊 / group 群聊 :param msg_id: 可选,被动回复时传入原消息 id """ if msg_id: if msg_type == "c2c": return send_c2c_message(access_token, target, content, msg_id) return send_group_message(access_token, target, content, msg_id) if msg_type == "c2c": return send_proactive_c2c_message(access_token, target, content) return send_proactive_group_message(access_token, target, content) ================================================ FILE: app/modules/qqbot/gateway.py ================================================ """ QQ Bot Gateway WebSocket 客户端 连接 QQ 开放平台 Gateway,接收 C2C 和群聊消息并转发至 MP 消息链 """ import json import threading import time from typing import Callable, Optional import websocket from app.log import logger # QQ Bot intents INTENT_GROUP_AND_C2C = 1 << 25 # 群聊和 C2C 私聊 def run_gateway( app_id: str, app_secret: str, config_name: str, get_token_fn: Callable[[str, str], str], get_gateway_url_fn: Callable[[str], str], on_message_fn: Callable[[dict], None], stop_event: threading.Event, ) -> None: """ 在后台线程中运行 Gateway WebSocket 连接 :param app_id: QQ 机器人 AppID :param app_secret: QQ 机器人 AppSecret :param config_name: 配置名称,用于消息来源标识 :param get_token_fn: 获取 access_token 的函数 (app_id, app_secret) -> token :param get_gateway_url_fn: 获取 gateway URL 的函数 (token) -> url :param on_message_fn: 收到消息时的回调 (payload_dict) -> None :param stop_event: 停止事件,set 时退出循环 """ last_seq: Optional[int] = None heartbeat_interval_ms: Optional[int] = None heartbeat_timer: Optional[threading.Timer] = None ws_ref: list = [] # 用于在闭包中保持 ws 引用 def send_heartbeat(): nonlocal heartbeat_timer if stop_event.is_set(): return try: if ws_ref and ws_ref[0]: payload = {"op": 1, "d": last_seq} ws_ref[0].send(json.dumps(payload)) logger.debug(f"[QQ Gateway:{config_name}] Heartbeat sent, seq={last_seq}") except Exception as err: logger.debug(f"[QQ Gateway:{config_name}] Heartbeat error: {err}") if heartbeat_interval_ms and not stop_event.is_set(): heartbeat_timer = threading.Timer(heartbeat_interval_ms / 1000.0, send_heartbeat) heartbeat_timer.daemon = True heartbeat_timer.start() def on_ws_message(_, message): nonlocal last_seq, heartbeat_interval_ms, heartbeat_timer try: payload = json.loads(message) except json.JSONDecodeError as err: logger.error(f"[QQ Gateway:{config_name}] Invalid JSON: {err}") return op = payload.get("op") d = payload.get("d") s = payload.get("s") t = payload.get("t") if s is not None: last_seq = s logger.debug(f"[QQ Gateway:{config_name}] op={op} t={t}") if op == 10: # Hello heartbeat_interval_ms = d.get("heartbeat_interval", 30000) logger.info(f"[QQ Gateway:{config_name}] Hello received, heartbeat_interval={heartbeat_interval_ms}") # Identify identify = { "op": 2, "d": { "token": f"QQBot {token}", "intents": INTENT_GROUP_AND_C2C, "shard": [0, 1], }, } ws_ref[0].send(json.dumps(identify)) logger.info(f"[QQ Gateway:{config_name}] Identify sent") # 启动心跳 if heartbeat_timer: heartbeat_timer.cancel() heartbeat_timer = threading.Timer(heartbeat_interval_ms / 1000.0, send_heartbeat) heartbeat_timer.daemon = True heartbeat_timer.start() elif op == 0: # Dispatch if t == "READY": session_id = d.get("session_id", "") logger.info(f"[QQ Gateway:{config_name}] 连接成功 Ready, session_id={session_id}") elif t == "RESUMED": logger.info(f"[QQ Gateway:{config_name}] 连接成功 Session resumed") elif t == "C2C_MESSAGE_CREATE": author = d.get("author", {}) user_openid = author.get("user_openid", "") content = d.get("content", "").strip() msg_id = d.get("id", "") if content: on_message_fn({ "type": "C2C_MESSAGE_CREATE", "content": content, "author": {"user_openid": user_openid}, "id": msg_id, "timestamp": d.get("timestamp", ""), }) elif t == "GROUP_AT_MESSAGE_CREATE": author = d.get("author", {}) member_openid = author.get("member_openid", "") group_openid = d.get("group_openid", "") content = d.get("content", "").strip() msg_id = d.get("id", "") if content: on_message_fn({ "type": "GROUP_AT_MESSAGE_CREATE", "content": content, "author": {"member_openid": member_openid}, "id": msg_id, "group_openid": group_openid, "timestamp": d.get("timestamp", ""), }) # 其他事件忽略 elif op == 7: # Reconnect logger.info(f"[QQ Gateway:{config_name}] Reconnect requested") # 当前实现不自动重连,由外层循环处理 elif op == 9: # Invalid Session logger.warning(f"[QQ Gateway:{config_name}] Invalid session") if ws_ref and ws_ref[0]: ws_ref[0].close() def on_ws_error(_, error): logger.error(f"[QQ Gateway:{config_name}] WebSocket error: {error}") def on_ws_close(_, close_status_code, close_msg): logger.info(f"[QQ Gateway:{config_name}] WebSocket closed: {close_status_code} {close_msg}") if heartbeat_timer: heartbeat_timer.cancel() reconnect_delays = [1, 2, 5, 10, 30, 60] attempt = 0 while not stop_event.is_set(): try: token = get_token_fn(app_id, app_secret) gateway_url = get_gateway_url_fn(token) logger.info(f"[QQ Gateway:{config_name}] Connecting to {gateway_url[:60]}...") ws = websocket.WebSocketApp( gateway_url, on_message=on_ws_message, on_error=on_ws_error, on_close=on_ws_close, ) ws_ref.clear() ws_ref.append(ws) # run_forever 会阻塞,需要传入 stop_event 的检查 # websocket-client 的 run_forever 支持 ping_interval, ping_timeout # 我们使用自定义心跳,所以不设置 ping ws.run_forever( ping_interval=None, ping_timeout=None, skip_utf8_validation=True, ) except Exception as e: logger.error(f"[QQ Gateway:{config_name}] Connection error: {e}") if stop_event.is_set(): break delay = reconnect_delays[min(attempt, len(reconnect_delays) - 1)] attempt += 1 logger.info(f"[QQ Gateway:{config_name}] Reconnecting in {delay}s (attempt {attempt})") for _ in range(delay * 10): if stop_event.is_set(): break time.sleep(0.1) if heartbeat_timer: heartbeat_timer.cancel() logger.info(f"[QQ Gateway:{config_name}] Gateway thread stopped") ================================================ FILE: app/modules/qqbot/qqbot.py ================================================ """ QQ Bot 通知客户端 基于 QQ 开放平台 API,支持主动消息推送和 Gateway 接收消息 """ import hashlib import io import pickle import threading from typing import Optional, List, Tuple from PIL import Image from app.chain.message import MessageChain from app.core.cache import FileCache from app.core.context import MediaInfo, Context from app.core.metainfo import MetaInfo from app.log import logger from app.modules.qqbot.api import ( get_access_token, get_gateway_url, send_proactive_c2c_message, send_proactive_group_message, ) from app.modules.qqbot.gateway import run_gateway from app.utils.http import RequestUtils from app.utils.string import StringUtils # QQ Markdown 图片默认尺寸(获取失败时使用,与 OpenClaw 对齐) _DEFAULT_IMAGE_SIZE: Tuple[int, int] = (512, 512) class QQBot: """QQ Bot 通知客户端""" def __init__( self, QQ_APP_ID: Optional[str] = None, QQ_APP_SECRET: Optional[str] = None, QQ_OPENID: Optional[str] = None, QQ_GROUP_OPENID: Optional[str] = None, name: Optional[str] = None, **kwargs, ): """ 初始化 QQ Bot :param QQ_APP_ID: QQ 机器人 AppID :param QQ_APP_SECRET: QQ 机器人 AppSecret :param QQ_OPENID: 默认接收者 openid(单聊) :param QQ_GROUP_OPENID: 默认群组 openid(群聊,与 QQ_OPENID 二选一) :param name: 配置名称,用于消息来源标识和 Gateway 接收 """ if not QQ_APP_ID or not QQ_APP_SECRET: logger.error("QQ Bot 配置不完整:缺少 AppID 或 AppSecret") self._ready = False return self._app_id = QQ_APP_ID self._app_secret = QQ_APP_SECRET self._default_openid = QQ_OPENID self._default_group_openid = QQ_GROUP_OPENID self._config_name = name or "qqbot" self._ready = True # 曾发过消息的用户/群,用于无默认接收者时的广播 {(target_id, is_group), ...} self._known_targets: set = set() _safe_name = hashlib.md5(self._config_name.encode()).hexdigest()[:12] self._cache_key = f"__qqbot_known_targets_{_safe_name}__" self._filecache = FileCache() self._load_known_targets() # 已处理的消息 ID,用于去重(避免同一条消息重复处理) self._processed_msg_ids: set = set() self._max_processed_ids = 1000 # Gateway 后台线程 self._gateway_stop = threading.Event() self._gateway_thread = None self._start_gateway() logger.info("QQ Bot 客户端初始化完成") def _load_known_targets(self) -> None: """从缓存加载曾互动的用户/群""" try: content = self._filecache.get(self._cache_key) if content: data = pickle.loads(content) if isinstance(data, (list, set)): self._known_targets = set(tuple(x) for x in data) except Exception as e: logger.debug(f"QQ Bot 加载 known_targets 失败: {e}") def _save_known_targets(self) -> None: """持久化曾互动的用户/群到缓存""" try: self._filecache.set(self._cache_key, pickle.dumps(list(self._known_targets))) except Exception as e: logger.debug(f"QQ Bot 保存 known_targets 失败: {e}") def _forward_to_message_chain(self, payload: dict) -> None: """直接调用消息链处理,避免 HTTP 开销""" def _run(): try: MessageChain().process( body=payload, form={}, args={"source": self._config_name}, ) except Exception as e: logger.error(f"QQ Bot 转发消息失败: {e}") threading.Thread(target=_run, daemon=True).start() def _on_gateway_message(self, payload: dict) -> None: """Gateway 收到消息时转发至 MP 消息链,并记录发送者用于广播""" msg_id = payload.get("id") if msg_id: if msg_id in self._processed_msg_ids: logger.debug(f"QQ Bot: 跳过重复消息 id={msg_id}") return self._processed_msg_ids.add(msg_id) if len(self._processed_msg_ids) > self._max_processed_ids: self._processed_msg_ids.clear() # 记录发送者,用于无默认接收者时的广播 msg_type = payload.get("type") if msg_type == "C2C_MESSAGE_CREATE": openid = (payload.get("author") or {}).get("user_openid") if openid: self._known_targets.add((openid, False)) self._save_known_targets() elif msg_type == "GROUP_AT_MESSAGE_CREATE": group_openid = payload.get("group_openid") if group_openid: self._known_targets.add((group_openid, True)) self._save_known_targets() self._forward_to_message_chain(payload) def _start_gateway(self) -> None: """启动 Gateway WebSocket 连接(后台线程)""" try: self._gateway_thread = threading.Thread( target=run_gateway, kwargs={ "app_id": self._app_id, "app_secret": self._app_secret, "config_name": self._config_name, "get_token_fn": get_access_token, "get_gateway_url_fn": get_gateway_url, "on_message_fn": self._on_gateway_message, "stop_event": self._gateway_stop, }, daemon=True, ) self._gateway_thread.start() logger.info(f"QQ Bot Gateway 已启动: {self._config_name}") except Exception as e: logger.error(f"QQ Bot Gateway 启动失败: {e}") def stop(self) -> None: """停止 Gateway 连接""" if self._gateway_stop: self._gateway_stop.set() if self._gateway_thread and self._gateway_thread.is_alive(): self._gateway_thread.join(timeout=5) def get_state(self) -> bool: """获取就绪状态""" return self._ready def _get_target(self, userid: Optional[str] = None, targets: Optional[dict] = None) -> tuple: """ 解析发送目标 :return: (target_id, is_group) """ # 优先使用 userid(可能是 openid) if userid: # 格式支持:group:xxx 表示群聊 if str(userid).lower().startswith("group:"): return userid[6:].strip(), True return str(userid), False # 从 targets 获取 if targets: qq_openid = targets.get("qq_userid") or targets.get("qq_openid") qq_group = targets.get("qq_group_openid") or targets.get("qq_group") if qq_group: return str(qq_group), True if qq_openid: return str(qq_openid), False # 使用默认配置 if self._default_group_openid: return self._default_group_openid, True if self._default_openid: return self._default_openid, False return None, False def _get_broadcast_targets(self) -> list: """获取广播目标列表(曾发过消息的用户/群)""" return list(self._known_targets) @staticmethod def _get_image_size(url: str) -> Optional[Tuple[int, int]]: """ 从图片 URL 获取尺寸,只下载前 64KB 解析文件头(参考 OpenClaw) :return: (width, height) 或 None """ try: resp = RequestUtils(timeout=5).get_res( url, headers={"Range": "bytes=0-65535", "User-Agent": "QQBot-Image-Size-Detector/1.0"}, ) if not resp or not resp.content: return None data = resp.content[:65536] if len(resp.content) > 65536 else resp.content with Image.open(io.BytesIO(data)) as img: return img.width, img.height except Exception as e: logger.debug(f"QQ Bot 获取图片尺寸失败 ({url[:60]}...): {e}") return None @staticmethod def _escape_markdown(text: str) -> str: """转义 Markdown 特殊字符,避免破坏格式。不转义 (),QQ 会误解析 \\( \\) 导致括号丢失或乱码""" if not text: return "" text = text.replace("\\", "\\\\") for char in ("*", "_", "[", "]", "`"): text = text.replace(char, f"\\{char}") return text @staticmethod def _format_message_markdown( title: Optional[str] = None, text: Optional[str] = None, image: Optional[str] = None, link: Optional[str] = None, ) -> tuple: """ 将消息格式化为 QQ Markdown,类似 Telegram 处理方式 :return: (content, use_markdown) """ parts = [] if title: # 标题加粗,移除可能破坏格式的换行 safe_title = (title or "").replace("\n", " ").strip() if safe_title: parts.append(f"**{QQBot._escape_markdown(safe_title)}**") if text: parts.append(QQBot._escape_markdown((text or "").strip())) if image: # QQ Markdown 图片需带尺寸才能正确渲染,格式: ![#宽px #高px](url),否则会显示为 [图片] 文本 # 参考 OpenClaw,先获取图片真实尺寸,失败则用默认 512x512 img_url = (image or "").strip() if img_url and (img_url.startswith("http://") or img_url.startswith("https://")): size = QQBot._get_image_size(img_url) w, h = size if size else _DEFAULT_IMAGE_SIZE if size: logger.debug(f"QQ Bot 图片尺寸: {w}x{h} - {img_url[:60]}...") parts.append(f"![#{w}px #{h}px]({img_url})") elif img_url: parts.append(img_url) if link: link_url = (link or "").strip() if link_url: parts.append(f"[查看详情]({link_url})") content = "\n\n".join(p for p in parts if p).strip() return content, bool(content) def send_msg( self, title: str, text: Optional[str] = None, image: Optional[str] = None, link: Optional[str] = None, userid: Optional[str] = None, targets: Optional[dict] = None, **kwargs, ) -> bool: """ 发送 QQ 消息 :param title: 标题 :param text: 正文 :param image: 图片 URL(QQ 主动消息暂不支持图片,可拼入文本) :param link: 链接 :param userid: 目标 openid 或 group:xxx :param targets: 目标字典 """ if not self._ready: return False target, is_group = self._get_target(userid, targets) targets_to_send = [] if target: targets_to_send = [(target, is_group)] else: # 无默认接收者时,向曾发过消息的用户/群广播 broadcast = self._get_broadcast_targets() if broadcast: targets_to_send = broadcast logger.debug(f"QQ Bot: 广播模式,共 {len(targets_to_send)} 个目标") else: logger.warn("QQ Bot: 未指定接收者且无互动用户,请在配置中设置 QQ_OPENID/QQ_GROUP_OPENID 或先让用户发消息") return False # 使用 Markdown 格式发送(类似 Telegram) content, use_markdown = self._format_message_markdown(title=title, text=text, image=image, link=link) logger.info(f"QQ Bot 发送内容 (use_markdown={use_markdown}):\n{content}") if not content: logger.warn("QQ Bot: 消息内容为空") return False success_count = 0 try: token = get_access_token(self._app_id, self._app_secret) for tgt, tgt_is_group in targets_to_send: send_fn = send_proactive_group_message if tgt_is_group else send_proactive_c2c_message try: send_fn(token, tgt, content, use_markdown=use_markdown) success_count += 1 logger.debug(f"QQ Bot: 消息已发送到 {'群' if tgt_is_group else '用户'} {tgt}") except Exception as e: err_msg = str(e) if use_markdown and ("markdown" in err_msg.lower() or "11244" in err_msg or "权限" in err_msg): # Markdown 未开通时回退为纯文本 plain_parts = [] if title: plain_parts.append(f"【{title}】") if text: plain_parts.append(text) if image: plain_parts.append(image) if link: plain_parts.append(link) plain_content = "\n".join(plain_parts).strip() if plain_content: send_fn(token, tgt, plain_content, use_markdown=False) success_count += 1 logger.debug(f"QQ Bot: Markdown 不可用,已回退纯文本发送至 {tgt}") else: logger.error(f"QQ Bot 发送失败 ({tgt}): {e}") return success_count > 0 except Exception as e: logger.error(f"QQ Bot 发送失败: {e}") return False def send_medias_msg( self, medias: List[MediaInfo], userid: Optional[str] = None, title: Optional[str] = None, link: Optional[str] = None, **kwargs, ) -> bool: """发送媒体列表(转为文本)""" if not medias: return False lines = [f"{i + 1}. {m.title_year} - {m.type.value}" for i, m in enumerate(medias)] text = "\n".join(lines) return self.send_msg( title=title or "媒体列表", text=text, link=link, userid=userid, **kwargs, ) def send_torrents_msg( self, torrents: List[Context], userid: Optional[str] = None, title: Optional[str] = None, link: Optional[str] = None, **kwargs, ) -> bool: """发送种子列表(转为文本)""" if not torrents: return False lines = [] for i, ctx in enumerate(torrents): t = ctx.torrent_info meta = MetaInfo(t.title, t.description) name = f"{meta.season_episode} {meta.resource_term} {meta.video_term}" name = " ".join(name.split()) lines.append(f"{i + 1}.【{t.site_name}】{name} {StringUtils.str_filesize(t.size)} {t.seeders}↑") text = "\n".join(lines) return self.send_msg( title=title or "种子列表", text=text, link=link, userid=userid, **kwargs, ) ================================================ FILE: app/modules/redis/__init__.py ================================================ from typing import Tuple, Union from app.core.config import settings from app.helper.redis import RedisHelper from app.modules import _ModuleBase from app.schemas.types import ModuleType, OtherModulesType class RedisModule(_ModuleBase): """ Redis 数据库模块 """ def init_module(self) -> None: pass @staticmethod def get_name() -> str: return "Redis缓存" @staticmethod def get_type() -> ModuleType: """ 获取模块类型 """ return ModuleType.Other @staticmethod def get_subtype() -> OtherModulesType: """ 获取模块子类型 """ return OtherModulesType.Redis @staticmethod def get_priority() -> int: """ 获取模块优先级,数字越小优先级越高,只有同一接口下优先级才生效 """ return 0 def init_setting(self) -> Tuple[str, Union[str, bool]]: pass def stop(self) -> None: pass def test(self): """ 测试模块连接性 """ if settings.CACHE_BACKEND_TYPE != "redis": return None if RedisHelper().test(): return True, "" return False, "Redis连接失败,请检查配置" ================================================ FILE: app/modules/rtorrent/__init__.py ================================================ from pathlib import Path from typing import Set, Tuple, Optional, Union, List, Dict from torrentool.torrent import Torrent from app import schemas from app.core.cache import FileCache from app.core.config import settings from app.core.metainfo import MetaInfo from app.log import logger from app.modules import _ModuleBase, _DownloaderBase from app.modules.rtorrent.rtorrent import Rtorrent from app.schemas import TransferTorrent, DownloadingTorrent from app.schemas.types import TorrentStatus, ModuleType, DownloaderType from app.utils.string import StringUtils class RtorrentModule(_ModuleBase, _DownloaderBase[Rtorrent]): def init_module(self) -> None: """ 初始化模块 """ super().init_service( service_name=Rtorrent.__name__.lower(), service_type=Rtorrent ) @staticmethod def get_name() -> str: return "Rtorrent" @staticmethod def get_type() -> ModuleType: """ 获取模块类型 """ return ModuleType.Downloader @staticmethod def get_subtype() -> DownloaderType: """ 获取模块子类型 """ return DownloaderType.Rtorrent @staticmethod def get_priority() -> int: """ 获取模块优先级,数字越小优先级越高,只有同一接口下优先级才生效 """ return 3 def stop(self): pass def test(self) -> Optional[Tuple[bool, str]]: """ 测试模块连接性 """ if not self.get_instances(): return None for name, server in self.get_instances().items(): if server.is_inactive(): server.reconnect() if not server.transfer_info(): return False, f"无法连接rTorrent下载器:{name}" return True, "" def init_setting(self) -> Tuple[str, Union[str, bool]]: pass def scheduler_job(self) -> None: """ 定时任务,每10分钟调用一次 """ for name, server in self.get_instances().items(): if server.is_inactive(): logger.info(f"rTorrent下载器 {name} 连接断开,尝试重连 ...") server.reconnect() def download( self, content: Union[Path, str, bytes], download_dir: Path, cookie: str, episodes: Set[int] = None, category: Optional[str] = None, label: Optional[str] = None, downloader: Optional[str] = None, ) -> Optional[Tuple[Optional[str], Optional[str], Optional[str], str]]: """ 根据种子文件,选择并添加下载任务 :param content: 种子文件地址或者磁力链接或种子内容 :param download_dir: 下载目录 :param cookie: cookie :param episodes: 需要下载的集数 :param category: 分类,rTorrent中未使用 :param label: 标签 :param downloader: 下载器 :return: 下载器名称、种子Hash、种子文件布局、错误原因 """ def __get_torrent_info() -> Tuple[Optional[Torrent], Optional[bytes]]: """ 获取种子名称 """ torrent_info, torrent_content = None, None try: if isinstance(content, Path): if content.exists(): torrent_content = content.read_bytes() else: torrent_content = FileCache().get( content.as_posix(), region="torrents" ) else: torrent_content = content if torrent_content: if StringUtils.is_magnet_link(torrent_content): return None, torrent_content else: torrent_info = Torrent.from_string(torrent_content) return torrent_info, torrent_content except Exception as e: logger.error(f"获取种子名称失败:{e}") return None, None if not content: return None, None, None, "下载内容为空" # 读取种子的名称 torrent_from_file, content = __get_torrent_info() # 检查是否为磁力链接 is_magnet = ( isinstance(content, str) and content.startswith("magnet:") or isinstance(content, bytes) and content.startswith(b"magnet:") ) if not torrent_from_file and not is_magnet: return None, None, None, f"添加种子任务失败:无法读取种子文件" # 获取下载器 server: Rtorrent = self.get_instance(downloader) if not server: return None # 生成随机Tag tag = StringUtils.generate_random_str(10) if label: tags = label.split(",") + [tag] elif settings.TORRENT_TAG: tags = [tag, settings.TORRENT_TAG] else: tags = [tag] # 如果要选择文件则先暂停 is_paused = True if episodes else False # 添加任务 state = server.add_torrent( content=content, download_dir=self.normalize_path(download_dir, downloader), is_paused=is_paused, tags=tags, cookie=cookie, ) # rTorrent 始终使用原始种子布局 torrent_layout = "Original" if not state: # 查询所有下载器的种子 torrents, error = server.get_torrents() if error: return None, None, None, "无法连接rTorrent下载器" if torrents: try: for torrent in torrents: # 名称与大小相等则认为是同一个种子 if torrent.get("name") == getattr( torrent_from_file, "name", "" ) and torrent.get("total_size") == getattr( torrent_from_file, "total_size", 0 ): torrent_hash = torrent.get("hash") torrent_tags = [ str(t).strip() for t in torrent.get("tags", "").split(",") if t.strip() ] logger.warn( f"下载器中已存在该种子任务:{torrent_hash} - {torrent.get('name')}" ) # 给种子打上标签 if "已整理" in torrent_tags: server.remove_torrents_tag( ids=torrent_hash, tag=["已整理"] ) if ( settings.TORRENT_TAG and settings.TORRENT_TAG not in torrent_tags ): logger.info( f"给种子 {torrent_hash} 打上标签:{settings.TORRENT_TAG}" ) server.set_torrents_tag( ids=torrent_hash, tags=[settings.TORRENT_TAG] ) return ( downloader or self.get_default_config_name(), torrent_hash, torrent_layout, f"下载任务已存在", ) finally: torrents.clear() del torrents return None, None, None, f"添加种子任务失败:{content}" else: # 获取种子Hash torrent_hash = server.get_torrent_id_by_tag(tags=tag) if not torrent_hash: return ( None, None, None, f"下载任务添加成功,但获取rTorrent任务信息失败:{content}", ) else: if is_paused: # 种子文件 torrent_files = server.get_files(torrent_hash) if not torrent_files: return ( downloader or self.get_default_config_name(), torrent_hash, torrent_layout, "获取种子文件失败,下载任务可能在暂停状态", ) # 不需要的文件ID file_ids = [] # 需要的集清单 sucess_epidised = set() try: for torrent_file in torrent_files: file_id = torrent_file.get("id") file_name = torrent_file.get("name") meta_info = MetaInfo(file_name) if not meta_info.episode_list or not set( meta_info.episode_list ).issubset(episodes): file_ids.append(file_id) else: sucess_epidised.update(meta_info.episode_list) finally: torrent_files.clear() del torrent_files sucess_epidised = list(sucess_epidised) if sucess_epidised and file_ids: # 设置不需要的文件优先级为0(不下载) server.set_files( torrent_hash=torrent_hash, file_ids=file_ids, priority=0 ) # 开始任务 server.start_torrents(torrent_hash) return ( downloader or self.get_default_config_name(), torrent_hash, torrent_layout, f"添加下载成功,已选择集数:{sucess_epidised}", ) else: return ( downloader or self.get_default_config_name(), torrent_hash, torrent_layout, "添加下载成功", ) def list_torrents( self, status: TorrentStatus = None, hashs: Union[list, str] = None, downloader: Optional[str] = None, ) -> Optional[List[Union[TransferTorrent, DownloadingTorrent]]]: """ 获取下载器种子列表 :param status: 种子状态 :param hashs: 种子Hash :param downloader: 下载器 :return: 下载器中符合状态的种子列表 """ # 获取下载器 if downloader: server: Rtorrent = self.get_instance(downloader) if not server: return None servers = {downloader: server} else: servers: Dict[str, Rtorrent] = self.get_instances() ret_torrents = [] if hashs: # 按Hash获取 for name, server in servers.items(): torrents, _ = ( server.get_torrents(ids=hashs, tags=settings.TORRENT_TAG) or [] ) try: for torrent in torrents: content_path = torrent.get("content_path") if content_path: torrent_path = Path(content_path) else: torrent_path = Path(torrent.get("save_path")) / torrent.get( "name" ) ret_torrents.append( TransferTorrent( downloader=name, title=torrent.get("name"), path=torrent_path, hash=torrent.get("hash"), size=torrent.get("total_size"), tags=torrent.get("tags"), progress=torrent.get("progress", 0), state="paused" if torrent.get("state") == 0 else "downloading", ) ) finally: torrents.clear() del torrents elif status == TorrentStatus.TRANSFER: # 获取已完成且未整理的 for name, server in servers.items(): torrents = ( server.get_completed_torrents(tags=settings.TORRENT_TAG) or [] ) try: for torrent in torrents: tags = torrent.get("tags") or "" tag_list = [t.strip() for t in tags.split(",") if t.strip()] if "已整理" in tag_list: continue content_path = torrent.get("content_path") if content_path: torrent_path = Path(content_path) else: torrent_path = Path(torrent.get("save_path")) / torrent.get( "name" ) ret_torrents.append( TransferTorrent( downloader=name, title=torrent.get("name"), path=torrent_path, hash=torrent.get("hash"), tags=torrent.get("tags"), ) ) finally: torrents.clear() del torrents elif status == TorrentStatus.DOWNLOADING: # 获取正在下载的任务 for name, server in servers.items(): torrents = ( server.get_downloading_torrents(tags=settings.TORRENT_TAG) or [] ) try: for torrent in torrents: meta = MetaInfo(torrent.get("name")) dlspeed = torrent.get("dlspeed", 0) upspeed = torrent.get("upspeed", 0) total_size = torrent.get("total_size", 0) completed = torrent.get("completed", 0) ret_torrents.append( DownloadingTorrent( downloader=name, hash=torrent.get("hash"), title=torrent.get("name"), name=meta.name, year=meta.year, season_episode=meta.season_episode, progress=torrent.get("progress", 0), size=total_size, state="paused" if torrent.get("state") == 0 else "downloading", dlspeed=StringUtils.str_filesize(dlspeed), upspeed=StringUtils.str_filesize(upspeed), left_time=StringUtils.str_secends( (total_size - completed) / dlspeed ) if dlspeed > 0 else "", ) ) finally: torrents.clear() del torrents else: return None return ret_torrents # noqa def transfer_completed( self, hashs: Union[str, list], downloader: Optional[str] = None ) -> None: """ 转移完成后的处理 :param hashs: 种子Hash :param downloader: 下载器 """ server: Rtorrent = self.get_instance(downloader) if not server: return None # 获取原标签 org_tags = server.get_torrent_tags(ids=hashs) # 种子打上已整理标签 if org_tags: tags = org_tags + ["已整理"] else: tags = ["已整理"] # 直接设置完整标签(覆盖) server.set_torrents_tag(ids=hashs, tags=tags, overwrite=True) return None def remove_torrents( self, hashs: Union[str, list], delete_file: Optional[bool] = True, downloader: Optional[str] = None, ) -> Optional[bool]: """ 删除下载器种子 :param hashs: 种子Hash :param delete_file: 是否删除文件 :param downloader: 下载器 :return: bool """ server: Rtorrent = self.get_instance(downloader) if not server: return None return server.delete_torrents(delete_file=delete_file, ids=hashs) def start_torrents( self, hashs: Union[list, str], downloader: Optional[str] = None ) -> Optional[bool]: """ 开始下载 :param hashs: 种子Hash :param downloader: 下载器 :return: bool """ server: Rtorrent = self.get_instance(downloader) if not server: return None return server.start_torrents(ids=hashs) def stop_torrents( self, hashs: Union[list, str], downloader: Optional[str] = None ) -> Optional[bool]: """ 停止下载 :param hashs: 种子Hash :param downloader: 下载器 :return: bool """ server: Rtorrent = self.get_instance(downloader) if not server: return None return server.stop_torrents(ids=hashs) def torrent_files( self, tid: str, downloader: Optional[str] = None ) -> Optional[List[Dict]]: """ 获取种子文件列表 """ server: Rtorrent = self.get_instance(downloader) if not server: return None return server.get_files(tid=tid) def downloader_info( self, downloader: Optional[str] = None ) -> Optional[List[schemas.DownloaderInfo]]: """ 下载器信息 """ if downloader: server: Rtorrent = self.get_instance(downloader) if not server: return None servers = [server] else: servers = self.get_instances().values() ret_info = [] for server in servers: info = server.transfer_info() if not info: continue ret_info.append( schemas.DownloaderInfo( download_speed=info.get("dl_info_speed"), upload_speed=info.get("up_info_speed"), download_size=info.get("dl_info_data"), upload_size=info.get("up_info_data"), ) ) return ret_info ================================================ FILE: app/modules/rtorrent/rtorrent.py ================================================ import socket import traceback import xmlrpc.client from pathlib import Path from typing import Optional, Union, Tuple, List, Dict from urllib.parse import urlparse from app.log import logger class SCGITransport(xmlrpc.client.Transport): """ 通过SCGI协议与rTorrent通信的Transport """ def single_request(self, host, handler, request_body, verbose=False): # 建立socket连接 parsed = urlparse(f"scgi://{host}") sock = socket.create_connection( (parsed.hostname, parsed.port or 5000), timeout=60 ) try: # 构造SCGI请求头 headers = ( f"CONTENT_LENGTH\x00{len(request_body)}\x00" f"SCGI\x001\x00" f"REQUEST_METHOD\x00POST\x00" f"REQUEST_URI\x00/RPC2\x00" ) # netstring格式: "len:headers," netstring = f"{len(headers)}:{headers},".encode() # 发送请求 sock.sendall(netstring + request_body) # 读取响应 response = b"" while True: chunk = sock.recv(4096) if not chunk: break response += chunk finally: sock.close() # 跳过HTTP响应头 header_end = response.find(b"\r\n\r\n") if header_end != -1: response = response[header_end + 4 :] # 解析XML-RPC响应 return self.parse_response(self._build_response(response)) @staticmethod def _build_response(data: bytes): """ 构造类文件对象用于parse_response """ import io import http.client class _FakeSocket(io.BytesIO): def makefile(self, *args, **kwargs): return self raw = b"HTTP/1.0 200 OK\r\nContent-Type: text/xml\r\n\r\n" + data response = http.client.HTTPResponse(_FakeSocket(raw)) # noqa response.begin() return response class Rtorrent: """ rTorrent下载器 """ def __init__( self, host: Optional[str] = None, port: Optional[int] = None, username: Optional[str] = None, password: Optional[str] = None, **kwargs, ): self._proxy = None if host and port: self._host = f"{host}:{port}" elif host: self._host = host else: logger.error("rTorrent配置不完整!") return self._username = username self._password = password self._proxy = self.__login_rtorrent() def __login_rtorrent(self) -> Optional[xmlrpc.client.ServerProxy]: """ 连接rTorrent """ if not self._host: return None try: url = self._host if url.startswith("scgi://"): # SCGI直连模式 logger.info(f"正在通过SCGI连接 rTorrent:{url}") proxy = xmlrpc.client.ServerProxy(url, transport=SCGITransport()) else: # HTTP模式 (通过nginx/ruTorrent代理) if not url.startswith("http"): url = f"http://{url}" # 注入认证信息到URL if self._username and self._password: parsed = urlparse(url) url = f"{parsed.scheme}://{self._username}:{self._password}@{parsed.hostname}" if parsed.port: url += f":{parsed.port}" url += parsed.path or "/RPC2" logger.info( f"正在通过HTTP连接 rTorrent:{url.split('@')[-1] if '@' in url else url}" ) proxy = xmlrpc.client.ServerProxy(url) # 测试连接 proxy.system.client_version() return proxy except Exception as err: stack_trace = "".join( traceback.format_exception(None, err, err.__traceback__) )[:2000] logger.error(f"rTorrent 连接出错:{str(err)}\n{stack_trace}") return None def is_inactive(self) -> bool: """ 判断是否需要重连 """ if not self._host: return False return True if not self._proxy else False def reconnect(self): """ 重连 """ self._proxy = self.__login_rtorrent() def get_torrents( self, ids: Optional[Union[str, list]] = None, status: Optional[str] = None, tags: Optional[Union[str, list]] = None, ) -> Tuple[List[Dict], bool]: """ 获取种子列表 :return: 种子列表, 是否发生异常 """ if not self._proxy: return [], True try: # 使用d.multicall2获取种子列表 fields = [ "d.hash=", "d.name=", "d.size_bytes=", "d.completed_bytes=", "d.down.rate=", "d.up.rate=", "d.state=", "d.complete=", "d.directory=", "d.custom1=", "d.is_active=", "d.is_open=", "d.ratio=", "d.base_path=", ] # 获取所有种子 results = self._proxy.d.multicall2("", "main", *fields) torrents = [] for r in results: torrent = { "hash": r[0], "name": r[1], "total_size": r[2], "completed": r[3], "dlspeed": r[4], "upspeed": r[5], "state": r[6], # 0=stopped, 1=started "complete": r[7], # 0=incomplete, 1=complete "save_path": r[8], "tags": r[9], # d.custom1 用于标签 "is_active": r[10], "is_open": r[11], "ratio": int(r[12]) / 1000.0 if r[12] else 0, "content_path": r[13], # base_path 即完整内容路径 } # 计算进度 if torrent["total_size"] > 0: torrent["progress"] = ( torrent["completed"] / torrent["total_size"] * 100 ) else: torrent["progress"] = 0 # ID过滤 if ids: if isinstance(ids, str): ids_list = [ids.upper()] else: ids_list = [i.upper() for i in ids] if torrent["hash"].upper() not in ids_list: continue # 标签过滤 if tags: torrent_tags = [ t.strip() for t in torrent["tags"].split(",") if t.strip() ] if isinstance(tags, str): tags_list = [t.strip() for t in tags.split(",")] else: tags_list = tags if not set(tags_list).issubset(set(torrent_tags)): continue torrents.append(torrent) return torrents, False except Exception as err: logger.error(f"获取种子列表出错:{str(err)}") return [], True def get_completed_torrents( self, ids: Union[str, list] = None, tags: Union[str, list] = None ) -> Optional[List[Dict]]: """ 获取已完成的种子 """ if not self._proxy: return None torrents, error = self.get_torrents(ids=ids, tags=tags) if error: return None return [t for t in torrents if t.get("complete") == 1] def get_downloading_torrents( self, ids: Union[str, list] = None, tags: Union[str, list] = None ) -> Optional[List[Dict]]: """ 获取正在下载的种子 """ if not self._proxy: return None torrents, error = self.get_torrents(ids=ids, tags=tags) if error: return None return [t for t in torrents if t.get("complete") == 0] def add_torrent( self, content: Union[str, bytes], is_paused: Optional[bool] = False, download_dir: Optional[str] = None, tags: Optional[List[str]] = None, cookie: Optional[str] = None, **kwargs, ) -> bool: """ 添加种子 :param content: 种子内容(bytes)或磁力链接/URL(str) :param is_paused: 添加后暂停 :param download_dir: 下载路径 :param tags: 标签列表 :param cookie: Cookie :return: bool """ if not self._proxy or not content: return False try: # 构造命令参数 commands = [] if download_dir: commands.append(f'd.directory.set="{download_dir}"') if tags: tag_str = ",".join(tags) commands.append(f'd.custom1.set="{tag_str}"') if isinstance(content, bytes): # 检查是否为磁力链接(bytes形式) if content.startswith(b"magnet:"): content = content.decode("utf-8", errors="ignore") else: # 种子文件内容,使用load.raw raw = xmlrpc.client.Binary(content) if is_paused: self._proxy.load.raw("", raw, *commands) else: self._proxy.load.raw_start("", raw, *commands) return True # URL或磁力链接 if is_paused: self._proxy.load.normal("", content, *commands) else: self._proxy.load.start("", content, *commands) return True except Exception as err: logger.error(f"添加种子出错:{str(err)}") return False def start_torrents(self, ids: Union[str, list]) -> bool: """ 启动种子 """ if not self._proxy: return False try: if isinstance(ids, str): ids = [ids] for tid in ids: self._proxy.d.start(tid) return True except Exception as err: logger.error(f"启动种子出错:{str(err)}") return False def stop_torrents(self, ids: Union[str, list]) -> bool: """ 停止种子 """ if not self._proxy: return False try: if isinstance(ids, str): ids = [ids] for tid in ids: self._proxy.d.stop(tid) return True except Exception as err: logger.error(f"停止种子出错:{str(err)}") return False def delete_torrents(self, delete_file: bool, ids: Union[str, list]) -> bool: """ 删除种子 """ if not self._proxy: return False if not ids: return False try: if isinstance(ids, str): ids = [ids] for tid in ids: if delete_file: # 先获取base_path用于删除文件 try: base_path = self._proxy.d.base_path(tid) self._proxy.d.erase(tid) if base_path: import shutil path = Path(base_path) if path.is_dir(): shutil.rmtree(str(path), ignore_errors=True) elif path.is_file(): path.unlink(missing_ok=True) except Exception as e: logger.warning(f"删除种子文件出错:{str(e)}") self._proxy.d.erase(tid) else: self._proxy.d.erase(tid) return True except Exception as err: logger.error(f"删除种子出错:{str(err)}") return False def get_files(self, tid: str) -> Optional[List[Dict]]: """ 获取种子文件列表 """ if not self._proxy: return None if not tid: return None try: files = self._proxy.f.multicall( tid, "", "f.path=", "f.size_bytes=", "f.priority=", "f.completed_chunks=", "f.size_chunks=", ) result = [] for idx, f in enumerate(files): result.append( { "id": idx, "name": f[0], "size": f[1], "priority": f[2], "progress": int(f[3]) / int(f[4]) * 100 if int(f[4]) > 0 else 0, } ) return result except Exception as err: logger.error(f"获取种子文件列表出错:{str(err)}") return None def set_files( self, torrent_hash: str = None, file_ids: list = None, priority: int = 0 ) -> bool: """ 设置下载文件的优先级,priority为0为不下载,priority为1为普通 """ if not self._proxy: return False if not torrent_hash or not file_ids: return False try: for file_id in file_ids: self._proxy.f.priority.set(f"{torrent_hash}:f{file_id}", priority) # 更新种子优先级 self._proxy.d.update_priorities(torrent_hash) return True except Exception as err: logger.error(f"设置种子文件状态出错:{str(err)}") return False def set_torrents_tag( self, ids: Union[str, list], tags: List[str], overwrite: bool = False ) -> bool: """ 设置种子标签(使用d.custom1) :param ids: 种子Hash :param tags: 标签列表 :param overwrite: 是否覆盖现有标签,默认为合并 """ if not self._proxy: return False if not ids: return False try: if isinstance(ids, str): ids = [ids] for tid in ids: if overwrite: # 直接覆盖标签 self._proxy.d.custom1.set(tid, ",".join(tags)) else: # 获取现有标签 existing = self._proxy.d.custom1(tid) existing_tags = ( [t.strip() for t in existing.split(",") if t.strip()] if existing else [] ) # 合并标签 merged = list(set(existing_tags + tags)) self._proxy.d.custom1.set(tid, ",".join(merged)) return True except Exception as err: logger.error(f"设置种子Tag出错:{str(err)}") return False def remove_torrents_tag(self, ids: Union[str, list], tag: Union[str, list]) -> bool: """ 移除种子标签 """ if not self._proxy: return False if not ids: return False try: if isinstance(ids, str): ids = [ids] if isinstance(tag, str): tag = [tag] for tid in ids: existing = self._proxy.d.custom1(tid) existing_tags = ( [t.strip() for t in existing.split(",") if t.strip()] if existing else [] ) new_tags = [t for t in existing_tags if t not in tag] self._proxy.d.custom1.set(tid, ",".join(new_tags)) return True except Exception as err: logger.error(f"移除种子Tag出错:{str(err)}") return False def get_torrent_tags(self, ids: str) -> List[str]: """ 获取种子标签 """ if not self._proxy: return [] try: existing = self._proxy.d.custom1(ids) return ( [t.strip() for t in existing.split(",") if t.strip()] if existing else [] ) except Exception as err: logger.error(f"获取种子标签出错:{str(err)}") return [] def get_torrent_id_by_tag( self, tags: Union[str, list], status: Optional[str] = None ) -> Optional[str]: """ 通过标签多次尝试获取刚添加的种子ID,并移除标签 """ import time if isinstance(tags, str): tags = [tags] torrent_id = None for i in range(1, 10): time.sleep(3) torrents, error = self.get_torrents(tags=tags) if not error and torrents: torrent_id = torrents[0].get("hash") # 移除查找标签 for tag in tags: self.remove_torrents_tag(ids=torrent_id, tag=[tag]) break return torrent_id def transfer_info(self) -> Optional[Dict]: """ 获取传输信息 """ if not self._proxy: return None try: return { "dl_info_speed": self._proxy.throttle.global_down.rate(), "up_info_speed": self._proxy.throttle.global_up.rate(), "dl_info_data": self._proxy.throttle.global_down.total(), "up_info_data": self._proxy.throttle.global_up.total(), } except Exception as err: logger.error(f"获取传输信息出错:{str(err)}") return None ================================================ FILE: app/modules/slack/__init__.py ================================================ import json import re from typing import Optional, Union, List, Tuple, Any from app.core.context import MediaInfo, Context from app.log import logger from app.modules import _ModuleBase, _MessageBase from app.modules.slack.slack import Slack from app.schemas import MessageChannel, CommingMessage, Notification from app.schemas.types import ModuleType class SlackModule(_ModuleBase, _MessageBase[Slack]): def init_module(self) -> None: """ 初始化模块 """ super().init_service(service_name=Slack.__name__.lower(), service_type=Slack) self._channel = MessageChannel.Slack @staticmethod def get_name() -> str: return "Slack" @staticmethod def get_type() -> ModuleType: """ 获取模块类型 """ return ModuleType.Notification @staticmethod def get_subtype() -> MessageChannel: """ 获取模块子类型 """ return MessageChannel.Slack @staticmethod def get_priority() -> int: """ 获取模块优先级,数字越小优先级越高,只有同一接口下优先级才生效 """ return 3 def stop(self): """ 停止模块 """ for client in self.get_instances().values(): client.stop() def test(self) -> Optional[Tuple[bool, str]]: """ 测试模块连接性 """ if not self.get_instances(): return None for name, client in self.get_instances().items(): state = client.get_state() if not state: return False, f"Slack {name} 未就绪" return True, "" def init_setting(self) -> Tuple[str, Union[str, bool]]: pass def message_parser(self, source: str, body: Any, form: Any, args: Any) -> Optional[CommingMessage]: """ 解析消息内容,返回字典,注意以下约定值: userid: 用户ID username: 用户名 text: 内容 :param source: 消息来源 :param body: 请求体 :param form: 表单 :param args: 参数 :return: 渠道、消息体 """ """ # 消息 { 'client_msg_id': '', 'type': 'message', 'text': 'hello', 'user': '', 'ts': '1670143568.444289', 'blocks': [{ 'type': 'rich_text', 'block_id': 'i2j+', 'elements': [{ 'type': 'rich_text_section', 'elements': [{ 'type': 'text', 'text': 'hello' }] }] }], 'team': '', 'client': '', 'event_ts': '1670143568.444289', 'channel_type': 'im' } # 命令 { "token": "", "team_id": "", "team_domain": "", "channel_id": "", "channel_name": "directmessage", "user_id": "", "user_name": "", "command": "/subscribes", "text": "", "api_app_id": "", "is_enterprise_install": "false", "response_url": "", "trigger_id": "" } # 快捷方式 { "type": "shortcut", "token": "XXXXXXXXXXXXX", "action_ts": "1581106241.371594", "team": { "id": "TXXXXXXXX", "domain": "shortcuts-test" }, "user": { "id": "UXXXXXXXXX", "username": "aman", "team_id": "TXXXXXXXX" }, "callback_id": "shortcut_create_task", "trigger_id": "944799105734.773906753841.38b5894552bdd4a780554ee59d1f3638" } # 按钮点击 { "type": "block_actions", "team": { "id": "T9TK3CUKW", "domain": "example" }, "user": { "id": "UA8RXUSPL", "username": "jtorrance", "team_id": "T9TK3CUKW" }, "api_app_id": "AABA1ABCD", "token": "9s8d9as89d8as9d8as989", "container": { "type": "message_attachment", "message_ts": "1548261231.000200", "attachment_id": 1, "channel_id": "CBR2V3XEX", "is_ephemeral": false, "is_app_unfurl": false }, "trigger_id": "12321423423.333649436676.d8c1bb837935619ccad0f624c448ffb3", "client": { "id": "CBR2V3XEX", "name": "review-updates" }, "message": { "bot_id": "BAH5CA16Z", "type": "message", "text": "This content can't be displayed.", "user": "UAJ2RU415", "ts": "1548261231.000200", ... }, "response_url": "https://hooks.slack.com/actions/AABA1ABCD/1232321423432/D09sSasdasdAS9091209", "actions": [ { "action_id": "WaXA", "block_id": "=qXel", "text": { "type": "plain_text", "text": "View", "emoji": true }, "value": "click_me_123", "type": "button", "action_ts": "1548426417.840180" } ] } """ # 获取服务配置 client_config = self.get_config(source) if not client_config: return None try: msg_json: dict = json.loads(body) except Exception as err: logger.debug(f"解析Slack消息失败:{str(err)}") return None if msg_json: if msg_json.get("type") == "message": userid = msg_json.get("user") text = msg_json.get("text") username = msg_json.get("user") elif msg_json.get("type") == "block_actions": userid = msg_json.get("user", {}).get("id") callback_data = msg_json.get("actions")[0].get("value") # 使用CALLBACK前缀标识按钮回调 text = f"CALLBACK:{callback_data}" username = msg_json.get("user", {}).get("name") # 获取原消息信息用于编辑 message_info = msg_json.get("message", {}) # Slack消息的时间戳作为消息ID message_ts = message_info.get("ts") channel_id = msg_json.get("channel", {}).get("id") or msg_json.get("container", {}).get("channel_id") logger.info(f"收到来自 {client_config.name} 的Slack按钮回调:" f"userid={userid}, username={username}, callback_data={callback_data}") # 创建包含回调信息的CommingMessage return CommingMessage( channel=MessageChannel.Slack, source=client_config.name, userid=userid, username=username, text=text, is_callback=True, callback_data=callback_data, message_id=message_ts, chat_id=channel_id ) elif msg_json.get("type") == "event_callback": userid = msg_json.get('event', {}).get('user') text = re.sub(r"<@[0-9A-Z]+>", "", msg_json.get("event", {}).get("text"), flags=re.IGNORECASE).strip() username = "" elif msg_json.get("type") == "shortcut": userid = msg_json.get("user", {}).get("id") text = msg_json.get("callback_id") username = msg_json.get("user", {}).get("username") elif msg_json.get("command"): userid = msg_json.get("user_id") text = msg_json.get("command") username = msg_json.get("user_name") else: return None logger.info(f"收到来自 {client_config.name} 的Slack消息:userid={userid}, username={username}, text={text}") return CommingMessage(channel=MessageChannel.Slack, source=client_config.name, userid=userid, username=username, text=text) return None def post_message(self, message: Notification, **kwargs) -> None: """ 发送消息 :param message: 消息 :return: 成功或失败 """ for conf in self.get_configs().values(): if not self.check_message(message, conf.name): continue targets = message.targets userid = message.userid if not userid and targets is not None: userid = targets.get('slack_userid') if not userid: logger.warn(f"用户没有指定 Slack用户ID,消息无法发送") return client: Slack = self.get_instance(conf.name) if client: client.send_msg(title=message.title, text=message.text, image=message.image, userid=userid, link=message.link, buttons=message.buttons, original_message_id=message.original_message_id, original_chat_id=message.original_chat_id) def post_medias_message(self, message: Notification, medias: List[MediaInfo]) -> None: """ 发送媒体信息选择列表 :param message: 消息体 :param medias: 媒体信息 :return: 成功或失败 """ for conf in self.get_configs().values(): if not self.check_message(message, conf.name): continue client: Slack = self.get_instance(conf.name) if client: client.send_medias_msg(title=message.title, medias=medias, userid=message.userid, buttons=message.buttons, original_message_id=message.original_message_id, original_chat_id=message.original_chat_id) def post_torrents_message(self, message: Notification, torrents: List[Context]) -> None: """ 发送种子信息选择列表 :param message: 消息体 :param torrents: 种子信息 :return: 成功或失败 """ for conf in self.get_configs().values(): if not self.check_message(message, conf.name): continue client: Slack = self.get_instance(conf.name) if client: client.send_torrents_msg(title=message.title, torrents=torrents, userid=message.userid, buttons=message.buttons, original_message_id=message.original_message_id, original_chat_id=message.original_chat_id) def delete_message(self, channel: MessageChannel, source: str, message_id: str, chat_id: Optional[str] = None) -> bool: """ 删除消息 :param channel: 消息渠道 :param source: 指定的消息源 :param message_id: 消息ID(Slack中为时间戳) :param chat_id: 聊天ID(频道ID) :return: 删除是否成功 """ success = False for conf in self.get_configs().values(): if channel != self._channel: break if source != conf.name: continue client: Slack = self.get_instance(conf.name) if client: result = client.delete_msg(message_id=message_id, chat_id=chat_id) if result: success = True return success ================================================ FILE: app/modules/slack/slack.py ================================================ import re from threading import Lock from typing import List, Optional from urllib.parse import quote import requests from slack_bolt import App from slack_bolt.adapter.socket_mode import SocketModeHandler from slack_sdk import WebClient from app.core.config import settings from app.core.context import MediaInfo, Context from app.core.metainfo import MetaInfo from app.log import logger from app.utils.string import StringUtils lock = Lock() class Slack: _client: WebClient = None _service: SocketModeHandler = None _ds_url = f"http://127.0.0.1:{settings.PORT}/api/v1/message?token={settings.API_TOKEN}" _channel = "" def __init__(self, SLACK_OAUTH_TOKEN: Optional[str] = None, SLACK_APP_TOKEN: Optional[str] = None, SLACK_CHANNEL: Optional[str] = None, **kwargs): if not SLACK_OAUTH_TOKEN or not SLACK_APP_TOKEN: logger.error("Slack 配置不完整!") return try: slack_app = App(token=SLACK_OAUTH_TOKEN, ssl_check_enabled=False, url_verification_enabled=False) except Exception as err: logger.error(f"Slack初始化失败: {str(err)}") return self._client = slack_app.client self._channel = SLACK_CHANNEL # 标记消息来源 if kwargs.get("name"): # URL encode the source name to handle special characters encoded_name = quote(kwargs.get('name'), safe='') self._ds_url = f"{self._ds_url}&source={encoded_name}" # 注册消息响应 @slack_app.event("message") def slack_message(message): with requests.post(self._ds_url, json=message, timeout=10) as local_res: logger.debug("message: %s processed, response is: %s" % (message, local_res.text)) @slack_app.action(re.compile(r"actionId-.*")) def slack_action(ack, body): ack() with requests.post(self._ds_url, json=body, timeout=60) as local_res: logger.debug("message: %s processed, response is: %s" % (body, local_res.text)) @slack_app.event("app_mention") def slack_mention(say, body): say(f"收到,请稍等... <@{body.get('event', {}).get('user')}>") with requests.post(self._ds_url, json=body, timeout=10) as local_res: logger.debug("message: %s processed, response is: %s" % (body, local_res.text)) @slack_app.shortcut(re.compile(r"/*")) def slack_shortcut(ack, body): ack() with requests.post(self._ds_url, json=body, timeout=10) as local_res: logger.debug("message: %s processed, response is: %s" % (body, local_res.text)) @slack_app.command(re.compile(r"/*")) def slack_command(ack, body): ack() with requests.post(self._ds_url, json=body, timeout=10) as local_res: logger.debug("message: %s processed, response is: %s" % (body, local_res.text)) # 启动服务 try: self._service = SocketModeHandler( slack_app, SLACK_APP_TOKEN ) self._service.connect() logger.info("Slack消息接收服务启动") except Exception as err: logger.error("Slack消息接收服务启动失败: %s" % str(err)) def stop(self): if self._service: try: self._service.close() logger.info("Slack消息接收服务已停止") except Exception as err: logger.error("Slack消息接收服务停止失败: %s" % str(err)) def get_state(self) -> bool: """ 获取状态 """ return True if self._client else False def send_msg(self, title: str, text: Optional[str] = None, image: Optional[str] = None, link: Optional[str] = None, userid: Optional[str] = None, buttons: Optional[List[List[dict]]] = None, original_message_id: Optional[str] = None, original_chat_id: Optional[str] = None): """ 发送Slack消息 :param title: 消息标题 :param text: 消息内容 :param image: 消息图片地址 :param link: 点击消息转转的URL :param userid: 用户ID,如有则只发消息给该用户 :param buttons: 消息按钮列表,格式为 [[{"text": "按钮文本", "callback_data": "回调数据", "url": "链接"}]] :param original_message_id: 原消息的时间戳,如果提供则编辑原消息 :param original_chat_id: 原消息的频道ID,编辑消息时需要 """ if not self._client: return False, "消息客户端未就绪" if not title and not text: return False, "标题和内容不能同时为空" try: if userid: channel = userid else: # 消息广播 channel = self.__find_public_channel() # 消息文本 message_text = "" # 结构体 blocks = [] if not image: message_text = f"{title}\n{text or ''}" else: # 消息图片 if image: # 拼装消息内容 blocks.append({"type": "section", "text": { "type": "mrkdwn", "text": f"*{title}*\n{text or ''}" }, 'accessory': { "type": "image", "image_url": f"{image}", "alt_text": f"{title}" }}) # 自定义按钮 if buttons: for button_row in buttons: elements = [] for button in button_row: if "url" in button: # URL按钮 elements.append({ "type": "button", "text": { "type": "plain_text", "text": button["text"], "emoji": True }, "url": button["url"], "action_id": f"actionId-url-{button.get('text', 'url')}-{len(elements)}" }) else: # 回调按钮 elements.append({ "type": "button", "text": { "type": "plain_text", "text": button["text"], "emoji": True }, "value": button["callback_data"], "action_id": f"actionId-{button['callback_data']}" }) if elements: blocks.append({ "type": "actions", "elements": elements }) elif link: # 默认链接按钮 blocks.append({ "type": "actions", "elements": [ { "type": "button", "text": { "type": "plain_text", "text": "查看详情", "emoji": True }, "value": "click_me_url", "url": f"{link}", "action_id": "actionId-url" } ] }) # 判断是编辑消息还是发送新消息 if original_message_id and original_chat_id: # 编辑消息 result = self._client.chat_update( channel=original_chat_id, ts=original_message_id, text=message_text[:1000], blocks=blocks or [] ) else: # 发送新消息 result = self._client.chat_postMessage( channel=channel, text=message_text[:1000], blocks=blocks, mrkdwn=True ) return True, result except Exception as msg_e: logger.error(f"Slack消息发送失败: {msg_e}") return False, str(msg_e) def send_medias_msg(self, medias: List[MediaInfo], userid: Optional[str] = None, title: Optional[str] = None, buttons: Optional[List[List[dict]]] = None, original_message_id: Optional[str] = None, original_chat_id: Optional[str] = None) -> Optional[bool]: """ 发送媒体列表消息 :param medias: 媒体信息列表 :param userid: 用户ID,如有则只发消息给该用户 :param title: 消息标题 :param buttons: 按钮列表,格式:[[{"text": "按钮文本", "callback_data": "回调数据"}]] :param original_message_id: 原消息的时间戳,如果提供则编辑原消息 :param original_chat_id: 原消息的频道ID,编辑消息时需要 """ if not self._client: return False if not medias: return False try: if userid: channel = userid else: # 消息广播 channel = self.__find_public_channel() # 消息主体 title_section = { "type": "section", "text": { "type": "mrkdwn", "text": f"*{title}*" } } blocks = [title_section] # 列表 if medias: blocks.append({ "type": "divider" }) index = 1 # 如果有自定义按钮,先添加所有媒体项,然后添加统一的按钮 if buttons: # 添加媒体列表(不带单独的选择按钮) for media in medias: if media.get_poster_image(): if media.vote_star: text = f"{index}. *<{media.detail_link}|{media.title_year}>*" \ f"\n类型:{media.type.value}" \ f"\n{media.vote_star}" \ f"\n{media.get_overview_string(50)}" else: text = f"{index}. *<{media.detail_link}|{media.title_year}>*" \ f"\n类型:{media.type.value}" \ f"\n{media.get_overview_string(50)}" blocks.append( { "type": "section", "text": { "type": "mrkdwn", "text": text }, "accessory": { "type": "image", "image_url": f"{media.get_poster_image()}", "alt_text": f"{media.title_year}" } } ) index += 1 # 添加统一的自定义按钮(在所有媒体项之后) for button_row in buttons: elements = [] for button in button_row: if "url" in button: elements.append({ "type": "button", "text": { "type": "plain_text", "text": button["text"], "emoji": True }, "url": button["url"], "action_id": f"actionId-url-{button.get('text', 'url')}-{len(elements)}" }) else: elements.append({ "type": "button", "text": { "type": "plain_text", "text": button["text"], "emoji": True }, "value": button["callback_data"], "action_id": f"actionId-{button['callback_data']}" }) if elements: blocks.append({ "type": "actions", "elements": elements }) else: # 使用默认的每个媒体项单独按钮 for media in medias: if media.get_poster_image(): if media.vote_star: text = f"{index}. *<{media.detail_link}|{media.title_year}>*" \ f"\n类型:{media.type.value}" \ f"\n{media.vote_star}" \ f"\n{media.get_overview_string(50)}" else: text = f"{index}. *<{media.detail_link}|{media.title_year}>*" \ f"\n类型:{media.type.value}" \ f"\n{media.get_overview_string(50)}" blocks.append( { "type": "section", "text": { "type": "mrkdwn", "text": text }, "accessory": { "type": "image", "image_url": f"{media.get_poster_image()}", "alt_text": f"{media.title_year}" } } ) # 使用默认选择按钮 blocks.append( { "type": "actions", "elements": [ { "type": "button", "text": { "type": "plain_text", "text": "选择", "emoji": True }, "value": f"{index}", "action_id": f"actionId-{index}" } ] } ) index += 1 # 判断是编辑消息还是发送新消息 if original_message_id and original_chat_id: # 编辑消息 result = self._client.chat_update( channel=original_chat_id, ts=original_message_id, text=title, blocks=blocks or [] ) else: # 发送新消息 result = self._client.chat_postMessage( channel=channel, text=title, blocks=blocks ) return True if result else False except Exception as msg_e: logger.error(f"Slack消息发送失败: {msg_e}") return False def send_torrents_msg(self, torrents: List[Context], userid: Optional[str] = None, title: Optional[str] = None, buttons: Optional[List[List[dict]]] = None, original_message_id: Optional[str] = None, original_chat_id: Optional[str] = None) -> Optional[bool]: """ 发送种子列表消息 :param torrents: 种子信息列表 :param userid: 用户ID,如有则只发消息给该用户 :param title: 消息标题 :param buttons: 按钮列表,格式:[[{"text": "按钮文本", "callback_data": "回调数据"}]] :param original_message_id: 原消息的时间戳,如果提供则编辑原消息 :param original_chat_id: 原消息的频道ID,编辑消息时需要 """ if not self._client: return None try: if userid: channel = userid else: # 消息广播 channel = self.__find_public_channel() # 消息主体 title_section = { "type": "section", "text": { "type": "mrkdwn", "text": f"*{title}*" } } blocks = [title_section, { "type": "divider" }] # 列表 index = 1 # 如果有自定义按钮,先添加种子列表,然后添加统一的按钮 if buttons: # 添加种子列表(不带单独的选择按钮) for context in torrents: torrent = context.torrent_info site_name = torrent.site_name meta = MetaInfo(torrent.title, torrent.description) link = torrent.page_url title_text = f"{meta.season_episode} " \ f"{meta.resource_term} " \ f"{meta.video_term} " \ f"{meta.release_group}" title_text = re.sub(r"\s+", " ", title_text).strip() free = torrent.volume_factor seeder = f"{torrent.seeders}↑" description = torrent.description text = f"{index}. 【{site_name}】<{link}|{title_text}> " \ f"{StringUtils.str_filesize(torrent.size)} {free} {seeder}\n" \ f"{description}" blocks.append( { "type": "section", "text": { "type": "mrkdwn", "text": text } } ) index += 1 # 添加统一的自定义按钮 for button_row in buttons: elements = [] for button in button_row: if "url" in button: elements.append({ "type": "button", "text": { "type": "plain_text", "text": button["text"], "emoji": True }, "url": button["url"], "action_id": f"actionId-url-{button.get('text', 'url')}-{len(elements)}" }) else: elements.append({ "type": "button", "text": { "type": "plain_text", "text": button["text"], "emoji": True }, "value": button["callback_data"], "action_id": f"actionId-{button['callback_data']}" }) if elements: blocks.append({ "type": "actions", "elements": elements }) else: # 使用默认的每个种子单独按钮 for context in torrents: torrent = context.torrent_info site_name = torrent.site_name meta = MetaInfo(torrent.title, torrent.description) link = torrent.page_url title_text = f"{meta.season_episode} " \ f"{meta.resource_term} " \ f"{meta.video_term} " \ f"{meta.release_group}" title_text = re.sub(r"\s+", " ", title_text).strip() free = torrent.volume_factor seeder = f"{torrent.seeders}↑" description = torrent.description text = f"{index}. 【{site_name}】<{link}|{title_text}> " \ f"{StringUtils.str_filesize(torrent.size)} {free} {seeder}\n" \ f"{description}" blocks.append( { "type": "section", "text": { "type": "mrkdwn", "text": text } } ) blocks.append( { "type": "actions", "elements": [ { "type": "button", "text": { "type": "plain_text", "text": "选择", "emoji": True }, "value": f"{index}", "action_id": f"actionId-{index}" } ] } ) index += 1 # 判断是编辑消息还是发送新消息 if original_message_id and original_chat_id: # 编辑消息 result = self._client.chat_update( channel=original_chat_id, ts=original_message_id, text=title, blocks=blocks or [] ) else: # 发送新消息 result = self._client.chat_postMessage( channel=channel, text=title, blocks=blocks ) return True if result else False except Exception as msg_e: logger.error(f"Slack消息发送失败: {msg_e}") return False def delete_msg(self, message_id: str, chat_id: Optional[str] = None) -> Optional[bool]: """ 删除Slack消息 :param message_id: 消息时间戳(Slack消息ID) :param chat_id: 频道ID :return: 删除是否成功 """ if not self._client: return None try: # 确定要删除消息的频道ID if chat_id: target_channel = chat_id else: target_channel = self.__find_public_channel() if not target_channel: logger.error("无法确定要删除消息的Slack频道") return False # 删除消息 result = self._client.chat_delete( channel=target_channel, ts=message_id ) if result.get("ok"): logger.info(f"成功删除Slack消息: channel={target_channel}, ts={message_id}") return True else: logger.error(f"删除Slack消息失败: {result.get('error', 'unknown error')}") return False except Exception as e: logger.error(f"删除Slack消息异常: {str(e)}") return False def __find_public_channel(self): """ 查找公共频道 """ if not self._client: return "" conversation_id = "" try: for result in self._client.conversations_list(types="public_channel,private_channel"): if conversation_id: break for channel in result["channels"]: if channel.get("name") == (self._channel or "全体"): conversation_id = channel.get("id") break except Exception as e: logger.error(f"查找Slack公共频道失败: {str(e)}") return conversation_id ================================================ FILE: app/modules/subtitle/__init__.py ================================================ import shutil import time from pathlib import Path from typing import Tuple, Union from lxml import etree from app.chain.storage import StorageChain from app.core.config import settings from app.core.context import Context from app.db.site_oper import SiteOper from app.helper.sites import SitesHelper # noqa from app.helper.torrent import TorrentHelper from app.log import logger from app.modules import _ModuleBase from app.modules.indexer.spider.mtorrent import MTorrentSpider from app.schemas import TorrentInfo from app.schemas.file import FileURI from app.schemas.types import ModuleType, OtherModulesType from app.utils.http import RequestUtils from app.utils.string import StringUtils from app.utils.system import SystemUtils class SubtitleModule(_ModuleBase): """ 字幕下载模块 """ # 站点详情页字幕下载链接识别XPATH _SITE_SUBTITLE_XPATH = [ '//td[@class="rowhead"][text()="字幕"]/following-sibling::td//a[not(@class)]/@href', '//td[@class="rowhead"][text()="字幕"]/following-sibling::td//a/@href', '//div[contains(@class, "font-bold")][text()="字幕"]/following-sibling::div[1]//a[not(@class)]/@href', # 憨憨 ] def init_module(self) -> None: pass @staticmethod def get_name() -> str: return "站点字幕" @staticmethod def get_type() -> ModuleType: """ 获取模块类型 """ return ModuleType.Other @staticmethod def get_subtype() -> OtherModulesType: """ 获取模块子类型 """ return OtherModulesType.Subtitle @staticmethod def get_priority() -> int: """ 获取模块优先级,数字越小优先级越高,只有同一接口下优先级才生效 """ return 0 def init_setting(self) -> Tuple[str, Union[str, bool]]: pass def stop(self) -> None: pass def test(self): pass def _get_subtitle_links(self, torrent: TorrentInfo): """ 获取字幕链接 """ # API请求方式的站点需要特殊处理 if torrent.site is not None: site = SiteOper().get(torrent.site) if indexer := SitesHelper().get_indexer(site.domain): if indexer.get("parser") == "mTorrent": return MTorrentSpider(indexer).get_subtitle_links( torrent.page_url ) # TODO 其它采用API访问的站点 # 普通站点通过解析网站代码的方式获取 request = RequestUtils( cookies=torrent.site_cookie, ua=torrent.site_ua, proxies=settings.PROXY if torrent.site_proxy else None, ) res = request.get_res(torrent.page_url) if res and res.status_code == 200: if not res.text: logger.warn(f"读取页面代码失败:{torrent.page_url}") return [] html = etree.HTML(res.text) try: sublink_list = [] for xpath in self._SITE_SUBTITLE_XPATH: sublinks = html.xpath(xpath) if sublinks: for sublink in sublinks: if not sublink: continue if not sublink.startswith("http"): base_url = StringUtils.get_base_url(torrent.page_url) if sublink.startswith("/"): sublink = "%s%s" % (base_url, sublink) else: sublink = "%s/%s" % (base_url, sublink) sublink_list.append(sublink) # 已成功获取了链接,后续xpath可以忽略 break return sublink_list finally: if html is not None: del html elif res is not None: logger.warn(f"连接 {torrent.page_url} 失败,状态码:{res.status_code}") else: logger.warn(f"无法打开链接:{torrent.page_url}") return None def download_added(self, context: Context, download_dir: Path, torrent_content: Union[str, bytes] = None): """ 添加下载任务成功后,从站点下载字幕,保存到下载目录 :param context: 上下文,包括识别信息、媒体信息、种子信息 :param download_dir: 下载目录 :param torrent_content: 种子内容,如果是种子文件,则为文件内容,否则为种子字符串 :return: None,该方法可被多个模块同时处理 """ if not settings.DOWNLOAD_SUBTITLE: return # 没有种子文件不处理 if not torrent_content: return # 没有详情页不处理 torrent = context.torrent_info if not torrent.page_url: return # 字幕下载目录 logger.info("开始从站点下载字幕:%s" % torrent.page_url) # 获取种子信息 folder_name, _ = TorrentHelper().get_fileinfo_from_torrent_content(torrent_content) # 文件保存目录,如果是单文件种子,则folder_name是空,此时文件保存目录就是下载目录 storageChain = StorageChain() # 等待目录存在 working_dir_item = None # split download_dir into storage and path fileURI = FileURI.from_uri(download_dir.as_posix()) storage = fileURI.storage download_dir = Path(fileURI.path) for _ in range(30): found = storageChain.get_file_item(storage, download_dir / folder_name) if found: working_dir_item = found break time.sleep(1) # 目录仍然不存在,且有文件夹名,则创建目录 if not working_dir_item and folder_name: parent_dir_item = storageChain.get_file_item(storage, download_dir) if parent_dir_item: working_dir_item = storageChain.create_folder( parent_dir_item, folder_name ) else: logger.error(f"下载根目录不存在,无法创建字幕文件夹:{download_dir}") return if not working_dir_item: logger.error(f"下载目录不存在,无法保存字幕:{download_dir / folder_name}") return # 读取网站代码 sublink_list = self._get_subtitle_links(torrent) if not sublink_list: logger.warn(f"{torrent.page_url} 页面未找到字幕下载链接") return # 下载所有字幕文件 request = RequestUtils( cookies=torrent.site_cookie, ua=torrent.site_ua, proxies=settings.PROXY if torrent.site_proxy else None, ) for sublink in sublink_list: logger.info(f"找到字幕下载链接:{sublink},开始下载...") # 下载 ret = request.get_res(sublink) if ret and ret.status_code == 200: # 保存ZIP file_name = TorrentHelper.get_url_filename(ret, sublink) if not file_name: logger.warn(f"链接不是字幕文件:{sublink}") continue if file_name.lower().endswith(".zip"): # ZIP包 zip_file = settings.TEMP_PATH / file_name # 保存 zip_file.write_bytes(ret.content) # 解压路径 zip_path = zip_file.with_name(zip_file.stem) # 解压文件 shutil.unpack_archive(zip_file, zip_path, format='zip') # 遍历转移文件 for sub_file in SystemUtils.list_files(zip_path, settings.RMT_SUBEXT): target_sub_file = Path(working_dir_item.path) / Path(sub_file.name) if storageChain.get_file_item(storage, target_sub_file): logger.info(f"字幕文件已存在:{target_sub_file}") continue logger.info(f"转移字幕 {sub_file} 到 {target_sub_file} ...") storageChain.upload_file(working_dir_item, sub_file) # 删除临时文件 try: shutil.rmtree(zip_path) zip_file.unlink() except Exception as err: logger.error(f"删除临时文件失败:{str(err)}") else: sub_file = settings.TEMP_PATH / file_name # 保存 sub_file.write_bytes(ret.content) target_sub_file = Path(working_dir_item.path) / Path(sub_file.name) if storageChain.get_file_item(storage, target_sub_file): logger.info(f"字幕文件已存在:{target_sub_file}") continue logger.info(f"转移字幕 {sub_file} 到 {target_sub_file} ...") storageChain.upload_file(working_dir_item, sub_file) else: logger.error(f"下载字幕文件失败:{sublink}") continue logger.info(f"{torrent.page_url} 页面字幕下载完成") ================================================ FILE: app/modules/synologychat/__init__.py ================================================ from typing import Optional, Union, List, Tuple, Any from app.core.context import MediaInfo, Context from app.log import logger from app.modules import _ModuleBase, _MessageBase from app.modules.synologychat.synologychat import SynologyChat from app.schemas import MessageChannel, CommingMessage, Notification from app.schemas.types import ModuleType class SynologyChatModule(_ModuleBase, _MessageBase[SynologyChat]): def init_module(self) -> None: """ 初始化模块 """ super().init_service(service_name=SynologyChat.__name__.lower(), service_type=SynologyChat) self._channel = MessageChannel.SynologyChat @staticmethod def get_name() -> str: return "Synology Chat" @staticmethod def get_type() -> ModuleType: """ 获取模块类型 """ return ModuleType.Notification @staticmethod def get_subtype() -> MessageChannel: """ 获取模块子类型 """ return MessageChannel.SynologyChat @staticmethod def get_priority() -> int: """ 获取模块优先级,数字越小优先级越高,只有同一接口下优先级才生效 """ return 5 def stop(self): pass def test(self) -> Optional[Tuple[bool, str]]: """ 测试模块连接性 """ if not self.get_instances(): return None for name, client in self.get_instances().items(): state = client.get_state() if not state: return False, f"Synology Chat {name} 未就绪" return True, "" def init_setting(self) -> Tuple[str, Union[str, bool]]: pass def message_parser(self, source: str, body: Any, form: Any, args: Any) -> Optional[CommingMessage]: """ 解析消息内容,返回字典,注意以下约定值: userid: 用户ID username: 用户名 text: 内容 :param source: 消息来源 :param body: 请求体 :param form: 表单 :param args: 参数 :return: 渠道、消息体 """ try: # 获取服务配置 client_config = self.get_config(source) if not client_config: return None client: SynologyChat = self.get_instance(client_config.name) if not client: return None # 解析消息 message: dict = form if not message: return None # 校验token token = message.get("token") if not token or not client.check_token(token): return None # 文本 text = message.get("text") # 用户ID user_id = int(message.get("user_id")) # 获取用户名 user_name = message.get("username") if text and user_id: logger.info(f"收到来自 {client_config.name} 的SynologyChat消息:" f"userid={user_id}, username={user_name}, text={text}") return CommingMessage(channel=MessageChannel.SynologyChat, source=client_config.name, userid=user_id, username=user_name, text=text) except Exception as err: logger.debug(f"解析SynologyChat消息失败:{str(err)}") return None def post_message(self, message: Notification, **kwargs) -> None: """ 发送消息 :param message: 消息体 :return: 成功或失败 """ for conf in self.get_configs().values(): if not self.check_message(message, conf.name): continue targets = message.targets userid = message.userid if not userid and targets is not None: userid = targets.get('synologychat_userid') if not userid: logger.warn(f"用户没有指定 SynologyChat用户ID,消息无法发送") return client: SynologyChat = self.get_instance(conf.name) if client: client.send_msg(title=message.title, text=message.text, image=message.image, userid=userid, link=message.link) def post_medias_message(self, message: Notification, medias: List[MediaInfo]) -> None: """ 发送媒体信息选择列表 :param message: 消息体 :param medias: 媒体列表 :return: 成功或失败 """ for conf in self.get_configs().values(): if not self.check_message(message, conf.name): continue client: SynologyChat = self.get_instance(conf.name) if client: client.send_medias_msg(title=message.title, medias=medias, userid=message.userid) def post_torrents_message(self, message: Notification, torrents: List[Context]) -> None: """ 发送种子信息选择列表 :param message: 消息体 :param torrents: 种子列表 :return: 成功或失败 """ for conf in self.get_configs().values(): if not self.check_message(message, conf.name): continue client: SynologyChat = self.get_instance(conf.name) if client: client.send_torrents_msg(title=message.title, torrents=torrents, userid=message.userid, link=message.link) ================================================ FILE: app/modules/synologychat/synologychat.py ================================================ import json import re from threading import Lock from typing import Optional, List from urllib.parse import quote from app.core.context import MediaInfo, Context from app.core.metainfo import MetaInfo from app.log import logger from app.utils.http import RequestUtils from app.utils.string import StringUtils lock = Lock() class SynologyChat: def __init__(self, SYNOLOGYCHAT_WEBHOOK: Optional[str] = None, SYNOLOGYCHAT_TOKEN: Optional[str] = None, **kwargs): if not SYNOLOGYCHAT_WEBHOOK or not SYNOLOGYCHAT_TOKEN: logger.error("SynologyChat配置不完整!") return self._req = RequestUtils(content_type="application/x-www-form-urlencoded") self._webhook_url = SYNOLOGYCHAT_WEBHOOK self._token = SYNOLOGYCHAT_TOKEN if self._webhook_url: self._domain = StringUtils.get_base_url(self._webhook_url) def check_token(self, token: str) -> bool: return True if token == self._token else False def get_state(self) -> bool: """ 获取状态 """ if not self._webhook_url or not self._token: return False ret = self.__get_bot_users() if ret: return True return False def send_msg(self, title: str, text: Optional[str] = None, image: Optional[str] = None, userid: Optional[str] = None, link: Optional[str] = None) -> Optional[bool]: """ 发送SynologyChat消息 :param title: 消息标题 :param text: 消息内容 :param image: 消息图片地址 :param userid: 用户ID,如有则只发消息给该用户 :user_id: 发送消息的目标用户ID,为空则发给管理员 :param link: 链接地址 """ if not title and not text: logger.error("标题和内容不能同时为空") return False if not self._webhook_url or not self._token: return False try: # 拼装消息内容 titles = str(title).split('\n') if len(titles) > 1: title = titles[0] if not text: text = "\n".join(titles[1:]) else: text = f"%s\n%s" % ("\n".join(titles[1:]), text) if text: caption = "*%s*\n%s" % (title, text.replace("\n\n", "\n")) else: caption = title if link: caption = f"{caption}\n[查看详情]({link})" payload_data = {'text': quote(caption)} if image: payload_data['file_url'] = quote(image) if userid: payload_data['user_ids'] = [int(userid)] else: userids = self.__get_bot_users() if not userids: logger.error("SynologyChat机器人没有对任何用户可见") return False payload_data['user_ids'] = userids return self.__send_request(payload_data) except Exception as msg_e: logger.error(f"SynologyChat发送消息错误:{str(msg_e)}") return False def send_medias_msg(self, medias: List[MediaInfo], userid: Optional[str] = None, title: Optional[str] = None) -> Optional[bool]: """ 发送列表类消息 """ if not medias: return False if not self._webhook_url or not self._token: return False try: if not title or not isinstance(medias, list): return False index, image, caption = 1, "", "*%s*" % title for media in medias: if not image: image = media.get_message_image() if media.vote_average: caption = "%s\n%s. <%s|%s>\n_%s,%s_" % (caption, index, media.detail_link, media.title_year, f"类型:{media.type.value}", f"评分:{media.vote_average}") else: caption = "%s\n%s. <%s|%s>\n_%s_" % (caption, index, media.detail_link, media.title_year, f"类型:{media.type.value}") index += 1 if userid: userids = [int(userid)] else: userids = self.__get_bot_users() payload_data = { "text": quote(caption), "user_ids": userids } return self.__send_request(payload_data) except Exception as msg_e: logger.error(f"SynologyChat发送消息错误:{str(msg_e)}") return False def send_torrents_msg(self, torrents: List[Context], userid: Optional[str] = None, title: Optional[str] = None, link: Optional[str] = None) -> Optional[bool]: """ 发送列表消息 """ if not self._webhook_url or not self._token: return None if not torrents: return False try: index, caption = 1, "*%s*" % title for context in torrents: torrent = context.torrent_info site_name = torrent.site_name meta = MetaInfo(torrent.title, torrent.description) link = torrent.page_url title = f"{meta.season_episode} " \ f"{meta.resource_term} " \ f"{meta.video_term} " \ f"{meta.release_group}" title = re.sub(r"\s+", " ", title).strip() free = torrent.volume_factor seeder = f"{torrent.seeders}↑" description = torrent.description caption = f"{caption}\n{index}.【{site_name}】<{link}|{title}> " \ f"{StringUtils.str_filesize(torrent.size)} {free} {seeder}\n" \ f"_{description}_" index += 1 if link: caption = f"{caption}\n[查看详情]({link})" if userid: userids = [int(userid)] else: userids = self.__get_bot_users() payload_data = { "text": quote(caption), "user_ids": userids } return self.__send_request(payload_data) except Exception as msg_e: logger.error(f"SynologyChat发送消息错误:{str(msg_e)}") return False def __get_bot_users(self): """ 查询机器人可见的用户列表 """ if not self._domain or not self._token: return [] req_url = f"{self._domain}" \ f"/webapi/entry.cgi?api=SYNO.Chat.External&method=user_list&version=2&token=" \ f"{self._token}" ret = self._req.get_res(url=req_url) if ret and ret.status_code == 200: users = ret.json().get("data", {}).get("users", []) or [] return [user.get("user_id") for user in users if user.get("deleted", True) is False] else: return [] def __send_request(self, payload_data): """ 发送消息请求 """ payload = f"payload={json.dumps(payload_data)}" ret = self._req.post_res(url=self._webhook_url, data=payload) if ret and ret.status_code == 200: result = ret.json() if result: errno = result.get('error', {}).get('code') errmsg = result.get('error', {}).get('errors') if not errno: return True logger.error(f"SynologyChat返回错误:{errno}-{errmsg}") return False else: logger.error(f"SynologyChat返回:{ret.text}") return False elif ret is not None: logger.error(f"SynologyChat请求失败,错误码:{ret.status_code},错误原因:{ret.reason}") return False else: logger.error(f"SynologyChat请求失败,未获取到返回信息") return False ================================================ FILE: app/modules/telegram/__init__.py ================================================ import copy import json import re from typing import Dict, Optional, Union, List, Tuple, Any from app.core.context import MediaInfo, Context from app.core.event import eventmanager from app.log import logger from app.modules import _ModuleBase, _MessageBase from app.modules.telegram.telegram import Telegram from app.schemas import MessageChannel, CommingMessage, Notification, CommandRegisterEventData, \ NotificationConf from app.schemas.types import ModuleType, ChainEventType from app.utils.structures import DictUtils class TelegramModule(_ModuleBase, _MessageBase[Telegram]): def init_module(self) -> None: """ 初始化模块 """ super().init_service(service_name=Telegram.__name__.lower(), service_type=Telegram) self._channel = MessageChannel.Telegram @staticmethod def get_name() -> str: return "Telegram" @staticmethod def get_type() -> ModuleType: """ 获取模块类型 """ return ModuleType.Notification @staticmethod def get_subtype() -> MessageChannel: """ 获取模块子类型 """ return MessageChannel.Telegram @staticmethod def get_priority() -> int: """ 获取模块优先级,数字越小优先级越高,只有同一接口下优先级才生效 """ return 0 def stop(self): """ 停止模块 """ for client in self.get_instances().values(): client.stop() def test(self) -> Optional[Tuple[bool, str]]: """ 测试模块连接性 """ if not self.get_instances(): return None for name, client in self.get_instances().items(): state = client.get_state() if not state: return False, f"Telegram {name} 未就绪" return True, "" def init_setting(self) -> Tuple[str, Union[str, bool]]: pass def message_parser(self, source: str, body: Any, form: Any, args: Any) -> Optional[CommingMessage]: """ 解析消息内容,返回字典,注意以下约定值: userid: 用户ID username: 用户名 text: 内容 :param source: 消息来源 :param body: 请求体 :param form: 表单 :param args: 参数 :return: 渠道、消息体 """ """ 普通消息格式: { 'update_id': , 'message': { 'message_id': , 'from': { 'id': , 'is_bot': False, 'first_name': '', 'username': '', 'language_code': 'zh-hans' }, 'chat': { 'id': , 'first_name': '', 'username': '', 'type': 'private' }, 'date': , 'text': '' } } 按钮回调格式: { 'callback_query': { 'id': '', 'from': {...}, 'message': {...}, 'data': 'callback_data' } } """ # 获取服务配置 client_config = self.get_config(source) if not client_config: return None client: Telegram = self.get_instance(client_config.name) try: message: dict = json.loads(body) except Exception as err: logger.debug(f"解析Telegram消息失败:{str(err)}") return None if message: # 处理按钮回调 if "callback_query" in message: return self._handle_callback_query(message, client_config) # 处理普通消息 return self._handle_text_message(message, client_config, client) return None @staticmethod def _handle_callback_query(message: dict, client_config: NotificationConf) -> Optional[CommingMessage]: """ 处理按钮回调查询 """ callback_query = message.get("callback_query", {}) user_info = callback_query.get("from", {}) callback_data = callback_query.get("data", "") user_id = user_info.get("id") user_name = user_info.get("username") if callback_data and user_id: logger.info(f"收到来自 {client_config.name} 的Telegram按钮回调:" f"userid={user_id}, username={user_name}, callback_data={callback_data}") # 将callback_data作为特殊格式的text返回,以便主程序识别这是按钮回调 callback_text = f"CALLBACK:{callback_data}" # 创建包含完整回调信息的CommingMessage return CommingMessage( channel=MessageChannel.Telegram, source=client_config.name, userid=user_id, username=user_name, text=callback_text, is_callback=True, callback_data=callback_data, message_id=callback_query.get("message", {}).get("message_id"), chat_id=str(callback_query.get("message", {}).get("chat", {}).get("id", "")), callback_query=callback_query ) return None def _handle_text_message(self, msg: dict, client_config: NotificationConf, client: Telegram) -> Optional[CommingMessage]: """ 处理普通文本消息 """ text = msg.get("text") user_id = msg.get("from", {}).get("id") user_name = msg.get("from", {}).get("username") # Extract chat_id to enable correct reply targeting chat_id = msg.get("chat", {}).get("id") if text and user_id: logger.info(f"收到来自 {client_config.name} 的Telegram消息:" f"userid={user_id}, username={user_name}, chat_id={chat_id}, text={text}") # Clean bot mentions from text to ensure consistent processing cleaned_text = self._clean_bot_mention(text, client.bot_username if client else None) # 检查权限 admin_users = client_config.config.get("TELEGRAM_ADMINS") user_list = client_config.config.get("TELEGRAM_USERS") config_chat_id = client_config.config.get("TELEGRAM_CHAT_ID") if cleaned_text.startswith("/"): if admin_users \ and str(user_id) not in admin_users.split(',') \ and str(user_id) != config_chat_id: client.send_msg(title="只有管理员才有权限执行此命令", userid=user_id) return None else: if user_list \ and str(user_id) not in user_list.split(','): logger.info(f"用户{user_id}不在用户白名单中,无法使用此机器人") client.send_msg(title="你不在用户白名单中,无法使用此机器人", userid=user_id) return None return CommingMessage( channel=MessageChannel.Telegram, source=client_config.name, userid=user_id, username=user_name, text=cleaned_text, # Use cleaned text chat_id=str(chat_id) if chat_id else None ) return None @staticmethod def _clean_bot_mention(text: str, bot_username: Optional[str]) -> str: """ 清理消息中的@bot部分,确保文本处理一致性 :param text: 原始消息文本 :param bot_username: bot用户名 :return: 清理后的文本 """ if not text or not bot_username: return text # Remove @bot_username from the beginning and any position in text cleaned = text mention_pattern = f"@{bot_username}" # Remove mention at the beginning with optional following space if cleaned.startswith(mention_pattern): cleaned = cleaned[len(mention_pattern):].lstrip() # Remove mention at any other position cleaned = cleaned.replace(mention_pattern, "").strip() # Clean up multiple spaces cleaned = re.sub(r'\s+', ' ', cleaned).strip() return cleaned def post_message(self, message: Notification, **kwargs) -> None: """ 发送消息 :param message: 消息体 :return: 成功或失败 """ for conf in self.get_configs().values(): if not self.check_message(message, conf.name): continue targets = message.targets userid = message.userid if not userid and targets is not None: userid = targets.get('telegram_userid') if not userid: logger.warn(f"用户没有指定 Telegram用户ID,消息无法发送") return client: Telegram = self.get_instance(conf.name) if client: client.send_msg(title=message.title, text=message.text, image=message.image, userid=userid, link=message.link, buttons=message.buttons, original_message_id=message.original_message_id, original_chat_id=message.original_chat_id) def post_medias_message(self, message: Notification, medias: List[MediaInfo]) -> None: """ 发送媒体信息选择列表 :param message: 消息体 :param medias: 媒体列表 :return: 成功或失败 """ for conf in self.get_configs().values(): if not self.check_message(message, conf.name): continue client: Telegram = self.get_instance(conf.name) if client: client.send_medias_msg(title=message.title, medias=medias, userid=message.userid, link=message.link, buttons=message.buttons, original_message_id=message.original_message_id, original_chat_id=message.original_chat_id) def post_torrents_message(self, message: Notification, torrents: List[Context]) -> None: """ 发送种子信息选择列表 :param message: 消息体 :param torrents: 种子列表 :return: 成功或失败 """ for conf in self.get_configs().values(): if not self.check_message(message, conf.name): continue client: Telegram = self.get_instance(conf.name) if client: client.send_torrents_msg(title=message.title, torrents=torrents, userid=message.userid, link=message.link, buttons=message.buttons, original_message_id=message.original_message_id, original_chat_id=message.original_chat_id) def delete_message(self, channel: MessageChannel, source: str, message_id: int, chat_id: Optional[int] = None) -> bool: """ 删除消息 :param channel: 消息渠道 :param source: 指定的消息源 :param message_id: 消息ID :param chat_id: 聊天ID :return: 删除是否成功 """ success = False for conf in self.get_configs().values(): if channel != self._channel: break if source != conf.name: continue client: Telegram = self.get_instance(conf.name) if client: result = client.delete_msg(message_id=message_id, chat_id=chat_id) if result: success = True return success def register_commands(self, commands: Dict[str, dict]): """ 注册命令,实现这个函数接收系统可用的命令菜单 :param commands: 命令字典 """ for client_config in self.get_configs().values(): client = self.get_instance(client_config.name) if not client: continue # 触发事件,允许调整命令数据,这里需要进行深复制,避免实例共享 scoped_commands = copy.deepcopy(commands) event = eventmanager.send_event( ChainEventType.CommandRegister, CommandRegisterEventData(commands=scoped_commands, origin="Telegram", service=client_config.name) ) # 如果事件返回有效的 event_data,使用事件中调整后的命令 if event and event.event_data: event_data: CommandRegisterEventData = event.event_data # 如果事件被取消,跳过命令注册,并清理菜单 if event_data.cancel: client.delete_commands() logger.debug( f"Command registration for {client_config.name} canceled by event: {event_data.source}" ) continue scoped_commands = event_data.commands or {} if not scoped_commands: logger.debug("Filtered commands are empty, skipping registration.") client.delete_commands() # scoped_commands 必须是 commands 的子集 filtered_scoped_commands = DictUtils.filter_keys_to_subset(scoped_commands, commands) # 如果 filtered_scoped_commands 为空,则跳过注册 if not filtered_scoped_commands: logger.debug("Filtered commands are empty, skipping registration.") client.delete_commands() continue # 对比调整后的命令与当前命令 if filtered_scoped_commands != commands: logger.debug(f"Command set has changed, Updating new commands: {filtered_scoped_commands}") client.register_commands(filtered_scoped_commands) ================================================ FILE: app/modules/telegram/telegram.py ================================================ import asyncio import re import threading from typing import Optional, List, Dict, Callable from urllib.parse import urljoin, quote from telebot import TeleBot, apihelper from telebot.types import BotCommand, InlineKeyboardMarkup, InlineKeyboardButton, InputMediaPhoto from telegramify_markdown import standardize, telegramify from telegramify_markdown.type import ContentTypes, SentType from app.core.config import settings from app.core.context import MediaInfo, Context from app.core.metainfo import MetaInfo from app.helper.thread import ThreadHelper from app.helper.image import ImageHelper from app.log import logger from app.utils.common import retry from app.utils.http import RequestUtils from app.utils.string import StringUtils class RetryException(Exception): pass class Telegram: _ds_url = f"http://127.0.0.1:{settings.PORT}/api/v1/message?token={settings.API_TOKEN}" _bot: TeleBot = None _callback_handlers: Dict[str, Callable] = {} # 存储回调处理器 _user_chat_mapping: Dict[str, str] = {} # userid -> chat_id mapping for reply targeting _bot_username: Optional[str] = None # Bot username for mention detection def __init__(self, TELEGRAM_TOKEN: Optional[str] = None, TELEGRAM_CHAT_ID: Optional[str] = None, **kwargs): """ 初始化参数 """ if not TELEGRAM_TOKEN or not TELEGRAM_CHAT_ID: logger.error("Telegram配置不完整!") return # Token self._telegram_token = TELEGRAM_TOKEN # Chat Id self._telegram_chat_id = TELEGRAM_CHAT_ID # 初始化机器人 if self._telegram_token and self._telegram_chat_id: # telegram bot api 地址,格式:https://api.telegram.org if kwargs.get("API_URL"): apihelper.API_URL = urljoin(kwargs["API_URL"], '/bot{0}/{1}') apihelper.FILE_URL = urljoin(kwargs["API_URL"], '/file/bot{0}/{1}') else: apihelper.proxy = settings.PROXY # bot _bot = TeleBot(self._telegram_token, parse_mode="MarkdownV2") # 记录句柄 self._bot = _bot # 获取并存储bot用户名用于@检测 try: bot_info = _bot.get_me() self._bot_username = bot_info.username logger.info(f"Telegram bot用户名: @{self._bot_username}") except Exception as e: logger.error(f"获取bot信息失败: {e}") self._bot_username = None # 标记渠道来源 if kwargs.get("name"): # URL encode the source name to handle special characters encoded_name = quote(kwargs.get('name'), safe='') self._ds_url = f"{self._ds_url}&source={encoded_name}" @_bot.message_handler(commands=['start', 'help']) def send_welcome(message): _bot.reply_to(message, "温馨提示:直接发送名称或`订阅`+名称,搜索或订阅电影、电视剧") @_bot.message_handler(func=lambda message: True) def echo_all(message): # Update user-chat mapping when receiving messages self._update_user_chat_mapping(message.from_user.id, message.chat.id) # Check if we should process this message if self._should_process_message(message): # 发送正在输入状态 try: _bot.send_chat_action(message.chat.id, 'typing') except Exception as e: logger.error(f"发送Telegram正在输入状态失败:{e}") RequestUtils(timeout=15).post_res(self._ds_url, json=message.json) @_bot.callback_query_handler(func=lambda call: True) def callback_query(call): """ 处理按钮点击回调 """ try: # Update user-chat mapping for callbacks too self._update_user_chat_mapping(call.from_user.id, call.message.chat.id) # 解析回调数据 callback_data = call.data user_id = str(call.from_user.id) logger.info(f"收到按钮回调:{callback_data},用户:{user_id}") # 发送回调数据给主程序处理 callback_json = { "callback_query": { "id": call.id, "from": call.from_user.to_dict(), "message": { "message_id": call.message.message_id, "chat": { "id": call.message.chat.id, } }, "data": callback_data } } # 先确认回调,避免用户看到loading状态 _bot.answer_callback_query(call.id) # 发送正在输入状态 try: _bot.send_chat_action(call.message.chat.id, 'typing') except Exception as e: logger.error(f"发送Telegram正在输入状态失败:{e}") # 发送给主程序处理 RequestUtils(timeout=15).post_res(self._ds_url, json=callback_json) except Exception as err: logger.error(f"处理按钮回调失败:{str(err)}") _bot.answer_callback_query(call.id, "处理失败,请重试") def run_polling(): """ 定义线程函数来运行 infinity_polling """ try: _bot.infinity_polling(long_polling_timeout=30, logger_level=None) except Exception as err: logger.error(f"Telegram消息接收服务异常:{str(err)}") # 启动线程来运行 infinity_polling self._polling_thread = threading.Thread(target=run_polling, daemon=True) self._polling_thread.start() logger.info("Telegram消息接收服务启动") @property def bot_username(self) -> Optional[str]: """ 获取Bot用户名 :return: Bot用户名或None """ return self._bot_username def _update_user_chat_mapping(self, userid: int, chat_id: int) -> None: """ 更新用户与聊天的映射关系 :param userid: 用户ID :param chat_id: 聊天ID """ if userid and chat_id: self._user_chat_mapping[str(userid)] = str(chat_id) def _get_user_chat_id(self, userid: str) -> Optional[str]: """ 获取用户对应的聊天ID :param userid: 用户ID :return: 聊天ID或None """ return self._user_chat_mapping.get(str(userid)) if userid else None def _should_process_message(self, message) -> bool: """ 判断是否应该处理这条消息 :param message: Telegram消息对象 :return: 是否处理 """ # 私聊消息总是处理 if message.chat.type == 'private': logger.debug(f"处理私聊消息:用户 {message.from_user.id}") return True # 群聊中的命令消息总是处理(以/开头) if message.text and message.text.startswith('/'): logger.debug(f"处理群聊命令消息:{message.text[:20]}...") return True # 群聊中检查是否@了机器人 if message.chat.type in ['group', 'supergroup']: if not self._bot_username: # 如果没有获取到bot用户名,为了安全起见处理所有消息 logger.debug("未获取到bot用户名,处理所有群聊消息") return True # 检查消息文本中是否包含@bot_username if message.text and f"@{self._bot_username}" in message.text: logger.debug(f"检测到@{self._bot_username},处理群聊消息") return True # 检查消息实体中是否有提及bot if message.entities: for entity in message.entities: if entity.type == 'mention': mention_text = message.text[entity.offset:entity.offset + entity.length] if mention_text == f"@{self._bot_username}": logger.debug(f"通过实体检测到@{self._bot_username},处理群聊消息") return True # 群聊中没有@机器人,不处理 logger.debug(f"群聊消息未@机器人,跳过处理:{message.text[:30] if message.text else 'No text'}...") return False # 其他类型的聊天默认处理 logger.debug(f"处理其他类型聊天消息:{message.chat.type}") return True def get_state(self) -> bool: """ 获取状态 """ return self._bot is not None def send_msg(self, title: str, text: Optional[str] = None, image: Optional[str] = None, userid: Optional[str] = None, link: Optional[str] = None, buttons: Optional[List[List[dict]]] = None, original_message_id: Optional[int] = None, original_chat_id: Optional[str] = None) -> Optional[bool]: """ 发送Telegram消息 :param title: 消息标题 :param text: 消息内容 :param image: 消息图片地址 :param userid: 用户ID,如有则只发消息给该用户 :param link: 跳转链接 :param buttons: 按钮列表,格式:[[{"text": "按钮文本", "callback_data": "回调数据"}]] :param original_message_id: 原消息ID,如果提供则编辑原消息 :param original_chat_id: 原消息的聊天ID,编辑消息时需要 """ if not self._telegram_token or not self._telegram_chat_id: return None if not title and not text: logger.warn("标题和内容不能同时为空") return False try: # 标准化标题后再加粗,避免**符号被显示为文本 bold_title = ( f"**{standardize(title).removesuffix('\n')}**" if title else None ) if bold_title and text: caption = f"{bold_title}\n{text}" elif bold_title: caption = bold_title elif text: caption = text else: caption = "" if link: caption = f"{caption}\n[查看详情]({link})" # Determine target chat_id with improved logic using user mapping chat_id = self._determine_target_chat_id(userid, original_chat_id) # 创建按钮键盘 reply_markup = None if buttons: reply_markup = self._create_inline_keyboard(buttons) # 判断是编辑消息还是发送新消息 if original_message_id and original_chat_id: # 编辑消息 return self.__edit_message(original_chat_id, original_message_id, caption, buttons, image) else: # 发送新消息 return self.__send_request(userid=chat_id, image=image, caption=caption, reply_markup=reply_markup) except Exception as msg_e: logger.error(f"发送消息失败:{msg_e}") return False def _determine_target_chat_id(self, userid: Optional[str] = None, original_chat_id: Optional[str] = None) -> str: """ 确定目标聊天ID,使用用户映射确保回复到正确的聊天 :param userid: 用户ID :param original_chat_id: 原消息的聊天ID :return: 目标聊天ID """ # 1. 优先使用原消息的聊天ID (编辑消息场景) if original_chat_id: return original_chat_id # 2. 如果有userid,尝试从映射中获取用户的聊天ID if userid: mapped_chat_id = self._get_user_chat_id(userid) if mapped_chat_id: return mapped_chat_id # 如果映射中没有,回退到使用userid作为聊天ID (私聊场景) return userid # 3. 最后使用默认聊天ID return self._telegram_chat_id def send_medias_msg(self, medias: List[MediaInfo], userid: Optional[str] = None, title: Optional[str] = None, link: Optional[str] = None, buttons: Optional[List[List[Dict]]] = None, original_message_id: Optional[int] = None, original_chat_id: Optional[str] = None) -> Optional[bool]: """ 发送媒体列表消息 :param medias: 媒体信息列表 :param userid: 用户ID,如有则只发消息给该用户 :param title: 消息标题 :param link: 跳转链接 :param buttons: 按钮列表,格式:[[{"text": "按钮文本", "callback_data": "回调数据"}]] :param original_message_id: 原消息ID,如果提供则编辑原消息 :param original_chat_id: 原消息的聊天ID,编辑消息时需要 """ if not self._telegram_token or not self._telegram_chat_id: return None try: index, image, caption = 1, "", "*%s*" % title for media in medias: if not image: image = media.get_message_image() if media.vote_average: caption = "%s\n%s. [%s](%s)\n_%s,%s_" % (caption, index, media.title_year, media.detail_link, f"类型:{media.type.value}", f"评分:{media.vote_average}") else: caption = "%s\n%s. [%s](%s)\n_%s_" % (caption, index, media.title_year, media.detail_link, f"类型:{media.type.value}") index += 1 if link: caption = f"{caption}\n[查看详情]({link})" # Determine target chat_id with improved logic using user mapping chat_id = self._determine_target_chat_id(userid, original_chat_id) # 创建按钮键盘 reply_markup = None if buttons: reply_markup = self._create_inline_keyboard(buttons) # 判断是编辑消息还是发送新消息 if original_message_id and original_chat_id: # 编辑消息 return self.__edit_message(original_chat_id, original_message_id, caption, buttons, image) else: # 发送新消息 return self.__send_request(userid=chat_id, image=image, caption=caption, reply_markup=reply_markup) except Exception as msg_e: logger.error(f"发送消息失败:{msg_e}") return False def send_torrents_msg(self, torrents: List[Context], userid: Optional[str] = None, title: Optional[str] = None, link: Optional[str] = None, buttons: Optional[List[List[Dict]]] = None, original_message_id: Optional[int] = None, original_chat_id: Optional[str] = None) -> Optional[bool]: """ 发送种子列表消息 :param torrents: 种子信息列表 :param userid: 用户ID,如有则只发消息给该用户 :param title: 消息标题 :param link: 跳转链接 :param buttons: 按钮列表,格式:[[{"text": "按钮文本", "callback_data": "回调数据"}]] :param original_message_id: 原消息ID,如果提供则编辑原消息 :param original_chat_id: 原消息的聊天ID,编辑消息时需要 """ if not self._telegram_token or not self._telegram_chat_id: return None try: index, caption = 1, "*%s*" % title image = torrents[0].media_info.get_message_image() for context in torrents: torrent = context.torrent_info site_name = torrent.site_name meta = MetaInfo(torrent.title, torrent.description) link = torrent.page_url title = f"{meta.season_episode} " \ f"{meta.resource_term} " \ f"{meta.video_term} " \ f"{meta.release_group}" title = re.sub(r"\s+", " ", title).strip() free = torrent.volume_factor seeder = f"{torrent.seeders}↑" caption = f"{caption}\n{index}.【{site_name}】[{title}]({link}) " \ f"{StringUtils.str_filesize(torrent.size)} {free} {seeder}" index += 1 if link: caption = f"{caption}\n[查看详情]({link})" # Determine target chat_id with improved logic using user mapping chat_id = self._determine_target_chat_id(userid, original_chat_id) # 创建按钮键盘 reply_markup = None if buttons: reply_markup = self._create_inline_keyboard(buttons) # 判断是编辑消息还是发送新消息 if original_message_id and original_chat_id: # 编辑消息(种子消息通常没有图片) return self.__edit_message(original_chat_id, original_message_id, caption, buttons, image) else: # 发送新消息 return self.__send_request(userid=chat_id, image=image, caption=caption, reply_markup=reply_markup) except Exception as msg_e: logger.error(f"发送消息失败:{msg_e}") return False @staticmethod def _create_inline_keyboard(buttons: List[List[Dict]]) -> InlineKeyboardMarkup: """ 创建内联键盘 :param buttons: 按钮配置,格式:[[{"text": "按钮文本", "callback_data": "回调数据", "url": "链接"}]] :return: InlineKeyboardMarkup对象 """ keyboard = [] for row in buttons: button_row = [] for button in row: if "url" in button: # URL按钮 btn = InlineKeyboardButton(text=button["text"], url=button["url"]) else: # 回调按钮 btn = InlineKeyboardButton(text=button["text"], callback_data=button["callback_data"]) button_row.append(btn) keyboard.append(button_row) return InlineKeyboardMarkup(keyboard) def answer_callback_query(self, callback_query_id: int, text: Optional[str] = None, show_alert: bool = False) -> Optional[bool]: """ 回应回调查询 """ if not self._bot: return None try: self._bot.answer_callback_query(callback_query_id, text=text, show_alert=show_alert) return True except Exception as e: logger.error(f"回应回调查询失败:{str(e)}") return False def delete_msg(self, message_id: int, chat_id: Optional[int] = None) -> Optional[bool]: """ 删除Telegram消息 :param message_id: 消息ID :param chat_id: 聊天ID :return: 删除是否成功 """ if not self._telegram_token or not self._telegram_chat_id: return None try: # 确定要删除消息的聊天ID if chat_id: target_chat_id = chat_id else: target_chat_id = self._telegram_chat_id # 删除消息 result = self._bot.delete_message(chat_id=target_chat_id, message_id=int(message_id)) if result: logger.info(f"成功删除Telegram消息: chat_id={target_chat_id}, message_id={message_id}") return True else: logger.error(f"删除Telegram消息失败: chat_id={target_chat_id}, message_id={message_id}") return False except Exception as e: logger.error(f"删除Telegram消息异常: {str(e)}") return False def __edit_message(self, chat_id: str, message_id: int, text: str, buttons: Optional[List[List[dict]]] = None, image: Optional[str] = None) -> Optional[bool]: """ 编辑已发送的消息 :param chat_id: 聊天ID :param message_id: 消息ID :param text: 新的消息内容 :param buttons: 按钮列表 :param image: 图片URL或路径 :return: 编辑是否成功 """ if not self._bot: return None try: # 创建按钮键盘 reply_markup = None if buttons: reply_markup = self._create_inline_keyboard(buttons) if image: # 如果有图片,使用edit_message_media media = InputMediaPhoto(media=image, caption=standardize(text), parse_mode="MarkdownV2") self._bot.edit_message_media( chat_id=chat_id, message_id=message_id, media=media, reply_markup=reply_markup ) else: # 如果没有图片,使用edit_message_text self._bot.edit_message_text( chat_id=chat_id, message_id=message_id, text=standardize(text), parse_mode="MarkdownV2", reply_markup=reply_markup ) return True except Exception as e: logger.error(f"编辑消息失败:{str(e)}") return False def __send_request(self, userid: Optional[str] = None, image="", caption="", reply_markup: Optional[InlineKeyboardMarkup] = None) -> bool: """ 向Telegram发送报文 :param reply_markup: 内联键盘 """ kwargs = { 'chat_id': userid or self._telegram_chat_id, 'parse_mode': "MarkdownV2", 'reply_markup': reply_markup } # 处理图片 image = self.__process_image(image) try: # 图片消息的标题长度限制为1024,文本消息为4096 caption_limit = 1024 if image else 4096 if len(caption) < caption_limit: ret = self.__send_short_message(image, caption, **kwargs) else: sent_idx = set() ret = self.__send_long_message(image, caption, sent_idx, **kwargs) return ret is not None except Exception as e: logger.error(f"发送Telegram消息失败: {e}") return False @staticmethod def __process_image(image_url: Optional[str]) -> Optional[bytes]: """ 处理图片URL,获取图片内容 """ if not image_url: return None image = ImageHelper().fetch_image(image_url) if not image: logger.warn(f"图片获取失败: {image_url},仅发送文本消息") return image @retry(RetryException, logger=logger) def __send_short_message(self, image: Optional[bytes], caption: str, **kwargs): """ 发送短消息 """ try: if image: return self._bot.send_photo( photo=image, caption=standardize(caption), **kwargs ) else: return self._bot.send_message( text=standardize(caption), **kwargs ) except Exception: raise RetryException(f"发送{'图片' if image else '文本'}消息失败") @retry(RetryException, logger=logger) def __send_long_message(self, image: Optional[bytes], caption: str, sent_idx: set, **kwargs): """ 发送长消息 """ try: reply_markup = kwargs.pop("reply_markup", None) boxs: SentType = ThreadHelper().submit(lambda x: asyncio.run(telegramify(x)), caption).result() ret = None for i, item in enumerate(boxs): if i in sent_idx: # 跳过已发送消息 continue current_reply_markup = reply_markup if i == 0 else None if item.content_type == ContentTypes.TEXT and (i != 0 or not image): ret = self._bot.send_message(**kwargs, text=item.content, reply_markup=current_reply_markup ) elif item.content_type == ContentTypes.PHOTO or (image and i == 0): ret = self._bot.send_photo(**kwargs, photo=(getattr(item, "file_name", ""), getattr(item, "file_data", image)), caption=getattr(item, "caption", item.content), reply_markup=current_reply_markup ) elif item.content_type == ContentTypes.FILE: ret = self._bot.send_document(**kwargs, document=(item.file_name, item.file_data), caption=item.caption, reply_markup=current_reply_markup ) sent_idx.add(i) return ret except Exception as e: try: raise RetryException(f"消息 [{i + 1}/{len(boxs)}] 发送失败") from e except NameError: raise def register_commands(self, commands: Dict[str, dict]): """ 注册菜单命令 """ if not self._bot: return # 设置bot命令 if commands: self._bot.delete_my_commands() self._bot.set_my_commands( commands=[ BotCommand(cmd[1:], str(desc.get("description"))) for cmd, desc in commands.items() ] ) def delete_commands(self): """ 清理菜单命令 """ if not self._bot: return # 清理菜单命令 self._bot.delete_my_commands() def stop(self): """ 停止Telegram消息接收服务 """ if self._bot: self._bot.stop_polling() self._polling_thread.join() logger.info("Telegram消息接收服务已停止") ================================================ FILE: app/modules/themoviedb/__init__.py ================================================ import re from typing import Optional, List, Tuple, Union, Dict import cn2an import zhconv from app import schemas from app.core.config import settings from app.core.context import MediaInfo from app.core.meta import MetaBase from app.log import logger from app.modules import _ModuleBase from app.modules.themoviedb.category import CategoryHelper from app.modules.themoviedb.scraper import TmdbScraper from app.modules.themoviedb.tmdb_cache import TmdbCache from app.modules.themoviedb.tmdbapi import TmdbApi from app.schemas.category import CategoryConfig from app.schemas.types import MediaType, MediaImageType, ModuleType, MediaRecognizeType from app.utils.http import RequestUtils class TheMovieDbModule(_ModuleBase): """ TMDB媒体信息匹配 """ CONFIG_WATCH = {"PROXY_HOST", "TMDB_API_DOMAIN", "TMDB_API_KEY", "TMDB_LOCALE"} # 元数据缓存 cache: TmdbCache = None # TMDB tmdb: TmdbApi = None # 二级分类 category: CategoryHelper = None # 刮削器 scraper: TmdbScraper = None def init_module(self) -> None: self.cache = TmdbCache() self.tmdb = TmdbApi() self.category = CategoryHelper() self.scraper = TmdbScraper() def on_config_changed(self): # 停止模块 self.stop() # 初始化模块 self.init_module() @staticmethod def get_name() -> str: return "TheMovieDb" @staticmethod def get_type() -> ModuleType: """ 获取模块类型 """ return ModuleType.MediaRecognize @staticmethod def get_subtype() -> MediaRecognizeType: """ 获取模块子类型 """ return MediaRecognizeType.TMDB @staticmethod def get_priority() -> int: """ 获取模块优先级,数字越小优先级越高,只有同一接口下优先级才生效 """ return 1 def stop(self): self.cache.save() self.tmdb.close() def test(self) -> Tuple[bool, str]: """ 测试模块连接性 """ ret = RequestUtils(ua=settings.NORMAL_USER_AGENT, proxies=settings.PROXY).get_res( f"https://{settings.TMDB_API_DOMAIN}/3/movie/550?api_key={settings.TMDB_API_KEY}") if ret and ret.status_code == 200: return True, "" elif ret: return False, f"无法连接 {settings.TMDB_API_DOMAIN},错误码:{ret.status_code}" return False, f"{settings.TMDB_API_DOMAIN} 网络连接失败" def init_setting(self) -> Tuple[str, Union[str, bool]]: pass @staticmethod def _validate_recognize_params(meta: MetaBase, tmdbid: Optional[int]) -> bool: """ 验证识别参数 """ if not tmdbid and not meta: return False if meta and not tmdbid and settings.RECOGNIZE_SOURCE != "themoviedb": return False if meta and not meta.name: logger.warn("识别媒体信息时未提供元数据名称") return False return True @staticmethod def _prepare_search_names(meta: MetaBase) -> List[str]: """ 准备搜索名称列表 """ # 简体名称 zh_name = zhconv.convert(meta.cn_name, "zh-hans") if meta.cn_name else None # 使用中英文名分别识别,去重去空,但要保持顺序 return list(dict.fromkeys([k for k in [meta.cn_name, zh_name, meta.en_name] if k])) def _search_by_name(self, name: str, meta: MetaBase, group_seasons: List[dict]) -> dict: """ 根据名称搜索媒体信息 """ if meta.begin_season: logger.info(f"正在识别 {name} 第{meta.begin_season}季 ...") else: logger.info(f"正在识别 {name} ...") if meta.type == MediaType.UNKNOWN and not meta.year: return self.tmdb.match_multi(name) else: if meta.type == MediaType.TV: # 确定是电视 info = self.tmdb.match(name=name, year=meta.year, mtype=meta.type, season_year=meta.year, season_number=meta.begin_season, group_seasons=group_seasons) if not info: # 去掉年份再查一次 info = self.tmdb.match(name=name, mtype=meta.type) return info else: # 有年份先按电影查 info = self.tmdb.match(name=name, year=meta.year, mtype=MediaType.MOVIE) # 没有再按电视剧查 if not info: info = self.tmdb.match(name=name, year=meta.year, mtype=MediaType.TV, group_seasons=group_seasons) if not info: # 去掉年份和类型再查一次 info = self.tmdb.match_multi(name=name) return info async def _async_search_by_name(self, name: str, meta: MetaBase, group_seasons: List[dict]) -> dict: """ 根据名称搜索媒体信息(异步版本) """ if meta.begin_season: logger.info(f"正在识别 {name} 第{meta.begin_season}季 ...") else: logger.info(f"正在识别 {name} ...") if meta.type == MediaType.UNKNOWN and not meta.year: return await self.tmdb.async_match_multi(name) else: if meta.type == MediaType.TV: # 确定是电视 info = await self.tmdb.async_match(name=name, year=meta.year, mtype=meta.type, season_year=meta.year, season_number=meta.begin_season, group_seasons=group_seasons) if not info: # 去掉年份再查一次 info = await self.tmdb.async_match(name=name, mtype=meta.type) return info else: # 有年份先按电影查 info = await self.tmdb.async_match(name=name, year=meta.year, mtype=MediaType.MOVIE) # 没有再按电视剧查 if not info: info = await self.tmdb.async_match(name=name, year=meta.year, mtype=MediaType.TV, group_seasons=group_seasons) if not info: # 去掉年份和类型再查一次 info = await self.tmdb.async_match_multi(name=name) return info def _process_episode_groups(self, mediainfo: MediaInfo, episode_group: Optional[str], group_seasons: List[dict]) -> MediaInfo: """ 处理剧集组信息 """ if mediainfo.type == MediaType.TV and mediainfo.episode_groups: if group_seasons: # 指定剧集组时 seasons = {} season_info = [] season_years = {} for group_season in group_seasons: # 季 season = group_season.get("order") # 集列表 episodes = group_season.get("episodes") if not episodes: continue seasons[season] = [ep.get("episode_number") for ep in episodes] season_info.append(group_season) # 当前季第一季时间 first_date = episodes[0].get("air_date") if re.match(r"^\d{4}-\d{2}-\d{2}$", first_date): season_years[season] = str(first_date).split("-")[0] # 每季集清单 if seasons: mediainfo.seasons = seasons mediainfo.number_of_seasons = len(seasons) # 每季集详情 if season_info: mediainfo.season_info = season_info # 每季年份 if season_years: mediainfo.season_years = season_years # 所有剧集组 mediainfo.episode_group = episode_group mediainfo.episode_groups = group_seasons else: # 每季年份 season_years = {} for group in mediainfo.episode_groups: if group.get('type') != 6: # 只处理剧集部分 continue group_episodes = self.tmdb.get_tv_group_seasons(group.get('id')) if not group_episodes: continue for group_episode in group_episodes: season = group_episode.get('order') episodes = group_episode.get('episodes') if not episodes: continue # 当前季第一季时间 first_date = episodes[0].get("air_date") # 判断是不是日期格式 if first_date and re.match(r"^\d{4}-\d{2}-\d{2}$", first_date): season_years[season] = str(first_date).split("-")[0] if season_years: mediainfo.season_years = season_years return mediainfo async def _async_process_episode_groups(self, mediainfo: MediaInfo, episode_group: Optional[str], group_seasons: List[dict]) -> MediaInfo: """ 处理剧集组信息(异步版本) """ if mediainfo.type == MediaType.TV and mediainfo.episode_groups: if group_seasons: # 指定剧集组时 seasons = {} season_info = [] season_years = {} for group_season in group_seasons: # 季 season = group_season.get("order") # 集列表 episodes = group_season.get("episodes") if not episodes: continue seasons[season] = [ep.get("episode_number") for ep in episodes] season_info.append(group_season) # 当前季第一季时间 first_date = episodes[0].get("air_date") if re.match(r"^\d{4}-\d{2}-\d{2}$", first_date): season_years[season] = str(first_date).split("-")[0] # 每季集清单 if seasons: mediainfo.seasons = seasons mediainfo.number_of_seasons = len(seasons) # 每季集详情 if season_info: mediainfo.season_info = season_info # 每季年份 if season_years: mediainfo.season_years = season_years # 所有剧集组 mediainfo.episode_group = episode_group mediainfo.episode_groups = group_seasons else: # 每季年份 season_years = {} for group in mediainfo.episode_groups: if group.get('type') != 6: # 只处理剧集部分 continue group_episodes = await self.tmdb.async_get_tv_group_seasons(group.get('id')) if not group_episodes: continue for group_episode in group_episodes: season = group_episode.get('order') episodes = group_episode.get('episodes') if not episodes: continue # 当前季第一季时间 first_date = episodes[0].get("air_date") # 判断是不是日期格式 if first_date and re.match(r"^\d{4}-\d{2}-\d{2}$", first_date): season_years[season] = str(first_date).split("-")[0] if season_years: mediainfo.season_years = season_years return mediainfo def _build_media_info_result(self, info: dict, meta: MetaBase, tmdbid: Optional[int], episode_group: Optional[str], group_seasons: List[dict]) -> MediaInfo: """ 构建MediaInfo结果 """ # 确定二级分类 if info.get('media_type') == MediaType.TV: cat = self.category.get_tv_category(info) else: cat = self.category.get_movie_category(info) # 赋值TMDB信息并返回 mediainfo = MediaInfo(tmdb_info=info) mediainfo.set_category(cat) if meta: logger.info(f"{meta.name} TMDB识别结果:{mediainfo.type.value} " f"{mediainfo.title_year} " f"{mediainfo.tmdb_id}") else: logger.info(f"{tmdbid} TMDB识别结果:{mediainfo.type.value} " f"{mediainfo.title_year}") # 处理剧集组信息 return self._process_episode_groups(mediainfo, episode_group, group_seasons) async def _async_build_media_info_result(self, info: dict, meta: MetaBase, tmdbid: Optional[int], episode_group: Optional[str], group_seasons: List[dict]) -> MediaInfo: """ 构建MediaInfo结果(异步版本) """ # 确定二级分类 if info.get('media_type') == MediaType.TV: cat = self.category.get_tv_category(info) else: cat = self.category.get_movie_category(info) # 赋值TMDB信息并返回 mediainfo = MediaInfo(tmdb_info=info) mediainfo.set_category(cat) if meta: logger.info(f"{meta.name} TMDB识别结果:{mediainfo.type.value} " f"{mediainfo.title_year} " f"{mediainfo.tmdb_id}") else: logger.info(f"{tmdbid} TMDB识别结果:{mediainfo.type.value} " f"{mediainfo.title_year}") # 处理剧集组信息 return await self._async_process_episode_groups(mediainfo, episode_group, group_seasons) def recognize_media(self, meta: MetaBase = None, mtype: MediaType = None, tmdbid: Optional[int] = None, episode_group: Optional[str] = None, cache: Optional[bool] = True, **kwargs) -> Optional[MediaInfo]: """ 识别媒体信息 :param meta: 识别的元数据 :param mtype: 识别的媒体类型,与tmdbid配套 :param tmdbid: tmdbid :param episode_group: 剧集组 :param cache: 是否使用缓存 :return: 识别的媒体信息,包括剧集信息 """ # 验证参数 if not self._validate_recognize_params(meta, tmdbid): return None if not meta: # 未提供元数据时,直接使用tmdbid查询,不使用缓存 cache_info = {} else: # 读取缓存 if mtype: meta.type = mtype if tmdbid: meta.tmdbid = tmdbid cache_info = self.cache.get(meta) # 查询剧集组 group_seasons = [] if episode_group: group_seasons = self.tmdb.get_tv_group_seasons(episode_group) # 识别匹配 if not cache_info or not cache: info = None # 缓存没有或者强制不使用缓存 if tmdbid: # 直接查询详情 info = self.tmdb.get_info(mtype=mtype, tmdbid=tmdbid) if not info and meta: # 准备搜索名称 names = self._prepare_search_names(meta) for name in names: info = self._search_by_name(name, meta, group_seasons) if not info: # 从网站查询 info = self.tmdb.match_web(name=name, mtype=meta.type) if info: # 查到就退出 break # 补充全量信息 if info and not info.get("genres"): info = self.tmdb.get_info(mtype=info.get("media_type"), tmdbid=info.get("id")) elif not info: logger.error("识别媒体信息时未提供元数据或唯一且有效的tmdbid") return None # 保存到缓存 if meta: self.cache.update(meta, info) else: # 使用缓存信息 if cache_info.get("title"): logger.info(f"{meta.name} 使用TMDB识别缓存:{cache_info.get('title')}") info = self.tmdb.get_info(mtype=cache_info.get("type"), tmdbid=cache_info.get("id")) else: logger.info(f"{meta.name} 使用TMDB识别缓存:无法识别") info = None if info: return self._build_media_info_result(info, meta, tmdbid, episode_group, group_seasons) else: logger.info(f"{meta.name if meta else tmdbid} 未匹配到TMDB媒体信息") return None async def async_recognize_media(self, meta: MetaBase = None, mtype: MediaType = None, tmdbid: Optional[int] = None, episode_group: Optional[str] = None, cache: Optional[bool] = True, **kwargs) -> Optional[MediaInfo]: """ 识别媒体信息(异步版本) :param meta: 识别的元数据 :param mtype: 识别的媒体类型,与tmdbid配套 :param tmdbid: tmdbid :param episode_group: 剧集组 :param cache: 是否使用缓存 :return: 识别的媒体信息,包括剧集信息 """ # 验证参数 if not self._validate_recognize_params(meta, tmdbid): return None if not meta: # 未提供元数据时,直接使用tmdbid查询,不使用缓存 cache_info = {} else: # 读取缓存 if mtype: meta.type = mtype if tmdbid: meta.tmdbid = tmdbid cache_info = self.cache.get(meta) # 查询剧集组 group_seasons = [] if episode_group: group_seasons = await self.tmdb.async_get_tv_group_seasons(episode_group) # 识别匹配 if not cache_info or not cache: info = None # 缓存没有或者强制不使用缓存 if tmdbid: # 直接查询详情 info = await self.tmdb.async_get_info(mtype=mtype, tmdbid=tmdbid) if not info and meta: # 准备搜索名称 names = self._prepare_search_names(meta) for name in names: info = await self._async_search_by_name(name, meta, group_seasons) if not info: # 从网站查询 info = await self.tmdb.async_match_web(name=name, mtype=meta.type) if info: # 查到就退出 break # 补充全量信息 if info and not info.get("genres"): info = await self.tmdb.async_get_info(mtype=info.get("media_type"), tmdbid=info.get("id")) elif not info: logger.error("识别媒体信息时未提供元数据或唯一且有效的tmdbid") return None # 保存到缓存 if meta: self.cache.update(meta, info) else: # 使用缓存信息 if cache_info.get("title"): logger.info(f"{meta.name} 使用TMDB识别缓存:{cache_info.get('title')}") info = await self.tmdb.async_get_info(mtype=cache_info.get("type"), tmdbid=cache_info.get("id")) else: logger.info(f"{meta.name} 使用TMDB识别缓存:无法识别") info = None if info: return await self._async_build_media_info_result(info, meta, tmdbid, episode_group, group_seasons) else: logger.info(f"{meta.name if meta else tmdbid} 未匹配到TMDB媒体信息") return None def match_tmdbinfo(self, name: str, mtype: MediaType = None, year: Optional[str] = None, season: Optional[int] = None) -> dict: """ 搜索和匹配TMDB信息 :param name: 名称 :param mtype: 类型 :param year: 年份 :param season: 季号 """ # 搜索 logger.info(f"开始使用 名称:{name} 年份:{year} 匹配TMDB信息 ...") info = self.tmdb.match(name=name, year=year, mtype=mtype, season_year=year, season_number=season) if info and not info.get("genres"): info = self.tmdb.get_info(mtype=info.get("media_type"), tmdbid=info.get("id")) return info async def async_match_tmdbinfo(self, name: str, mtype: MediaType = None, year: Optional[str] = None, season: Optional[int] = None) -> dict: """ 异步搜索和匹配TMDB信息 :param name: 名称 :param mtype: 类型 :param year: 年份 :param season: 季号 """ # 搜索 logger.info(f"开始使用 名称:{name} 年份:{year} 匹配TMDB信息 ...") info = await self.tmdb.async_match(name=name, year=year, mtype=mtype, season_year=year, season_number=season) if info and not info.get("genres"): info = await self.tmdb.async_get_info(mtype=info.get("media_type"), tmdbid=info.get("id")) return info def tmdb_info(self, tmdbid: int, mtype: MediaType, season: Optional[int] = None) -> Optional[dict]: """ 获取TMDB信息 :param tmdbid: int :param mtype: 媒体类型 :param season: 季号 :return: TVDB信息 """ if not season: return self.tmdb.get_info(mtype=mtype, tmdbid=tmdbid) else: return self.tmdb.get_tv_season_detail(tmdbid=tmdbid, season=season) async def async_tmdb_info(self, tmdbid: int, mtype: MediaType, season: Optional[int] = None) -> Optional[dict]: """ 异步获取TMDB信息 :param tmdbid: int :param mtype: 媒体类型 :param season: 季号 :return: TVDB信息 """ if not season: return await self.tmdb.async_get_info(mtype=mtype, tmdbid=tmdbid) else: return await self.tmdb.async_get_tv_season_detail(tmdbid=tmdbid, season=season) def media_category(self) -> Optional[Dict[str, list]]: """ 获取媒体分类 :return: 获取二级分类配置字典项,需包括电影、电视剧 """ return { MediaType.MOVIE.value: list(self.category.movie_categorys), MediaType.TV.value: list(self.category.tv_categorys) } def search_medias(self, meta: MetaBase) -> Optional[List[MediaInfo]]: """ 搜索媒体信息 :param meta: 识别的元数据 :reutrn: 媒体信息列表 """ if settings.SEARCH_SOURCE and "themoviedb" not in settings.SEARCH_SOURCE: return None if not meta.name: return [] if meta.type == MediaType.UNKNOWN and not meta.year: results = self.tmdb.search_multiis(meta.name) else: if meta.type == MediaType.UNKNOWN: results = self.tmdb.search_movies(meta.name, meta.year) results.extend(self.tmdb.search_tvs(meta.name, meta.year)) # 组合结果的情况下要排序 results = sorted( results, key=lambda x: x.get("release_date") or x.get("first_air_date") or "0000-00-00", reverse=True ) elif meta.type == MediaType.MOVIE: results = self.tmdb.search_movies(meta.name, meta.year) else: results = self.tmdb.search_tvs(meta.name, meta.year) # 将搜索词中的季写入标题中 if results: medias = [MediaInfo(tmdb_info=info) for info in results] if meta.begin_season: # 小写数据转大写 season_str = cn2an.an2cn(meta.begin_season, "low") for media in medias: if media.type == MediaType.TV: media.title = f"{media.title} 第{season_str}季" media.season = meta.begin_season return medias return [] def search_persons(self, name: str) -> Optional[List[schemas.MediaPerson]]: """ 搜索人物信息 """ if settings.SEARCH_SOURCE and "themoviedb" not in settings.SEARCH_SOURCE: return None if not name: return [] results = self.tmdb.search_persons(name) if results: return [schemas.MediaPerson(source='themoviedb', **person) for person in results] return [] async def async_search_persons(self, name: str) -> Optional[List[schemas.MediaPerson]]: """ 异步搜索人物信息 """ if settings.SEARCH_SOURCE and "themoviedb" not in settings.SEARCH_SOURCE: return None if not name: return [] results = await self.tmdb.async_search_persons(name) if results: return [schemas.MediaPerson(source='themoviedb', **person) for person in results] return [] def search_collections(self, name: str) -> Optional[List[MediaInfo]]: """ 搜索集合信息 """ if not name: return [] results = self.tmdb.search_collections(name) if results: return [MediaInfo(tmdb_info=info) for info in results] return [] async def async_search_collections(self, name: str) -> Optional[List[MediaInfo]]: """ 异步搜索集合信息 """ if not name: return [] results = await self.tmdb.async_search_collections(name) if results: return [MediaInfo(tmdb_info=info) for info in results] return [] def tmdb_collection(self, collection_id: int) -> Optional[List[MediaInfo]]: """ 根据合集ID查询集合 :param collection_id: 合集ID """ results = self.tmdb.get_collection(collection_id) if results: return [MediaInfo(tmdb_info=info) for info in results] return [] def metadata_nfo(self, meta: MetaBase, mediainfo: MediaInfo, season: Optional[int] = None, episode: Optional[int] = None) -> Optional[str]: """ 获取NFO文件内容文本 :param meta: 元数据 :param mediainfo: 媒体信息 :param season: 季号 :param episode: 集号 """ if settings.SCRAP_SOURCE != "themoviedb": return None return self.scraper.get_metadata_nfo(meta=meta, mediainfo=mediainfo, season=season, episode=episode) def metadata_img(self, mediainfo: MediaInfo, season: Optional[int] = None, episode: Optional[int] = None) -> Optional[dict]: """ 获取图片名称和url :param mediainfo: 媒体信息 :param season: 季号 :param episode: 集号 """ if settings.SCRAP_SOURCE != "themoviedb": return None return self.scraper.get_metadata_img(mediainfo=mediainfo, season=season, episode=episode) def tmdb_discover(self, mtype: MediaType, sort_by: str, with_genres: str, with_original_language: str, with_keywords: str, with_watch_providers: str, vote_average: float, vote_count: int, release_date: str, page: Optional[int] = 1) -> Optional[List[MediaInfo]]: """ :param mtype: 媒体类型 :param sort_by: 排序方式 :param with_genres: 类型 :param with_original_language: 语言 :param with_keywords: 关键字 :param with_watch_providers: 提供商 :param vote_average: 评分 :param vote_count: 评分人数 :param release_date: 发布日期 :param page: 页码 :return: 媒体信息列表 """ if mtype == MediaType.MOVIE: infos = self.tmdb.discover_movies({ "sort_by": sort_by, "with_genres": with_genres, "with_original_language": with_original_language, "with_keywords": with_keywords, "with_watch_providers": with_watch_providers, "vote_average.gte": vote_average, "vote_count.gte": vote_count, "release_date.gte": release_date, "page": page }) elif mtype == MediaType.TV: infos = self.tmdb.discover_tvs({ "sort_by": sort_by, "with_genres": with_genres, "with_original_language": with_original_language, "with_keywords": with_keywords, "with_watch_providers": with_watch_providers, "vote_average.gte": vote_average, "vote_count.gte": vote_count, "first_air_date.gte": release_date, "page": page }) else: return [] if infos: return [MediaInfo(tmdb_info=info) for info in infos] return [] def tmdb_trending(self, page: Optional[int] = 1) -> List[MediaInfo]: """ TMDB流行趋势 :param page: 第几页 :return: TMDB信息列表 """ trending = self.tmdb.discover_trending(page=page) if trending: return [MediaInfo(tmdb_info=info) for info in trending] return [] def tmdb_seasons(self, tmdbid: int) -> List[schemas.TmdbSeason]: """ 根据TMDBID查询themoviedb所有季信息 :param tmdbid: TMDBID """ tmdb_info = self.tmdb.get_info(tmdbid=tmdbid, mtype=MediaType.TV) if not tmdb_info: return [] return [schemas.TmdbSeason(**sea) for sea in tmdb_info.get("seasons", []) if sea.get("season_number") is not None] def tmdb_group_seasons(self, group_id: str) -> List[schemas.TmdbSeason]: """ 根据剧集组ID查询themoviedb所有季集信息 :param group_id: 剧集组ID """ group_seasons = self.tmdb.get_tv_group_seasons(group_id) if not group_seasons: return [] return [schemas.TmdbSeason( season_number=sea.get("order"), name=sea.get("name"), episode_count=len(sea.get("episodes") or []), air_date=sea.get("episodes")[0].get("air_date") if sea.get("episodes") else None, ) for sea in group_seasons] def tmdb_episodes(self, tmdbid: int, season: int, episode_group: Optional[str] = None) -> List[schemas.TmdbEpisode]: """ 根据TMDBID查询某季的所有集信息 :param tmdbid: TMDBID :param season: 季 :param episode_group: 剧集组 """ if episode_group: season_info = self.tmdb.get_tv_group_detail(episode_group, season=season) else: season_info = self.tmdb.get_tv_season_detail(tmdbid=tmdbid, season=season) if not season_info or not season_info.get("episodes"): return [] return [schemas.TmdbEpisode(**episode) for episode in season_info.get("episodes")] def scheduler_job(self) -> None: """ 定时任务,每10分钟调用一次 """ self.cache.save() @staticmethod def _validate_obtain_images_params(mediainfo: MediaInfo) -> Optional[MediaInfo]: """ 验证 obtain_images 参数 :param mediainfo: 媒体信息 :return: None 表示不处理,MediaInfo 表示继续处理 """ if settings.RECOGNIZE_SOURCE != "themoviedb": return None if not mediainfo.tmdb_id: return mediainfo if mediainfo.logo_path \ and mediainfo.poster_path \ and mediainfo.backdrop_path: # 没有图片缺失 return mediainfo return None @staticmethod def _process_tmdb_images(mediainfo: MediaInfo, images: dict) -> MediaInfo: """ 处理 TMDB 图片数据 :param mediainfo: 媒体信息 :param images: 图片数据 :return: 更新后的媒体信息 """ if isinstance(images, list): images = images[0] # 背景图 if not mediainfo.backdrop_path: backdrops = images.get("backdrops") if backdrops: backdrops = sorted(backdrops, key=lambda x: x.get("vote_average"), reverse=True) mediainfo.backdrop_path = settings.TMDB_IMAGE_URL(backdrops[0].get("file_path")) # 标志 if not mediainfo.logo_path: logos = images.get("logos") if logos: logos = sorted(logos, key=lambda x: x.get("vote_average"), reverse=True) mediainfo.logo_path = settings.TMDB_IMAGE_URL(logos[0].get("file_path")) # 海报 if not mediainfo.poster_path: posters = images.get("posters") if posters: posters = sorted(posters, key=lambda x: x.get("vote_average"), reverse=True) mediainfo.poster_path = settings.TMDB_IMAGE_URL(posters[0].get("file_path")) return mediainfo def obtain_images(self, mediainfo: MediaInfo) -> Optional[MediaInfo]: """ 补充抓取媒体信息图片 :param mediainfo: 识别的媒体信息 :return: 更新后的媒体信息 """ # 验证参数 result = self._validate_obtain_images_params(mediainfo) if result is not None: return result # 调用TMDB图片接口 if mediainfo.type == MediaType.MOVIE: images = self.tmdb.get_movie_images(mediainfo.tmdb_id) else: images = self.tmdb.get_tv_images(mediainfo.tmdb_id) if not images: return mediainfo # 处理图片数据 return self._process_tmdb_images(mediainfo, images) async def async_obtain_images(self, mediainfo: MediaInfo) -> Optional[MediaInfo]: """ 补充抓取媒体信息图片(异步版本) :param mediainfo: 识别的媒体信息 :return: 更新后的媒体信息 """ # 验证参数 result = self._validate_obtain_images_params(mediainfo) if result is not None: return result # 调用TMDB图片接口 if mediainfo.type == MediaType.MOVIE: images = await self.tmdb.async_get_movie_images(mediainfo.tmdb_id) else: images = await self.tmdb.async_get_tv_images(mediainfo.tmdb_id) if not images: return mediainfo # 处理图片数据 return self._process_tmdb_images(mediainfo, images) def obtain_specific_image(self, mediaid: Union[str, int], mtype: MediaType, image_type: MediaImageType, image_prefix: Optional[str] = "w500", season: Optional[int] = None, episode: Optional[int] = None) -> Optional[str]: """ 获取指定媒体信息图片,返回图片地址 :param mediaid: 媒体ID :param mtype: 媒体类型 :param image_type: 图片类型 :param image_prefix: 图片前缀 :param season: 季 :param episode: 集 """ if not str(mediaid).isdigit(): return None # 图片相对路径 image_path = None image_prefix = image_prefix or "w500" if season is None and not episode: tmdbinfo = self.tmdb.get_info(mtype=mtype, tmdbid=int(mediaid)) if tmdbinfo: image_path = tmdbinfo.get(image_type.value) elif season is not None and episode: episodeinfo = self.tmdb.get_tv_episode_detail(tmdbid=int(mediaid), season=season, episode=episode) if episodeinfo: image_path = episodeinfo.get("still_path") elif season is not None: seasoninfo = self.tmdb.get_tv_season_detail(tmdbid=int(mediaid), season=season) if seasoninfo: image_path = seasoninfo.get(image_type.value) if image_path: return settings.TMDB_IMAGE_URL(image_path, image_prefix) return None def tmdb_movie_similar(self, tmdbid: int) -> List[MediaInfo]: """ 根据TMDBID查询类似电影 :param tmdbid: TMDBID """ similar = self.tmdb.get_movie_similar(tmdbid=tmdbid) if similar: return [MediaInfo(tmdb_info=info) for info in similar] return [] def tmdb_tv_similar(self, tmdbid: int) -> List[MediaInfo]: """ 根据TMDBID查询类似电视剧 :param tmdbid: TMDBID """ similar = self.tmdb.get_tv_similar(tmdbid=tmdbid) if similar: return [MediaInfo(tmdb_info=info) for info in similar] return [] def tmdb_movie_recommend(self, tmdbid: int) -> List[MediaInfo]: """ 根据TMDBID查询推荐电影 :param tmdbid: TMDBID """ recommend = self.tmdb.get_movie_recommend(tmdbid=tmdbid) if recommend: return [MediaInfo(tmdb_info=info) for info in recommend] return [] def tmdb_tv_recommend(self, tmdbid: int) -> List[MediaInfo]: """ 根据TMDBID查询推荐电视剧 :param tmdbid: TMDBID """ recommend = self.tmdb.get_tv_recommend(tmdbid=tmdbid) if recommend: return [MediaInfo(tmdb_info=info) for info in recommend] return [] def tmdb_movie_credits(self, tmdbid: int, page: Optional[int] = 1) -> List[schemas.MediaPerson]: """ 根据TMDBID查询电影演职员表 :param tmdbid: TMDBID :param page: 页码 """ credit_infos = self.tmdb.get_movie_credits(tmdbid=tmdbid, page=page) if credit_infos: return [schemas.MediaPerson(source="themoviedb", **info) for info in credit_infos] return [] def tmdb_tv_credits(self, tmdbid: int, page: Optional[int] = 1) -> List[schemas.MediaPerson]: """ 根据TMDBID查询电视剧演职员表 :param tmdbid: TMDBID :param page: 页码 """ credit_infos = self.tmdb.get_tv_credits(tmdbid=tmdbid, page=page) if credit_infos: return [schemas.MediaPerson(source="themoviedb", **info) for info in credit_infos] return [] def tmdb_person_detail(self, person_id: int) -> schemas.MediaPerson: """ 根据TMDBID查询人物详情 :param person_id: 人物ID """ detail = self.tmdb.get_person_detail(person_id=person_id) if detail: return schemas.MediaPerson(source="themoviedb", **detail) return schemas.MediaPerson() def tmdb_person_credits(self, person_id: int, page: Optional[int] = 1) -> List[MediaInfo]: """ 根据TMDBID查询人物参演作品 :param person_id: 人物ID :param page: 页码 """ infos = self.tmdb.get_person_credits(person_id=person_id, page=page) if infos: return [MediaInfo(tmdb_info=tmdbinfo) for tmdbinfo in infos] return [] # 异步方法 async def async_search_medias(self, meta: MetaBase) -> Optional[List[MediaInfo]]: """ 搜索媒体信息(异步版本) :param meta: 识别的元数据 :reutrn: 媒体信息列表 """ if settings.SEARCH_SOURCE and "themoviedb" not in settings.SEARCH_SOURCE: return None if not meta.name: return [] if meta.type == MediaType.UNKNOWN and not meta.year: results = await self.tmdb.async_search_multiis(meta.name) else: if meta.type == MediaType.UNKNOWN: results = await self.tmdb.async_search_movies(meta.name, meta.year) results.extend(await self.tmdb.async_search_tvs(meta.name, meta.year)) # 组合结果的情况下要排序 results = sorted( results, key=lambda x: x.get("release_date") or x.get("first_air_date") or "0000-00-00", reverse=True ) elif meta.type == MediaType.MOVIE: results = await self.tmdb.async_search_movies(meta.name, meta.year) else: results = await self.tmdb.async_search_tvs(meta.name, meta.year) # 将搜索词中的季写入标题中 if results: medias = [MediaInfo(tmdb_info=info) for info in results] if meta.begin_season: # 小写数据转大写 season_str = cn2an.an2cn(meta.begin_season, "low") for media in medias: if media.type == MediaType.TV: media.title = f"{media.title} 第{season_str}季" media.season = meta.begin_season return medias return [] async def async_tmdb_discover(self, mtype: MediaType, sort_by: str, with_genres: str, with_original_language: str, with_keywords: str, with_watch_providers: str, vote_average: float, vote_count: int, release_date: str, page: Optional[int] = 1) -> Optional[List[MediaInfo]]: """ TMDB发现功能(异步版本) :param mtype: 媒体类型 :param sort_by: 排序方式 :param with_genres: 类型 :param with_original_language: 语言 :param with_keywords: 关键字 :param with_watch_providers: 提供商 :param vote_average: 评分 :param vote_count: 评分人数 :param release_date: 发布日期 :param page: 页码 :return: 媒体信息列表 """ if mtype == MediaType.MOVIE: infos = await self.tmdb.async_discover_movies({ "sort_by": sort_by, "with_genres": with_genres, "with_original_language": with_original_language, "with_keywords": with_keywords, "with_watch_providers": with_watch_providers, "vote_average.gte": vote_average, "vote_count.gte": vote_count, "release_date.gte": release_date, "page": page }) elif mtype == MediaType.TV: infos = await self.tmdb.async_discover_tvs({ "sort_by": sort_by, "with_genres": with_genres, "with_original_language": with_original_language, "with_keywords": with_keywords, "with_watch_providers": with_watch_providers, "vote_average.gte": vote_average, "vote_count.gte": vote_count, "first_air_date.gte": release_date, "page": page }) else: return [] if infos: return [MediaInfo(tmdb_info=info) for info in infos] return [] async def async_tmdb_trending(self, page: Optional[int] = 1) -> List[MediaInfo]: """ TMDB流行趋势(异步版本) :param page: 第几页 :return: TMDB信息列表 """ trending = await self.tmdb.async_discover_trending(page=page) if trending: return [MediaInfo(tmdb_info=info) for info in trending] return [] async def async_tmdb_collection(self, collection_id: int) -> Optional[List[MediaInfo]]: """ 根据合集ID查询集合(异步版本) :param collection_id: 合集ID """ results = await self.tmdb.async_get_collection(collection_id) if results: return [MediaInfo(tmdb_info=info) for info in results] return [] async def async_tmdb_seasons(self, tmdbid: int) -> List[schemas.TmdbSeason]: """ 根据TMDBID查询themoviedb所有季信息(异步版本) :param tmdbid: TMDBID """ tmdb_info = await self.tmdb.async_get_info(tmdbid=tmdbid, mtype=MediaType.TV) if not tmdb_info: return [] return [schemas.TmdbSeason(**sea) for sea in tmdb_info.get("seasons", []) if sea.get("season_number") is not None] async def async_tmdb_group_seasons(self, group_id: str) -> List[schemas.TmdbSeason]: """ 根据剧集组ID查询themoviedb所有季集信息(异步版本) :param group_id: 剧集组ID """ group_seasons = await self.tmdb.async_get_tv_group_seasons(group_id) if not group_seasons: return [] return [schemas.TmdbSeason( season_number=sea.get("order"), name=sea.get("name"), episode_count=len(sea.get("episodes") or []), air_date=sea.get("episodes")[0].get("air_date") if sea.get("episodes") else None, ) for sea in group_seasons] async def async_tmdb_episodes(self, tmdbid: int, season: int, episode_group: Optional[str] = None) -> List[schemas.TmdbEpisode]: """ 根据TMDBID查询某季的所有集信息(异步版本) :param tmdbid: TMDBID :param season: 季 :param episode_group: 剧集组 """ if episode_group: season_info = await self.tmdb.async_get_tv_group_detail(episode_group, season=season) else: season_info = await self.tmdb.async_get_tv_season_detail(tmdbid=tmdbid, season=season) if not season_info or not season_info.get("episodes"): return [] return [schemas.TmdbEpisode(**episode) for episode in season_info.get("episodes")] async def async_tmdb_movie_similar(self, tmdbid: int) -> List[MediaInfo]: """ 根据TMDBID查询类似电影(异步版本) :param tmdbid: TMDBID """ similar = await self.tmdb.async_get_movie_similar(tmdbid=tmdbid) if similar: return [MediaInfo(tmdb_info=info) for info in similar] return [] async def async_tmdb_tv_similar(self, tmdbid: int) -> List[MediaInfo]: """ 根据TMDBID查询类似电视剧(异步版本) :param tmdbid: TMDBID """ similar = await self.tmdb.async_get_tv_similar(tmdbid=tmdbid) if similar: return [MediaInfo(tmdb_info=info) for info in similar] return [] async def async_tmdb_movie_recommend(self, tmdbid: int) -> List[MediaInfo]: """ 根据TMDBID查询推荐电影(异步版本) :param tmdbid: TMDBID """ recommend = await self.tmdb.async_get_movie_recommend(tmdbid=tmdbid) if recommend: return [MediaInfo(tmdb_info=info) for info in recommend] return [] async def async_tmdb_tv_recommend(self, tmdbid: int) -> List[MediaInfo]: """ 根据TMDBID查询推荐电视剧(异步版本) :param tmdbid: TMDBID """ recommend = await self.tmdb.async_get_tv_recommend(tmdbid=tmdbid) if recommend: return [MediaInfo(tmdb_info=info) for info in recommend] return [] async def async_tmdb_movie_credits(self, tmdbid: int, page: Optional[int] = 1) -> List[schemas.MediaPerson]: """ 根据TMDBID查询电影演职员表(异步版本) :param tmdbid: TMDBID :param page: 页码 """ credit_infos = await self.tmdb.async_get_movie_credits(tmdbid=tmdbid, page=page) if credit_infos: return [schemas.MediaPerson(source="themoviedb", **info) for info in credit_infos] return [] async def async_tmdb_tv_credits(self, tmdbid: int, page: Optional[int] = 1) -> List[schemas.MediaPerson]: """ 根据TMDBID查询电视剧演职员表(异步版本) :param tmdbid: TMDBID :param page: 页码 """ credit_infos = await self.tmdb.async_get_tv_credits(tmdbid=tmdbid, page=page) if credit_infos: return [schemas.MediaPerson(source="themoviedb", **info) for info in credit_infos] return [] async def async_tmdb_person_detail(self, person_id: int) -> schemas.MediaPerson: """ 根据TMDBID查询人物详情(异步版本) :param person_id: 人物ID """ detail = await self.tmdb.async_get_person_detail(person_id=person_id) if detail: return schemas.MediaPerson(source="themoviedb", **detail) return schemas.MediaPerson() async def async_tmdb_person_credits(self, person_id: int, page: Optional[int] = 1) -> List[MediaInfo]: """ 根据TMDBID查询人物参演作品(异步版本) :param person_id: 人物ID :param page: 页码 """ infos = await self.tmdb.async_get_person_credits(person_id=person_id, page=page) if infos: return [MediaInfo(tmdb_info=tmdbinfo) for tmdbinfo in infos] return [] def clear_cache(self): """ 清除缓存 """ logger.info("开始清除TMDB缓存 ...") self.tmdb.clear_cache() self.cache.clear() logger.info("TMDB缓存清除完成") def load_category_config(self) -> CategoryConfig: """ 加载分类配置 """ return self.category.load() def save_category_config(self, config: CategoryConfig) -> bool: """ 保存分类配置 """ return self.category.save(config) ================================================ FILE: app/modules/themoviedb/category.py ================================================ import shutil from pathlib import Path from typing import Union import ruamel.yaml from ruamel.yaml import CommentedMap from app.core.config import settings from app.log import logger from app.schemas.category import CategoryConfig from app.utils.singleton import WeakSingleton HEADER_COMMENTS = """####### 配置说明 ####### # 1. 该配置文件用于配置电影和电视剧的分类策略,配置后程序会按照配置的分类策略名称进行分类,配置文件采用yaml格式,需要严格符合语法规则 # 2. 配置文件中的一级分类名称:`movie`、`tv` 为固定名称不可修改,二级名称同时也是目录名称,会按先后顺序匹配,匹配后程序会按这个名称建立二级目录 # 3. 支持的分类条件: # `original_language` 语种,具体含义参考下方字典 # `production_countries` 国家或地区(电影)、`origin_country` 国家或地区(电视剧),具体含义参考下方字典 # `genre_ids` 内容类型,具体含义参考下方字典 # `release_year` 发行年份,格式:YYYY,电影实际对应`release_date`字段,电视剧实际对应`first_air_date`字段,支持范围设定,如:`YYYY-YYYY` # themoviedb 详情API返回的其它一级字段 # 4. 配置多项条件时需要同时满足,一个条件需要匹配多个值是使用`,`分隔 # 5. !条件值表示排除该值 """ class CategoryHelper(metaclass=WeakSingleton): """ 二级分类 """ def __init__(self): self._category_path: Path = settings.CONFIG_PATH / "category.yaml" self._categorys = {} self._movie_categorys = {} self._tv_categorys = {} self.init() def init(self): """ 初始化 """ try: if not self._category_path.exists(): shutil.copy(settings.INNER_CONFIG_PATH / "category.yaml", self._category_path) with open(self._category_path, mode='r', encoding='utf-8') as f: try: yaml_loader = ruamel.yaml.YAML() self._categorys = yaml_loader.load(f) except Exception as e: logger.warn(f"二级分类策略配置文件格式出现严重错误!请检查:{str(e)}") self._categorys = {} except Exception as err: logger.warn(f"二级分类策略配置文件加载出错:{str(err)}") if self._categorys: self._movie_categorys = self._categorys.get('movie') self._tv_categorys = self._categorys.get('tv') logger.info(f"已加载二级分类策略 category.yaml") def load(self) -> CategoryConfig: """ 加载配置 """ config = CategoryConfig() if not self._category_path.exists(): return config try: with open(self._category_path, 'r', encoding='utf-8') as f: yaml_loader = ruamel.yaml.YAML() data = yaml_loader.load(f) if data: config = CategoryConfig(**data) except Exception as e: logger.error(f"Load category config failed: {e}") return config def save(self, config: CategoryConfig) -> bool: """ 保存配置 """ data = config.model_dump(exclude_none=True) try: with open(self._category_path, 'w', encoding='utf-8') as f: f.write(HEADER_COMMENTS) yaml_dumper = ruamel.yaml.YAML() yaml_dumper.dump(data, f) # 保存后重新加载配置 self.init() return True except Exception as e: logger.error(f"Save category config failed: {e}") return False @property def is_movie_category(self) -> bool: """ 获取电影分类标志 """ if self._movie_categorys: return True return False @property def is_tv_category(self) -> bool: """ 获取电视剧分类标志 """ if self._tv_categorys: return True return False @property def movie_categorys(self) -> list: """ 获取电影分类清单 """ if not self._movie_categorys: return [] return list(self._movie_categorys.keys()) @property def tv_categorys(self) -> list: """ 获取电视剧分类清单 """ if not self._tv_categorys: return [] return list(self._tv_categorys.keys()) def get_movie_category(self, tmdb_info) -> str: """ 判断电影的分类 :param tmdb_info: 识别的TMDB中的信息 :return: 二级分类的名称 """ return self.get_category(self._movie_categorys, tmdb_info) def get_tv_category(self, tmdb_info) -> str: """ 判断电视剧的分类,包括动漫 :param tmdb_info: 识别的TMDB中的信息 :return: 二级分类的名称 """ return self.get_category(self._tv_categorys, tmdb_info) @staticmethod def get_category(categorys: Union[dict, CommentedMap], tmdb_info: dict) -> str: """ 根据 TMDB信息与分类配置文件进行比较,确定所属分类 :param categorys: 分类配置 :param tmdb_info: TMDB信息 :return: 分类的名称 """ if not tmdb_info: return "" if not categorys: return "" for key, item in categorys.items(): if not item: return key match_flag = True for attr, value in item.items(): if not value: continue if attr == "release_year": # 发行年份 info_value = tmdb_info.get("release_date") or tmdb_info.get("first_air_date") if info_value: info_value = str(info_value)[:4] else: info_value = tmdb_info.get(attr) if not info_value: match_flag = False continue elif attr == "production_countries": # 制片国家 info_values = [str(val.get("iso_3166_1")).upper() for val in info_value] # type: ignore else: if isinstance(info_value, list): info_values = [str(val).upper() for val in info_value] else: info_values = [str(info_value).upper()] values = [] invert_values = [] # 如果有 "," 进行分割 values = [str(val) for val in value.split(",") if val] expanded_values = [] for v in values: if "-" not in v: expanded_values.append(v) continue # - 表示范围 value_begin, value_end = v.split("-", 1) prefix = "" if value_begin.startswith('!'): prefix = '!' value_begin = value_begin[1:] if value_begin.isdigit() and value_end.isdigit(): # 数字范围 expanded_values.extend(f"{prefix}{val}" for val in range(int(value_begin), int(value_end) + 1)) else: # 字符串范围 expanded_values.extend([f"{prefix}{value_begin}", f"{prefix}{value_end}"]) values = list(map(str.upper, expanded_values)) invert_values = [val[1:] for val in values if val.startswith('!')] values = [val for val in values if not val.startswith('!')] if values and not set(values).intersection(set(info_values)): match_flag = False if invert_values and set(invert_values).intersection(set(info_values)): match_flag = False if match_flag: return key return "" ================================================ FILE: app/modules/themoviedb/scraper.py ================================================ from pathlib import Path from typing import Optional, Tuple from xml.dom import minidom from app.core.config import settings from app.core.context import MediaInfo from app.core.meta import MetaBase from app.schemas.types import MediaType from app.utils.dom import DomUtils from app.modules.themoviedb.tmdbapi import TmdbApi class TmdbScraper: _meta_tmdb = None _img_tmdb = None @property def default_tmdb(self): """ 获取元数据TMDB Api """ if not self._meta_tmdb: self._meta_tmdb = TmdbApi(language=settings.TMDB_LOCALE) return self._meta_tmdb def original_tmdb(self, mediainfo: Optional[MediaInfo] = None): """ 获取图片TMDB Api """ if settings.TMDB_SCRAP_ORIGINAL_IMAGE and mediainfo: return TmdbApi(language=mediainfo.original_language) return self.default_tmdb def get_metadata_nfo(self, meta: MetaBase, mediainfo: MediaInfo, season: Optional[int] = None, episode: Optional[int] = None) -> Optional[str]: """ 获取NFO文件内容文本 :param meta: 元数据 :param mediainfo: 媒体信息 :param season: 季号 :param episode: 集号 """ if mediainfo.type == MediaType.MOVIE: # 电影元数据文件 doc = self.__gen_movie_nfo_file(mediainfo=mediainfo) else: if season is not None: # 查询季信息 if mediainfo.episode_group: seasoninfo = self.default_tmdb.get_tv_group_detail(mediainfo.episode_group, season=season) else: seasoninfo = self.default_tmdb.get_tv_season_detail(mediainfo.tmdb_id, season=season) if episode: # 集元数据文件 episodeinfo = self.__get_episode_detail(seasoninfo, meta.begin_episode) doc = self.__gen_tv_episode_nfo_file(episodeinfo=episodeinfo, tmdbid=mediainfo.tmdb_id, season=season, episode=episode) else: # 季元数据文件 doc = self.__gen_tv_season_nfo_file(seasoninfo=seasoninfo, season=season) else: # 电视剧元数据文件 doc = self.__gen_tv_nfo_file(mediainfo=mediainfo) if doc: return doc.toprettyxml(indent=" ", encoding="utf-8") # noqa return None def get_metadata_img(self, mediainfo: MediaInfo, season: Optional[int] = None, episode: Optional[int] = None) -> dict: """ 获取图片名称和url :param mediainfo: 媒体信息 :param season: 季号 :param episode: 集号 """ images = {} if season is not None: # 只需要季集的图片 if episode: # 集的图片 if mediainfo.episode_group: seasoninfo = self.original_tmdb(mediainfo).get_tv_group_detail(mediainfo.episode_group, season) else: seasoninfo = self.original_tmdb(mediainfo).get_tv_season_detail(mediainfo.tmdb_id, season) if seasoninfo: episodeinfo = self.__get_episode_detail(seasoninfo, episode) if still_path := episodeinfo.get("still_path"): # TMDB集still图片 still_name = f"{episode}" still_url = settings.TMDB_IMAGE_URL(still_path) images[still_name] = still_url else: # 季的图片 seasoninfo = self.original_tmdb(mediainfo).get_tv_season_detail(mediainfo.tmdb_id, season) if seasoninfo: # TMDB季poster图片 poster_name, poster_url = self.get_season_poster(seasoninfo, season) if poster_name and poster_url: images[poster_name] = poster_url return images else: # 获取媒体信息中原有图片(TheMovieDb或Fanart) for attr_name, attr_value in vars(mediainfo).items(): if attr_value \ and attr_name.endswith("_path") \ and attr_value \ and isinstance(attr_value, str) \ and attr_value.startswith("http"): image_name = attr_name.replace("_path", "") + Path(attr_value).suffix images[image_name] = attr_value # 替换原语言Poster if settings.TMDB_SCRAP_ORIGINAL_IMAGE: _mediainfo = self.original_tmdb(mediainfo).get_info(mediainfo.type, mediainfo.tmdb_id) if _mediainfo: for attr_name, attr_value in _mediainfo.items(): if attr_name.endswith("_path") and attr_value is not None: image_url = settings.TMDB_IMAGE_URL(attr_value) image_name = attr_name.replace("_path", "") + Path(image_url).suffix images[image_name] = image_url return images @staticmethod def get_season_poster(seasoninfo: dict, season: int) -> Tuple[str, str]: """ 获取季的海报 """ # TMDB季poster图片 sea_seq = str(season).rjust(2, '0') if poster_path := seasoninfo.get("poster_path"): # 后缀 ext = Path(poster_path).suffix # URL url = settings.TMDB_IMAGE_URL(poster_path) # S0海报格式不同 if season == 0: image_name = f"season-specials-poster{ext}" else: image_name = f"season{sea_seq}-poster{ext}" return image_name, url return "", "" @staticmethod def __get_episode_detail(seasoninfo: dict, episode: int) -> dict: """ 根据季信息获取集的信息 """ for _episode_info in seasoninfo.get("episodes") or []: if _episode_info.get("episode_number") == episode: return _episode_info return {} @staticmethod def __gen_common_nfo(mediainfo: MediaInfo, doc: minidom.Document, root: minidom.Element): """ 生成公共NFO """ # TMDB DomUtils.add_node(doc, root, "tmdbid", mediainfo.tmdb_id or "") uniqueid_tmdb = DomUtils.add_node(doc, root, "uniqueid", mediainfo.tmdb_id or "") uniqueid_tmdb.setAttribute("type", "tmdb") uniqueid_tmdb.setAttribute("default", "true") # TVDB if mediainfo.tvdb_id: DomUtils.add_node(doc, root, "tvdbid", str(mediainfo.tvdb_id)) uniqueid_tvdb = DomUtils.add_node(doc, root, "uniqueid", str(mediainfo.tvdb_id)) uniqueid_tvdb.setAttribute("type", "tvdb") # IMDB if mediainfo.imdb_id: DomUtils.add_node(doc, root, "imdbid", mediainfo.imdb_id) uniqueid_imdb = DomUtils.add_node(doc, root, "uniqueid", mediainfo.imdb_id) uniqueid_imdb.setAttribute("type", "imdb") uniqueid_imdb.setAttribute("default", "true") uniqueid_tmdb.setAttribute("default", "false") # 简介 xplot = DomUtils.add_node(doc, root, "plot") xplot.appendChild(doc.createCDATASection(mediainfo.overview or "")) xoutline = DomUtils.add_node(doc, root, "outline") xoutline.appendChild(doc.createCDATASection(mediainfo.overview or "")) # 导演 for director in mediainfo.directors: xdirector = DomUtils.add_node(doc, root, "director", director.get("name") or "") xdirector.setAttribute("tmdbid", str(director.get("id") or "")) # 演员 for actor in mediainfo.actors: # 获取中文名 xactor = DomUtils.add_node(doc, root, "actor") DomUtils.add_node(doc, xactor, "name", actor.get("name") or "") DomUtils.add_node(doc, xactor, "type", "Actor") DomUtils.add_node(doc, xactor, "role", actor.get("character") or actor.get("role") or "") DomUtils.add_node(doc, xactor, "tmdbid", actor.get("id") or "") if profile_path := actor.get('profile_path'): DomUtils.add_node(doc, xactor, "thumb", settings.TMDB_IMAGE_URL(profile_path)) DomUtils.add_node(doc, xactor, "profile", f"https://www.themoviedb.org/person/{actor.get('id')}") # 风格 genres = mediainfo.genres or [] for genre in genres: DomUtils.add_node(doc, root, "genre", genre.get("name") or "") # 评分 DomUtils.add_node(doc, root, "rating", mediainfo.vote_average or "0") # 内容分级 if content_rating := mediainfo.content_rating: DomUtils.add_node(doc, root, "mpaa", content_rating) return doc def __gen_movie_nfo_file(self, mediainfo: MediaInfo) -> minidom.Document: """ 生成电影的NFO描述文件 :param mediainfo: 识别后的媒体信息 """ # 开始生成XML doc = minidom.Document() root = DomUtils.add_node(doc, doc, "movie") # 公共部分 doc = self.__gen_common_nfo(mediainfo=mediainfo, doc=doc, root=root) # 标题 DomUtils.add_node(doc, root, "title", mediainfo.title or "") DomUtils.add_node(doc, root, "originaltitle", mediainfo.original_title or "") # 发布日期 DomUtils.add_node(doc, root, "premiered", mediainfo.release_date or "") # 年份 DomUtils.add_node(doc, root, "year", mediainfo.year or "") return doc def __gen_tv_nfo_file(self, mediainfo: MediaInfo) -> minidom.Document: """ 生成电视剧的NFO描述文件 :param mediainfo: 媒体信息 """ # 开始生成XML doc = minidom.Document() root = DomUtils.add_node(doc, doc, "tvshow") # 公共部分 doc = self.__gen_common_nfo(mediainfo=mediainfo, doc=doc, root=root) # 标题 DomUtils.add_node(doc, root, "title", mediainfo.title or "") DomUtils.add_node(doc, root, "originaltitle", mediainfo.original_title or "") # 发布日期 DomUtils.add_node(doc, root, "premiered", mediainfo.release_date or "") # 年份 DomUtils.add_node(doc, root, "year", mediainfo.year or "") DomUtils.add_node(doc, root, "season", "-1") DomUtils.add_node(doc, root, "episode", "-1") return doc @staticmethod def __gen_tv_season_nfo_file(seasoninfo: dict, season: int) -> minidom.Document: """ 生成电视剧季的NFO描述文件 :param seasoninfo: TMDB季媒体信息 :param season: 季号 """ doc = minidom.Document() root = DomUtils.add_node(doc, doc, "season") # 简介 xplot = DomUtils.add_node(doc, root, "plot") xplot.appendChild(doc.createCDATASection(seasoninfo.get("overview") or "")) xoutline = DomUtils.add_node(doc, root, "outline") xoutline.appendChild(doc.createCDATASection(seasoninfo.get("overview") or "")) # 标题 DomUtils.add_node(doc, root, "title", seasoninfo.get("name") or "季 %s" % season) # 发行日期 DomUtils.add_node(doc, root, "premiered", seasoninfo.get("air_date") or "") DomUtils.add_node(doc, root, "releasedate", seasoninfo.get("air_date") or "") # 发行年份 DomUtils.add_node(doc, root, "year", seasoninfo.get("air_date")[:4] if seasoninfo.get("air_date") else "") # seasonnumber DomUtils.add_node(doc, root, "seasonnumber", str(season)) return doc @staticmethod def __gen_tv_episode_nfo_file(tmdbid: int, episodeinfo: dict, season: int, episode: int) -> minidom.Document: """ 生成电视剧集的NFO描述文件 :param tmdbid: TMDBID :param episodeinfo: 集TMDB元数据 :param season: 季号 :param episode: 集号 """ # 开始生成集的信息 doc = minidom.Document() root = DomUtils.add_node(doc, doc, "episodedetails") # TMDBID uniqueid = DomUtils.add_node(doc, root, "uniqueid", str(episodeinfo.get("id"))) uniqueid.setAttribute("type", "tmdb") uniqueid.setAttribute("default", "true") # tmdbid # 应与uniqueid一致 使用剧集id 否则jellyfin/emby会将此id覆盖上面的uniqueid DomUtils.add_node(doc, root, "tmdbid", str(episodeinfo.get("id"))) # 标题 DomUtils.add_node(doc, root, "title", episodeinfo.get("name") or "第 %s 集" % episode) # 简介 xplot = DomUtils.add_node(doc, root, "plot") xplot.appendChild(doc.createCDATASection(episodeinfo.get("overview") or "")) xoutline = DomUtils.add_node(doc, root, "outline") xoutline.appendChild(doc.createCDATASection(episodeinfo.get("overview") or "")) # 发布日期 DomUtils.add_node(doc, root, "aired", episodeinfo.get("air_date") or "") # 年份 DomUtils.add_node(doc, root, "year", episodeinfo.get("air_date")[:4] if episodeinfo.get("air_date") else "") # 季 DomUtils.add_node(doc, root, "season", str(season)) # 集 DomUtils.add_node(doc, root, "episode", str(episode)) # 评分 DomUtils.add_node(doc, root, "rating", episodeinfo.get("vote_average") or "0") # 导演 directors = episodeinfo.get("crew") or [] for director in directors: if director.get("known_for_department") == "Directing": xdirector = DomUtils.add_node(doc, root, "director", director.get("name") or "") xdirector.setAttribute("tmdbid", str(director.get("id") or "")) # 演员 actors = episodeinfo.get("guest_stars") or [] for actor in actors: if actor.get("known_for_department") == "Acting": xactor = DomUtils.add_node(doc, root, "actor") DomUtils.add_node(doc, xactor, "name", actor.get("name") or "") DomUtils.add_node(doc, xactor, "type", "Actor") DomUtils.add_node(doc, xactor, "tmdbid", actor.get("id") or "") if profile_path := actor.get('profile_path'): DomUtils.add_node(doc, xactor, "thumb", settings.TMDB_IMAGE_URL(profile_path)) DomUtils.add_node(doc, xactor, "profile", f"https://www.themoviedb.org/person/{actor.get('id')}") return doc ================================================ FILE: app/modules/themoviedb/tmdb_cache.py ================================================ import pickle import traceback from pathlib import Path from threading import RLock from app.core.cache import TTLCache from app.core.config import settings from app.core.meta import MetaBase from app.log import logger from app.schemas.types import MediaType from app.utils.singleton import WeakSingleton lock = RLock() class TmdbCache(metaclass=WeakSingleton): """ TMDB缓存数据 { "id": '', "title": '', "year": '', "type": MediaType } """ # TMDB缓存过期 _tmdb_cache_expire: bool = True def __init__(self): self.maxsize = settings.CONF.douban self.ttl = settings.CONF.meta self.region = "__tmdb_cache__" self._meta_filepath = settings.TEMP_PATH / self.region # 初始化缓存 self._cache = TTLCache(region=self.region, maxsize=self.maxsize, ttl=self.ttl) # 非Redis加载本地缓存数据 if not self._cache.is_redis(): for key, value in self.__load(self._meta_filepath).items(): self._cache.set(key, value) def clear(self): """ 清空所有TMDB缓存 """ with lock: self._cache.clear() @staticmethod def __get_key(meta: MetaBase) -> str: """ 获取缓存KEY """ return f"[{meta.type.value if meta.type else '未知'}][{settings.TMDB_LOCALE}]{meta.tmdbid or meta.name}-{meta.year}-{meta.begin_season}" def get(self, meta: MetaBase): """ 根据KEY值获取缓存值 """ key = self.__get_key(meta) with lock: return self._cache.get(key) or {} def delete(self, key: str) -> dict: """ 删除缓存信息 @param key: 缓存key @return: 被删除的缓存内容 """ with lock: redis_data = self._cache.get(key) if redis_data: self._cache.delete(key) return redis_data return {} def modify(self, key: str, title: str) -> dict: """ 修改缓存信息 @param key: 缓存key @param title: 标题 @return: 被修改后缓存内容 """ with lock: redis_data = self._cache.get(key) if redis_data: redis_data['title'] = title self._cache.set(key, redis_data) return redis_data return {} @staticmethod def __load(path: Path) -> dict: """ 从文件中加载缓存 """ try: if path.exists(): with open(path, 'rb') as f: data = pickle.load(f) return data except Exception as e: logger.error(f'加载缓存失败:{str(e)} - {traceback.format_exc()}') return {} def update(self, meta: MetaBase, info: dict) -> None: """ 新增或更新缓存条目 """ key = self.__get_key(meta) if info: # 缓存标题 cache_title = info.get("title") \ if info.get("media_type") == MediaType.MOVIE else info.get("name") # 缓存年份 cache_year = info.get('release_date') \ if info.get("media_type") == MediaType.MOVIE else info.get('first_air_date') if cache_year: cache_year = cache_year[:4] with lock: # 缓存数据 cache_data = { "id": info.get("id"), "type": info.get("media_type"), "year": cache_year, "title": cache_title, "poster_path": info.get("poster_path"), "backdrop_path": info.get("backdrop_path") } self._cache.set(key, cache_data) elif info is not None: # None时不缓存,此时代表网络错误,允许重复请求 with lock: self._cache.set(key, {"id": 0}) def save(self, force: bool = False) -> None: """ 保存缓存数据到文件 """ # Redis不需要保存到本地文件 if self._cache.is_redis(): return # Redis不可用时,保存到本地文件 meta_data = self.__load(self._meta_filepath) # 当前缓存,去除无法识别 new_meta_data = {k: v for k, v in self._cache.items() if v.get("id")} if not force \ and meta_data.keys() == new_meta_data.keys(): return with open(self._meta_filepath, 'wb') as f: pickle.dump(new_meta_data, f, pickle.HIGHEST_PROTOCOL) # type: ignore def __del__(self): self.save() ================================================ FILE: app/modules/themoviedb/tmdbapi.py ================================================ import re import traceback from typing import Optional, List from urllib.parse import quote import zhconv from lxml import etree from app.core.cache import cached from app.core.config import settings from app.log import logger from app.schemas import APIRateLimitException from app.schemas.types import MediaType from app.utils.http import RequestUtils, AsyncRequestUtils from app.utils.limit import rate_limit_exponential from app.utils.string import StringUtils from .tmdbv3api import TMDb, Search, Movie, TV, Season, Episode, Discover, Trending, Person, Collection from .tmdbv3api.exceptions import TMDbException class TmdbApi: """ TMDB识别匹配 """ def __init__(self, language: Optional[str] = None): # TMDB主体 self.tmdb = TMDb(language=language) # TMDB查询对象 self.search = Search(language=language) self.movie = Movie(language=language) self.tv = TV(language=language) self.season_obj = Season(language=language) self.episode_obj = Episode(language=language) self.discover = Discover(language=language) self.trending = Trending(language=language) self.person = Person(language=language) self.collection = Collection(language=language) def search_multiis(self, title: str) -> List[dict]: """ 同时查询模糊匹配的电影、电视剧TMDB信息 """ if not title: return [] ret_infos = [] multis = self.search.multi(term=title) or [] for multi in multis: if multi.get("media_type") in ["movie", "tv"]: multi['media_type'] = MediaType.MOVIE if multi.get("media_type") == "movie" else MediaType.TV ret_infos.append(multi) return ret_infos def search_movies(self, title: str, year: str) -> List[dict]: """ 查询模糊匹配的所有电影TMDB信息 """ if not title: return [] ret_infos = [] if year: movies = self.search.movies(term=title, year=year) or [] else: movies = self.search.movies(term=title) or [] for movie in movies: if title in movie.get("title"): movie['media_type'] = MediaType.MOVIE ret_infos.append(movie) return ret_infos def search_tvs(self, title: str, year: str) -> List[dict]: """ 查询模糊匹配的所有电视剧TMDB信息 """ if not title: return [] ret_infos = [] if year: tvs = self.search.tv_shows(term=title, release_year=year) or [] else: tvs = self.search.tv_shows(term=title) or [] for tv in tvs: if title in tv.get("name"): tv['media_type'] = MediaType.TV ret_infos.append(tv) return ret_infos def search_persons(self, name: str) -> List[dict]: """ 查询模糊匹配的所有人物TMDB信息 """ if not name: return [] return self.search.people(term=name) or [] def search_collections(self, name: str) -> List[dict]: """ 查询模糊匹配的所有合集TMDB信息 """ if not name: return [] collections = self.search.collections(term=name) or [] for collection in collections: collection['media_type'] = MediaType.COLLECTION collection['collection_id'] = collection.get("id") return collections def get_collection(self, collection_id: int) -> List[dict]: """ 根据合集ID查询合集详情 """ if not collection_id: return [] try: return self.collection.details(collection_id=collection_id) except TMDbException as err: logger.error(f"连接TMDB出错:{str(err)}") except Exception as e: logger.error(f"连接TMDB出错:{str(e)}") return [] @staticmethod def __compare_names(file_name: str, tmdb_names: list) -> bool: """ 比较文件名是否匹配,忽略大小写和特殊字符 :param file_name: 识别的文件名或者种子名 :param tmdb_names: TMDB返回的译名 :return: True or False """ if not file_name or not tmdb_names: return False if not isinstance(tmdb_names, list): tmdb_names = [tmdb_names] file_name = StringUtils.clear(file_name).upper() for tmdb_name in tmdb_names: tmdb_name = StringUtils.clear(tmdb_name).strip().upper() if file_name == tmdb_name: return True return False # 公共方法 @staticmethod def _validate_match_params(name: str, search_obj) -> bool: """ 验证匹配方法的基本参数 """ if not search_obj: return False if not name: return False return True @staticmethod def _generate_year_range(year: Optional[str]) -> List[Optional[str]]: """ 生成年份范围用于匹配 """ year_range = [year] if year: year_range.append(str(int(year) + 1)) year_range.append(str(int(year) - 1)) return year_range @staticmethod def _log_match_debug(mtype: MediaType, name: str, year: Optional[str] = None, season_number: Optional[int] = None, season_year: Optional[str] = None): """ 记录匹配调试日志 """ if season_number is not None and season_year: logger.debug(f"正在识别{mtype.value}:{name}, 季集={season_number}, 季集年份={season_year} ...") else: logger.debug(f"正在识别{mtype.value}:{name}, 年份={year} ...") @staticmethod def _set_media_type(info: dict, mtype: MediaType) -> dict: """ 设置媒体类型 """ if info: info['media_type'] = mtype return info @staticmethod def _sort_multi_results(multis: List[dict]) -> List[dict]: """ 按年份降序排列搜索结果,电影在前面 """ return sorted( multis, key=lambda x: ("1" if x.get("media_type") == "movie" else "0") + (x.get('release_date') or x.get('first_air_date') or '0000-00-00'), reverse=True ) @staticmethod def _convert_media_type(ret_info: dict) -> dict: """ 转换媒体类型为MediaType枚举 """ if (ret_info and not isinstance(ret_info.get("media_type"), MediaType)): ret_info['media_type'] = MediaType.MOVIE if ret_info.get("media_type") == "movie" else MediaType.TV return ret_info def _match_multi_item(self, name: str, multi: dict, get_info_func) -> Optional[dict]: """ 匹配单个多媒体搜索结果项 :param name: 查询名称 :param multi: 搜索结果项 :param get_info_func: 获取详细信息的函数(同步或异步) :return: 匹配的结果或None """ if multi.get("media_type") == "movie": if self.__compare_names(name, multi.get('title')) \ or self.__compare_names(name, multi.get('original_title')): return multi # 匹配别名、译名 if not multi.get("names"): multi = get_info_func(mtype=MediaType.MOVIE, tmdbid=multi.get("id")) if multi and self.__compare_names(name, multi.get("names")): return multi elif multi.get("media_type") == "tv": if self.__compare_names(name, multi.get('name')) \ or self.__compare_names(name, multi.get('original_name')): return multi # 匹配别名、译名 if not multi.get("names"): multi = get_info_func(mtype=MediaType.TV, tmdbid=multi.get("id")) if multi and self.__compare_names(name, multi.get("names")): return multi return None async def _async_match_multi_item(self, name: str, multi: dict) -> Optional[dict]: """ 匹配单个多媒体搜索结果项(异步版本) :param name: 查询名称 :param multi: 搜索结果项 :return: 匹配的结果或None """ if multi.get("media_type") == "movie": if self.__compare_names(name, multi.get('title')) \ or self.__compare_names(name, multi.get('original_title')): return multi # 匹配别名、译名 if not multi.get("names"): multi = await self.async_get_info(mtype=MediaType.MOVIE, tmdbid=multi.get("id")) if multi and self.__compare_names(name, multi.get("names")): return multi elif multi.get("media_type") == "tv": if self.__compare_names(name, multi.get('name')) \ or self.__compare_names(name, multi.get('original_name')): return multi # 匹配别名、译名 if not multi.get("names"): multi = await self.async_get_info(mtype=MediaType.TV, tmdbid=multi.get("id")) if multi and self.__compare_names(name, multi.get("names")): return multi return None # match_web 公共方法 @staticmethod def _validate_web_params(name: str) -> Optional[dict]: """ 验证网站搜索参数 :return: None表示继续,dict表示直接返回结果 """ if not name: return None if StringUtils.is_chinese(name): return {} return None # 继续执行 @staticmethod def _build_tmdb_search_url(name: str) -> str: """ 构建TMDB搜索URL """ return "https://www.themoviedb.org/search?query=%s" % quote(name) @staticmethod def _validate_response(res) -> Optional[dict]: """ 验证HTTP响应 :return: None表示继续,dict表示直接返回结果,Exception表示抛出异常 """ if res is None: return None if res.status_code == 429: raise APIRateLimitException("触发TheDbMovie网站限流,获取媒体信息失败") if res.status_code != 200: return {} return None # 继续执行 @staticmethod def _extract_tmdb_links(html_text: str, mtype: MediaType) -> List[str]: """ 从HTML文本中提取TMDB链接 """ if not html_text: return [] html = None try: tmdb_links = [] html = etree.HTML(html_text) if mtype == MediaType.TV: links = html.xpath("//a[@data-id and @data-media-type='tv']/@href") else: links = html.xpath("//a[@data-id]/@href") for link in links: if not link or (not link.startswith("/tv") and not link.startswith("/movie")): continue if link not in tmdb_links: tmdb_links.append(link) return tmdb_links except Exception as err: logger.error(f"解析TMDB网站HTML出错:{str(err)}") return [] finally: if html is not None: del html @staticmethod def _log_web_search_result(name: str, tmdbinfo: dict): """ 记录网站搜索结果日志 """ if tmdbinfo.get('media_type') == MediaType.MOVIE: logger.info("%s 从WEB识别到 电影:TMDBID=%s, 名称=%s, 上映日期=%s" % ( name, tmdbinfo.get('id'), tmdbinfo.get('title'), tmdbinfo.get('release_date'))) else: logger.info("%s 从WEB识别到 电视剧:TMDBID=%s, 名称=%s, 首播日期=%s" % ( name, tmdbinfo.get('id'), tmdbinfo.get('name'), tmdbinfo.get('first_air_date'))) def _process_web_search_links(self, name: str, mtype: MediaType, tmdb_links: List[str], get_info_func) -> Optional[dict]: """ 处理网站搜索得到的链接 """ if len(tmdb_links) == 1: tmdbid = self._parse_tmdb_id_from_link(tmdb_links[0]) if not tmdbid: logger.warn(f"无法从链接解析TMDBID:{tmdb_links[0]}") return {} tmdbinfo = get_info_func( mtype=MediaType.TV if tmdb_links[0].startswith("/tv") else MediaType.MOVIE, tmdbid=tmdbid) if tmdbinfo: if mtype == MediaType.TV and tmdbinfo.get('media_type') != MediaType.TV: return {} self._log_web_search_result(name, tmdbinfo) return tmdbinfo elif len(tmdb_links) > 1: logger.info("%s TMDB网站返回数据过多:%s" % (name, len(tmdb_links))) else: logger.info("%s TMDB网站未查询到媒体信息!" % name) return {} async def _async_process_web_search_links(self, name: str, mtype: MediaType, tmdb_links: List[str]) -> Optional[dict]: """ 处理网站搜索得到的链接(异步版本) """ if len(tmdb_links) == 1: tmdbid = self._parse_tmdb_id_from_link(tmdb_links[0]) if not tmdbid: logger.warn(f"无法从链接解析TMDBID:{tmdb_links[0]}") return {} tmdbinfo = await self.async_get_info( mtype=MediaType.TV if tmdb_links[0].startswith("/tv") else MediaType.MOVIE, tmdbid=tmdbid) if tmdbinfo: if mtype == MediaType.TV and tmdbinfo.get('media_type') != MediaType.TV: return {} self._log_web_search_result(name, tmdbinfo) return tmdbinfo elif len(tmdb_links) > 1: logger.info("%s TMDB网站返回数据过多:%s" % (name, len(tmdb_links))) else: logger.info("%s TMDB网站未查询到媒体信息!" % name) return {} @staticmethod def _parse_tmdb_id_from_link(link: str) -> Optional[int]: """ 从 TMDB 相对链接中解析数值 ID。 兼容格式:/movie/1195631-william-tell、/tv/65942-re、/tv/79744-the-rookie """ if not link: return None match = re.match(r"^/[^/]+/(\d+)", link) if match: try: return int(match.group(1)) except Exception as err: logger.debug(f"解析TMDBID失败:{str(err)} - {traceback.format_exc()}") return None return None @staticmethod def __get_names(tmdb_info: dict) -> List[str]: """ 搜索tmdb中所有的标题和译名,用于名称匹配 :param tmdb_info: TMDB信息 :return: 所有译名的清单 """ if not tmdb_info: return [] ret_names = [] if tmdb_info.get('media_type') == MediaType.MOVIE: alternative_titles = tmdb_info.get("alternative_titles", {}).get("titles", []) for alternative_title in alternative_titles: title = alternative_title.get("title") if title and title not in ret_names: ret_names.append(title) translations = tmdb_info.get("translations", {}).get("translations", []) for translation in translations: title = translation.get("data", {}).get("title") if title and title not in ret_names: ret_names.append(title) else: alternative_titles = tmdb_info.get("alternative_titles", {}).get("results", []) for alternative_title in alternative_titles: name = alternative_title.get("title") if name and name not in ret_names: ret_names.append(name) translations = tmdb_info.get("translations", {}).get("translations", []) for translation in translations: name = translation.get("data", {}).get("name") if name and name not in ret_names: ret_names.append(name) return ret_names def match(self, name: str, mtype: MediaType, year: Optional[str] = None, season_year: Optional[str] = None, season_number: Optional[int] = None, group_seasons: Optional[List[dict]] = None) -> Optional[dict]: """ 搜索tmdb中的媒体信息,匹配返回一条尽可能正确的信息 :param name: 检索的名称 :param mtype: 类型:电影、电视剧 :param year: 年份,如要是季集需要是首播年份(first_air_date) :param season_year: 当前季集年份 :param season_number: 季集,整数 :param group_seasons: 集数组信息 :return: TMDB的INFO,同时会将mtype赋值到media_type中 """ # 基本参数验证 if not self._validate_match_params(name, self.search): return None # TMDB搜索 info = {} if mtype != MediaType.TV: year_range = self._generate_year_range(year) for search_year in year_range: self._log_match_debug(mtype, name, search_year) info = self.__search_movie_by_name(name, search_year) if info: break info = self._set_media_type(info, MediaType.MOVIE) else: # 有当前季和当前季集年份,使用精确匹配 if season_year and season_number is not None: self._log_match_debug(mtype, name, season_year, season_number, season_year) info = self.__search_tv_by_season(name, season_year, season_number, group_seasons) if not info: year_range = self._generate_year_range(year) for search_year in year_range: self._log_match_debug(mtype, name, search_year) info = self.__search_tv_by_name(name, search_year) if info: break info = self._set_media_type(info, MediaType.TV) return info def __search_movie_by_name(self, name: str, year: str) -> Optional[dict]: """ 根据名称查询电影TMDB匹配 :param name: 识别的文件名或种子名 :param year: 电影上映日期 :return: 匹配的媒体信息 """ try: if year: movies = self.search.movies(term=name, year=year) else: movies = self.search.movies(term=name) except TMDbException as err: logger.error(f"连接TMDB出错:{str(err)}") return None except Exception as e: logger.error(f"连接TMDB出错:{str(e)} - {traceback.format_exc()}") return None logger.debug(f"API返回:{str(self.search.total_results)}") if (movies is None) or (len(movies) == 0): logger.debug(f"{name} 未找到相关电影信息!") return {} else: # 按年份降序排列 movies = sorted( movies, key=lambda x: x.get('release_date') or '0000-00-00', reverse=True ) for movie in movies: # 年份 movie_year = movie.get('release_date')[0:4] if movie.get('release_date') else None if year and movie_year != year: # 年份不匹配 continue # 匹配标题、原标题 if self.__compare_names(name, movie.get('title')): return movie if self.__compare_names(name, movie.get('original_title')): return movie # 匹配别名、译名 if not movie.get("names"): movie = self.get_info(mtype=MediaType.MOVIE, tmdbid=movie.get("id")) if movie and self.__compare_names(name, movie.get("names")): return movie return {} def __search_tv_by_name(self, name: str, year: str) -> Optional[dict]: """ 根据名称查询电视剧TMDB匹配 :param name: 识别的文件名或者种子名 :param year: 电视剧的首播年份 :return: 匹配的媒体信息 """ try: if year: tvs = self.search.tv_shows(term=name, release_year=year) else: tvs = self.search.tv_shows(term=name) except TMDbException as err: logger.error(f"连接TMDB出错:{str(err)}") return None except Exception as e: logger.error(f"连接TMDB出错:{str(e)} - {traceback.format_exc()}") return None logger.debug(f"API返回:{str(self.search.total_results)}") if (tvs is None) or (len(tvs) == 0): logger.debug(f"{name} 未找到相关剧集信息!") return {} else: # 按年份降序排列 tvs = sorted( tvs, key=lambda x: x.get('first_air_date') or '0000-00-00', reverse=True ) for tv in tvs: tv_year = tv.get('first_air_date')[0:4] if tv.get('first_air_date') else None if year and tv_year != year: # 年份不匹配 continue # 匹配标题、原标题 if self.__compare_names(name, tv.get('name')): return tv if self.__compare_names(name, tv.get('original_name')): return tv # 匹配别名、译名 if not tv.get("names"): tv = self.get_info(mtype=MediaType.TV, tmdbid=tv.get("id")) if tv and self.__compare_names(name, tv.get("names")): return tv return {} def __search_tv_by_season(self, name: str, season_year: str, season_number: int, group_seasons: Optional[List[dict]] = None) -> Optional[dict]: """ 根据电视剧的名称和季的年份及序号匹配TMDB :param name: 识别的文件名或者种子名 :param season_year: 季的年份 :param season_number: 季序号 :param group_seasons: 集数组信息 :return: 匹配的媒体信息 """ def __season_match(tv_info: dict, _season_year: str) -> bool: if not tv_info: return False try: if group_seasons: for group_season in group_seasons: season = group_season.get('order') if season != season_number: continue episodes = group_season.get('episodes') if not episodes: continue first_date = episodes[0].get("air_date") if re.match(r"^\d{4}-\d{2}-\d{2}$", first_date): if str(_season_year) == str(first_date).split("-")[0]: return True else: seasons = self.__get_tv_seasons(tv_info) for season, season_info in seasons.items(): if season_info.get("air_date"): if season_info.get("air_date")[0:4] == str(_season_year) \ and season == int(season_number): return True except Exception as e1: logger.error(f"连接TMDB出错:{e1}") print(traceback.format_exc()) return False return False try: tvs = self.search.tv_shows(term=name) except TMDbException as err: logger.error(f"连接TMDB出错:{str(err)}") return None except Exception as e: logger.error(f"连接TMDB出错:{str(e)}") print(traceback.format_exc()) return None if (tvs is None) or (len(tvs) == 0): logger.debug("%s 未找到季%s相关信息!" % (name, season_number)) return {} else: # 按年份降序排列 tvs = sorted( tvs, key=lambda x: x.get('first_air_date') or '0000-00-00', reverse=True ) for tv in tvs: # 使用年份、名称匹配 tv_year = tv.get('first_air_date')[0:4] if tv.get('first_air_date') else None if (self.__compare_names(name, tv.get('name')) or self.__compare_names(name, tv.get('original_name'))) \ and (tv_year == str(season_year)): return tv # 获取别名、译名重新匹配 if not tv.get("names"): tv = self.get_info(mtype=MediaType.TV, tmdbid=tv.get("id")) if not tv or not ( self.__compare_names(name, tv.get("name")) or self.__compare_names(name, tv.get("original_name")) or self.__compare_names(name, tv.get("names"))): continue if tv_year == str(season_year): return tv # 季年份匹配 if __season_match(tv_info=tv, _season_year=season_year): return tv return {} @staticmethod def __get_tv_seasons(tv_info: dict) -> Optional[dict]: """ 查询TMDB电视剧的所有季 :param tv_info: TMDB 的季信息 :return: 包括每季集数的字典 """ """ "seasons": [ { "air_date": "2006-01-08", "episode_count": 11, "id": 3722, "name": "特别篇", "overview": "", "poster_path": "/snQYndfsEr3Sto2jOmkmsQuUXAQ.jpg", "season_number": 0 }, { "air_date": "2005-03-27", "episode_count": 9, "id": 3718, "name": "第 1 季", "overview": "", "poster_path": "/foM4ImvUXPrD2NvtkHyixq5vhPx.jpg", "season_number": 1 } ] """ if not tv_info: return {} ret_seasons = {} for season_info in tv_info.get("seasons") or []: if season_info.get("season_number") is None: continue ret_seasons[season_info.get("season_number")] = season_info return ret_seasons def match_multi(self, name: str) -> Optional[dict]: """ 根据名称同时查询电影和电视剧,没有类型也没有年份时使用 :param name: 识别的文件名或种子名 :return: 匹配的媒体信息 """ try: multis = self.search.multi(term=name) or [] except TMDbException as err: logger.error(f"连接TMDB出错:{str(err)}") return None except Exception as e: logger.error(f"连接TMDB出错:{str(e)}") print(traceback.format_exc()) return None logger.debug(f"API返回:{str(self.search.total_results)}") # 返回结果 if (multis is None) or (len(multis) == 0): logger.debug(f"{name} 未找到相关媒体息!") return {} # 按年份降序排列,电影在前面 multis = self._sort_multi_results(multis) ret_info = {} for multi in multis: matched = self._match_multi_item(name, multi, self.get_info) if matched: ret_info = matched break # 类型变更 return self._convert_media_type(ret_info) @cached(maxsize=settings.CONF.tmdb, ttl=settings.CONF.meta) @rate_limit_exponential(source="match_tmdb_web", base_wait=5, max_wait=1800, enable_logging=True) def match_web(self, name: str, mtype: MediaType) -> Optional[dict]: """ 搜索TMDB网站,直接抓取结果,结果只有一条时才返回 :param name: 名称 :param mtype: 媒体类型 """ # 参数验证 validation_result = self._validate_web_params(name) if validation_result is not None: return validation_result logger.info("正在从TheMovieDb网站查询:%s ..." % name) tmdb_url = self._build_tmdb_search_url(name) res = RequestUtils(timeout=5, ua=settings.NORMAL_USER_AGENT, proxies=settings.PROXY).get_res(url=tmdb_url) if res is None: logger.error("无法连接TheMovieDb") return None # 响应验证 response_result = self._validate_response(res) if response_result is not None: return response_result try: # 提取链接 tmdb_links = self._extract_tmdb_links(res.text, mtype) # 处理结果 return self._process_web_search_links(name, mtype, tmdb_links, self.get_info) except Exception as err: logger.error(f"从TheDbMovie网站查询出错:{str(err)}") return {} def get_info(self, mtype: MediaType, tmdbid: int) -> dict: """ 给定TMDB号,查询一条媒体信息 :param mtype: 类型:电影、电视剧,为空时都查(此时用不上年份) :param tmdbid: TMDB的ID,有tmdbid时优先使用tmdbid,否则使用年份和标题 """ def __get_genre_ids(genres: list) -> list: """ 从TMDB详情中获取genre_id列表 """ if not genres: return [] genre_ids = [] for genre in genres: genre_ids.append(genre.get('id')) return genre_ids # 查询TMDB详情 if mtype == MediaType.MOVIE: tmdb_info = self.__get_movie_detail(tmdbid) if tmdb_info: tmdb_info['media_type'] = MediaType.MOVIE elif mtype == MediaType.TV: tmdb_info = self.__get_tv_detail(tmdbid) if tmdb_info: tmdb_info['media_type'] = MediaType.TV else: tmdb_info_tv = self.__get_tv_detail(tmdbid) tmdb_info_movie = self.__get_movie_detail(tmdbid) if tmdb_info_tv and tmdb_info_movie: tmdb_info = None logger.warn(f"无法判断tmdb_id:{tmdbid} 是电影还是电视剧") elif tmdb_info_tv: tmdb_info = tmdb_info_tv tmdb_info['media_type'] = MediaType.TV elif tmdb_info_movie: tmdb_info = tmdb_info_movie tmdb_info['media_type'] = MediaType.MOVIE else: tmdb_info = None logger.warn(f"tmdb_id:{tmdbid} 未查询到媒体信息") if tmdb_info: # 转换genreid tmdb_info['genre_ids'] = __get_genre_ids(tmdb_info.get('genres')) # 别名和译名 tmdb_info['names'] = self.__get_names(tmdb_info) # 内容分级 tmdb_info['content_rating'] = self.__get_content_rating(tmdb_info) # 转换多语种标题 self.__update_tmdbinfo_extra_title(tmdb_info) # 转换中文标题 if self.tmdb.language in ("zh", "zh-CN"): self.__update_tmdbinfo_cn_title(tmdb_info) return tmdb_info @staticmethod def __get_content_rating(tmdb_info: dict) -> Optional[str]: """ 获得tmdb中的内容评级 :param tmdb_info: TMDB信息 :return: 内容评级 """ if not tmdb_info: return None # dict[地区:分级] ratings = {} if results := (tmdb_info.get("release_dates") or {}).get("results"): """ [ { "iso_3166_1": "AR", "release_dates": [ { "certification": "+13", "descriptors": [], "iso_639_1": "", "note": "", "release_date": "2025-01-23T00:00:00.000Z", "type": 3 } ] } ] """ for item in results: iso_3166_1 = item.get("iso_3166_1") if not iso_3166_1: continue dates = item.get("release_dates") if not dates: continue certification = dates[0].get("certification") if not certification: continue ratings[iso_3166_1] = certification elif results := (tmdb_info.get("content_ratings") or {}).get("results"): """ [ { "descriptors": [], "iso_3166_1": "US", "rating": "TV-MA" } ] """ for item in results: iso_3166_1 = item.get("iso_3166_1") if not iso_3166_1: continue rating = item.get("rating") if not rating: continue ratings[iso_3166_1] = rating if not ratings: return None return ratings.get("CN") or ratings.get("US") @staticmethod def __update_tmdbinfo_cn_title(tmdb_info: dict): """ 更新TMDB信息中的中文名称 """ def __get_tmdb_chinese_title(tmdbinfo) -> Optional[str]: """ 从别名中获取中文标题 """ if not tmdbinfo: return None if tmdbinfo.get("media_type") == MediaType.MOVIE: alternative_titles = tmdbinfo.get("alternative_titles", {}).get("titles", []) else: alternative_titles = tmdbinfo.get("alternative_titles", {}).get("results", []) for alternative_title in alternative_titles: iso_3166_1 = alternative_title.get("iso_3166_1") if iso_3166_1 == "CN": title = alternative_title.get("title") if title and StringUtils.is_chinese(title) \ and zhconv.convert(title, "zh-hans") == title: return title return tmdbinfo.get("title") if tmdbinfo.get("media_type") == MediaType.MOVIE else tmdbinfo.get("name") # 原标题 org_title = tmdb_info.get("title") \ if tmdb_info.get("media_type") == MediaType.MOVIE \ else tmdb_info.get("name") # 查找中文名 if not StringUtils.is_chinese(org_title): cn_title = __get_tmdb_chinese_title(tmdb_info) if cn_title and cn_title != org_title: # 使用中文别名 if tmdb_info.get("media_type") == MediaType.MOVIE: tmdb_info['title'] = cn_title else: tmdb_info['name'] = cn_title else: # 使用新加坡名 sg_title = tmdb_info.get("sg_title") if sg_title and sg_title != org_title and StringUtils.is_chinese(sg_title): if tmdb_info.get("media_type") == MediaType.MOVIE: tmdb_info['title'] = sg_title else: tmdb_info['name'] = sg_title @staticmethod def __update_tmdbinfo_extra_title(tmdb_info: dict): """ 更新TMDB信息中的其它语种名称 """ def __get_tmdb_lang_title(tmdbinfo: dict, lang: Optional[str] = "US") -> Optional[str]: """ 从译名中获取其它语种标题 """ if not tmdbinfo: return None translations = tmdb_info.get("translations", {}).get("translations", []) for translation in translations: if translation.get("iso_3166_1") == lang: return translation.get("data", {}).get("title") if tmdbinfo.get("media_type") == MediaType.MOVIE \ else translation.get("data", {}).get("name") return None # 原标题 org_title = ( tmdb_info.get("original_title") if tmdb_info.get("media_type") == MediaType.MOVIE else tmdb_info.get("original_name") ) # 查找英文名 if tmdb_info.get("original_language") == "en": tmdb_info['en_title'] = org_title else: en_title = __get_tmdb_lang_title(tmdb_info, "US") tmdb_info['en_title'] = en_title or org_title # 查找香港台湾译名 tmdb_info['hk_title'] = __get_tmdb_lang_title(tmdb_info, "HK") tmdb_info['tw_title'] = __get_tmdb_lang_title(tmdb_info, "TW") # 查找新加坡名(用于替代中文名) tmdb_info['sg_title'] = __get_tmdb_lang_title(tmdb_info, "SG") or org_title def __get_movie_detail(self, tmdbid: int, append_to_response: Optional[str] = "images," "credits," "alternative_titles," "translations," "release_dates," "external_ids") -> Optional[dict]: """ 获取电影的详情 :param tmdbid: TMDB ID :return: TMDB信息 """ """ { "adult": false, "backdrop_path": "/r9PkFnRUIthgBp2JZZzD380MWZy.jpg", "belongs_to_collection": { "id": 94602, "name": "穿靴子的猫(系列)", "poster_path": "/anHwj9IupRoRZZ98WTBvHpTiE6A.jpg", "backdrop_path": "/feU1DWV5zMWxXUHJyAIk3dHRQ9c.jpg" }, "budget": 90000000, "genres": [ { "id": 16, "name": "动画" }, { "id": 28, "name": "动作" }, { "id": 12, "name": "冒险" }, { "id": 35, "name": "喜剧" }, { "id": 10751, "name": "家庭" }, { "id": 14, "name": "奇幻" } ], "homepage": "", "id": 315162, "imdb_id": "tt3915174", "original_language": "en", "original_title": "Puss in Boots: The Last Wish", "overview": "时隔11年,臭屁自大又爱卖萌的猫大侠回来了!如今的猫大侠(安东尼奥·班德拉斯 配音),依旧幽默潇洒又不拘小节、数次“花式送命”后,九条命如今只剩一条,于是不得不请求自己的老搭档兼“宿敌”——迷人的软爪妞(萨尔玛·海耶克 配音)来施以援手来恢复自己的九条生命。", "popularity": 8842.129, "poster_path": "/rnn30OlNPiC3IOoWHKoKARGsBRK.jpg", "production_companies": [ { "id": 33, "logo_path": "/8lvHyhjr8oUKOOy2dKXoALWKdp0.png", "name": "Universal Pictures", "origin_country": "US" }, { "id": 521, "logo_path": "/kP7t6RwGz2AvvTkvnI1uteEwHet.png", "name": "DreamWorks Animation", "origin_country": "US" } ], "production_countries": [ { "iso_3166_1": "US", "name": "United States of America" } ], "release_date": "2022-12-07", "revenue": 260725470, "runtime": 102, "spoken_languages": [ { "english_name": "English", "iso_639_1": "en", "name": "English" }, { "english_name": "Spanish", "iso_639_1": "es", "name": "Español" } ], "status": "Released", "tagline": "", "title": "穿靴子的猫2", "video": false, "vote_average": 8.614, "vote_count": 2291 } """ if not self.movie: return {} try: logger.debug("正在查询TMDB电影:%s ..." % tmdbid) tmdbinfo = self.movie.details(tmdbid, append_to_response) if tmdbinfo: logger.debug(f"{tmdbid} 查询结果:{tmdbinfo.get('title')}") return tmdbinfo or {} except Exception as e: logger.error(str(e)) return None def __get_tv_detail(self, tmdbid: int, append_to_response: Optional[str] = "images," "credits," "alternative_titles," "translations," "content_ratings," "external_ids," "episode_groups") -> Optional[dict]: """ 获取电视剧的详情 :param tmdbid: TMDB ID :return: TMDB信息 """ """ { "adult": false, "backdrop_path": "/uDgy6hyPd82kOHh6I95FLtLnj6p.jpg", "created_by": [ { "id": 35796, "credit_id": "5e84f06a3344c600153f6a57", "name": "Craig Mazin", "gender": 2, "profile_path": "/uEhna6qcMuyU5TP7irpTUZ2ZsZc.jpg" }, { "id": 1295692, "credit_id": "5e84f03598f1f10016a985c0", "name": "Neil Druckmann", "gender": 2, "profile_path": "/bVUsM4aYiHbeSYE1xAw2H5Z1ANU.jpg" } ], "episode_run_time": [], "first_air_date": "2023-01-15", "genres": [ { "id": 18, "name": "剧情" }, { "id": 10765, "name": "Sci-Fi & Fantasy" }, { "id": 10759, "name": "动作冒险" } ], "homepage": "https://www.hbo.com/the-last-of-us", "id": 100088, "in_production": true, "languages": [ "en" ], "last_air_date": "2023-01-15", "last_episode_to_air": { "air_date": "2023-01-15", "episode_number": 1, "id": 2181581, "name": "当你迷失在黑暗中", "overview": "在一场全球性的流行病摧毁了文明之后,一个顽强的幸存者负责照顾一个 14 岁的小女孩,她可能是人类最后的希望。", "production_code": "", "runtime": 81, "season_number": 1, "show_id": 100088, "still_path": "/aRquEWm8wWF1dfa9uZ1TXLvVrKD.jpg", "vote_average": 8, "vote_count": 33 }, "name": "最后生还者", "next_episode_to_air": { "air_date": "2023-01-22", "episode_number": 2, "id": 4071039, "name": "虫草变异菌", "overview": "", "production_code": "", "runtime": 55, "season_number": 1, "show_id": 100088, "still_path": "/jkUtYTmeap6EvkHI4n0j5IRFrIr.jpg", "vote_average": 10, "vote_count": 1 }, "networks": [ { "id": 49, "name": "HBO", "logo_path": "/tuomPhY2UtuPTqqFnKMVHvSb724.png", "origin_country": "US" } ], "number_of_episodes": 9, "number_of_seasons": 1, "origin_country": [ "US" ], "original_language": "en", "original_name": "The Last of Us", "overview": "不明真菌疫情肆虐之后的美国,被真菌感染的人都变成了可怕的怪物,乔尔(Joel)为了换回武器答应将小女孩儿艾莉(Ellie)送到指定地点,由此开始了两人穿越美国的漫漫旅程。", "popularity": 5585.639, "poster_path": "/nOY3VBFO0VnlN9nlRombnMTztyh.jpg", "production_companies": [ { "id": 3268, "logo_path": "/tuomPhY2UtuPTqqFnKMVHvSb724.png", "name": "HBO", "origin_country": "US" }, { "id": 11073, "logo_path": "/aCbASRcI1MI7DXjPbSW9Fcv9uGR.png", "name": "Sony Pictures Television Studios", "origin_country": "US" }, { "id": 23217, "logo_path": "/kXBZdQigEf6QiTLzo6TFLAa7jKD.png", "name": "Naughty Dog", "origin_country": "US" }, { "id": 115241, "logo_path": null, "name": "The Mighty Mint", "origin_country": "US" }, { "id": 119645, "logo_path": null, "name": "Word Games", "origin_country": "US" }, { "id": 125281, "logo_path": "/3hV8pyxzAJgEjiSYVv1WZ0ZYayp.png", "name": "PlayStation Productions", "origin_country": "US" } ], "production_countries": [ { "iso_3166_1": "US", "name": "United States of America" } ], "seasons": [ { "air_date": "2023-01-15", "episode_count": 9, "id": 144593, "name": "第 1 季", "overview": "", "poster_path": "/aUQKIpZZ31KWbpdHMCmaV76u78T.jpg", "season_number": 1 } ], "spoken_languages": [ { "english_name": "English", "iso_639_1": "en", "name": "English" } ], "status": "Returning Series", "tagline": "", "type": "Scripted", "vote_average": 8.924, "vote_count": 601 } """ if not self.tv: return {} try: logger.debug("正在查询TMDB电视剧:%s ..." % tmdbid) tmdbinfo = self.tv.details(tv_id=tmdbid, append_to_response=append_to_response) if tmdbinfo: logger.debug(f"{tmdbid} 查询结果:{tmdbinfo.get('name')}") return tmdbinfo or {} except Exception as e: logger.error(str(e)) return None def get_tv_season_detail(self, tmdbid: int, season: int): """ 获取电视剧季的详情 :param tmdbid: TMDB ID :param season: 季,数字 :return: TMDB信息 """ """ { "_id": "5e614cd3357c00001631a6ef", "air_date": "2023-01-15", "episodes": [ { "air_date": "2023-01-15", "episode_number": 1, "id": 2181581, "name": "当你迷失在黑暗中", "overview": "在一场全球性的流行病摧毁了文明之后,一个顽强的幸存者负责照顾一个 14 岁的小女孩,她可能是人类最后的希望。", "production_code": "", "runtime": 81, "season_number": 1, "show_id": 100088, "still_path": "/aRquEWm8wWF1dfa9uZ1TXLvVrKD.jpg", "vote_average": 8, "vote_count": 33, "crew": [ { "job": "Writer", "department": "Writing", "credit_id": "619c370063536a00619a08ee", "adult": false, "gender": 2, "id": 35796, "known_for_department": "Writing", "name": "Craig Mazin", "original_name": "Craig Mazin", "popularity": 15.211, "profile_path": "/uEhna6qcMuyU5TP7irpTUZ2ZsZc.jpg" }, ], "guest_stars": [ { "character": "Marlene", "credit_id": "63c4ca5e5f2b8d00aed539fc", "order": 500, "adult": false, "gender": 1, "id": 1253388, "known_for_department": "Acting", "name": "Merle Dandridge", "original_name": "Merle Dandridge", "popularity": 21.679, "profile_path": "/lKwHdTtDf6NGw5dUrSXxbfkZLEk.jpg" } ] }, ], "name": "第 1 季", "overview": "", "id": 144593, "poster_path": "/aUQKIpZZ31KWbpdHMCmaV76u78T.jpg", "season_number": 1 } """ if not self.season_obj: return {} try: logger.debug("正在查询TMDB电视剧:%s,季:%s ..." % (tmdbid, season)) tmdbinfo = self.season_obj.details(tv_id=tmdbid, season_num=season) return tmdbinfo or {} except Exception as e: logger.error(str(e)) return {} def get_tv_episode_detail(self, tmdbid: int, season: int, episode: int) -> dict: """ 获取电视剧集的详情 :param tmdbid: TMDB ID :param season: 季,数字 :param episode: 集,数字 """ if not self.episode_obj: return {} try: logger.debug("正在查询TMDB集详情:%s,季:%s,集:%s ..." % (tmdbid, season, episode)) tmdbinfo = self.episode_obj.details(tv_id=tmdbid, season_num=season, episode_num=episode) return tmdbinfo or {} except Exception as e: logger.error(str(e)) return {} def discover_movies(self, params: dict) -> List[dict]: """ 发现电影 :param params: 参数 :return: """ if not self.discover: return [] try: logger.debug(f"正在发现电影:{params}...") tmdbinfo = self.discover.discover_movies(tuple(params.items())) if tmdbinfo: for info in tmdbinfo: info['media_type'] = MediaType.MOVIE return tmdbinfo or [] except Exception as e: logger.error(str(e)) return [] def discover_tvs(self, params: dict) -> List[dict]: """ 发现电视剧 :param params: 参数 :return: """ if not self.discover: return [] try: logger.debug(f"正在发现电视剧:{params}...") tmdbinfo = self.discover.discover_tv_shows(tuple(params.items())) if tmdbinfo: for info in tmdbinfo: info['media_type'] = MediaType.TV return tmdbinfo or [] except Exception as e: logger.error(str(e)) return [] def discover_trending(self, page: Optional[int] = 1) -> List[dict]: """ 流行趋势 """ if not self.trending: return [] try: logger.debug(f"正在获取流行趋势:page={page} ...") return self.trending.all_week(page=page) except Exception as e: logger.error(str(e)) return [] def get_movie_images(self, tmdbid: int) -> dict: """ 获取电影的图片 """ if not self.movie: return {} try: logger.debug(f"正在获取电影图片:{tmdbid}...") return self.movie.images(movie_id=tmdbid) or {} except Exception as e: logger.error(str(e)) return {} def get_tv_images(self, tmdbid: int) -> dict: """ 获取电视剧的图片 """ if not self.tv: return {} try: logger.debug(f"正在获取电视剧图片:{tmdbid}...") return self.tv.images(tv_id=tmdbid) or {} except Exception as e: logger.error(str(e)) return {} def get_movie_similar(self, tmdbid: int) -> List[dict]: """ 获取电影的相似电影 """ if not self.movie: return [] try: logger.debug(f"正在获取相似电影:{tmdbid}...") return self.movie.similar(movie_id=tmdbid) or [] except Exception as e: logger.error(str(e)) return [] def get_tv_similar(self, tmdbid: int) -> List[dict]: """ 获取电视剧的相似电视剧 """ if not self.tv: return [] try: logger.debug(f"正在获取相似电视剧:{tmdbid}...") return self.tv.similar(tv_id=tmdbid) or [] except Exception as e: logger.error(str(e)) return [] def get_movie_recommend(self, tmdbid: int) -> List[dict]: """ 获取电影的推荐电影 """ if not self.movie: return [] try: logger.debug(f"正在获取推荐电影:{tmdbid}...") return self.movie.recommendations(movie_id=tmdbid) or [] except Exception as e: logger.error(str(e)) return [] def get_tv_recommend(self, tmdbid: int) -> List[dict]: """ 获取电视剧的推荐电视剧 """ if not self.tv: return [] try: logger.debug(f"正在获取推荐电视剧:{tmdbid}...") return self.tv.recommendations(tv_id=tmdbid) or [] except Exception as e: logger.error(str(e)) return [] def get_movie_credits(self, tmdbid: int, page: Optional[int] = 1, count: Optional[int] = 24) -> List[dict]: """ 获取电影的演职员列表 """ if not self.movie: return [] try: logger.debug(f"正在获取电影演职人员:{tmdbid}...") info = self.movie.credits(movie_id=tmdbid) or {} cast = info.get('cast') or [] if cast: return cast[(page - 1) * count: page * count] return [] except Exception as e: logger.error(str(e)) return [] def get_tv_credits(self, tmdbid: int, page: Optional[int] = 1, count: Optional[int] = 24) -> List[dict]: """ 获取电视剧的演职员列表 """ if not self.tv: return [] try: logger.debug(f"正在获取电视剧演职人员:{tmdbid}...") info = self.tv.credits(tv_id=tmdbid) or {} cast = info.get('cast') or [] if cast: return cast[(page - 1) * count: page * count] return [] except Exception as e: logger.error(str(e)) return [] def get_tv_group_seasons(self, group_id: str) -> List[dict]: """ 获取电视剧剧集组季集列表 """ if not self.tv: return [] try: logger.debug(f"正在获取剧集组:{group_id}...") group_seasons = self.tv.group_episodes(group_id) or [] return [ { **group_season, "episodes": [ {**ep, "episode_number": idx} # 剧集组中每个季的episode_number从1开始 for idx, ep in enumerate(group_season.get("episodes", []), start=1) ] } for group_season in group_seasons ] except Exception as e: logger.error(str(e)) return [] def get_tv_group_detail(self, group_id: str, season: int) -> dict: """ 获取剧集组某个季的信息 """ group_seasons = self.get_tv_group_seasons(group_id) if not group_seasons: return {} for group_season in group_seasons: if group_season.get('order') == season: return group_season return {} def get_person_detail(self, person_id: int) -> dict: """ 获取人物详情 { "adult": false, "also_known_as": [ "Michael Chen", "Chen He", "陈赫" ], "biography": "陈赫,xxx", "birthday": "1985-11-09", "deathday": null, "gender": 2, "homepage": "https://movie.douban.com/celebrity/1313841/", "id": 1397016, "imdb_id": "nm4369305", "known_for_department": "Acting", "name": "Chen He", "place_of_birth": "Fuzhou,Fujian Province,China", "popularity": 9.228, "profile_path": "/2Bk39zVuoHUNHtpZ7LVg7OgkDd4.jpg" } """ if not self.person: return {} try: logger.debug(f"正在获取人物详情:{person_id}...") return self.person.details(person_id=person_id) or {} except Exception as e: logger.error(str(e)) return {} def get_person_credits(self, person_id: int, page: Optional[int] = 1, count: Optional[int] = 24) -> List[dict]: """ 获取人物参演作品 """ if not self.person: return [] try: logger.debug(f"正在获取人物参演作品:{person_id}...") movies = self.person.movie_credits(person_id=person_id) or {} tvs = self.person.tv_credits(person_id=person_id) or {} cast = (movies.get('cast') or []) + (tvs.get('cast') or []) if cast: # 按年份降序排列 cast = sorted(cast, key=lambda x: x.get('release_date') or x.get('first_air_date') or '1900-01-01', reverse=True) return cast[(page - 1) * count: page * count] return [] except Exception as e: logger.error(str(e)) return [] def clear_cache(self): """ 清除缓存 """ self.match_web.cache_clear() self.discover.discover_movies.cache_clear() self.discover.discover_tv_shows.cache_clear() self.tmdb.cache_clear() # 私有异步方法 async def __async_search_movie_by_name(self, name: str, year: str) -> Optional[dict]: """ 根据名称查询电影TMDB匹配(异步版本) :param name: 识别的文件名或种子名 :param year: 电影上映日期 :return: 匹配的媒体信息 """ try: if year: movies = await self.search.async_movies(term=name, year=year) else: movies = await self.search.async_movies(term=name) except TMDbException as err: logger.error(f"连接TMDB出错:{str(err)}") return None except Exception as e: logger.error(f"连接TMDB出错:{str(e)} - {traceback.format_exc()}") return None logger.debug(f"API返回:{str(self.search.total_results)}") if (movies is None) or (len(movies) == 0): logger.debug(f"{name} 未找到相关电影信息!") return {} else: # 按年份降序排列 movies = sorted( movies, key=lambda x: x.get('release_date') or '0000-00-00', reverse=True ) for movie in movies: # 年份 movie_year = movie.get('release_date')[0:4] if movie.get('release_date') else None if year and movie_year != year: # 年份不匹配 continue # 匹配标题、原标题 if self.__compare_names(name, movie.get('title')): return movie if self.__compare_names(name, movie.get('original_title')): return movie # 匹配别名、译名 if not movie.get("names"): movie = await self.async_get_info(mtype=MediaType.MOVIE, tmdbid=movie.get("id")) if movie and self.__compare_names(name, movie.get("names")): return movie return {} async def __async_search_tv_by_name(self, name: str, year: str) -> Optional[dict]: """ 根据名称查询电视剧TMDB匹配(异步版本) :param name: 识别的文件名或者种子名 :param year: 电视剧的首播年份 :return: 匹配的媒体信息 """ try: if year: tvs = await self.search.async_tv_shows(term=name, release_year=year) else: tvs = await self.search.async_tv_shows(term=name) except TMDbException as err: logger.error(f"连接TMDB出错:{str(err)}") return None except Exception as e: logger.error(f"连接TMDB出错:{str(e)} - {traceback.format_exc()}") return None logger.debug(f"API返回:{str(self.search.total_results)}") if (tvs is None) or (len(tvs) == 0): logger.debug(f"{name} 未找到相关剧集信息!") return {} else: # 按年份降序排列 tvs = sorted( tvs, key=lambda x: x.get('first_air_date') or '0000-00-00', reverse=True ) for tv in tvs: tv_year = tv.get('first_air_date')[0:4] if tv.get('first_air_date') else None if year and tv_year != year: # 年份不匹配 continue # 匹配标题、原标题 if self.__compare_names(name, tv.get('name')): return tv if self.__compare_names(name, tv.get('original_name')): return tv # 匹配别名、译名 if not tv.get("names"): tv = await self.async_get_info(mtype=MediaType.TV, tmdbid=tv.get("id")) if tv and self.__compare_names(name, tv.get("names")): return tv return {} async def __async_search_tv_by_season(self, name: str, season_year: str, season_number: int, group_seasons: Optional[List[dict]] = None) -> Optional[dict]: """ 根据电视剧的名称和季的年份及序号匹配TMDB(异步版本) :param name: 识别的文件名或者种子名 :param season_year: 季的年份 :param season_number: 季序号 :param group_seasons: 集数组信息 :return: 匹配的媒体信息 """ def __season_match(tv_info: dict, _season_year: str) -> bool: if not tv_info: return False try: if group_seasons: for group_season in group_seasons: season = group_season.get('order') if season != season_number: continue episodes = group_season.get('episodes') if not episodes: continue first_date = episodes[0].get("air_date") if re.match(r"^\d{4}-\d{2}-\d{2}$", first_date): if str(_season_year) == str(first_date).split("-")[0]: return True else: seasons = self.__get_tv_seasons(tv_info) for season, season_info in seasons.items(): if season_info.get("air_date"): if season_info.get("air_date")[0:4] == str(_season_year) \ and season == int(season_number): return True except Exception as e1: logger.error(f"连接TMDB出错:{e1}") print(traceback.format_exc()) return False return False try: tvs = await self.search.async_tv_shows(term=name) except TMDbException as err: logger.error(f"连接TMDB出错:{str(err)}") return None except Exception as e: logger.error(f"连接TMDB出错:{str(e)}") print(traceback.format_exc()) return None if (tvs is None) or (len(tvs) == 0): logger.debug("%s 未找到季%s相关信息!" % (name, season_number)) return {} else: # 按年份降序排列 tvs = sorted( tvs, key=lambda x: x.get('first_air_date') or '0000-00-00', reverse=True ) for tv in tvs: # 年份 tv_year = tv.get('first_air_date')[0:4] if tv.get('first_air_date') else None if (self.__compare_names(name, tv.get('name')) or self.__compare_names(name, tv.get('original_name'))) \ and (tv_year == str(season_year)): return tv # 匹配别名、译名 if not tv.get("names"): tv = await self.async_get_info(mtype=MediaType.TV, tmdbid=tv.get("id")) if not tv or not self.__compare_names(name, tv.get("names")): continue if __season_match(tv_info=tv, _season_year=season_year): return tv return {} async def __async_get_movie_detail(self, tmdbid: int, append_to_response: Optional[str] = "images," "credits," "alternative_titles," "translations," "release_dates," "external_ids") -> Optional[dict]: """ 获取电影的详情(异步版本) :param tmdbid: TMDB ID :return: TMDB信息 """ if not self.movie: return {} try: logger.debug("正在查询TMDB电影:%s ..." % tmdbid) tmdbinfo = await self.movie.async_details(tmdbid, append_to_response) if tmdbinfo: logger.debug(f"{tmdbid} 查询结果:{tmdbinfo.get('title')}") return tmdbinfo or {} except Exception as e: logger.error(str(e)) return None async def __async_get_tv_detail(self, tmdbid: int, append_to_response: Optional[str] = "images," "credits," "alternative_titles," "translations," "content_ratings," "external_ids," "episode_groups") -> Optional[dict]: """ 获取电视剧的详情(异步版本) :param tmdbid: TMDB ID :return: TMDB信息 """ if not self.tv: return {} try: logger.debug("正在查询TMDB电视剧:%s ..." % tmdbid) tmdbinfo = await self.tv.async_details(tv_id=tmdbid, append_to_response=append_to_response) if tmdbinfo: logger.debug(f"{tmdbid} 查询结果:{tmdbinfo.get('name')}") return tmdbinfo or {} except Exception as e: logger.error(str(e)) return None # 公共异步方法 @cached(maxsize=settings.CONF.tmdb, ttl=settings.CONF.meta) @rate_limit_exponential(source="match_tmdb_web", base_wait=5, max_wait=1800, enable_logging=True) async def async_match_web(self, name: str, mtype: MediaType) -> Optional[dict]: """ 搜索TMDB网站,直接抓取结果,结果只有一条时才返回(异步版本) :param name: 名称 :param mtype: 媒体类型 """ # 参数验证 validation_result = self._validate_web_params(name) if validation_result is not None: return validation_result logger.info("正在从TheDbMovie网站查询:%s ..." % name) tmdb_url = self._build_tmdb_search_url(name) res = await AsyncRequestUtils(timeout=5, ua=settings.NORMAL_USER_AGENT, proxies=settings.PROXY).get_res( url=tmdb_url) if res is None: logger.error("无法连接TheDbMovie") return None # 响应验证 response_result = self._validate_response(res) if response_result is not None: return response_result try: # 提取链接 tmdb_links = self._extract_tmdb_links(res.text, mtype) # 处理结果 return await self._async_process_web_search_links(name, mtype, tmdb_links) except Exception as err: logger.error(f"从TheDbMovie网站查询出错:{str(err)}") return {} async def async_search_multiis(self, title: str) -> List[dict]: """ 同时查询模糊匹配的电影、电视剧TMDB信息(异步版本) """ if not title: return [] ret_infos = [] multis = await self.search.async_multi(term=title) or [] for multi in multis: if multi.get("media_type") in ["movie", "tv"]: multi['media_type'] = MediaType.MOVIE if multi.get("media_type") == "movie" else MediaType.TV ret_infos.append(multi) return ret_infos async def async_search_movies(self, title: str, year: str) -> List[dict]: """ 查询模糊匹配的所有电影TMDB信息(异步版本) """ if not title: return [] ret_infos = [] if year: movies = await self.search.async_movies(term=title, year=year) or [] else: movies = await self.search.async_movies(term=title) or [] for movie in movies: if title in movie.get("title"): movie['media_type'] = MediaType.MOVIE ret_infos.append(movie) return ret_infos async def async_search_tvs(self, title: str, year: str) -> List[dict]: """ 查询模糊匹配的所有电视剧TMDB信息(异步版本) """ if not title: return [] ret_infos = [] if year: tvs = await self.search.async_tv_shows(term=title, release_year=year) or [] else: tvs = await self.search.async_tv_shows(term=title) or [] for tv in tvs: if title in tv.get("name"): tv['media_type'] = MediaType.TV ret_infos.append(tv) return ret_infos async def async_discover_movies(self, params: dict) -> List[dict]: """ 发现电影(异步版本) """ if not params: return [] try: items = await self.discover.async_discover_movies(params_tuple=tuple(params.items())) or [] for item in items: item['media_type'] = MediaType.MOVIE return items except Exception as e: logger.error(f"获取电影发现失败:{str(e)}") return [] async def async_discover_tvs(self, params: dict) -> List[dict]: """ 发现电视剧(异步版本) """ if not params: return [] try: items = await self.discover.async_discover_tv_shows(params_tuple=tuple(params.items())) or [] for item in items: item['media_type'] = MediaType.TV return items except Exception as e: logger.error(f"获取电视剧发现失败:{str(e)}") return [] async def async_search_persons(self, name: str) -> List[dict]: """ 查询模糊匹配的所有人物TMDB信息(异步版本) """ if not name: return [] return await self.search.async_people(term=name) or [] async def async_search_collections(self, name: str) -> List[dict]: """ 查询模糊匹配的所有合集TMDB信息(异步版本) """ if not name: return [] collections = await self.search.async_collections(term=name) or [] for collection in collections: collection['media_type'] = MediaType.COLLECTION collection['collection_id'] = collection.get("id") return collections async def async_get_collection(self, collection_id: int) -> List[dict]: """ 根据合集ID查询合集详情(异步版本) """ if not collection_id: return [] try: return await self.collection.async_details(collection_id=collection_id) except TMDbException as err: logger.error(f"连接TMDB出错:{str(err)}") except Exception as e: logger.error(f"连接TMDB出错:{str(e)}") return [] async def async_match(self, name: str, mtype: MediaType, year: Optional[str] = None, season_year: Optional[str] = None, season_number: Optional[int] = None, group_seasons: Optional[List[dict]] = None) -> Optional[dict]: """ 搜索tmdb中的媒体信息,匹配返回一条尽可能正确的信息(异步版本) :param name: 检索的名称 :param mtype: 类型:电影、电视剧 :param year: 年份,如要是季集需要是首播年份(first_air_date) :param season_year: 当前季集年份 :param season_number: 季集,整数 :param group_seasons: 集数组信息 :return: TMDB的INFO,同时会将mtype赋值到media_type中 """ # 基本参数验证 if not self._validate_match_params(name, self.search): return None # TMDB搜索 info = {} if mtype != MediaType.TV: year_range = self._generate_year_range(year) for search_year in year_range: self._log_match_debug(mtype, name, search_year) info = await self.__async_search_movie_by_name(name, search_year) if info: break info = self._set_media_type(info, MediaType.MOVIE) else: # 有当前季和当前季集年份,使用精确匹配 if season_year and season_number is not None: self._log_match_debug(mtype, name, season_year, season_number, season_year) info = await self.__async_search_tv_by_season(name, season_year, season_number, group_seasons) if not info: year_range = self._generate_year_range(year) for search_year in year_range: self._log_match_debug(mtype, name, search_year) info = await self.__async_search_tv_by_name(name, search_year) if info: break info = self._set_media_type(info, MediaType.TV) return info async def async_match_multi(self, name: str) -> Optional[dict]: """ 根据名称同时查询电影和电视剧,没有类型也没有年份时使用(异步版本) :param name: 识别的文件名或种子名 :return: 匹配的媒体信息 """ try: multis = await self.search.async_multi(term=name) or [] except TMDbException as err: logger.error(f"连接TMDB出错:{str(err)}") return None except Exception as e: logger.error(f"连接TMDB出错:{str(e)}") print(traceback.format_exc()) return None logger.debug(f"API返回:{str(self.search.total_results)}") # 返回结果 if (multis is None) or (len(multis) == 0): logger.debug(f"{name} 未找到相关媒体息!") return {} # 按年份降序排列,电影在前面 multis = self._sort_multi_results(multis) ret_info = {} for multi in multis: matched = await self._async_match_multi_item(name, multi) if matched: ret_info = matched break # 类型变更 return self._convert_media_type(ret_info) async def async_get_info(self, mtype: MediaType, tmdbid: int) -> dict: """ 给定TMDB号,查询一条媒体信息(异步版本) :param mtype: 类型:电影、电视剧,为空时都查(此时用不上年份) :param tmdbid: TMDB的ID,有tmdbid时优先使用tmdbid,否则使用年份和标题 """ def __get_genre_ids(genres: list) -> list: """ 从TMDB详情中获取genre_id列表 """ if not genres: return [] genre_ids = [] for genre in genres: genre_ids.append(genre.get('id')) return genre_ids # 查询TMDB详情 if mtype == MediaType.MOVIE: tmdb_info = await self.__async_get_movie_detail(tmdbid) if tmdb_info: tmdb_info['media_type'] = MediaType.MOVIE elif mtype == MediaType.TV: tmdb_info = await self.__async_get_tv_detail(tmdbid) if tmdb_info: tmdb_info['media_type'] = MediaType.TV else: tmdb_info_tv = await self.__async_get_tv_detail(tmdbid) tmdb_info_movie = await self.__async_get_movie_detail(tmdbid) if tmdb_info_tv and tmdb_info_movie: tmdb_info = None logger.warn(f"无法判断tmdb_id:{tmdbid} 是电影还是电视剧") elif tmdb_info_tv: tmdb_info = tmdb_info_tv tmdb_info['media_type'] = MediaType.TV elif tmdb_info_movie: tmdb_info = tmdb_info_movie tmdb_info['media_type'] = MediaType.MOVIE else: tmdb_info = None logger.warn(f"tmdb_id:{tmdbid} 未查询到媒体信息") if tmdb_info: # 转换genreid tmdb_info['genre_ids'] = __get_genre_ids(tmdb_info.get('genres')) # 别名和译名 tmdb_info['names'] = self.__get_names(tmdb_info) # 内容分级 tmdb_info['content_rating'] = self.__get_content_rating(tmdb_info) # 转换多语种标题 self.__update_tmdbinfo_extra_title(tmdb_info) # 转换中文标题 if self.tmdb.language in ("zh", "zh-CN"): self.__update_tmdbinfo_cn_title(tmdb_info) return tmdb_info async def async_get_tv_season_detail(self, tmdbid: int, season: int): """ 获取电视剧季的详情(异步版本) :param tmdbid: TMDB ID :param season: 季,数字 :return: TMDB信息 """ if not self.season_obj: return {} try: logger.debug("正在查询TMDB电视剧:%s,季:%s ..." % (tmdbid, season)) tmdbinfo = await self.season_obj.async_details(tv_id=tmdbid, season_num=season) return tmdbinfo or {} except Exception as e: logger.error(str(e)) return {} async def async_get_tv_episode_detail(self, tmdbid: int, season: int, episode: int) -> dict: """ 获取电视剧集的详情(异步版本) :param tmdbid: TMDB ID :param season: 季,数字 :param episode: 集,数字 """ if not self.episode_obj: return {} try: logger.debug("正在查询TMDB集详情:%s,季:%s,集:%s ..." % (tmdbid, season, episode)) tmdbinfo = await self.episode_obj.async_details(tv_id=tmdbid, season_num=season, episode_num=episode) return tmdbinfo or {} except Exception as e: logger.error(str(e)) return {} async def async_discover_trending(self, page: Optional[int] = 1) -> List[dict]: """ 流行趋势(异步版本) """ if not self.trending: return [] try: logger.debug(f"正在获取流行趋势:page={page} ...") return await self.trending.async_all_week(page=page) except Exception as e: logger.error(str(e)) return [] async def async_get_movie_images(self, tmdbid: int) -> dict: """ 获取电影的图片(异步版本) """ if not self.movie: return {} try: logger.debug(f"正在获取电影图片:{tmdbid}...") return await self.movie.async_images(movie_id=tmdbid) or {} except Exception as e: logger.error(str(e)) return {} async def async_get_tv_images(self, tmdbid: int) -> dict: """ 获取电视剧的图片(异步版本) """ if not self.tv: return {} try: logger.debug(f"正在获取电视剧图片:{tmdbid}...") return await self.tv.async_images(tv_id=tmdbid) or {} except Exception as e: logger.error(str(e)) return {} async def async_get_movie_similar(self, tmdbid: int) -> List[dict]: """ 获取电影的相似电影(异步版本) """ if not self.movie: return [] try: logger.debug(f"正在获取相似电影:{tmdbid}...") return await self.movie.async_similar(movie_id=tmdbid) or [] except Exception as e: logger.error(str(e)) return [] async def async_get_tv_similar(self, tmdbid: int) -> List[dict]: """ 获取电视剧的相似电视剧(异步版本) """ if not self.tv: return [] try: logger.debug(f"正在获取相似电视剧:{tmdbid}...") return await self.tv.async_similar(tv_id=tmdbid) or [] except Exception as e: logger.error(str(e)) return [] async def async_get_movie_recommend(self, tmdbid: int) -> List[dict]: """ 获取电影的推荐电影(异步版本) """ if not self.movie: return [] try: logger.debug(f"正在获取推荐电影:{tmdbid}...") return await self.movie.async_recommendations(movie_id=tmdbid) or [] except Exception as e: logger.error(str(e)) return [] async def async_get_tv_recommend(self, tmdbid: int) -> List[dict]: """ 获取电视剧的推荐电视剧(异步版本) """ if not self.tv: return [] try: logger.debug(f"正在获取推荐电视剧:{tmdbid}...") return await self.tv.async_recommendations(tv_id=tmdbid) or [] except Exception as e: logger.error(str(e)) return [] async def async_get_movie_credits(self, tmdbid: int, page: Optional[int] = 1, count: Optional[int] = 24) -> List[dict]: """ 获取电影的演职员列表(异步版本) """ if not self.movie: return [] try: logger.debug(f"正在获取电影演职人员:{tmdbid}...") info = await self.movie.async_credits(movie_id=tmdbid) or {} cast = info.get('cast') or [] if cast: return cast[(page - 1) * count: page * count] return [] except Exception as e: logger.error(str(e)) return [] async def async_get_tv_credits(self, tmdbid: int, page: Optional[int] = 1, count: Optional[int] = 24) -> List[dict]: """ 获取电视剧的演职员列表(异步版本) """ if not self.tv: return [] try: logger.debug(f"正在获取电视剧演职人员:{tmdbid}...") info = await self.tv.async_credits(tv_id=tmdbid) or {} cast = info.get('cast') or [] if cast: return cast[(page - 1) * count: page * count] return [] except Exception as e: logger.error(str(e)) return [] async def async_get_tv_group_seasons(self, group_id: str) -> List[dict]: """ 获取电视剧剧集组季集列表(异步版本) """ if not self.tv: return [] try: logger.debug(f"正在获取剧集组:{group_id}...") group_seasons = await self.tv.async_group_episodes(group_id) or [] return [ { **group_season, "episodes": [ {**ep, "episode_number": idx} # 剧集组中每个季的episode_number从1开始 for idx, ep in enumerate(group_season.get("episodes", []), start=1) ] } for group_season in group_seasons ] except Exception as e: logger.error(str(e)) return [] async def async_get_tv_group_detail(self, group_id: str, season: int) -> dict: """ 获取剧集组某个季的信息(异步版本) """ group_seasons = await self.async_get_tv_group_seasons(group_id) if not group_seasons: return {} for group_season in group_seasons: if group_season.get('order') == season: return group_season return {} async def async_get_person_detail(self, person_id: int) -> dict: """ 获取人物详情(异步版本) """ if not self.person: return {} try: logger.debug(f"正在获取人物详情:{person_id}...") return await self.person.async_details(person_id=person_id) or {} except Exception as e: logger.error(str(e)) return {} async def async_get_person_credits(self, person_id: int, page: Optional[int] = 1, count: Optional[int] = 24) -> List[dict]: """ 获取人物参演作品(异步版本) """ if not self.person: return [] try: logger.debug(f"正在获取人物参演作品:{person_id}...") movies = await self.person.async_movie_credits(person_id=person_id) or {} tvs = await self.person.async_tv_credits(person_id=person_id) or {} cast = (movies.get('cast') or []) + (tvs.get('cast') or []) if cast: # 按年份降序排列 cast = sorted(cast, key=lambda x: x.get('release_date') or x.get('first_air_date') or '1900-01-01', reverse=True) return cast[(page - 1) * count: page * count] return [] except Exception as e: logger.error(str(e)) return [] def close(self): """ 关闭连接 """ self.tmdb.close() ================================================ FILE: app/modules/themoviedb/tmdbv3api/__init__.py ================================================ from .objs.account import Account from .objs.auth import Authentication from .objs.certification import Certification from .objs.change import Change from .objs.collection import Collection from .objs.company import Company from .objs.configuration import Configuration from .objs.credit import Credit from .objs.discover import Discover from .objs.episode import Episode from .objs.find import Find from .objs.genre import Genre from .objs.group import Group from .objs.keyword import Keyword from .objs.list import List from .objs.movie import Movie from .objs.network import Network from .objs.person import Person from .objs.provider import Provider from .objs.review import Review from .objs.search import Search from .objs.season import Season from .objs.trending import Trending from .objs.tv import TV from .tmdb import TMDb ================================================ FILE: app/modules/themoviedb/tmdbv3api/as_obj.py ================================================ # encoding: utf-8 import sys class AsObj: def __init__(self, json=None, key=None, dict_key=False, dict_key_name=None): self._json = json if json else {} self._key = key self._dict_key = dict_key self._dict_key_name = dict_key_name self._obj_list = [] self._list_only = False if isinstance(self._json, list): self._obj_list = [AsObj(o) if isinstance(o, (dict, list)) else o for o in self._json] self._list_only = True elif dict_key: self._obj_list = [ AsObj({k: v}, key=k, dict_key_name=dict_key_name) if isinstance(v, (dict, list)) else v for k, v in self._json.items() ] self._list_only = True else: for key, value in self._json.items(): if isinstance(value, (dict, list)): if self._key and key == self._key: final = AsObj(value, dict_key=isinstance(value, dict), dict_key_name=key) self._obj_list = final else: final = AsObj(value) else: final = value if dict_key_name: setattr(self, dict_key_name, key) setattr(self, key, final) def _dict(self): return {k: v for k, v in self.__dict__.items() if not k.startswith("_")} def to_dict(self): return self._dict() def __delitem__(self, key): return delattr(self, key) def __getitem__(self, key): if isinstance(key, int) and self._obj_list: return self._obj_list[key] else: return getattr(self, key) def __iter__(self): return (o for o in self._obj_list) if self._obj_list else iter(self._dict()) def __len__(self): return len(self._obj_list) if self._obj_list else len(self._dict()) def __repr__(self): return str(self._obj_list) if self._list_only else str(self._dict()) def __setitem__(self, key, value): return setattr(self, key, value) def __str__(self): return str(self._obj_list) if self._list_only else str(self._dict()) if sys.version_info >= (3, 8): def __reversed__(self): return reversed(self._dict()) if sys.version_info >= (3, 9): def __class_getitem__(cls, key): return cls.__dict__.__class_getitem__(key) def __ior__(self, value): return self._dict().__ior__(value) def __or__(self, value): return self._dict().__or__(value) def copy(self): return AsObj(self._json.copy(), key=self._key, dict_key=self._dict_key, dict_key_name=self._dict_key_name) def get(self, key, value=None): return self._dict().get(key, value) def items(self): return self._dict().items() def keys(self): return self._dict().keys() def pop(self, key, value=None): return self.__dict__.pop(key, value) def popitem(self): return self.__dict__.popitem() def setdefault(self, key, value=None): return self.__dict__.setdefault(key, value) def update(self, entries): return self.__dict__.update(entries) def values(self): return self._dict().values() ================================================ FILE: app/modules/themoviedb/tmdbv3api/exceptions.py ================================================ class TMDbException(Exception): pass ================================================ FILE: app/modules/themoviedb/tmdbv3api/objs/__init__.py ================================================ ================================================ FILE: app/modules/themoviedb/tmdbv3api/objs/account.py ================================================ import os from ..exceptions import TMDbException from ..tmdb import TMDb class Account(TMDb): _urls = { "details": "/account", "created_lists": "/account/%s/lists", "favorite_movies": "/account/%s/favorite/movies", "favorite_tv": "/account/%s/favorite/tv", "favorite": "/account/%s/favorite", "rated_movies": "/account/%s/rated/movies", "rated_tv": "/account/%s/rated/tv", "rated_episodes": "/account/%s/rated/tv/episodes", "movie_watchlist": "/account/%s/watchlist/movies", "tv_watchlist": "/account/%s/watchlist/tv", "watchlist": "/account/%s/watchlist", } @property def account_id(self): if not os.environ.get("TMDB_ACCOUNT_ID"): os.environ["TMDB_ACCOUNT_ID"] = str(self.details()["id"]) return os.environ.get("TMDB_ACCOUNT_ID") def details(self): """ Get your account details. :return: """ return self._request_obj( self._urls["details"], params="session_id=%s" % self.session_id ) async def async_details(self): """ Get your account details.(异步版本) :return: """ return await self._async_request_obj( self._urls["details"], params="session_id=%s" % self.session_id ) def created_lists(self, page=1): """ Get all of the lists created by an account. Will include private lists if you are the owner. :param page: int :return: """ return self._request_obj( self._urls["created_lists"] % self.account_id, params="session_id=%s&page=%s" % (self.session_id, page), key="results" ) async def async_created_lists(self, page=1): """ Get all of the lists created by an account. Will include private lists if you are the owner.(异步版本) :param page: int :return: """ return await self._async_request_obj( self._urls["created_lists"] % self.account_id, params="session_id=%s&page=%s" % (self.session_id, page), key="results" ) def _get_list(self, url, asc_sort=True, page=1): params = "session_id=%s&page=%s" % (self.session_id, page) if asc_sort is False: params += "&sort_by=created_at.desc" return self._request_obj( self._urls[url] % self.account_id, params=params, key="results" ) async def _async_get_list(self, url, asc_sort=True, page=1): params = "session_id=%s&page=%s" % (self.session_id, page) if asc_sort is False: params += "&sort_by=created_at.desc" return await self._async_request_obj( self._urls[url] % self.account_id, params=params, key="results" ) def favorite_movies(self, asc_sort=True, page=1): """ Get the list of your favorite movies. :param asc_sort: bool :param page: int :return: """ return self._get_list("favorite_movies", asc_sort=asc_sort, page=page) async def async_favorite_movies(self, asc_sort=True, page=1): """ Get the list of your favorite movies.(异步版本) :param asc_sort: bool :param page: int :return: """ return await self._async_get_list("favorite_movies", asc_sort=asc_sort, page=page) def favorite_tv_shows(self, asc_sort=True, page=1): """ Get the list of your favorite TV shows. :param asc_sort: bool :param page: int :return: """ return self._get_list("favorite_tv", asc_sort=asc_sort, page=page) async def async_favorite_tv_shows(self, asc_sort=True, page=1): """ Get the list of your favorite TV shows.(异步版本) :param asc_sort: bool :param page: int :return: """ return await self._async_get_list("favorite_tv", asc_sort=asc_sort, page=page) def mark_as_favorite(self, media_id, media_type, favorite=True): """ This method allows you to mark a movie or TV show as a favorite item. :param media_id: int :param media_type: str :param favorite:bool """ if media_type not in ["tv", "movie"]: raise TMDbException("Media Type should be tv or movie.") self._request_obj( self._urls["favorite"] % self.account_id, params="session_id=%s" % self.session_id, method="POST", json={ "media_type": media_type, "media_id": media_id, "favorite": favorite, } ) async def async_mark_as_favorite(self, media_id, media_type, favorite=True): """ This method allows you to mark a movie or TV show as a favorite item.(异步版本) :param media_id: int :param media_type: str :param favorite:bool """ if media_type not in ["tv", "movie"]: raise TMDbException("Media Type should be tv or movie.") await self._async_request_obj( self._urls["favorite"] % self.account_id, params="session_id=%s" % self.session_id, method="POST", json={ "media_type": media_type, "media_id": media_id, "favorite": favorite, } ) def unmark_as_favorite(self, media_id, media_type): """ This method allows you to unmark a movie or TV show as a favorite item. :param media_id: int :param media_type: str """ self.mark_as_favorite(media_id, media_type, favorite=False) async def async_unmark_as_favorite(self, media_id, media_type): """ This method allows you to unmark a movie or TV show as a favorite item.(异步版本) :param media_id: int :param media_type: str """ await self.async_mark_as_favorite(media_id, media_type, favorite=False) def rated_movies(self, asc_sort=True, page=1): """ Get a list of all the movies you have rated. :param asc_sort: bool :param page: int :return: """ return self._get_list("rated_movies", asc_sort=asc_sort, page=page) async def async_rated_movies(self, asc_sort=True, page=1): """ Get a list of all the movies you have rated.(异步版本) :param asc_sort: bool :param page: int :return: """ return await self._async_get_list("rated_movies", asc_sort=asc_sort, page=page) def rated_tv_shows(self, asc_sort=True, page=1): """ Get a list of all the TV shows you have rated. :param asc_sort: bool :param page: int :return: """ return self._get_list("rated_tv", asc_sort=asc_sort, page=page) async def async_rated_tv_shows(self, asc_sort=True, page=1): """ Get a list of all the TV shows you have rated.(异步版本) :param asc_sort: bool :param page: int :return: """ return await self._async_get_list("rated_tv", asc_sort=asc_sort, page=page) def rated_episodes(self, asc_sort=True, page=1): """ Get a list of all the TV episodes you have rated. :param asc_sort: bool :param page: int :return: """ return self._get_list("rated_episodes", asc_sort=asc_sort, page=page) async def async_rated_episodes(self, asc_sort=True, page=1): """ Get a list of all the TV episodes you have rated.(异步版本) :param asc_sort: bool :param page: int :return: """ return await self._async_get_list("rated_episodes", asc_sort=asc_sort, page=page) def movie_watchlist(self, asc_sort=True, page=1): """ Get a list of all the movies you have added to your watchlist. :param asc_sort: bool :param page: int :return: """ return self._get_list("movie_watchlist", asc_sort=asc_sort, page=page) async def async_movie_watchlist(self, asc_sort=True, page=1): """ Get a list of all the movies you have added to your watchlist.(异步版本) :param asc_sort: bool :param page: int :return: """ return await self._async_get_list("movie_watchlist", asc_sort=asc_sort, page=page) def tv_show_watchlist(self, asc_sort=True, page=1): """ Get a list of all the TV shows you have added to your watchlist. :param asc_sort: bool :param page: int :return: """ return self._get_list("tv_watchlist", asc_sort=asc_sort, page=page) async def async_tv_show_watchlist(self, asc_sort=True, page=1): """ Get a list of all the TV shows you have added to your watchlist.(异步版本) :param asc_sort: bool :param page: int :return: """ return await self._async_get_list("tv_watchlist", asc_sort=asc_sort, page=page) def add_to_watchlist(self, media_id, media_type, watchlist=True): """ Add a movie or TV show to your watchlist. :param media_id: int :param media_type: str :param watchlist: bool """ if media_type not in ["tv", "movie"]: raise TMDbException("Media Type should be tv or movie.") self._request_obj( self._urls["watchlist"] % self.account_id, "session_id=%s" % self.session_id, method="POST", json={ "media_type": media_type, "media_id": media_id, "watchlist": watchlist, } ) async def async_add_to_watchlist(self, media_id, media_type, watchlist=True): """ Add a movie or TV show to your watchlist.(异步版本) :param media_id: int :param media_type: str :param watchlist: bool """ if media_type not in ["tv", "movie"]: raise TMDbException("Media Type should be tv or movie.") await self._async_request_obj( self._urls["watchlist"] % self.account_id, "session_id=%s" % self.session_id, method="POST", json={ "media_type": media_type, "media_id": media_id, "watchlist": watchlist, } ) def remove_from_watchlist(self, media_id, media_type): """ Remove a movie or TV show from your watchlist. :param media_id: int :param media_type: str """ self.add_to_watchlist(media_id, media_type, watchlist=False) async def async_remove_from_watchlist(self, media_id, media_type): """ Remove a movie or TV show from your watchlist.(异步版本) :param media_id: int :param media_type: str """ await self.async_add_to_watchlist(media_id, media_type, watchlist=False) ================================================ FILE: app/modules/themoviedb/tmdbv3api/objs/auth.py ================================================ from ..tmdb import TMDb class Authentication(TMDb): _urls = { "create_request_token": "/authentication/token/new", "validate_with_login": "/authentication/token/validate_with_login", "create_session": "/authentication/session/new", "delete_session": "/authentication/session", } def __init__(self, username, password): super().__init__() self.username = username self.password = password self.expires_at = None self.request_token = self._create_request_token() self._authorise_request_token_with_login() self._create_session() def _create_request_token(self): """ Create a temporary request token that can be used to validate a TMDb user login. """ response = self._request_obj(self._urls["create_request_token"]) self.expires_at = response.expires_at return response.request_token def _create_session(self): """ You can use this method to create a fully valid session ID once a user has validated the request token. """ response = self._request_obj( self._urls["create_session"], method="POST", json={"request_token": self.request_token} ) self.session_id = response.session_id def _authorise_request_token_with_login(self): """ This method allows an application to validate a request token by entering a username and password. """ self._request_obj( self._urls["validate_with_login"], method="POST", json={ "username": self.username, "password": self.password, "request_token": self.request_token, } ) def delete_session(self): """ If you would like to delete (or "logout") from a session, call this method with a valid session ID. """ if self.has_session: self._request_obj( self._urls["delete_session"], method="DELETE", json={"session_id": self.session_id} ) self.session_id = "" ================================================ FILE: app/modules/themoviedb/tmdbv3api/objs/certification.py ================================================ from ..tmdb import TMDb class Certification(TMDb): _urls = { "movie_list": "/certification/movie/list", "tv_list": "/certification/tv/list", } def movie_list(self): """ Get an up to date list of the officially supported movie certifications on TMDB. :return: """ return self._request_obj(self._urls["movie_list"], key="certifications") async def async_movie_list(self): """ Get an up to date list of the officially supported movie certifications on TMDB.(异步版本) :return: """ return await self._async_request_obj(self._urls["movie_list"], key="certifications") def tv_list(self): """ Get an up to date list of the officially supported TV show certifications on TMDB. :return: """ return self._request_obj(self._urls["tv_list"], key="certifications") async def async_tv_list(self): """ Get an up to date list of the officially supported TV show certifications on TMDB.(异步版本) :return: """ return await self._async_request_obj(self._urls["tv_list"], key="certifications") ================================================ FILE: app/modules/themoviedb/tmdbv3api/objs/change.py ================================================ from ..tmdb import TMDb class Change(TMDb): _urls = { "movie": "/movie/changes", "tv": "/tv/changes", "person": "/person/changes" } def _change_list(self, change_type, start_date="", end_date="", page=1): params = "page=%s" % page if start_date: params += "&start_date=%s" % start_date if end_date: params += "&end_date=%s" % end_date return self._request_obj( self._urls[change_type], params=params, key="results" ) async def _async_change_list(self, change_type, start_date="", end_date="", page=1): params = "page=%s" % page if start_date: params += "&start_date=%s" % start_date if end_date: params += "&end_date=%s" % end_date return await self._async_request_obj( self._urls[change_type], params=params, key="results" ) def movie_change_list(self, start_date="", end_date="", page=1): """ Get the changes for a movie. By default only the last 24 hours are returned. You can query up to 14 days in a single query by using the start_date and end_date query parameters. :param start_date: str :param end_date: str :param page: int :return: """ return self._change_list("movie", start_date=start_date, end_date=end_date, page=page) async def async_movie_change_list(self, start_date="", end_date="", page=1): """ Get the changes for a movie. By default only the last 24 hours are returned.(异步版本) You can query up to 14 days in a single query by using the start_date and end_date query parameters. :param start_date: str :param end_date: str :param page: int :return: """ return await self._async_change_list("movie", start_date=start_date, end_date=end_date, page=page) def tv_change_list(self, start_date="", end_date="", page=1): """ Get a list of all of the TV show ids that have been changed in the past 24 hours. You can query up to 14 days in a single query by using the start_date and end_date query parameters. :param start_date: str :param end_date: str :param page: int :return: """ return self._change_list("tv", start_date=start_date, end_date=end_date, page=page) async def async_tv_change_list(self, start_date="", end_date="", page=1): """ Get a list of all of the TV show ids that have been changed in the past 24 hours.(异步版本) You can query up to 14 days in a single query by using the start_date and end_date query parameters. :param start_date: str :param end_date: str :param page: int :return: """ return await self._async_change_list("tv", start_date=start_date, end_date=end_date, page=page) def person_change_list(self, start_date="", end_date="", page=1): """ Get a list of all of the person ids that have been changed in the past 24 hours. You can query up to 14 days in a single query by using the start_date and end_date query parameters. :param start_date: str :param end_date: str :param page: int :return: """ return self._change_list("person", start_date=start_date, end_date=end_date, page=page) async def async_person_change_list(self, start_date="", end_date="", page=1): """ Get a list of all of the person ids that have been changed in the past 24 hours.(异步版本) You can query up to 14 days in a single query by using the start_date and end_date query parameters. :param start_date: str :param end_date: str :param page: int :return: """ return await self._async_change_list("person", start_date=start_date, end_date=end_date, page=page) ================================================ FILE: app/modules/themoviedb/tmdbv3api/objs/collection.py ================================================ from ..tmdb import TMDb class Collection(TMDb): _urls = { "details": "/collection/%s", "images": "/collection/%s/images", "translations": "/collection/%s/translations" } def details(self, collection_id): """ Get collection details by id. :param collection_id: int :return: """ return self._request_obj(self._urls["details"] % collection_id, key="parts") def images(self, collection_id): """ Get the images for a collection by id. :param collection_id: int :return: """ return self._request_obj(self._urls["images"] % collection_id) def translations(self, collection_id): """ Get the list translations for a collection by id. :param collection_id: int :return: """ return self._request_obj(self._urls["translations"] % collection_id, key="translations") # 异步版本方法 async def async_details(self, collection_id): """ Get collection details by id.(异步版本) :param collection_id: int :return: """ return await self._async_request_obj(self._urls["details"] % collection_id, key="parts") async def async_images(self, collection_id): """ Get the images for a collection by id.(异步版本) :param collection_id: int :return: """ return await self._async_request_obj(self._urls["images"] % collection_id) async def async_translations(self, collection_id): """ Get the list translations for a collection by id.(异步版本) :param collection_id: int :return: """ return await self._async_request_obj(self._urls["translations"] % collection_id, key="translations") ================================================ FILE: app/modules/themoviedb/tmdbv3api/objs/company.py ================================================ from ..tmdb import TMDb class Company(TMDb): _urls = { "details": "/company/%s", "alternative_names": "/company/%s/alternative_names", "images": "/company/%s/images", "movies": "/company/%s/movies" } def details(self, company_id): """ Get a companies details by id. :param company_id: int :return: """ return self._request_obj(self._urls["details"] % company_id) def alternative_names(self, company_id): """ Get the alternative names of a company. :param company_id: int :return: """ return self._request_obj(self._urls["alternative_names"] % company_id, key="results") def images(self, company_id): """ Get the alternative names of a company. :param company_id: int :return: """ return self._request_obj(self._urls["images"] % company_id, key="logos") def movies(self, company_id, page=1): """ Get the movies of a company by id. :param company_id: int :param page: int :return: """ return self._request_obj( self._urls["movies"] % company_id, params="page=%s" % page, key="results" ) # 异步版本方法 async def async_details(self, company_id): """ Get a companies details by id.(异步版本) :param company_id: int :return: """ return await self._async_request_obj(self._urls["details"] % company_id) async def async_alternative_names(self, company_id): """ Get the alternative names of a company.(异步版本) :param company_id: int :return: """ return await self._async_request_obj(self._urls["alternative_names"] % company_id, key="results") async def async_images(self, company_id): """ Get the alternative names of a company.(异步版本) :param company_id: int :return: """ return await self._async_request_obj(self._urls["images"] % company_id, key="logos") async def async_movies(self, company_id, page=1): """ Get the movies of a company by id.(异步版本) :param company_id: int :param page: int :return: """ return await self._async_request_obj( self._urls["movies"] % company_id, params="page=%s" % page, key="results" ) ================================================ FILE: app/modules/themoviedb/tmdbv3api/objs/configuration.py ================================================ import warnings from ..tmdb import TMDb class Configuration(TMDb): _urls = { "api_configuration": "/configuration", "countries": "/configuration/countries", "jobs": "/configuration/jobs", "languages": "/configuration/languages", "primary_translations": "/configuration/primary_translations", "timezones": "/configuration/timezones" } def info(self): warnings.warn("info method is deprecated use tmdbv3api.Configuration().api_configuration()", DeprecationWarning) return self.api_configuration() def api_configuration(self): """ Get the system wide configuration info. """ return self._request_obj(self._urls["api_configuration"]) def countries(self): """ Get the list of countries (ISO 3166-1 tags) used throughout TMDb. """ return self._request_obj(self._urls["countries"]) def jobs(self): """ Get a list of the jobs and departments we use on TMDb. """ return self._request_obj(self._urls["jobs"]) def languages(self): """ Get the list of languages (ISO 639-1 tags) used throughout TMDb. """ return self._request_obj(self._urls["languages"]) def primary_translations(self): """ Get a list of the officially supported translations on TMDb. """ return self._request_obj(self._urls["primary_translations"]) def timezones(self): """ Get the list of timezones used throughout TMDb. """ return self._request_obj(self._urls["timezones"]) # 异步版本方法 async def async_api_configuration(self): """ Get the system wide configuration info.(异步版本) """ return await self._async_request_obj(self._urls["api_configuration"]) async def async_countries(self): """ Get the list of countries (ISO 3166-1 tags) used throughout TMDb.(异步版本) """ return await self._async_request_obj(self._urls["countries"]) async def async_jobs(self): """ Get a list of the jobs and departments we use on TMDb.(异步版本) """ return await self._async_request_obj(self._urls["jobs"]) async def async_languages(self): """ Get the list of languages (ISO 639-1 tags) used throughout TMDb.(异步版本) """ return await self._async_request_obj(self._urls["languages"]) async def async_primary_translations(self): """ Get a list of the officially supported translations on TMDb.(异步版本) """ return await self._async_request_obj(self._urls["primary_translations"]) async def async_timezones(self): """ Get the list of timezones used throughout TMDb.(异步版本) """ return await self._async_request_obj(self._urls["timezones"]) ================================================ FILE: app/modules/themoviedb/tmdbv3api/objs/credit.py ================================================ from ..tmdb import TMDb class Credit(TMDb): _urls = { "details": "/credit/%s" } def details(self, credit_id): """ Get a movie or TV credit details by id. :param credit_id: int :return: """ return self._request_obj(self._urls["details"] % credit_id) async def async_details(self, credit_id): """ Get a movie or TV credit details by id.(异步版本) :param credit_id: int :return: """ return await self._async_request_obj(self._urls["details"] % credit_id) ================================================ FILE: app/modules/themoviedb/tmdbv3api/objs/discover.py ================================================ from app.core.cache import cached from ..tmdb import TMDb try: from urllib import urlencode except ImportError: from urllib.parse import urlencode class Discover(TMDb): _urls = { "movies": "/discover/movie", "tv": "/discover/tv" } @cached(maxsize=1, ttl=43200) def discover_movies(self, params_tuple): """ Discover movies by different types of data like average rating, number of votes, genres and certifications. :param params_tuple: dict :return: """ params = dict(params_tuple) return self._request_obj(self._urls["movies"], urlencode(params), key="results", call_cached=False) @cached(maxsize=1, ttl=43200) def discover_tv_shows(self, params_tuple): """ Discover TV shows by different types of data like average rating, number of votes, genres, the network they aired on and air dates. :param params_tuple: dict :return: """ return self._request_obj(self._urls["tv"], urlencode(params_tuple), key="results", call_cached=False) @cached(maxsize=1, ttl=43200) async def async_discover_movies(self, params_tuple): """ Discover movies by different types of data like average rating, number of votes, genres and certifications.(异步版本) :param params_tuple: dict :return: """ params = dict(params_tuple) return await self._async_request_obj(self._urls["movies"], urlencode(params), key="results", call_cached=False) @cached(maxsize=1, ttl=43200) async def async_discover_tv_shows(self, params_tuple): """ Discover TV shows by different types of data like average rating, number of votes, genres, the network they aired on and air dates.(异步版本) :param params_tuple: dict :return: """ return await self._async_request_obj(self._urls["tv"], urlencode(params_tuple), key="results", call_cached=False) ================================================ FILE: app/modules/themoviedb/tmdbv3api/objs/episode.py ================================================ from ..tmdb import TMDb class Episode(TMDb): _urls = { "details": "/tv/%s/season/%s/episode/%s", "account_states": "/tv/%s/season/%s/episode/%s/account_states", "changes": "/tv/episode/%s/changes", "credits": "/tv/%s/season/%s/episode/%s/credits", "external_ids": "/tv/%s/season/%s/episode/%s/external_ids", "images": "/tv/%s/season/%s/episode/%s/images", "translations": "/tv/%s/season/%s/episode/%s/translations", "rate_tv_episode": "/tv/%s/season/%s/episode/%s/rating", "delete_rating": "/tv/%s/season/%s/episode/%s/rating", "videos": "/tv/%s/season/%s/episode/%s/videos", } def details(self, tv_id, season_num, episode_num, append_to_response="trailers,images,casts,translations"): """ Get the TV episode details by id. :param tv_id: int :param season_num: int :param episode_num: int :param append_to_response: str :return: """ return self._request_obj( self._urls["details"] % (tv_id, season_num, episode_num), params="append_to_response=%s" % append_to_response ) def account_states(self, tv_id, season_num, episode_num): """ Get your rating for a episode. :param tv_id: int :param season_num: int :param episode_num: int :return: """ return self._request_obj( self._urls["account_states"] % (tv_id, season_num, episode_num), params="session_id=%s" % self.session_id ) def changes(self, episode_id, start_date=None, end_date=None, page=1): """ Get the changes for a TV episode. By default only the last 24 hours are returned. You can query up to 14 days in a single query by using the start_date and end_date query parameters. :param episode_id: int :param start_date: str :param end_date: str :param page: int :return: """ params = "page=%s" % page if start_date: params += "&start_date=%s" % start_date if end_date: params += "&end_date=%s" % end_date return self._request_obj( self._urls["changes"] % episode_id, params=params, key="changes" ) def credits(self, tv_id, season_num, episode_num): """ Get the credits for TV season. :param tv_id: int :param season_num: int :param episode_num: int :return: """ return self._request_obj(self._urls["credits"] % (tv_id, season_num, episode_num)) def external_ids(self, tv_id, season_num, episode_num): """ Get the external ids for a TV episode. :param tv_id: int :param season_num: int :param episode_num: int :return: """ return self._request_obj(self._urls["external_ids"] % (tv_id, season_num, episode_num)) def images(self, tv_id, season_num, episode_num, include_image_language=None): """ Get the images that belong to a TV episode. :param tv_id: int :param season_num: int :param episode_num: int :param include_image_language: str :return: """ return self._request_obj( self._urls["images"] % (tv_id, season_num, episode_num), params="include_image_language=%s" % include_image_language if include_image_language else "", key="stills" ) def translations(self, tv_id, season_num, episode_num): """ Get the translation data for an episode. :param tv_id: int :param season_num: int :param episode_num: int :return: """ return self._request_obj( self._urls["translations"] % (tv_id, season_num, episode_num), key="translations" ) def rate_tv_episode(self, tv_id, season_num, episode_num, rating): """ Rate a TV episode. :param tv_id: int :param season_num: int :param episode_num: int :param rating: float """ self._request_obj( self._urls["rate_tv_episode"] % (tv_id, season_num, episode_num), params="session_id=%s" % self.session_id, method="POST", json={"value": rating} ) def delete_rating(self, tv_id, season_num, episode_num): """ Remove your rating for a TV episode. :param tv_id: int :param season_num: int :param episode_num: int """ self._request_obj( self._urls["delete_rating"] % (tv_id, season_num, episode_num), params="session_id=%s" % self.session_id, method="DELETE" ) def videos(self, tv_id, season_num, episode_num, include_video_language=None): """ Get the videos that have been added to a TV episode. :param tv_id: int :param season_num: int :param episode_num: int :param include_video_language: str :return: """ params = "" if include_video_language: params += "&include_video_language=%s" % include_video_language return self._request_obj( self._urls["videos"] % (tv_id, season_num, episode_num), params=params ) # 异步版本方法 async def async_details(self, tv_id, season_num, episode_num, append_to_response="trailers,images,casts,translations"): """ Get the TV episode details by id.(异步版本) :param tv_id: int :param season_num: int :param episode_num: int :param append_to_response: str :return: """ return await self._async_request_obj( self._urls["details"] % (tv_id, season_num, episode_num), params="append_to_response=%s" % append_to_response ) async def async_account_states(self, tv_id, season_num, episode_num): """ Get your rating for a episode.(异步版本) :param tv_id: int :param season_num: int :param episode_num: int :return: """ return await self._async_request_obj( self._urls["account_states"] % (tv_id, season_num, episode_num), params="session_id=%s" % self.session_id ) async def async_changes(self, episode_id, start_date=None, end_date=None, page=1): """ Get the changes for a TV episode. By default only the last 24 hours are returned. You can query up to 14 days in a single query by using the start_date and end_date query parameters.(异步版本) :param episode_id: int :param start_date: str :param end_date: str :param page: int :return: """ params = "page=%s" % page if start_date: params += "&start_date=%s" % start_date if end_date: params += "&end_date=%s" % end_date return await self._async_request_obj( self._urls["changes"] % episode_id, params=params, key="changes" ) async def async_credits(self, tv_id, season_num, episode_num): """ Get the credits for TV season.(异步版本) :param tv_id: int :param season_num: int :param episode_num: int :return: """ return await self._async_request_obj(self._urls["credits"] % (tv_id, season_num, episode_num)) async def async_external_ids(self, tv_id, season_num, episode_num): """ Get the external ids for a TV episode.(异步版本) :param tv_id: int :param season_num: int :param episode_num: int :return: """ return await self._async_request_obj(self._urls["external_ids"] % (tv_id, season_num, episode_num)) async def async_images(self, tv_id, season_num, episode_num, include_image_language=None): """ Get the images that belong to a TV episode.(异步版本) :param tv_id: int :param season_num: int :param episode_num: int :param include_image_language: str :return: """ return await self._async_request_obj( self._urls["images"] % (tv_id, season_num, episode_num), params="include_image_language=%s" % include_image_language if include_image_language else "", key="stills" ) async def async_translations(self, tv_id, season_num, episode_num): """ Get the translation data for an episode.(异步版本) :param tv_id: int :param season_num: int :param episode_num: int :return: """ return await self._async_request_obj( self._urls["translations"] % (tv_id, season_num, episode_num), key="translations" ) async def async_rate_tv_episode(self, tv_id, season_num, episode_num, rating): """ Rate a TV episode.(异步版本) :param tv_id: int :param season_num: int :param episode_num: int :param rating: float """ await self._async_request_obj( self._urls["rate_tv_episode"] % (tv_id, season_num, episode_num), params="session_id=%s" % self.session_id, method="POST", json={"value": rating} ) async def async_delete_rating(self, tv_id, season_num, episode_num): """ Remove your rating for a TV episode.(异步版本) :param tv_id: int :param season_num: int :param episode_num: int """ await self._async_request_obj( self._urls["delete_rating"] % (tv_id, season_num, episode_num), params="session_id=%s" % self.session_id, method="DELETE" ) async def async_videos(self, tv_id, season_num, episode_num, include_video_language=None): """ Get the videos that have been added to a TV episode.(异步版本) :param tv_id: int :param season_num: int :param episode_num: int :param include_video_language: str :return: """ params = "" if include_video_language: params += "&include_video_language=%s" % include_video_language return await self._async_request_obj( self._urls["videos"] % (tv_id, season_num, episode_num), params=params ) ================================================ FILE: app/modules/themoviedb/tmdbv3api/objs/find.py ================================================ from ..tmdb import TMDb class Find(TMDb): _urls = { "find": "/find/%s" } def find(self, external_id, external_source): """ The find method makes it easy to search for objects in our database by an external id. For example, an IMDB ID. :param external_id: str :param external_source str :return: """ return self._request_obj( self._urls["find"] % external_id.replace("/", "%2F"), params="external_source=" + external_source ) def find_by_imdb_id(self, imdb_id): """ The find method makes it easy to search for objects in our database by an IMDB ID. :param imdb_id: str :return: """ return self.find(imdb_id, "imdb_id") def find_by_tvdb_id(self, tvdb_id): """ The find method makes it easy to search for objects in our database by a TVDB ID. :param tvdb_id: int :return: """ return self.find(tvdb_id, "tvdb_id") def find_by_freebase_mid(self, freebase_mid): """ The find method makes it easy to search for objects in our database by a Freebase MID. :param freebase_mid: str :return: """ return self.find(freebase_mid, "freebase_mid") def find_by_freebase_id(self, freebase_id): """ The find method makes it easy to search for objects in our database by a Freebase ID. :param freebase_id: str :return: """ return self.find(freebase_id, "freebase_id") def find_by_tvrage_id(self, tvrage_id): """ The find method makes it easy to search for objects in our database by a TVRage ID. :param tvrage_id: str :return: """ return self.find(tvrage_id, "tvrage_id") def find_by_facebook_id(self, facebook_id): """ The find method makes it easy to search for objects in our database by a Facebook ID. :param facebook_id: str :return: """ return self.find(facebook_id, "facebook_id") def find_by_instagram_id(self, instagram_id): """ The find method makes it easy to search for objects in our database by a Instagram ID. :param instagram_id: str :return: """ return self.find(instagram_id, "instagram_id") def find_by_twitter_id(self, twitter_id): """ The find method makes it easy to search for objects in our database by a Twitter ID. :param twitter_id: str :return: """ return self.find(twitter_id, "twitter_id") # 异步版本方法 async def async_find(self, external_id, external_source): """ The find method makes it easy to search for objects in our database by an external id. For example, an IMDB ID.(异步版本) :param external_id: str :param external_source str :return: """ return await self._async_request_obj( self._urls["find"] % external_id.replace("/", "%2F"), params="external_source=" + external_source ) async def async_find_by_imdb_id(self, imdb_id): """ The find method makes it easy to search for objects in our database by an IMDB ID.(异步版本) :param imdb_id: str :return: """ return await self.async_find(imdb_id, "imdb_id") async def async_find_by_tvdb_id(self, tvdb_id): """ The find method makes it easy to search for objects in our database by a TVDB ID.(异步版本) :param tvdb_id: int :return: """ return await self.async_find(tvdb_id, "tvdb_id") async def async_find_by_freebase_mid(self, freebase_mid): """ The find method makes it easy to search for objects in our database by a Freebase MID.(异步版本) :param freebase_mid: str :return: """ return await self.async_find(freebase_mid, "freebase_mid") async def async_find_by_freebase_id(self, freebase_id): """ The find method makes it easy to search for objects in our database by a Freebase ID.(异步版本) :param freebase_id: str :return: """ return await self.async_find(freebase_id, "freebase_id") async def async_find_by_tvrage_id(self, tvrage_id): """ The find method makes it easy to search for objects in our database by a TVRage ID.(异步版本) :param tvrage_id: str :return: """ return await self.async_find(tvrage_id, "tvrage_id") async def async_find_by_facebook_id(self, facebook_id): """ The find method makes it easy to search for objects in our database by a Facebook ID.(异步版本) :param facebook_id: str :return: """ return await self.async_find(facebook_id, "facebook_id") async def async_find_by_instagram_id(self, instagram_id): """ The find method makes it easy to search for objects in our database by a Instagram ID.(异步版本) :param instagram_id: str :return: """ return await self.async_find(instagram_id, "instagram_id") async def async_find_by_twitter_id(self, twitter_id): """ The find method makes it easy to search for objects in our database by a Twitter ID.(异步版本) :param twitter_id: str :return: """ return await self.async_find(twitter_id, "twitter_id") ================================================ FILE: app/modules/themoviedb/tmdbv3api/objs/genre.py ================================================ from ..tmdb import TMDb class Genre(TMDb): _urls = { "movie_list": "/genre/movie/list", "tv_list": "/genre/tv/list" } def movie_list(self): """ Get the list of official genres for movies. :return: """ return self._request_obj(self._urls["movie_list"], key="genres") def tv_list(self): """ Get the list of official genres for TV shows. :return: """ return self._request_obj(self._urls["tv_list"], key="genres") # 异步版本方法 async def async_movie_list(self): """ Get the list of official genres for movies.(异步版本) :return: """ return await self._async_request_obj(self._urls["movie_list"], key="genres") async def async_tv_list(self): """ Get the list of official genres for TV shows.(异步版本) :return: """ return await self._async_request_obj(self._urls["tv_list"], key="genres") ================================================ FILE: app/modules/themoviedb/tmdbv3api/objs/group.py ================================================ from ..tmdb import TMDb class Group(TMDb): _urls = { "details": "/tv/episode_group/%s" } def details(self, group_id): """ Get the details of a TV episode group. :param group_id: int :return: """ return self._request_obj(self._urls["details"] % group_id, key="groups") async def async_details(self, group_id): """ Get the details of a TV episode group.(异步版本) :param group_id: int :return: """ return await self._async_request_obj(self._urls["details"] % group_id, key="groups") ================================================ FILE: app/modules/themoviedb/tmdbv3api/objs/keyword.py ================================================ from ..tmdb import TMDb class Keyword(TMDb): _urls = { "details": "/keyword/%s", "movies": "/keyword/%s/movies" } def details(self, keyword_id): """ Get a keywords details by id. :param keyword_id: int :return: """ return self._request_obj(self._urls["details"] % keyword_id) def movies(self, keyword_id): """ Get the movies of a keyword by id. :param keyword_id: int :return: """ return self._request_obj(self._urls["movies"] % keyword_id, key="results") # 异步版本方法 async def async_details(self, keyword_id): """ Get a keywords details by id.(异步版本) :param keyword_id: int :return: """ return await self._async_request_obj(self._urls["details"] % keyword_id) async def async_movies(self, keyword_id): """ Get the movies of a keyword by id.(异步版本) :param keyword_id: int :return: """ return await self._async_request_obj(self._urls["movies"] % keyword_id, key="results") ================================================ FILE: app/modules/themoviedb/tmdbv3api/objs/list.py ================================================ from ..tmdb import TMDb class List(TMDb): _urls = { "details": "/list/%s", "check_status": "/list/%s/item_status", "create": "/list", "add_movie": "/list/%s/add_item", "remove_movie": "/list/%s/remove_item", "clear_list": "/list/%s/clear", "delete_list": "/list/%s", } def details(self, list_id): """ Get list details by id. :param list_id: int :return: """ return self._request_obj(self._urls["details"] % list_id, key="items") def check_item_status(self, list_id, movie_id): """ You can use this method to check if a movie has already been added to the list. :param list_id: int :param movie_id: int :return: """ return self._request_obj(self._urls["check_status"] % list_id, params="movie_id=%s" % movie_id)["item_present"] def create_list(self, name, description): """ You can use this method to check if a movie has already been added to the list. :param name: str :param description: str :return: """ return self._request_obj( self._urls["create"], params="session_id=%s" % self.session_id, method="POST", json={ "name": name, "description": description, "language": self.language } ).list_id def add_movie(self, list_id, movie_id): """ Add a movie to a list. :param list_id: int :param movie_id: int """ self._request_obj( self._urls["add_movie"] % list_id, params="session_id=%s" % self.session_id, method="POST", json={"media_id": movie_id} ) def remove_movie(self, list_id, movie_id): """ Remove a movie from a list. :param list_id: int :param movie_id: int """ self._request_obj( self._urls["remove_movie"] % list_id, params="session_id=%s" % self.session_id, method="POST", json={"media_id": movie_id} ) def clear_list(self, list_id): """ Clear all of the items from a list. :param list_id: int """ self._request_obj( self._urls["clear_list"] % list_id, params="session_id=%s&confirm=true" % self.session_id, method="POST" ) def delete_list(self, list_id): """ Delete a list. :param list_id: int """ self._request_obj( self._urls["delete_list"] % list_id, params="session_id=%s" % self.session_id, method="DELETE" ) # 异步版本方法 async def async_details(self, list_id): """ Get list details by id.(异步版本) :param list_id: int :return: """ return await self._async_request_obj(self._urls["details"] % list_id, key="items") async def async_check_item_status(self, list_id, movie_id): """ You can use this method to check if a movie has already been added to the list.(异步版本) :param list_id: int :param movie_id: int :return: """ result = await self._async_request_obj(self._urls["check_status"] % list_id, params="movie_id=%s" % movie_id) return result["item_present"] async def async_create_list(self, name, description): """ You can use this method to check if a movie has already been added to the list.(异步版本) :param name: str :param description: str :return: """ result = await self._async_request_obj( self._urls["create"], params="session_id=%s" % self.session_id, method="POST", json={ "name": name, "description": description, "language": self.language } ) return result.list_id async def async_add_movie(self, list_id, movie_id): """ Add a movie to a list.(异步版本) :param list_id: int :param movie_id: int """ await self._async_request_obj( self._urls["add_movie"] % list_id, params="session_id=%s" % self.session_id, method="POST", json={"media_id": movie_id} ) async def async_remove_movie(self, list_id, movie_id): """ Remove a movie from a list.(异步版本) :param list_id: int :param movie_id: int """ await self._async_request_obj( self._urls["remove_movie"] % list_id, params="session_id=%s" % self.session_id, method="POST", json={"media_id": movie_id} ) async def async_clear_list(self, list_id): """ Clear all of the items from a list.(异步版本) :param list_id: int """ await self._async_request_obj( self._urls["clear_list"] % list_id, params="session_id=%s&confirm=true" % self.session_id, method="POST" ) async def async_delete_list(self, list_id): """ Delete a list.(异步版本) :param list_id: int """ await self._async_request_obj( self._urls["delete_list"] % list_id, params="session_id=%s" % self.session_id, method="DELETE" ) ================================================ FILE: app/modules/themoviedb/tmdbv3api/objs/movie.py ================================================ from ..tmdb import TMDb class Movie(TMDb): _urls = { "details": "/movie/%s", "account_states": "/movie/%s/account_states", "alternative_titles": "/movie/%s/alternative_titles", "changes": "/movie/%s/changes", "credits": "/movie/%s/credits", "external_ids": "/movie/%s/external_ids", "images": "/movie/%s/images", "keywords": "/movie/%s/keywords", "lists": "/movie/%s/lists", "recommendations": "/movie/%s/recommendations", "release_dates": "/movie/%s/release_dates", "reviews": "/movie/%s/reviews", "similar": "/movie/%s/similar", "translations": "/movie/%s/translations", "videos": "/movie/%s/videos", "watch_providers": "/movie/%s/watch/providers", "rate_movie": "/movie/%s/rating", "delete_rating": "/movie/%s/rating", "latest": "/movie/latest", "now_playing": "/movie/now_playing", "popular": "/movie/popular", "top_rated": "/movie/top_rated", "upcoming": "/movie/upcoming", } def details(self, movie_id, append_to_response="videos,trailers,images,casts,translations,keywords,release_dates"): """ Get the primary information about a movie. :param movie_id: int :param append_to_response: str :return: """ return self._request_obj( self._urls["details"] % movie_id, params="append_to_response=%s" % append_to_response ) def account_states(self, movie_id): """ Grab the following account states for a session: Movie rating, If it belongs to your watchlist, or If it belongs to your favourite list. :param movie_id: int :return: """ return self._request_obj( self._urls["account_states"] % movie_id, params="session_id=%s" % self.session_id ) def alternative_titles(self, movie_id, country=None): """ Get all of the alternative titles for a movie. :param movie_id: int :param country: str :return: """ return self._request_obj( self._urls["alternative_titles"] % movie_id, params="country=%s" % country if country else "", key="titles" ) def changes(self, movie_id, start_date=None, end_date=None, page=1): """ Get the changes for a movie. By default only the last 24 hours are returned. You can query up to 14 days in a single query by using the start_date and end_date query parameters. :param movie_id: int :param start_date: str :param end_date: str :param page: int :return: """ params = "page=%s" % page if start_date: params += "&start_date=%s" % start_date if end_date: params += "&end_date=%s" % end_date return self._request_obj( self._urls["changes"] % movie_id, params=params, key="changes" ) def credits(self, movie_id): """ Get the cast and crew for a movie. :param movie_id: int :return: """ return self._request_obj(self._urls["credits"] % movie_id) def external_ids(self, movie_id): """ Get the external ids for a movie. :param movie_id: int :return: """ return self._request_obj(self._urls["external_ids"] % movie_id) def images(self, movie_id, include_image_language=None): """ Get the images that belong to a movie. Querying images with a language parameter will filter the results. If you want to include a fallback language (especially useful for backdrops) you can use the include_image_language parameter. This should be a comma separated value like so: include_image_language=en,null. :param movie_id: int :param include_image_language: str :return: """ return self._request_obj( self._urls["images"] % movie_id, params="include_image_language=%s" % include_image_language if include_image_language else "" ) def keywords(self, movie_id): """ Get the keywords associated to a movie. :param movie_id: int :return: """ return self._request_obj( self._urls["keywords"] % movie_id, key="keywords" ) def lists(self, movie_id, page=1): """ Get a list of lists that this movie belongs to. :param movie_id: int :param page: int :return: """ return self._request_obj( self._urls["lists"] % movie_id, params="page=%s" % page, key="results" ) def recommendations(self, movie_id, page=1): """ Get a list of recommended movies for a movie. :param movie_id: int :param page: int :return: """ return self._request_obj( self._urls["recommendations"] % movie_id, params="page=%s" % page, key="results" ) def release_dates(self, movie_id): """ Get the release date along with the certification for a movie. :param movie_id: int :return: """ return self._request_obj( self._urls["release_dates"] % movie_id, key="results" ) def reviews(self, movie_id, page=1): """ Get the user reviews for a movie. :param movie_id: int :param page: int :return: """ return self._request_obj( self._urls["reviews"] % movie_id, params="page=%s" % page, key="results" ) def similar(self, movie_id, page=1): """ Get a list of similar movies. :param movie_id: int :param page: int :return: """ return self._request_obj( self._urls["similar"] % movie_id, params="page=%s" % page, key="results" ) def translations(self, movie_id): """ Get a list of translations that have been created for a movie. :param movie_id: int :return: """ return self._request_obj( self._urls["translations"] % movie_id, key="translations" ) def videos(self, movie_id, page=1): """ Get the videos that have been added to a movie. :param movie_id: int :param page: int :return: """ return self._request_obj( self._urls["videos"] % movie_id, params="page=%s" % page, key="results" ) def watch_providers(self, movie_id): """ You can query this method to get a list of the availabilities per country by provider. :param movie_id: int :return: """ return self._request_obj( self._urls["watch_providers"] % movie_id, key="results" ) def rate_movie(self, movie_id, rating): """ Rate a movie. :param movie_id: int :param rating: float """ self._request_obj( self._urls["rate_movie"] % movie_id, params="session_id=%s" % self.session_id, method="POST", json={"value": rating} ) def delete_rating(self, movie_id): """ Remove your rating for a movie. :param movie_id: int """ self._request_obj( self._urls["delete_rating"] % movie_id, params="session_id=%s" % self.session_id, method="DELETE" ) def latest(self): """ Get the most newly created movie. This is a live response and will continuously change. :return: """ return self._request_obj(self._urls["latest"]) def now_playing(self, region=None, page=1): """ Get a list of movies in theatres. :param region: str :param page: int :return: """ params = "page=%s" % page if region: params += "®ion=%s" % region return self._request_obj( self._urls["now_playing"], params=params, key="results" ) def popular(self, region=None, page=1): """ Get a list of the current popular movies on TMDb. This list updates daily. :param region: str :param page: int :return: """ params = "page=%s" % page if region: params += "®ion=%s" % region return self._request_obj( self._urls["popular"], params=params, key="results" ) def top_rated(self, region=None, page=1): """ Get the top rated movies on TMDb. :param region: str :param page: int :return: """ params = "page=%s" % page if region: params += "®ion=%s" % region return self._request_obj( self._urls["top_rated"], params=params, key="results" ) def upcoming(self, region=None, page=1): """ Get a list of upcoming movies in theatres. :param region: str :param page: int :return: """ params = "page=%s" % page if region: params += "®ion=%s" % region return self._request_obj( self._urls["upcoming"], params=params, key="results" ) # 异步版本方法 async def async_details(self, movie_id, append_to_response="videos,trailers,images,casts,translations,keywords,release_dates"): """ Get the primary information about a movie.(异步版本) :param movie_id: int :param append_to_response: str :return: """ return await self._async_request_obj( self._urls["details"] % movie_id, params="append_to_response=%s" % append_to_response ) async def async_account_states(self, movie_id): """ Grab the following account states for a session: Movie rating, If it belongs to your watchlist, or If it belongs to your favourite list.(异步版本) :param movie_id: int :return: """ return await self._async_request_obj( self._urls["account_states"] % movie_id, params="session_id=%s" % self.session_id ) async def async_alternative_titles(self, movie_id, country=None): """ Get all of the alternative titles for a movie.(异步版本) :param movie_id: int :param country: str :return: """ return await self._async_request_obj( self._urls["alternative_titles"] % movie_id, params="country=%s" % country if country else "", key="titles" ) async def async_changes(self, movie_id, start_date=None, end_date=None, page=1): """ Get the changes for a movie. By default only the last 24 hours are returned. You can query up to 14 days in a single query by using the start_date and end_date query parameters.(异步版本) :param movie_id: int :param start_date: str :param end_date: str :param page: int :return: """ params = "page=%s" % page if start_date: params += "&start_date=%s" % start_date if end_date: params += "&end_date=%s" % end_date return await self._async_request_obj( self._urls["changes"] % movie_id, params=params, key="changes" ) async def async_credits(self, movie_id): """ Get the cast and crew for a movie.(异步版本) :param movie_id: int :return: """ return await self._async_request_obj(self._urls["credits"] % movie_id) async def async_external_ids(self, movie_id): """ Get the external ids for a movie.(异步版本) :param movie_id: int :return: """ return await self._async_request_obj(self._urls["external_ids"] % movie_id) async def async_images(self, movie_id, include_image_language=None): """ Get the images that belong to a movie. Querying images with a language parameter will filter the results. If you want to include a fallback language (especially useful for backdrops) you can use the include_image_language parameter. This should be a comma separated value like so: include_image_language=en,null.(异步版本) :param movie_id: int :param include_image_language: str :return: """ return await self._async_request_obj( self._urls["images"] % movie_id, params="include_image_language=%s" % include_image_language if include_image_language else "" ) async def async_keywords(self, movie_id): """ Get the keywords associated to a movie.(异步版本) :param movie_id: int :return: """ return await self._async_request_obj( self._urls["keywords"] % movie_id, key="keywords" ) async def async_lists(self, movie_id, page=1): """ Get a list of lists that this movie belongs to.(异步版本) :param movie_id: int :param page: int :return: """ return await self._async_request_obj( self._urls["lists"] % movie_id, params="page=%s" % page, key="results" ) async def async_recommendations(self, movie_id, page=1): """ Get a list of recommended movies for a movie.(异步版本) :param movie_id: int :param page: int :return: """ return await self._async_request_obj( self._urls["recommendations"] % movie_id, params="page=%s" % page, key="results" ) async def async_release_dates(self, movie_id): """ Get the release date along with the certification for a movie.(异步版本) :param movie_id: int :return: """ return await self._async_request_obj( self._urls["release_dates"] % movie_id, key="results" ) async def async_reviews(self, movie_id, page=1): """ Get the user reviews for a movie.(异步版本) :param movie_id: int :param page: int :return: """ return await self._async_request_obj( self._urls["reviews"] % movie_id, params="page=%s" % page, key="results" ) async def async_similar(self, movie_id, page=1): """ Get a list of similar movies.(异步版本) :param movie_id: int :param page: int :return: """ return await self._async_request_obj( self._urls["similar"] % movie_id, params="page=%s" % page, key="results" ) async def async_translations(self, movie_id): """ Get a list of translations that have been created for a movie.(异步版本) :param movie_id: int :return: """ return await self._async_request_obj( self._urls["translations"] % movie_id, key="translations" ) async def async_videos(self, movie_id, page=1): """ Get the videos that have been added to a movie.(异步版本) :param movie_id: int :param page: int :return: """ return await self._async_request_obj( self._urls["videos"] % movie_id, params="page=%s" % page, key="results" ) async def async_watch_providers(self, movie_id): """ You can query this method to get a list of the availabilities per country by provider.(异步版本) :param movie_id: int :return: """ return await self._async_request_obj( self._urls["watch_providers"] % movie_id, key="results" ) async def async_rate_movie(self, movie_id, rating): """ Rate a movie.(异步版本) :param movie_id: int :param rating: float """ await self._async_request_obj( self._urls["rate_movie"] % movie_id, params="session_id=%s" % self.session_id, method="POST", json={"value": rating} ) async def async_delete_rating(self, movie_id): """ Remove your rating for a movie.(异步版本) :param movie_id: int """ await self._async_request_obj( self._urls["delete_rating"] % movie_id, params="session_id=%s" % self.session_id, method="DELETE" ) async def async_latest(self): """ Get the most newly created movie. This is a live response and will continuously change.(异步版本) :return: """ return await self._async_request_obj(self._urls["latest"]) async def async_now_playing(self, region=None, page=1): """ Get a list of movies in theatres.(异步版本) :param region: str :param page: int :return: """ params = "page=%s" % page if region: params += "®ion=%s" % region return await self._async_request_obj( self._urls["now_playing"], params=params, key="results" ) async def async_popular(self, region=None, page=1): """ Get a list of the current popular movies on TMDb. This list updates daily.(异步版本) :param region: str :param page: int :return: """ params = "page=%s" % page if region: params += "®ion=%s" % region return await self._async_request_obj( self._urls["popular"], params=params, key="results" ) async def async_top_rated(self, region=None, page=1): """ Get the top rated movies on TMDb.(异步版本) :param region: str :param page: int :return: """ params = "page=%s" % page if region: params += "®ion=%s" % region return await self._async_request_obj( self._urls["top_rated"], params=params, key="results" ) async def async_upcoming(self, region=None, page=1): """ Get a list of upcoming movies in theatres.(异步版本) :param region: str :param page: int :return: """ params = "page=%s" % page if region: params += "®ion=%s" % region return await self._async_request_obj( self._urls["upcoming"], params=params, key="results" ) ================================================ FILE: app/modules/themoviedb/tmdbv3api/objs/network.py ================================================ from ..tmdb import TMDb class Network(TMDb): _urls = { "details": "/network/%s", "alternative_names": "/network/%s/alternative_names", "images": "/network/%s/images" } def details(self, network_id): """ Get a networks details by id. :param network_id: int :return: """ return self._request_obj(self._urls["details"] % network_id) async def async_details(self, network_id): """ Get a networks details by id.(异步版本) :param network_id: int :return: """ return await self._async_request_obj(self._urls["details"] % network_id) def alternative_names(self, network_id): """ Get the alternative names of a network. :param network_id: int :return: """ return self._request_obj( self._urls["alternative_names"] % network_id, key="results" ) async def async_alternative_names(self, network_id): """ Get the alternative names of a network.(异步版本) :param network_id: int :return: """ return await self._async_request_obj( self._urls["alternative_names"] % network_id, key="results" ) def images(self, network_id): """ Get the TV network logos by id. :param network_id: int :return: """ return self._request_obj( self._urls["images"] % network_id, key="logos" ) async def async_images(self, network_id): """ Get the TV network logos by id.(异步版本) :param network_id: int :return: """ return await self._async_request_obj( self._urls["images"] % network_id, key="logos" ) ================================================ FILE: app/modules/themoviedb/tmdbv3api/objs/person.py ================================================ from ..tmdb import TMDb class Person(TMDb): _urls = { "details": "/person/%s", "changes": "/person/%s/changes", "movie_credits": "/person/%s/movie_credits", "tv_credits": "/person/%s/tv_credits", "combined_credits": "/person/%s/combined_credits", "external_ids": "/person/%s/external_ids", "images": "/person/%s/images", "tagged_images": "/person/%s/tagged_images", "translations": "/person/%s/translations", "latest": "/person/latest", "popular": "/person/popular", } def details(self, person_id, append_to_response="videos,images"): """ Get the primary person details by id. :param append_to_response: str :param person_id: int :return: """ return self._request_obj( self._urls["details"] % person_id, params="append_to_response=%s" % append_to_response ) def changes(self, person_id, start_date=None, end_date=None, page=1): """ Get the changes for a person. By default only the last 24 hours are returned. You can query up to 14 days in a single query by using the start_date and end_date query parameters. :param person_id: int :param start_date: str :param end_date: str :param page: int :return: """ params = "page=%s" % page if start_date: params += "&start_date=%s" % start_date if end_date: params += "&end_date=%s" % end_date return self._request_obj( self._urls["changes"] % person_id, params=params, key="changes" ) def movie_credits(self, person_id): """ Get the movie credits for a person. :param person_id: int :return: """ return self._request_obj(self._urls["movie_credits"] % person_id) def tv_credits(self, person_id): """ Get the TV show credits for a person. :param person_id: int :return: """ return self._request_obj(self._urls["tv_credits"] % person_id) def combined_credits(self, person_id): """ Get the movie and TV credits together in a single response. :param person_id: int :return: """ return self._request_obj(self._urls["combined_credits"] % person_id) def external_ids(self, person_id): """ Get the external ids for a person. We currently support the following external sources. IMDB ID, Facebook, Freebase MID, Freebase ID, Instagram, TVRage ID, and Twitter :param person_id: int :return: """ return self._request_obj(self._urls["external_ids"] % person_id) def images(self, person_id): """ Get the images for a person. :param person_id: int :return: """ return self._request_obj( self._urls["images"] % person_id, key="profiles" ) def tagged_images(self, person_id, page=1): """ Get the images that this person has been tagged in. :param person_id: int :param page: int :return: """ return self._request_obj( self._urls["tagged_images"] % person_id, params="page=%s" % page, key="results" ) def translations(self, person_id): """ Get a list of translations that have been created for a person. :param person_id: int :return: """ return self._request_obj( self._urls["translations"] % person_id, key="translations" ) def latest(self): """ Get the most newly created person. This is a live response and will continuously change. :return: """ return self._request_obj(self._urls["latest"]) def popular(self, page=1): """ Get the list of popular people on TMDb. This list updates daily. :param page: int :return: """ return self._request_obj( self._urls["popular"], params="page=%s" % page, key="results" ) # 异步版本方法 async def async_details(self, person_id, append_to_response="videos,images"): """ Get the primary person details by id.(异步版本) :param append_to_response: str :param person_id: int :return: """ return await self._async_request_obj( self._urls["details"] % person_id, params="append_to_response=%s" % append_to_response ) async def async_changes(self, person_id, start_date=None, end_date=None, page=1): """ Get the changes for a person. By default only the last 24 hours are returned. You can query up to 14 days in a single query by using the start_date and end_date query parameters.(异步版本) :param person_id: int :param start_date: str :param end_date: str :param page: int :return: """ params = "page=%s" % page if start_date: params += "&start_date=%s" % start_date if end_date: params += "&end_date=%s" % end_date return await self._async_request_obj( self._urls["changes"] % person_id, params=params, key="changes" ) async def async_movie_credits(self, person_id): """ Get the movie credits for a person.(异步版本) :param person_id: int :return: """ return await self._async_request_obj(self._urls["movie_credits"] % person_id) async def async_tv_credits(self, person_id): """ Get the TV show credits for a person.(异步版本) :param person_id: int :return: """ return await self._async_request_obj(self._urls["tv_credits"] % person_id) async def async_combined_credits(self, person_id): """ Get the movie and TV credits together in a single response.(异步版本) :param person_id: int :return: """ return await self._async_request_obj(self._urls["combined_credits"] % person_id) async def async_external_ids(self, person_id): """ Get the external ids for a person. We currently support the following external sources. IMDB ID, Facebook, Freebase MID, Freebase ID, Instagram, TVRage ID, and Twitter(异步版本) :param person_id: int :return: """ return await self._async_request_obj(self._urls["external_ids"] % person_id) async def async_images(self, person_id): """ Get the images for a person.(异步版本) :param person_id: int :return: """ return await self._async_request_obj( self._urls["images"] % person_id, key="profiles" ) async def async_tagged_images(self, person_id, page=1): """ Get the images that this person has been tagged in.(异步版本) :param person_id: int :param page: int :return: """ return await self._async_request_obj( self._urls["tagged_images"] % person_id, params="page=%s" % page, key="results" ) async def async_translations(self, person_id): """ Get a list of translations that have been created for a person.(异步版本) :param person_id: int :return: """ return await self._async_request_obj( self._urls["translations"] % person_id, key="translations" ) async def async_latest(self): """ Get the most newly created person. This is a live response and will continuously change.(异步版本) :return: """ return await self._async_request_obj(self._urls["latest"]) async def async_popular(self, page=1): """ Get the list of popular people on TMDb. This list updates daily.(异步版本) :param page: int :return: """ return await self._async_request_obj( self._urls["popular"], params="page=%s" % page, key="results" ) ================================================ FILE: app/modules/themoviedb/tmdbv3api/objs/provider.py ================================================ from ..tmdb import TMDb class Provider(TMDb): _urls = { "regions": "/watch/providers/regions", # TODO: "movie": "/watch/providers/movie", # TODO: "tv": "/watch/providers/tv", # TODO: } def available_regions(self): """ Returns a list of all of the countries we have watch provider (OTT/streaming) data for. :return: """ return self._request_obj( self._urls["regions"], key="results" ) async def async_available_regions(self): """ Returns a list of all of the countries we have watch provider (OTT/streaming) data for.(异步版本) :return: """ return await self._async_request_obj( self._urls["regions"], key="results" ) def movie_providers(self, region=None): """ Returns a list of the watch provider (OTT/streaming) data we have available for movies. :return: """ return self._request_obj( self._urls["movie"], params="watch_region=%s" % region if region else "", key="results" ) async def async_movie_providers(self, region=None): """ Returns a list of the watch provider (OTT/streaming) data we have available for movies.(异步版本) :return: """ return await self._async_request_obj( self._urls["movie"], params="watch_region=%s" % region if region else "", key="results" ) def tv_providers(self, region=None): """ Returns a list of the watch provider (OTT/streaming) data we have available for TV series. :return: """ return self._request_obj( self._urls["tv"], params="watch_region=%s" % region if region else "", key="results" ) async def async_tv_providers(self, region=None): """ Returns a list of the watch provider (OTT/streaming) data we have available for TV series.(异步版本) :return: """ return await self._async_request_obj( self._urls["tv"], params="watch_region=%s" % region if region else "", key="results" ) ================================================ FILE: app/modules/themoviedb/tmdbv3api/objs/review.py ================================================ from ..tmdb import TMDb class Review(TMDb): _urls = { "details": "/review/%s", } def details(self, review_id): """ Get the primary person details by id. :param review_id: int :return: """ return self._request_obj(self._urls["details"] % review_id) async def async_details(self, review_id): """ Get the primary person details by id.(异步版本) :param review_id: int :return: """ return await self._async_request_obj(self._urls["details"] % review_id) ================================================ FILE: app/modules/themoviedb/tmdbv3api/objs/search.py ================================================ from ..tmdb import TMDb try: from urllib import quote except ImportError: from urllib.parse import quote class Search(TMDb): _urls = { "companies": "/search/company", "collections": "/search/collection", "keywords": "/search/keyword", "movies": "/search/movie", "multi": "/search/multi", "people": "/search/person", "tv_shows": "/search/tv", } def companies(self, term, page=1): """ Search for companies. :param term: str :param page: int :return: """ return self._request_obj( self._urls["companies"], params="query=%s&page=%s" % (quote(term), page), key="results" ) def collections(self, term, page=1): """ Search for collections. :param term: str :param page: int :return: """ return self._request_obj( self._urls["collections"], params="query=%s&page=%s" % (quote(term), page), key="results" ) def keywords(self, term, page=1): """ Search for keywords. :param term: str :param page: int :return: """ return self._request_obj( self._urls["keywords"], params="query=%s&page=%s" % (quote(term), page), key="results" ) def movies(self, term, adult=None, region=None, year=None, release_year=None, page=1): """ Search for movies. :param term: str :param adult: bool :param region: str :param year: int :param release_year: int :param page: int :return: """ params = "query=%s&page=%s" % (quote(term), page) if adult is not None: params += "&include_adult=%s" % "true" if adult else "false" if region is not None: params += "®ion=%s" % quote(region) if year is not None: params += "&year=%s" % year if release_year is not None: params += "&primary_release_year=%s" % release_year return self._request_obj( self._urls["movies"], params=params, key="results" ) def multi(self, term, adult=None, region=None, page=1): """ Search multiple models in a single request. Multi search currently supports searching for movies, tv shows and people in a single request. :param term: str :param adult: bool :param region: str :param page: int :return: """ params = "query=%s&page=%s" % (quote(term), page) if adult is not None: params += "&include_adult=%s" % "true" if adult else "false" if region is not None: params += "®ion=%s" % quote(region) return self._request_obj( self._urls["multi"], params=params, key="results" ) def people(self, term, adult=None, region=None, page=1): """ Search for people. :param term: str :param adult: bool :param region: str :param page: int :return: """ params = "query=%s&page=%s" % (quote(term), page) if adult is not None: params += "&include_adult=%s" % "true" if adult else "false" if region is not None: params += "®ion=%s" % quote(region) return self._request_obj( self._urls["people"], params=params, key="results" ) def tv_shows(self, term, adult=None, release_year=None, page=1): """ Search for a TV show. :param term: str :param adult: bool :param release_year: int :param page: int :return: """ params = "query=%s&page=%s" % (quote(term), page) if adult is not None: params += "&include_adult=%s" % "true" if adult else "false" if release_year is not None: params += "&first_air_date_year=%s" % release_year return self._request_obj( self._urls["tv_shows"], params=params, key="results" ) # 异步版本方法 async def async_companies(self, term, page=1): """ Search for companies.(异步版本) :param term: str :param page: int :return: """ return await self._async_request_obj( self._urls["companies"], params="query=%s&page=%s" % (quote(term), page), key="results" ) async def async_collections(self, term, page=1): """ Search for collections.(异步版本) :param term: str :param page: int :return: """ return await self._async_request_obj( self._urls["collections"], params="query=%s&page=%s" % (quote(term), page), key="results" ) async def async_keywords(self, term, page=1): """ Search for keywords.(异步版本) :param term: str :param page: int :return: """ return await self._async_request_obj( self._urls["keywords"], params="query=%s&page=%s" % (quote(term), page), key="results" ) async def async_people(self, term, adult=None, region=None, page=1): """ Search for people.(异步版本) :param term: str :param adult: bool :param region: str :param page: int :return: """ params = "query=%s&page=%s" % (quote(term), page) if adult is not None: params += "&include_adult=%s" % "true" if adult else "false" if region is not None: params += "®ion=%s" % quote(region) return await self._async_request_obj( self._urls["people"], params=params, key="results" ) async def async_multi(self, term, adult=None, region=None, page=1): """ Search multiple models in a single request.(异步版本) Multi search currently supports searching for movies, tv shows and people in a single request. :param term: str :param adult: bool :param region: str :param page: int :return: """ params = "query=%s&page=%s" % (quote(term), page) if adult is not None: params += "&include_adult=%s" % "true" if adult else "false" if region is not None: params += "®ion=%s" % quote(region) return await self._async_request_obj( self._urls["multi"], params=params, key="results" ) async def async_movies(self, term, adult=None, region=None, year=None, release_year=None, page=1): """ Search for movies.(异步版本) :param term: str :param adult: bool :param region: str :param year: int :param release_year: int :param page: int :return: """ params = "query=%s&page=%s" % (quote(term), page) if adult is not None: params += "&include_adult=%s" % "true" if adult else "false" if region is not None: params += "®ion=%s" % quote(region) if year is not None: params += "&year=%s" % year if release_year is not None: params += "&primary_release_year=%s" % release_year return await self._async_request_obj( self._urls["movies"], params=params, key="results" ) async def async_tv_shows(self, term, adult=None, release_year=None, page=1): """ Search for a TV show.(异步版本) :param term: str :param adult: bool :param release_year: int :param page: int :return: """ params = "query=%s&page=%s" % (quote(term), page) if adult is not None: params += "&include_adult=%s" % "true" if adult else "false" if release_year is not None: params += "&first_air_date_year=%s" % release_year return await self._async_request_obj( self._urls["tv_shows"], params=params, key="results" ) ================================================ FILE: app/modules/themoviedb/tmdbv3api/objs/season.py ================================================ from ..tmdb import TMDb class Season(TMDb): _urls = { "details": "/tv/%s/season/%s", "account_states": "/tv/%s/season/%s/account_states", "aggregate_credits": "/tv/%s/season/%s/aggregate_credits", "changes": "/tv/season/%s/changes", "credits": "/tv/%s/season/%s/credits", "external_ids": "/tv/%s/season/%s/external_ids", "images": "/tv/%s/season/%s/images", "translations": "/tv/%s/season/%s/translations", "videos": "/tv/%s/season/%s/videos", } def details(self, tv_id, season_num, append_to_response="videos,trailers,images,credits,translations"): """ Get the TV season details by id. :param tv_id: int :param season_num: int :param append_to_response: str :return: """ return self._request_obj( self._urls["details"] % (tv_id, season_num), params="append_to_response=%s" % append_to_response ) def account_states(self, tv_id, season_num): """ Get all of the user ratings for the season's episodes. :param tv_id: int :param season_num: int :return: """ return self._request_obj( self._urls["account_states"] % (tv_id, season_num), params="session_id=%s" % self.session_id, key="results" ) def aggregate_credits(self, tv_id, season_num): """ Get the aggregate credits for TV season. This call differs from the main credits call in that it does not only return the season credits, but rather is a view of all the cast & crew for all of the episodes belonging to a season. :param tv_id: int :param season_num: int :return: """ return self._request_obj(self._urls["aggregate_credits"] % (tv_id, season_num)) def changes(self, season_id, start_date=None, end_date=None, page=1): """ Get the changes for a TV season. By default only the last 24 hours are returned. You can query up to 14 days in a single query by using the start_date and end_date query parameters. :param season_id: int :param start_date: str :param end_date: str :param page: int :return: """ params = "page=%s" % page if start_date: params += "&start_date=%s" % start_date if end_date: params += "&end_date=%s" % end_date return self._request_obj( self._urls["changes"] % season_id, params=params, key="changes" ) def credits(self, tv_id, season_num): """ Get the credits for TV season. :param tv_id: int :param season_num: int :return: """ return self._request_obj(self._urls["credits"] % (tv_id, season_num)) def external_ids(self, tv_id, season_num): """ Get the external ids for a TV season. :param tv_id: int :param season_num: int :return: """ return self._request_obj(self._urls["external_ids"] % (tv_id, season_num)) def images(self, tv_id, season_num, include_image_language=None): """ Get the images that belong to a TV season. :param tv_id: int :param season_num: int :param include_image_language: str :return: """ return self._request_obj( self._urls["images"] % (tv_id, season_num), params="include_image_language=%s" % include_image_language if include_image_language else "", key="posters" ) def translations(self, tv_id, season_num): """ Get a list of the translations that exist for a TV show. :param tv_id: int :param season_num: int """ return self._request_obj( self._urls["translations"] % (tv_id, season_num), key="translations" ) def videos(self, tv_id, season_num, include_video_language=None, page=1): """ Get the videos that have been added to a TV show. :param tv_id: int :param season_num: int :param include_video_language: str :param page: int :return: """ params = "page=%s" % page if include_video_language: params += "&include_video_language=%s" % include_video_language return self._request_obj( self._urls["videos"] % (tv_id, season_num), params=params ) # 异步版本方法 async def async_details(self, tv_id, season_num, append_to_response="videos,trailers,images,credits,translations"): """ Get the TV season details by id.(异步版本) :param tv_id: int :param season_num: int :param append_to_response: str :return: """ return await self._async_request_obj( self._urls["details"] % (tv_id, season_num), params="append_to_response=%s" % append_to_response ) async def async_account_states(self, tv_id, season_num): """ Get all of the user ratings for the season's episodes.(异步版本) :param tv_id: int :param season_num: int :return: """ return await self._async_request_obj( self._urls["account_states"] % (tv_id, season_num), params="session_id=%s" % self.session_id, key="results" ) async def async_aggregate_credits(self, tv_id, season_num): """ Get the aggregate credits for TV season. This call differs from the main credits call in that it does not only return the season credits, but rather is a view of all the cast & crew for all of the episodes belonging to a season.(异步版本) :param tv_id: int :param season_num: int :return: """ return await self._async_request_obj(self._urls["aggregate_credits"] % (tv_id, season_num)) async def async_changes(self, season_id, start_date=None, end_date=None, page=1): """ Get the changes for a TV season. By default only the last 24 hours are returned. You can query up to 14 days in a single query by using the start_date and end_date query parameters.(异步版本) :param season_id: int :param start_date: str :param end_date: str :param page: int :return: """ params = "page=%s" % page if start_date: params += "&start_date=%s" % start_date if end_date: params += "&end_date=%s" % end_date return await self._async_request_obj( self._urls["changes"] % season_id, params=params, key="changes" ) async def async_credits(self, tv_id, season_num): """ Get the credits for TV season.(异步版本) :param tv_id: int :param season_num: int :return: """ return await self._async_request_obj(self._urls["credits"] % (tv_id, season_num)) async def async_external_ids(self, tv_id, season_num): """ Get the external ids for a TV season.(异步版本) :param tv_id: int :param season_num: int :return: """ return await self._async_request_obj(self._urls["external_ids"] % (tv_id, season_num)) async def async_images(self, tv_id, season_num, include_image_language=None): """ Get the images that belong to a TV season.(异步版本) :param tv_id: int :param season_num: int :param include_image_language: str :return: """ return await self._async_request_obj( self._urls["images"] % (tv_id, season_num), params="include_image_language=%s" % include_image_language if include_image_language else "", key="posters" ) async def async_translations(self, tv_id, season_num): """ Get a list of the translations that exist for a TV show.(异步版本) :param tv_id: int :param season_num: int """ return await self._async_request_obj( self._urls["translations"] % (tv_id, season_num), key="translations" ) async def async_videos(self, tv_id, season_num, include_video_language=None, page=1): """ Get the videos that have been added to a TV show.(异步版本) :param tv_id: int :param season_num: int :param include_video_language: str :param page: int :return: """ params = "page=%s" % page if include_video_language: params += "&include_video_language=%s" % include_video_language return await self._async_request_obj( self._urls["videos"] % (tv_id, season_num), params=params ) ================================================ FILE: app/modules/themoviedb/tmdbv3api/objs/trending.py ================================================ from ..tmdb import TMDb class Trending(TMDb): _urls = {"trending": "/trending/%s/%s"} def _trending(self, media_type="all", time_window="day", page=1): """ Get trending, TTLCache 12 hours """ return self._request_obj( self._urls["trending"] % (media_type, time_window), params="page=%s" % page, key="results", call_cached=False ) def all_day(self, page=1): """ Get all daily trending :param page: int :return: """ return self._trending(media_type="all", time_window="day", page=page) def all_week(self, page=1): """ Get all weekly trending :param page: int :return: """ return self._trending(media_type="all", time_window="week", page=page) def movie_day(self, page=1): """ Get movie daily trending :param page: int :return: """ return self._trending(media_type="movie", time_window="day", page=page) def movie_week(self, page=1): """ Get movie weekly trending :param page: int :return: """ return self._trending(media_type="movie", time_window="week", page=page) def tv_day(self, page=1): """ Get tv daily trending :param page: int :return: """ return self._trending(media_type="tv", time_window="day", page=page) def tv_week(self, page=1): """ Get tv weekly trending :param page: int :return: """ return self._trending(media_type="tv", time_window="week", page=page) def person_day(self, page=1): """ Get person daily trending :param page: int :return: """ return self._trending(media_type="person", time_window="day", page=page) def person_week(self, page=1): """ Get person weekly trending :param page: int :return: """ return self._trending(media_type="person", time_window="week", page=page) # 异步版本方法 async def _async_trending(self, media_type="all", time_window="day", page=1): """ Get trending, TTLCache 12 hours(异步版本) """ return await self._async_request_obj( self._urls["trending"] % (media_type, time_window), params="page=%s" % page, key="results", call_cached=False ) async def async_all_day(self, page=1): """ Get all daily trending(异步版本) :param page: int :return: """ return await self._async_trending(media_type="all", time_window="day", page=page) async def async_all_week(self, page=1): """ Get all weekly trending(异步版本) :param page: int :return: """ return await self._async_trending(media_type="all", time_window="week", page=page) async def async_movie_day(self, page=1): """ Get movie daily trending(异步版本) :param page: int :return: """ return await self._async_trending(media_type="movie", time_window="day", page=page) async def async_movie_week(self, page=1): """ Get movie weekly trending(异步版本) :param page: int :return: """ return await self._async_trending(media_type="movie", time_window="week", page=page) async def async_tv_day(self, page=1): """ Get tv daily trending(异步版本) :param page: int :return: """ return await self._async_trending(media_type="tv", time_window="day", page=page) async def async_tv_week(self, page=1): """ Get tv weekly trending(异步版本) :param page: int :return: """ return await self._async_trending(media_type="tv", time_window="week", page=page) async def async_person_day(self, page=1): """ Get person daily trending(异步版本) :param page: int :return: """ return await self._async_trending(media_type="person", time_window="day", page=page) async def async_person_week(self, page=1): """ Get person weekly trending(异步版本) :param page: int :return: """ return await self._async_trending(media_type="person", time_window="week", page=page) ================================================ FILE: app/modules/themoviedb/tmdbv3api/objs/tv.py ================================================ from ..tmdb import TMDb try: from urllib import quote except ImportError: from urllib.parse import quote class TV(TMDb): _urls = { "details": "/tv/%s", "account_states": "/tv/%s/account_states", "aggregate_credits": "/tv/%s/aggregate_credits", "alternative_titles": "/tv/%s/alternative_titles", "changes": "/tv/%s/changes", "content_ratings": "/tv/%s/content_ratings", "credits": "/tv/%s/credits", "episode_groups": "/tv/%s/episode_groups", "external_ids": "/tv/%s/external_ids", "images": "/tv/%s/images", "keywords": "/tv/%s/keywords", "recommendations": "/tv/%s/recommendations", "reviews": "/tv/%s/reviews", "screened_theatrically": "/tv/%s/screened_theatrically", "similar": "/tv/%s/similar", "translations": "/tv/%s/translations", "videos": "/tv/%s/videos", "watch_providers": "/tv/%s/watch/providers", "rate_tv_show": "/tv/%s/rating", "delete_rating": "/tv/%s/rating", "latest": "/tv/latest", "airing_today": "/tv/airing_today", "on_the_air": "/tv/on_the_air", "popular": "/tv/popular", "top_rated": "/tv/top_rated", "group_episodes": "/tv/episode_group/%s", } def details(self, tv_id, append_to_response="videos,trailers,images,credits,translations"): """ Get the primary TV show details by id. :param tv_id: int :param append_to_response: str :return: """ return self._request_obj( self._urls["details"] % tv_id, params="append_to_response=%s" % append_to_response, ) def account_states(self, tv_id): """ Grab the following account states for a session: TV show rating, If it belongs to your watchlist, or If it belongs to your favourite list. :param tv_id: int :return: """ return self._request_obj( self._urls["account_states"] % tv_id, params="session_id=%s" % self.session_id ) def aggregate_credits(self, tv_id): """ Get the aggregate credits (cast and crew) that have been added to a TV show. This call differs from the main credits call in that it does not return the newest season but rather, is a view of all the entire cast & crew for all episodes belonging to a TV show. :param tv_id: int :return: """ return self._request_obj(self._urls["aggregate_credits"] % tv_id) def alternative_titles(self, tv_id): """ Returns all of the alternative titles for a TV show. :param tv_id: int :return: """ return self._request_obj( self._urls["alternative_titles"] % tv_id, key="results" ) def changes(self, tv_id, start_date=None, end_date=None, page=1): """ Get the changes for a TV show. By default only the last 24 hours are returned. You can query up to 14 days in a single query by using the start_date and end_date query parameters. :param tv_id: int :param start_date: str :param end_date: str :param page: int """ params = "page=%s" % page if start_date: params += "&start_date=%s" % start_date if end_date: params += "&end_date=%s" % end_date return self._request_obj( self._urls["changes"] % tv_id, params=params, key="changes" ) def content_ratings(self, tv_id): """ Get the list of content ratings (certifications) that have been added to a TV show. :param tv_id: int :return: """ return self._request_obj( self._urls["content_ratings"] % tv_id, key="results" ) def credits(self, tv_id): """ Get the credits (cast and crew) that have been added to a TV show. :param tv_id: int :return: """ return self._request_obj(self._urls["credits"] % tv_id) def episode_groups(self, tv_id): """ Get all of the episode groups that have been created for a TV show. :param tv_id: int :return: """ return self._request_obj( self._urls["episode_groups"] % tv_id, key="results" ) def group_episodes(self, group_id): """ 查询剧集组所有剧集 :param group_id: int :return: """ return self._request_obj( self._urls["group_episodes"] % group_id, key="groups" ) def external_ids(self, tv_id): """ Get the external ids for a TV show. :param tv_id: int :return: """ return self._request_obj(self._urls["external_ids"] % tv_id) def images(self, tv_id, include_image_language=None): """ Get the images that belong to a TV show. Querying images with a language parameter will filter the results. If you want to include a fallback language (especially useful for backdrops) you can use the include_image_language parameter. This should be a comma separated value like so: include_image_language=en,null. :param tv_id: int :param include_image_language: str :return: """ return self._request_obj( self._urls["images"] % tv_id, params="include_image_language=%s" % include_image_language if include_image_language else "" ) def keywords(self, tv_id): """ Get the keywords that have been added to a TV show. :param tv_id: int :return: """ return self._request_obj( self._urls["keywords"] % tv_id, key="results" ) def recommendations(self, tv_id, page=1): """ Get the list of TV show recommendations for this item. :param tv_id: int :param page: int :return: """ return self._request_obj( self._urls["recommendations"] % tv_id, params="page=%s" % page, key="results" ) def reviews(self, tv_id, page=1): """ Get the reviews for a TV show. :param tv_id: int :param page: int :return: """ return self._request_obj( self._urls["reviews"] % tv_id, params="page=%s" % page, key="results" ) def screened_theatrically(self, tv_id): """ Get a list of seasons or episodes that have been screened in a film festival or theatre. :param tv_id: int :return: """ return self._request_obj( self._urls["screened_theatrically"] % tv_id, key="results" ) def similar(self, tv_id, page=1): """ Get the primary TV show details by id. :param tv_id: int :param page: int :return: """ return self._request_obj( self._urls["similar"] % tv_id, params="page=%s" % page, key="results" ) def translations(self, tv_id): """ Get a list of the translations that exist for a TV show. :param tv_id: int :return: """ return self._request_obj( self._urls["translations"] % tv_id, key="translations" ) def videos(self, tv_id, include_video_language=None, page=1): """ Get the videos that have been added to a TV show. :param tv_id: int :param include_video_language: str :param page: int :return: """ params = "page=%s" % page if include_video_language: params += "&include_video_language=%s" % include_video_language return self._request_obj( self._urls["videos"] % tv_id, params=params ) def watch_providers(self, tv_id): """ You can query this method to get a list of the availabilities per country by provider. :param tv_id: int :return: """ return self._request_obj( self._urls["watch_providers"] % tv_id, key="results" ) def rate_tv_show(self, tv_id, rating): """ Rate a TV show. :param tv_id: int :param rating: float """ self._request_obj( self._urls["rate_tv_show"] % tv_id, params="session_id=%s" % self.session_id, method="POST", json={"value": rating} ) def delete_rating(self, tv_id): """ Remove your rating for a TV show. :param tv_id: int """ self._request_obj( self._urls["delete_rating"] % tv_id, params="session_id=%s" % self.session_id, method="DELETE" ) def latest(self): """ Get the most newly created TV show. This is a live response and will continuously change. :return: """ return self._request_obj(self._urls["latest"]) def airing_today(self, page=1): """ Get a list of TV shows that are airing today. This query is purely day based as we do not currently support airing times. :param page: int :return: """ return self._request_obj( self._urls["airing_today"], params="page=%s" % page, key="results" ) def on_the_air(self, page=1): """ Get a list of shows that are currently on the air. :param page: :return: """ return self._request_obj( self._urls["on_the_air"], params="page=%s" % page, key="results" ) def popular(self, page=1): """ Get a list of the current popular TV shows on TMDb. This list updates daily. :param page: :return: """ return self._request_obj( self._urls["popular"], params="page=%s" % page, key="results" ) def top_rated(self, page=1): """ Get a list of the top rated TV shows on TMDb. :param page: :return: """ return self._request_obj( self._urls["top_rated"], params="page=%s" % page, key="results" ) # 异步版本方法 async def async_details(self, tv_id, append_to_response="videos,trailers,images,credits,translations"): """ Get the primary TV show details by id.(异步版本) :param tv_id: int :param append_to_response: str :return: """ return await self._async_request_obj( self._urls["details"] % tv_id, params="append_to_response=%s" % append_to_response, ) async def async_account_states(self, tv_id): """ Grab the following account states for a session: TV show rating, If it belongs to your watchlist, or If it belongs to your favourite list.(异步版本) :param tv_id: int :return: """ return await self._async_request_obj( self._urls["account_states"] % tv_id, params="session_id=%s" % self.session_id ) async def async_aggregate_credits(self, tv_id): """ Get the aggregate credits (cast and crew) that have been added to a TV show. This call differs from the main credits call in that it does not return the newest season but rather, is a view of all the entire cast & crew for all episodes belonging to a TV show.(异步版本) :param tv_id: int :return: """ return await self._async_request_obj(self._urls["aggregate_credits"] % tv_id) async def async_alternative_titles(self, tv_id): """ Returns all of the alternative titles for a TV show.(异步版本) :param tv_id: int :return: """ return await self._async_request_obj( self._urls["alternative_titles"] % tv_id, key="results" ) async def async_changes(self, tv_id, start_date=None, end_date=None, page=1): """ Get the changes for a TV show. By default only the last 24 hours are returned. You can query up to 14 days in a single query by using the start_date and end_date query parameters.(异步版本) :param tv_id: int :param start_date: str :param end_date: str :param page: int """ params = "page=%s" % page if start_date: params += "&start_date=%s" % start_date if end_date: params += "&end_date=%s" % end_date return await self._async_request_obj( self._urls["changes"] % tv_id, params=params, key="changes" ) async def async_content_ratings(self, tv_id): """ Get the list of content ratings (certifications) that have been added to a TV show.(异步版本) :param tv_id: int :return: """ return await self._async_request_obj( self._urls["content_ratings"] % tv_id, key="results" ) async def async_credits(self, tv_id): """ Get the credits (cast and crew) that have been added to a TV show.(异步版本) :param tv_id: int :return: """ return await self._async_request_obj(self._urls["credits"] % tv_id) async def async_episode_groups(self, tv_id): """ Get all of the episode groups that have been created for a TV show.(异步版本) :param tv_id: int :return: """ return await self._async_request_obj( self._urls["episode_groups"] % tv_id, key="results" ) async def async_group_episodes(self, group_id): """ 查询剧集组所有剧集(异步版本) :param group_id: int :return: """ return await self._async_request_obj( self._urls["group_episodes"] % group_id, key="groups" ) async def async_external_ids(self, tv_id): """ Get the external ids for a TV show.(异步版本) :param tv_id: int :return: """ return await self._async_request_obj(self._urls["external_ids"] % tv_id) async def async_images(self, tv_id, include_image_language=None): """ Get the images that belong to a TV show. Querying images with a language parameter will filter the results. If you want to include a fallback language (especially useful for backdrops) you can use the include_image_language parameter. This should be a comma separated value like so: include_image_language=en,null.(异步版本) :param tv_id: int :param include_image_language: str :return: """ return await self._async_request_obj( self._urls["images"] % tv_id, params="include_image_language=%s" % include_image_language if include_image_language else "" ) async def async_keywords(self, tv_id): """ Get the keywords that have been added to a TV show.(异步版本) :param tv_id: int :return: """ return await self._async_request_obj( self._urls["keywords"] % tv_id, key="results" ) async def async_recommendations(self, tv_id, page=1): """ Get the list of TV show recommendations for this item.(异步版本) :param tv_id: int :param page: int :return: """ return await self._async_request_obj( self._urls["recommendations"] % tv_id, params="page=%s" % page, key="results" ) async def async_reviews(self, tv_id, page=1): """ Get the reviews for a TV show.(异步版本) :param tv_id: int :param page: int :return: """ return await self._async_request_obj( self._urls["reviews"] % tv_id, params="page=%s" % page, key="results" ) async def async_screened_theatrically(self, tv_id): """ Get a list of seasons or episodes that have been screened in a film festival or theatre.(异步版本) :param tv_id: int :return: """ return await self._async_request_obj( self._urls["screened_theatrically"] % tv_id, key="results" ) async def async_similar(self, tv_id, page=1): """ Get the primary TV show details by id.(异步版本) :param tv_id: int :param page: int :return: """ return await self._async_request_obj( self._urls["similar"] % tv_id, params="page=%s" % page, key="results" ) async def async_translations(self, tv_id): """ Get a list of the translations that exist for a TV show.(异步版本) :param tv_id: int :return: """ return await self._async_request_obj( self._urls["translations"] % tv_id, key="translations" ) async def async_videos(self, tv_id, include_video_language=None, page=1): """ Get the videos that have been added to a TV show.(异步版本) :param tv_id: int :param include_video_language: str :param page: int :return: """ params = "page=%s" % page if include_video_language: params += "&include_video_language=%s" % include_video_language return await self._async_request_obj( self._urls["videos"] % tv_id, params=params ) async def async_watch_providers(self, tv_id): """ You can query this method to get a list of the availabilities per country by provider.(异步版本) :param tv_id: int :return: """ return await self._async_request_obj( self._urls["watch_providers"] % tv_id, key="results" ) async def async_rate_tv_show(self, tv_id, rating): """ Rate a TV show.(异步版本) :param tv_id: int :param rating: float """ await self._async_request_obj( self._urls["rate_tv_show"] % tv_id, params="session_id=%s" % self.session_id, method="POST", json={"value": rating} ) async def async_delete_rating(self, tv_id): """ Remove your rating for a TV show.(异步版本) :param tv_id: int """ await self._async_request_obj( self._urls["delete_rating"] % tv_id, params="session_id=%s" % self.session_id, method="DELETE" ) async def async_latest(self): """ Get the most newly created TV show. This is a live response and will continuously change.(异步版本) :return: """ return await self._async_request_obj(self._urls["latest"]) async def async_airing_today(self, page=1): """ Get a list of TV shows that are airing today. This query is purely day based as we do not currently support airing times.(异步版本) :param page: int :return: """ return await self._async_request_obj( self._urls["airing_today"], params="page=%s" % page, key="results" ) async def async_on_the_air(self, page=1): """ Get a list of shows that are currently on the air.(异步版本) :param page: :return: """ return await self._async_request_obj( self._urls["on_the_air"], params="page=%s" % page, key="results" ) async def async_popular(self, page=1): """ Get a list of the current popular TV shows on TMDb. This list updates daily.(异步版本) :param page: :return: """ return await self._async_request_obj( self._urls["popular"], params="page=%s" % page, key="results" ) async def async_top_rated(self, page=1): """ Get a list of the top rated TV shows on TMDb.(异步版本) :param page: :return: """ return await self._async_request_obj( self._urls["top_rated"], params="page=%s" % page, key="results" ) ================================================ FILE: app/modules/themoviedb/tmdbv3api/tmdb.py ================================================ # -*- coding: utf-8 -*- import asyncio import logging import time from datetime import datetime import requests import requests.exceptions from app.core.cache import cached, fresh, async_fresh from app.core.config import settings from app.utils.http import RequestUtils, AsyncRequestUtils from .exceptions import TMDbException logger = logging.getLogger(__name__) class TMDb(object): def __init__(self, session=None, language=None): self._api_key = settings.TMDB_API_KEY self._language = language or settings.TMDB_LOCALE or "en-US" self._session_id = None self._session = session self._wait_on_rate_limit = True self._proxies = settings.PROXY self._domain = settings.TMDB_API_DOMAIN self._page = None self._total_results = None self._total_pages = None if not self._session: self._session = requests.Session() self._req = RequestUtils(ua=settings.NORMAL_USER_AGENT, session=self._session, proxies=self.proxies) self._async_req = AsyncRequestUtils(ua=settings.NORMAL_USER_AGENT, proxies=self.proxies) self._remaining = 40 self._reset = None self._timeout = 15 @property def page(self): return self._page @property def total_results(self): return self._total_results @property def total_pages(self): return self._total_pages @property def api_key(self): return self._api_key @property def domain(self): return self._domain @property def proxies(self): return self._proxies @proxies.setter def proxies(self, proxies): self._proxies = proxies @api_key.setter def api_key(self, api_key): self._api_key = str(api_key) @domain.setter def domain(self, domain): self._domain = str(domain) @property def language(self): return self._language @language.setter def language(self, language): self._language = language @property def has_session(self): return True if self._session_id else False @property def session_id(self): if not self._session_id: raise TMDbException("Must Authenticate to create a session run Authentication(username, password)") return self._session_id @session_id.setter def session_id(self, session_id): self._session_id = session_id @property def wait_on_rate_limit(self): return self._wait_on_rate_limit @wait_on_rate_limit.setter def wait_on_rate_limit(self, wait_on_rate_limit): self._wait_on_rate_limit = bool(wait_on_rate_limit) @cached(maxsize=settings.CONF.tmdb, ttl=settings.CONF.meta, skip_none=True) def request(self, method, url, data, json, **kwargs): if method == "GET": req = self._req.get_res(url, params=data, json=json) else: req = self._req.post_res(url, data=data, json=json) if req is None: raise TMDbException("无法连接TheMovieDb,请检查网络连接!") return req @cached(maxsize=settings.CONF.tmdb, ttl=settings.CONF.meta, skip_none=True) async def async_request(self, method, url, data, json, **kwargs): if method == "GET": req = await self._async_req.get_res(url, params=data, json=json) else: req = await self._async_req.post_res(url, data=data, json=json) if req is None: raise TMDbException("无法连接TheMovieDb,请检查网络连接!") return req def cache_clear(self): return self.request.cache_clear() def _validate_api_key(self): if self.api_key is None or self.api_key == "": raise TMDbException("TheMovieDb API Key 未设置!") def _build_url(self, action, params=""): return "https://%s/3%s?api_key=%s&%s&language=%s" % ( self.domain, action, self.api_key, params, self.language, ) def _handle_headers(self, headers): if "X-RateLimit-Remaining" in headers: self._remaining = int(headers["X-RateLimit-Remaining"]) if "X-RateLimit-Reset" in headers: self._reset = int(headers["X-RateLimit-Reset"]) def _handle_rate_limit(self): if self._remaining < 1: current_time = int(time.time()) sleep_time = self._reset - current_time if self.wait_on_rate_limit: logger.warning("达到请求频率限制,休眠:%d 秒..." % sleep_time) return abs(sleep_time) else: raise TMDbException("达到请求频率限制,请稍后再试!") return 0 def _process_json_response(self, json_data, is_async=False): if "page" in json_data: self._page = json_data["page"] if "total_results" in json_data: self._total_results = json_data["total_results"] if "total_pages" in json_data: self._total_pages = json_data["total_pages"] @staticmethod def _handle_errors(json_data): if "errors" in json_data: raise TMDbException(json_data["errors"]) if "success" in json_data and json_data["success"] is False: raise TMDbException(json_data["status_message"]) def _request_obj(self, action, params="", call_cached=True, method="GET", data=None, json=None, key=None): self._validate_api_key() url = self._build_url(action, params) with fresh(not call_cached or method == "POST"): req = self.request(method, url, data, json, _ts=datetime.strftime(datetime.now(), '%Y%m%d')) if req is None: return None self._handle_headers(req.headers) rate_limit_result = self._handle_rate_limit() if rate_limit_result: logger.warning("达到请求频率限制,将在 %d 秒后重试..." % rate_limit_result) time.sleep(rate_limit_result) return self._request_obj(action, params, False, method, data, json, key) json_data = req.json() self._process_json_response(json_data, is_async=False) self._handle_errors(json_data) if key: return json_data.get(key) return json_data async def _async_request_obj(self, action, params="", call_cached=True, method="GET", data=None, json=None, key=None): self._validate_api_key() url = self._build_url(action, params) async with async_fresh(not call_cached or method == "POST"): req = await self.async_request(method, url, data, json, _ts=datetime.strftime(datetime.now(), '%Y%m%d')) if req is None: return None self._handle_headers(req.headers) rate_limit_result = self._handle_rate_limit() if rate_limit_result: logger.warning("达到请求频率限制,将在 %d 秒后重试..." % rate_limit_result) await asyncio.sleep(rate_limit_result) return await self._async_request_obj(action, params, False, method, data, json, key) json_data = req.json() self._process_json_response(json_data, is_async=True) self._handle_errors(json_data) if key: return json_data.get(key) return json_data def close(self): if self._session: self._session.close() ================================================ FILE: app/modules/thetvdb/__init__.py ================================================ from threading import Lock from typing import Optional, Tuple, Union from app.core.config import settings from app.log import logger from app.modules import _ModuleBase from app.modules.thetvdb import tvdb_v4_official from app.schemas.types import ModuleType, MediaRecognizeType class TheTvDbModule(_ModuleBase): """ TVDB媒体信息匹配 """ __timeout: int = 15 tvdb: Optional[tvdb_v4_official.TVDB] = None __auth_lock = Lock() def init_module(self) -> None: pass def _initialize_tvdb_session(self, is_retry: bool = False) -> None: """ 创建或刷新 TVDB 登录会话。 :param is_retry: 是否是由于token失效后的重试登录 """ action = "刷新" if is_retry else "创建" logger.info(f"开始{action}TVDB登录会话...") try: if not settings.TVDB_V4_API_KEY: raise ConnectionError("TVDB API Key 未配置,无法初始化会话。") self.tvdb = tvdb_v4_official.TVDB(apikey=settings.TVDB_V4_API_KEY, pin=settings.TVDB_V4_API_PIN, proxy=settings.PROXY, timeout=self.__timeout) if self.tvdb: logger.info(f"TVDB登录会话{action}成功。") else: raise ValueError(f"TVDB登录会话{action}后实例仍为None。") except Exception as e: self.tvdb = None raise ConnectionError(f"TVDB登录会话{action}失败: {str(e)}") from e def _ensure_tvdb_session(self, is_retry: bool = False) -> None: """ 确保TVDB会话存在。如果不存在或需要强制重新初始化,则进行初始化。 :param is_retry: 是否重新初始化(例如token失效时) """ # 第一次检查 (无锁),提高性能,避免不必要锁竞争 if not self.tvdb or is_retry: with self.__auth_lock: # 第二次检查 (有锁),防止多个线程都通过第一次检查后重复初始化 if not self.tvdb or is_retry: self._initialize_tvdb_session(is_retry=is_retry) def _handle_tvdb_call(self, method_name: str, *args, **kwargs): """ 包裹 TVDB 调用,处理 token 失效情况并尝试重新初始化 :param method_name: 要在 self.tvdb 实例上调用的方法的名称 (字符串) """ try: self._ensure_tvdb_session() actual_method = getattr(self.tvdb, method_name) return actual_method(*args, **kwargs) except ValueError as e: if "Unauthorized" in str(e): logger.warning("TVDB Token 可能已失效,正在尝试重新登录...") try: self._ensure_tvdb_session(is_retry=True) actual_method = getattr(self.tvdb, method_name) return actual_method(*args, **kwargs) except ConnectionError as conn_err: logger.error(f"TVDB Token失效后重新登录失败: {conn_err}") raise elif "NotFoundException" in str(e) or "ID not found" in str(e): logger.warning(f"TVDB 资源未找到 (调用 {method_name}): {e}") return None else: logger.error(f"TVDB 调用 ({method_name}) 时发生未处理的 ValueError: {str(e)}") raise except ConnectionError as e: logger.error(f"TVDB 连接会话错误: {str(e)}") raise except AttributeError as e: logger.error(f"TVDB 实例上没有方法 '{method_name}': {e}") raise except Exception as e: logger.error(f"TVDB 调用时发生未知错误: {str(e)}", exc_info=True) raise @staticmethod def get_name() -> str: return "TheTvDb" @staticmethod def get_type() -> ModuleType: """ 获取模块类型 """ return ModuleType.MediaRecognize @staticmethod def get_subtype() -> MediaRecognizeType: """ 获取模块子类型 """ return MediaRecognizeType.TVDB @staticmethod def get_priority() -> int: """ 获取模块优先级,数字越小优先级越高,只有同一接口下优先级才生效 """ return 4 def stop(self): logger.info("TheTvDbModule 停止。正在清除 TVDB 会话。") with self.__auth_lock: self.tvdb = None def test(self) -> Tuple[bool, str]: """ 测试模块连接性 """ try: self._handle_tvdb_call("get_series", 81189) return True, "" except Exception as e: return False, str(e) def init_setting(self) -> Tuple[str, Union[str, bool]]: pass def tvdb_info(self, tvdbid: int) -> Optional[dict]: """ 获取TVDB信息 :param tvdbid: int :return: TVDB信息 """ try: logger.info(f"开始获取TVDB信息: {tvdbid} ...") return self._handle_tvdb_call("get_series_extended", tvdbid) except Exception as err: logger.error(f"获取TVDB信息失败: {str(err)}") return None def search_tvdb(self, title: str) -> list: """ 用标题搜索TVDB剧集 :param title: 标题 :return: TVDB信息 """ try: logger.info(f"开始用标题搜索TVDB剧集: {title} ...") res = self._handle_tvdb_call("search", title) if res is None: return [] if not isinstance(res, list): logger.warning(f"TVDB 搜索 '{title}' 未返回列表:{type(res)}") return [] return [item for item in res if isinstance(item, dict) and item.get("type") == "series"] except Exception as err: logger.error(f"用标题搜索TVDB剧集失败 ({title}): {str(err)}") return [] def clear_cache(self): """ 清除缓存 """ logger.info(f"开始清除{self.get_name()}缓存 ...") if tvdb := self.tvdb: tvdb.clear_cache() logger.info(f"{self.get_name()}缓存清除完成") ================================================ FILE: app/modules/thetvdb/tvdb_v4_official.py ================================================ """Official python package for using the tvdb v4 api""" __author__ = "Weylin Wagnon" __version__ = "1.0.12" import json import urllib.parse from http import HTTPStatus from app.core.cache import cached from app.core.config import settings from app.utils.http import RequestUtils class Auth: """ TVDB认证类 """ def __init__(self, url: str, apikey: str, pin: str = "", proxy: dict = None, timeout: int = 15): login_info = {"apikey": apikey} if pin != "": login_info["pin"] = pin login_info_bytes = json.dumps(login_info, indent=2) try: # 使用项目统一的RequestUtils类 req_utils = RequestUtils(proxies=proxy, timeout=timeout) response = req_utils.post_res( url=url, data=login_info_bytes, headers={"Content-Type": "application/json"} ) if response and response.status_code == 200: result = response.json() self.token = result["data"]["token"] else: if response is not None: try: error_data = response.json() error_msg = f"Code: {response.status_code}, {error_data.get('message', '未知错误')}" except Exception as err: error_msg = f"Code: {response.status_code}, 响应解析失败:{err}" else: error_msg = "网络连接失败,未收到响应" raise Exception(error_msg) except Exception as e: raise Exception(f"TVDB认证失败: {str(e)}") def get_token(self): """ 获取认证token """ return self.token class Request: """ 请求处理类 """ def __init__(self, auth_token: str, proxy: dict = None, timeout: int = 15): self.auth_token = auth_token self.links = None self.proxy = proxy self.timeout = timeout @cached(maxsize=settings.CONF.tmdb, ttl=settings.CONF.meta, skip_none=True) def make_request(self, url: str, if_modified_since: bool = None): """ 向指定的 URL 发起请求并返回数据 """ headers = {"Authorization": f"Bearer {self.auth_token}"} if if_modified_since: headers["If-Modified-Since"] = str(if_modified_since) try: # 使用项目统一的RequestUtils类 req_utils = RequestUtils(proxies=self.proxy, timeout=self.timeout) response = req_utils.get_res(url=url, headers=headers) if response is None: raise ValueError(f"获取 {url} 失败\n 网络连接失败") if response.status_code == HTTPStatus.NOT_MODIFIED: return { "code": HTTPStatus.NOT_MODIFIED.real, "message": "Not-Modified", } if response.status_code == 200: result = response.json() data = result.get("data", None) if data is not None and result.get("status", "failure") != "failure": self.links = result.get("links", None) return data msg = result.get("message", "未知错误") raise ValueError(f"获取 {url} 失败\n {str(msg)}") else: # 处理其他HTTP错误状态码 try: error_data = response.json() msg = error_data.get("message", f"HTTP {response.status_code}") except Exception as err: msg = f"HTTP {response.status_code} {err}" raise ValueError(f"获取 {url} 失败\n {str(msg)}") except Exception as e: if isinstance(e, ValueError): raise raise ValueError(f"获取 {url} 失败\n {str(e)}") class Url: """ URL构建类 """ def __init__(self): self.base_url = "https://api4.thetvdb.com/v4/" def construct(self, url_sect: str, url_id: int = None, url_subsect: str = None, url_lang: str = None, **kwargs): """ 构建API URL """ url = self.base_url + url_sect if url_id: url += "/" + str(url_id) if url_subsect: url += "/" + url_subsect if url_lang: url += "/" + url_lang if kwargs: params = {var: val for var, val in kwargs.items() if val is not None} if params: url += "?" + urllib.parse.urlencode(params) return url class TVDB: """ TVDB API主类 """ def __init__(self, apikey: str, pin: str = "", proxy: dict = None, timeout: int = 15): self.url = Url() login_url = self.url.construct("login") self.auth = Auth(login_url, apikey, pin, proxy, timeout) auth_token = self.auth.get_token() self.request = Request(auth_token, proxy, timeout) def get_req_links(self) -> dict: """ 获取上一次请求返回的链接信息(例如分页链接) """ return self.request.links def get_artwork_statuses(self, meta: str = None, if_modified_since: bool = None) -> list: """ 返回艺术图状态列表 """ url = self.url.construct("artwork/statuses", meta=meta) return self.request.make_request(url, if_modified_since) def get_artwork_types(self, meta: str = None, if_modified_since: bool = None) -> list: """ 返回艺术图类型列表 """ url = self.url.construct("artwork/types", meta=meta) return self.request.make_request(url, if_modified_since) def get_artwork(self, id: int, meta: str = None, if_modified_since: bool = None) -> dict: """ 返回单个艺术图信息的字典 """ url = self.url.construct("artwork", id, meta=meta) return self.request.make_request(url, if_modified_since) def get_artwork_extended(self, id: int, meta: str = None, if_modified_since: bool = None) -> dict: """ 返回单个艺术图的扩展信息字典 """ url = self.url.construct("artwork", id, "extended", meta=meta) return self.request.make_request(url, if_modified_since) def get_all_awards(self, meta: str = None, if_modified_since: bool = None) -> list: """ 返回奖项列表 """ url = self.url.construct("awards", meta=meta) return self.request.make_request(url, if_modified_since) def get_award(self, id: int, meta: str = None, if_modified_since: bool = None) -> dict: """ 返回单个奖项信息的字典 """ url = self.url.construct("awards", id, meta=meta) return self.request.make_request(url, if_modified_since) def get_award_extended(self, id: int, meta: str = None, if_modified_since: bool = None) -> dict: """ 返回单个奖项的扩展信息字典 """ url = self.url.construct("awards", id, "extended", meta=meta) return self.request.make_request(url, if_modified_since) def get_all_award_categories(self, meta: str = None, if_modified_since: bool = None) -> list: """ 返回奖项类别列表 """ url = self.url.construct("awards/categories", meta=meta) return self.request.make_request(url, if_modified_since) def get_award_category(self, id: int, meta: str = None, if_modified_since: bool = None) -> dict: """ 返回单个奖项类别信息的字典 """ url = self.url.construct("awards/categories", id, meta=meta) return self.request.make_request(url, if_modified_since) def get_award_category_extended(self, id: int, meta: str = None, if_modified_since: bool = None) -> dict: """ 返回单个奖项类别的扩展信息字典 """ url = self.url.construct("awards/categories", id, "extended", meta=meta) return self.request.make_request(url, if_modified_since) def get_content_ratings(self, meta: str = None, if_modified_since: bool = None) -> list: """ 返回内容分级列表 """ url = self.url.construct("content/ratings", meta=meta) return self.request.make_request(url, if_modified_since) def get_countries(self, meta: str = None, if_modified_since: bool = None) -> list: """ 返回国家列表 """ url = self.url.construct("countries", meta=meta) return self.request.make_request(url, if_modified_since) def get_all_companies(self, page: int = None, meta: str = None, if_modified_since: bool = None) -> list: """ 返回公司列表 (可分页) """ url = self.url.construct("companies", page=page, meta=meta) return self.request.make_request(url, if_modified_since) def get_company_types(self, meta: str = None, if_modified_since: bool = None) -> list: """ 返回公司类型列表 """ url = self.url.construct("companies/types", meta=meta) return self.request.make_request(url, if_modified_since) def get_company(self, id: int, meta: str = None, if_modified_since: bool = None) -> dict: """ 返回单个公司信息的字典 """ url = self.url.construct("companies", id, meta=meta) return self.request.make_request(url, if_modified_since) def get_all_series(self, page: int = None, meta: str = None, if_modified_since: bool = None) -> list: """ 返回剧集列表 (可分页) """ url = self.url.construct("series", page=page, meta=meta) return self.request.make_request(url, if_modified_since) def get_series(self, id: int, meta: str = None, if_modified_since: bool = None) -> dict: """ 返回单个剧集信息的字典 """ url = self.url.construct("series", id, meta=meta) return self.request.make_request(url, if_modified_since) def get_series_by_slug(self, slug: str, meta: str = None, if_modified_since: bool = None) -> dict: """ 通过 slug (别名) 返回单个剧集信息的字典 """ url = self.url.construct("series/slug", slug, meta=meta) return self.request.make_request(url, if_modified_since) def get_series_extended(self, id: int, meta=None, short=False, if_modified_since=None) -> dict: """ 返回单个剧集的扩展信息字典 """ url = self.url.construct("series", id, "extended", meta=meta, short=short) return self.request.make_request(url, if_modified_since) def get_series_episodes(self, id: int, season_type: str = "default", page: int = 0, lang: str = None, meta: str = None, if_modified_since: bool = None, **kwargs) -> dict: """ 返回指定剧集和季类型的各集信息字典 (可分页,可指定语言) """ url = self.url.construct( "series", id, "episodes/" + season_type, lang, page=page, meta=meta, **kwargs ) return self.request.make_request(url, if_modified_since) def get_series_translation(self, id: int, lang: str, meta: str = None, if_modified_since: bool = None) -> dict: """ 返回剧集的指定语言翻译信息字典 """ url = self.url.construct("series", id, "translations", lang, meta=meta) return self.request.make_request(url, if_modified_since) def get_series_artworks(self, id: int, lang: str, type=None, if_modified_since=None) -> dict: """ 返回包含艺术图数组的剧集记录 (可指定语言和类型) """ url = self.url.construct("series", id, "artworks", lang=lang, type=type) return self.request.make_request(url, if_modified_since) def get_series_next_aired(self, id: int, if_modified_since=None) -> dict: """ 返回剧集的下一播出信息字典 """ url = self.url.construct("series", id, "nextAired") return self.request.make_request(url, if_modified_since) def get_all_movies(self, page: int = None, meta: str = None, if_modified_since: bool = None) -> list: """ 返回电影列表 (可分页) """ url = self.url.construct("movies", page=page, meta=meta) return self.request.make_request(url, if_modified_since) def get_movie(self, id: int, meta: str = None, if_modified_since: bool = None) -> dict: """ 返回单个电影信息的字典 """ url = self.url.construct("movies", id, meta=meta) return self.request.make_request(url, if_modified_since) def get_movie_by_slug(self, slug: str, meta: str = None, if_modified_since: bool = None) -> dict: """ 通过 slug (别名) 返回单个电影信息的字典 """ url = self.url.construct("movies/slug", slug, meta=meta) return self.request.make_request(url, if_modified_since) def get_movie_extended(self, id: int, meta=None, short=False, if_modified_since=None) -> dict: """ 返回电影的扩展信息字典 """ url = self.url.construct("movies", id, "extended", meta=meta, short=short) return self.request.make_request(url, if_modified_since) def get_movie_translation(self, id: int, lang: str, meta: str = None, if_modified_since: bool = None) -> dict: """ 返回电影的指定语言翻译信息字典 """ url = self.url.construct("movies", id, "translations", lang, meta=meta) return self.request.make_request(url, if_modified_since) def get_all_seasons(self, page: int = None, meta: str = None, if_modified_since: bool = None) -> list: """ 返回季列表 (可分页) """ url = self.url.construct("seasons", page=page, meta=meta) return self.request.make_request(url, if_modified_since) def get_season(self, id: int, meta: str = None, if_modified_since: bool = None) -> dict: """ 返回单季信息的字典 """ url = self.url.construct("seasons", id, meta=meta) return self.request.make_request(url, if_modified_since) def get_season_extended(self, id: int, meta: str = None, if_modified_since: bool = None) -> dict: """ 返回单季的扩展信息字典 """ url = self.url.construct("seasons", id, "extended", meta=meta) return self.request.make_request(url, if_modified_since) def get_season_types(self, meta: str = None, if_modified_since: bool = None) -> list: """ 返回季类型列表 """ url = self.url.construct("seasons/types", meta=meta) return self.request.make_request(url, if_modified_since) def get_season_translation(self, id: int, lang: str, meta: str = None, if_modified_since: bool = None) -> dict: """ 返回季的指定语言翻译信息字典 """ url = self.url.construct("seasons", id, "translations", lang, meta=meta) return self.request.make_request(url, if_modified_since) def get_all_episodes(self, page: int = None, meta: str = None, if_modified_since: bool = None) -> list: """ 返回集列表 (可分页) """ url = self.url.construct("episodes", page=page, meta=meta) return self.request.make_request(url, if_modified_since) def get_episode(self, id: int, meta: str = None, if_modified_since: bool = None) -> dict: """ 返回单集信息的字典 """ url = self.url.construct("episodes", id, meta=meta) return self.request.make_request(url, if_modified_since) def get_episode_extended(self, id: int, meta: str = None, if_modified_since: bool = None) -> dict: """ 返回单集的扩展信息字典 """ url = self.url.construct("episodes", id, "extended", meta=meta) return self.request.make_request(url, if_modified_since) def get_episode_translation(self, id: int, lang: str, meta: str = None, if_modified_since: bool = None) -> dict: """ 返回单集的指定语言翻译信息字典 """ url = self.url.construct("episodes", id, "translations", lang, meta=meta) return self.request.make_request(url, if_modified_since) # 兼容旧函数名。 get_episodes_translation = get_episode_translation def get_all_genders(self, meta: str = None, if_modified_since: bool = None) -> list: """ 返回性别列表 """ url = self.url.construct("genders", meta=meta) return self.request.make_request(url, if_modified_since) def get_all_genres(self, meta: str = None, if_modified_since: bool = None) -> list: """ 返回类型(流派)列表 """ url = self.url.construct("genres", meta=meta) return self.request.make_request(url, if_modified_since) def get_genre(self, id: int, meta: str = None, if_modified_since: bool = None) -> dict: """ 返回单个类型(流派)信息的字典 """ url = self.url.construct("genres", id, meta=meta) return self.request.make_request(url, if_modified_since) def get_all_languages(self, meta: str = None, if_modified_since: bool = None) -> list: """ 返回语言列表 """ url = self.url.construct("languages", meta=meta) return self.request.make_request(url, if_modified_since) def get_all_people(self, page: int = None, meta: str = None, if_modified_since: bool = None) -> list: """ 返回人物列表 (可分页) """ url = self.url.construct("people", page=page, meta=meta) return self.request.make_request(url, if_modified_since) def get_person(self, id: int, meta: str = None, if_modified_since: bool = None) -> dict: """ 返回单个人物信息的字典 """ url = self.url.construct("people", id, meta=meta) return self.request.make_request(url, if_modified_since) def get_person_extended(self, id: int, meta: str = None, if_modified_since: bool = None) -> dict: """ 返回单个人物的扩展信息字典 """ url = self.url.construct("people", id, "extended", meta=meta) return self.request.make_request(url, if_modified_since) def get_person_translation(self, id: int, lang: str, meta: str = None, if_modified_since: bool = None) -> dict: """ 返回人物的指定语言翻译信息字典 """ url = self.url.construct("people", id, "translations", lang, meta=meta) return self.request.make_request(url, if_modified_since) def get_character(self, id: int, meta: str = None, if_modified_since: bool = None) -> dict: """ 返回角色信息的字典 """ url = self.url.construct("characters", id, meta=meta) return self.request.make_request(url, if_modified_since) def get_people_types(self, meta: str = None, if_modified_since: bool = None) -> list: """ 返回人物类型列表 """ url = self.url.construct("people/types", meta=meta) return self.request.make_request(url, if_modified_since) # 兼容旧函数名 get_all_people_types = get_people_types def get_source_types(self, meta: str = None, if_modified_since: bool = None) -> list: """ 返回来源类型列表 """ url = self.url.construct("sources/types", meta=meta) return self.request.make_request(url, if_modified_since) # 兼容旧函数名 get_all_sourcetypes = get_source_types def get_updates(self, since: int, **kwargs) -> list: """ 返回更新列表 """ url = self.url.construct("updates", since=since, **kwargs) return self.request.make_request(url) def get_all_tag_options(self, page: int = None, meta: str = None, if_modified_since: bool = None) -> list: """ 返回标签选项列表 (可分页) """ url = self.url.construct("tags/options", page=page, meta=meta) return self.request.make_request(url, if_modified_since) def get_tag_option(self, id: int, meta: str = None, if_modified_since: bool = None) -> dict: """ 返回单个标签选项信息的字典 """ url = self.url.construct("tags/options", id, meta=meta) return self.request.make_request(url, if_modified_since) def get_all_lists(self, page: int = None, meta=None) -> dict: """ 返回所有公开的列表信息 (可分页) """ url = self.url.construct("lists", page=page, meta=meta) return self.request.make_request(url) def get_list(self, id: int, meta: str = None, if_modified_since: bool = None) -> dict: """ 返回单个列表信息的字典 """ url = self.url.construct("lists", id, meta=meta) return self.request.make_request(url, if_modified_since) def get_list_by_slug(self, slug: str, meta: str = None, if_modified_since: bool = None) -> dict: """ 通过 slug (别名) 返回单个列表信息的字典 """ url = self.url.construct("lists/slug", slug, meta=meta) return self.request.make_request(url, if_modified_since) def get_list_extended(self, id: int, meta: str = None, if_modified_since: bool = None) -> dict: """ 返回单个列表的扩展信息字典 """ url = self.url.construct("lists", id, "extended", meta=meta) return self.request.make_request(url, if_modified_since) def get_list_translation(self, id: int, lang: str, meta: str = None, if_modified_since: bool = None) -> dict: """ 返回列表的指定语言翻译信息字典 """ url = self.url.construct("lists", id, "translations", lang, meta=meta) return self.request.make_request(url, if_modified_since) def get_inspiration_types(self, meta: str = None, if_modified_since: bool = None) -> dict: """ 返回灵感类型列表 """ url = self.url.construct("inspiration/types", meta=meta) return self.request.make_request(url, if_modified_since) def search(self, query: str, **kwargs) -> list: """ 根据查询字符串进行搜索,并返回结果列表 """ url = self.url.construct("search", query=query, **kwargs) return self.request.make_request(url) def search_by_remote_id(self, remoteid: str) -> list: """ 通过外部 ID 精确匹配搜索,并返回结果列表 """ url = self.url.construct("search/remoteid", remoteid) return self.request.make_request(url) def get_tags(self, slug: str, if_modified_since=None) -> dict: """ 返回具有指定 slug 的标签实体信息字典 (此方法基于的 /entities/{slug} 端点非标准,请谨慎使用) """ url = self.url.construct("entities", url_subsect=slug) return self.request.make_request(url, if_modified_since) def get_entities_types(self, if_modified_since=None) -> dict: """ 返回可用的实体类型列表 """ url = self.url.construct("entities") return self.request.make_request(url, if_modified_since) def get_user_by_id(self, id: int) -> dict: """ 通过用户 ID 返回用户信息字典 """ url = self.url.construct("user", id) return self.request.make_request(url) def get_user(self) -> dict: """ 返回当前认证的用户信息字典 """ url = self.url.construct("user") return self.request.make_request(url) def get_user_favorites(self) -> dict: """ 返回当前认证用户的收藏夹信息字典 """ url = self.url.construct('user/favorites') return self.request.make_request(url) def clear_cache(self): """ 清除缓存 """ self.request.make_request.cache_clear() ================================================ FILE: app/modules/transmission/__init__.py ================================================ from pathlib import Path from typing import Set, Tuple, Optional, Union, List, Dict from torrentool.torrent import Torrent from transmission_rpc import File from app import schemas from app.core.cache import FileCache from app.core.config import settings from app.core.metainfo import MetaInfo from app.log import logger from app.modules import _ModuleBase, _DownloaderBase from app.modules.transmission.transmission import Transmission from app.schemas import TransferTorrent, DownloadingTorrent from app.schemas.types import TorrentStatus, ModuleType, DownloaderType from app.utils.string import StringUtils class TransmissionModule(_ModuleBase, _DownloaderBase[Transmission]): def init_module(self) -> None: """ 初始化模块 """ super().init_service(service_name=Transmission.__name__.lower(), service_type=Transmission) @staticmethod def get_name() -> str: return "Transmission" @staticmethod def get_type() -> ModuleType: """ 获取模块类型 """ return ModuleType.Downloader @staticmethod def get_subtype() -> DownloaderType: """ 获取模块子类型 """ return DownloaderType.Transmission @staticmethod def get_priority() -> int: """ 获取模块优先级,数字越小优先级越高,只有同一接口下优先级才生效 """ return 2 def stop(self): pass def test(self) -> Optional[Tuple[bool, str]]: """ 测试模块连接性 """ if not self.get_instances(): return None for name, server in self.get_instances().items(): if server.is_inactive(): server.reconnect() if not server.transfer_info(): return False, f"无法连接Transmission下载器:{name}" return True, "" def init_setting(self) -> Tuple[str, Union[str, bool]]: pass def scheduler_job(self) -> None: """ 定时任务,每10分钟调用一次 """ # 定时重连 for name, server in self.get_instances().items(): if server.is_inactive(): logger.info(f"Transmission下载器 {name} 连接断开,尝试重连 ...") server.reconnect() def download(self, content: Union[Path, str, bytes], download_dir: Path, cookie: str, episodes: Set[int] = None, category: Optional[str] = None, label: Optional[str] = None, downloader: Optional[str] = None) -> Optional[Tuple[Optional[str], Optional[str], Optional[str], str]]: """ 根据种子文件,选择并添加下载任务 :param content: 种子文件地址或者磁力链接或种子内容 :param download_dir: 下载目录 :param cookie: cookie :param episodes: 需要下载的集数 :param category: 分类,TR中未使用 :param label: 标签 :param downloader: 下载器 :return: 下载器名称、种子Hash、种子文件布局、错误原因 """ def __get_torrent_info() -> Tuple[Optional[Torrent], Optional[bytes]]: """ 获取种子名称 """ torrent_info, torrent_content = None, None try: if isinstance(content, Path): if content.exists(): torrent_content = content.read_bytes() else: # 读取缓存的种子文件 torrent_content = FileCache().get(content.as_posix(), region="torrents") else: torrent_content = content if torrent_content: # 检查是否为磁力链接 if StringUtils.is_magnet_link(torrent_content): return None, torrent_content else: torrent_info = Torrent.from_string(torrent_content) return torrent_info, torrent_content except Exception as e: logger.error(f"获取种子名称失败:{e}") return None, None if not content: return None, None, None, "下载内容为空" # 读取种子的名称 torrent_from_file, content = __get_torrent_info() # 检查是否为磁力链接 is_magnet = isinstance(content, str) and content.startswith("magnet:") or isinstance(content, bytes) and content.startswith( b"magnet:") if not torrent_from_file and not is_magnet: return None, None, None, f"添加种子任务失败:无法读取种子文件" # 获取下载器 server: Transmission = self.get_instance(downloader) if not server: return None # 如果要选择文件则先暂停 is_paused = True if episodes else False # 标签 if label: labels = label.split(',') elif settings.TORRENT_TAG: labels = settings.TORRENT_TAG.split(',') else: labels = None # 添加任务 added_torrent = server.add_torrent( content=content, download_dir=self.normalize_path(download_dir, downloader), is_paused=is_paused, labels=labels, cookie=cookie ) # TR 始终使用原始种子布局, 返回"Original" torrent_layout = "Original" if not added_torrent: # 查询所有下载器的种子 torrents, error = server.get_torrents() if error: return None, None, None, "无法连接transmission下载器" if torrents: try: for torrent in torrents: # 名称与大小相等则认为是同一个种子 if torrent.name == getattr(torrent_from_file, 'name', '') and torrent.total_size == getattr(torrent_from_file, 'total_size', 0): torrent_hash = torrent.hashString logger.warn(f"下载器中已存在该种子任务:{torrent_hash} - {torrent.name}") # 给种子打上标签 if settings.TORRENT_TAG: logger.info(f"给种子 {torrent_hash} 打上标签:{settings.TORRENT_TAG}") # 种子标签 labels = [str(tag).strip() for tag in torrent.labels] if hasattr(torrent, "labels") else [] if "已整理" in labels: labels.remove("已整理") server.set_torrent_tag(ids=torrent_hash, tags=labels) if settings.TORRENT_TAG and settings.TORRENT_TAG not in labels: labels.append(settings.TORRENT_TAG) server.set_torrent_tag(ids=torrent_hash, tags=labels) return downloader or self.get_default_config_name(), torrent_hash, torrent_layout, f"下载任务已存在" finally: torrents.clear() del torrents return None, None, None, f"添加种子任务失败:{content}" else: torrent_hash = added_torrent.hashString if is_paused: # 选择文件 torrent_files = server.get_files(torrent_hash) if not torrent_files: return downloader or self.get_default_config_name(), torrent_hash, torrent_layout, "获取种子文件失败,下载任务可能在暂停状态" # 需要的文件信息 file_ids = [] unwanted_file_ids = [] try: for torrent_file in torrent_files: file_id = torrent_file.id file_name = torrent_file.name meta_info = MetaInfo(file_name) if not meta_info.episode_list: unwanted_file_ids.append(file_id) continue selected = set(meta_info.episode_list).issubset(set(episodes)) if not selected: unwanted_file_ids.append(file_id) continue file_ids.append(file_id) # 选择文件 server.set_files(torrent_hash, file_ids) server.set_unwanted_files(torrent_hash, unwanted_file_ids) # 开始任务 server.start_torrents(torrent_hash) finally: torrent_files.clear() del torrent_files return downloader or self.get_default_config_name(), torrent_hash, torrent_layout, "添加下载任务成功" else: return downloader or self.get_default_config_name(), torrent_hash, torrent_layout, "添加下载任务成功" def list_torrents(self, status: TorrentStatus = None, hashs: Union[list, str] = None, downloader: Optional[str] = None ) -> Optional[List[Union[TransferTorrent, DownloadingTorrent]]]: """ 获取下载器种子列表 :param status: 种子状态 :param hashs: 种子Hash :param downloader: 下载器 :return: 下载器中符合状态的种子列表 """ # 获取下载器 if downloader: server: Transmission = self.get_instance(downloader) if not server: return None servers = {downloader: server} else: servers: Dict[str, Transmission] = self.get_instances() ret_torrents = [] if hashs: # 按Hash获取 for name, server in servers.items(): torrents, _ = server.get_torrents(ids=hashs, tags=settings.TORRENT_TAG) or [] try: for torrent in torrents: ret_torrents.append(TransferTorrent( downloader=name, title=torrent.name, path=Path(torrent.download_dir) / torrent.name, hash=torrent.hashString, size=torrent.total_size, tags=",".join(torrent.labels or []), progress=torrent.progress )) finally: torrents.clear() del torrents elif status == TorrentStatus.TRANSFER: # 获取已完成且未整理的 for name, server in servers.items(): torrents = server.get_completed_torrents(tags=settings.TORRENT_TAG) or [] try: for torrent in torrents: # 含"已整理"tag的不处理 if "已整理" in torrent.labels or []: continue # 下载路径 path = torrent.download_dir # 无法获取下载路径的不处理 if not path: logger.debug(f"未获取到 {torrent.name} 下载保存路径") continue ret_torrents.append(TransferTorrent( downloader=name, title=torrent.name, path=Path(torrent.download_dir) / torrent.name, hash=torrent.hashString, tags=",".join(torrent.labels or []), progress=torrent.progress, state="paused" if torrent.status == "stopped" else "downloading", )) finally: torrents.clear() del torrents elif status == TorrentStatus.DOWNLOADING: # 获取正在下载的任务 for name, server in servers.items(): torrents = server.get_downloading_torrents(tags=settings.TORRENT_TAG) or [] try: for torrent in torrents: meta = MetaInfo(torrent.name) dlspeed = torrent.rate_download if hasattr(torrent, "rate_download") else torrent.rateDownload upspeed = torrent.rate_upload if hasattr(torrent, "rate_upload") else torrent.rateUpload ret_torrents.append(DownloadingTorrent( downloader=name, hash=torrent.hashString, title=torrent.name, name=meta.name, year=meta.year, season_episode=meta.season_episode, progress=torrent.progress, size=torrent.total_size, state="paused" if torrent.status == "stopped" else "downloading", dlspeed=StringUtils.str_filesize(dlspeed), upspeed=StringUtils.str_filesize(upspeed), left_time=StringUtils.str_secends(torrent.left_until_done / dlspeed) if dlspeed > 0 else '' )) finally: torrents.clear() del torrents else: return None return ret_torrents # noqa def transfer_completed(self, hashs: str, downloader: Optional[str] = None) -> None: """ 转移完成后的处理 :param hashs: 种子Hash :param downloader: 下载器 """ # 获取下载器 server: Transmission = self.get_instance(downloader) if not server: return None # 获取原标签 org_tags = server.get_torrent_tags(ids=hashs) # 种子打上已整理标签 if org_tags: tags = org_tags + ['已整理'] else: tags = ['已整理'] server.set_torrent_tag(ids=hashs, tags=tags) return None def remove_torrents(self, hashs: Union[str, list], delete_file: Optional[bool] = True, downloader: Optional[str] = None) -> Optional[bool]: """ 删除下载器种子 :param hashs: 种子Hash :param delete_file: 是否删除文件 :param downloader: 下载器 :return: bool """ # 获取下载器 server: Transmission = self.get_instance(downloader) if not server: return None return server.delete_torrents(delete_file=delete_file, ids=hashs) def start_torrents(self, hashs: Union[list, str], downloader: Optional[str] = None) -> Optional[bool]: """ 开始下载 :param hashs: 种子Hash :param downloader: 下载器 :return: bool """ # 获取下载器 server: Transmission = self.get_instance(downloader) if not server: return None return server.start_torrents(ids=hashs) def stop_torrents(self, hashs: Union[list, str], downloader: Optional[str] = None) -> Optional[bool]: """ 停止下载 :param hashs: 种子Hash :param downloader: 下载器 :return: bool """ # 获取下载器 server: Transmission = self.get_instance(downloader) if not server: return None return server.stop_torrents(ids=hashs) def torrent_files(self, tid: str, downloader: Optional[str] = None) -> Optional[List[File]]: """ 获取种子文件列表 """ # 获取下载器 server: Transmission = self.get_instance(downloader) if not server: return None return server.get_files(tid=tid) def downloader_info(self, downloader: Optional[str] = None) -> Optional[List[schemas.DownloaderInfo]]: """ 下载器信息 """ if downloader: server: Transmission = self.get_instance(downloader) if not server: return None servers = [server] else: servers = self.get_instances().values() # 调用Qbittorrent API查询实时信息 ret_info = [] for server in servers: info = server.transfer_info() if not info: continue ret_info.append(schemas.DownloaderInfo( download_speed=info.download_speed, upload_speed=info.upload_speed, download_size=info.current_stats.downloaded_bytes, upload_size=info.current_stats.uploaded_bytes )) return ret_info ================================================ FILE: app/modules/transmission/transmission.py ================================================ from typing import Optional, Union, Tuple, List import transmission_rpc from transmission_rpc import Client, Torrent, File from transmission_rpc.session import SessionStats, Session from app.log import logger from app.utils.url import UrlUtils class Transmission: """ Transmission下载器 """ # 参考transmission web,仅查询需要的参数,加速种子搜索 _trarg = ["id", "name", "status", "labels", "hashString", "totalSize", "percentDone", "addedDate", "trackerList", "trackerStats", "leftUntilDone", "rateDownload", "rateUpload", "recheckProgress", "rateDownload", "rateUpload", "peersGettingFromUs", "peersSendingToUs", "uploadRatio", "uploadedEver", "downloadedEver", "downloadDir", "error", "errorString", "doneDate", "queuePosition", "activityDate", "trackers"] def __init__(self, host: Optional[str] = None, port: Optional[int] = None, username: Optional[str] = None, password: Optional[str] = None, **kwargs): """ 若不设置参数,则创建配置文件设置的下载器 """ self.trc = None if host and port: self._protocol, self._host, self._port = kwargs.get("protocol", "http"), host, port elif host: result = UrlUtils.parse_url_params(url=host) if result: self._protocol, self._host, self._port, path = result else: logger.error("Transmission配置不正确!") return else: logger.error("Transmission配置不完整!") return self._username = username self._password = password self.trc = self.__login_transmission() def __login_transmission(self) -> Optional[Client]: """ 连接transmission :return: transmission对象 """ if not self._host or not self._port: return None try: # 登录 logger.info(f"正在连接 transmission:{self._protocol}://{self._host}:{self._port}") trt = transmission_rpc.Client(protocol=self._protocol, # noqa host=self._host, port=self._port, username=self._username, password=self._password, timeout=60) return trt except Exception as err: logger.error(f"transmission 连接出错:{str(err)}") return None def is_inactive(self) -> bool: """ 判断是否需要重连 """ if not self._host or not self._port: return False return True if not self.trc else False def reconnect(self): """ 重连 """ self.trc = self.__login_transmission() def get_torrents(self, ids: Union[str, list] = None, status: Union[str, list] = None, tags: Union[str, list] = None) -> Tuple[List[Torrent], bool]: """ 获取种子列表 返回结果 种子列表, 是否有错误 """ if not self.trc: return [], True try: torrents = self.trc.get_torrents(ids=ids, arguments=self._trarg) except Exception as err: logger.error(f"获取种子列表出错:{str(err)}") return [], True if status and not isinstance(status, list): status = [status] if tags and not isinstance(tags, list): tags = tags.split(',') ret_torrents = [] try: for torrent in torrents: # 状态过滤 if status and torrent.status not in status: continue # 种子标签 labels = [str(tag).strip() for tag in torrent.labels] if hasattr(torrent, "labels") else [] if tags and not set(tags).issubset(set(labels)): continue ret_torrents.append(torrent) finally: torrents.clear() del torrents return ret_torrents, False def get_completed_torrents(self, ids: Union[str, list] = None, tags: Union[str, list] = None) -> Optional[List[Torrent]]: """ 获取已完成的种子列表 return 种子列表, 发生错误时返回None """ if not self.trc: return None try: torrents, error = self.get_torrents(status=["seeding", "seed_pending"], ids=ids, tags=tags) return None if error else torrents or [] except Exception as err: logger.error(f"获取已完成的种子列表出错:{str(err)}") return None def get_downloading_torrents(self, ids: Union[str, list] = None, tags: Union[str, list] = None) -> Optional[List[Torrent]]: """ 获取正在下载的种子列表 return 种子列表, 发生错误时返回None """ if not self.trc: return None try: torrents, error = self.get_torrents(ids=ids, status=["downloading", "download_pending"], tags=tags) return None if error else torrents or [] except Exception as err: logger.error(f"获取正在下载的种子列表出错:{str(err)}") return None def set_torrent_tag(self, ids: str, tags: list, org_tags: list = None) -> bool: """ 设置种子标签,注意TR默认会覆盖原有标签,如需追加需传入原有标签 """ if not self.trc: return False if not ids or not tags: return False try: self.trc.change_torrent(labels=list(set((org_tags or []) + tags)), ids=ids) return True except Exception as err: logger.error(f"设置种子标签出错:{str(err)}") return False def get_torrent_tags(self, ids: str) -> List[str]: """ 获取所有种子标签 """ if not self.trc: return [] try: torrents = self.trc.get_torrents(ids=ids, arguments=self._trarg) if len(torrents): torrent = torrents[0] labels = [str(tag).strip() for tag in torrent.labels] if hasattr(torrent, "labels") else [] return labels except Exception as err: logger.error(f"获取种子标签出错:{str(err)}") return [] return [] def add_torrent(self, content: Union[str, bytes], is_paused: Optional[bool] = False, download_dir: Optional[str] = None, labels=None, cookie=None) -> Optional[Torrent]: """ 添加下载任务 :param content: 种子urls或文件内容 :param is_paused: 添加后暂停 :param download_dir: 下载路径 :param labels: 标签 :param cookie: 站点Cookie用于辅助下载种子 :return: Torrent """ if not self.trc: return None try: return self.trc.add_torrent(torrent=content, download_dir=download_dir, paused=is_paused, labels=labels, cookies=cookie) except Exception as err: logger.error(f"添加种子出错:{str(err)}") return None def start_torrents(self, ids: Union[str, list]) -> bool: """ 启动种子 """ if not self.trc: return False try: self.trc.start_torrent(ids=ids) return True except Exception as err: logger.error(f"启动种子出错:{str(err)}") return False def stop_torrents(self, ids: Union[str, list]) -> bool: """ 停止种子 """ if not self.trc: return False try: self.trc.stop_torrent(ids=ids) return True except Exception as err: logger.error(f"停止种子出错:{str(err)}") return False def delete_torrents(self, delete_file: bool, ids: Union[str, list]) -> bool: """ 删除种子 """ if not self.trc: return False if not ids: return False try: self.trc.remove_torrent(delete_data=delete_file, ids=ids) return True except Exception as err: logger.error(f"删除种子出错:{str(err)}") return False def get_files(self, tid: str) -> Optional[List[File]]: """ 获取种子文件列表 """ if not self.trc: return None if not tid: return None try: torrent = self.trc.get_torrent(tid) except Exception as err: logger.error(f"获取种子文件列表出错:{str(err)}") return None if torrent: return torrent.files() else: return None def set_files(self, tid: str, file_ids: list) -> bool: """ 设置下载文件的状态 """ if not self.trc: return False try: self.trc.change_torrent(ids=tid, files_wanted=file_ids) return True except Exception as err: logger.error(f"设置下载文件状态出错:{str(err)}") return False def set_unwanted_files(self, tid: str, file_ids: list) -> bool: """ 设置下载文件的状态 """ if not self.trc: return False try: self.trc.change_torrent(ids=tid, files_unwanted=file_ids) return True except Exception as err: logger.error(f"设置下载文件状态出错:{str(err)}") return False def transfer_info(self) -> Optional[SessionStats]: """ 获取传输信息 """ if not self.trc: return None try: return self.trc.session_stats() except Exception as err: logger.error(f"获取传输信息出错:{str(err)}") return None def set_speed_limit(self, download_limit: float = None, upload_limit: float = None) -> bool: """ 设置速度限制 :param download_limit: 下载速度限制,单位KB/s :param upload_limit: 上传速度限制,单位kB/s """ if not self.trc: return False try: download_limit_enabled = True if download_limit else False upload_limit_enabled = True if upload_limit else False self.trc.set_session( speed_limit_down=int(download_limit), speed_limit_up=int(upload_limit), speed_limit_down_enabled=download_limit_enabled, speed_limit_up_enabled=upload_limit_enabled ) return True except Exception as err: logger.error(f"设置速度限制出错:{str(err)}") return False def get_speed_limit(self) -> Optional[Tuple[float, float]]: """ 获取TR速度 :return: download_limit 下载速度 默认是0 upload_limit 上传速度 默认是0 """ if not self.trc: return None download_limit = 0 upload_limit = 0 try: download_limit = self.trc.get_session().get('speed_limit_down') upload_limit = self.trc.get_session().get('speed_limit_up') except Exception as err: logger.error(f"获取速度限制出错:{str(err)}") return ( download_limit, upload_limit ) def recheck_torrents(self, ids: Union[str, list]) -> bool: """ 重新校验种子 """ if not self.trc: return False try: self.trc.verify_torrent(ids=ids) return True except Exception as err: logger.error(f"重新校验种子出错:{str(err)}") return False def change_torrent(self, hash_string: str, upload_limit=None, download_limit=None, ratio_limit=None, seeding_time_limit=None) -> bool: """ 设置种子 :param hash_string: ID :param upload_limit: 上传限速 Kb/s :param download_limit: 下载限速 Kb/s :param ratio_limit: 分享率限制 :param seeding_time_limit: 做种时间限制 :return: bool """ if not hash_string: return False if upload_limit: uploadLimited = True uploadLimit = int(upload_limit) else: uploadLimited = False uploadLimit = 0 if download_limit: downloadLimited = True downloadLimit = int(download_limit) else: downloadLimited = False downloadLimit = 0 if ratio_limit: seedRatioMode = 1 seedRatioLimit = round(float(ratio_limit), 2) else: seedRatioMode = 2 seedRatioLimit = 0 if seeding_time_limit: seedIdleMode = 1 seedIdleLimit = int(seeding_time_limit) else: seedIdleMode = 2 seedIdleLimit = 0 try: self.trc.change_torrent(ids=hash_string, uploadLimited=uploadLimited, uploadLimit=uploadLimit, downloadLimited=downloadLimited, downloadLimit=downloadLimit, seedRatioMode=seedRatioMode, seedRatioLimit=seedRatioLimit, seedIdleMode=seedIdleMode, seedIdleLimit=seedIdleLimit) return True except Exception as err: logger.error(f"设置种子出错:{str(err)}") return False def update_tracker(self, hash_string: str, tracker_list: list = None) -> bool: """ tr4.0及以上弃用直接设置tracker 共用change方法 https://github.com/trim21/transmission-rpc/blob/8eb82629492a0eeb0bb565f82c872bf9ccdcb313/transmission_rpc/client.py#L654 """ if not self.trc: return False try: self.trc.change_torrent(ids=hash_string, tracker_list=tracker_list) return True except Exception as err: logger.error(f"修改tracker出错:{str(err)}") return False def get_session(self) -> Optional[Session]: """ 获取Transmission当前的会话信息和配置设置 :return dict """ if not self.trc: return None try: return self.trc.get_session() except Exception as err: logger.error(f"获取session出错:{str(err)}") return None ================================================ FILE: app/modules/trimemedia/__init__.py ================================================ from typing import Any, Generator, List, Optional, Tuple, Union from app import schemas from app.core.context import MediaInfo from app.core.event import eventmanager from app.log import logger from app.modules import _MediaServerBase, _ModuleBase from app.modules.trimemedia.trimemedia import TrimeMedia from app.schemas import AuthCredentials, AuthInterceptCredentials from app.schemas.types import ChainEventType, MediaServerType, MediaType, ModuleType class TrimeMediaModule(_ModuleBase, _MediaServerBase[TrimeMedia]): def init_module(self) -> None: """ 初始化模块 """ super().init_service( service_name=TrimeMedia.__name__.lower(), service_type=lambda conf: TrimeMedia( **conf.config, sync_libraries=conf.sync_libraries ), ) @staticmethod def get_name() -> str: return "飞牛影视" @staticmethod def get_type() -> ModuleType: """ 获取模块类型 """ return ModuleType.MediaServer @staticmethod def get_subtype() -> MediaServerType: """ 获取模块子类型 """ return MediaServerType.TrimeMedia @staticmethod def get_priority() -> int: """ 获取模块优先级,数字越小优先级越高,只有同一接口下优先级才生效 """ return 4 def init_setting(self) -> Tuple[str, Union[str, bool]]: pass def scheduler_job(self) -> None: """ 定时任务,每10分钟调用一次 """ # 定时重连 for name, server in self.get_instances().items(): if server.is_configured() and server.is_inactive(): logger.info(f"飞牛影视 {name} 连接断开,尝试重连 ...") server.reconnect() def stop(self): for server in self.get_instances().values(): if server.is_authenticated(): server.disconnect() def test(self) -> Optional[Tuple[bool, str]]: """ 测试模块连接性 """ if not self.get_instances(): return None for name, server in self.get_instances().items(): if not server.is_configured(): return False, f"飞牛影视配置不完整:{name}" if server.is_inactive() and not server.reconnect(): return False, f"无法连接飞牛影视:{name}" return True, "" def user_authenticate( self, credentials: AuthCredentials, service_name: Optional[str] = None ) -> Optional[AuthCredentials]: """ 使用飞牛影视用户辅助完成用户认证 :param credentials: 认证数据 :param service_name: 指定要认证的媒体服务器名称,若为 None 则认证所有服务 :return: 认证数据 """ # 飞牛影视认证 if not credentials or credentials.grant_type != "password": return None # 确定要认证的服务器列表 if service_name: # 如果指定了服务名,获取该服务实例 servers = ( [(service_name, server)] if (server := self.get_instance(service_name)) else [] ) else: # 如果没有指定服务名,遍历所有服务 servers = self.get_instances().items() # 遍历要认证的服务器 for name, server in servers: # 触发认证拦截事件 intercept_event = eventmanager.send_event( etype=ChainEventType.AuthIntercept, data=AuthInterceptCredentials( username=credentials.username, channel=self.get_name(), service=name, status="triggered", ), ) if intercept_event and intercept_event.event_data: intercept_data: AuthInterceptCredentials = intercept_event.event_data if intercept_data.cancel: continue token = server.authenticate(credentials.username, credentials.password) if token: credentials.channel = self.get_name() credentials.service = name credentials.token = token return credentials return None def webhook_parser( self, body: Any, form: Any, args: Any ) -> Optional[schemas.WebhookEventInfo]: """ 解析Webhook报文体 :param body: 请求体 :param form: 请求表单 :param args: 请求参数 :return: 字典,解析为消息时需要包含:title、text、image """ source = args.get("source") if source: server: Optional[TrimeMedia] = self.get_instance(source) if not server: return None result = server.get_webhook_message(body) if result: result.server_name = source return result for server in self.get_instances().values(): if server: result = server.get_webhook_message(body) if result: return result return None def media_exists( self, mediainfo: MediaInfo, itemid: Optional[str] = None, server: Optional[str] = None, ) -> Optional[schemas.ExistMediaInfo]: """ 判断媒体文件是否存在 :param mediainfo: 识别的媒体信息 :param itemid: 媒体服务器ItemID :param server: 媒体服务器名称 :return: 如不存在返回None,存在时返回信息,包括每季已存在所有集{type: movie/tv, seasons: {season: [episodes]}} """ if server: servers = [(server, self.get_instance(server))] else: servers = self.get_instances().items() for name, s in servers: if not s: continue if mediainfo.type == MediaType.MOVIE: if itemid: movie = s.get_iteminfo(itemid) if movie: logger.info(f"媒体库 {name} 中找到了 {movie}") return schemas.ExistMediaInfo( type=MediaType.MOVIE, server_type="trimemedia", server=name, itemid=movie.item_id, ) movies = s.get_movies( title=mediainfo.title, year=mediainfo.year, tmdb_id=mediainfo.tmdb_id, ) if not movies: logger.info(f"{mediainfo.title_year} 没有在媒体库 {name} 中") continue else: logger.info(f"媒体库 {name} 中找到了 {movies}") return schemas.ExistMediaInfo( type=MediaType.MOVIE, server_type="trimemedia", server=name, itemid=movies[0].item_id, ) else: itemid, tvs = s.get_tv_episodes( title=mediainfo.title, year=mediainfo.year, tmdb_id=mediainfo.tmdb_id, item_id=itemid, ) if not tvs: logger.info(f"{mediainfo.title_year} 没有在媒体库 {name} 中") continue else: logger.info( f"{mediainfo.title_year} 在媒体库 {name} 中找到了这些季集:{tvs}" ) return schemas.ExistMediaInfo( type=MediaType.TV, seasons=tvs, server_type="trimemedia", server=name, itemid=itemid, ) return None def media_statistic( self, server: Optional[str] = None ) -> Optional[List[schemas.Statistic]]: """ 媒体数量统计 """ if server: server_obj: Optional[TrimeMedia] = self.get_instance(server) if not server_obj: return None servers = [server_obj] else: servers = self.get_instances().values() media_statistics = [] for s in servers: media_statistic = s.get_medias_count() if not media_statistic: continue media_statistic.user_count = s.get_user_count() media_statistics.append(media_statistic) return media_statistics def mediaserver_librarys( self, server: Optional[str] = None, hidden: Optional[bool] = False, **kwargs ) -> Optional[List[schemas.MediaServerLibrary]]: """ 媒体库列表 """ server_obj: Optional[TrimeMedia] = self.get_instance(server) if server_obj: return server_obj.get_librarys(hidden=hidden) return None def mediaserver_items( self, server: str, library_id: Union[str, int], start_index: Optional[int] = 0, limit: Optional[int] = -1, ) -> Optional[Generator]: """ 获取媒体服务器项目列表,支持分页和不分页逻辑,默认不分页获取所有数据 :param server: 媒体服务器名称 :param library_id: 媒体库ID,用于标识要获取的媒体库 :param start_index: 起始索引,用于分页获取数据。默认为 0,即从第一个项目开始获取 :param limit: 每次请求的最大项目数,用于分页。如果为 None 或 -1,则表示一次性获取所有数据,默认为 -1 :return: 返回一个生成器对象,用于逐步获取媒体服务器中的项目 """ server_obj: Optional[TrimeMedia] = self.get_instance(server) if server_obj: return server_obj.get_items(library_id, start_index, limit) return None def mediaserver_iteminfo( self, server: str, item_id: str ) -> Optional[schemas.MediaServerItem]: """ 媒体库项目详情 """ server_obj: Optional[TrimeMedia] = self.get_instance(server) if server_obj: return server_obj.get_iteminfo(item_id) return None def mediaserver_tv_episodes( self, server: str, item_id: Union[str, int] ) -> Optional[List[schemas.MediaServerSeasonInfo]]: """ 获取剧集信息 """ if not isinstance(item_id, str): return None server_obj: Optional[TrimeMedia] = self.get_instance(server) if not server_obj: return None _, seasoninfo = server_obj.get_tv_episodes(item_id=item_id) if not seasoninfo: return [] return [ schemas.MediaServerSeasonInfo(season=season, episodes=episodes) for season, episodes in seasoninfo.items() ] def mediaserver_playing( self, server: str, count: Optional[int] = 20, **kwargs ) -> List[schemas.MediaServerPlayItem]: """ 获取媒体服务器正在播放信息 """ server_obj: Optional[TrimeMedia] = self.get_instance(server) if not server_obj: return [] return server_obj.get_resume(num=count) or [] def mediaserver_play_url( self, server: str, item_id: Union[str, int] ) -> Optional[str]: """ 获取媒体库播放地址 """ if not isinstance(item_id, str): return None server_obj: Optional[TrimeMedia] = self.get_instance(server) if not server_obj: return None return server_obj.get_play_url(item_id) def mediaserver_latest( self, server: Optional[str] = None, count: Optional[int] = 20, **kwargs, ) -> List[schemas.MediaServerPlayItem]: """ 获取媒体服务器最新入库条目 """ server_obj: Optional[TrimeMedia] = self.get_instance(server) if not server_obj: return [] return server_obj.get_latest(num=count) or [] def mediaserver_latest_images( self, server: Optional[str] = None, count: Optional[int] = 20, remote: Optional[bool] = False, **kwargs, ) -> List[str]: """ 获取媒体服务器最新入库条目的图片 :param server: 媒体服务器名称 :param count: 获取数量 :param remote: True为外网链接, False为内网链接 :return: 图片链接列表 """ server_obj: Optional[TrimeMedia] = self.get_instance(server) if not server_obj: return [] return server_obj.get_latest_backdrops(num=count, remote=remote) or [] def mediaserver_image_cookies( self, server: Optional[str] = None, image_url: Optional[str] = None, **kwargs, ) -> Optional[str | dict]: """ 获取飞牛影视服务器的图片Cookies :param server: 媒体服务器名称 :param image_url: 图片网址 """ if not image_url: return None if server: server_obj = self.get_instance(server) if not server_obj: return None return server_obj.get_image_cookies(image_url) else: for server_obj in self.get_instances().values(): if cookies := server_obj.get_image_cookies(image_url): return cookies ================================================ FILE: app/modules/trimemedia/api.py ================================================ import hashlib import json import random import time from dataclasses import dataclass from enum import Enum from typing import List, Optional, Union from app.core.config import settings from app.log import logger from app.utils.http import RequestUtils, requests @dataclass class User: guid: str username: str is_admin: int = 0 class Category(Enum): MOVIE = "Movie" TV = "TV" MIX = "Mix" OTHERS = "Others" @classmethod def _missing_(cls, value): return cls.OTHERS class Type(Enum): MOVIE = "Movie" TV = "TV" SEASON = "Season" EPISODE = "Episode" VIDEO = "Video" DIRECTORY = "Directory" @classmethod def _missing_(cls, value): return cls.VIDEO @dataclass class MediaDb: guid: str category: Category name: Optional[str] = None posters: Optional[list[str]] = None dir_list: Optional[list[str]] = None @dataclass class MediaDbSummary: favorite: int = 0 movie: int = 0 tv: int = 0 video: int = 0 total: int = 0 @dataclass class Version: # 飞牛影视版本 frontend: Optional[str] = None backend: Optional[str] = None @dataclass class Item: guid: str ancestor_guid: str = "" type: Optional[Type] = None # 当type为Episode时是剧名,parent_title是季名,title作为分集名称 tv_title: Optional[str] = None parent_title: Optional[str] = None title: Optional[str] = None original_title: Optional[str] = None overview: Optional[str] = None poster: Optional[str] = None backdrops: Optional[str] = None posters: Optional[str] = None douban_id: Optional[int] = None imdb_id: Optional[str] = None trim_id: Optional[str] = None release_date: Optional[str] = None air_date: Optional[str] = None vote_average: Optional[str] = None season_number: Optional[int] = None episode_number: Optional[int] = None duration: Optional[int] = None # 片长(秒) ts: Optional[int] = None # 已播放(秒) watched: Optional[int] = None # 1:已看完 @property def tmdb_id(self) -> Optional[int]: if self.trim_id is None: return None if self.trim_id.startswith("tt") or self.trim_id.startswith("tm"): # 飞牛给tmdbid加了前缀用以区分tv或movie return int(self.trim_id[2:]) return None class Api: __slots__ = ( "_host", "_token", "_apikey", "_api_path", "_request_utils", "_version", "_session", ) @property def token(self) -> Optional[str]: return self._token @property def host(self) -> str: return self._host @property def apikey(self) -> str: return self._apikey @property def version(self) -> Optional[Version]: return self._version def __init__(self, host: str, apikey: str): """ :param host: 飞牛服务端地址,如http://127.0.0.1:5666/v """ self._api_path = "/api/v1" self._host = host.rstrip("/") self._apikey = apikey self._token: Optional[str] = None self._version: Optional[Version] = None self._session = requests.Session() self._request_utils = RequestUtils(session=self._session, timeout=10) def sys_version(self) -> Optional[Version]: """ 飞牛影视版本号 """ if (res := self.request("/sys/version")) and res.success: if res.data: self._version = Version( frontend=res.data.get("version"), backend=res.data.get("mediasrvVersion"), ) return self._version return None def login(self, username, password) -> Optional[str]: """ 登录飞牛影视 :return: 成功返回token 否则返回None """ if ( res := self.request( "/login", data={ "username": username, "password": password, "app_name": "trimemedia-web", }, ) ) and res.success: self._token = res.data.get("token") return self._token def logout(self) -> bool: """ 退出账号 """ if not self._token: return True if (res := self.request("/user/logout", method="post")) and res.success: if res.data: self._token = None return True return False def user_list(self) -> Optional[list[User]]: """ 用户列表(仅管理员有权访问) """ if (res := self.request("/manager/user/list")) and res.success: if not res.data: return [] return [ User( guid=info.get("guid"), username=info.get("username"), is_admin=info.get("is_admin", 0), ) for info in res.data ] return None def user_info(self) -> Optional[User]: """ 当前用户信息 """ if (res := self.request("/user/info")) and res.success: _user = User("", "") _user.__dict__.update(res.data) return _user return None def mediadb_sum(self) -> Optional[MediaDbSummary]: """ 媒体数量统计 """ if (res := self.request("/mediadb/sum")) and res.success: sums = MediaDbSummary() sums.__dict__.update(res.data) return sums return None def mediadb_list(self) -> Optional[List[MediaDb]]: """ 媒体库列表(普通用户) """ if (res := self.request("/mediadb/list")) and res.success: _items = [] for info in res.data or []: mdb = MediaDb( guid=info.get("guid"), category=Category(info.get("category")), name=info.get("title", ""), posters=[ self.__build_img_api_url(poster) for poster in info.get("posters", []) ], ) _items.append(mdb) return _items return None def __build_img_api_url(self, img_path: Optional[str]) -> Optional[str]: if not img_path: return None if img_path[0] != "/": img_path = "/" + img_path return f"{self._api_path}/sys/img{img_path}" def mdb_list(self) -> Optional[list[MediaDb]]: """ 媒体库列表(管理员) """ if (res := self.request("/mdb/list")) and res.success: _items = [] for info in res.data or []: mdb = MediaDb( guid=info.get("guid"), category=Category(info.get("category")), name=info.get("name", ""), posters=[ self.__build_img_api_url(poster) for poster in info.get("posters", []) ], dir_list=info.get("dir_list"), ) _items.append(mdb) return _items return None def mdb_scanall(self) -> bool: """ 扫描所有媒体库 """ if (res := self.request("/mdb/scanall", method="post")) and res.success: if res.data: return True return False def mdb_scan(self, mdb: MediaDb) -> bool: """ 扫描指定媒体库 """ if (res := self.request(f"/mdb/scan/{mdb.guid}", data={})) and res.success: if res.data: return True return False def task_running(self): """ 当前正在运行的任务 """ if (res := self.request("/task/running")) and res.success: if res.data: # TODO 具体正在运行的任务 return True return False def __build_item(self, info: dict) -> Item: """ 构造媒体Item """ item = Item(guid="") item.__dict__.update(info) item.type = Type(info.get("type")) # Item详情接口才有posters和backdrops item.posters = self.__build_img_api_url(item.posters) item.backdrops = self.__build_img_api_url(item.backdrops) item.poster = ( self.__build_img_api_url(item.poster) if item.poster else item.posters ) return item def item_list( self, guid: Optional[str] = None, types=None, exclude_grouped_video=True, page=1, page_size=20, sort_by="create_time", sort="DESC", ) -> Optional[list[Item]]: """ 媒体列表 """ if types is None: types = [Type.MOVIE, Type.TV, Type.DIRECTORY, Type.VIDEO] post = { "tags": {"type": types} if types else {}, "sort_type": sort, "sort_column": sort_by, "page": page, "page_size": page_size, } if guid: post["ancestor_guid"] = guid if exclude_grouped_video: post["exclude_grouped_video"] = 1 if (res := self.request("/item/list", data=post)) and res.success: if not res.data: return [] return [self.__build_item(info) for info in res.data.get("list", [])] return None def search_list(self, keywords: str) -> Optional[list[Item]]: """ 搜索影片、演员 """ if ( res := self.request("/search/list", params={"q": keywords}) ) and res.success: if not res.data: return [] return [self.__build_item(info) for info in res.data] return None def item(self, guid: str) -> Optional[Item]: """ 查询媒体详情 """ if (res := self.request(f"/item/{guid}")) and res.success: return self.__build_item(res.data) return None def del_item(self, guid: str, delete_file: bool) -> bool: """ 删除媒体 :param guid: 媒体GUID :param delete_file: True删除媒体文件,False仅从媒体库移除 """ if ( res := self.request( f"/item/{guid}", method="delete", data={"delete_file": 1 if delete_file else 0, "media_guids": []}, ) ) and res.success: if res.data: return True return False def season_list(self, tv_guid: str) -> Optional[list[Item]]: """ 查询季列表 """ if (res := self.request(f"/season/list/{tv_guid}")) and res.success: if not res.data: return [] return [self.__build_item(info) for info in res.data] return None def episode_list(self, season_guid: str) -> Optional[list[Item]]: """ 查询剧集列表 """ if (res := self.request(f"/episode/list/{season_guid}")) and res.success: if not res.data: return [] return [self.__build_item(info) for info in res.data] return None def play_list(self) -> Optional[list[Item]]: """ 继续观看列表 """ if (res := self.request("/play/list")) and res.success: if not res.data: return [] return [self.__build_item(info) for info in res.data] return None def __get_authx(self, api_path: str, body: Optional[str]): """ 计算消息签名 """ if not api_path.startswith("/v"): api_path = "/v" + api_path nonce = str(random.randint(100000, 999999)) ts = str(int(time.time() * 1000)) md5 = hashlib.md5() md5.update((body or "").encode()) data_hash = md5.hexdigest() md5 = hashlib.md5() md5.update( "_".join( [ "NDzZTVxnRKP8Z0jXg1VAMonaG8akvh", api_path, nonce, ts, data_hash, self._apikey, ] ).encode() ) sign = md5.hexdigest() return f"nonce={nonce}×tamp={ts}&sign={sign}" def request( self, api: str, method: Optional[str] = None, params: Optional[dict] = None, data: Optional[dict] = None, suppress_log=False, ): """ 请求飞牛影视API :param suppress_log: 是否禁止日志 """ @dataclass class Result: @property def success(self) -> bool: return code == 0 code: int msg: Optional[str] = None data: Optional[Union[dict, list, str, bool]] = None class JsonEncoder(json.JSONEncoder): def default(self, obj): if isinstance(obj, Type): return obj.value return super().default(obj) if not self._host or not api: return None if not api.startswith("/"): api_path = f"{self._api_path}/{api}" else: api_path = self._api_path + api url = self._host + api_path if method is None: method = "get" if data is None else "post" if method != "get": json_body = ( json.dumps(data, allow_nan=False, cls=JsonEncoder) if data else "" ) else: json_body = None if params: queries_unquoted = "&".join([f"{k}={v}" for k, v in params.items()]) else: queries_unquoted = None headers = { "User-Agent": settings.USER_AGENT, "Accept": "application/json", "Referer": self._host, "Authorization": self._token, "authx": self.__get_authx(api_path, json_body or queries_unquoted), } if json_body is not None: headers["Content-Type"] = "application/json" try: res = self._request_utils.request( method=method, url=url, headers=headers, params=params, data=json_body ) if res: resp = res.json() msg = resp.get("msg") if code := int(resp.get("code", -1)): if not suppress_log: logger.error(f"请求接口 {url} 失败,错误码:{code} {msg}") return Result(code, msg) return Result(0, msg, resp.get("data")) elif not suppress_log: logger.error(f"请求接口 {url} 失败") except Exception as e: if not suppress_log: logger.error(f"请求接口 {url} 异常:" + str(e)) return None def close(self): """ 关闭API会话 """ if self._session: self._session.close() ================================================ FILE: app/modules/trimemedia/trimemedia.py ================================================ from pathlib import Path from typing import Any, Dict, Generator, List, Optional, Tuple, Union import app.modules.trimemedia.api as fnapi from app import schemas from app.log import logger from app.schemas import MediaType from app.utils.security import SecurityUtils from app.utils.url import UrlUtils class TrimeMedia: _username: Optional[str] = None _password: Optional[str] = None _userinfo: Optional[fnapi.User] = None _host: Optional[str] = None _playhost: Optional[str] = None _libraries: dict[str, fnapi.MediaDb] = {} _sync_libraries: List[str] = [] _api: Optional[fnapi.Api] = None _version: Optional[fnapi.Version] = None def __init__( self, host: Optional[str] = None, username: Optional[str] = None, password: Optional[str] = None, play_host: Optional[str] = None, sync_libraries: Optional[list] = None, **kwargs, ): if not host or not username or not password: logger.error("飞牛影视配置不完整!!") return self._username = username self._password = password self._host = host self._sync_libraries = sync_libraries or [] if not self.reconnect(): logger.error(f"请检查服务端地址 {host}") return if result := self.__create_api(play_host): self._playhost = result.api.host result.api.close() elif play_host: logger.warning(f"请检查外网播放地址 {play_host}") self._playhost = UrlUtils.standardize_base_url(play_host).rstrip("/") @property def api(self) -> Optional[fnapi.Api]: """ 获得飞牛API """ return self._api @property def version(self) -> Optional[fnapi.Version]: """ 获得飞牛API的版本 """ return self._version class _ApiCreateResult: api: fnapi.Api version: fnapi.Version @staticmethod def __create_api(host: Optional[str]) -> Optional["TrimeMedia._ApiCreateResult"]: """ 创建一个飞牛API :param host: 服务端地址 :return: 如果地址无效、不可访问则返回None """ if not host: return None api_key = "16CCEB3D-AB42-077D-36A1-F355324E4237" host = UrlUtils.standardize_base_url(host).rstrip("/") if not host.endswith("/v"): # 尝试补上结尾的/v 测试能否正常访问 res = TrimeMedia._ApiCreateResult() res.api = fnapi.Api(host + "/v", api_key) if fnver := res.api.sys_version(): res.version = fnver return res # 测试用户配置的地址 res = TrimeMedia._ApiCreateResult() res.api = fnapi.Api(host, api_key) if fnver := res.api.sys_version(): res.version = fnver return res return None def close(self): self.disconnect() def is_configured(self) -> bool: return bool(self._host and self._username and self._password) def is_authenticated(self) -> bool: """ 是否已登录 """ return ( self.is_configured() and self._api is not None and self._api.token is not None and self._userinfo is not None ) def is_inactive(self) -> bool: """ 判断是否需要重连 """ if not self.is_authenticated(): return True self._userinfo = self._api.user_info() return self._userinfo is None def reconnect(self): """ 重连 """ if not self.is_configured(): return False self.disconnect() if result := self.__create_api(self._host): self._api = result.api self._version = result.version # 版本号:0.8.53, 服务版本:0.8.23 # 版本号:0.8.56, 服务版本:0.8.23 接口/memory/user/list改为/manager/user/list logger.debug( f"版本号:{result.version.frontend}, 服务版本:{result.version.backend}" ) else: return False if self._api.login(self._username, self._password) is None: return False self._userinfo = self._api.user_info() if self._userinfo is None: return False logger.debug(f"{self._username} 成功登录飞牛影视") # 刷新媒体库列表 self.get_librarys() return True def disconnect(self): """ 断开与飞牛的连接 """ if self._api: self._api.logout() self._api.close() self._api = None self._userinfo = None logger.debug(f"{self._username} 已断开飞牛影视") def get_librarys( self, hidden: Optional[bool] = False ) -> List[schemas.MediaServerLibrary]: """ 获取媒体服务器所有媒体库列表 """ if not self.is_authenticated(): return [] if self._userinfo.is_admin == 1: mdb_list = self._api.mdb_list() or [] else: mdb_list = self._api.mediadb_list() or [] self._libraries = {lib.guid: lib for lib in mdb_list} libraries = [] for library in self._libraries.values(): if hidden and self.__is_library_blocked(library.guid): continue if library.category == fnapi.Category.MOVIE: library_type = MediaType.MOVIE.value elif library.category == fnapi.Category.TV: library_type = MediaType.TV.value elif library.category == fnapi.Category.OTHERS: # 忽略这个库 continue else: library_type = MediaType.UNKNOWN.value libraries.append( schemas.MediaServerLibrary( server="trimemedia", id=library.guid, name=library.name, type=library_type, path=library.dir_list, image_list=[ f"{self._api.host}{img_path}?w=256" for img_path in library.posters or [] ], link=f"{self._playhost or self._api.host}/library/{library.guid}", server_type="trimemedia", use_cookies=True, ) ) return libraries def get_user_count(self) -> int: """ 获取用户数量(非管理员不能调用) """ if not self.is_authenticated(): return 0 if not self._userinfo or self._userinfo.is_admin != 1: return 0 return len(self._api.user_list() or []) def get_medias_count(self) -> schemas.Statistic: """ 获取媒体数量 :return: MovieCount SeriesCount """ if not self.is_authenticated(): return schemas.Statistic() if (info := self._api.mediadb_sum()) is None: return schemas.Statistic() return schemas.Statistic( movie_count=info.movie, tv_count=info.tv, ) def authenticate(self, username: str, password: str) -> Optional[str]: """ 用户认证 :param username: 用户名 :param password: 密码 :return: 认证成功返回token,否则返回None """ if not username or not password: return None if not self.is_configured(): return None if result := self.__create_api(self._host): try: return result.api.login(username, password) finally: result.api.logout() result.api.close() def get_movies( self, title: str, year: Optional[str] = None, tmdb_id: Optional[int] = None ) -> Optional[List[schemas.MediaServerItem]]: """ 根据标题和年份,检查电影是否在飞牛中存在,存在则返回列表 :param title: 标题 :param year: 年份,为空则不过滤 :param tmdb_id: TMDB ID :return: 含title、year属性的字典列表 """ if not self.is_authenticated(): return None movies = [] items = self._api.search_list(keywords=title) or [] for item in items: if item.type != fnapi.Type.MOVIE: continue if ( (not tmdb_id or tmdb_id == item.tmdb_id) and title in [item.title, item.original_title] and (not year or (item.release_date and item.release_date[:4] == year)) ): movies.append(self.__build_media_server_item(item)) return movies def __get_series_id_by_name(self, name: str, year: str) -> Optional[str]: items = self._api.search_list(keywords=name) or [] for item in items: if item.type != fnapi.Type.TV: continue # 可惜搜索接口不下发original_title 也不能指定分类、年份 if name in [item.title, item.original_title]: if not year or (item.air_date and item.air_date[:4] == year): return item.guid return None def get_tv_episodes( self, item_id: Optional[str] = None, title: Optional[str] = None, year: Optional[str] = None, tmdb_id: Optional[int] = None, season: Optional[int] = None, ) -> Tuple[Optional[str], Optional[Dict[int, list]]]: """ 根据标题和年份和季,返回飞牛中的剧集列表 :param item_id: 飞牛影视中的guid :param title: 标题 :param year: 年份 :param tmdb_id: TMDBID :param season: 季 :return: 集号的列表 """ if not self.is_authenticated(): return None, None if not item_id: item_id = self.__get_series_id_by_name(title, year) if item_id is None: return None, None item_info = self.get_iteminfo(item_id) if not item_info: return None, {} if tmdb_id and item_info.tmdbid: if tmdb_id != item_info.tmdbid: return None, {} seasons = self._api.season_list(item_id) if not seasons: # 季列表获取失败 return None, {} if season is not None: for item in seasons: if item.season_number == season: seasons = [item] break else: # 没有匹配的季 return None, {} season_episodes = {} for item in seasons: episodes = self._api.episode_list(item.guid) for episode in episodes or []: if episode.season_number not in season_episodes: season_episodes[episode.season_number] = [] season_episodes[episode.season_number].append(episode.episode_number) return item_id, season_episodes def refresh_root_library(self) -> Optional[bool]: """ 通知飞牛刷新整个媒体库(非管理员不能调用) """ if not self.is_authenticated(): return None if not self._userinfo or self._userinfo.is_admin != 1: logger.error("飞牛仅支持管理员账号刷新媒体库") return False # 必须调用 否则容易误报 -14 Task duplicate self._api.task_running() logger.info("刷新所有媒体库") return self._api.mdb_scanall() def refresh_library_by_items( self, items: List[schemas.RefreshMediaItem] ) -> Optional[bool]: """ 按路径刷新所在的媒体库(非管理员不能调用) :param items: 已识别的需要刷新媒体库的媒体信息列表 """ if not self.is_authenticated(): return None if not self._userinfo or self._userinfo.is_admin != 1: logger.error("飞牛仅支持管理员账号刷新媒体库") return False libraries = set() for item in items: lib = self.__match_library_by_path(item.target_path) if lib is None: # 如果有匹配失败的,刷新整个库 return self.refresh_root_library() # 媒体库去重 libraries.add(lib.guid) # 必须调用 否则容易误报 -14 Task duplicate self._api.task_running() for lib_guid in libraries: # 逐个刷新 lib = self._libraries[lib_guid] logger.info(f"刷新媒体库:{lib.name}") if not self._api.mdb_scan(lib): # 如果失败,刷新整个库 return self.refresh_root_library() return True def __match_library_by_path(self, path: Path) -> Optional[fnapi.MediaDb]: def is_subpath(_path: Path, _parent: Path) -> bool: """ 判断_path是否是_parent的子目录下 """ _path = _path.resolve() _parent = _parent.resolve() return _path.parts[: len(_parent.parts)] == _parent.parts if path is None: return None for lib in self._libraries.values(): for d in lib.dir_list or []: if is_subpath(path, Path(d)): return lib return None def get_webhook_message(self, body: any) -> Optional[schemas.WebhookEventInfo]: pass def get_iteminfo(self, itemid: str) -> Optional[schemas.MediaServerItem]: """ 获取单个项目详情 """ if not self.is_authenticated(): return None if item := self._api.item(guid=itemid): return self.__build_media_server_item(item) return None @staticmethod def __build_media_server_item(item: fnapi.Item): if item.air_date and item.type == fnapi.Type.TV: year = item.air_date[:4] elif item.release_date: year = item.release_date[:4] else: year = None user_state = schemas.MediaServerItemUserState() if item.watched: user_state.played = True if item.duration and item.ts is not None: user_state.percentage = item.ts / item.duration * 100 user_state.resume = True if item.type is None: item_type = None else: # 将飞牛的媒体类型转为MP能识别的 item_type = "Series" if item.type == fnapi.Type.TV else item.type.value return schemas.MediaServerItem( server="trimemedia", library=item.ancestor_guid, item_id=item.guid, item_type=item_type, title=item.title, original_title=item.original_title, year=year, tmdbid=item.tmdb_id, imdbid=item.imdb_id, user_state=user_state, ) @staticmethod def __build_play_url(host: str, item: fnapi.Item) -> str: """ 拼装播放链接 """ if item.type == fnapi.Type.EPISODE: return f"{host}/tv/episode/{item.guid}" elif item.type == fnapi.Type.SEASON: return f"{host}/tv/season/{item.guid}" elif item.type == fnapi.Type.MOVIE: return f"{host}/movie/{item.guid}" elif item.type == fnapi.Type.TV: return f"{host}/tv/{item.guid}" else: # 其它类型走通用页面,由飞牛来判断 return f"{host}/other/{item.guid}" def __build_media_server_play_item( self, item: fnapi.Item ) -> schemas.MediaServerPlayItem: if item.type == fnapi.Type.EPISODE: title = item.tv_title subtitle = f"S{item.season_number}:{item.episode_number} - {item.title}" else: title = item.title subtitle = "电影" if item.type == fnapi.Type.MOVIE else "视频" types = ( MediaType.MOVIE.value if item.type in [fnapi.Type.MOVIE, fnapi.Type.VIDEO] else MediaType.TV.value ) return schemas.MediaServerPlayItem( id=item.guid, title=title, subtitle=subtitle, type=types, image=f"{self._api.host}{item.poster}", link=self.__build_play_url(self._playhost or self._api.host, item), percent=( item.ts / item.duration * 100.0 if item.duration and item.ts is not None else 0 ), server_type="trimemedia", use_cookies=True, ) def get_items( self, parent: Union[str, int], start_index: Optional[int] = 0, limit: Optional[int] = -1, ) -> Generator[schemas.MediaServerItem | None | Any, Any, None]: """ 获取媒体服务器项目列表,支持分页和不分页逻辑,默认不分页获取所有数据 :param parent: 媒体库ID,用于标识要获取的媒体库 :param start_index: 起始索引,用于分页获取数据。默认为 0,即从第一个项目开始获取 :param limit: 每次请求的最大项目数,用于分页。如果为 None 或 -1,则表示一次性获取所有数据,默认为 -1 :return: 返回一个生成器对象,用于逐步获取媒体服务器中的项目 """ if not self.is_authenticated(): return None if (page_size := limit) is None: page_size = -1 items = ( self._api.item_list( guid=parent, page=start_index + 1, page_size=page_size, types=[fnapi.Type.MOVIE, fnapi.Type.TV, fnapi.Type.DIRECTORY], ) or [] ) for item in items: if item.type == fnapi.Type.DIRECTORY: for items in self.get_items(parent=item.guid): yield items elif item.type in [fnapi.Type.MOVIE, fnapi.Type.TV]: yield self.__build_media_server_item(item) return None def get_play_url(self, item_id: str) -> Optional[str]: """ 获取媒体的外网播放链接 :param item_id: 媒体ID """ if not self.is_authenticated(): return None if (item := self._api.item(item_id)) is None: return None # 根据查询到的信息拼装出播放链接 return self.__build_play_url(self._playhost or self._api.host, item) def get_resume( self, num: Optional[int] = 12 ) -> Optional[List[schemas.MediaServerPlayItem]]: """ 获取继续观看列表 :param num: 列表大小,None不限制数量 """ if not self.is_authenticated(): return None ret_resume = [] for item in self._api.play_list() or []: if len(ret_resume) == num: break if self.__is_library_blocked(item.ancestor_guid): continue ret_resume.append(self.__build_media_server_play_item(item)) return ret_resume def get_latest(self, num=20) -> Optional[List[schemas.MediaServerPlayItem]]: """ 获取最近更新列表 """ if not self.is_authenticated(): return None items = ( self._api.item_list( page=1, page_size=max(100, num * 5), types=[fnapi.Type.MOVIE, fnapi.Type.TV], ) or [] ) latest = [] for item in items: if len(latest) == num: break if self.__is_library_blocked(item.ancestor_guid): continue latest.append(self.__build_media_server_play_item(item)) return latest def get_latest_backdrops(self, num=20, remote=False) -> Optional[List[str]]: """ 获取最近更新的媒体Backdrop图片 """ if not self.is_authenticated(): return None items = ( self._api.item_list( page=1, page_size=max(100, num * 5), types=[fnapi.Type.MOVIE, fnapi.Type.TV], ) or [] ) backdrops = [] for item in items: if len(backdrops) == num: break if self.__is_library_blocked(item.ancestor_guid): continue if (item_details := self._api.item(item.guid)) is None: continue if remote: # FIXME 新版飞牛的壁纸无法直接在浏览器中访问 img_host = self._playhost or self._api.host else: img_host = self._api.host if item_details.backdrops: item_image = item_details.backdrops else: item_image = ( item_details.posters if item_details.posters else item_details.poster ) backdrops.append(f"{img_host}{item_image}") return backdrops def __is_library_blocked(self, library_guid: str): if library := self._libraries.get(library_guid): if library.category == fnapi.Category.OTHERS: # 忽略这个库 return True return ( True if ( self._sync_libraries and "all" not in self._sync_libraries and library_guid not in self._sync_libraries ) else False ) def get_image_cookies(self, image_url: str): """ 获得指定图片的Cookies """ if not self.is_authenticated(): return None if not image_url or not SecurityUtils.is_safe_url( image_url, [self._api.host], strict=True ): return None return {"Trim-MC-token": self._api.token} ================================================ FILE: app/modules/ugreen/__init__.py ================================================ from typing import Any, Generator, List, Optional, Tuple, Union from app import schemas from app.core.context import MediaInfo from app.core.event import eventmanager from app.log import logger from app.modules import _MediaServerBase, _ModuleBase from app.modules.ugreen.ugreen import Ugreen from app.schemas import AuthCredentials, AuthInterceptCredentials from app.schemas.types import ChainEventType, MediaServerType, MediaType, ModuleType class UgreenModule(_ModuleBase, _MediaServerBase[Ugreen]): def init_module(self) -> None: """ 初始化模块 """ super().init_service( service_name=Ugreen.__name__.lower(), service_type=lambda conf: Ugreen( **conf.config, sync_libraries=conf.sync_libraries ), ) @staticmethod def get_name() -> str: return "绿联影视" @staticmethod def get_type() -> ModuleType: """ 获取模块类型 """ return ModuleType.MediaServer @staticmethod def get_subtype() -> MediaServerType: """ 获取模块子类型 """ return MediaServerType.Ugreen @staticmethod def get_priority() -> int: """ 获取模块优先级,数字越小优先级越高,只有同一接口下优先级才生效 """ return 5 def init_setting(self) -> Tuple[str, Union[str, bool]]: pass def scheduler_job(self) -> None: """ 定时任务,每10分钟调用一次 """ for name, server in self.get_instances().items(): if server.is_configured() and server.is_inactive(): logger.info(f"绿联影视 {name} 连接断开,尝试重连 ...") server.reconnect() def stop(self): for server in self.get_instances().values(): if server.is_authenticated(): server.disconnect() def test(self) -> Optional[Tuple[bool, str]]: """ 测试模块连接性 """ if not self.get_instances(): return None for name, server in self.get_instances().items(): if not server.is_configured(): return False, f"绿联影视配置不完整:{name}" if server.is_inactive() and not server.reconnect(): return False, f"无法连接绿联影视:{name}" return True, "" def user_authenticate( self, credentials: AuthCredentials, service_name: Optional[str] = None ) -> Optional[AuthCredentials]: """ 使用绿联影视用户辅助完成用户认证 """ if not credentials or credentials.grant_type != "password": return None if service_name: servers = ( [(service_name, server)] if (server := self.get_instance(service_name)) else [] ) else: servers = self.get_instances().items() for name, server in servers: intercept_event = eventmanager.send_event( etype=ChainEventType.AuthIntercept, data=AuthInterceptCredentials( username=credentials.username, channel=self.get_name(), service=name, status="triggered", ), ) if intercept_event and intercept_event.event_data: intercept_data: AuthInterceptCredentials = intercept_event.event_data if intercept_data.cancel: continue token = server.authenticate(credentials.username, credentials.password) if token: credentials.channel = self.get_name() credentials.service = name credentials.token = token return credentials return None def webhook_parser( self, body: Any, form: Any, args: Any ) -> Optional[schemas.WebhookEventInfo]: """ 解析Webhook报文体 """ source = args.get("source") if source: server: Optional[Ugreen] = self.get_instance(source) if not server: return None result = server.get_webhook_message(body) if result: result.server_name = source return result for server in self.get_instances().values(): if server: result = server.get_webhook_message(body) if result: return result return None def media_exists( self, mediainfo: MediaInfo, itemid: Optional[str] = None, server: Optional[str] = None, ) -> Optional[schemas.ExistMediaInfo]: """ 判断媒体文件是否存在 """ if server: servers = [(server, self.get_instance(server))] else: servers = self.get_instances().items() for name, s in servers: if not s: continue if mediainfo.type == MediaType.MOVIE: if itemid: movie = s.get_iteminfo(itemid) if movie: logger.info(f"媒体库 {name} 中找到了 {movie}") return schemas.ExistMediaInfo( type=MediaType.MOVIE, server_type="ugreen", server=name, itemid=movie.item_id, ) movies = s.get_movies( title=mediainfo.title, year=mediainfo.year, tmdb_id=mediainfo.tmdb_id, ) if not movies: logger.info(f"{mediainfo.title_year} 没有在媒体库 {name} 中") continue logger.info(f"媒体库 {name} 中找到了 {movies}") return schemas.ExistMediaInfo( type=MediaType.MOVIE, server_type="ugreen", server=name, itemid=movies[0].item_id, ) itemid, tvs = s.get_tv_episodes( title=mediainfo.title, year=mediainfo.year, tmdb_id=mediainfo.tmdb_id, item_id=itemid, ) if not tvs: logger.info(f"{mediainfo.title_year} 没有在媒体库 {name} 中") continue logger.info(f"{mediainfo.title_year} 在媒体库 {name} 中找到了这些季集:{tvs}") return schemas.ExistMediaInfo( type=MediaType.TV, seasons=tvs, server_type="ugreen", server=name, itemid=itemid, ) return None def media_statistic( self, server: Optional[str] = None ) -> Optional[List[schemas.Statistic]]: """ 媒体数量统计 """ if server: server_obj: Optional[Ugreen] = self.get_instance(server) if not server_obj: return None servers = [server_obj] else: servers = self.get_instances().values() media_statistics = [] for s in servers: media_statistic = s.get_medias_count() if not media_statistic: continue media_statistic.user_count = s.get_user_count() media_statistics.append(media_statistic) return media_statistics def mediaserver_librarys( self, server: Optional[str] = None, hidden: Optional[bool] = False, **kwargs ) -> Optional[List[schemas.MediaServerLibrary]]: """ 媒体库列表 """ server_obj: Optional[Ugreen] = self.get_instance(server) if server_obj: return server_obj.get_librarys(hidden=hidden) return None def mediaserver_items( self, server: str, library_id: Union[str, int], start_index: Optional[int] = 0, limit: Optional[int] = -1, ) -> Optional[Generator]: """ 获取媒体服务器项目列表 """ server_obj: Optional[Ugreen] = self.get_instance(server) if server_obj: return server_obj.get_items(library_id, start_index, limit) return None def mediaserver_iteminfo( self, server: str, item_id: str ) -> Optional[schemas.MediaServerItem]: """ 媒体库项目详情 """ server_obj: Optional[Ugreen] = self.get_instance(server) if server_obj: return server_obj.get_iteminfo(item_id) return None def mediaserver_tv_episodes( self, server: str, item_id: Union[str, int] ) -> Optional[List[schemas.MediaServerSeasonInfo]]: """ 获取剧集信息 """ if not item_id: return None server_obj: Optional[Ugreen] = self.get_instance(server) if not server_obj: return None _, seasoninfo = server_obj.get_tv_episodes(item_id=str(item_id)) if not seasoninfo: return [] return [ schemas.MediaServerSeasonInfo(season=season, episodes=episodes) for season, episodes in seasoninfo.items() ] def mediaserver_playing( self, server: str, count: Optional[int] = 20, **kwargs ) -> List[schemas.MediaServerPlayItem]: """ 获取媒体服务器正在播放信息 """ server_obj: Optional[Ugreen] = self.get_instance(server) if not server_obj: return [] return server_obj.get_resume(num=count) or [] def mediaserver_play_url( self, server: str, item_id: Union[str, int] ) -> Optional[str]: """ 获取媒体库播放地址 """ if not item_id: return None server_obj: Optional[Ugreen] = self.get_instance(server) if not server_obj: return None return server_obj.get_play_url(str(item_id)) def mediaserver_latest( self, server: Optional[str] = None, count: Optional[int] = 20, **kwargs, ) -> List[schemas.MediaServerPlayItem]: """ 获取媒体服务器最新入库条目 """ server_obj: Optional[Ugreen] = self.get_instance(server) if not server_obj: return [] return server_obj.get_latest(num=count) or [] def mediaserver_latest_images( self, server: Optional[str] = None, count: Optional[int] = 20, remote: Optional[bool] = False, **kwargs, ) -> List[str]: """ 获取媒体服务器最新入库条目的图片 """ server_obj: Optional[Ugreen] = self.get_instance(server) if not server_obj: return [] return server_obj.get_latest_backdrops(num=count, remote=remote) or [] def mediaserver_image_cookies( self, server: Optional[str] = None, image_url: Optional[str] = None, **kwargs, ) -> Optional[str | dict]: """ 获取绿联影视服务器的图片Cookies """ if not image_url: return None if server: server_obj: Optional[Ugreen] = self.get_instance(server) if not server_obj: return None return server_obj.get_image_cookies(image_url) for server_obj in self.get_instances().values(): if cookies := server_obj.get_image_cookies(image_url): return cookies return None ================================================ FILE: app/modules/ugreen/api.py ================================================ import base64 import uuid from dataclasses import dataclass from typing import Any, Dict, Mapping, Optional, Union from urllib.parse import urlsplit, urlunsplit from requests import Session from app.log import logger from app.utils.ugreen_crypto import UgreenCrypto from app.utils.url import UrlUtils @dataclass class ApiResult: code: int = -1 msg: str = "" data: Any = None debug: Optional[str] = None raw: Optional[dict] = None @property def success(self) -> bool: return self.code == 200 class Api: """ 绿联影视 API 客户端(统一加密通道)。 说明: 1. 所有业务接口调用都应走 `request()`; 2. `request()` 会自动将明文查询参数加密为 `encrypt_query`; 3. 若响应包含 `encrypt_resp_body`,会自动完成解密后再返回。 """ __slots__ = ( "_host", "_session", "_token", "_static_token", "_is_ugk", "_public_key", "_crypto", "_username", "_client_id", "_client_version", "_language", "_ug_agent", "_timeout", "_verify_ssl", ) def __init__( self, host: str, client_version: str = "76363", language: str = "zh-CN", ug_agent: str = "PC/WEB", timeout: int = 20, verify_ssl: bool = True, ): self._host = self._normalize_base_url(host) self._session = Session() self._token: Optional[str] = None self._static_token: Optional[str] = None self._is_ugk: bool = False self._public_key: Optional[str] = None self._crypto: Optional[UgreenCrypto] = None self._username: Optional[str] = None self._client_id = f"{uuid.uuid4()}-WEB" self._client_version = client_version self._language = language self._ug_agent = ug_agent self._timeout = timeout # 是否校验证书,默认开启;仅在用户明确配置时才应关闭。 self._verify_ssl = bool(verify_ssl) @property def host(self) -> str: return self._host @property def token(self) -> Optional[str]: return self._token @property def static_token(self) -> Optional[str]: return self._static_token @property def is_ugk(self) -> bool: return self._is_ugk @property def public_key(self) -> Optional[str]: return self._public_key def close(self): """ 关闭底层 HTTP 会话。 """ self._session.close() @staticmethod def _normalize_base_url(host: str) -> str: if not host: return "" host = UrlUtils.standardize_base_url(host).rstrip("/") parsed = urlsplit(host) return urlunsplit((parsed.scheme, parsed.netloc, "", "", "")).rstrip("/") @staticmethod def _decode_public_key(raw: Optional[str]) -> Optional[str]: if not raw: return None value = str(raw).strip() if not value: return None if "BEGIN" in value: return value try: return base64.b64decode(value).decode("utf-8") except Exception: return None @staticmethod def _extract_rsa_token(resp_json: dict, headers: Mapping[str, str]) -> Optional[str]: token = headers.get("x-rsa-token") or headers.get("X-Rsa-Token") if token: return token token = resp_json.get("xRsaToken") or resp_json.get("x-rsa-token") if token: return token data = resp_json.get("data") if isinstance(resp_json, Mapping) else None if isinstance(data, Mapping): return data.get("xRsaToken") or data.get("x-rsa-token") return None def _common_headers(self) -> dict[str, str]: """ 获取绿联 Web 端通用请求头。 """ return { "Accept": "application/json, text/plain, */*", "Client-Id": self._client_id, "Client-Version": self._client_version, "UG-Agent": self._ug_agent, "X-Specify-Language": self._language, } def _request_json( self, url: str, method: str = "GET", headers: Optional[dict] = None, params: Optional[dict] = None, json_data: Optional[dict] = None, ) -> Optional[dict]: """ 发送 HTTP 请求并尝试解析为 JSON。 """ try: method = method.upper() if method == "POST": resp = self._session.post( url=url, headers=headers, params=params, json=json_data, timeout=self._timeout, verify=self._verify_ssl, ) else: resp = self._session.get( url=url, headers=headers, params=params, timeout=self._timeout, verify=self._verify_ssl, ) return resp.json() except Exception as err: logger.error(f"请求绿联接口失败:{url} {err}") return None @staticmethod def _build_result(payload: Any) -> ApiResult: if not isinstance(payload, Mapping): return ApiResult(code=-1, msg="响应格式错误", raw=None) code = payload.get("code") try: code = int(code) except Exception: code = -1 return ApiResult( code=code, msg=str(payload.get("msg") or ""), data=payload.get("data"), debug=payload.get("debug"), raw=dict(payload), ) def login(self, username: str, password: str, keepalive: bool = True) -> Optional[str]: """ 登录绿联账号并初始化加密上下文。 :param username: 用户名 :param password: 密码(会先做 RSA 分段加密) :param keepalive: 是否保持登录 :return: 登录成功返回 token """ if not username or not password: return None headers = self._common_headers() try: check_resp = self._session.post( url=f"{self._host}/ugreen/v1/verify/check", headers=headers, json={"username": username}, timeout=self._timeout, verify=self._verify_ssl, ) check_json = check_resp.json() except Exception as err: logger.error(f"绿联获取登录公钥失败:{err}") return None check_result = self._build_result(check_json) if not check_result.success: logger.error(f"绿联获取登录公钥失败:{check_result.msg}") return None rsa_token = self._extract_rsa_token(check_json, check_resp.headers) login_public_key = self._decode_public_key(rsa_token) if not login_public_key: logger.error("绿联获取登录公钥失败:公钥为空") return None encrypted_password = UgreenCrypto(public_key=login_public_key).rsa_encrypt_long(password) login_json = self._request_json( url=f"{self._host}/ugreen/v1/verify/login", method="POST", headers=headers, json_data={ "username": username, "password": encrypted_password, "keepalive": keepalive, "otp": True, "is_simple": True, }, ) if not login_json: return None login_result = self._build_result(login_json) if not login_result.success or not isinstance(login_result.data, Mapping): logger.error(f"绿联登录失败:{login_result.msg}") return None token = str(login_result.data.get("token") or "").strip() public_key = self._decode_public_key(str(login_result.data.get("public_key") or "")) if not token or not public_key: logger.error("绿联登录失败:未返回 token/public_key") return None self._token = token static_token = str(login_result.data.get("static_token") or "").strip() self._static_token = static_token or self._token self._is_ugk = bool(login_result.data.get("is_ugk")) self._public_key = public_key self._crypto = UgreenCrypto( public_key=self._public_key, token=self._token, client_id=self._client_id, client_version=self._client_version, ug_agent=self._ug_agent, language=self._language, ) self._username = username return self._token def export_session_state(self) -> Optional[dict]: """ 导出当前登录会话,供持久化存储使用。 """ if not self._token or not self._public_key: return None return { "token": self._token, "static_token": self._static_token, "is_ugk": self._is_ugk, "public_key": self._public_key, "username": self._username, "client_id": self._client_id, "client_version": self._client_version, "language": self._language, "ug_agent": self._ug_agent, "cookies": self._session.cookies.get_dict(), } def import_session_state(self, state: Mapping[str, Any]) -> bool: """ 从持久化数据恢复登录会话,避免重复登录。 """ if not isinstance(state, Mapping): return False token = str(state.get("token") or "").strip() public_key = self._decode_public_key(str(state.get("public_key") or "")) if not token or not public_key: return False static_token = str(state.get("static_token") or "").strip() is_ugk = bool(state.get("is_ugk")) # 会话可能与 client_id 绑定,需恢复原客户端信息 client_id = str(state.get("client_id") or "").strip() if client_id: self._client_id = client_id client_version = str(state.get("client_version") or "").strip() if client_version: self._client_version = client_version language = str(state.get("language") or "").strip() if language: self._language = language ug_agent = str(state.get("ug_agent") or "").strip() if ug_agent: self._ug_agent = ug_agent username = str(state.get("username") or "").strip() self._username = username or None cookies = state.get("cookies") if isinstance(cookies, Mapping): try: self._session.cookies.update( { str(k): str(v) for k, v in cookies.items() if k is not None and v is not None } ) except Exception: pass self._token = token self._static_token = static_token or self._token self._is_ugk = is_ugk self._public_key = public_key self._crypto = UgreenCrypto( public_key=self._public_key, token=self._token, client_id=self._client_id, client_version=self._client_version, ug_agent=self._ug_agent, language=self._language, ) return True def logout(self): """ 登出并清理本地认证状态。 """ if not self._token or not self._crypto: return try: req = self._crypto.build_encrypted_request( url=f"{self._host}/ugreen/v1/verify/logout", method="GET", params={}, ) self._session.get( req.url, headers=req.headers, params=req.params, timeout=self._timeout, verify=self._verify_ssl, ) except Exception: pass self._token = None self._static_token = None self._is_ugk = False self._public_key = None self._crypto = None self._username = None def request( self, path: str, method: str = "GET", params: Optional[dict] = None, data: Optional[dict] = None, ) -> ApiResult: """ 统一请求入口。 核心行为: 1. 自动把 `params` 明文序列化并加密为 `encrypt_query`; 2. 自动注入绿联安全头(`X-Ugreen-*`); 3. 对 `POST/PUT/PATCH` 的 JSON 体加密; 4. 自动解密 `encrypt_resp_body`。 :param path: `/ugreen/` 后的相对路径,例如 `v1/video/homepage/media_list` :param method: HTTP 方法 :param params: 明文查询参数(无需自己处理 encrypt_query) :param data: 明文 JSON 请求体(自动加密) """ if not self._crypto: return ApiResult(code=-1, msg="未登录") api_path = path.strip("/") # 由加密工具自动构建 encrypt_query 与加密请求体 req = self._crypto.build_encrypted_request( url=f"{self._host}/ugreen/{api_path}", method=method.upper(), params=params or {}, data=data, encrypt_body=method.upper() in {"POST", "PUT", "PATCH"}, ) payload = self._request_json( url=req.url, method=method, headers=req.headers, params=req.params, json_data=req.json, ) if payload is None: return ApiResult(code=-1, msg="接口请求失败") # 响应若包含 encrypt_resp_body,这里会自动解密 decrypted = self._crypto.decrypt_response(payload, req.aes_key) return self._build_result(decrypted) def current_user(self) -> Optional[dict]: """ 获取当前登录用户信息。 """ result = self.request("v1/user/current/user") if not result.success or not isinstance(result.data, Mapping): return None return dict(result.data) def media_list(self) -> list[dict]: """ 获取首页媒体库列表(`media_lib_info_list`)。 """ result = self.request("v1/video/homepage/media_list") if not result.success or not isinstance(result.data, Mapping): return [] items = result.data.get("media_lib_info_list") return items if isinstance(items, list) else [] def media_lib_users(self) -> list[dict]: """ 获取媒体库用户列表。 """ result = self.request("v1/video/media_lib/get_user_list") if not result.success or not isinstance(result.data, Mapping): return [] users = result.data.get("user_info_arr") return users if isinstance(users, list) else [] def recently_played(self, page: int = 1, page_size: int = 12) -> Optional[dict]: """ 获取继续观看列表。 """ result = self.request( "v1/video/recently_played/get", params={ "page": page, "page_size": page_size, "language": self._language, "create_time_order": "false", }, ) return result.data if result.success and isinstance(result.data, Mapping) else None def recently_updated(self, page: int = 1, page_size: int = 20) -> Optional[dict]: """ 获取最近更新列表。 """ result = self.request( "v1/video/recently_update/get", params={ "page": page, "page_size": page_size, "language": self._language, "create_time_order": "false", }, ) return result.data if result.success and isinstance(result.data, Mapping) else None def recently_played_info(self, item_id: Union[str, int]) -> Optional[dict]: """ 获取单个视频的播放状态与基础详情信息。 """ result = self.request( "v1/video/recently_played/info", params={ "ug_video_info_id": item_id, "version_control": "true", }, ) if result.code in {200, 1303} and isinstance(result.data, Mapping): return dict(result.data) return None def search(self, keyword: str, offset: int = 0, limit: int = 200) -> Optional[dict]: """ 搜索媒体(电影/剧集)。 """ result = self.request( "v1/video/search", params={ "language": self._language, "search_type": 1, "offset": offset, "limit": limit, "keyword": keyword, }, ) return result.data if result.success and isinstance(result.data, Mapping) else None def video_all(self, classification: int, page: int = 1, page_size: int = 20) -> Optional[dict]: """ 获取 `v1/video/all` 分类列表。 常用分类: -102: 电影 -103: 电视剧 """ result = self.request( "v1/video/all", params={ "page": page, "pageSize": page_size, "classification": classification, "sort_type": 2, "order_type": 2, "release_date_begin": -9999999999, "release_date_end": -9999999999, "identify_status": 0, "watch_status": -1, "ug_style_id": 0, "ug_country_id": 0, "clarity": -1, }, ) return result.data if result.success and isinstance(result.data, Mapping) else None def poster_wall_get_folder( self, path: Optional[str] = None, page: int = 1, page_size: int = 100, sort_type: int = 1, order_type: int = 1, ) -> Optional[dict]: """ 获取海报墙文件夹与条目(可按目录路径递归展开)。 """ params: Dict[str, Any] = { "page": page, "page_size": page_size, "sort_type": sort_type, "order_type": order_type, } if path: params["path"] = path result = self.request("v1/video/poster_wall/media_lib/get_folder", params=params) return result.data if result.success and isinstance(result.data, Mapping) else None def get_movie( self, item_id: Union[str, int], media_lib_set_id: Union[str, int], path: Optional[str] = None, folder_path: Optional[str] = None, ) -> Optional[dict]: """ 获取电影详情。 """ params: Dict[str, Any] = { "id": item_id, "media_lib_set_id": media_lib_set_id, "fileVersion": "true", } if path: params["path"] = path if folder_path: params["folder_path"] = folder_path result = self.request("v1/video/details/getMovie", params=params) return result.data if result.success and isinstance(result.data, Mapping) else None def get_tv(self, item_id: Union[str, int], folder_path: str = "ALL") -> Optional[dict]: """ 获取剧集详情(含季/集信息)。 """ result = self.request( "v2/video/details/getTV", params={ "ug_video_info_id": item_id, "folder_path": folder_path, }, ) return result.data if result.success and isinstance(result.data, Mapping) else None def scan(self, media_lib_set_id: Union[str, int], scan_type: int = 2, op_type: int = 2) -> bool: """ 触发媒体库扫描。 :param media_lib_set_id: 媒体库 ID :param scan_type: 扫描类型(1: 新添加和修改, 2: 补充缺失, 3: 覆盖扫描) :param op_type: 操作类型(网页端常用 2) """ result = self.request( "v1/video/media_lib/scan", params={ "op_type": op_type, "media_lib_set_id": media_lib_set_id, "media_lib_scan_type": scan_type, }, ) return result.success def scan_status(self, only_brief: bool = True) -> list[dict]: """ 获取媒体库扫描状态。 """ result = self.request( "v1/video/media_lib/scan/status", params={"only_brief": "true" if only_brief else "false"}, ) if not result.success or not isinstance(result.data, Mapping): return [] arr = result.data.get("media_lib_scan_status_arr") return arr if isinstance(arr, list) else [] def preferences_all(self) -> Optional[Any]: """ 获取影视偏好设置(`v1/video/preferences/all`)。 """ result = self.request("v1/video/preferences/all") return result.data if result.success else None def history_get(self, num: int = 10) -> Optional[Any]: """ 获取历史记录(`v1/video/history/get`)。 """ result = self.request("v1/video/history/get", params={"num": num}) return result.data if result.success else None def data_source_get_config(self) -> Optional[Any]: """ 获取数据源配置(`v1/video/data_source/get_config`)。 """ result = self.request("v1/video/data_source/get_config") return result.data if result.success else None def homepage_slider( self, language: Optional[str] = None, app_name: str = "web" ) -> Optional[Any]: """ 获取首页轮播数据(`v1/video/homepage/slider`)。 """ result = self.request( "v1/video/homepage/slider", params={ "language": language or self._language, "app_name": app_name, }, ) return result.data if result.success else None def media_lib_guide_init(self) -> Optional[Any]: """ 获取媒体库引导初始化信息(`v1/video/media_lib/guide_init`)。 """ result = self.request("v1/video/media_lib/guide_init") return result.data if result.success else None def media_lib_filter_options( self, media_type: int = 0, language: Optional[str] = None ) -> Optional[Any]: """ 获取媒体库筛选项(`v1/video/media_lib/filter/options`)。 """ result = self.request( "v1/video/media_lib/filter/options", params={ "type": media_type, "language": language or self._language, }, ) return result.data if result.success else None def guide(self, guide_position: int = 1, client_type: int = 1) -> Optional[Any]: """ 获取引导位数据(`v1/video/guide`)。 """ result = self.request( "v1/video/guide", params={ "guide_position": guide_position, "client_type": client_type, }, ) return result.data if result.success else None def homepage_v2(self, language: Optional[str] = None) -> Optional[Any]: """ 获取新版首页聚合数据(`v2/video/homepage`)。 """ result = self.request( "v2/video/homepage", params={"language": language or self._language}, ) return result.data if result.success else None def media_lib_init_user_permission(self) -> Optional[Any]: """ 初始化用户媒体库权限(`v1/video/media_lib/init_user_permission`)。 """ result = self.request("v1/video/media_lib/init_user_permission") return result.data if result.success else None def media_lib_get_all( self, req_type: int = 2, language: Optional[str] = None ) -> Optional[Any]: """ 获取全部媒体库集合(`v1/video/media_lib/get_all`)。 """ result = self.request( "v1/video/media_lib/get_all", params={ "mediaLib_get_all_req_type": req_type, "language": language or self._language, }, ) return result.data if result.success else None ================================================ FILE: app/modules/ugreen/ugreen.py ================================================ import hashlib from collections import deque from datetime import datetime from pathlib import Path from typing import Any, Dict, Generator, List, Mapping, Optional, Union from urllib.parse import parse_qs, urlparse from app import schemas from app.db.systemconfig_oper import SystemConfigOper from app.log import logger from app.modules.ugreen.api import Api from app.schemas import MediaType from app.schemas.types import SystemConfigKey from app.utils.url import UrlUtils class Ugreen: _username: Optional[str] = None _password: Optional[str] = None _userinfo: Optional[dict] = None _host: Optional[str] = None _playhost: Optional[str] = None _libraries: dict[str, dict] = {} _library_paths: dict[str, str] = {} _sync_libraries: List[str] = [] _scan_type: int = 2 _verify_ssl: bool = True _api: Optional[Api] = None def __init__( self, host: Optional[str] = None, username: Optional[str] = None, password: Optional[str] = None, play_host: Optional[str] = None, sync_libraries: Optional[list] = None, scan_mode: Optional[Union[str, int]] = None, scan_type: Optional[Union[str, int]] = None, verify_ssl: Optional[Union[bool, str, int]] = True, **kwargs, ): if not host or not username or not password: logger.error("绿联影视配置不完整!!") return self._host = host self._username = username self._password = password self._sync_libraries = sync_libraries or [] # 绿联媒体库扫描模式: # 1 新添加和修改、2 补充缺失、3 覆盖扫描 self._scan_type = self.__resolve_scan_type(scan_mode=scan_mode, scan_type=scan_type) # HTTPS 证书校验开关:默认开启,仅兼容自签证书等场景下可关闭。 self._verify_ssl = self.__resolve_verify_ssl(verify_ssl) if play_host: self._playhost = UrlUtils.standardize_base_url(play_host).rstrip("/") if not self.reconnect(): logger.error(f"请检查服务端地址 {host}") @property def api(self) -> Optional[Api]: return self._api def close(self): self.disconnect() def is_configured(self) -> bool: return bool(self._host and self._username and self._password) def is_authenticated(self) -> bool: return ( self.is_configured() and self._api is not None and self._api.token is not None and self._userinfo is not None ) def is_inactive(self) -> bool: if not self.is_authenticated(): return True self._userinfo = self._api.current_user() if self._api else None return self._userinfo is None def __session_cache_key(self) -> str: """ 生成当前绿联实例的会话缓存键(基于 host + username)。 """ normalized_host = UrlUtils.standardize_base_url(self._host or "").rstrip("/").lower() username = (self._username or "").strip().lower() raw = f"{normalized_host}|{username}" return hashlib.sha256(raw.encode("utf-8")).hexdigest() def __password_digest(self) -> str: """ 存储密码摘要用于检测配置是否变更,避免明文落盘。 """ return hashlib.sha256((self._password or "").encode("utf-8")).hexdigest() @staticmethod def __load_all_session_cache() -> dict: sessions = SystemConfigOper().get(SystemConfigKey.UgreenSessionCache) return sessions if isinstance(sessions, dict) else {} @staticmethod def __save_all_session_cache(sessions: dict): SystemConfigOper().set(SystemConfigKey.UgreenSessionCache, sessions) def __remove_persisted_session(self): cache_key = self.__session_cache_key() sessions = self.__load_all_session_cache() if cache_key in sessions: sessions.pop(cache_key, None) self.__save_all_session_cache(sessions) def __save_persisted_session(self): if not self._api: return session_state = self._api.export_session_state() if not session_state: return sessions = self.__load_all_session_cache() cache_key = self.__session_cache_key() sessions[cache_key] = { **session_state, "host": UrlUtils.standardize_base_url(self._host or "").rstrip("/"), "username": self._username, "password_digest": self.__password_digest(), "updated_at": int(datetime.now().timestamp()), } self.__save_all_session_cache(sessions) def __restore_persisted_session(self) -> bool: cache_key = self.__session_cache_key() sessions = self.__load_all_session_cache() cached = sessions.get(cache_key) if not isinstance(cached, Mapping): return False # 配置变更(尤其密码变更)后,不复用旧会话 if cached.get("password_digest") != self.__password_digest(): logger.info(f"绿联影视 {self._username} 检测到密码变更,清理旧会话缓存") self.__remove_persisted_session() return False api = Api(host=self._host, verify_ssl=self._verify_ssl) if not api.import_session_state(cached): api.close() self.__remove_persisted_session() return False userinfo = api.current_user() if not userinfo: # 会话失效,清理缓存并走正常登录 api.close() self.__remove_persisted_session() logger.info(f"绿联影视 {self._username} 持久化会话已失效,准备重新登录") return False self._api = api self._userinfo = userinfo logger.debug(f"{self._username} 已复用绿联影视持久化会话") return True def reconnect(self) -> bool: if not self.is_configured(): return False # 关闭旧连接(不主动登出,避免破坏可复用会话) self.disconnect(logout=False) if self.__restore_persisted_session(): self.get_librarys() return True self._api = Api(host=self._host, verify_ssl=self._verify_ssl) if self._api.login(self._username, self._password) is None: self.__remove_persisted_session() return False self._userinfo = self._api.current_user() if not self._userinfo: self.__remove_persisted_session() return False # 登录成功后持久化参数,下次优先复用 self.__save_persisted_session() logger.debug(f"{self._username} 成功登录绿联影视") self.get_librarys() return True def disconnect(self, logout: bool = False): if self._api: if logout: # 显式登出时同步清理本地缓存 self._api.logout() self.__remove_persisted_session() self._api.close() self._api = None self._userinfo = None logger.debug(f"{self._username} 已断开绿联影视") @staticmethod def __normalize_dir_path(path: Union[str, Path, None]) -> str: if path is None: return "" value = str(path).replace("\\", "/").rstrip("/") return value @staticmethod def __is_subpath(path: Union[str, Path, None], parent: Union[str, Path, None]) -> bool: path_str = Ugreen.__normalize_dir_path(path) parent_str = Ugreen.__normalize_dir_path(parent) if not path_str or not parent_str: return False return path_str == parent_str or path_str.startswith(parent_str + "/") def __build_image_stream_url(self, source_url: str, size: int = 1) -> Optional[str]: """ 通过绿联 getImaStream 中转图片,规避 scraper.ugnas.com 403 问题。 """ if not self._api: return None auth_token = self._api.static_token or self._api.token if not auth_token: return None params = { "app_name": "web", "name": source_url, "size": size, } if self._api.is_ugk: params["ugk"] = auth_token else: params["token"] = auth_token return UrlUtils.combine_url( host=self._api.host, path="/ugreen/v2/video/getImaStream", query=params, ) def __resolve_image(self, path: Optional[str]) -> Optional[str]: if not path: return None if path.startswith("http://") or path.startswith("https://"): parsed = urlparse(path) if parsed.netloc.lower() == "scraper.ugnas.com": # scraper 链接优先改为本机 getImaStream,避免签名过期导致 403 if image_stream_url := self.__build_image_stream_url(path): return image_stream_url # 绿联返回的 scraper.ugnas.com 图片常带 auth_key 时效签名, # 过期后会直接 403。这里提前过滤,避免前端出现裂图。 if self.__is_expired_signed_image(path): return None return path # 绿联本地图片路径需要额外鉴权头,MP图片代理当前仅支持Cookie,故先忽略本地路径。 return None @staticmethod def __is_expired_signed_image(url: str) -> bool: """ 判断绿联 scraper 签名图是否已过期。 auth_key 结构通常为: `{过期时间戳}-{随机串}-...` """ try: parsed = urlparse(url) if parsed.netloc.lower() != "scraper.ugnas.com": return False auth_key = parse_qs(parsed.query).get("auth_key", [None])[0] if not auth_key: return False expire_part = str(auth_key).split("-", 1)[0] expire_ts = int(expire_part) now_ts = int(datetime.now().timestamp()) return expire_ts <= now_ts except Exception: return False @staticmethod def __parse_year(video_info: dict) -> Optional[Union[str, int]]: year = video_info.get("year") if isinstance(year, int) and year > 0: return year release_date = video_info.get("release_date") if isinstance(release_date, (int, float)) and release_date > 0: try: return datetime.fromtimestamp(release_date).year except Exception: return None return None @staticmethod def __map_item_type(video_type: Any) -> Optional[str]: if video_type == 2: return "Series" if video_type == 1: return "Movie" if video_type == 3: return "Collection" if video_type == 0: return "Folder" return "Video" @staticmethod def __build_media_server_item(video_info: dict, play_status: Optional[dict] = None): user_state = schemas.MediaServerItemUserState() if isinstance(play_status, dict): progress = play_status.get("progress") watch_status = play_status.get("watch_status") if watch_status == 2: user_state.played = True if isinstance(progress, (int, float)) and progress > 0: user_state.resume = progress < 1 user_state.percentage = progress * 100.0 last_play_time = play_status.get("last_access_time") or play_status.get("LastPlayTime") if isinstance(last_play_time, (int, float)) and last_play_time > 0: user_state.last_played_date = str(int(last_play_time)) tmdb_id = video_info.get("tmdb_id") if not isinstance(tmdb_id, int) or tmdb_id <= 0: tmdb_id = None item_id = video_info.get("ug_video_info_id") if item_id is None: return None return schemas.MediaServerItem( server="ugreen", library=video_info.get("media_lib_set_id"), item_id=str(item_id), item_type=Ugreen.__map_item_type(video_info.get("type")), title=video_info.get("name"), original_title=video_info.get("original_name"), year=Ugreen.__parse_year(video_info), tmdbid=tmdb_id, user_state=user_state, ) def __build_root_url(self) -> str: """ 统一返回 NAS Web 根地址作为跳转链接,避免失效深链。 """ host = self._playhost or (self._api.host if self._api else "") if not host: return "" return f"{host.rstrip('/')}/" def __build_play_url(self, item_id: Union[str, int], video_type: Any, media_lib_set_id: Any) -> str: # 绿联深链在部分版本会失效,统一回落到 NAS 根地址。 return self.__build_root_url() def __build_play_item_from_wrapper(self, wrapper: dict) -> Optional[schemas.MediaServerPlayItem]: video_info = wrapper.get("video_info") if isinstance(wrapper.get("video_info"), dict) else wrapper if not isinstance(video_info, dict): return None item_id = video_info.get("ug_video_info_id") if item_id is None: return None play_status = wrapper.get("play_status") if isinstance(wrapper.get("play_status"), dict) else {} progress = play_status.get("progress") if isinstance(play_status.get("progress"), (int, float)) else 0 if video_info.get("type") == 2: subtitle = play_status.get("tv_name") or "剧集" media_type = MediaType.TV.value else: subtitle = "电影" if video_info.get("type") == 1 else "视频" media_type = MediaType.MOVIE.value image = self.__resolve_image(video_info.get("poster_path")) or self.__resolve_image( video_info.get("backdrop_path") ) return schemas.MediaServerPlayItem( id=str(item_id), title=video_info.get("name"), subtitle=subtitle, type=media_type, image=image, link=self.__build_play_url(item_id, video_info.get("type"), video_info.get("media_lib_set_id")), percent=max(0.0, min(100.0, progress * 100.0)), server_type="ugreen", use_cookies=False, ) @staticmethod def __infer_library_type(name: str, path: Optional[str]) -> str: name = name or "" path = path or "" if "电视剧" in path or any(key in name for key in ["剧", "综艺", "动漫", "纪录片"]): return MediaType.TV.value if "电影" in path or "电影" in name: return MediaType.MOVIE.value return MediaType.UNKNOWN.value def __is_library_blocked(self, library_id: str) -> bool: return ( True if ( self._sync_libraries and "all" not in self._sync_libraries and str(library_id) not in self._sync_libraries ) else False ) @staticmethod def __resolve_scan_type( scan_mode: Optional[Union[str, int]] = None, scan_type: Optional[Union[str, int]] = None, ) -> int: """ 解析绿联扫描模式并转为 `media_lib_scan_type`。 支持值: - 1 / new_and_modified: 新添加和修改 - 2 / supplement_missing: 补充缺失 - 3 / full_override: 覆盖扫描 """ # 优先使用显式 scan_type 数值配置。 for value in (scan_type, scan_mode): try: parsed = int(value) # type: ignore[arg-type] if parsed in (1, 2, 3): return parsed except Exception: pass mode = str(scan_mode or "").strip().lower() mode_map = { "new_and_modified": 1, "new_modified": 1, "add": 1, "added": 1, "new": 1, "scan_new_modified": 1, "supplement_missing": 2, "supplement": 2, "additional": 2, "missing": 2, "scan_missing": 2, "full_override": 3, "override": 3, "cover": 3, "replace": 3, "scan_override": 3, } return mode_map.get(mode, 2) @staticmethod def __resolve_verify_ssl(verify_ssl: Optional[Union[bool, str, int]]) -> bool: if isinstance(verify_ssl, bool): return verify_ssl if verify_ssl is None: return True value = str(verify_ssl).strip().lower() if value in {"1", "true", "yes", "on"}: return True if value in {"0", "false", "no", "off"}: return False return True def __scan_library(self, library_id: str, scan_type: Optional[int] = None) -> bool: if not self._api: return False return self._api.scan( media_lib_set_id=library_id, scan_type=scan_type or self._scan_type, op_type=2, ) def __load_library_paths(self) -> dict[str, str]: if not self._api: return {} paths: dict[str, str] = {} page = 1 while True: data = self._api.poster_wall_get_folder(page=page, page_size=100) if not data: break for folder in data.get("folder_arr") or []: lib_id = folder.get("media_lib_set_id") lib_path = folder.get("path") if lib_id is not None and lib_path: paths[str(lib_id)] = str(lib_path) if data.get("is_last_page"): break page += 1 return paths def get_librarys(self, hidden: Optional[bool] = False) -> List[schemas.MediaServerLibrary]: if not self.is_authenticated() or not self._api: return [] media_libs = self._api.media_list() self._library_paths = self.__load_library_paths() libraries = [] self._libraries = {} for lib in media_libs: lib_id = str(lib.get("media_lib_set_id")) if hidden and self.__is_library_blocked(lib_id): continue lib_name = lib.get("media_name") or "" lib_path = self._library_paths.get(lib_id) library_type = self.__infer_library_type(lib_name, lib_path) poster_paths = lib.get("poster_paths") or [] backdrop_paths = lib.get("backdrop_paths") or [] image_list = list( filter( None, [self.__resolve_image(p) for p in [*poster_paths, *backdrop_paths]], ) ) self._libraries[lib_id] = { "id": lib_id, "name": lib_name, "path": lib_path, "type": library_type, "video_count": lib.get("video_count") or 0, } libraries.append( schemas.MediaServerLibrary( server="ugreen", id=lib_id, name=lib_name, type=library_type, path=lib_path, image_list=image_list, link=self.__build_root_url(), server_type="ugreen", use_cookies=False, ) ) return libraries def get_user_count(self) -> int: if not self.is_authenticated() or not self._api: return 0 users = self._api.media_lib_users() return len(users) def get_medias_count(self) -> schemas.Statistic: if not self.is_authenticated() or not self._api: return schemas.Statistic() movie_data = self._api.video_all(classification=-102, page=1, page_size=1) or {} tv_data = self._api.video_all(classification=-103, page=1, page_size=1) or {} return schemas.Statistic( movie_count=int(movie_data.get("total_num") or 0), tv_count=int(tv_data.get("total_num") or 0), # 绿联当前不统计剧集总数,返回 None 由前端展示“未获取”。 episode_count=None, ) def authenticate(self, username: str, password: str) -> Optional[str]: if not username or not password or not self._host: return None api = Api(self._host, verify_ssl=self._verify_ssl) try: return api.login(username, password) finally: api.logout() api.close() @staticmethod def __extract_video_info_list(bucket: Any) -> list[dict]: if not isinstance(bucket, Mapping): return [] video_arr = bucket.get("video_arr") if not isinstance(video_arr, list): return [] result = [] for item in video_arr: if not isinstance(item, Mapping): continue info = item.get("video_info") if isinstance(info, Mapping): result.append(dict(info)) return result def get_movies( self, title: str, year: Optional[str] = None, tmdb_id: Optional[int] = None ) -> Optional[List[schemas.MediaServerItem]]: if not self.is_authenticated() or not self._api or not title: return None data = self._api.search(title) if not data: return [] movies = [] for info in self.__extract_video_info_list(data.get("movies_list")): info_tmdb = info.get("tmdb_id") if tmdb_id and tmdb_id != info_tmdb: continue if title not in [info.get("name"), info.get("original_name")]: continue item_year = info.get("year") if year and str(item_year) != str(year): continue media_item = self.__build_media_server_item(info) if media_item: movies.append(media_item) return movies def __search_tv_item(self, title: str, year: Optional[str] = None, tmdb_id: Optional[int] = None) -> Optional[dict]: if not self._api: return None data = self._api.search(title) if not data: return None for info in self.__extract_video_info_list(data.get("tv_list")): if tmdb_id and tmdb_id != info.get("tmdb_id"): continue if title not in [info.get("name"), info.get("original_name")]: continue item_year = info.get("year") if year and str(item_year) != str(year): continue return info return None def get_tv_episodes( self, item_id: Optional[str] = None, title: Optional[str] = None, year: Optional[str] = None, tmdb_id: Optional[int] = None, season: Optional[int] = None, ) -> tuple[Optional[str], Optional[Dict[int, list]]]: if not self.is_authenticated() or not self._api: return None, None if not item_id: if not title: return None, None if not (tv_info := self.__search_tv_item(title, year, tmdb_id)): return None, None found_item_id = tv_info.get("ug_video_info_id") if found_item_id is None: return None, None item_id = str(found_item_id) else: item_id = str(item_id) item_info = self.get_iteminfo(item_id) if not item_info: return None, {} if tmdb_id and item_info.tmdbid and tmdb_id != item_info.tmdbid: return None, {} tv_detail = self._api.get_tv(item_id, folder_path="ALL") if not tv_detail: return None, {} season_map = {} for info in tv_detail.get("season_info") or []: if not isinstance(info, dict): continue category_id = info.get("category_id") season_num = info.get("season_num") if category_id and isinstance(season_num, int): season_map[str(category_id)] = season_num season_episodes: Dict[int, list] = {} for ep in tv_detail.get("tv_info") or []: if not isinstance(ep, dict): continue episode = ep.get("episode") if not isinstance(episode, int): continue season_num = season_map.get(str(ep.get("category_id")), 1) if season is not None and season_num != season: continue season_episodes.setdefault(season_num, []).append(episode) for season_num in list(season_episodes.keys()): season_episodes[season_num] = sorted(set(season_episodes[season_num])) return item_id, season_episodes def refresh_root_library(self, scan_mode: Optional[Union[str, int]] = None) -> Optional[bool]: if not self.is_authenticated() or not self._api: return None if not self._libraries: self.get_librarys() scan_type = ( self.__resolve_scan_type(scan_mode=scan_mode) if scan_mode is not None else self._scan_type ) results = [] for lib_id in self._libraries.keys(): logger.info( f"刷新媒体库:{self._libraries[lib_id].get('name')}(扫描模式: {scan_type})" ) results.append(self.__scan_library(library_id=lib_id, scan_type=scan_type)) return all(results) if results else True def __match_library_id_by_path(self, path: Optional[Path]) -> Optional[str]: if path is None: return None path_str = self.__normalize_dir_path(path) if not self._library_paths: self.get_librarys() for lib_id, lib_path in self._library_paths.items(): if self.__is_subpath(path_str, lib_path): return lib_id return None def refresh_library_by_items( self, items: List[schemas.RefreshMediaItem], scan_mode: Optional[Union[str, int]] = None, ) -> Optional[bool]: if not self.is_authenticated() or not self._api: return None scan_type = ( self.__resolve_scan_type(scan_mode=scan_mode) if scan_mode is not None else self._scan_type ) library_ids = set() for item in items: library_id = self.__match_library_id_by_path(item.target_path) if library_id is None: return self.refresh_root_library(scan_mode=scan_mode) library_ids.add(library_id) for library_id in library_ids: lib_name = self._libraries.get(library_id, {}).get("name", library_id) logger.info(f"刷新媒体库:{lib_name}(扫描模式: {scan_type})") if not self.__scan_library(library_id=library_id, scan_type=scan_type): return self.refresh_root_library(scan_mode=scan_mode) return True @staticmethod def get_webhook_message(body: Any) -> Optional[schemas.WebhookEventInfo]: return None def get_iteminfo(self, itemid: str) -> Optional[schemas.MediaServerItem]: if not self.is_authenticated() or not self._api or not itemid: return None info = self._api.recently_played_info(itemid) if not info: return None video_info = info.get("video_info") if isinstance(info.get("video_info"), dict) else None if not video_info or not video_info.get("ug_video_info_id"): return None return self.__build_media_server_item(video_info, info.get("play_status")) def _iter_library_videos(self, root_path: str, page_size: int = 100): if not self._api or not root_path: return queue = deque([root_path]) visited: set[str] = set() max_paths = 20000 while queue and len(visited) < max_paths: current_path = queue.popleft() if current_path in visited: continue visited.add(current_path) page = 1 while True: data = self._api.poster_wall_get_folder( path=current_path, page=page, page_size=page_size, sort_type=1, order_type=1, ) if not data: break for video in data.get("video_arr") or []: if isinstance(video, dict): yield video for folder in data.get("folder_arr") or []: if not isinstance(folder, dict): continue sub_path = folder.get("path") if sub_path and sub_path not in visited: queue.append(str(sub_path)) if data.get("is_last_page"): break page += 1 def get_items( self, parent: Union[str, int], start_index: Optional[int] = 0, limit: Optional[int] = -1, ) -> Generator[schemas.MediaServerItem | None | Any, Any, None]: if not self.is_authenticated() or not self._api: return None library_id = str(parent) if not self._library_paths: self.get_librarys() root_path = self._library_paths.get(library_id) if not root_path: return None skip = max(0, start_index or 0) remain = -1 if limit in [None, -1] else max(0, limit) for video in self._iter_library_videos(root_path=root_path): video_type = video.get("type") if video_type not in [1, 2]: continue if skip > 0: skip -= 1 continue item = self.__build_media_server_item(video) if item: yield item if remain != -1: remain -= 1 if remain <= 0: break return None def get_play_url(self, item_id: str) -> Optional[str]: if not self.is_authenticated() or not self._api: return None info = self._api.recently_played_info(item_id) if not info: return None video_info = info.get("video_info") if isinstance(info.get("video_info"), dict) else None if not video_info: return None return self.__build_play_url( item_id=item_id, video_type=video_info.get("type"), media_lib_set_id=video_info.get("media_lib_set_id"), ) def get_resume(self, num: Optional[int] = 12) -> Optional[List[schemas.MediaServerPlayItem]]: if not self.is_authenticated() or not self._api: return None page_size = max(1, num or 12) data = self._api.recently_played(page=1, page_size=page_size) if not data: return [] ret_resume = [] for item in data.get("video_arr") or []: if len(ret_resume) == page_size: break if not isinstance(item, dict): continue video_info = item.get("video_info") if isinstance(item.get("video_info"), dict) else {} library_id = str(video_info.get("media_lib_set_id") or "") if self.__is_library_blocked(library_id): continue play_item = self.__build_play_item_from_wrapper(item) if play_item: ret_resume.append(play_item) return ret_resume def get_latest(self, num: int = 20) -> Optional[List[schemas.MediaServerPlayItem]]: if not self.is_authenticated() or not self._api: return None page_size = max(1, num) data = self._api.recently_updated(page=1, page_size=page_size) if not data: return [] latest = [] for item in data.get("video_arr") or []: if len(latest) == page_size: break if not isinstance(item, dict): continue video_info = item.get("video_info") if isinstance(item.get("video_info"), dict) else {} library_id = str(video_info.get("media_lib_set_id") or "") if self.__is_library_blocked(library_id): continue play_item = self.__build_play_item_from_wrapper(item) if play_item: latest.append(play_item) return latest def get_latest_backdrops(self, num: int = 20, remote: bool = False) -> Optional[List[str]]: if not self.is_authenticated() or not self._api: return None data = self._api.recently_updated(page=1, page_size=max(1, num)) if not data: return [] images: List[str] = [] for item in data.get("video_arr") or []: if len(images) == num: break if not isinstance(item, dict): continue video_info = item.get("video_info") if isinstance(item.get("video_info"), dict) else {} library_id = str(video_info.get("media_lib_set_id") or "") if self.__is_library_blocked(library_id): continue image = self.__resolve_image(video_info.get("backdrop_path")) or self.__resolve_image( video_info.get("poster_path") ) if image: images.append(image) return images @staticmethod def get_image_cookies(image_url: str): # 绿联图片流接口依赖加密鉴权头,当前图片代理仅支持Cookie注入。 return None ================================================ FILE: app/modules/vocechat/__init__.py ================================================ import json from typing import Optional, Union, List, Tuple, Any, Dict from app.core.context import Context, MediaInfo from app.log import logger from app.modules import _ModuleBase, _MessageBase from app.modules.vocechat.vocechat import VoceChat from app.schemas import MessageChannel, CommingMessage, Notification from app.schemas.types import ModuleType class VoceChatModule(_ModuleBase, _MessageBase[VoceChat]): def init_module(self) -> None: """ 初始化模块 """ super().init_service(service_name=VoceChat.__name__.lower(), service_type=VoceChat) self._channel = MessageChannel.VoceChat @staticmethod def get_name() -> str: return "VoceChat" @staticmethod def get_type() -> ModuleType: """ 获取模块类型 """ return ModuleType.Notification @staticmethod def get_subtype() -> MessageChannel: """ 获取模块子类型 """ return MessageChannel.VoceChat @staticmethod def get_priority() -> int: """ 获取模块优先级,数字越小优先级越高,只有同一接口下优先级才生效 """ return 4 def stop(self): pass def test(self) -> Optional[Tuple[bool, str]]: """ 测试模块连接性 """ if not self.get_instances(): return None for name, client in self.get_instances().items(): state = client.get_state() if not state: return False, f"VoceChat {name} 未就绪" return True, "" def init_setting(self) -> Tuple[str, Union[str, bool]]: pass def message_parser(self, source: str, body: Any, form: Any, args: Any) -> Optional[CommingMessage]: """ 解析消息内容,返回字典,注意以下约定值: userid: 用户ID username: 用户名 text: 内容 :param source: 消息来源 :param body: 请求体 :param form: 表单 :param args: 参数 :return: 渠道、消息体 """ try: """ { "created_at": 1672048481664, //消息创建的时间戳 "detail": { "content": "hello this is my message to you", //消息内容 "content_type": "text/plain", //消息类型,text/plain:纯文本消息,text/markdown:markdown消息,vocechat/file:文件类消息 "expires_in": null, //消息过期时长,如果有大于0数字,说明该消息是个限时消息 "properties": null, //一些有关消息的元数据,比如at信息,文件消息的具体类型信息,如果是个图片消息,还会有一些宽高,图片名称等元信息 "type": "normal" //消息类型,normal代表是新消息 }, "from_uid": 7910, //来自于谁 "mid": 2978, //消息ID "target": { "gid": 2 } //发送给谁,gid代表是发送给频道,uid代表是发送给个人,此时的数据结构举例:{"uid":1} } """ # 获取服务配置 client_config = self.get_config(source) if not client_config: return None # 报文体 msg_body = json.loads(body) # 类型 msg_type = msg_body.get("detail", {}).get("type") if msg_type != "normal": # 非新消息 return None logger.debug(f"收到VoceChat请求:{msg_body}") # 文本内容 content = msg_body.get("detail", {}).get("content") # 用户ID gid = msg_body.get("target", {}).get("gid") channel_id = client_config.config.get("channel_id") if gid and str(gid) == str(channel_id): # 来自监听频道的消息 userid = f"GID#{gid}" else: # 来自个人的消息 userid = f"UID#{msg_body.get('from_uid')}" # 处理消息内容 if content and userid: logger.info(f"收到来自 {client_config.name} 的VoceChat消息:userid={userid}, text={content}") return CommingMessage(channel=MessageChannel.VoceChat, source=client_config.name, userid=userid, username=userid, text=content) except Exception as err: logger.error(f"VoceChat消息处理发生错误:{str(err)}") return None def post_message(self, message: Notification, **kwargs) -> None: """ 发送消息 :param message: 消息内容 :return: 成功或失败 """ for conf in self.get_configs().values(): if not self.check_message(message, conf.name): continue targets = message.targets userid = message.userid if not message.userid and targets: userid = targets.get('telegram_userid') client: VoceChat = self.get_instance(conf.name) if client: client.send_msg(title=message.title, text=message.text, userid=userid, link=message.link) def post_medias_message(self, message: Notification, medias: List[MediaInfo]) -> None: """ 发送媒体信息选择列表 :param message: 消息内容 :param medias: 媒体列表 :return: 成功或失败 """ for conf in self.get_configs().values(): if not self.check_message(message, conf.name): continue client: VoceChat = self.get_instance(conf.name) if client: client.send_msg(title=message.title, userid=message.userid) client.send_medias_msg(title=message.title, medias=medias, userid=message.userid, link=message.link) def post_torrents_message(self, message: Notification, torrents: List[Context]) -> None: """ 发送种子信息选择列表 :param message: 消息内容 :param torrents: 种子列表 :return: 成功或失败 """ for conf in self.get_configs().values(): if not self.check_message(message, conf.name): continue targets = message.targets userid = message.userid if not userid and targets is not None: userid = targets.get('vocechat_userid') if not userid: logger.warn(f"用户没有指定 VoceChat用户ID,消息无法发送") return client: VoceChat = self.get_instance(conf.name) if client: client.send_torrents_msg(title=message.title, torrents=torrents, userid=userid, link=message.link) def register_commands(self, commands: Dict[str, dict]): pass ================================================ FILE: app/modules/vocechat/vocechat.py ================================================ import re import threading from typing import Optional, List from app.core.context import MediaInfo, Context from app.core.metainfo import MetaInfo from app.log import logger from app.utils.common import retry from app.utils.http import RequestUtils from app.utils.string import StringUtils lock = threading.Lock() class VoceChat: # host _host = None # apikey _apikey = None # 频道ID _channel_id = None # 请求对象 _client = None def __init__(self, VOCECHAT_HOST: Optional[str] = None, VOCECHAT_API_KEY: Optional[str] = None, VOCECHAT_CHANNEL_ID: Optional[str] = None, **kwargs): """ 初始化 """ if not VOCECHAT_HOST or not VOCECHAT_API_KEY or not VOCECHAT_CHANNEL_ID: logger.error("VoceChat配置不完整!") return self._host = VOCECHAT_HOST if self._host: if not self._host.endswith("/"): self._host += "/" if not self._host.startswith("http"): self._playhost = "http://" + self._host self._apikey = VOCECHAT_API_KEY self._channel_id = VOCECHAT_CHANNEL_ID if self._apikey and self._host and self._channel_id: self._client = RequestUtils(headers={ "content-type": "text/markdown", "x-api-key": self._apikey, "accept": "application/json; charset=utf-8" }) def get_state(self): """ 获取状态 """ return True if self.get_groups() else False def get_groups(self): """ 获取频道列表 """ if not self._client: return None result = self._client.get_res(f"{self._host}api/bot") if result and result.status_code == 200: return result.json() def send_msg(self, title: str, text: Optional[str] = None, userid: Optional[str] = None, link: Optional[str] = None) -> Optional[bool]: """ 微信消息发送入口,支持文本、图片、链接跳转、指定发送对象 :param title: 消息标题 :param text: 消息内容 :param userid: 消息发送对象的ID,为空则发给所有人 :param link: 消息链接 :return: 发送状态,错误信息 """ if not self._client: return None if not title and not text: logger.warn("标题和内容不能同时为空") return False try: if text: caption = f"**{title}**\n{text}" else: caption = f"**{title}**" if link: caption = f"{caption}\n[查看详情]({link})" if userid: chat_id = userid else: chat_id = f"GID#{self._channel_id}" return self.__send_request(userid=chat_id, caption=caption) except Exception as msg_e: logger.error(f"发送消息失败:{msg_e}") return False def send_medias_msg(self, title: str, medias: List[MediaInfo], userid: Optional[str] = None, link: Optional[str] = None) -> Optional[bool]: """ 发送列表类消息 """ if not self._client: return None try: index, caption = 1, "**%s**" % title for media in medias: if media.vote_average: caption = "%s\n%s. [%s](%s)\n_%s,%s_" % (caption, index, media.title_year, media.detail_link, f"类型:{media.type.value}", f"评分:{media.vote_average}") else: caption = "%s\n%s. [%s](%s)\n_%s_" % (caption, index, media.title_year, media.detail_link, f"类型:{media.type.value}") index += 1 if link: caption = f"{caption}\n[查看详情]({link})" if userid: chat_id = userid else: chat_id = f"GID#{self._channel_id}" return self.__send_request(userid=chat_id, caption=caption) except Exception as msg_e: logger.error(f"发送消息失败:{msg_e}") return False def send_torrents_msg(self, torrents: List[Context], userid: Optional[str] = None, title: Optional[str] = None, link: Optional[str] = None) -> Optional[bool]: """ 发送列表消息 """ if not self._client: return None if not torrents: return False try: index, caption = 1, "**%s**" % title for context in torrents: torrent = context.torrent_info site_name = torrent.site_name meta = MetaInfo(torrent.title, torrent.description) link = torrent.page_url title = f"{meta.season_episode} " \ f"{meta.resource_term} " \ f"{meta.video_term} " \ f"{meta.release_group}" title = re.sub(r"\s+", " ", title).strip() free = torrent.volume_factor seeder = f"{torrent.seeders}↑" caption = f"{caption}\n{index}.【{site_name}】[{title}]({link}) " \ f"{StringUtils.str_filesize(torrent.size)} {free} {seeder}" index += 1 if link: caption = f"{caption}\n[查看详情]({link})" if userid: chat_id = userid else: chat_id = f"GID#{self._channel_id}" return self.__send_request(userid=chat_id, caption=caption) except Exception as msg_e: logger.error(f"发送消息失败:{msg_e}") return False @retry(Exception, logger=logger) def __send_request(self, userid: str, caption: str) -> bool: """ 向VoceChat发送报文 userid格式:UID#xxx / GID#xxx """ if not self._client: return False if userid.startswith("GID#"): action = "send_to_group" else: action = "send_to_user" idstr = userid[4:] with lock: result = self._client.post_res(f"{self._host}api/bot/{action}/{idstr}", data=caption.encode("utf-8")) if result and result.status_code == 200: return True elif result is not None: logger.error(f"VoceChat发送消息失败,错误码:{result.status_code}") return False else: raise Exception("VoceChat发送消息失败,连接失败") ================================================ FILE: app/modules/webpush/__init__.py ================================================ import json from typing import Union, Tuple from pywebpush import webpush, WebPushException from app.core.config import global_vars, settings from app.log import logger from app.modules import _ModuleBase, _MessageBase from app.schemas import Notification from app.schemas.types import ModuleType, MessageChannel class WebPushModule(_ModuleBase, _MessageBase): def init_module(self) -> None: """ 初始化模块 """ super().init_service(service_name=self.get_name().lower()) self._channel = MessageChannel.WebPush @staticmethod def get_name() -> str: return "WebPush" @staticmethod def get_type() -> ModuleType: """ 获取模块类型 """ return ModuleType.Notification @staticmethod def get_subtype() -> MessageChannel: """ 获取模块子类型 """ return MessageChannel.WebPush @staticmethod def get_priority() -> int: """ 获取模块优先级,数字越小优先级越高,只有同一接口下优先级才生效 """ return 6 def stop(self): pass def test(self) -> Tuple[bool, str]: """ 测试模块连接性 """ return True, "" def init_setting(self) -> Tuple[str, Union[str, bool]]: pass def post_message(self, message: Notification, **kwargs) -> None: """ 发送消息 :param message: 消息内容 :return: 成功或失败 """ for conf in self.get_configs().values(): if not self.check_message(message, conf.name): continue webpush_users = conf.config.get("WEBPUSH_USERNAME") or "" if webpush_users: # 设定了接收用户时,非该用户的消息不接收 if not message.username or message.username not in webpush_users.split(","): continue if not message.title and not message.text: logger.warn("标题和内容不能同时为空") return try: if message.title: caption = message.title content = message.text else: caption = message.text content = "" for sub in global_vars.get_subscriptions(): logger.debug(f"给 {sub} 发送WebPush:{caption} {content}") try: webpush( subscription_info=sub, data=json.dumps({ "title": caption, "body": content, "url": message.link or "/?shotcut=message" }), vapid_private_key=settings.VAPID.get("privateKey"), vapid_claims={ "sub": settings.VAPID.get("subject") }, ) except WebPushException as err: logger.error(f"WebPush发送失败: {str(err)}") except Exception as msg_e: logger.error(f"发送消息失败:{msg_e}") ================================================ FILE: app/modules/wechat/WXBizMsgCrypt3.py ================================================ #!/usr/bin/env python # -*- encoding:utf-8 -*- """ 对企业微信发送给企业后台的消息加解密示例代码. @copyright: Copyright (c) 1998-2014 Tencent Inc. """ import base64 import hashlib # ------------------------------------------------------------------------ import logging import random import socket import struct import time import xml.etree.cElementTree as ET from Crypto.Cipher import AES # Description:定义错误码含义 ######################################################################### WXBizMsgCrypt_OK = 0 WXBizMsgCrypt_ValidateSignature_Error = -40001 WXBizMsgCrypt_ParseXml_Error = -40002 WXBizMsgCrypt_ComputeSignature_Error = -40003 WXBizMsgCrypt_IllegalAesKey = -40004 WXBizMsgCrypt_ValidateCorpid_Error = -40005 WXBizMsgCrypt_EncryptAES_Error = -40006 WXBizMsgCrypt_DecryptAES_Error = -40007 WXBizMsgCrypt_IllegalBuffer = -40008 WXBizMsgCrypt_EncodeBase64_Error = -40009 WXBizMsgCrypt_DecodeBase64_Error = -40010 WXBizMsgCrypt_GenReturnXml_Error = -40011 """ 关于Crypto.Cipher模块,ImportError: No module named 'Crypto'解决方案 请到官方网站 https://www.dlitz.net/software/pycrypto/ 下载pycrypto。 下载后,按照README中的“Installation”小节的提示进行pycrypto安装。 """ class FormatException(Exception): pass def throw_exception(message, exception_class=FormatException): """my define raise exception function""" raise exception_class(message) class SHA1: """计算企业微信的消息签名接口""" @staticmethod def getSHA1(token, timestamp, nonce, encrypt): """用SHA1算法生成安全签名 @param token: 票据 @param timestamp: 时间戳 @param encrypt: 密文 @param nonce: 随机字符串 @return: 安全签名 """ try: sortlist = [token, timestamp, nonce, encrypt] sortlist.sort() sha = hashlib.sha1() sha.update("".join(sortlist).encode()) return WXBizMsgCrypt_OK, sha.hexdigest() except Exception as e: logger = logging.getLogger() logger.error(e) return WXBizMsgCrypt_ComputeSignature_Error, None class XMLParse: """提供提取消息格式中的密文及生成回复消息格式的接口""" # xml消息模板 AES_TEXT_RESPONSE_TEMPLATE = """ %(timestamp)s """ @staticmethod def extract(xmltext): """提取出xml数据包中的加密消息 @param xmltext: 待提取的xml字符串 @return: 提取出的加密消息字符串 """ try: xml_tree = ET.fromstring(xmltext) encrypt = xml_tree.find("Encrypt") return WXBizMsgCrypt_OK, encrypt.text except Exception as e: logger = logging.getLogger() logger.error(e) return WXBizMsgCrypt_ParseXml_Error, None def generate(self, encrypt, signature, timestamp, nonce): """生成xml消息 @param encrypt: 加密后的消息密文 @param signature: 安全签名 @param timestamp: 时间戳 @param nonce: 随机字符串 @return: 生成的xml字符串 """ resp_dict = { 'msg_encrypt': encrypt, 'msg_signaturet': signature, 'timestamp': timestamp, 'nonce': nonce, } resp_xml = self.AES_TEXT_RESPONSE_TEMPLATE % resp_dict return resp_xml class PKCS7Encoder: """提供基于PKCS7算法的加解密接口""" block_size = 32 def encode(self, text): """ 对需要加密的明文进行填充补位 @param text: 需要进行填充补位操作的明文 @return: 补齐明文字符串 """ text_length = len(text) # 计算需要填充的位数 amount_to_pad = self.block_size - (text_length % self.block_size) if amount_to_pad == 0: amount_to_pad = self.block_size # 获得补位所用的字符 pad = chr(amount_to_pad) return text + (pad * amount_to_pad).encode() @staticmethod def decode(decrypted): """删除解密后明文的补位字符 @param decrypted: 解密后的明文 @return: 删除补位字符后的明文 """ pad = ord(decrypted[-1]) if pad < 1 or pad > 32: pad = 0 return decrypted[:-pad] class Prpcrypt(object): """提供接收和推送给企业微信消息的加解密接口""" def __init__(self, key): # self.key = base64.b64decode(key+"=") self.key = key # 设置加解密模式为AES的CBC模式 self.mode = AES.MODE_CBC def encrypt(self, text, receiveid): """对明文进行加密 @param text: 需要加密的明文 @param receiveid: receiveid @return: 加密得到的字符串 """ # 16位随机字符串添加到明文开头 text = text.encode() text = self.get_random_str() + struct.pack("I", socket.htonl(len(text))) + text + receiveid.encode() # 使用自定义的填充方式对明文进行补位填充 pkcs7 = PKCS7Encoder() text = pkcs7.encode(text) # 加密 cryptor = AES.new(self.key, self.mode, self.key[:16]) try: ciphertext = cryptor.encrypt(text) # 使用BASE64对加密后的字符串进行编码 return WXBizMsgCrypt_OK, base64.b64encode(ciphertext) except Exception as e: logger = logging.getLogger() logger.error(e) return WXBizMsgCrypt_EncryptAES_Error, None def decrypt(self, text, receiveid): """对解密后的明文进行补位删除 @param text: 密文 @param receiveid: receiveid @return: 删除填充补位后的明文 """ try: cryptor = AES.new(self.key, self.mode, self.key[:16]) # 使用BASE64对密文进行解码,然后AES-CBC解密 plain_text = cryptor.decrypt(base64.b64decode(text)) except Exception as e: logger = logging.getLogger() logger.error(e) return WXBizMsgCrypt_DecryptAES_Error, None try: pad = plain_text[-1] # 去掉补位字符串 # pkcs7 = PKCS7Encoder() # plain_text = pkcs7.encode(plain_text) # 去除16位随机字符串 content = plain_text[16:-pad] xml_len = socket.ntohl(struct.unpack("I", content[: 4])[0]) xml_content = content[4: xml_len + 4] from_receiveid = content[xml_len + 4:] except Exception as e: logger = logging.getLogger() logger.error(e) return WXBizMsgCrypt_IllegalBuffer, None if from_receiveid.decode('utf8') != receiveid: return WXBizMsgCrypt_ValidateCorpid_Error, None return 0, xml_content @staticmethod def get_random_str(): """ 随机生成16位字符串 @return: 16位字符串 """ return str(random.randint(1000000000000000, 9999999999999999)).encode() class WXBizMsgCrypt(object): # 构造函数 def __init__(self, sToken, sEncodingAESKey, sReceiveId): try: self.key = base64.b64decode(sEncodingAESKey + "=") assert len(self.key) == 32 except Exception as err: print(str(err)) throw_exception("[error]: EncodingAESKey unvalid !", FormatException) # return WXBizMsgCrypt_IllegalAesKey,None self.m_sToken = sToken self.m_sReceiveId = sReceiveId # 验证URL # @param sMsgSignature: 签名串,对应URL参数的msg_signature # @param sTimeStamp: 时间戳,对应URL参数的timestamp # @param sNonce: 随机串,对应URL参数的nonce # @param sEchoStr: 随机串,对应URL参数的echostr # @param sReplyEchoStr: 解密之后的echostr,当return返回0时有效 # @return:成功0,失败返回对应的错误码 def VerifyURL(self, sMsgSignature, sTimeStamp, sNonce, sEchoStr): sha1 = SHA1() ret, signature = sha1.getSHA1(self.m_sToken, sTimeStamp, sNonce, sEchoStr) if ret != 0: return ret, None if not signature == sMsgSignature: return WXBizMsgCrypt_ValidateSignature_Error, None pc = Prpcrypt(self.key) ret, sReplyEchoStr = pc.decrypt(sEchoStr, self.m_sReceiveId) return ret, sReplyEchoStr def EncryptMsg(self, sReplyMsg, sNonce, timestamp=None): # 将企业回复用户的消息加密打包 # @param sReplyMsg: 企业号待回复用户的消息,xml格式的字符串 # @param sTimeStamp: 时间戳,可以自己生成,也可以用URL参数的timestamp,如为None则自动用当前时间 # @param sNonce: 随机串,可以自己生成,也可以用URL参数的nonce # sEncryptMsg: 加密后的可以直接回复用户的密文,包括msg_signature, timestamp, nonce, encrypt的xml格式的字符串, # return:成功0,sEncryptMsg,失败返回对应的错误码None pc = Prpcrypt(self.key) ret, encrypt = pc.encrypt(sReplyMsg, self.m_sReceiveId) encrypt = encrypt.decode('utf8') if ret != 0: return ret, None if timestamp is None: timestamp = str(int(time.time())) # 生成安全签名 sha1 = SHA1() ret, signature = sha1.getSHA1(self.m_sToken, timestamp, sNonce, encrypt) if ret != 0: return ret, None xmlParse = XMLParse() return ret, xmlParse.generate(encrypt, signature, timestamp, sNonce) def DecryptMsg(self, sPostData, sMsgSignature, sTimeStamp, sNonce): # 检验消息的真实性,并且获取解密后的明文 # @param sMsgSignature: 签名串,对应URL参数的msg_signature # @param sTimeStamp: 时间戳,对应URL参数的timestamp # @param sNonce: 随机串,对应URL参数的nonce # @param sPostData: 密文,对应POST请求的数据 # xml_content: 解密后的原文,当return返回0时有效 # @return: 成功0,失败返回对应的错误码 # 验证安全签名 xmlParse = XMLParse() ret, encrypt = xmlParse.extract(sPostData) if ret != 0: return ret, None sha1 = SHA1() ret, signature = sha1.getSHA1(self.m_sToken, sTimeStamp, sNonce, encrypt) if ret != 0: return ret, None if not signature == sMsgSignature: return WXBizMsgCrypt_ValidateSignature_Error, None pc = Prpcrypt(self.key) ret, xml_content = pc.decrypt(encrypt, self.m_sReceiveId) return ret, xml_content ================================================ FILE: app/modules/wechat/__init__.py ================================================ import copy import xml.dom.minidom from typing import Optional, Union, List, Tuple, Any, Dict from app.core.context import Context, MediaInfo from app.core.event import eventmanager from app.log import logger from app.modules import _ModuleBase, _MessageBase from app.modules.wechat.WXBizMsgCrypt3 import WXBizMsgCrypt from app.modules.wechat.wechat import WeChat from app.modules.wechat.wechatbot import WeChatBot from app.schemas import MessageChannel, CommingMessage, Notification, CommandRegisterEventData from app.schemas.types import ModuleType, ChainEventType from app.utils.dom import DomUtils from app.utils.structures import DictUtils class WechatModule(_ModuleBase, _MessageBase[WeChat]): def init_module(self) -> None: """ 初始化模块 """ self.stop() super().init_service(service_name=WeChat.__name__.lower(), service_type=self._create_client) self._channel = MessageChannel.Wechat @staticmethod def get_name() -> str: return "微信" @staticmethod def get_type() -> ModuleType: """ 获取模块类型 """ return ModuleType.Notification @staticmethod def get_subtype() -> MessageChannel: """ 获取模块的子类型 """ return MessageChannel.Wechat @staticmethod def get_priority() -> int: """ 获取模块优先级,数字越小优先级越高,只有同一接口下优先级才生效 """ return 1 def stop(self): for client in self.get_instances().values(): if hasattr(client, "stop"): try: client.stop() except Exception as err: logger.error(f"停止微信模块实例失败:{err}") @staticmethod def _is_bot_mode(config: dict) -> bool: return (config or {}).get("WECHAT_MODE", "app") == "bot" @classmethod def _create_client(cls, conf): if cls._is_bot_mode(conf.config): return WeChatBot(name=conf.name, **conf.config) return WeChat(name=conf.name, **conf.config) def test(self) -> Optional[Tuple[bool, str]]: """ 测试模块连接性 """ if not self.get_instances(): return None for name, client in self.get_instances().items(): state = client.get_state() if not state: return False, f"企业微信 {name} 未就绪" return True, "" def init_setting(self) -> Tuple[str, Union[str, bool]]: pass def message_parser(self, source: str, body: Any, form: Any, args: Any) -> Optional[CommingMessage]: """ 解析消息内容,返回字典,注意以下约定值: userid: 用户ID username: 用户名 text: 内容 :param source: 消息来源 :param body: 请求体 :param form: 表单 :param args: 参数 :return: 渠道、消息体 """ try: # 获取服务配置 client_config = self.get_config(source) if not client_config: return None if self._is_bot_mode(client_config.config): return None client: WeChat = self.get_instance(client_config.name) # URL参数 sVerifyMsgSig = args.get("msg_signature") sVerifyTimeStamp = args.get("timestamp") sVerifyNonce = args.get("nonce") if not sVerifyMsgSig or not sVerifyTimeStamp or not sVerifyNonce: logger.debug(f"微信请求参数错误:{args}") return None # 解密模块 wxcpt = WXBizMsgCrypt(sToken=client_config.config.get('WECHAT_TOKEN'), sEncodingAESKey=client_config.config.get('WECHAT_ENCODING_AESKEY'), sReceiveId=client_config.config.get('WECHAT_CORPID')) # 报文数据 if not body: logger.debug(f"微信请求数据为空") return None logger.debug(f"收到微信请求:{body}") ret, sMsg = wxcpt.DecryptMsg(sPostData=body, sMsgSignature=sVerifyMsgSig, sTimeStamp=sVerifyTimeStamp, sNonce=sVerifyNonce) if ret != 0: logger.error(f"解密微信消息失败 DecryptMsg ret = {ret}") return None # 解析XML报文 """ 1、消息格式: 1348831860 1234567890123456 1 2、事件格式: 1348831860 1 """ dom_tree = xml.dom.minidom.parseString(sMsg.decode('UTF-8')) root_node = dom_tree.documentElement # 消息类型 msg_type = DomUtils.tag_value(root_node, "MsgType") # Event event事件只有click才有效,enter_agent无效 event = DomUtils.tag_value(root_node, "Event") # 用户ID user_id = DomUtils.tag_value(root_node, "FromUserName") # 没的消息类型和用户ID的消息不要 if not msg_type or not user_id: logger.warn(f"解析不到消息类型和用户ID") return None # 解析消息内容 if msg_type == "event" and event == "click": # 校验用户有权限执行交互命令 if client_config.config.get('WECHAT_ADMINS'): wechat_admins = client_config.config.get('WECHAT_ADMINS').split(',') if wechat_admins and not any( user_id == admin_user for admin_user in wechat_admins): client.send_msg(title="用户无权限执行菜单命令", userid=user_id) return None # 根据EventKey执行命令 content = DomUtils.tag_value(root_node, "EventKey") logger.info(f"收到来自 {client_config.name} 的微信事件:userid={user_id}, event={content}") elif msg_type == "text": # 文本消息 content = DomUtils.tag_value(root_node, "Content", default="") logger.info(f"收到来自 {client_config.name} 的微信消息:userid={user_id}, text={content}") else: return None if content: # 处理消息内容 return CommingMessage(channel=MessageChannel.Wechat, source=client_config.name, userid=user_id, username=user_id, text=content) except Exception as err: logger.error(f"微信消息处理发生错误:{str(err)}") return None def post_message(self, message: Notification, **kwargs) -> None: """ 发送消息 :param message: 消息内容 :return: 成功或失败 """ for conf in self.get_configs().values(): if not self.check_message(message, conf.name): continue targets = message.targets userid = message.userid if not userid and targets is not None: userid = targets.get('wechat_userid') if not userid: logger.warn(f"用户没有指定 微信用户ID,消息无法发送") return client: WeChat = self.get_instance(conf.name) if client: client.send_msg(title=message.title, text=message.text, image=message.image, userid=userid, link=message.link) def post_medias_message(self, message: Notification, medias: List[MediaInfo]) -> None: """ 发送媒体信息选择列表 :param message: 消息内容 :param medias: 媒体列表 :return: 成功或失败 """ for conf in self.get_configs().values(): if not self.check_message(message, conf.name): continue client: WeChat = self.get_instance(conf.name) if client: # 先发送标题 client.send_msg(title=message.title, userid=message.userid, link=message.link) # 再发送内容 client.send_medias_msg(medias=medias, userid=message.userid) def post_torrents_message(self, message: Notification, torrents: List[Context]) -> None: """ 发送种子信息选择列表 :param message: 消息内容 :param torrents: 种子列表 :return: 成功或失败 """ for conf in self.get_configs().values(): if not self.check_message(message, conf.name): continue client: WeChat = self.get_instance(conf.name) if client: client.send_torrents_msg(title=message.title, torrents=torrents, userid=message.userid, link=message.link) def register_commands(self, commands: Dict[str, dict]): """ 注册命令,实现这个函数接收系统可用的命令菜单 :param commands: 命令字典 """ for client_config in self.get_configs().values(): if self._is_bot_mode(client_config.config): logger.debug(f"{client_config.name} 为智能机器人模式,跳过传统菜单初始化") continue # 如果没有配置消息解密相关参数,则也没有必要进行菜单初始化 if not client_config.config.get("WECHAT_ENCODING_AESKEY") or not client_config.config.get("WECHAT_TOKEN"): logger.debug(f"{client_config.name} 缺少消息解密参数,跳过后续菜单初始化") continue client = self.get_instance(client_config.name) if not client: continue # 触发事件,允许调整命令数据,这里需要进行深复制,避免实例共享 scoped_commands = copy.deepcopy(commands) event = eventmanager.send_event( ChainEventType.CommandRegister, CommandRegisterEventData(commands=scoped_commands, origin="WeChat", service=client_config.name) ) # 如果事件返回有效的 event_data,使用事件中调整后的命令 if event and event.event_data: event_data: CommandRegisterEventData = event.event_data # 如果事件被取消,跳过命令注册,并清理菜单 if event_data.cancel: client.delete_menus() logger.debug( f"Command registration for {client_config.name} canceled by event: {event_data.source}" ) continue scoped_commands = event_data.commands or {} if not scoped_commands: logger.debug("Filtered commands are empty, skipping registration.") client.delete_menus() # scoped_commands 必须是 commands 的子集 filtered_scoped_commands = DictUtils.filter_keys_to_subset(scoped_commands, commands) # 如果 filtered_scoped_commands 为空,则跳过注册 if not filtered_scoped_commands: logger.debug("Filtered commands are empty, skipping registration.") client.delete_menus() continue # 对比调整后的命令与当前命令 if filtered_scoped_commands != commands: logger.debug(f"Command set has changed, Updating new commands: {filtered_scoped_commands}") client.create_menus(filtered_scoped_commands) ================================================ FILE: app/modules/wechat/wechat.py ================================================ import json import re import threading from datetime import datetime from typing import Optional, List, Dict from app.core.context import MediaInfo, Context from app.core.metainfo import MetaInfo from app.log import logger from app.utils.common import retry from app.utils.http import RequestUtils from app.utils.string import StringUtils from app.utils.url import UrlUtils lock = threading.Lock() class RetryException(Exception): pass class WeChat: # 企业微信Token _access_token = None # 企业微信Token过期时间 _expires_in: int = None # 企业微信Token获取时间 _access_token_time: datetime = None # 企业微信CorpID _corpid = None # 企业微信AppSecret _appsecret = None # 企业微信AppID _appid = None # 代理 _proxy = None # 企业微信发送消息URL _send_msg_url = "cgi-bin/message/send?access_token={access_token}" # 企业微信获取TokenURL _token_url = "cgi-bin/gettoken?corpid={corpid}&corpsecret={corpsecret}" # 企业微信创建菜单URL _create_menu_url = "cgi-bin/menu/create?access_token={access_token}&agentid={agentid}" # 企业微信删除菜单URL _delete_menu_url = "cgi-bin/menu/delete?access_token={access_token}&agentid={agentid}" def __init__(self, WECHAT_CORPID: Optional[str] = None, WECHAT_APP_SECRET: Optional[str] = None, WECHAT_APP_ID: Optional[str] = None, WECHAT_PROXY: Optional[str] = None, **kwargs): """ 初始化 """ if not WECHAT_CORPID or not WECHAT_APP_SECRET or not WECHAT_APP_ID: logger.error("企业微信配置不完整!") return self._corpid = WECHAT_CORPID self._appsecret = WECHAT_APP_SECRET self._appid = WECHAT_APP_ID self._proxy = WECHAT_PROXY or "https://qyapi.weixin.qq.com" if self._proxy: self._send_msg_url = UrlUtils.adapt_request_url(self._proxy, self._send_msg_url) self._token_url = UrlUtils.adapt_request_url(self._proxy, self._token_url) self._create_menu_url = UrlUtils.adapt_request_url(self._proxy, self._create_menu_url) self._delete_menu_url = UrlUtils.adapt_request_url(self._proxy, self._delete_menu_url) if self._corpid and self._appsecret and self._appid: self.__get_access_token() def get_state(self): """ 获取状态 """ return True if self.__get_access_token() else False @retry(RetryException, logger=logger) def __get_access_token(self, force=False): """ 获取微信Token :return: 微信Token """ token_flag = True if not self._access_token: token_flag = False else: if (datetime.now() - self._access_token_time).seconds >= self._expires_in: token_flag = False if not token_flag or force: if not self._corpid or not self._appsecret: return None token_url = self._token_url.format(corpid=self._corpid, corpsecret=self._appsecret) res = RequestUtils().get_res(token_url) if res: ret_json = res.json() if ret_json.get("errcode") == 0: self._access_token = ret_json.get("access_token") self._expires_in = ret_json.get("expires_in") self._access_token_time = datetime.now() elif res is not None: logger.error(f"获取微信access_token失败,错误码:{res.status_code},错误原因:{res.reason}") else: logger.error(f"获取微信access_token失败,未获取到返回信息") raise RetryException("获取微信access_token失败,重试中...") return self._access_token @staticmethod def __split_content(content: str, max_bytes: int = 2048) -> List[str]: """ 将内容分块为不超过 max_bytes 字节的块 :param content: 待拆分的内容 :param max_bytes: 最大字节数 :return: 分块后的内容列表 """ content_chunks = [] current_chunk = bytearray() for line in content.splitlines(): encoded_line = (line + "\n").encode("utf-8") line_length = len(encoded_line) if line_length > max_bytes: # 在处理长行之前,先将 current_chunk 添加到 content_chunks if current_chunk: content_chunks.append(current_chunk.decode("utf-8", errors="replace").strip()) current_chunk = bytearray() # 处理长行,拆分为多个不超过 max_bytes 的块 start = 0 while start < line_length: end = start + max_bytes # 不再需要为 "..." 预留空间 if end >= line_length: end = line_length else: # 调整以避免拆分多字节字符 while end > start and (encoded_line[end] & 0xC0) == 0x80: end -= 1 if end == start: # 单个字符超过了 max_bytes,强制包含整个字符 end = start + 1 while end < line_length and (encoded_line[end] & 0xC0) == 0x80: end += 1 truncated_line = encoded_line[start:end].decode("utf-8", errors="replace") content_chunks.append(truncated_line.strip()) start = end continue # 继续处理下一行 # 检查添加当前行后是否会超过 max_bytes if len(current_chunk) + line_length > max_bytes: # 将 current_chunk 添加到 content_chunks content_chunks.append(current_chunk.decode("utf-8", errors="replace").strip()) current_chunk = bytearray() # 将当前行添加到 current_chunk current_chunk += encoded_line # 处理剩余的 current_chunk if current_chunk: content_chunks.append(current_chunk.decode("utf-8", errors="replace").strip()) return content_chunks def __send_message(self, title: str, text: Optional[str] = None, userid: Optional[str] = None, link: Optional[str] = None) -> bool: """ 发送文本消息 :param title: 消息标题 :param text: 消息内容 :param userid: 消息发送对象的ID,为空则发给所有人 :param link: 跳转链接 :return: 发送状态,错误信息 """ if not title and not text: logger.error("消息标题和内容不能都为空") return False if text: formatted_text = text.replace("\n\n", "\n") content = f"{title}\n{formatted_text}" else: content = title if link: content = f"{content}\n点击查看:{link}" if not userid: userid = "@all" # 分块处理逻辑 content_chunks = self.__split_content(content) # 逐块发送消息 for chunk in content_chunks: req_json = { "touser": userid, "msgtype": "text", "agentid": self._appid, "text": { "content": chunk }, "safe": 0, "enable_id_trans": 0, "enable_duplicate_check": 0 } try: # 如果是超长消息,有一个发送失败就全部失败 if not self.__post_request(self._send_msg_url, req_json): return False except Exception as e: logger.error(f"发送消息块失败:{e}") return False return True def __send_image_message(self, title: str, text: str, image_url: str, userid: Optional[str] = None, link: Optional[str] = None) -> Optional[bool]: """ 发送图文消息 :param title: 消息标题 :param text: 消息内容 :param image_url: 图片地址 :param userid: 消息发送对象的ID,为空则发给所有人 :param link: 跳转链接 :return: 发送状态,错误信息 """ if text: text = text.replace("\n\n", "\n") if not userid: userid = "@all" req_json = { "touser": userid, "msgtype": "news", "agentid": self._appid, "news": { "articles": [ { "title": title, "description": text, "picurl": image_url, "url": link } ] } } try: return self.__post_request(self._send_msg_url, req_json) except Exception as e: logger.error(f"发送图文消息失败:{e}") return False def send_msg(self, title: str, text: Optional[str] = None, image: Optional[str] = None, userid: Optional[str] = None, link: Optional[str] = None) -> Optional[bool]: """ 微信消息发送入口,支持文本、图片、链接跳转、指定发送对象 :param title: 消息标题 :param text: 消息内容 :param image: 图片地址 :param userid: 消息发送对象的ID,为空则发给所有人 :param link: 跳转链接 :return: 发送状态,错误信息 """ try: if not self.__get_access_token(): logger.error("获取微信access_token失败,请检查参数配置") return None if image: ret_code = self.__send_image_message(title=title, text=text, image_url=image, userid=userid, link=link) else: ret_code = self.__send_message(title=title, text=text, userid=userid, link=link) return ret_code except Exception as e: logger.error(f"发送消息失败:{e}") return False def send_medias_msg(self, medias: List[MediaInfo], userid: Optional[str] = None) -> Optional[bool]: """ 发送列表类消息 """ try: if not self.__get_access_token(): logger.error("获取微信access_token失败,请检查参数配置") return None if not userid: userid = "@all" articles = [] index = 1 for media in medias: if media.vote_average: title = f"{index}. {media.title_year}\n类型:{media.type.value},评分:{media.vote_average}" else: title = f"{index}. {media.title_year}\n类型:{media.type.value}" articles.append({ "title": title, "description": "", "picurl": media.get_message_image() if index == 1 else media.get_poster_image(), "url": media.detail_link }) index += 1 req_json = { "touser": userid, "msgtype": "news", "agentid": self._appid, "news": { "articles": articles } } return self.__post_request(self._send_msg_url, req_json) except Exception as e: logger.error(f"发送消息失败:{e}") return False def send_torrents_msg(self, torrents: List[Context], userid: Optional[str] = None, title: Optional[str] = None, link: Optional[str] = None) -> Optional[bool]: """ 发送列表消息 """ try: if not self.__get_access_token(): logger.error("获取微信access_token失败,请检查参数配置") return None # 先发送标题 if title: self.__send_message(title=title, userid=userid, link=link) # 发送列表 if not userid: userid = "@all" articles = [] index = 1 for context in torrents: torrent = context.torrent_info meta = MetaInfo(title=torrent.title, subtitle=torrent.description) mediainfo = context.media_info torrent_title = f"{index}.【{torrent.site_name}】" \ f"{meta.season_episode} " \ f"{meta.resource_term} " \ f"{meta.video_term} " \ f"{meta.release_group} " \ f"{StringUtils.str_filesize(torrent.size)} " \ f"{torrent.volume_factor} " \ f"{torrent.seeders}↑" torrent_title = re.sub(r"\s+", " ", torrent_title).strip() articles.append({ "title": torrent_title, "description": torrent.description if index == 1 else "", "picurl": mediainfo.get_message_image() if index == 1 else "", "url": torrent.page_url }) index += 1 req_json = { "touser": userid, "msgtype": "news", "agentid": self._appid, "news": { "articles": articles } } return self.__post_request(self._send_msg_url, req_json) except Exception as e: logger.error(f"发送消息失败:{e}") return False @retry(RetryException, logger=logger) def __post_request(self, url: str, req_json: dict) -> bool: """ 向微信发送请求 """ url = url.format(access_token=self.__get_access_token()) res = RequestUtils(content_type="application/json").post( url=url, data=json.dumps(req_json, ensure_ascii=False).encode("utf-8") ) if res is None: error_msg = "发送请求失败,未获取到返回信息" raise Exception(error_msg) if res.status_code != 200: error_msg = f"发送请求失败,错误码:{res.status_code},错误原因:{res.reason}" raise Exception(error_msg) ret_json = res.json() if ret_json.get("errcode") == 0: return True else: if ret_json.get("errcode") == 42001: self.__get_access_token(force=True) error_msg = (f"access_token 已过期,尝试重新获取 access_token," f"errcode: {ret_json.get('errcode')}, errmsg: {ret_json.get('errmsg')}") raise RetryException(error_msg) else: logger.error(f"发送请求失败,错误信息:{ret_json.get('errmsg')}") return False def create_menus(self, commands: Dict[str, dict]): """ 自动注册微信菜单 :param commands: 命令字典 命令字典: { "/cookiecloud": { "func": CookieCloudChain(self._db).remote_sync, "description": "同步站点", "category": "站点", "data": {} } } 注册报文格式,一级菜单只有最多3条,子菜单最多只有5条: { "button":[ { "type":"click", "name":"今日歌曲", "key":"V1001_TODAY_MUSIC" }, { "name":"菜单", "sub_button":[ { "type":"view", "name":"搜索", "url":"https://www.soso.com/" }, { "type":"click", "name":"赞一下我们", "key":"V1001_GOOD" } ] } ] } """ try: # 请求URL req_url = self._create_menu_url.format(access_token="{access_token}", agentid=self._appid) # 对commands按category分组 category_dict = {} for key, value in commands.items(): category: str = value.get("category") if category: if not category_dict.get(category): category_dict[category] = {} category_dict[category][key] = value # 一级菜单 buttons = [] for category, menu in category_dict.items(): # 二级菜单 sub_buttons = [] for key, value in menu.items(): sub_buttons.append({ "type": "click", "name": value.get("description"), "key": key }) buttons.append({ "name": category, "sub_button": sub_buttons[:5] }) if buttons: # 发送请求 self.__post_request(req_url, { "button": buttons[:3] }) except Exception as e: logger.error(f"创建菜单失败:{e}") def delete_menus(self): """ 删除微信菜单 """ try: # 请求URL req_url = self._delete_menu_url.format(access_token=self.__get_access_token(), agentid=self._appid) # 发送请求 RequestUtils().get(req_url) except Exception as e: logger.error(f"删除菜单失败:{e}") ================================================ FILE: app/modules/wechat/wechatbot.py ================================================ import hashlib import json import pickle import re import threading import time import uuid from typing import Optional, List, Dict, Tuple, Set import websocket from app.chain.message import MessageChain from app.core.cache import FileCache from app.core.context import MediaInfo, Context from app.core.metainfo import MetaInfo from app.log import logger from app.schemas.types import MessageChannel from app.utils.string import StringUtils class WeChatBot: """ 企业微信智能机器人(长连接模式) 固定使用: - dmPolicy = open - groupPolicy = disabled """ _default_ws_url = "wss://openws.work.weixin.qq.com" _heartbeat_interval = 30 _ack_timeout = 10 def __init__(self, WECHAT_BOT_ID: Optional[str] = None, WECHAT_BOT_SECRET: Optional[str] = None, WECHAT_BOT_CHAT_ID: Optional[str] = None, WECHAT_BOT_WS_URL: Optional[str] = None, WECHAT_ADMINS: Optional[str] = None, name: Optional[str] = None, **kwargs): self._config_name = name or "wechat" self._bot_id = WECHAT_BOT_ID self._bot_secret = WECHAT_BOT_SECRET self._default_chat_id = WECHAT_BOT_CHAT_ID.strip() if WECHAT_BOT_CHAT_ID else None self._ws_url = WECHAT_BOT_WS_URL or self._default_ws_url self._admins = [item.strip() for item in (WECHAT_ADMINS or "").split(",") if item.strip()] safe_name = hashlib.md5(self._config_name.encode()).hexdigest()[:12] self._cache_key = f"__wechatbot_known_targets_{safe_name}__" self._filecache = FileCache() self._known_targets: Set[str] = set() self._ready = False self._ws_app: Optional[websocket.WebSocketApp] = None self._ws_thread: Optional[threading.Thread] = None self._heartbeat_thread: Optional[threading.Thread] = None self._stop_event = threading.Event() self._authenticated = threading.Event() self._send_lock = threading.Lock() self._acks_lock = threading.Lock() self._pending_acks: Dict[str, dict] = {} if not self._bot_id or not self._bot_secret: logger.error("企业微信智能机器人配置不完整!") return self._load_known_targets() self._ready = True self._start_gateway() @staticmethod def _build_req_id(prefix: str) -> str: return f"{prefix}_{uuid.uuid4().hex}" @staticmethod def _split_content(content: str, max_bytes: int = 4000) -> List[str]: """ 将 markdown 内容拆分为较小分块,避免消息过长发送失败 """ if not content: return [] chunks = [] current = bytearray() for line in content.splitlines(): encoded = (line + "\n").encode("utf-8") if len(encoded) > max_bytes: if current: chunks.append(current.decode("utf-8", errors="replace").strip()) current = bytearray() start = 0 while start < len(encoded): end = min(start + max_bytes, len(encoded)) while end > start and end < len(encoded) and (encoded[end] & 0xC0) == 0x80: end -= 1 chunks.append(encoded[start:end].decode("utf-8", errors="replace").strip()) start = end continue if len(current) + len(encoded) > max_bytes: chunks.append(current.decode("utf-8", errors="replace").strip()) current = bytearray() current += encoded if current: chunks.append(current.decode("utf-8", errors="replace").strip()) return [chunk for chunk in chunks if chunk] def _start_gateway(self) -> None: if self._ws_thread and self._ws_thread.is_alive(): return self._stop_event.clear() self._ws_thread = threading.Thread(target=self._run_gateway, daemon=True) self._ws_thread.start() self._heartbeat_thread = threading.Thread(target=self._heartbeat_loop, daemon=True) self._heartbeat_thread.start() logger.info(f"企业微信智能机器人长连接已启动:{self._config_name}") def stop(self) -> None: self._stop_event.set() self._authenticated.clear() if self._ws_app: try: self._ws_app.close() except Exception as err: logger.debug(f"关闭企业微信智能机器人连接失败:{err}") if self._ws_thread and self._ws_thread.is_alive(): self._ws_thread.join(timeout=5) if self._heartbeat_thread and self._heartbeat_thread.is_alive(): self._heartbeat_thread.join(timeout=2) def get_state(self) -> bool: return self._ready and self._authenticated.is_set() def _load_known_targets(self) -> None: try: content = self._filecache.get(self._cache_key) if not content: return data = pickle.loads(content) if isinstance(data, (list, set, tuple)): self._known_targets = {str(item).strip() for item in data if str(item).strip()} except Exception as err: logger.debug(f"加载企业微信智能机器人已互动用户失败:{err}") def _save_known_targets(self) -> None: try: self._filecache.set(self._cache_key, pickle.dumps(sorted(self._known_targets))) except Exception as err: logger.debug(f"保存企业微信智能机器人已互动用户失败:{err}") def _remember_target(self, userid: Optional[str]) -> None: target = str(userid).strip() if userid else None if not target: return if target not in self._known_targets: self._known_targets.add(target) self._save_known_targets() def _run_gateway(self) -> None: reconnect_delays = [1, 2, 5, 10, 30, 60] attempt = 0 while not self._stop_event.is_set(): self._authenticated.clear() try: self._ws_app = websocket.WebSocketApp( self._ws_url, on_open=self._on_open, on_message=self._on_message, on_error=self._on_error, on_close=self._on_close, ) self._ws_app.run_forever( ping_interval=None, ping_timeout=None, skip_utf8_validation=True, ) except Exception as err: logger.error(f"企业微信智能机器人连接异常:{err}") if self._stop_event.is_set(): break delay = reconnect_delays[min(attempt, len(reconnect_delays) - 1)] attempt += 1 logger.info(f"企业微信智能机器人将在 {delay}s 后重连:{self._config_name}") for _ in range(delay * 10): if self._stop_event.is_set(): break time.sleep(0.1) def _heartbeat_loop(self) -> None: while not self._stop_event.is_set(): if self._authenticated.is_set(): try: self._send_raw({ "cmd": "ping", "headers": {"req_id": self._build_req_id("ping")}, }) except Exception as err: logger.debug(f"发送企业微信智能机器人心跳失败:{err}") for _ in range(self._heartbeat_interval * 10): if self._stop_event.is_set(): return time.sleep(0.1) def _on_open(self, ws) -> None: logger.info(f"企业微信智能机器人连接成功,开始订阅:{self._config_name}") self._send_raw({ "cmd": "aibot_subscribe", "headers": {"req_id": self._build_req_id("aibot_subscribe")}, "body": { "bot_id": self._bot_id, "secret": self._bot_secret, }, }) def _on_message(self, ws, message: str) -> None: try: payload = json.loads(message) except Exception as err: logger.error(f"解析企业微信智能机器人消息失败:{err}") return req_id = (payload.get("headers") or {}).get("req_id") if req_id: self._resolve_ack(req_id, payload) cmd = payload.get("cmd") if not cmd: if str(req_id).startswith("aibot_subscribe"): if payload.get("errcode") == 0: self._authenticated.set() logger.info(f"企业微信智能机器人订阅成功:{self._config_name}") else: logger.error( f"企业微信智能机器人订阅失败:{payload.get('errmsg')} ({payload.get('errcode')})" ) self._authenticated.clear() return if cmd == "aibot_msg_callback": self._handle_callback_message(payload) elif cmd == "aibot_event_callback": self._handle_callback_event(payload) def _on_error(self, ws, error) -> None: self._authenticated.clear() logger.error(f"企业微信智能机器人 WebSocket 错误:{error}") def _on_close(self, ws, close_status_code, close_msg) -> None: self._authenticated.clear() logger.info(f"企业微信智能机器人连接关闭:{close_status_code} {close_msg}") def _resolve_ack(self, req_id: str, payload: dict) -> None: with self._acks_lock: pending = self._pending_acks.get(req_id) if not pending: return pending["payload"] = payload pending["event"].set() def _send_raw(self, payload: dict) -> None: if not self._ws_app or not self._ws_app.sock or not self._ws_app.sock.connected: raise RuntimeError("企业微信智能机器人未连接") self._ws_app.send(json.dumps(payload, ensure_ascii=False)) def _send_with_ack(self, payload: dict) -> bool: req_id = (payload.get("headers") or {}).get("req_id") if not req_id: return False if not self._authenticated.wait(timeout=self._ack_timeout): logger.error("企业微信智能机器人未完成认证,无法发送消息") return False pending = {"event": threading.Event(), "payload": None} with self._acks_lock: self._pending_acks[req_id] = pending try: with self._send_lock: self._send_raw(payload) if not pending["event"].wait(timeout=self._ack_timeout): logger.error(f"企业微信智能机器人消息发送超时:req_id={req_id}") return False ack = pending["payload"] or {} if ack.get("errcode") != 0: logger.error( f"企业微信智能机器人消息发送失败:{ack.get('errmsg')} ({ack.get('errcode')})" ) return False return True finally: with self._acks_lock: self._pending_acks.pop(req_id, None) def _handle_callback_event(self, payload: dict) -> None: event = ((payload.get("body") or {}).get("event") or {}).get("eventtype") if event == "disconnected_event": logger.info(f"企业微信智能机器人旧连接被踢下线:{self._config_name}") @staticmethod def _extract_text_from_body(body: dict) -> Optional[str]: msgtype = body.get("msgtype") text_parts = [] if msgtype == "text": text = ((body.get("text") or {}).get("content") or "").strip() if text: text_parts.append(text) elif msgtype == "voice": text = ((body.get("voice") or {}).get("content") or "").strip() if text: text_parts.append(text) elif msgtype == "mixed": for item in (body.get("mixed") or {}).get("msg_item") or []: if item.get("msgtype") == "text": content = ((item.get("text") or {}).get("content") or "").strip() if content: text_parts.append(content) quote = body.get("quote") or {} if not text_parts and quote.get("msgtype") == "text": quote_text = ((quote.get("text") or {}).get("content") or "").strip() if quote_text: text_parts.append(quote_text) text = "\n".join(part for part in text_parts if part).strip() return text or None def _handle_callback_message(self, payload: dict) -> None: body = payload.get("body") or {} sender = ((body.get("from") or {}).get("userid") or "").strip() if not sender: return if body.get("chattype") == "group": logger.debug(f"企业微信智能机器人忽略群聊消息(groupPolicy=disabled):{self._config_name}") return text = self._extract_text_from_body(body) if not text: return text = re.sub(r"@\S+", "", text).strip() if not text: return self._remember_target(sender) if text.startswith("/") and self._admins and sender not in self._admins: self.send_msg(title="只有管理员才有权限执行此命令", userid=sender) return logger.info(f"收到来自 {self._config_name} 的企业微信智能机器人消息:userid={sender}, text={text}") self._forward_to_message_chain(userid=sender, text=text) def _forward_to_message_chain(self, userid: str, text: str) -> None: def _run(): try: MessageChain().handle_message( channel=MessageChannel.Wechat, source=self._config_name, userid=userid, username=userid, text=text, ) except Exception as err: logger.error(f"企业微信智能机器人转发消息失败:{err}") threading.Thread(target=_run, daemon=True).start() @staticmethod def _normalize_target(userid: Optional[str], default_chat_id: Optional[str]) -> Tuple[Optional[str], int]: target = str(userid).strip() if userid else (default_chat_id.strip() if default_chat_id else None) if not target: return None, 1 lowered = target.lower() if lowered.startswith("group:"): return target[6:].strip(), 2 if lowered.startswith("user:"): return target[5:].strip(), 1 return target, 1 @staticmethod def _build_markdown(title: Optional[str] = None, text: Optional[str] = None, image: Optional[str] = None, link: Optional[str] = None) -> str: parts = [] if title: parts.append(f"**{title}**") if text: parts.append(text.replace("\n\n", "\n")) if image: parts.append(f"![]({image})") if link: parts.append(f"[点击查看]({link})") return "\n\n".join(part for part in parts if part).strip() def _resolve_targets(self, userid: Optional[str] = None) -> List[Tuple[str, int]]: target, chat_type = self._normalize_target(userid=userid, default_chat_id=self._default_chat_id) if target: return [(target, chat_type)] return [(known_userid, 1) for known_userid in sorted(self._known_targets)] def _send_markdown(self, content: str, userid: Optional[str] = None) -> Optional[bool]: if not content: return False targets = self._resolve_targets(userid=userid) if not targets: logger.warning(f"{self._config_name} 未配置默认发送目标,且暂无已互动用户") return False send_success = False for target, chat_type in targets: target_success = True for chunk in self._split_content(content): req_id = self._build_req_id("aibot_send_msg") payload = { "cmd": "aibot_send_msg", "headers": {"req_id": req_id}, "body": { "chatid": target, "chat_type": chat_type, "msgtype": "markdown", "markdown": { "content": chunk } } } if not self._send_with_ack(payload): target_success = False logger.warning(f"{self._config_name} 向目标 {target} 发送通知失败") break send_success = send_success or target_success return send_success def send_msg(self, title: str, text: Optional[str] = None, image: Optional[str] = None, userid: Optional[str] = None, link: Optional[str] = None) -> Optional[bool]: content = self._build_markdown(title=title, text=text, image=image, link=link) return self._send_markdown(content=content, userid=userid) def send_medias_msg(self, medias: List[MediaInfo], userid: Optional[str] = None) -> Optional[bool]: if not medias: return False lines = ["**媒体列表**"] for index, media in enumerate(medias, start=1): line = f"{index}. {media.title_year}" if media.vote_average: line += f" 评分:{media.vote_average}" if media.detail_link: line += f"\n{media.detail_link}" lines.append(line) return self._send_markdown(content="\n\n".join(lines), userid=userid) def send_torrents_msg(self, torrents: List[Context], userid: Optional[str] = None, title: Optional[str] = None, link: Optional[str] = None) -> Optional[bool]: if not torrents: return False lines = [f"**{title or '种子列表'}**"] if link: lines.append(link) for index, context in enumerate(torrents, start=1): torrent = context.torrent_info meta = MetaInfo(title=torrent.title, subtitle=torrent.description) torrent_title = ( f"{index}.【{torrent.site_name}】" f"{meta.season_episode} " f"{meta.resource_term} " f"{meta.video_term} " f"{meta.release_group} " f"{StringUtils.str_filesize(torrent.size)} " f"{torrent.volume_factor} " f"{torrent.seeders}↑" ) torrent_title = re.sub(r"\s+", " ", torrent_title).strip() if torrent.page_url: torrent_title += f"\n{torrent.page_url}" lines.append(torrent_title) return self._send_markdown(content="\n\n".join(lines), userid=userid) def create_menus(self, commands: Dict[str, dict]): """ 智能机器人模式不支持传统自建应用菜单 """ return def delete_menus(self): """ 智能机器人模式不支持传统自建应用菜单 """ return ================================================ FILE: app/monitor.py ================================================ import json import platform import re import threading import time import traceback from pathlib import Path from threading import Lock from typing import Any, Optional, Dict, List from apscheduler.schedulers.background import BackgroundScheduler from watchdog.events import FileSystemEventHandler, FileSystemMovedEvent, FileSystemEvent from watchdog.observers.polling import PollingObserver from app.chain import ChainBase from app.chain.storage import StorageChain from app.chain.transfer import TransferChain from app.core.cache import TTLCache, FileCache from app.core.config import settings from app.helper.directory import DirectoryHelper from app.helper.message import MessageHelper from app.log import logger from app.schemas import FileItem from app.schemas.types import SystemConfigKey from app.utils.mixins import ConfigReloadMixin from app.utils.singleton import SingletonClass from app.utils.system import SystemUtils lock = Lock() snapshot_lock = Lock() class MonitorChain(ChainBase): pass class FileMonitorHandler(FileSystemEventHandler): """ 目录监控响应类 """ def __init__(self, mon_path: Path, callback: Any, **kwargs): super(FileMonitorHandler, self).__init__(**kwargs) self._watch_path = mon_path self.callback = callback def on_created(self, event: FileSystemEvent): try: self.callback.event_handler(event=event, text="创建", event_path=event.src_path, file_size=Path(event.src_path).stat().st_size) except Exception as e: logger.error(f"on_created 异常: {e}") def on_moved(self, event: FileSystemMovedEvent): try: self.callback.event_handler(event=event, text="移动", event_path=event.dest_path, file_size=Path(event.dest_path).stat().st_size) except Exception as e: logger.error(f"on_moved 异常: {e}") class Monitor(ConfigReloadMixin, metaclass=SingletonClass): """ 目录监控处理链,单例模式 """ CONFIG_WATCH = {SystemConfigKey.Directories.value} def __init__(self): super().__init__() # 退出事件 self._event = threading.Event() # 监控服务 self._observers = [] # 定时服务 self._scheduler = None # 存储过照间隔(分钟) self._snapshot_interval = 5 # TTL缓存,10秒钟有效 self._cache = TTLCache(region="monitor", maxsize=1024, ttl=10) # 快照文件缓存 self._snapshot_cache = FileCache(base=settings.CACHE_PATH / "snapshots") # 监控的文件扩展名 self.all_exts = settings.RMT_MEDIAEXT + settings.RMT_SUBEXT + settings.RMT_AUDIOEXT # 启动目录监控和文件整理 self.init() def on_config_changed(self): self.init() def get_reload_name(self): return "目录监控" def save_snapshot(self, storage: str, snapshot: Dict, file_count: int = 0, last_snapshot_time: Optional[float] = None): """ 保存快照到文件缓存 :param storage: 存储名称 :param snapshot: 快照数据 :param last_snapshot_time: 上次快照时间戳 :param file_count: 文件数量,用于调整监控间隔 """ try: snapshot_time = max((item.get('modify_time', 0) for item in snapshot.values()), default=None) if snapshot_time is None: snapshot_time = last_snapshot_time or time.time() snapshot_data = { 'timestamp': snapshot_time, 'file_count': file_count, 'snapshot': snapshot } # 使用FileCache保存快照数据 cache_key = f"{storage}_snapshot" snapshot_json = json.dumps(snapshot_data, ensure_ascii=False, indent=2) self._snapshot_cache.set(cache_key, snapshot_json.encode('utf-8'), region="snapshots") logger.debug(f"快照已保存到缓存: {storage}") except Exception as e: logger.error(f"保存快照失败: {e}") def reset_snapshot(self, storage: str) -> bool: """ 重置快照,强制下次扫描时重新建立基准 :param storage: 存储名称 :return: 是否成功 """ try: cache_key = f"{storage}_snapshot" if self._snapshot_cache.exists(cache_key, region="snapshots"): self._snapshot_cache.delete(cache_key, region="snapshots") logger.info(f"快照已重置: {storage}") return True logger.debug(f"快照文件不存在,无需重置: {storage}") return True except Exception as e: logger.error(f"重置快照失败: {storage} - {e}") return False def force_full_scan(self, storage: str, mon_path: Path) -> bool: """ 强制全量扫描并处理所有文件(包括已存在的文件) :param storage: 存储名称 :param mon_path: 监控路径 :return: 是否成功 """ try: logger.info(f"开始强制全量扫描: {storage}:{mon_path}") # 生成快照 new_snapshot = StorageChain().snapshot_storage( storage=storage, path=mon_path, last_snapshot_time=0 # 全量扫描,不使用增量 ) if new_snapshot is None: logger.warn(f"获取 {storage}:{mon_path} 快照失败") return False file_count = len(new_snapshot) logger.info(f"{storage}:{mon_path} 全量扫描完成,发现 {file_count} 个文件") # 处理所有文件 processed_count = 0 for file_path, file_info in new_snapshot.items(): try: logger.info(f"处理文件:{file_path}") file_size = file_info.get('size', 0) if isinstance(file_info, dict) else file_info self.__handle_file(storage=storage, event_path=Path(file_path), file_size=file_size) processed_count += 1 except Exception as e: logger.error(f"处理文件 {file_path} 失败: {e}") continue logger.info(f"{storage}:{mon_path} 全量扫描完成,共处理 {processed_count}/{file_count} 个文件") # 保存快照 self.save_snapshot(storage, new_snapshot, file_count) return True except Exception as e: logger.error(f"强制全量扫描失败: {storage}:{mon_path} - {e}") return False def load_snapshot(self, storage: str) -> Optional[Dict]: """ 从文件缓存加载快照 :param storage: 存储名称 :return: 快照数据或None """ try: cache_key = f"{storage}_snapshot" snapshot_data = self._snapshot_cache.get(cache_key, region="snapshots") if snapshot_data: data = json.loads(snapshot_data.decode('utf-8')) logger.debug(f"成功加载快照: {storage}, 包含 {len(data.get('snapshot', {}))} 个文件") return data logger.debug(f"快照文件不存在: {storage}") return None except Exception as e: logger.error(f"加载快照失败: {e}") return None @staticmethod def adjust_monitor_interval(file_count: int) -> int: """ 根据文件数量动态调整监控间隔 :param file_count: 文件数量 :return: 监控间隔(分钟) """ if file_count < 100: return 5 # 5分钟 elif file_count < 500: return 10 # 10分钟 elif file_count < 1000: return 15 # 15分钟 else: return 30 # 30分钟 @staticmethod def compare_snapshots(old_snapshot: Dict, new_snapshot: Dict) -> Dict[str, List]: """ 比对快照,找出变化的文件(只处理新增和修改,不处理删除) :param old_snapshot: 旧快照 :param new_snapshot: 新快照 :return: 变化信息 """ changes = { 'added': [], 'modified': [] } old_files = set(old_snapshot.keys()) new_files = set(new_snapshot.keys()) # 新增文件 changes['added'] = list(new_files - old_files) # 修改文件(大小或时间变化) for file_path in old_files & new_files: old_info = old_snapshot[file_path] new_info = new_snapshot[file_path] # 检查文件大小变化 old_size = old_info.get('size', 0) if isinstance(old_info, dict) else old_info new_size = new_info.get('size', 0) if isinstance(new_info, dict) else new_info # 检查修改时间变化(如果有的话) old_time = old_info.get('modify_time', 0) if isinstance(old_info, dict) else 0 new_time = new_info.get('modify_time', 0) if isinstance(new_info, dict) else 0 if old_size != new_size or (old_time and new_time and old_time != new_time): changes['modified'].append(file_path) return changes @staticmethod def count_directory_files(directory: Path, max_check: int = 10000) -> int: """ 统计目录下的文件数量(用于检测是否超过系统限制) :param directory: 目录路径 :param max_check: 最大检查数量,避免长时间阻塞 :return: 文件数量 """ try: count = 0 import os for root, dirs, files in os.walk(str(directory)): count += len(files) if count > max_check: return count return count except Exception as err: logger.debug(f"统计目录文件数量失败: {err}") return 0 @staticmethod def check_system_limits() -> Dict[str, Any]: """ 检查系统限制 :return: 系统限制信息 """ limits = { 'max_user_watches': 0, 'max_user_instances': 0, 'current_watches': 0, 'warnings': [] } try: system = platform.system() if system == 'Linux': # 检查 inotify 限制 try: with open('/proc/sys/fs/inotify/max_user_watches', 'r') as f: limits['max_user_watches'] = int(f.read().strip()) except Exception as e: logger.debug(f"读取 inotify 限制失败: {e}") limits['max_user_watches'] = 8192 # 默认值 try: with open('/proc/sys/fs/inotify/max_user_instances', 'r') as f: limits['max_user_instances'] = int(f.read().strip()) except Exception as e: logger.debug(f"读取 inotify 实例限制失败: {e}") # 检查当前使用的watches try: import subprocess result = subprocess.run(['find', '/proc/*/fd', '-lname', 'anon_inode:inotify', '-printf', '%h\n'], capture_output=True, text=True, timeout=5) if result.returncode == 0: limits['current_watches'] = len(result.stdout.strip().split('\n')) except Exception as e: logger.debug(f"检查当前 inotify 使用失败: {e}") except Exception as e: limits['warnings'].append(f"检查系统限制时出错: {e}") return limits @staticmethod def get_system_optimization_tips() -> List[str]: """ 获取系统优化建议 :return: 优化建议列表 """ tips = [] system = platform.system() if system == 'Linux': tips.extend([ "增加 inotify 监控数量限制:", "echo fs.inotify.max_user_watches=524288 | sudo tee -a /etc/sysctl.conf", "echo fs.inotify.max_user_instances=524288 | sudo tee -a /etc/sysctl.conf", "sudo sysctl -p", "", "如果在Docker中运行,请在宿主机上执行以上命令" ]) elif system == 'Darwin': tips.extend([ "macOS 系统优化建议:", "sudo sysctl kern.maxfiles=65536", "sudo sysctl kern.maxfilesperproc=32768", "ulimit -n 32768" ]) elif system == 'Windows': tips.extend([ "Windows 系统优化建议:", "1. 关闭不必要的实时保护软件对监控目录的扫描", "2. 将监控目录添加到Windows Defender排除列表", "3. 确保有足够的可用内存" ]) return tips @staticmethod def should_use_polling(directory: Path, monitor_mode: str, file_count: int, limits: dict) -> tuple[bool, str]: """ 判断是否应该使用轮询模式 :param directory: 监控目录 :param monitor_mode: 配置的监控模式 :param file_count: 目录文件数量 :param limits: 系统限制信息 :return: (是否使用轮询, 原因) """ if monitor_mode == "compatibility": return True, "用户配置为兼容模式" # 检查网络文件系统 if SystemUtils.is_network_filesystem(directory): return True, "检测到网络文件系统,建议使用兼容模式" max_watches = limits.get('max_user_watches') if max_watches and file_count > max_watches * 0.8: return True, f"目录文件数量({file_count})接近系统限制({max_watches})" return False, "使用快速模式" def init(self): """ 启动监控 """ # 停止现有任务 self.stop() # 读取目录配置 monitor_dirs = DirectoryHelper().get_download_dirs() if not monitor_dirs: logger.info("未找到任何目录监控配置") return # 按下载目录去重 monitor_dirs = list({f"{d.storage}_{d.download_path}": d for d in monitor_dirs}.values()) logger.info(f"找到 {len(monitor_dirs)} 个目录监控配置") # 启动定时服务进程 self._scheduler = BackgroundScheduler(timezone=settings.TZ) messagehelper = MessageHelper() mon_storages = {} for mon_dir in monitor_dirs: if not mon_dir.library_path: logger.warn(f"跳过监控配置 {mon_dir.download_path}:未设置媒体库目录") continue if mon_dir.monitor_type != "monitor": logger.debug(f"跳过监控配置 {mon_dir.download_path}:监控类型为 {mon_dir.monitor_type}") continue # 检查媒体库目录是不是下载目录的子目录 mon_path = Path(mon_dir.download_path) target_path = Path(mon_dir.library_path) if target_path.is_relative_to(mon_path): logger.warn(f"{target_path} 是监控目录 {mon_path} 的子目录,无法监控!") messagehelper.put(f"{target_path} 是监控目录 {mon_path} 的子目录,无法监控", title="目录监控") continue # 启动监控 if mon_dir.storage == "local": # 本地目录监控 logger.info(f"正在启动本地目录监控: {mon_path}") logger.info("*** 重要提示:目录监控只处理新增和修改的文件,不会处理监控启动前已存在的文件 ***") try: # 统计文件数量并给出提示 file_count = self.count_directory_files(mon_path) logger.info(f"监控目录 {mon_path} 包含约 {file_count} 个文件") # 检查系统限制 limits = self.check_system_limits() # 检查是否需要使用轮询模式 use_polling, reason = self.should_use_polling(mon_path, monitor_mode=mon_dir.monitor_mode, file_count=file_count, limits=limits) logger.info(f"监控模式决策: {reason}") if use_polling: observer = PollingObserver() logger.info(f"使用兼容模式(轮询)监控 {mon_path}") else: observer = self.__choose_observer() if observer is None: logger.warn(f"快速模式不可用,自动切换到兼容模式监控 {mon_path}") observer = PollingObserver() else: logger.info(f"使用快速模式监控 {mon_path}") if limits['warnings']: for warning in limits['warnings']: logger.warn(f"系统限制警告: {warning}") if limits['max_user_watches'] > 0: usage_percent = (file_count / limits['max_user_watches']) * 100 logger.info( f"系统监控资源使用率: {usage_percent:.1f}% ({file_count}/{limits['max_user_watches']})") self._observers.append(observer) observer.schedule(FileMonitorHandler(mon_path=mon_path, callback=self), path=str(mon_path), recursive=True) observer.daemon = True observer.start() mode_name = "兼容模式(轮询)" if use_polling else "快速模式" logger.info(f"✓ 本地目录监控已启动: {mon_path} [{mode_name}]") except Exception as e: err_msg = str(e) logger.error(f"启动本地目录监控失败: {mon_path}") logger.error(f"错误详情: {err_msg}") if "inotify" in err_msg.lower(): logger.error("inotify 相关错误,这通常是由于系统监控数量限制导致的") logger.error("解决方案:") tips = self.get_system_optimization_tips() for tip in tips: logger.error(f" {tip}") logger.error("执行上述命令后重启 MoviePilot") elif "permission" in err_msg.lower(): logger.error("权限错误,请检查 MoviePilot 是否有足够的权限访问监控目录") else: logger.error("建议尝试使用兼容模式进行监控") messagehelper.put(f"启动本地目录监控失败: {mon_path}\n错误: {err_msg}", title="目录监控") else: if not mon_storages.get(mon_dir.storage): mon_storages[mon_dir.storage] = [] mon_storages[mon_dir.storage].append(mon_path) for storage, paths in mon_storages.items(): # 远程目录监控 - 使用智能间隔 # 先尝试加载已有快照获取文件数量 snapshot_data = self.load_snapshot(storage) file_count = snapshot_data.get('file_count', 0) if snapshot_data else 0 interval = self.adjust_monitor_interval(file_count) for path in paths: logger.info(f"正在启动远程目录监控: {path} [{storage}]") logger.info("*** 重要提示:远程目录监控只处理新增和修改的文件,不会处理监控启动前已存在的文件 ***") logger.info(f"预估文件数量: {file_count}, 监控间隔: {interval}分钟") self._scheduler.add_job( self.polling_observer, 'interval', minutes=interval, kwargs={ 'storage': storage, 'mon_paths': paths }, id=f"monitor_{storage}", replace_existing=True ) logger.info(f"✓ 远程目录监控已启动: [间隔: {interval}分钟]") # 启动定时服务 if self._scheduler.get_jobs(): self._scheduler.print_jobs() self._scheduler.start() logger.info("定时监控服务已启动") # 输出监控总结 local_count = len([d for d in monitor_dirs if d.storage == "local" and d.monitor_type == "monitor"]) remote_count = len([d for d in monitor_dirs if d.storage != "local" and d.monitor_type == "monitor"]) logger.info(f"目录监控启动完成: 本地监控 {local_count} 个,远程监控 {remote_count} 个") def __choose_observer(self) -> Optional[Any]: """ 选择最优的监控模式(带错误处理和自动回退) """ system = platform.system() observers_to_try = [] try: if system == 'Linux': observers_to_try = [ ('InotifyObserver', lambda: self.__try_import_observer('watchdog.observers.inotify', 'InotifyObserver')), ] elif system == 'Darwin': observers_to_try = [ ('FSEventsObserver', lambda: self.__try_import_observer('watchdog.observers.fsevents', 'FSEventsObserver')), ] elif system == 'Windows': observers_to_try = [ ('WindowsApiObserver', lambda: self.__try_import_observer('watchdog.observers.read_directory_changes', 'WindowsApiObserver')), ] # 尝试每个观察者 for observer_name, observer_func in observers_to_try: try: observer_class = observer_func() if observer_class: # 尝试创建实例以验证是否可用 test_observer = observer_class() test_observer.stop() # 立即停止测试实例 logger.debug(f"成功初始化 {observer_name}") return observer_class() except Exception as e: logger.debug(f"初始化 {observer_name} 失败: {e}") continue except Exception as e: logger.debug(f"选择观察者时出错: {e}") logger.debug("所有快速监控模式都不可用,将使用兼容模式") return None @staticmethod def __try_import_observer(module_name: str, class_name: str): """ 尝试导入观察者类 """ try: module = __import__(module_name, fromlist=[class_name]) return getattr(module, class_name) except (ImportError, AttributeError) as e: logger.debug(f"导入 {module_name}.{class_name} 失败: {e}") return None def polling_observer(self, storage: str, mon_paths: List[Path]): """ 轮询监控(改进版) """ with snapshot_lock: try: # 加载上次快照数据 old_snapshot_data = self.load_snapshot(storage) old_snapshot = old_snapshot_data.get('snapshot', {}) if old_snapshot_data else {} last_snapshot_time = old_snapshot_data.get('timestamp', 0) if old_snapshot_data else 0 # 判断是否为首次快照:检查快照文件是否存在且有效 is_first_snapshot = old_snapshot_data is None new_snapshot = {} for mon_path in mon_paths: logger.debug(f"开始对 {storage}:{mon_path} 进行快照...") # 生成新快照(增量模式) snapshot = StorageChain().snapshot_storage( storage=storage, path=mon_path, last_snapshot_time=last_snapshot_time ) if snapshot is None: logger.warn(f"获取 {storage}:{mon_path} 快照失败") continue new_snapshot.update(snapshot) file_count = len(snapshot) logger.info(f"{storage}:{mon_path} 快照完成,发现 {file_count} 个文件") file_count = len(new_snapshot) if not is_first_snapshot: # 比较快照找出变化 changes = self.compare_snapshots(old_snapshot, new_snapshot) # 处理新增文件 for new_file in changes['added']: logger.info(f"发现新增文件:{new_file}") file_info = new_snapshot.get(new_file, {}) file_size = file_info.get('size', 0) if isinstance(file_info, dict) else file_info self.__handle_file(storage=storage, event_path=Path(new_file), file_size=file_size) # 处理修改文件 for modified_file in changes['modified']: logger.info(f"发现修改文件:{modified_file}") file_info = new_snapshot.get(modified_file, {}) file_size = file_info.get('size', 0) if isinstance(file_info, dict) else file_info self.__handle_file(storage=storage, event_path=Path(modified_file), file_size=file_size) if changes['added'] or changes['modified']: logger.info( f"{storage} 发现 {len(changes['added'])} 个新增文件,{len(changes['modified'])} 个修改文件") else: logger.debug(f"{storage} 无文件变化") else: logger.info(f"{storage} 首次快照完成,共 {file_count} 个文件") logger.info("*** 首次快照仅建立基准,不会处理现有文件。后续监控将处理新增和修改的文件 ***") # 保存新快照 self.save_snapshot(storage, new_snapshot, file_count, last_snapshot_time) # 动态调整监控间隔 new_interval = self.adjust_monitor_interval(file_count) current_job = self._scheduler.get_job(f"monitor_{storage}") if current_job and current_job.trigger.interval.total_seconds() / 60 != new_interval: # 重新安排任务 self._scheduler.modify_job( f"monitor_{storage}", trigger='interval', minutes=new_interval ) logger.info(f"{storage}:{mon_path} 监控间隔已调整为 {new_interval} 分钟") except Exception as e: logger.error(f"轮询监控 {storage}:{mon_path} 出现错误:{e}") logger.debug(traceback.format_exc()) def event_handler(self, event, text: str, event_path: str, file_size: float = None): """ 处理文件变化 :param event: 事件 :param text: 事件描述 :param event_path: 事件文件路径 :param file_size: 文件大小 """ if not event.is_directory: # 文件发生变化 logger.debug(f"检测到文件变化: {event_path} [{text}]") # 整理文件 self.__handle_file(storage="local", event_path=Path(event_path), file_size=file_size) def __handle_file(self, storage: str, event_path: Path, file_size: float = None): """ 整理一个文件 :param storage: 存储 :param event_path: 事件文件路径 :param file_size: 文件大小 """ def __is_bluray_sub(_path: Path) -> bool: """ 判断是否蓝光原盘目录内的子目录或文件 """ return True if re.search(r"BDMV/STREAM", _path.as_posix(), re.IGNORECASE) else False def __get_bluray_dir(_path: Path) -> Optional[Path]: """ 获取蓝光原盘BDMV目录的上级目录 """ for p in _path.parents: if p.name == "BDMV": return p.parent return None # 全程加锁 with lock: is_bluray_folder = False # 蓝光原盘文件处理 if __is_bluray_sub(event_path): event_path = __get_bluray_dir(event_path) if not event_path: return is_bluray_folder = True # TTL缓存控重 if self._cache.get(str(event_path)): logger.debug(f"文件 {event_path} 在缓存中,跳过处理") return self._cache[str(event_path)] = True try: if is_bluray_folder: logger.info(f"开始整理蓝光原盘: {event_path}") else: logger.info(f"开始整理文件: {event_path}") # 开始整理 TransferChain().do_transfer( fileitem=FileItem( storage=storage, path=( event_path.as_posix() if not is_bluray_folder else event_path.as_posix() + "/" ), type="file" if not is_bluray_folder else "dir", name=event_path.name, basename=event_path.stem, extension=event_path.suffix[1:], size=file_size ) ) except Exception as e: logger.error("目录监控整理文件发生错误:%s - %s" % (str(e), traceback.format_exc())) def stop(self): """ 退出监控 """ self._event.set() if self._observers: logger.info("正在停止本地目录监控服务...") for observer in self._observers: try: observer.stop() observer.join() logger.debug(f"已停止监控服务: {observer}") except Exception as e: logger.error(f"停止目录监控服务出现了错误:{e}") self._observers = [] logger.info("本地目录监控服务已停止") if self._scheduler: self._scheduler.remove_all_jobs() if self._scheduler.running: try: self._scheduler.shutdown() logger.info("定时监控服务已停止") except Exception as e: logger.error(f"停止定时服务出现了错误:{e}") self._scheduler = None if self._cache: self._cache.close() if self._snapshot_cache: self._snapshot_cache.close() self._event.clear() ================================================ FILE: app/plugins/__init__.py ================================================ from abc import ABCMeta, abstractmethod from pathlib import Path from typing import Any, List, Dict, Tuple, Optional, Type from app.chain import ChainBase from app.core.config import settings from app.core.event import EventManager from app.db.plugindata_oper import PluginDataOper from app.db.systemconfig_oper import SystemConfigOper from app.helper.message import MessageHelper from app.schemas import Notification, NotificationType, MessageChannel class PluginChian(ChainBase): """ 插件处理链 """ pass class _PluginBase(metaclass=ABCMeta): """ 插件模块基类,通过继续该类实现插件功能 除内置属性外,还有以下方法可以扩展或调用: - stop_service() 停止插件服务 - get_config() 获取配置信息 - update_config() 更新配置信息 - init_plugin() 生效配置信息 - get_data_path() 获取插件数据保存目录 """ # 插件名称 plugin_name: Optional[str] = "" # 插件描述 plugin_desc: Optional[str] = "" # 插件顺序 plugin_order: Optional[int] = 9999 # 是否为插件分身 is_clone: bool = False def __init__(self): # 插件数据 self.plugindata = PluginDataOper() # 处理链 self.chain = PluginChian() # 系统配置 self.systemconfig = SystemConfigOper() # 系统消息 self.systemmessage = MessageHelper() # 事件管理器 self.eventmanager = EventManager() @abstractmethod def init_plugin(self, config: dict = None): """ 生效配置信息 :param config: 配置信息字典 """ pass def get_name(self) -> str: """ 获取插件名称 :return: 插件名称 """ return self.plugin_name @abstractmethod def get_state(self) -> bool: """ 获取插件运行状态 """ pass @staticmethod def get_command() -> List[Dict[str, Any]]: """ 注册插件远程命令 [{ "cmd": "/xx", "event": EventType.xx, "desc": "名称", "category": "分类,需要注册到Wechat时必须有分类", "data": {} }] """ pass @staticmethod def get_render_mode() -> Tuple[str, Optional[str]]: """ 获取插件渲染模式 :return: 1、渲染模式,支持:vue/vuetify,默认vuetify;2、vue模式下编译后文件的相对路径,默认为`dist/asserts`,vuetify模式下为None """ return "vuetify", None @abstractmethod def get_api(self) -> List[Dict[str, Any]]: """ 注册插件API [{ "path": "/xx", "endpoint": self.xxx, "methods": ["GET", "POST"], "auth: "apikey", # 鉴权类型:apikey/bear "summary": "API名称", "description": "API说明" }] """ pass @abstractmethod def get_form(self) -> Tuple[Optional[List[dict]], Dict[str, Any]]: """ 拼装插件配置页面,插件配置页面使用Vuetify组件拼装,参考:https://vuetifyjs.com/ :return: 1、页面配置(vuetify模式)或 None(vue模式);2、默认数据结构 """ pass @abstractmethod def get_page(self) -> Optional[List[dict]]: """ 拼装插件详情页面,需要返回页面配置,同时附带数据 插件详情页面使用Vuetify组件拼装,参考:https://vuetifyjs.com/ :return: 页面配置(vuetify模式)或 None(vue模式) """ pass def get_service(self) -> List[Dict[str, Any]]: """ 注册插件公共服务 [{ "id": "服务ID", "name": "服务名称", "trigger": "触发器:cron/interval/date/CronTrigger.from_crontab()", "func": self.xxx, "kwargs": {} # 定时器参数 }] """ pass def get_dashboard(self, key: str, **kwargs) -> Optional[Tuple[Dict[str, Any], Dict[str, Any], Optional[List[dict]]]]: """ 获取插件仪表盘页面,需要返回:1、仪表板col配置字典;2、全局配置(布局、自动刷新等);3、仪表板页面元素配置含数据json(vuetify)或 None(vue模式) 1、col配置参考: { "cols": 12, "md": 6 } 2、全局配置参考: { "refresh": 10, // 自动刷新时间,单位秒 "border": True, // 是否显示边框,默认True,为False时取消组件边框和边距,由插件自行控制 "title": "组件标题", // 组件标题,如有将显示该标题,否则显示插件名称 "subtitle": "组件子标题", // 组件子标题,缺省时不展示子标题 } 3、vuetify模式页面配置使用Vuetify组件拼装,参考:https://vuetifyjs.com/;vue模式为None kwargs参数可获取的值:1、user_agent:浏览器UA :param key: 仪表盘key,根据指定的key返回相应的仪表盘数据,缺省时返回一个固定的仪表盘数据(兼容旧版) """ pass def get_dashboard_meta(self) -> Optional[List[Dict[str, str]]]: """ 获取插件仪表盘元信息 返回示例: [{ "key": "dashboard1", // 仪表盘的key,在当前插件范围唯一 "name": "仪表盘1" // 仪表盘的名称 }, { "key": "dashboard2", "name": "仪表盘2" }] """ pass def get_module(self) -> Dict[str, Any]: """ 获取插件模块声明,用于胁持系统模块实现(方法名:方法实现) { "id1": self.xxx1, "id2": self.xxx2, } """ pass def get_actions(self) -> List[Dict[str, Any]]: """ 获取插件工作流动作 [{ "id": "动作ID", "name": "动作名称", "func": self.xxx, "kwargs": {} # 需要附加传递的参数 }] 对实现函数的要求: 1、函数的第一个参数固定为 ActionContent 实例,如需要传递额外参数,在kwargs中定义 2、函数的返回:执行状态 True / False,更新后的 ActionContent 实例 """ pass def get_agent_tools(self) -> List[Type]: """ 获取插件智能体工具 返回工具类列表,每个工具类必须继承自 MoviePilotTool [ToolClass1, ToolClass2, ...] 对工具类的要求: 1、工具类必须继承自 app.agent.tools.base.MoviePilotTool 2、工具类需要实现 run 方法(异步方法) 3、工具类需要定义 name 和 description 属性 4、工具类可以定义 args_schema 来指定输入参数模型 """ pass @abstractmethod def stop_service(self): """ 停止插件 """ pass def update_config(self, config: dict, plugin_id: Optional[str] = None) -> bool: """ 更新配置信息 :param config: 配置信息字典 :param plugin_id: 插件ID """ if not plugin_id: plugin_id = self.__class__.__name__ return self.systemconfig.set(f"plugin.{plugin_id}", config) def get_config(self, plugin_id: Optional[str] = None) -> Any: """ 获取配置信息 :param plugin_id: 插件ID """ if not plugin_id: plugin_id = self.__class__.__name__ return self.systemconfig.get(f"plugin.{plugin_id}") def get_data_path(self, plugin_id: Optional[str] = None) -> Path: """ 获取插件数据保存目录 """ if not plugin_id: plugin_id = self.__class__.__name__ data_path = settings.PLUGIN_DATA_PATH / f"{plugin_id}" if not data_path.exists(): data_path.mkdir(parents=True) return data_path def save_data(self, key: str, value: Any, plugin_id: Optional[str] = None): """ 保存插件数据 :param key: 数据key :param value: 数据值 :param plugin_id: 插件ID """ if not plugin_id: plugin_id = self.__class__.__name__ self.plugindata.save(plugin_id, key, value) def get_data(self, key: Optional[str] = None, plugin_id: Optional[str] = None) -> Any: """ 获取插件数据 :param key: 数据key :param plugin_id: plugin_id """ if not plugin_id: plugin_id = self.__class__.__name__ return self.plugindata.get_data(plugin_id, key) def del_data(self, key: str, plugin_id: Optional[str] = None) -> Any: """ 删除插件数据 :param key: 数据key :param plugin_id: plugin_id """ if not plugin_id: plugin_id = self.__class__.__name__ return self.plugindata.del_data(plugin_id, key) def post_message(self, channel: MessageChannel = None, mtype: NotificationType = None, title: Optional[str] = None, text: Optional[str] = None, image: Optional[str] = None, link: Optional[str] = None, userid: Optional[str] = None, username: Optional[str] = None, **kwargs): """ 发送消息 """ if not link: link = settings.MP_DOMAIN(f"#/plugins?tab=installed&id={self.__class__.__name__}") self.chain.post_message(Notification( channel=channel, mtype=mtype, title=title, text=text, image=image, link=link, userid=userid, username=username, **kwargs )) def close(self): pass ================================================ FILE: app/scheduler.py ================================================ import asyncio import gc import inspect import multiprocessing import threading import traceback from datetime import datetime, timedelta from typing import List, Optional import pytz from apscheduler.executors.pool import ThreadPoolExecutor from apscheduler.jobstores.base import JobLookupError from apscheduler.schedulers.background import BackgroundScheduler from apscheduler.triggers.cron import CronTrigger from app import schemas from app.chain import ChainBase from app.chain.mediaserver import MediaServerChain from app.chain.recommend import RecommendChain from app.chain.site import SiteChain from app.chain.subscribe import SubscribeChain from app.chain.transfer import TransferChain from app.chain.workflow import WorkflowChain from app.core.config import settings, global_vars from app.core.event import eventmanager from app.core.plugin import PluginManager from app.db.systemconfig_oper import SystemConfigOper from app.helper.message import MessageHelper from app.helper.sites import SitesHelper # noqa from app.helper.image import WallpaperHelper from app.log import logger from app.schemas import Notification, NotificationType, Workflow from app.schemas.types import EventType, SystemConfigKey from app.utils.gc import get_memory_usage from app.utils.mixins import ConfigReloadMixin from app.utils.singleton import SingletonClass from app.utils.timer import TimerUtils lock = threading.Lock() class SchedulerChain(ChainBase): pass class Scheduler(ConfigReloadMixin, metaclass=SingletonClass): """ 定时任务管理 """ CONFIG_WATCH = { "DEV", "COOKIECLOUD_INTERVAL", "MEDIASERVER_SYNC_INTERVAL", "SUBSCRIBE_SEARCH", "SUBSCRIBE_SEARCH_INTERVAL", "SUBSCRIBE_MODE", "SUBSCRIBE_RSS_INTERVAL", "SITEDATA_REFRESH_INTERVAL", } def __init__(self): # 定时服务 self._scheduler = None # 退出事件 self._event = threading.Event() # 锁 self._lock = threading.RLock() # 各服务的运行状态 self._jobs = {} # 用户认证失败次数 self._auth_count = 0 # 用户认证失败消息发送 self._auth_message = False # 初始化 self.init() def on_config_changed(self): self.init() def get_reload_name(self): return "定时服务" def init(self): """ 初始化定时服务 """ # 停止定时服务 self.stop() # 调试模式不启动定时服务 if settings.DEV: return with lock: # 各服务的运行状态 self._jobs = { "cookiecloud": { "name": "同步CookieCloud站点", "func": SiteChain().sync_cookies, "running": False }, "mediaserver_sync": { "name": "同步媒体服务器", "func": MediaServerChain().sync, "running": False }, "subscribe_tmdb": { "name": "订阅元数据更新", "func": SubscribeChain().check, "running": False }, "subscribe_search": { "name": "订阅搜索补全", "func": SubscribeChain().search, "running": False, "kwargs": { "state": "R" } }, "new_subscribe_search": { "name": "新增订阅搜索", "func": SubscribeChain().search, "running": False, "kwargs": { "state": "N" } }, "subscribe_refresh": { "name": "订阅刷新", "func": SubscribeChain().refresh, "running": False }, "subscribe_follow": { "name": "关注的订阅分享", "func": SubscribeChain().follow, "running": False }, "transfer": { "name": "下载文件整理", "func": TransferChain().process, "running": False }, "clear_cache": { "name": "缓存清理", "func": self.clear_cache, "running": False }, "user_auth": { "name": "用户认证检查", "func": self.user_auth, "running": False }, "scheduler_job": { "name": "公共定时服务", "func": SchedulerChain().scheduler_job, "running": False }, "random_wallpager": { "name": "壁纸缓存", "func": WallpaperHelper().get_wallpapers, "running": False }, "sitedata_refresh": { "name": "站点数据刷新", "func": SiteChain().refresh_userdatas, "running": False }, "recommend_refresh": { "name": "推荐缓存", "func": RecommendChain().refresh_recommend, "running": False }, "plugin_market_refresh": { "name": "插件市场缓存", "func": PluginManager().async_get_online_plugins, "running": False, "kwargs": { "force": True } }, "subscribe_calendar_cache": { "name": "订阅日历缓存", "func": SubscribeChain().cache_calendar, "running": False }, "full_gc": { "name": "主动内存回收", "func": self.full_gc, "running": False } } # 创建定时服务 self._scheduler = BackgroundScheduler(timezone=settings.TZ, executors={ 'default': ThreadPoolExecutor(settings.CONF.scheduler) }) # CookieCloud定时同步 if settings.COOKIECLOUD_INTERVAL \ and str(settings.COOKIECLOUD_INTERVAL).isdigit(): self._scheduler.add_job( self.start, "interval", id="cookiecloud", name="同步CookieCloud站点", minutes=int(settings.COOKIECLOUD_INTERVAL), next_run_time=datetime.now(pytz.timezone(settings.TZ)) + timedelta(minutes=5), kwargs={ 'job_id': 'cookiecloud' } ) # 媒体服务器同步 if settings.MEDIASERVER_SYNC_INTERVAL \ and str(settings.MEDIASERVER_SYNC_INTERVAL).isdigit(): self._scheduler.add_job( self.start, "interval", id="mediaserver_sync", name="同步媒体服务器", hours=int(settings.MEDIASERVER_SYNC_INTERVAL), next_run_time=datetime.now(pytz.timezone(settings.TZ)) + timedelta(minutes=10), kwargs={ 'job_id': 'mediaserver_sync' } ) # 新增订阅时搜索(5分钟检查一次) self._scheduler.add_job( self.start, "interval", id="new_subscribe_search", name="新增订阅搜索", minutes=5, kwargs={ 'job_id': 'new_subscribe_search' } ) # 检查更新订阅TMDB数据(每隔6小时) self._scheduler.add_job( self.start, "interval", id="subscribe_tmdb", name="订阅元数据更新", hours=6, kwargs={ 'job_id': 'subscribe_tmdb' } ) # 订阅状态每隔24小时搜索一次 if settings.SUBSCRIBE_SEARCH: self._scheduler.add_job( self.start, "interval", id="subscribe_search", name="订阅搜索补全", hours=settings.SUBSCRIBE_SEARCH_INTERVAL, kwargs={ 'job_id': 'subscribe_search' } ) if settings.SUBSCRIBE_MODE == "spider": # 站点首页种子定时刷新模式 triggers = TimerUtils.random_scheduler(num_executions=32) for trigger in triggers: self._scheduler.add_job( self.start, "cron", id=f"subscribe_refresh|{trigger.hour}:{trigger.minute}", name="订阅刷新", hour=trigger.hour, minute=trigger.minute, kwargs={ 'job_id': 'subscribe_refresh' }) else: # RSS订阅模式 if not settings.SUBSCRIBE_RSS_INTERVAL \ or not str(settings.SUBSCRIBE_RSS_INTERVAL).isdigit(): settings.SUBSCRIBE_RSS_INTERVAL = 30 elif int(settings.SUBSCRIBE_RSS_INTERVAL) < 5: settings.SUBSCRIBE_RSS_INTERVAL = 5 self._scheduler.add_job( self.start, "interval", id="subscribe_refresh", name="RSS订阅刷新", minutes=int(settings.SUBSCRIBE_RSS_INTERVAL), kwargs={ 'job_id': 'subscribe_refresh' } ) # 关注订阅分享(每1小时) self._scheduler.add_job( self.start, "interval", id="subscribe_follow", name="关注的订阅分享", hours=1, kwargs={ 'job_id': 'subscribe_follow' } ) # 下载器文件转移(每5分钟) self._scheduler.add_job( self.start, "interval", id="transfer", name="下载文件整理", minutes=5, kwargs={ 'job_id': 'transfer' } ) # 后台刷新TMDB壁纸 self._scheduler.add_job( self.start, "interval", id="random_wallpager", name="壁纸缓存", minutes=30, next_run_time=datetime.now(pytz.timezone(settings.TZ)) + timedelta(seconds=1), kwargs={ 'job_id': 'random_wallpager' } ) # 公共定时服务 self._scheduler.add_job( self.start, "interval", id="scheduler_job", name="公共定时服务", minutes=10, kwargs={ 'job_id': 'scheduler_job' } ) # 缓存清理服务,每隔24小时 self._scheduler.add_job( self.start, "interval", id="clear_cache", name="缓存清理", hours=settings.CONF.meta / 3600, kwargs={ 'job_id': 'clear_cache' } ) # 定时检查用户认证,每隔10分钟 self._scheduler.add_job( self.start, "interval", id="user_auth", name="用户认证检查", minutes=10, kwargs={ 'job_id': 'user_auth' } ) # 站点数据刷新 if settings.SITEDATA_REFRESH_INTERVAL: self._scheduler.add_job( self.start, "interval", id="sitedata_refresh", name="站点数据刷新", minutes=settings.SITEDATA_REFRESH_INTERVAL * 60, kwargs={ 'job_id': 'sitedata_refresh' } ) # 推荐缓存 self._scheduler.add_job( self.start, "interval", id="recommend_refresh", name="推荐缓存", hours=24, next_run_time=datetime.now(pytz.timezone(settings.TZ)) + timedelta(seconds=5), kwargs={ 'job_id': 'recommend_refresh' } ) # 插件市场缓存 self._scheduler.add_job( self.start, "interval", id="plugin_market_refresh", name="插件市场缓存", minutes=30, kwargs={ 'job_id': 'plugin_market_refresh' } ) # 订阅日历缓存 self._scheduler.add_job( self.start, "interval", id="subscribe_calendar_cache", name="订阅日历缓存", hours=6, next_run_time=datetime.now(pytz.timezone(settings.TZ)) + timedelta(minutes=2), kwargs={ 'job_id': 'subscribe_calendar_cache' } ) # 主动内存回收 if settings.MEMORY_GC_INTERVAL: self._scheduler.add_job( self.start, "interval", id="full_gc", name="主动内存回收", minutes=settings.MEMORY_GC_INTERVAL, kwargs={ 'job_id': 'full_gc' } ) # 初始化工作流服务 self.init_workflow_jobs() # 初始化插件服务 self.init_plugin_jobs() # 启动定时服务 self._scheduler.start() def __prepare_job(self, job_id: str) -> Optional[dict]: """ 准备定时任务 """ with self._lock: job = self._jobs.get(job_id) if not job: return None if job.get("running"): logger.warning(f"定时任务 {job_id} - {job.get('name')} 正在运行 ...") return None self._jobs[job_id]["running"] = True return job def __finish_job(self, job_id: str): """ 完成定时任务 """ with self._lock: try: self._jobs[job_id]["running"] = False except KeyError: pass def start(self, job_id: str, *args, **kwargs): """ 启动定时服务 """ def __start_coro(coro): """ 启动协程 """ return asyncio.run_coroutine_threadsafe(coro, global_vars.loop) # 获取定时任务 job = self.__prepare_job(job_id) if not job: return # 开始运行 try: if not kwargs: kwargs = job.get("kwargs") or {} func = job.get("func") if not func: return # 是否多进程运行 run_in_process = job.get("run_in_process", False) if inspect.iscoroutinefunction(func): # 协程函数 __start_coro(func(*args, **kwargs)) elif run_in_process: # 多进程运行 p = multiprocessing.Process(target=func, args=args, kwargs=kwargs) p.start() p.join() else: # 普通函数 job["func"](*args, **kwargs) except Exception as e: logger.error(f"定时任务 {job.get('name')} 执行失败:{str(e)} - {traceback.format_exc()}") MessageHelper().put(title=f"{job.get('name')} 执行失败", message=str(e), role="system") eventmanager.send_event( EventType.SystemError, { "type": "scheduler", "scheduler_id": job_id, "scheduler_name": job.get('name'), "error": str(e), "traceback": traceback.format_exc() } ) # 运行结束 self.__finish_job(job_id) def init_plugin_jobs(self): """ 初始化插件定时服务 """ for pid in PluginManager().get_running_plugin_ids(): self.update_plugin_job(pid) def init_workflow_jobs(self): """ 初始化工作流定时服务 """ for workflow in WorkflowChain().get_timer_workflows() or []: self.update_workflow_job(workflow) def remove_workflow_job(self, workflow: Workflow): """ 移除工作流服务 """ if not self._scheduler: return with self._lock: job_id = f"workflow-{workflow.id}" service = self._jobs.pop(job_id, {}) if not service: return try: # 在调度器中查找并移除对应的 job job_removed = False for job in list(self._scheduler.get_jobs()): if job_id == job.id: try: self._scheduler.remove_job(job.id) job_removed = True except JobLookupError: pass break if job_removed: logger.info(f"移除工作流服务:{service.get('name')}") except Exception as e: logger.error(f"移除工作流服务失败:{str(e)} - {job_id}: {service}") SchedulerChain().messagehelper.put(title=f"工作流 {workflow.name} 服务移除失败", message=str(e), role="system") def remove_plugin_job(self, pid: str, job_id: Optional[str] = None): """ 移除定时服务,可以是单个服务(包括默认服务)或整个插件的所有服务 :param pid: 插件 ID :param job_id: 可选,指定要移除的单个服务的 job_id。如果不提供,则移除该插件的所有服务,当移除单个服务时,默认服务也包含在内 """ if not self._scheduler: return with self._lock: if job_id: # 移除单个服务 service = self._jobs.pop(job_id, None) if not service: return jobs_to_remove = [(job_id, service)] else: # 移除插件的所有服务 jobs_to_remove = [ (job_id, service) for job_id, service in self._jobs.items() if service.get("pid") == pid ] for job_id, _ in jobs_to_remove: self._jobs.pop(job_id, None) if not jobs_to_remove: return plugin_name = PluginManager().get_plugin_attr(pid, "plugin_name") # 遍历移除任务 for job_id, service in jobs_to_remove: try: # 在调度器中查找并移除对应的 job job_removed = False for job in list(self._scheduler.get_jobs()): job_id_from_service = job.id.split("|")[0] if job_id == job_id_from_service: try: self._scheduler.remove_job(job.id) job_removed = True except JobLookupError: pass if job_removed: logger.info(f"移除插件服务({plugin_name}):{service.get('name')}") # noqa except Exception as e: logger.error(f"移除插件服务失败:{str(e)} - {job_id}: {service}") SchedulerChain().messagehelper.put(title=f"插件 {plugin_name} 服务移除失败", message=str(e), role="system") def update_workflow_job(self, workflow: Workflow): """ 更新工作流定时服务 """ if not self._scheduler: return # 移除该工作流的全部服务 self.remove_workflow_job(workflow) # 添加工作流服务 with self._lock: try: job_id = f"workflow-{workflow.id}" self._jobs[job_id] = { "func": WorkflowChain().process, "name": workflow.name, "provider_name": "工作流", "running": False, } self._scheduler.add_job( self.start, trigger=CronTrigger.from_crontab(workflow.timer), id=job_id, name=workflow.name, kwargs={"job_id": job_id, "workflow_id": workflow.id}, replace_existing=True ) logger.info(f"注册工作流服务:{workflow.name} - {workflow.timer}") except Exception as e: logger.error(f"注册工作流服务失败:{workflow.name} - {str(e)}") SchedulerChain().messagehelper.put(title=f"工作流 {workflow.name} 服务注册失败", message=str(e), role="system") def update_plugin_job(self, pid: str): """ 更新插件定时服务 """ if not self._scheduler or not pid: return # 移除该插件的全部服务 self.remove_plugin_job(pid) # 获取插件服务列表 with self._lock: plugin_manager = PluginManager() try: plugin_services = plugin_manager.get_plugin_services(pid=pid) except Exception as e: logger.error(f"运行插件 {pid} 服务失败:{str(e)} - {traceback.format_exc()}") return # 获取插件名称 plugin_name = plugin_manager.get_plugin_attr(pid, "plugin_name") # 开始注册插件服务 for service in plugin_services: try: sid = f"{pid}_{service['id']}" job_id = sid.split("|")[0] self.remove_plugin_job(pid, job_id) self._jobs[job_id] = { "func": service["func"], "name": service["name"], "pid": pid, "provider_name": plugin_name, "kwargs": service.get("func_kwargs") or {}, "running": False, } self._scheduler.add_job( self.start, service["trigger"], id=sid, name=service["name"], **(service.get("kwargs") or {}), kwargs={"job_id": job_id}, replace_existing=True ) logger.info(f"注册插件{plugin_name}服务:{service['name']} - {service['trigger']}") except Exception as e: logger.error(f"注册插件{plugin_name}服务失败:{str(e)} - {service}") SchedulerChain().messagehelper.put(title=f"插件 {plugin_name} 服务注册失败", message=str(e), role="system") def list(self) -> List[schemas.ScheduleInfo]: """ 当前所有任务 """ if not self._scheduler: return [] with self._lock: # 返回计时任务 schedulers = [] # 去重 added = [] # 避免_scheduler.shutdown()处于阻塞状态导致的死锁 if not self._scheduler or not self._scheduler.running: return [] jobs = self._scheduler.get_jobs() # 按照下次运行时间排序 jobs.sort(key=lambda x: x.next_run_time) # 将正在运行的任务提取出来 (保障一次性任务正常显示) for job_id, service in self._jobs.items(): name = service.get("name") provider_name = service.get("provider_name") if service.get("running") and name and provider_name: if job_id not in added: added.append(job_id) schedulers.append(schemas.ScheduleInfo( id=job_id, name=name, provider=provider_name, status="正在运行", )) # 获取其他待执行任务 for job in jobs: job_id = job.id.split("|")[0] if job_id not in added: added.append(job_id) else: continue service = self._jobs.get(job_id) if not service: continue # 任务状态 status = "正在运行" if service.get("running") else "等待" # 下次运行时间 next_run = TimerUtils.time_difference(job.next_run_time) schedulers.append(schemas.ScheduleInfo( id=job_id, name=job.name, provider=service.get("provider_name", "[系统]"), status=status, next_run=next_run )) return schedulers def stop(self): """ 关闭定时服务 """ with lock: try: if self._scheduler: logger.info("正在停止定时任务...") self._event.set() self._scheduler.remove_all_jobs() if self._scheduler.running: self._scheduler.shutdown() self._scheduler = None logger.info("定时任务停止完成") except Exception as e: logger.error(f"停止定时任务失败::{str(e)} - {traceback.format_exc()}") @staticmethod def clear_cache(): """ 清理缓存 """ SchedulerChain().clear_cache() @staticmethod def full_gc(): """ 主动内存回收 """ memory_before = get_memory_usage() collected = gc.collect() memory_after = get_memory_usage() memory_freed = memory_before - memory_after logger.info(f"主动内存回收完成,回收对象数: {collected},释放内存: {memory_freed:.2f} MB") def user_auth(self): """ 用户认证检查 """ if SitesHelper().auth_level >= 2: return # 最大重试次数 __max_try__ = 30 if self._auth_count > __max_try__: if not self._auth_message: SchedulerChain().messagehelper.put(title=f"用户认证失败", message="用户认证失败次数过多,将不再尝试认证!", role="system") self._auth_message = True return logger.info("用户未认证,正在尝试认证...") auth_conf = SystemConfigOper().get(SystemConfigKey.UserSiteAuthParams) if auth_conf: status, msg = SitesHelper().check_user(**auth_conf) else: status, msg = SitesHelper().check_user() if status: self._auth_count = 0 logger.info(f"{msg} 用户认证成功") SchedulerChain().post_message( Notification( mtype=NotificationType.Manual, title="MoviePilot用户认证成功", text=f"使用站点:{msg},如有插件使用异常,请重启MoviePilot。", link=settings.MP_DOMAIN('#/site') ) ) # 认证通过后重新初始化插件 PluginManager().init_config() self.init_plugin_jobs() else: self._auth_count += 1 logger.error(f"用户认证失败,{msg},共失败 {self._auth_count} 次") if self._auth_count >= __max_try__: logger.error("用户认证失败次数过多,将不再尝试认证!") ================================================ FILE: app/schemas/__init__.py ================================================ from .context import * from .dashboard import * from .download import * from .event import * from .exception import * from .file import * from .history import * from .mediaserver import * from .message import * from .monitoring import * from .plugin import * from .response import * from .rule import * from .servarr import * from .servcookie import * from .site import * from .subscribe import * from .system import * from .system import * from .tmdb import * from .token import * from .transfer import * from .user import * from .workflow import * from .mcp import * ================================================ FILE: app/schemas/agent.py ================================================ """AI智能体相关数据模型""" from datetime import datetime from typing import Dict, List, Optional, Any from pydantic import BaseModel, Field, ConfigDict, field_serializer class ConversationMemory(BaseModel): """对话记忆模型""" session_id: str = Field(description="会话ID") user_id: Optional[str] = Field(default=None, description="用户ID") title: Optional[str] = Field(default=None, description="会话标题") messages: List[Dict[str, Any]] = Field(default_factory=list, description="消息列表") context: Dict[str, Any] = Field(default_factory=dict, description="会话上下文") created_at: datetime = Field(default_factory=datetime.now, description="创建时间") updated_at: datetime = Field(default_factory=datetime.now, description="更新时间") model_config = ConfigDict() @field_serializer('created_at', 'updated_at', when_used='json') def serialize_datetime(self, value: datetime) -> str: return value.isoformat() class AgentState(BaseModel): """AI智能体状态模型""" session_id: str = Field(description="会话ID") current_task: Optional[str] = Field(default=None, description="当前任务") is_thinking: bool = Field(default=False, description="是否正在思考") last_activity: datetime = Field(default_factory=datetime.now, description="最后活动时间") model_config = ConfigDict() @field_serializer('last_activity', when_used='json') def serialize_datetime(self, value: datetime) -> str: return value.isoformat() class UserMessage(BaseModel): """用户消息模型""" session_id: str = Field(description="会话ID") content: str = Field(description="消息内容") user_id: Optional[str] = Field(default=None, description="用户ID") channel: Optional[str] = Field(default=None, description="消息渠道") source: Optional[str] = Field(default=None, description="消息来源") class ToolResult(BaseModel): """工具执行结果模型""" session_id: str = Field(description="会话ID") call_id: str = Field(description="调用ID") success: bool = Field(description="是否成功") result: Optional[str] = Field(default=None, description="执行结果") error: Optional[str] = Field(default=None, description="错误信息") ================================================ FILE: app/schemas/category.py ================================================ from typing import Dict, Optional from pydantic import BaseModel, ConfigDict class CategoryRule(BaseModel): """ 分类规则详情 """ # 内容类型 genre_ids: Optional[str] = None # 语种 original_language: Optional[str] = None # 国家或地区(电视剧) origin_country: Optional[str] = None # 国家或地区(电影) production_countries: Optional[str] = None # 发行年份 release_year: Optional[str] = None # 允许接收其他动态字段 model_config = ConfigDict(extra='allow') class CategoryConfig(BaseModel): """ 分类策略配置 """ # 电影分类策略 movie: Optional[Dict[str, Optional[CategoryRule]]] = {} # 电视剧分类策略 tv: Optional[Dict[str, Optional[CategoryRule]]] = {} ================================================ FILE: app/schemas/context.py ================================================ from typing import Optional, Dict, List, Union, Any from pydantic import BaseModel, Field class MetaInfo(BaseModel): """ 识别元数据 """ # 是否处理的文件 isfile: Optional[bool] = False # 原字符串 org_string: Optional[str] = None # 原标题 title: Optional[str] = None # 副标题 subtitle: Optional[str] = None # 类型 电影、电视剧 type: Optional[str] = None # 名称 name: Optional[str] = None # 识别的中文名 cn_name: Optional[str] = None # 识别的英文名 en_name: Optional[str] = None # 年份 year: Optional[str] = None # 总季数 total_season: Optional[int] = 0 # 识别的开始季 数字 begin_season: Optional[int] = None # 识别的结束季 数字 end_season: Optional[int] = None # 总集数 total_episode: Optional[int] = 0 # 识别的开始集 begin_episode: Optional[int] = None # 识别的结束集 end_episode: Optional[int] = None # SxxExx season_episode: Optional[str] = None # 集列表 episode_list: Optional[List[int]] = Field(default_factory=list) # Partx Cd Dvd Disk Disc part: Optional[str] = None # 识别的资源类型 resource_type: Optional[str] = None # 识别的效果 resource_effect: Optional[str] = None # 识别的分辨率 resource_pix: Optional[str] = None # 识别的制作组/字幕组 resource_team: Optional[str] = None # 视频编码 video_encode: Optional[str] = None # 音频编码 audio_encode: Optional[str] = None # 资源类型 edition: Optional[str] = None # 流媒体平台 web_source: Optional[str] = None # 应用的识别词信息 apply_words: Optional[List[str]] = None class MediaInfo(BaseModel): """ 识别媒体信息 """ # 来源:themoviedb、douban、bangumi source: Optional[str] = None # 类型 电影、电视剧、合集 type: Optional[str] = None # 媒体标题 title: Optional[str] = None # 英文标题 en_title: Optional[str] = None # 年份 year: Optional[str] = None # 标题(年份) title_year: Optional[str] = None # 当前指定季,如有 season: Optional[int] = None # TMDB ID tmdb_id: Optional[int] = None # IMDB ID imdb_id: Optional[str] = None # TVDB ID tvdb_id: Optional[int] = None # 豆瓣ID douban_id: Optional[str] = None # Bangumi ID bangumi_id: Optional[int] = None # 合集ID collection_id: Optional[int] = None # 其它媒体ID前缀 mediaid_prefix: Optional[str] = None # 其它媒体ID值 media_id: Optional[str] = None # 媒体原语种 original_language: Optional[str] = None # 媒体原发行标题 original_title: Optional[str] = None # 媒体发行日期 release_date: Optional[str] = None # 背景图片 backdrop_path: Optional[str] = None # 海报图片 poster_path: Optional[str] = None # 评分 vote_average: Optional[float] = 0.0 # 描述 overview: Optional[str] = None # 二级分类 category: Optional[str] = "" # 季季集清单 seasons: Optional[Dict[int, list]] = Field(default_factory=dict) # 季详情 season_info: Optional[List[dict]] = Field(default_factory=list) # 别名和译名 names: Optional[list] = Field(default_factory=list) # 演员 actors: Optional[list] = Field(default_factory=list) # 导演 directors: Optional[list] = Field(default_factory=list) # 详情链接 detail_link: Optional[str] = None # 其它TMDB属性 # 是否成人内容 adult: Optional[bool] = False # 创建人 created_by: Optional[list] = Field(default_factory=list) # 集时长 episode_run_time: Optional[list] = Field(default_factory=list) # 风格 genres: Optional[List[dict]] = Field(default_factory=list) # 首播日期 first_air_date: Optional[str] = None # 首页 homepage: Optional[str] = None # 语种 languages: Optional[list] = Field(default_factory=list) # 最后上映日期 last_air_date: Optional[str] = None # 流媒体平台 networks: Optional[list] = Field(default_factory=list) # 集数 number_of_episodes: Optional[int] = 0 # 季数 number_of_seasons: Optional[int] = 0 # 原产国 origin_country: Optional[list] = Field(default_factory=list) # 原名 original_name: Optional[str] = None # 出品公司 production_companies: Optional[list] = Field(default_factory=list) # 出品国 production_countries: Optional[list] = Field(default_factory=list) # 语种 spoken_languages: Optional[list] = Field(default_factory=list) # 所有发行日期 release_dates: list = Field(default_factory=list) # 状态 status: Optional[str] = None # 标签 tagline: Optional[str] = None # 风格ID genre_ids: Optional[list] = Field(default_factory=list) # 评价数量 vote_count: Optional[int] = 0 # 流行度 popularity: Optional[float] = 0.0 # 时长 runtime: Optional[int] = None # 下一集 next_episode_to_air: Optional[dict] = Field(default_factory=dict) # 全部剧集组 episode_groups: Optional[list] = Field(default_factory=list) # 剧集组 episode_group: Optional[str] = None class TorrentInfo(BaseModel): """ 搜索种子信息 """ # 站点ID site: Optional[int] = None # 站点名称 site_name: Optional[str] = None # 站点Cookie site_cookie: Optional[str] = None # 站点UA site_ua: Optional[str] = None # 站点是否使用代理 site_proxy: Optional[bool] = False # 站点优先级 site_order: Optional[int] = 0 # 站点下载器 site_downloader: Optional[str] = None # 种子名称 title: Optional[str] = None # 种子副标题 description: Optional[str] = None # IMDB ID imdbid: Optional[str] = None # 种子链接 enclosure: Optional[str] = None # 详情页面 page_url: Optional[str] = None # 种子大小 size: Optional[float] = 0.0 # 做种者 seeders: Optional[int] = 0 # 下载者 peers: Optional[int] = 0 # 完成者 grabs: Optional[int] = 0 # 发布时间 pubdate: Optional[str] = None # 已过时间 date_elapsed: Optional[str] = None # 免费截止时间 freedate: Optional[str] = None # 上传因子 uploadvolumefactor: Optional[float] = None # 下载因子 downloadvolumefactor: Optional[float] = None # HR hit_and_run: Optional[bool] = False # 种子标签 labels: Optional[list] = Field(default_factory=list) # 种子优先级 pri_order: Optional[int] = 0 # 促销 volume_factor: Optional[str] = None # 剩余免费时间 freedate_diff: Optional[str] = None class Context(BaseModel): """ 上下文 """ # 元数据 meta_info: Optional[Union[MetaInfo, Any]] = None # 媒体信息 media_info: Optional[Union[MediaInfo, Any]] = None # 种子信息 torrent_info: Optional[TorrentInfo] = None class MediaSeason(BaseModel): """ 季信息 """ air_date: Optional[str] = None episode_count: Optional[int] = None name: Optional[str] = None overview: Optional[str] = None poster_path: Optional[str] = None season_number: Optional[int] = None vote_average: Optional[float] = None class MediaPerson(BaseModel): """ 媒体人物信息 """ # 来源:themoviedb、douban、bangumi source: Optional[str] = None # 公共 id: Optional[int] = None type: Optional[Union[str, int]] = 1 name: Optional[str] = None character: Optional[str] = None images: Optional[dict] = Field(default_factory=dict) # themoviedb profile_path: Optional[str] = None gender: Optional[Union[str, int]] = None original_name: Optional[str] = None credit_id: Optional[str] = None also_known_as: Optional[list] = Field(default_factory=list) birthday: Optional[str] = None deathday: Optional[str] = None imdb_id: Optional[str] = None known_for_department: Optional[str] = None place_of_birth: Optional[str] = None popularity: Optional[float] = None biography: Optional[str] = None # douban roles: Optional[list] = Field(default_factory=list) title: Optional[str] = None url: Optional[str] = None avatar: Optional[Union[str, dict]] = None latin_name: Optional[str] = None # bangumi career: Optional[list] = Field(default_factory=list) relation: Optional[str] = None ================================================ FILE: app/schemas/dashboard.py ================================================ from typing import Optional from pydantic import BaseModel class Statistic(BaseModel): # 电影 movie_count: Optional[int] = 0 # 电视剧数量 tv_count: Optional[int] = 0 # 集数量 episode_count: Optional[int] = 0 # 用户数量 user_count: Optional[int] = 0 class Storage(BaseModel): # 总存储空间 total_storage: Optional[float] = 0.0 # 已使用空间 used_storage: Optional[float] = 0.0 class ProcessInfo(BaseModel): # 进程ID pid: Optional[int] = 0 # 进程名称 name: Optional[str] = None # 进程状态 status: Optional[str] = None # 进程占用CPU cpu: Optional[float] = 0.0 # 进程占用内存 MB memory: Optional[float] = 0.0 # 进程创建时间 create_time: Optional[float] = 0.0 # 进程运行时间 秒 run_time: Optional[float] = 0.0 class DownloaderInfo(BaseModel): # 下载速度 download_speed: Optional[float] = 0.0 # 上传速度 upload_speed: Optional[float] = 0.0 # 下载量 download_size: Optional[float] = 0.0 # 上传量 upload_size: Optional[float] = 0.0 # 剩余空间 free_space: Optional[float] = 0.0 class ScheduleInfo(BaseModel): # ID id: Optional[str] = None # 名称 name: Optional[str] = None # 提供者 provider: Optional[str] = None # 状态 status: Optional[str] = None # 下次执行时间 next_run: Optional[str] = None ================================================ FILE: app/schemas/download.py ================================================ from typing import Optional from pydantic import BaseModel, Field class DownloadTask(BaseModel): """ 下载任务 """ download_id: Optional[str] = Field(default=None, description="任务ID") downloader: Optional[str] = Field(default=None, description="下载器") path: Optional[str] = Field(default=None, description="下载路径") completed: Optional[bool] = Field(default=False, description="是否完成") ================================================ FILE: app/schemas/event.py ================================================ from pathlib import Path from typing import Iterable, Optional, Dict, Any, List, Set, Callable from pydantic import BaseModel, Field, field_validator, model_validator from app.schemas.message import MessageChannel from app.schemas.file import FileItem class Event(BaseModel): """ 事件模型 """ event_type: str = Field(..., description="事件类型") event_data: Optional[dict] = Field(default={}, description="事件数据") priority: Optional[int] = Field(0, description="事件优先级") class BaseEventData(BaseModel): """ 事件数据的基类,所有具体事件数据类应继承自此类 """ pass class ConfigChangeEventData(BaseEventData): """ ConfigChange 事件的数据模型 """ key: set[str] = Field(..., description="配置项的键(集合类型)") value: Optional[Any] = Field(default=None, description="配置项的新值") change_type: str = Field(default="update", description="配置项的变更类型,如 'add', 'update', 'delete'") @field_validator('key', mode='before') @classmethod def convert_to_set(cls, v): """将输入的 str、list、dict.keys() 等转为 set""" if v is None: return set() elif isinstance(v, str): return {v} elif isinstance(v, dict): return set(str(k) for k in v.keys()) elif isinstance(v, (list, tuple)): return set(str(item) for item in v) elif isinstance(v, set): return set(str(item) for item in v) elif isinstance(v, Iterable): return set(str(item) for item in v) else: return {str(v)} class ChainEventData(BaseEventData): """ 链式事件数据的基类,所有具体事件数据类应继承自此类 """ pass class AuthCredentials(ChainEventData): """ AuthVerification 事件的数据模型 Attributes: username (Optional[str]): 用户名,适用于 "password" grant_type password (Optional[str]): 用户密码,适用于 "password" grant_type mfa_code (Optional[str]): 一次性密码,目前仅适用于 "password" 认证类型 code (Optional[str]): 授权码,适用于 "authorization_code" grant_type grant_type (str): 认证类型,如 "password", "authorization_code", "client_credentials" # scope (List[str]): 权限范围,如 ["read", "write"] token (Optional[str]): 认证令牌 channel (Optional[str]): 认证渠道 service (Optional[str]): 服务名称 """ # 输入参数 username: Optional[str] = Field(None, description="用户名,适用于 'password' 认证类型") password: Optional[str] = Field(None, description="用户密码,适用于 'password' 认证类型") mfa_code: Optional[str] = Field(None, description="一次性密码,目前仅适用于 'password' 认证类型") code: Optional[str] = Field(None, description="授权码,适用于 'authorization_code' 认证类型") grant_type: str = Field(..., description="认证类型,如 'password', 'authorization_code', 'client_credentials'") # scope: List[str] = Field(default_factory=list, description="权限范围,如 ['read', 'write']") # 输出参数 # grant_type 为 authorization_code 时,输出参数包括 username、token、channel、service token: Optional[str] = Field(default=None, description="认证令牌") channel: Optional[str] = Field(default=None, description="认证渠道") service: Optional[str] = Field(default=None, description="服务名称") @model_validator(mode='before') @classmethod def check_fields_based_on_grant_type(cls, values): # noqa grant_type = values.get("grant_type") if not grant_type: values["grant_type"] = "password" grant_type = "password" if grant_type == "password": if not values.get("username") or not values.get("password"): raise ValueError("username and password are required for grant_type 'password'") elif grant_type == "authorization_code": if not values.get("code"): raise ValueError("code is required for grant_type 'authorization_code'") return values class AuthInterceptCredentials(ChainEventData): """ AuthIntercept 事件的数据模型 Attributes: # 输入参数 username (str): 用户名 channel (str): 认证渠道 service (str): 服务名称 token (str): 认证令牌 status (str): 认证状态,"triggered" 和 "completed" 两个状态 # 输出参数 source (str): 拦截源,默认值为 "未知拦截源" cancel (bool): 是否取消认证,默认值为 False """ # 输入参数 username: Optional[str] = Field(..., description="用户名") channel: str = Field(..., description="认证渠道") service: str = Field(..., description="服务名称") status: str = Field(..., description="认证状态, 包含 'triggered' 表示认证触发,'completed' 表示认证成功") token: Optional[str] = Field(default=None, description="认证令牌") # 输出参数 source: str = Field(default="未知拦截源", description="拦截源") cancel: bool = Field(default=False, description="是否取消认证") class CommandRegisterEventData(ChainEventData): """ CommandRegister 事件的数据模型 Attributes: # 输入参数 commands (dict): 菜单命令 origin (str): 事件源,可以是 Chain 或具体的模块名称 service (str): 服务名称 # 输出参数 source (str): 拦截源,默认值为 "未知拦截源" cancel (bool): 是否取消认证,默认值为 False """ # 输入参数 commands: Dict[str, dict] = Field(..., description="菜单命令") origin: str = Field(..., description="事件源") service: Optional[str] = Field(..., description="服务名称") # 输出参数 cancel: bool = Field(default=False, description="是否取消注册") source: str = Field(default="未知拦截源", description="拦截源") class TransferRenameEventData(ChainEventData): """ TransferRename 事件的数据模型 Attributes: # 输入参数 template_string (str): Jinja2 模板字符串 rename_dict (dict): 渲染上下文 render_str (str): 渲染生成的字符串 path (Optional[Path]): 当前文件的目标路径 # 输出参数 updated (bool): 是否已更新,默认值为 False updated_str (str): 更新后的字符串 source (str): 拦截源,默认值为 "未知拦截源" """ # 输入参数 template_string: str = Field(..., description="模板字符串") rename_dict: Dict[str, Any] = Field(..., description="渲染上下文") path: Optional[Path] = Field(None, description="文件的目标路径") render_str: str = Field(..., description="渲染生成的字符串") # 输出参数 updated: bool = Field(default=False, description="是否已更新") updated_str: Optional[str] = Field(default=None, description="更新后的字符串") source: Optional[str] = Field(default="未知拦截源", description="拦截源") class ResourceSelectionEventData(BaseModel): """ ResourceSelection 事件的数据模型 Attributes: # 输入参数 contexts (List[Context]): 当前待选择的资源上下文列表 source (str): 事件源,指示事件的触发来源 # 输出参数 updated (bool): 是否已更新,默认值为 False updated_contexts (Optional[List[Context]]): 已更新的资源上下文列表,默认值为 None source (str): 更新源,默认值为 "未知更新源" """ # 输入参数 contexts: Any = Field(None, description="待选择的资源上下文列表") downloader: Optional[str] = Field(None, description="下载器") origin: Optional[str] = Field(None, description="来源") # 输出参数 updated: bool = Field(default=False, description="是否已更新") updated_contexts: Optional[List[Any]] = Field(default=None, description="已更新的资源上下文列表") source: Optional[str] = Field(default="未知拦截源", description="拦截源") class ResourceDownloadEventData(ChainEventData): """ ResourceDownload 事件的数据模型 Attributes: # 输入参数 context (Context): 当前资源上下文 episodes (Set[int]): 需要下载的集数 channel (MessageChannel): 通知渠道 origin (str): 来源(消息通知、Subscribe、Manual等) downloader (str): 下载器 options (dict): 其他参数 # 输出参数 cancel (bool): 是否取消下载,默认值为 False source (str): 拦截源,默认值为 "未知拦截源" reason (str): 拦截原因,描述拦截的具体原因 """ # 输入参数 context: Any = Field(None, description="当前资源上下文") episodes: Optional[Set[int]] = Field(None, description="需要下载的集数") channel: Optional[MessageChannel] = Field(None, description="通知渠道") origin: Optional[str] = Field(None, description="来源") downloader: Optional[str] = Field(None, description="下载器") options: Optional[dict] = Field(default={}, description="其他参数") # 输出参数 cancel: bool = Field(default=False, description="是否取消下载") source: str = Field(default="未知拦截源", description="拦截源") reason: str = Field(default="", description="拦截原因") class TransferInterceptEventData(ChainEventData): """ TransferIntercept 事件的数据模型 Attributes: # 输入参数 fileitem (FileItem): 源文件 target_storage (str): 目标存储 target_path (Path): 目标路径 transfer_type (str): 整理方式(copy、move、link、softlink等) options (dict): 其他参数 # 输出参数 cancel (bool): 是否取消下载,默认值为 False source (str): 拦截源,默认值为 "未知拦截源" reason (str): 拦截原因,描述拦截的具体原因 """ # 输入参数 fileitem: FileItem = Field(..., description="源文件") mediainfo: Any = Field(..., description="媒体信息") target_storage: str = Field(..., description="目标存储") target_path: Path = Field(..., description="目标路径") transfer_type: str = Field(..., description="整理方式") options: Optional[dict] = Field(default=None, description="其他参数") # 输出参数 cancel: bool = Field(default=False, description="是否取消整理") source: str = Field(default="未知拦截源", description="拦截源") reason: str = Field(default="", description="拦截原因") class DiscoverMediaSource(BaseModel): """ 探索媒体数据源的基类 """ name: str = Field(..., description="数据源名称") mediaid_prefix: str = Field(..., description="媒体ID的前缀,不含:") api_path: str = Field(..., description="媒体数据源API地址") filter_params: Optional[Dict[str, Any]] = Field(default=None, description="过滤参数") filter_ui: Optional[List[dict]] = Field(default=[], description="过滤参数UI配置") depends: Optional[Dict[str, list]] = Field(default=None, description="UI依赖关系字典") class DiscoverSourceEventData(ChainEventData): """ DiscoverSource 事件的数据模型 Attributes: # 输出参数 extra_sources (List[DiscoverMediaSource]): 额外媒体数据源 """ # 输出参数 extra_sources: List[DiscoverMediaSource] = Field(default_factory=list, description="额外媒体数据源") class RecommendMediaSource(BaseModel): """ 推荐媒体数据源的基类 """ name: str = Field(..., description="数据源名称") api_path: str = Field(..., description="媒体数据源API地址") type: str = Field(..., description="类型") class RecommendSourceEventData(ChainEventData): """ RecommendSource 事件的数据模型 Attributes: # 输出参数 extra_sources (List[RecommendMediaSource]): 额外媒体数据源 """ # 输出参数 extra_sources: List[RecommendMediaSource] = Field(default_factory=list, description="额外媒体数据源") class MediaRecognizeConvertEventData(ChainEventData): """ MediaRecognizeConvert 事件的数据模型 Attributes: # 输入参数 mediaid (str): 媒体ID,格式为`前缀:ID值`,如 tmdb:12345、douban:1234567 convert_type (str): 转换类型 仅支持:themoviedb/douban,需要转换为对应的媒体数据并返回 # 输出参数 media_dict (dict): TheMovieDb/豆瓣的媒体数据 """ # 输入参数 mediaid: str = Field(..., description="媒体ID") convert_type: str = Field(..., description="转换类型(themoviedb/douban)") # 输出参数 media_dict: dict = Field(default_factory=dict, description="转换后的媒体信息(TheMovieDb/豆瓣)") class StorageOperSelectionEventData(ChainEventData): """ StorageOperSelect 事件的数据模型 Attributes: # 输入参数 storage (str): 存储类型 # 输出参数 storage_oper (Callable): 存储操作对象 """ # 输入参数 storage: Optional[str] = Field(default=None, description="存储类型") # 输出参数 storage_oper: Optional[Callable] = Field(default=None, description="存储操作对象") ================================================ FILE: app/schemas/exception.py ================================================ class ImmediateException(Exception): """ 用于立即抛出异常而不重试的特殊异常类。 当不希望使用重试机制时,可以抛出此异常。 """ pass class LimitException(ImmediateException): """ 用于表示本地限流器或外部触发的限流异常的基类。 该异常类可用于本地限流逻辑或外部限流处理。 """ pass class APIRateLimitException(LimitException): """ 用于表示API速率限制的异常类。 当API调用触发速率限制时,可以抛出此异常以立即终止操作并报告错误。 """ pass class RateLimitExceededException(LimitException): """ 用于表示本地限流器触发的异常类。 当函数调用频率超过限流器的限制时,可以抛出此异常以停止当前操作并告知调用者限流情况。 这个异常通常用于本地限流逻辑(例如 RateLimiter),当系统检测到函数调用频率过高时,触发限流并抛出该异常。 """ pass class OperationInterrupted(KeyboardInterrupt): """ 用于表示操作被中断 """ pass ================================================ FILE: app/schemas/file.py ================================================ from typing import Optional from pathlib import Path from pydantic import BaseModel, Field from app.schemas.types import StorageSchema class FileURI(BaseModel): # 文件路径 path: Optional[str] = "/" # 存储类型 storage: Optional[str] = Field(default="local") @property def uri(self) -> str: return self.path if self.storage == "local" else f"{self.storage}:{self.path}" @classmethod def from_uri(cls, uri: str) -> "FileURI": storage, path = 'local', uri for s in StorageSchema: protocol = f"{s.value}:" if uri.startswith(protocol): path = uri[len(protocol):] storage = s.value break if not path.startswith("/"): path = "/" + path path = Path(path).as_posix() return cls(storage=storage, path=path) class FileItem(FileURI): # 类型 dir/file type: Optional[str] = None # 文件名 name: Optional[str] = None # 文件名 basename: Optional[str] = None # 文件后缀 extension: Optional[str] = None # 文件大小 size: Optional[int] = None # 修改时间 modify_time: Optional[float] = None # 子节点 children: Optional[list] = Field(default_factory=list) # ID fileid: Optional[str] = None # 父ID parent_fileid: Optional[str] = None # 缩略图 thumbnail: Optional[str] = None # 115 pickcode pickcode: Optional[str] = None # drive_id drive_id: Optional[str] = None # url url: Optional[str] = None class StorageUsage(BaseModel): # 总空间 total: float = 0.0 # 剩余空间 available: float = 0.0 class StorageTransType(BaseModel): # 传输类型 transtype: Optional[dict] = Field(default_factory=dict) ================================================ FILE: app/schemas/history.py ================================================ from typing import Optional, Any from pydantic import BaseModel, ConfigDict class DownloadHistory(BaseModel): # ID id: int # 保存路程 path: Optional[str] = None # 类型:电影、电视剧 type: Optional[str] = None # 标题 title: Optional[str] = None # 年份 year: Optional[str] = None # TMDBID tmdbid: Optional[int] = None # IMDBID imdbid: Optional[str] = None # TVDBID tvdbid: Optional[int] = None # 豆瓣ID doubanid: Optional[str] = None # 季Sxx seasons: Optional[str] = None # 集Exx episodes: Optional[str] = None # 海报 image: Optional[str] = None # 下载器Hash download_hash: Optional[str] = None # 种子名称 torrent_name: Optional[str] = None # 种子描述 torrent_description: Optional[str] = None # 站点 torrent_site: Optional[str] = None # 下载用户 userid: Optional[str] = None # 下载用户名 username: Optional[str] = None # 下载渠道 channel: Optional[str] = None # 创建时间 date: Optional[str] = None # 备注 note: Optional[Any] = None # 自定义媒体类别 media_category: Optional[str] = None # 自定义剧集组 episode_group: Optional[str] = None model_config = ConfigDict(from_attributes=True) class TransferHistory(BaseModel): # ID id: int # 源目录 src: Optional[str] = None # 目的目录 dest: Optional[str] = None # 转移模式 mode: Optional[str] = None # 类型:电影、电视剧 type: Optional[str] = None # 二级分类 category: Optional[str] = None # 标题 title: Optional[str] = None # 年份 year: Optional[str] = None # TMDBID tmdbid: Optional[int] = None # IMDBID imdbid: Optional[str] = None # TVDBID tvdbid: Optional[int] = None # 豆瓣ID doubanid: Optional[str] = None # 季Sxx seasons: Optional[str] = None # 集Exx episodes: Optional[str] = None # 海报 image: Optional[str] = None # 下载器Hash download_hash: Optional[str] = None # 自定义剧集组 episode_group: Optional[str] = None # 状态 1-成功,0-失败 status: bool = True # 失败原因 errmsg: Optional[str] = None # 日期 date: Optional[str] = None model_config = ConfigDict(from_attributes=True) ================================================ FILE: app/schemas/mcp.py ================================================ from typing import Any, Dict, Optional from pydantic import BaseModel, Field class ToolCallRequest(BaseModel): """工具调用请求模型""" tool_name: str = Field(..., description="工具名称") arguments: Dict[str, Any] = Field(default_factory=dict, description="工具参数") class ToolCallResponse(BaseModel): """工具调用响应模型""" success: bool = Field(..., description="是否成功") result: Optional[str] = Field(None, description="工具执行结果") error: Optional[str] = Field(None, description="错误信息") ================================================ FILE: app/schemas/mediaserver.py ================================================ from pathlib import Path from typing import Optional, Dict, Union, List, Any from pydantic import BaseModel, Field, ConfigDict from app.schemas.types import MediaType class ExistMediaInfo(BaseModel): """ 媒体服务器存在媒体信息 """ # 类型 电影、电视剧 type: Optional[MediaType] = None # 季 seasons: Optional[Dict[int, list]] = Field(default_factory=dict) # 媒体服务器类型:plex、jellyfin、emby、trimemedia、ugreen server_type: Optional[str] = None # 媒体服务器名称 server: Optional[str] = None # 媒体ID itemid: Optional[Union[str, int]] = None class NotExistMediaInfo(BaseModel): """ 媒体服务器不存在媒体信息 """ # 季 season: Optional[int] = None # 剧集列表 episodes: Optional[list] = Field(default_factory=list) # 总集数 total_episode: Optional[int] = 0 # 开始集 start_episode: Optional[int] = 0 class RefreshMediaItem(BaseModel): """ 媒体库刷新信息 """ # 标题 title: Optional[str] = None # 年份 year: Optional[Union[str, int]] = None # 类型 type: Optional[MediaType] = None # 类别 category: Optional[str] = None # 目录 target_path: Optional[Path] = None class MediaServerLibrary(BaseModel): """ 媒体服务器媒体库信息 """ # 服务器 server: Optional[str] = None # ID id: Optional[Union[str, int]] = None # 名称 name: Optional[str] = None # 路径 path: Optional[Union[str, list]] = None # 类型 type: Optional[str] = None # 封面图 image: Optional[str] = None # 封面图列表 image_list: Optional[List[str]] = None # 跳转链接 link: Optional[str] = None # 服务器类型 server_type: Optional[str] = None # 飞牛的图片需要Cookies use_cookies: Optional[bool] = None class MediaServerItemUserState(BaseModel): # 已播放 played: Optional[bool] = None # 继续播放 resume: Optional[bool] = None # 上次播放时间 10位时间戳 last_played_date: Optional[str] = None # 播放次数(不等于完播次数,理解为浏览次数) play_count: Optional[int] = None # 播放进度 percentage: Optional[float] = None class MediaServerItem(BaseModel): """ 媒体服务器媒体信息 """ # ID id: Optional[Union[str, int]] = None # 服务器 server: Optional[str] = None # 媒体库ID library: Optional[Union[str, int]] = None # ID item_id: Optional[str] = None # 类型 item_type: Optional[str] = None # 标题 title: Optional[str] = None # 原标题 original_title: Optional[str] = None # 年份 year: Optional[Union[str, int]] = None # TMDBID tmdbid: Optional[int] = None # IMDBID imdbid: Optional[str] = None # TVDBID tvdbid: Optional[str] = None # 路径 path: Optional[str] = None # 季集 seasoninfo: Optional[Dict[int, list]] = None # 备注 note: Optional[Any] = None # 同步时间 lst_mod_date: Optional[str] = None user_state: Optional[MediaServerItemUserState] = None model_config = ConfigDict(from_attributes=True) class MediaServerSeasonInfo(BaseModel): """ 媒体服务器媒体剧集信息 """ season: Optional[int] = None episodes: Optional[List[int]] = Field(default_factory=list) class WebhookEventInfo(BaseModel): """ Webhook事件信息 """ event: Optional[str] = None channel: Optional[str] = None server_name: Optional[str] = None item_type: Optional[str] = None item_name: Optional[str] = None item_id: Optional[str] = None item_path: Optional[str] = None season_id: Optional[str] = None episode_id: Optional[str] = None tmdb_id: Optional[str] = None overview: Optional[str] = None percentage: Optional[float] = None ip: Optional[str] = None device_name: Optional[str] = None client: Optional[str] = None user_name: Optional[str] = None image_url: Optional[str] = None item_favorite: Optional[bool] = None save_reason: Optional[str] = None item_isvirtual: Optional[bool] = None media_type: Optional[str] = None json_object: Optional[dict] = Field(default_factory=dict) class MediaServerPlayItem(BaseModel): """ 媒体服务器可播放项目信息 """ id: Optional[Union[str, int]] = None title: Optional[str] = None subtitle: Optional[str] = None type: Optional[str] = None image: Optional[str] = None link: Optional[str] = None percent: Optional[float] = None BackdropImageTags: Optional[list] = Field(default_factory=list) server_type: Optional[str] = None # 飞牛的图片需要Cookies use_cookies: Optional[bool] = None ================================================ FILE: app/schemas/message.py ================================================ from dataclasses import dataclass from enum import Enum from typing import Optional, Union, List, Dict, Set from pydantic import BaseModel, Field from app.schemas.types import ContentType, NotificationType, MessageChannel class CommingMessage(BaseModel): """ 外来消息 """ # 用户ID userid: Optional[Union[str, int]] = None # 用户名称 username: Optional[Union[str, int]] = None # 消息渠道 channel: Optional[MessageChannel] = None # 来源(渠道名称) source: Optional[str] = None # 消息体 text: Optional[str] = None # 时间 date: Optional[str] = None # 消息方向 action: Optional[int] = 0 # 是否为回调消息 is_callback: Optional[bool] = False # 回调数据 callback_data: Optional[str] = None # 消息ID(用于回调时定位原消息) message_id: Optional[Union[str, int]] = None # 聊天ID(用于回调时定位聊天) chat_id: Optional[str] = None # 完整的回调查询信息(原始数据) callback_query: Optional[Dict] = None def to_dict(self): """ 转换为字典 """ items = self.model_dump() for k, v in items.items(): if isinstance(v, MessageChannel): items[k] = v.value return items class Notification(BaseModel): """ 消息 """ # 消息渠道 channel: Optional[MessageChannel] = None # 消息来源 source: Optional[str] = None # 消息类型 mtype: Optional[NotificationType] = None # 内容类型 ctype: Optional[ContentType] = None # 标题 title: Optional[str] = None # 文本内容 text: Optional[str] = None # 图片 image: Optional[str] = None # 链接 link: Optional[str] = None # 用户ID userid: Optional[Union[str, int]] = None # 用户名称 username: Optional[Union[str, int]] = None # 时间 date: Optional[str] = None # 消息方向 action: Optional[int] = 1 # 消息目标用户ID字典,未指定用户ID时使用 targets: Optional[dict] = None # 按钮列表,格式:[[{"text": "按钮文本", "callback_data": "回调数据", "url": "链接"}]] buttons: Optional[List[List[dict]]] = None # 原消息ID,用于编辑消息 original_message_id: Optional[Union[str, int]] = None # 原消息的聊天ID,用于编辑消息 original_chat_id: Optional[str] = None def to_dict(self): """ 转换为字典 """ items = self.model_dump() for k, v in items.items(): if isinstance(v, MessageChannel) \ or isinstance(v, NotificationType): items[k] = v.value return items class NotificationSwitch(BaseModel): """ 消息开关 """ # 消息类型 mtype: Optional[str] = None # 微信开关 wechat: Optional[bool] = False # TG开关 telegram: Optional[bool] = False # Slack开关 slack: Optional[bool] = False # SynologyChat开关 synologychat: Optional[bool] = False # VoceChat开关 vocechat: Optional[bool] = False # WebPush开关 webpush: Optional[bool] = False # QQ开关 qq: Optional[bool] = False class Subscription(BaseModel): """ 客户端消息订阅 """ endpoint: Optional[str] = None keys: Optional[dict] = Field(default_factory=dict) class SubscriptionMessage(BaseModel): """ 客户端订阅消息体 """ title: Optional[str] = None body: Optional[str] = None icon: Optional[str] = None url: Optional[str] = None data: Optional[dict] = Field(default_factory=dict) class ChannelCapability(Enum): """ 渠道能力枚举 """ # 支持内联按钮 INLINE_BUTTONS = "inline_buttons" # 支持菜单命令 MENU_COMMANDS = "menu_commands" # 支持消息编辑 MESSAGE_EDITING = "message_editing" # 支持消息删除 MESSAGE_DELETION = "message_deletion" # 支持回调查询 CALLBACK_QUERIES = "callback_queries" # 支持富文本 RICH_TEXT = "rich_text" # 支持图片 IMAGES = "images" # 支持链接 LINKS = "links" # 支持文件发送 FILE_SENDING = "file_sending" @dataclass class ChannelCapabilities: """ 渠道能力配置 """ channel: MessageChannel capabilities: Set[ChannelCapability] max_buttons_per_row: int = 5 max_button_rows: int = 10 max_button_text_length: int = 30 fallback_enabled: bool = True class ChannelCapabilityManager: """ 渠道能力管理器 """ _capabilities: Dict[MessageChannel, ChannelCapabilities] = { MessageChannel.Telegram: ChannelCapabilities( channel=MessageChannel.Telegram, capabilities={ ChannelCapability.INLINE_BUTTONS, ChannelCapability.MENU_COMMANDS, ChannelCapability.MESSAGE_EDITING, ChannelCapability.MESSAGE_DELETION, ChannelCapability.CALLBACK_QUERIES, ChannelCapability.RICH_TEXT, ChannelCapability.IMAGES, ChannelCapability.LINKS, ChannelCapability.FILE_SENDING }, max_buttons_per_row=4, max_button_rows=10, max_button_text_length=30 ), MessageChannel.Wechat: ChannelCapabilities( channel=MessageChannel.Wechat, capabilities={ ChannelCapability.IMAGES, ChannelCapability.LINKS, ChannelCapability.MENU_COMMANDS }, fallback_enabled=True ), MessageChannel.Slack: ChannelCapabilities( channel=MessageChannel.Slack, capabilities={ ChannelCapability.INLINE_BUTTONS, ChannelCapability.MESSAGE_EDITING, ChannelCapability.MESSAGE_DELETION, ChannelCapability.CALLBACK_QUERIES, ChannelCapability.RICH_TEXT, ChannelCapability.IMAGES, ChannelCapability.LINKS, ChannelCapability.MENU_COMMANDS }, max_buttons_per_row=3, max_button_rows=8, max_button_text_length=25, fallback_enabled=True ), MessageChannel.Discord: ChannelCapabilities( channel=MessageChannel.Discord, capabilities={ ChannelCapability.INLINE_BUTTONS, ChannelCapability.MESSAGE_EDITING, ChannelCapability.MESSAGE_DELETION, ChannelCapability.CALLBACK_QUERIES, ChannelCapability.RICH_TEXT, ChannelCapability.IMAGES, ChannelCapability.LINKS }, max_buttons_per_row=5, max_button_rows=5, max_button_text_length=80, fallback_enabled=True ), MessageChannel.SynologyChat: ChannelCapabilities( channel=MessageChannel.SynologyChat, capabilities={ ChannelCapability.RICH_TEXT, ChannelCapability.IMAGES, ChannelCapability.LINKS }, fallback_enabled=True ), MessageChannel.VoceChat: ChannelCapabilities( channel=MessageChannel.VoceChat, capabilities={ ChannelCapability.RICH_TEXT, ChannelCapability.IMAGES, ChannelCapability.LINKS }, fallback_enabled=True ), MessageChannel.WebPush: ChannelCapabilities( channel=MessageChannel.WebPush, capabilities={ ChannelCapability.LINKS }, fallback_enabled=True ), MessageChannel.Web: ChannelCapabilities( channel=MessageChannel.Web, capabilities={ ChannelCapability.RICH_TEXT, ChannelCapability.IMAGES, ChannelCapability.LINKS }, fallback_enabled=True ), MessageChannel.QQ: ChannelCapabilities( channel=MessageChannel.QQ, capabilities={ ChannelCapability.RICH_TEXT, ChannelCapability.IMAGES, ChannelCapability.LINKS }, fallback_enabled=True ) } @classmethod def get_capabilities(cls, channel: MessageChannel) -> Optional[ChannelCapabilities]: """ 获取渠道能力 """ return cls._capabilities.get(channel) @classmethod def supports_capability(cls, channel: MessageChannel, capability: ChannelCapability) -> bool: """ 检查渠道是否支持某项能力 """ channel_caps = cls.get_capabilities(channel) if not channel_caps: return False return capability in channel_caps.capabilities @classmethod def supports_buttons(cls, channel: MessageChannel) -> bool: """ 检查渠道是否支持按钮 """ return cls.supports_capability(channel, ChannelCapability.INLINE_BUTTONS) @classmethod def supports_callbacks(cls, channel: MessageChannel) -> bool: """ 检查渠道是否支持回调 """ return cls.supports_capability(channel, ChannelCapability.CALLBACK_QUERIES) @classmethod def supports_editing(cls, channel: MessageChannel) -> bool: """ 检查渠道是否支持消息编辑 """ return cls.supports_capability(channel, ChannelCapability.MESSAGE_EDITING) @classmethod def supports_deletion(cls, channel: MessageChannel) -> bool: """ 检查渠道是否支持消息删除 """ return cls.supports_capability(channel, ChannelCapability.MESSAGE_DELETION) @classmethod def get_max_buttons_per_row(cls, channel: MessageChannel) -> int: """ 获取每行最大按钮数 """ channel_caps = cls.get_capabilities(channel) return channel_caps.max_buttons_per_row if channel_caps else 2 @classmethod def get_max_button_rows(cls, channel: MessageChannel) -> int: """ 获取最大按钮行数 """ channel_caps = cls.get_capabilities(channel) return channel_caps.max_button_rows if channel_caps else 5 @classmethod def get_max_button_text_length(cls, channel: MessageChannel) -> int: """ 获取按钮文本最大长度 """ channel_caps = cls.get_capabilities(channel) return channel_caps.max_button_text_length if channel_caps else 20 @classmethod def should_use_fallback(cls, channel: MessageChannel) -> bool: """ 是否应该使用降级策略 """ channel_caps = cls.get_capabilities(channel) return channel_caps.fallback_enabled if channel_caps else True ================================================ FILE: app/schemas/monitoring.py ================================================ from datetime import datetime from typing import List from pydantic import BaseModel class RequestMetrics(BaseModel): """ 请求指标模型 """ path: str method: str status_code: int response_time: float timestamp: datetime client_ip: str user_agent: str class PerformanceSnapshot(BaseModel): """ 性能快照模型 """ timestamp: datetime cpu_usage: float memory_usage: float active_requests: int request_rate: float avg_response_time: float error_rate: float slow_requests: int class EndpointStats(BaseModel): """ 端点统计模型 """ endpoint: str count: int total_time: float errors: int avg_time: float class ErrorRequest(BaseModel): """ 错误请求模型 """ timestamp: str method: str path: str status_code: int response_time: float client_ip: str class MonitoringOverview(BaseModel): """ 监控概览模型 """ performance: PerformanceSnapshot top_endpoints: List[EndpointStats] recent_errors: List[ErrorRequest] alerts: List[str] class MonitoringConfig(BaseModel): """ 监控配置模型 """ slow_request_threshold: float = 1.0 error_threshold: float = 0.05 cpu_threshold: float = 80.0 memory_threshold: float = 80.0 max_history: int = 1000 window_size: int = 60 ================================================ FILE: app/schemas/plugin.py ================================================ from typing import Optional, List, Dict, Any from pydantic import BaseModel, Field class Plugin(BaseModel): """ 插件信息 """ id: str = None # 插件名称 plugin_name: Optional[str] = None # 插件描述 plugin_desc: Optional[str] = None # 插件图标 plugin_icon: Optional[str] = None # 插件版本 plugin_version: Optional[str] = None # 插件标签 plugin_label: Optional[str] = None # 插件作者 plugin_author: Optional[str] = None # 作者主页 author_url: Optional[str] = None # 插件配置项ID前缀 plugin_config_prefix: Optional[str] = None # 加载顺序 plugin_order: Optional[int] = 0 # 可使用的用户级别 auth_level: Optional[int] = 0 # 是否已安装 installed: Optional[bool] = False # 运行状态 state: Optional[bool] = False # 是否有详情页面 has_page: Optional[bool] = False # 是否有新版本 has_update: Optional[bool] = False # 是否本地 is_local: Optional[bool] = False # 仓库地址 repo_url: Optional[str] = None # 安装次数 install_count: Optional[int] = 0 # 更新记录 history: Optional[dict] = Field(default_factory=dict) # 添加时间,值越小表示越靠后发布 add_time: Optional[int] = 0 # 插件公钥 plugin_public_key: Optional[str] = None class PluginDashboard(Plugin): """ 插件仪表盘 """ id: Optional[str] = None # 名称 name: Optional[str] = None # 仪表板key key: Optional[str] = None # 演染模式 render_mode: Optional[str] = Field(default="vuetify") # 全局配置 attrs: Optional[dict] = Field(default_factory=dict) # col列数 cols: Optional[dict] = Field(default_factory=dict) # 页面元素 elements: Optional[List[dict]] = Field(default_factory=list) class PluginMemoryInfo(BaseModel): """插件内存信息""" plugin_id: str = Field(description="插件ID") plugin_name: str = Field(description="插件名称") plugin_version: str = Field(description="插件版本") total_memory_bytes: int = Field(description="总内存使用量(字节)") total_memory_mb: float = Field(description="总内存使用量(MB)") object_count: int = Field(description="对象数量") calculation_time_ms: float = Field(description="计算耗时(毫秒)") timestamp: float = Field(description="统计时间戳") error: Optional[str] = Field(default=None, description="错误信息") object_details: Optional[List[Dict[str, Any]]] = Field(default=None, description="大对象详情") ================================================ FILE: app/schemas/response.py ================================================ from typing import Optional, Union from pydantic import BaseModel, Field class Response(BaseModel): # 状态 success: bool # 消息文本 message: Optional[str] = None # 数据 data: Optional[Union[dict, list]] = Field(default_factory=dict) ================================================ FILE: app/schemas/rule.py ================================================ from typing import Optional from pydantic import BaseModel class CustomRule(BaseModel): """ 自定义规则项 """ # 规则ID id: Optional[str] = None # 名称 name: Optional[str] = None # 包含 include: Optional[str] = None # 排除 exclude: Optional[str] = None # 大小范围(MB) size_range: Optional[str] = None # 最少做种人数 seeders: Optional[str] = None # 发布时间 publish_time: Optional[str] = None class FilterRuleGroup(BaseModel): """ 过滤规则组 """ # 名称 name: Optional[str] = None # 规则串 rule_string: Optional[str] = None # 适用类媒体类型 None-全部 电影/电视剧 media_type: Optional[str] = None # 适用媒体类别 None-全部 对应二级分类 category: Optional[str] = None ================================================ FILE: app/schemas/servarr.py ================================================ from typing import Optional from pydantic import BaseModel, Field class RadarrMovie(BaseModel): id: Optional[int] = None title: Optional[str] = None year: Optional[str | int] = None isAvailable: bool = False monitored: bool = False tmdbId: Optional[int] = None imdbId: Optional[str] = None titleSlug: Optional[str] = None folderName: Optional[str] = None path: Optional[str] = None profileId: Optional[int] = None qualityProfileId: Optional[int] = None added: Optional[str] = None hasFile: bool = False class SonarrSeries(BaseModel): id: Optional[int] = None title: Optional[str] = None sortTitle: Optional[str] = None seasonCount: Optional[int] = None status: Optional[str] = None overview: Optional[str] = None network: Optional[str] = None airTime: Optional[str] = None images: list = Field(default_factory=list) remotePoster: Optional[str] = None seasons: list = Field(default_factory=list) year: Optional[str | int] = None path: Optional[str] = None profileId: Optional[int] = None languageProfileId: Optional[int] = None seasonFolder: bool = False monitored: bool = False useSceneNumbering: bool = False runtime: Optional[int] = None tmdbId: Optional[int] = None imdbId: Optional[str] = None tvdbId: Optional[int] = None tvRageId: Optional[int] = None tvMazeId: Optional[int] = None firstAired: Optional[str] = None seriesType: Optional[str] = None cleanTitle: Optional[str] = None titleSlug: Optional[str] = None certification: Optional[str] = None genres: list = Field(default_factory=list) tags: list = Field(default_factory=list) added: Optional[str] = None ratings: Optional[dict] = None qualityProfileId: Optional[int] = None statistics: dict = Field(default_factory=dict) isAvailable: Optional[bool] = False hasFile: Optional[bool] = False ================================================ FILE: app/schemas/servcookie.py ================================================ from fastapi import Query from pydantic import BaseModel class CookieData(BaseModel): encrypted: str = Query(min_length=1, max_length=1024 * 1024 * 50) uuid: str = Query(min_length=5, pattern="^[a-zA-Z0-9]+$") class CookiePassword(BaseModel): password: str ================================================ FILE: app/schemas/site.py ================================================ from typing import Optional, Any, Union, Dict from pydantic import BaseModel, Field, ConfigDict class Site(BaseModel): # ID id: Optional[int] = None # 站点名称 name: Optional[str] = None # 站点主域名Key domain: Optional[str] = None # 站点地址 url: Optional[str] = None # 站点优先级 pri: Optional[int] = 0 # RSS地址 rss: Optional[str] = None # Cookie cookie: Optional[str] = None # User-Agent ua: Optional[str] = None # ApiKey apikey: Optional[str] = None # Token token: Optional[str] = None # 是否使用代理 proxy: Optional[int] = 0 # 过滤规则 filter: Optional[str] = None # 是否演染 render: Optional[int] = 0 # 是否公开站点 public: Optional[int] = 0 # 备注 note: Optional[Any] = None # 超时时间 timeout: Optional[int] = 15 # 流控单位周期 limit_interval: Optional[int] = None # 流控次数 limit_count: Optional[int] = None # 流控间隔 limit_seconds: Optional[int] = None # 是否启用 is_active: Optional[bool] = True # 下载器 downloader: Optional[str] = None model_config = ConfigDict(from_attributes=True) class SiteStatistic(BaseModel): # 站点ID domain: Optional[str] = None # 成功次数 success: Optional[int] = 0 # 失败次数 fail: Optional[int] = 0 # 平均响应时间 seconds: Optional[int] = 0 # 最后状态 lst_state: Optional[int] = 0 # 最后修改时间 lst_mod_date: Optional[str] = None # 备注 note: Optional[Any] = None model_config = ConfigDict(from_attributes=True) class SiteUserData(BaseModel): # 站点域名 domain: Optional[str] = None # 用户名 username: Optional[str] = None # 用户ID userid: Optional[Union[str, int]] = None # 用户等级 user_level: Optional[str] = None # 加入时间 join_at: Optional[str] = None # 积分 bonus: Optional[float] = 0.0 # 上传量 upload: Optional[int] = 0 # 下载量 download: Optional[int] = 0 # 分享率 ratio: Optional[float] = 0.0 # 做种数 seeding: Optional[int] = 0 # 下载数 leeching: Optional[int] = 0 # 做种体积 seeding_size: Optional[int] = 0 # 下载体积 leeching_size: Optional[int] = 0 # 做种人数, 种子大小 seeding_info: Optional[list] = Field(default_factory=list) # 未读消息 message_unread: Optional[int] = 0 # 未读消息内容 message_unread_contents: Optional[list] = Field(default_factory=list) # 错误信息 err_msg: Optional[str] = None # 更新日期 updated_day: Optional[str] = None # 更新时间 updated_time: Optional[str] = None class SiteAuth(BaseModel): site: Optional[str] = None params: Optional[Dict[str, Union[int, str]]] = Field(default_factory=dict) class SiteCategory(BaseModel): id: Optional[int] = None cat: Optional[str] = None desc: Optional[str] = None ================================================ FILE: app/schemas/subscribe.py ================================================ from typing import Optional, List, Dict, Any from pydantic import BaseModel, Field, ConfigDict class Subscribe(BaseModel): id: Optional[int] = None # 订阅名称 name: Optional[str] = None # 订阅年份 year: Optional[str] = None # 订阅类型 电影/电视剧 type: Optional[str] = None # 搜索关键字 keyword: Optional[str] = None tmdbid: Optional[int] = None doubanid: Optional[str] = None bangumiid: Optional[int] = None mediaid: Optional[str] = None # 季号 season: Optional[int] = None # 海报 poster: Optional[str] = None # 背景图 backdrop: Optional[str] = None # 评分 vote: Optional[float] = 0.0 # 描述 description: Optional[str] = None # 过滤规则 filter: Optional[str] = None # 包含 include: Optional[str] = None # 排除 exclude: Optional[str] = None # 质量 quality: Optional[str] = None # 分辨率 resolution: Optional[str] = None # 特效 effect: Optional[str] = None # 总集数 total_episode: Optional[int] = 0 # 开始集数 start_episode: Optional[int] = 0 # 缺失集数 lack_episode: Optional[int] = 0 # 附加信息 note: Optional[Any] = None # 状态:N-新建, R-订阅中 state: Optional[str] = None # 最后更新时间 last_update: Optional[str] = None # 订阅用户 username: Optional[str] = None # 订阅站点 sites: Optional[List[int]] = Field(default_factory=list) # 下载器 downloader: Optional[str] = None # 是否洗版 best_version: Optional[int] = 0 # 当前优先级 current_priority: Optional[int] = None # 保存路径 save_path: Optional[str] = None # 是否使用 imdbid 搜索 search_imdbid: Optional[int] = 0 # 时间 date: Optional[str] = None # 自定义识别词 custom_words: Optional[str] = None # 自定义媒体类别 media_category: Optional[str] = None # 过滤规则组 filter_groups: Optional[List[str]] = Field(default_factory=list) # 剧集组 episode_group: Optional[str] = None model_config = ConfigDict(from_attributes=True) class SubscribeShare(BaseModel): # 分享ID id: Optional[int] = None # 订阅ID subscribe_id: Optional[int] = None # 分享标题 share_title: Optional[str] = None # 分享说明 share_comment: Optional[str] = None # 分享人 share_user: Optional[str] = None # 分享人唯一ID share_uid: Optional[str] = None # 订阅名称 name: Optional[str] = None # 订阅年份 year: Optional[str] = None # 订阅类型 电影/电视剧 type: Optional[str] = None # 搜索关键字 keyword: Optional[str] = None tmdbid: Optional[int] = None doubanid: Optional[str] = None bangumiid: Optional[int] = None # 季号 season: Optional[int] = None # 海报 poster: Optional[str] = None # 背景图 backdrop: Optional[str] = None # 评分 vote: Optional[float] = 0.0 # 描述 description: Optional[str] = None # 包含 include: Optional[str] = None # 排除 exclude: Optional[str] = None # 质量 quality: Optional[str] = None # 分辨率 resolution: Optional[str] = None # 特效 effect: Optional[str] = None # 总集数 total_episode: Optional[int] = 0 # 时间 date: Optional[str] = None # 自定义识别词 custom_words: Optional[str] = None # 自定义媒体类别 media_category: Optional[str] = None # 自定义剧集组 episode_group: Optional[str] = None # 复用人次 count: Optional[int] = 0 class SubscribeShareStatistics(BaseModel): # 分享人 share_user: Optional[str] = None # 分享数量 share_count: Optional[int] = 0 # 总复用人次 total_reuse_count: Optional[int] = 0 class SubscribeDownloadFileInfo(BaseModel): # 种子名称 torrent_title: Optional[str] = None # 站点名称 site_name: Optional[str] = None # 下载器 downloader: Optional[str] = None # hash hash: Optional[str] = None # 文件路径 file_path: Optional[str] = None class SubscribeLibraryFileInfo(BaseModel): # 存储 storage: Optional[str] = "local" # 文件路径 file_path: Optional[str] = None class SubscribeEpisodeInfo(BaseModel): # 标题 title: Optional[str] = None # 描述 description: Optional[str] = None # 背景图 backdrop: Optional[str] = None # 下载文件信息 download: Optional[List[SubscribeDownloadFileInfo]] = Field(default_factory=list) # 媒体库文件信息 library: Optional[List[SubscribeLibraryFileInfo]] = Field(default_factory=list) class SubscrbieInfo(BaseModel): # 订阅信息 subscribe: Optional[Subscribe] = None # 集信息 {集号: {download: 文件路径,library: 文件路径, backdrop: url, title: 标题, description: 描述}} episodes: Optional[Dict[int, SubscribeEpisodeInfo]] = Field(default_factory=dict) ================================================ FILE: app/schemas/system.py ================================================ from dataclasses import dataclass from typing import Optional, Any from pydantic import BaseModel, Field @dataclass class ServiceInfo: """ 封装服务相关信息的数据类 """ # 名称 name: Optional[str] = None # 实例 instance: Optional[Any] = None # 模块 module: Optional[Any] = None # 类型 type: Optional[str] = None # 配置 config: Optional[Any] = None class MediaServerConf(BaseModel): """ 媒体服务器配置 """ # 名称 name: Optional[str] = None # 类型 emby/jellyfin/plex/trimemedia/ugreen type: Optional[str] = None # 配置 config: Optional[dict] = Field(default_factory=dict) # 是否启用 enabled: Optional[bool] = False # 同步媒体体库列表 sync_libraries: Optional[list] = Field(default_factory=list) class DownloaderConf(BaseModel): """ 下载器配置 """ # 名称 name: Optional[str] = None # 类型 qbittorrent/transmission/rtorrent type: Optional[str] = None # 是否默认 default: Optional[bool] = False # 配置 config: Optional[dict] = Field(default_factory=dict) # 是否启用 enabled: Optional[bool] = False # 路径映射 path_mapping: Optional[list[tuple[str, str]]] = Field(default_factory=list) class NotificationConf(BaseModel): """ 通知配置 """ # 名称 name: Optional[str] = None # 类型 telegram/wechat/vocechat/synologychat/slack/webpush/qqbot type: Optional[str] = None # 配置 config: Optional[dict] = Field(default_factory=dict) # 场景开关 switchs: Optional[list] = Field(default_factory=list) # 是否启用 enabled: Optional[bool] = False class NotificationSwitchConf(BaseModel): """ 通知场景开关配置 """ # 场景名称 type: str = None # 通知范围 all/user/admin action: Optional[str] = "all" class StorageConf(BaseModel): """ 存储配置 """ # 类型 local/alipan/u115/rclone/alist type: Optional[str] = None # 名称 name: Optional[str] = None # 配置 config: Optional[dict] = Field(default_factory=dict) class TransferDirectoryConf(BaseModel): """ 文件整理目录配置 """ # 名称 name: Optional[str] = None # 优先级 priority: Optional[int] = 0 # 存储 storage: Optional[str] = None # 下载目录 download_path: Optional[str] = None # 适用媒体类型 media_type: Optional[str] = None # 适用媒体类别 media_category: Optional[str] = None # 下载类型子目录 download_type_folder: Optional[bool] = False # 下载类别子目录 download_category_folder: Optional[bool] = False # 监控方式 downloader/monitor,None为不监控 monitor_type: Optional[str] = None # 监控模式 fast / compatibility monitor_mode: Optional[str] = "fast" # 整理方式 move/copy/link/softlink transfer_type: Optional[str] = None # 文件覆盖模式 always/size/never/latest overwrite_mode: Optional[str] = None # 整理到媒体库目录 library_path: Optional[str] = None # 媒体库目录存储 library_storage: Optional[str] = None # 智能重命名 renaming: Optional[bool] = False # 刮削 scraping: Optional[bool] = False # 是否发送通知 notify: Optional[bool] = True # 媒体库类型子目录 library_type_folder: Optional[bool] = False # 媒体库类别子目录 library_category_folder: Optional[bool] = False ================================================ FILE: app/schemas/tmdb.py ================================================ from typing import Optional from pydantic import BaseModel, Field class TmdbSeason(BaseModel): """ TMDB季信息 """ air_date: Optional[str] = None episode_count: Optional[int] = None name: Optional[str] = None overview: Optional[str] = None poster_path: Optional[str] = None season_number: Optional[int] = None vote_average: Optional[float] = None class TmdbEpisode(BaseModel): """ TMDB集信息 """ air_date: Optional[str] = None episode_number: Optional[int] = None episode_type: Optional[str] = None name: Optional[str] = None overview: Optional[str] = None runtime: Optional[int] = None season_number: Optional[int] = None still_path: Optional[str] = None vote_average: Optional[float] = None crew: Optional[list] = Field(default_factory=list) guest_stars: Optional[list] = Field(default_factory=list) ================================================ FILE: app/schemas/token.py ================================================ from typing import Optional from pydantic import BaseModel, Field class Token(BaseModel): # 令牌 access_token: str # 令牌类型 token_type: str # 超级用户 super_user: bool # 用户ID user_id: int # 用户名 user_name: str # 头像 avatar: Optional[str] = None # 权限级别 level: int = 1 # 详细权限 permissions: Optional[dict] = Field(default_factory=dict) # 是否显示配置向导 wizard: Optional[bool] = None class TokenPayload(BaseModel): # 用户ID sub: Optional[int] = None # 用户名 username: Optional[str] = None # 超级用户 super_user: Optional[bool] = None # 权限级别 level: Optional[int] = None # 令牌用途 authentication\resource purpose: Optional[str] = None ================================================ FILE: app/schemas/transfer.py ================================================ from pathlib import Path from typing import Optional, List, Any, Callable from pydantic import BaseModel, Field from app.schemas.context import MetaInfo, MediaInfo from app.schemas.file import FileItem from app.schemas.history import DownloadHistory from app.schemas.system import TransferDirectoryConf from app.schemas.tmdb import TmdbEpisode class TransferTorrent(BaseModel): """ 待转移任务信息 """ downloader: Optional[str] = None title: Optional[str] = None path: Optional[Path] = None hash: Optional[str] = None tags: Optional[str] = None size: Optional[int] = 0 userid: Optional[str] = None progress: Optional[float] = 0.0 state: Optional[str] = None class DownloadingTorrent(BaseModel): """ 下载中任务信息 """ downloader: Optional[str] = None hash: Optional[str] = None title: Optional[str] = None name: Optional[str] = None year: Optional[str] = None season_episode: Optional[str] = None size: Optional[float] = 0.0 progress: Optional[float] = 0.0 state: Optional[str] = 'downloading' upspeed: Optional[str] = None dlspeed: Optional[str] = None media: Optional[dict] = Field(default_factory=dict) userid: Optional[str] = None username: Optional[str] = None left_time: Optional[str] = None class TransferTask(BaseModel): """ 文件整理任务 """ fileitem: FileItem meta: Optional[Any] = None mediainfo: Optional[Any] = None target_directory: Optional[TransferDirectoryConf] = None target_storage: Optional[str] = None target_path: Optional[Path] = None transfer_type: Optional[str] = None scrape: Optional[bool] = False library_type_folder: Optional[bool] = False library_category_folder: Optional[bool] = False episodes_info: Optional[List[TmdbEpisode]] = None username: Optional[str] = None downloader: Optional[str] = None download_hash: Optional[str] = None download_history: Optional[DownloadHistory] = None manual: Optional[bool] = False background: Optional[bool] = True def to_dict(self): """ 返回字典 """ dicts = vars(self).copy() dicts["fileitem"] = self.fileitem.model_dump() if self.fileitem else None dicts["meta"] = self.meta.model_dump() if self.meta else None dicts["mediainfo"] = self.mediainfo.model_dump() if self.mediainfo else None dicts["target_directory"] = self.target_directory.model_dump() if self.target_directory else None return dicts class TransferJobTask(BaseModel): """ 文件整理作业任务 """ fileitem: Optional[FileItem] = None meta: Optional[MetaInfo] = None state: Optional[str] = None downloader: Optional[str] = None download_hash: Optional[str] = None class TransferJob(BaseModel): """ 文件整理作业 """ media: Optional[MediaInfo] = None season: Optional[int] = None tasks: Optional[List[TransferJobTask]] = Field(default_factory=list) class TransferInfo(BaseModel): """ 文件整理结果 """ # 是否成功标志 success: bool = True # 整理⼁路径 fileitem: Optional[FileItem] = None # 转移后的目录项,媒体的根目录 target_diritem: Optional[FileItem] = None # 转移后路径 target_item: Optional[FileItem] = None # 整理方式 transfer_type: Optional[str] = None # 处理文件数 file_count: Optional[int] = Field(default=0) # 处理文件清单 file_list: Optional[list] = Field(default_factory=list) # 目标文件清单 file_list_new: Optional[list] = Field(default_factory=list) # 总文件大小 total_size: Optional[int] = Field(default=0) # 失败清单 fail_list: Optional[list] = Field(default_factory=list) # 错误信息 message: Optional[str] = None # 是否需要刮削 need_scrape: Optional[bool] = False # 是否需要通知 need_notify: Optional[bool] = False def to_dict(self): """ 返回字典 """ dicts = vars(self).copy() dicts["fileitem"] = self.fileitem.model_dump() if self.fileitem else None dicts["target_item"] = self.target_item.model_dump() if self.target_item else None return dicts class TransferQueue(BaseModel): """ 异步整理队列信息 """ # 任务信息 task: Optional[TransferTask] = None # 回调函数 callback: Optional[Callable] = None # 整理结果 result: Optional[TransferInfo] = None class EpisodeFormat(BaseModel): """ 剧集自定义识别格式 """ format: Optional[str] = None detail: Optional[str] = None part: Optional[str] = None offset: Optional[str] = None class ManualTransferItem(BaseModel): # 文件项 fileitem: FileItem = None # 日志ID logid: Optional[int] = None # 目标存储 target_storage: Optional[str] = None # 目标路径 target_path: Optional[str] = None # TMDB ID tmdbid: Optional[int] = None # 豆瓣ID doubanid: Optional[str] = None # 类型 type_name: Optional[str] = None # 季号 season: Optional[int] = None # 整理方式 transfer_type: Optional[str] = None # 自定义格式 episode_format: Optional[str] = None # 指定集数 episode_detail: Optional[str] = None # 指定PART episode_part: Optional[str] = None # 集数偏移 episode_offset: Optional[str] = None # 最小文件大小 min_filesize: Optional[int] = 0 # 刮削 scrape: bool = False # 媒体库类型子目录 library_type_folder: Optional[bool] = None # 媒体库类别子目录 library_category_folder: Optional[bool] = None # 复用历史识别信息 from_history: Optional[bool] = False # 剧集组 episode_group: Optional[str] = None ================================================ FILE: app/schemas/types.py ================================================ from enum import Enum from typing import Optional # 媒体类型 class MediaType(Enum): MOVIE = '电影' TV = '电视剧' COLLECTION = '系列' UNKNOWN = '未知' @staticmethod def from_agent(key: str) -> Optional["MediaType"]: """'movie' -> MediaType.MOVIE, 'tv' -> MediaType.TV, 否则 None""" _map = {"movie": MediaType.MOVIE, "tv": MediaType.TV} return _map.get(key.strip().lower() if key else "") def to_agent(self) -> str: """MediaType.MOVIE -> 'movie', MediaType.TV -> 'tv', 其他返回 .value""" return {MediaType.MOVIE: "movie", MediaType.TV: "tv"}.get(self, self.value) def media_type_to_agent(value) -> Optional[str]: """将 MediaType 枚举或中文字符串统一转为 'movie'/'tv'""" if isinstance(value, MediaType): return value.to_agent() if isinstance(value, str): mt = MediaType.from_agent(value) return mt.to_agent() if mt else value return None # 排序类型枚举 class SortType(Enum): TIME = "time" # 按时间排序 COUNT = "count" # 按人数排序 RATING = "rating" # 按评分排序 # 种子状态 class TorrentStatus(Enum): TRANSFER = "可转移" DOWNLOADING = "下载中" # 异步广播事件 class EventType(Enum): # 插件需要重载 PluginReload = "plugin.reload" # 触发插件动作 PluginAction = "plugin.action" # 插件触发事件 PluginTriggered = "plugin.triggered" # 执行命令 CommandExcute = "command.excute" # 站点已删除 SiteDeleted = "site.deleted" # 站点已更新 SiteUpdated = "site.updated" # 站点已刷新 SiteRefreshed = "site.refreshed" # 媒体文件整理完成 TransferComplete = "transfer.complete" # 媒体文件整理失败 TransferFailed = "transfer.failed" # 字幕整理完成 SubtitleTransferComplete = "transfer.subtitle.complete" # 字幕整理失败 SubtitleTransferFailed = "transfer.subtitle.failed" # 音频文件整理完成 AudioTransferComplete = "transfer.audio.complete" # 音频文件整理失败 AudioTransferFailed = "transfer.audio.failed" # 下载已添加 DownloadAdded = "download.added" # 删除历史记录 HistoryDeleted = "history.deleted" # 删除下载源文件 DownloadFileDeleted = "downloadfile.deleted" # 删除下载任务 DownloadDeleted = "download.deleted" # 收到用户外来消息 UserMessage = "user.message" # 收到Webhook消息 WebhookMessage = "webhook.message" # 发送消息通知 NoticeMessage = "notice.message" # 订阅已添加 SubscribeAdded = "subscribe.added" # 订阅已调整 SubscribeModified = "subscribe.modified" # 订阅已删除 SubscribeDeleted = "subscribe.deleted" # 订阅已完成 SubscribeComplete = "subscribe.complete" # 系统错误 SystemError = "system.error" # 刮削元数据 MetadataScrape = "metadata.scrape" # 模块需要重载 ModuleReload = "module.reload" # 配置项更新 ConfigChanged = "config.updated" # 消息交互动作 MessageAction = "message.action" # 执行工作流 WorkflowExecute = "workflow.execute" # EventType中文名称翻译字典 EVENT_TYPE_NAMES = { EventType.PluginReload: "插件重载", EventType.PluginAction: "触发插件动作", EventType.PluginTriggered: "触发插件事件", EventType.CommandExcute: "执行命令", EventType.SiteDeleted: "站点已删除", EventType.SiteUpdated: "站点已更新", EventType.SiteRefreshed: "站点已刷新", EventType.TransferComplete: "整理完成", EventType.TransferFailed: "整理失败", EventType.SubtitleTransferComplete: "字幕整理完成", EventType.SubtitleTransferFailed: "字幕整理失败", EventType.AudioTransferComplete: "音频整理完成", EventType.AudioTransferFailed: "音频整理失败", EventType.DownloadAdded: "添加下载", EventType.HistoryDeleted: "删除历史记录", EventType.DownloadFileDeleted: "删除下载源文件", EventType.DownloadDeleted: "删除下载任务", EventType.UserMessage: "收到用户消息", EventType.WebhookMessage: "收到Webhook消息", EventType.NoticeMessage: "发送消息通知", EventType.SubscribeAdded: "添加订阅", EventType.SubscribeModified: "订阅已调整", EventType.SubscribeDeleted: "订阅已删除", EventType.SubscribeComplete: "订阅已完成", EventType.SystemError: "系统错误", EventType.MetadataScrape: "刮削元数据", EventType.ModuleReload: "模块重载", EventType.ConfigChanged: "配置项更新", EventType.MessageAction: "消息交互动作", EventType.WorkflowExecute: "执行工作流", } # 同步链式事件 class ChainEventType(Enum): # 名称识别 NameRecognize = "name.recognize" # 认证验证 AuthVerification = "auth.verification" # 认证拦截 AuthIntercept = "auth.intercept" # 命令注册 CommandRegister = "command.register" # 整理重命名 TransferRename = "transfer.rename" # 整理拦截 TransferIntercept = "transfer.intercept" # 资源选择 ResourceSelection = "resource.selection" # 资源下载 ResourceDownload = "resource.download" # 探索数据源 DiscoverSource = "discover.source" # 媒体识别转换 MediaRecognizeConvert = "media.recognize.convert" # 推荐数据源 RecommendSource = "recommend.source" # 工作流执行 WorkflowExecution = "workflow.execution" # 存储操作选择 StorageOperSelection = "storage.operation" # 系统配置Key字典 class SystemConfigKey(Enum): # 下载器配置 Downloaders = "Downloaders" # 媒体服务器配置 MediaServers = "MediaServers" # 消息通知配置 Notifications = "Notifications" # 通知场景开关设置 NotificationSwitchs = "NotificationSwitchs" # 目录配置 Directories = "Directories" # 存储配置 Storages = "Storages" # 搜索站点范围 IndexerSites = "IndexerSites" # 订阅站点范围 RssSites = "RssSites" # 自定义制作组/字幕组 CustomReleaseGroups = "CustomReleaseGroups" # 自定义占位符 Customization = "Customization" # 自定义识别词 CustomIdentifiers = "CustomIdentifiers" # 转移屏蔽词 TransferExcludeWords = "TransferExcludeWords" # 种子优先级规则 TorrentsPriority = "TorrentsPriority" # 用户自定义规则 CustomFilterRules = "CustomFilterRules" # 用户规则组 UserFilterRuleGroups = "UserFilterRuleGroups" # 搜索默认过滤规则组 SearchFilterRuleGroups = "SearchFilterRuleGroups" # 订阅默认过滤规则组 SubscribeFilterRuleGroups = "SubscribeFilterRuleGroups" # 订阅默认参数 SubscribeDefaultParams = "SubscribeDefaultParams" # 洗版默认过滤规则组 BestVersionFilterRuleGroups = "BestVersionFilterRuleGroups" # 订阅统计 SubscribeReport = "SubscribeReport" # 用户自定义CSS UserCustomCSS = "UserCustomCSS" # 用户已安装的插件 UserInstalledPlugins = "UserInstalledPlugins" # 插件文件夹分组配置 PluginFolders = "PluginFolders" # 默认电影订阅规则 DefaultMovieSubscribeConfig = "DefaultMovieSubscribeConfig" # 默认电视剧订阅规则 DefaultTvSubscribeConfig = "DefaultTvSubscribeConfig" # 用户站点认证参数 UserSiteAuthParams = "UserSiteAuthParams" # Follow订阅分享者 FollowSubscribers = "FollowSubscribers" # 通知发送时间 NotificationSendTime = "NotificationSendTime" # AI智能体配置 AIAgentConfig = "AIAgentConfig" # 通知消息格式模板 NotificationTemplates = "NotificationTemplates" # 刮削开关设置 ScrapingSwitchs = "ScrapingSwitchs" # 插件安装统计 PluginInstallReport = "PluginInstallReport" # 配置向导状态 SetupWizardState = "SetupWizardState" # 绿联影视登录会话缓存 UgreenSessionCache = "UgreenSessionCache" # 处理进度Key字典 class ProgressKey(Enum): # 搜索 Search = "search" # 整理 FileTransfer = "filetransfer" # 批量重命名 BatchRename = "batchrename" # 媒体图片类型 class MediaImageType(Enum): Poster = "poster_path" Backdrop = "backdrop_path" # 消息类型 class NotificationType(Enum): # 资源下载 Download = "资源下载" # 整理入库 Organize = "整理入库" # 订阅 Subscribe = "订阅" # 站点消息 SiteMessage = "站点" # 媒体服务器通知 MediaServer = "媒体服务器" # 处理失败需要人工干预 Manual = "手动处理" # 插件消息 Plugin = "插件" # 其它消息 Other = "其它" class ContentType(str, Enum): """ 消息内容类型 操作状态的通知消息类型标识 """ # 订阅添加成功 SubscribeAdded = "subscribeAdded" # 订阅完成 SubscribeComplete = "subscribeComplete" # 入库成功 OrganizeSuccess = "organizeSuccess" # 下载开始(添加下载任务成功) DownloadAdded = "downloadAdded" # 消息渠道 class MessageChannel(Enum): """ 消息渠道 """ Wechat = "微信" Telegram = "Telegram" Slack = "Slack" Discord = "Discord" SynologyChat = "SynologyChat" VoceChat = "VoceChat" Web = "Web" WebPush = "WebPush" QQ = "QQ" # 下载器类型 class DownloaderType(Enum): # Qbittorrent Qbittorrent = "Qbittorrent" # Transmission Transmission = "Transmission" # Rtorrent Rtorrent = "Rtorrent" # Aria2 # Aria2 = "Aria2" # 媒体服务器类型 class MediaServerType(Enum): # Emby Emby = "Emby" # Jellyfin Jellyfin = "Jellyfin" # Plex Plex = "Plex" # 飞牛影视 TrimeMedia = "TrimeMedia" # 绿联影视 Ugreen = "Ugreen" # 识别器类型 class MediaRecognizeType(Enum): # 豆瓣 Douban = "豆瓣" # TMDB TMDB = "TheMovieDb" # TVDB TVDB = "TheTvDb" # bangumi Bangumi = "Bangumi" # 用户配置Key字典 class UserConfigKey(Enum): # 监控面板 Dashboard = "Dashboard" # 支持的存储类型 class StorageSchema(Enum): # 存储类型 Local = "local" Alipan = "alipan" U115 = "u115" Rclone = "rclone" Alist = "alist" SMB = "smb" # 模块类型 class ModuleType(Enum): # 下载器 Downloader = "downloader" # 媒体服务器 MediaServer = "mediaserver" # 消息服务 Notification = "notification" # 媒体识别 MediaRecognize = "mediarecognize" # 站点索引 Indexer = "indexer" # 其它 Other = "other" # 其他杂项模块类型 class OtherModulesType(Enum): # 字幕 Subtitle = "站点字幕" # Fanart Fanart = "Fanart" # 文件整理 FileManager = "文件整理" # 过滤器 Filter = "过滤器" # 站点索引 Indexer = "站点索引" # PostgreSQL PostgreSQL = "PostgreSQL" # Redis Redis = "Redis" class NameValueEnum(Enum): """支持通过 name 或 value 实例化的枚举基类""" @classmethod def _missing_(cls, value): if isinstance(value, str): for member in cls: if member.name.lower() == value.lower() or member.value == value: return member return None # 刮削策略 class ScrapingPolicy(NameValueEnum): MISSINGONLY = "仅缺失" SKIP = "跳过" OVERWRITE = "覆盖" # 刮削目标类型 class ScrapingTarget(NameValueEnum): MOVIE = "电影" TV = "电视剧" SEASON = "季" EPISODE = "集" # 刮削元数据类型 class ScrapingMetadata(NameValueEnum): NFO = "NFO" POSTER = "海报" BACKDROP = "背景图" LOGO = "Logo" BANNER = "横幅图" THUMB = "缩略图" DISC = "光盘图" ================================================ FILE: app/schemas/user.py ================================================ from typing import Optional from pydantic import BaseModel, Field, ConfigDict # Shared properties class UserBase(BaseModel): # 用户名 name: str # 邮箱,未启用 email: Optional[str] = None # 状态 is_active: Optional[bool] = True # 超级管理员 is_superuser: bool = False # 头像 avatar: Optional[str] = None # 是否开启二次验证 is_otp: Optional[bool] = False # 权限 permissions: Optional[dict] = Field(default_factory=dict) # 个性化设置 settings: Optional[dict] = Field(default_factory=dict) model_config = ConfigDict(from_attributes=True) # Properties to receive via API on creation class UserCreate(UserBase): name: str email: Optional[str] = None password: Optional[str] = None settings: Optional[dict] = Field(default_factory=dict) permissions: Optional[dict] = Field(default_factory=dict) # Properties to receive via API on update class UserUpdate(UserBase): id: int name: str email: Optional[str] = None password: Optional[str] = None settings: Optional[dict] = Field(default_factory=dict) permissions: Optional[dict] = Field(default_factory=dict) class UserInDBBase(UserBase): id: Optional[int] = None model_config = ConfigDict(from_attributes=True) # Additional properties to return via API class User(UserInDBBase): name: str email: Optional[str] = None # Additional properties stored in DB class UserInDB(UserInDBBase): hashed_password: str ================================================ FILE: app/schemas/workflow.py ================================================ from typing import Optional, List from pydantic import BaseModel, Field, ConfigDict from app.schemas.context import Context, MediaInfo from app.schemas.download import DownloadTask from app.schemas.file import FileItem from app.schemas.site import Site from app.schemas.subscribe import Subscribe class Workflow(BaseModel): """ 工作流信息 """ id: Optional[int] = Field(default=None, description="工作流ID") name: Optional[str] = Field(default=None, description="工作流名称") description: Optional[str] = Field(default=None, description="工作流描述") timer: Optional[str] = Field(default=None, description="定时器") trigger_type: Optional[str] = Field(default='timer', description="触发类型:timer-定时触发 event-事件触发 manual-手动触发") event_type: Optional[str] = Field(default=None, description="事件类型(当trigger_type为event时使用)") event_conditions: Optional[dict] = Field(default={}, description="事件条件(JSON格式,用于过滤事件)") state: Optional[str] = Field(default=None, description="状态") current_action: Optional[str] = Field(default=None, description="已执行动作") result: Optional[str] = Field(default=None, description="任务执行结果") run_count: Optional[int] = Field(default=0, description="已执行次数") actions: Optional[list] = Field(default=[], description="任务列表") flows: Optional[list] = Field(default=[], description="任务流") add_time: Optional[str] = Field(default=None, description="创建时间") last_time: Optional[str] = Field(default=None, description="最后执行时间") model_config = ConfigDict(from_attributes=True) class ActionParams(BaseModel): """ 动作基础参数 """ loop: Optional[bool] = Field(default=False, description="是否需要循环") loop_interval: Optional[int] = Field(default=0, description="循环间隔 (秒)") class Action(BaseModel): """ 动作信息 """ id: Optional[str] = Field(default=None, description="动作ID") type: Optional[str] = Field(default=None, description="动作类型 (类名)") name: Optional[str] = Field(default=None, description="动作名称") description: Optional[str] = Field(default=None, description="动作描述") position: Optional[dict] = Field(default={}, description="位置") data: Optional[dict] = Field(default={}, description="参数") class ActionExecution(BaseModel): """ 动作执行情况 """ action: Optional[str] = Field(default=None, description="当前动作(名称)") result: Optional[bool] = Field(default=None, description="执行结果") message: Optional[str] = Field(default=None, description="执行消息") class ActionContext(BaseModel): """ 动作基础上下文,各动作通用数据 """ content: Optional[str] = Field(default=None, description="文本类内容") torrents: Optional[List[Context]] = Field(default=[], description="资源列表") medias: Optional[List[MediaInfo]] = Field(default=[], description="媒体列表") fileitems: Optional[List[FileItem]] = Field(default=[], description="文件列表") downloads: Optional[List[DownloadTask]] = Field(default=[], description="下载任务列表") sites: Optional[List[Site]] = Field(default=[], description="站点列表") subscribes: Optional[List[Subscribe]] = Field(default=[], description="订阅列表") execute_history: Optional[List[ActionExecution]] = Field(default=[], description="执行历史") progress: Optional[int] = Field(default=0, description="执行进度(%)") class ActionFlow(BaseModel): """ 工作流流程 """ id: Optional[str] = Field(default=None, description="流程ID") source: Optional[str] = Field(default=None, description="源动作") target: Optional[str] = Field(default=None, description="目标动作") animated: Optional[bool] = Field(default=True, description="是否动画流程") class WorkflowShare(BaseModel): """ 工作流分享信息 """ id: Optional[int] = Field(default=None, description="分享ID") share_title: Optional[str] = Field(default=None, description="分享标题") share_comment: Optional[str] = Field(default=None, description="分享说明") share_user: Optional[str] = Field(default=None, description="分享人") share_uid: Optional[str] = Field(default=None, description="分享人唯一ID") name: Optional[str] = Field(default=None, description="工作流名称") description: Optional[str] = Field(default=None, description="工作流描述") timer: Optional[str] = Field(default=None, description="定时器") trigger_type: Optional[str] = Field(default=None, description="触发类型") event_type: Optional[str] = Field(default=None, description="事件类型") event_conditions: Optional[str] = Field(default=None, description="事件条件") actions: Optional[str] = Field(default=None, description="任务列表(JSON字符串)") flows: Optional[str] = Field(default=None, description="任务流(JSON字符串)") context: Optional[str] = Field(default=None, description="执行上下文(JSON字符串)") date: Optional[str] = Field(default=None, description="分享时间") count: Optional[int] = Field(default=0, description="复用人次") model_config = ConfigDict(from_attributes=True) ================================================ FILE: app/startup/__init__.py ================================================ ================================================ FILE: app/startup/agent_initializer.py ================================================ import asyncio import threading from app.agent import agent_manager from app.core.config import settings from app.log import logger class AgentInitializer: """ AI智能体初始化器 """ def __init__(self): self._initialized = False async def initialize(self) -> bool: """ 初始化AI智能体管理器 """ try: if not settings.AI_AGENT_ENABLE: logger.info("AI智能体功能未启用") return True await agent_manager.initialize() self._initialized = True logger.info("AI智能体管理器初始化成功") return True except Exception as e: logger.error(f"AI智能体管理器初始化失败: {e}") return False async def cleanup(self) -> None: """ 清理AI智能体管理器 """ try: if not self._initialized: return await agent_manager.close() self._initialized = False logger.info("AI智能体管理器已关闭") except Exception as e: logger.error(f"关闭AI智能体管理器时发生错误: {e}") # 全局AI智能体初始化器实例 agent_initializer = AgentInitializer() def init_agent(): """ 初始化AI智能体(同步版本,用于在后台线程中运行) """ try: if not settings.AI_AGENT_ENABLE: logger.info("AI智能体功能未启用") return True # 在新的事件循环中初始化AI智能体管理器 def run_init(): loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) try: success = loop.run_until_complete(agent_initializer.initialize()) if success: logger.info("AI智能体管理器初始化成功") else: logger.error("AI智能体管理器初始化失败") return success except Exception as err: logger.error(f"AI智能体管理器初始化失败: {err}") return False finally: loop.close() # 在后台线程中初始化 init_thread = threading.Thread(target=run_init, daemon=True) init_thread.start() return True except Exception as e: logger.error(f"初始化AI智能体时发生错误: {e}") return False async def stop_agent(): """ 停止AI智能体(异步版本,用于在应用关闭时调用) """ try: await agent_initializer.cleanup() except Exception as e: logger.error(f"停止AI智能体时发生错误: {e}") ================================================ FILE: app/startup/command_initializer.py ================================================ from app.command import Command def init_command(): """ 初始化命令 """ Command() def stop_command(): """ 停止命令 """ pass def restart_command(): """ 重启命令 """ Command().init_commands() ================================================ FILE: app/startup/lifecycle.py ================================================ import asyncio from contextlib import asynccontextmanager from fastapi import FastAPI from app.chain.system import SystemChain from app.core.config import global_vars from app.helper.system import SystemHelper from app.startup.command_initializer import init_command, stop_command, restart_command from app.startup.modules_initializer import init_modules, stop_modules from app.startup.monitor_initializer import stop_monitor, init_monitor from app.startup.plugins_initializer import init_plugins, stop_plugins, sync_plugins from app.startup.routers_initializer import init_routers from app.startup.scheduler_initializer import stop_scheduler, init_scheduler, init_plugin_scheduler from app.startup.workflow_initializer import init_workflow, stop_workflow async def init_extra(): """ 同步插件及重启相关依赖服务 """ if await sync_plugins(): # 重新注册插件定时服务 init_plugin_scheduler() # 重新注册命令 restart_command() # 设置系统已修改标志 SystemHelper().set_system_modified() # 重启完成 SystemChain().restart_finish() @asynccontextmanager async def lifespan(app: FastAPI): """ 定义应用的生命周期事件 """ print("Starting up...") # 存储当前循环 global_vars.set_loop(asyncio.get_event_loop()) # 初始化路由 init_routers(app) # 初始化模块 init_modules() # 恢复插件备份 SystemChain().restore_plugins() # 初始化插件 init_plugins() # 初始化定时器 init_scheduler() # 初始化监控器 init_monitor() # 初始化命令 init_command() # 初始化工作流 init_workflow() # 插件同步到本地 sync_plugins_task = asyncio.create_task(init_extra()) try: # 在此处 yield,表示应用已经启动,控制权交回 FastAPI 主事件循环 yield finally: print("Shutting down...") # 取消同步插件任务 try: sync_plugins_task.cancel() await sync_plugins_task except asyncio.CancelledError: pass except Exception as e: print(str(e)) # 备份插件 SystemChain().backup_plugins() # 停止工作流 stop_workflow() # 停止命令 stop_command() # 停止监控器 stop_monitor() # 停止定时器 stop_scheduler() # 停止插件 stop_plugins() # 停止模块 await stop_modules() ================================================ FILE: app/startup/modules_initializer.py ================================================ import sys from app.helper.redis import RedisHelper, AsyncRedisHelper # SitesHelper涉及资源包拉取,提前引入并容错提示 try: from app.helper.sites import SitesHelper # noqa except ImportError as e: SitesHelper = None error_message = f"错误: {str(e)}\n站点认证及索引相关资源导入失败,请尝试重建容器或手动拉取资源" print(error_message, file=sys.stderr) sys.exit(1) from app.utils.system import SystemUtils from app.log import logger from app.core.config import settings from app.core.module import ModuleManager from app.core.event import EventManager from app.helper.thread import ThreadHelper from app.helper.display import DisplayHelper from app.helper.doh import DohHelper from app.helper.resource import ResourceHelper from app.helper.message import MessageHelper, stop_message from app.helper.subscribe import SubscribeHelper from app.db import close_database from app.db.systemconfig_oper import SystemConfigOper from app.command import CommandChain from app.schemas import Notification, NotificationType from app.schemas.types import SystemConfigKey from app.startup.agent_initializer import init_agent, stop_agent def start_frontend(): """ 启动前端服务 """ # 仅Windows可执行文件支持内嵌nginx if not SystemUtils.is_frozen() \ or not SystemUtils.is_windows(): return # 临时Nginx目录 nginx_path = settings.ROOT_PATH / 'nginx' if not nginx_path.exists(): return # 配置目录下的Nginx目录 run_nginx_dir = settings.CONFIG_PATH.with_name('nginx') if not run_nginx_dir.exists(): # 移动到配置目录 SystemUtils.move(nginx_path, run_nginx_dir) # 启动Nginx import subprocess subprocess.Popen("start nginx.exe", cwd=run_nginx_dir, shell=True) def stop_frontend(): """ 停止前端服务 """ if not SystemUtils.is_frozen() \ or not SystemUtils.is_windows(): return import subprocess subprocess.Popen(f"taskkill /f /im nginx.exe", shell=True) def clear_temp(): """ 清理临时文件和图片缓存 """ # 清理临时目录中3天前的文件 SystemUtils.clear(settings.TEMP_PATH, days=settings.TEMP_FILE_DAYS) # 清理图片缓存目录中7天前的文件 SystemUtils.clear(settings.CACHE_PATH / "images", days=settings.GLOBAL_IMAGE_CACHE_DAYS) def user_auth(): """ 用户认证检查 """ sites_helper = SitesHelper() if sites_helper.auth_level >= 2: return auth_conf = SystemConfigOper().get(SystemConfigKey.UserSiteAuthParams) status, msg = sites_helper.check_user(**auth_conf) if auth_conf else sites_helper.check_user() if status: logger.info(f"{msg} 用户认证成功") else: logger.info(f"用户认证失败,{msg}") def check_auth(): """ 检查认证状态 """ if SitesHelper().auth_level < 2: err_msg = "用户认证失败,站点相关功能将无法使用!" MessageHelper().put(f"注意:{err_msg}", title="用户认证", role="system") CommandChain().post_message( Notification( mtype=NotificationType.Manual, title="MoviePilot用户认证", text=err_msg, link=settings.MP_DOMAIN('#/site') ) ) async def stop_modules(): """ 服务关闭 """ # 停止AI智能体 await stop_agent() # 停止模块 ModuleManager().stop() # 停止事件消费 EventManager().stop() # 停止虚拟显示 DisplayHelper().stop() # 停止线程池 ThreadHelper().shutdown() # 停止消息服务 stop_message() # 关闭Redis缓存连接 RedisHelper().close() await AsyncRedisHelper().close() # 停止数据库连接 await close_database() # 停止前端服务 stop_frontend() # 清理临时文件 clear_temp() def init_modules(): """ 启动模块 """ # 虚拟显示 DisplayHelper() # DoH DohHelper() # 站点管理 SitesHelper() # 资源包检测 ResourceHelper() # 用户认证 user_auth() # 加载模块 ModuleManager() # 启动事件消费 EventManager().start() # 初始化订阅分享 SubscribeHelper() # 初始化AI智能体 init_agent() # 启动前端服务 start_frontend() # 检查认证状态 check_auth() ================================================ FILE: app/startup/monitor_initializer.py ================================================ from app.monitor import Monitor def init_monitor(): """ 初始化监控器 """ Monitor() def stop_monitor(): """ 停止监控器 """ Monitor().stop() ================================================ FILE: app/startup/plugins_initializer.py ================================================ from app.core.config import global_vars from app.core.plugin import PluginManager from app.log import logger async def sync_plugins() -> bool: """ 初始化安装插件,并动态注册后台任务及API """ try: loop = global_vars.loop plugin_manager = PluginManager() sync_result = await execute_task(loop, plugin_manager.sync, "插件同步到本地") resolved_dependencies = await execute_task(loop, plugin_manager.install_plugin_missing_dependencies, "缺失依赖项安装") # 判断是否需要进行插件初始化 if not sync_result and not resolved_dependencies: logger.debug("没有新的插件同步到本地或缺失依赖项需要安装") return False # 继续执行后续的插件初始化步骤 logger.info("正在重新初始化插件") # 重新初始化插件 plugin_manager.init_config() # 重新注册插件API register_plugin_api() logger.info("所有插件初始化完成") return True except Exception as e: logger.error(f"插件初始化过程中出现异常: {e}") return False async def execute_task(loop, task_func, task_name): """ 执行后台任务 """ try: result = await loop.run_in_executor(None, task_func) if isinstance(result, list) and result: logger.debug(f"{task_name} 已完成,共处理 {len(result)} 个项目") else: logger.debug(f"没有新的 {task_name} 需要处理") return result except Exception as e: logger.error(f"{task_name} 时发生错误:{e}", exc_info=True) return [] def register_plugin_api(): """ 插件启动后注册插件API """ from app.api.endpoints import plugin plugin.register_plugin_api() def init_plugins(): """ 初始化插件 """ PluginManager().start() register_plugin_api() def stop_plugins(): """ 停止插件 """ try: plugin_manager = PluginManager() plugin_manager.stop() plugin_manager.stop_monitor() except Exception as e: logger.error(f"停止插件时发生错误:{e}", exc_info=True) ================================================ FILE: app/startup/routers_initializer.py ================================================ from fastapi import FastAPI from app.core.config import settings def init_routers(app: FastAPI): """ 初始化路由 """ from app.api.apiv1 import api_router from app.api.servarr import arr_router from app.api.servcookie import cookie_router # API路由 app.include_router(api_router, prefix=settings.API_V1_STR) # Radarr、Sonarr路由 app.include_router(arr_router, prefix="/api/v3") # CookieCloud路由 app.include_router(cookie_router, prefix="/cookiecloud") ================================================ FILE: app/startup/scheduler_initializer.py ================================================ from app.scheduler import Scheduler def init_scheduler(): """ 初始化定时器 """ Scheduler() def stop_scheduler(): """ 停止定时器 """ Scheduler().stop() def restart_scheduler(): """ 重启定时器 """ Scheduler().init() def init_plugin_scheduler(): """ 初始化插件定时器 """ Scheduler().init_plugin_jobs() ================================================ FILE: app/startup/workflow_initializer.py ================================================ from app.workflow import WorkFlowManager def init_workflow(): """ 初始化工作流 """ WorkFlowManager() def stop_workflow(): """ 停止工作流 """ WorkFlowManager().stop() ================================================ FILE: app/utils/__init__.py ================================================ ================================================ FILE: app/utils/common.py ================================================ import asyncio import inspect import time from functools import wraps from typing import Any, Callable from app.schemas import ImmediateException def retry(ExceptionToCheck: Any, tries: int = 3, delay: int = 3, backoff: int = 2, logger: Any = None): """ :param ExceptionToCheck: 需要捕获的异常 :param tries: 重试次数 :param delay: 延迟时间 :param backoff: 延迟倍数 :param logger: 日志对象 """ def deco_retry(f): def f_retry(*args, **kwargs): mtries, mdelay = tries, delay while mtries > 1: try: return f(*args, **kwargs) except ImmediateException: raise except ExceptionToCheck as e: msg = f"{str(e)}, {mdelay} 秒后重试 ..." if logger: logger.warn(msg) else: print(msg) time.sleep(mdelay) mtries -= 1 mdelay *= backoff return f(*args, **kwargs) async def async_f_retry(*args, **kwargs): mtries, mdelay = tries, delay while mtries > 1: try: return await f(*args, **kwargs) except ImmediateException: raise except ExceptionToCheck as e: msg = f"{str(e)}, {mdelay} 秒后重试 ..." if logger: logger.warn(msg) else: print(msg) await asyncio.sleep(mdelay) mtries -= 1 mdelay *= backoff return await f(*args, **kwargs) # 根据函数类型返回相应的包装器 if inspect.iscoroutinefunction(f): return async_f_retry else: return f_retry return deco_retry def log_execution_time(logger: Any = None): """ 记录函数执行时间的装饰器 :param logger: 日志记录器对象,用于记录异常信息 """ def decorator(func: Callable): @wraps(func) def wrapper(*args, **kwargs): start_time = time.time() result = func(*args, **kwargs) end_time = time.time() msg = f"{func.__name__} execution time: {end_time - start_time:.2f} seconds" if logger: logger.debug(msg) else: print(msg) return result @wraps(func) async def async_wrapper(*args, **kwargs): start_time = time.time() result = await func(*args, **kwargs) end_time = time.time() msg = f"{func.__name__} execution time: {end_time - start_time:.2f} seconds" if logger: logger.debug(msg) else: print(msg) return result # 根据函数类型返回相应的包装器 if inspect.iscoroutinefunction(func): return async_wrapper else: return wrapper return decorator ================================================ FILE: app/utils/crypto.py ================================================ import base64 import hashlib from hashlib import md5 from typing import Union, Optional, Tuple from Crypto import Random from Crypto.Cipher import AES from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives import hashes, serialization from cryptography.hazmat.primitives.asymmetric import padding as asym_padding, rsa class RSAUtils: @staticmethod def generate_rsa_key_pair(key_size: int = 2048) -> Tuple[str, str]: """ 生成RSA密钥对 :return: 私钥和公钥(Base64 编码,无标识符) """ # 生成RSA密钥对 private_key = rsa.generate_private_key( public_exponent=65537, key_size=key_size, ) public_key = private_key.public_key() # 导出私钥为DER格式 private_key_der = private_key.private_bytes( encoding=serialization.Encoding.DER, format=serialization.PrivateFormat.PKCS8, encryption_algorithm=serialization.NoEncryption() ) # 导出公钥为DER格式 public_key_der = public_key.public_bytes( encoding=serialization.Encoding.DER, format=serialization.PublicFormat.SubjectPublicKeyInfo ) # 将DER格式的密钥编码为Base64 private_key_b64 = base64.b64encode(private_key_der).decode("utf-8") public_key_b64 = base64.b64encode(public_key_der).decode("utf-8") return private_key_b64, public_key_b64 @staticmethod def verify_rsa_keys(private_key: Optional[str], public_key: Optional[str]) -> bool: """ 使用 RSA 验证私钥和公钥是否匹配 :param private_key: 私钥字符串 (Base64 编码,无标识符) :param public_key: 公钥字符串 (Base64 编码,无标识符) :return: 如果匹配则返回 True,否则返回 False """ if not private_key or not public_key: return False try: # 解码 Base64 编码的公钥和私钥 public_key_bytes = base64.b64decode(public_key) private_key_bytes = base64.b64decode(private_key) # 加载公钥 public_key = serialization.load_der_public_key(public_key_bytes, backend=default_backend()) # 加载私钥 private_key = serialization.load_der_private_key(private_key_bytes, password=None, backend=default_backend()) # 测试加解密 message = b'test' encrypted_message = public_key.encrypt( message, asym_padding.OAEP( mgf=asym_padding.MGF1(algorithm=hashes.SHA256()), algorithm=hashes.SHA256(), label=None ) ) decrypted_message = private_key.decrypt( encrypted_message, asym_padding.OAEP( mgf=asym_padding.MGF1(algorithm=hashes.SHA256()), algorithm=hashes.SHA256(), label=None ) ) return message == decrypted_message except Exception as e: print(f"RSA 密钥验证失败: {e}") return False class HashUtils: @staticmethod def md5(data: Union[str, bytes], encoding: str = "utf-8") -> str: """ 生成数据的MD5哈希值,并以字符串形式返回 :param data: 输入的数据,类型为字符串 :param encoding: 字符串编码类型,默认使用UTF-8 :return: 生成的MD5哈希字符串 """ if isinstance(data, str): data = data.encode(encoding) return hashlib.md5(data).hexdigest() @staticmethod def sha1(data: Union[str, bytes], encoding: str = "utf-8") -> str: """ 生成数据的SHA-1哈希值,并以字符串形式返回 :param data: 输入的数据,类型为字符串或字节 :param encoding: 字符串编码类型,默认使用UTF-8 :return: 生成的SHA-1哈希字符串 """ if isinstance(data, str): data = data.encode(encoding) return hashlib.sha1(data).hexdigest() @staticmethod def md5_bytes(data: Union[str, bytes], encoding: str = "utf-8") -> bytes: """ 生成数据的MD5哈希值,并以字节形式返回 :param data: 输入的数据,类型为字符串 :param encoding: 字符串编码类型,默认使用UTF-8 :return: 生成的MD5哈希二进制数据 """ if isinstance(data, str): data = data.encode(encoding) return hashlib.md5(data).digest() class CryptoJsUtils: @staticmethod def bytes_to_key(data: bytes, salt: bytes, output=48) -> bytes: """ 生成加密/解密所需的密钥和初始化向量 (IV) """ # extended from https://gist.github.com/gsakkis/4546068 assert len(salt) == 8, len(salt) data += salt key = md5(data).digest() final_key = key while len(final_key) < output: key = md5(key + data).digest() final_key += key return final_key[:output] @staticmethod def encrypt(message: bytes, passphrase: bytes) -> bytes: """ 使用 CryptoJS 兼容的加密策略对消息进行加密 """ # This is a modified copy of https://stackoverflow.com/questions/36762098/how-to-decrypt-password-from-javascript-cryptojs-aes-encryptpassword-passphras # 生成8字节的随机盐值 salt = Random.new().read(8) # 通过密码短语和盐值生成密钥和IV key_iv = CryptoJsUtils.bytes_to_key(passphrase, salt, 32 + 16) key = key_iv[:32] iv = key_iv[32:] # 创建AES加密器(CBC模式) aes = AES.new(key, AES.MODE_CBC, iv) # 应用PKCS#7填充 padding_length = 16 - (len(message) % 16) padding = bytes([padding_length] * padding_length) padded_message = message + padding # 加密消息 encrypted = aes.encrypt(padded_message) # 构建加密数据格式:b"Salted__" + salt + encrypted_message salted_encrypted = b"Salted__" + salt + encrypted # 返回Base64编码的加密数据 return base64.b64encode(salted_encrypted) @staticmethod def decrypt(encrypted: Union[str, bytes], passphrase: bytes) -> bytes: """ 使用 CryptoJS 兼容的解密策略对加密消息进行解密 """ # 确保输入是字节类型 if isinstance(encrypted, str): encrypted = encrypted.encode("utf-8") # Base64 解码 encrypted = base64.b64decode(encrypted) # 检查前8字节是否为 "Salted__" assert encrypted.startswith(b"Salted__"), "Invalid encrypted data format" # 提取盐值 salt = encrypted[8:16] # 通过密码短语和盐值生成密钥和IV key_iv = CryptoJsUtils.bytes_to_key(passphrase, salt, 32 + 16) key = key_iv[:32] iv = key_iv[32:] # 创建AES解密器(CBC模式) aes = AES.new(key, AES.MODE_CBC, iv) # 解密加密部分 decrypted_padded = aes.decrypt(encrypted[16:]) # 移除PKCS#7填充 padding_length = decrypted_padded[-1] if isinstance(padding_length, str): padding_length = ord(padding_length) decrypted = decrypted_padded[:-padding_length] return decrypted ================================================ FILE: app/utils/debounce.py ================================================ import asyncio import functools import inspect from abc import ABC, abstractmethod from threading import Timer, Lock from typing import Callable, Any, Optional from app.log import logger class BaseDebouncer(ABC): """ 防抖器的抽象基类。定义了防抖器的基本接口和日志功能。 所有防抖器实现类必须继承此类并实现其抽象方法。 """ def __init__(self, func: Callable, interval: float, *, leading: bool = False, enable_logging: bool = False, source: str = ""): """ 初始化防抖器实例。 :param func: 要防抖的函数或协程 :param interval: 防抖间隔,单位秒 :param leading: 是否启用前沿模式 :param enable_logging: 是否启用日志记录 :param source: 日志来源标识 """ self.func = func self.interval = interval self.leading = leading self.enable_logging = enable_logging self.source = source @abstractmethod def __call__(self, *args, **kwargs) -> None: """ 定义防抖调用的契约,子类必须实现。 """ pass @abstractmethod def cancel(self) -> None: """ 定义取消挂起调用的契约,子类必须实现。 """ pass def format_log(self, message: str) -> str: """ 格式化日志消息,加入 source 前缀。 """ return f"[{self.source}] {message}" if self.source else message def log(self, level: str, message: str): """ 根据日志级别记录日志。 """ if self.enable_logging: log_method = getattr(logger, level, logger.debug) log_method(self.format_log(message)) def log_debug(self, message: str): """ 记录调试日志。 """ self.log("debug", message) def log_info(self, message: str): """ 记录信息日志。 """ self.log("info", message) def log_warning(self, message: str): """ 记录警告日志。 """ self.log("warning", message) def error(self, message: str): """ 记录错误日志。 """ self.log("error", message) def critical(self, message: str): """ 记录严重错误日志。 """ self.log("critical", message) class Debouncer(BaseDebouncer): """ 同步防抖实现类 """ def __init__(self, *args, **kwargs): """ 初始化防抖器实例。 """ super().__init__(*args, **kwargs) self.timer: Optional[Timer] = None self.lock = Lock() # 用于前沿模式,标记是否处于“冷却”或“不应期” self.is_cooling_down = False def __call__(self, *args, **kwargs) -> None: """ 调用防抖函数。 :param args: :param kwargs: :return: """ with self.lock: if self.leading: self._call_leading(*args, **kwargs) else: self._call_trailing(*args, **kwargs) def _call_leading(self, *args, **kwargs): """ 前沿模式的逻辑。 """ # 如果不在冷却期,则立即执行 if not self.is_cooling_down: self.log_info("前沿模式: 立即执行函数。") self.func(*args, **kwargs) # 无论是否执行,都重置冷却计时器 if self.timer and self.timer.is_alive(): self.timer.cancel() # 设置自己进入冷却期 self.is_cooling_down = True # 在间隔结束后,将冷却状态解除 self.timer = Timer(self.interval, self._end_cool_down) self.timer.start() self.log_debug(f"前沿模式: 进入 {self.interval} 秒的冷却期。") def _end_cool_down(self): """ 计时器到期后,解除冷却状态 """ with self.lock: self.is_cooling_down = False self.log_debug("前沿模式: 冷却时间结束,可以再次立即执行。") def _call_trailing(self, *args, **kwargs): """ 后沿模式的逻辑。 """ # 【日志点】记录计时器被重置 if self.timer and self.timer.is_alive(): self.timer.cancel() self.log_debug("后沿模式: 检测到新的调用,已重置计时器。") def execute(): self.log_info("后沿模式: 计时结束,开始执行函数。") self.func(*args, **kwargs) self.timer = Timer(self.interval, execute) self.timer.start() self.log_debug(f"后沿模式: 计时器已启动,将在 {self.interval} 秒后执行。") def cancel(self) -> None: """ 取消任何挂起的调用,并重置状态。 """ with self.lock: if self.timer and self.timer.is_alive(): self.timer.cancel() self.timer = None self.log_info("防抖器被手动取消。") self.is_cooling_down = False class AsyncDebouncer(BaseDebouncer): """ 异步防抖实现类。 """ def __init__(self, *args, **kwargs): """ 初始化异步防抖器实例。 """ super().__init__(*args, **kwargs) self.task: Optional[asyncio.Task] = None self.lock = asyncio.Lock() self.is_cooling_down = False async def __call__(self, *args, **kwargs) -> None: """ 异步调用防抖函数。 """ async with self.lock: if self.leading: await self._call_leading(*args, **kwargs) else: await self._call_trailing(*args, **kwargs) async def _call_leading(self, *args, **kwargs): """ 前沿模式的逻辑。 """ if not self.is_cooling_down: self.log_info("前沿模式 (async): 立即执行协程。") await self.func(*args, **kwargs) if self.task and not self.task.done(): self.task.cancel() self.is_cooling_down = True self.task = asyncio.create_task(self._end_cool_down()) self.log_debug(f"前沿模式 (async): 进入 {self.interval} 秒的冷却期。") async def _end_cool_down(self): """ 计时器到期后,解除冷却状态 """ await asyncio.sleep(self.interval) async with self.lock: self.is_cooling_down = False self.log_debug("前沿模式 (async): 冷却时间结束。") async def _call_trailing(self, *args, **kwargs): """ 后沿模式的逻辑。 """ if self.task and not self.task.done(): self.task.cancel() self.log_debug("后沿模式 (async): 检测到新的调用,已取消旧任务。") self.task = asyncio.create_task(self._delayed_execute(*args, **kwargs)) self.log_debug(f"后沿模式 (async): 任务已创建,将在 {self.interval} 秒后执行。") async def _delayed_execute(self, *args, **kwargs): """ 延迟执行实际的协程函数。 """ try: await asyncio.sleep(self.interval) self.log_info("后沿模式 (async): 延迟结束,开始执行协程。") await self.func(*args, **kwargs) except asyncio.CancelledError: # 任务被取消是正常行为,无需处理 pass async def cancel(self) -> None: """ 取消任何挂起的调用,并重置状态。 """ async with self.lock: if self.task and not self.task.done(): self.task.cancel() self.task = None self.log_info("异步防抖器被手动取消。") self.is_cooling_down = False def debounce(interval: float, *, leading: bool = False, enable_logging: bool = False, source: str = "") -> Callable: """ 支持同步和异步的防抖装饰器工厂。 """ def decorator(func: Callable) -> Callable: # 检查函数类型,并选择合适的引擎 if inspect.iscoroutinefunction(func): # 异步函数,使用 AsyncDebouncer instance = AsyncDebouncer(func, interval, leading=leading, enable_logging=enable_logging, source=source) @functools.wraps(func) async def async_wrapper(*args, **kwargs) -> Any: await instance(*args, **kwargs) async_wrapper.cancel = instance.cancel return async_wrapper else: # 同步函数,使用 Debouncer instance = Debouncer(func, interval, leading=leading, enable_logging=enable_logging, source=source) @functools.wraps(func) def wrapper(*args, **kwargs) -> Any: instance(*args, **kwargs) wrapper.cancel = instance.cancel return wrapper return decorator ================================================ FILE: app/utils/dom.py ================================================ from typing import Union class DomUtils: @staticmethod def tag_value(tag_item, tag_name: str, attname: str = "", default: Union[str, int] = None): """ 解析XML标签值 """ tagNames = tag_item.getElementsByTagName(tag_name) if tagNames: if attname: attvalue = tagNames[0].getAttribute(attname) if attvalue: return attvalue else: firstChild = tagNames[0].firstChild if firstChild: return firstChild.data return default @staticmethod def add_node(doc, parent, name: str, value: str = None): """ 添加一个DOM节点 """ node = doc.createElement(name) parent.appendChild(node) if value is not None: text = doc.createTextNode(str(value)) node.appendChild(text) return node ================================================ FILE: app/utils/gc.py ================================================ """ 内存回收装饰器模块 提供装饰器用于在函数执行后立即回收内存 """ import gc import functools import psutil import os from typing import Callable, Any, Optional from app.log import logger def memory_gc(force_collect: bool = True, log_memory_usage: bool = False) -> Callable: """ 内存回收装饰器 Args: force_collect: 是否强制执行垃圾回收,默认True log_memory_usage: 是否记录内存使用日志,默认False Returns: 装饰器函数 """ def decorator(func: Callable) -> Callable: @functools.wraps(func) def wrapper(*args, **kwargs) -> Any: # 记录函数执行前的内存使用情况 memory_before = None memory_after = None if log_memory_usage: memory_before = get_memory_usage() logger.info(f"函数 {func.__name__} 执行前内存使用: {memory_before}") try: # 执行原函数 result = func(*args, **kwargs) # 记录函数执行后的内存使用情况 if log_memory_usage: memory_after = get_memory_usage() logger.info(f"函数 {func.__name__} 执行后内存使用: {memory_after}") if memory_before: memory_diff = memory_after - memory_before logger.info(f"函数 {func.__name__} 内存变化: {memory_diff} MB") return result finally: # 强制垃圾回收 if force_collect: collected = gc.collect() if log_memory_usage: logger.info(f"函数 {func.__name__} 垃圾回收完成,回收对象数: {collected}") # 记录垃圾回收后的内存使用情况 if log_memory_usage: memory_after_gc = get_memory_usage() logger.info(f"函数 {func.__name__} 垃圾回收后内存使用: {memory_after_gc}") if memory_after: memory_freed = memory_after - memory_after_gc logger.info(f"函数 {func.__name__} 释放内存: {memory_freed} MB") return wrapper return decorator def get_memory_usage() -> float: """ 获取当前进程的内存使用情况(MB) Returns: 内存使用量(MB) """ try: process = psutil.Process(os.getpid()) memory_info = process.memory_info() return memory_info.rss / 1024 / 1024 # 转换为MB except Exception as e: logger.warning(f"获取内存使用情况失败: {e}") return 0.0 def memory_monitor(threshold_mb: Optional[float] = None) -> Callable: """ 内存监控装饰器,当内存使用超过阈值时自动触发垃圾回收 Args: threshold_mb: 内存阈值(MB),超过此值将触发垃圾回收 Returns: 装饰器函数 """ def decorator(func: Callable) -> Callable: @functools.wraps(func) def wrapper(*args, **kwargs) -> Any: # 检查内存使用情况 current_memory = get_memory_usage() if threshold_mb and current_memory > threshold_mb: logger.warning(f"内存使用超过阈值 {threshold_mb}MB,当前使用: {current_memory}MB") collected = gc.collect() logger.info(f"自动垃圾回收完成,回收对象数: {collected}") # 执行原函数 result = func(*args, **kwargs) # 执行后再次检查并回收 if threshold_mb: memory_after = get_memory_usage() if memory_after > threshold_mb: collected = gc.collect() logger.info(f"函数执行后垃圾回收完成,回收对象数: {collected}") return result return wrapper return decorator # 便捷的装饰器别名 memory_cleanup = memory_gc auto_gc = memory_gc(force_collect=True, log_memory_usage=True) memory_watch = memory_monitor ================================================ FILE: app/utils/http.py ================================================ import re import sys from contextlib import contextmanager, asynccontextmanager from pathlib import Path from typing import Any, Optional, Tuple, Union import chardet import httpx import requests import urllib3 from requests import Response, Session from urllib3.exceptions import InsecureRequestWarning from urllib.parse import unquote, quote from app.core.config import settings from app.log import logger urllib3.disable_warnings(InsecureRequestWarning) def _url_decode_if_latin(original: str) -> str: """ 解码URL编码的字符串,只解码文本,二进程数据保持不变 :param original: URL编码字符串 :return: 解码后的字符串或原始二进制数据 """ try: # 先解码 decoded = unquote(original, encoding='latin-1') # 再完整编码 fully_encoded = quote(decoded, safe='') # 验证 decoded_again = unquote(fully_encoded, encoding='latin-1') if decoded_again == decoded: return decoded except Exception as e: logger.error(f"latin-1解码URL编码失败:{e}") return original def cookie_parse(cookies_str: str, array: bool = False) -> Union[list, dict]: """ 解析cookie,转化为字典或者数组 :param cookies_str: cookie字符串 :param array: 是否转化为数组 :return: 字典或者数组 """ if not cookies_str: return {} cookie_dict = {} cookies = cookies_str.split(";") for cookie in cookies: cstr = cookie.split("=", 1) # 只分割第一个=,因为value可能包含= if len(cstr) > 1: # URL解码Cookie值(但保留Cookie名不解码) cookie_dict[cstr[0].strip()] = _url_decode_if_latin(cstr[1].strip()) if array: return [{"name": k, "value": v} for k, v in cookie_dict.items()] return cookie_dict def get_caller(): """ 获取调用者的名称,识别是否为插件调用 """ # 调用者名称 caller_name = None try: frame = sys._getframe(3) # noqa except (AttributeError, ValueError): return None while frame: filepath = Path(frame.f_code.co_filename) parts = filepath.parts if "app" in parts: if not caller_name and "plugins" in parts: try: plugins_index = parts.index("plugins") if plugins_index + 1 < len(parts): plugin_candidate = parts[plugins_index + 1] if plugin_candidate != "__init__.py": caller_name = plugin_candidate break except ValueError: pass if "main.py" in parts: break elif len(parts) != 1: break try: frame = frame.f_back except AttributeError: break return caller_name class RequestUtils: """ HTTP请求工具类,提供同步HTTP请求的基本功能 """ def __init__(self, headers: dict = None, ua: str = None, cookies: Union[str, dict] = None, proxies: dict = None, session: Session = None, timeout: int = None, referer: str = None, content_type: str = None, accept_type: str = None): """ :param headers: 请求头部信息 :param ua: User-Agent字符串 :param cookies: Cookie字符串或字典 :param proxies: 代理设置 :param session: requests.Session实例,如果为None则创建新的Session :param timeout: 请求超时时间,默认为20秒 :param referer: Referer头部信息 :param content_type: 请求的Content-Type,默认为 "application/x-www-form-urlencoded; charset=UTF-8" :param accept_type: Accept头部信息,默认为 "application/json" """ self._proxies = proxies self._session = session self._timeout = timeout or 20 if not content_type: content_type = "application/x-www-form-urlencoded; charset=UTF-8" if headers: self._headers = headers else: if ua and ua == settings.USER_AGENT: caller_name = get_caller() if caller_name: ua = f"{settings.USER_AGENT} Plugin/{caller_name}" self._headers = { "User-Agent": ua, "Content-Type": content_type, "Accept": accept_type, "referer": referer } if cookies: if isinstance(cookies, str): self._cookies = cookie_parse(cookies) else: self._cookies = cookies else: self._cookies = None @contextmanager def response_manager(self, method: str, url: str, **kwargs): """ 响应管理器上下文管理器,确保响应对象被正确关闭 :param method: HTTP方法 :param url: 请求的URL :param kwargs: 其他请求参数 """ response = None try: response = self.request(method=method, url=url, **kwargs) yield response finally: if response: try: response.close() except Exception as e: logger.debug(f"关闭响应失败: {e}") def request(self, method: str, url: str, raise_exception: bool = False, **kwargs) -> Optional[Response]: """ 发起HTTP请求 :param method: HTTP方法,如 get, post, put 等 :param url: 请求的URL :param raise_exception: 是否在发生异常时抛出异常,否则默认拦截异常返回None :param kwargs: 其他请求参数,如headers, cookies, proxies等 :return: HTTP响应对象 :raises: requests.exceptions.RequestException 仅raise_exception为True时会抛出 """ if self._session is None: req_method = requests.request else: req_method = self._session.request kwargs.setdefault("headers", self._headers) kwargs.setdefault("cookies", self._cookies) kwargs.setdefault("proxies", self._proxies) kwargs.setdefault("timeout", self._timeout) kwargs.setdefault("verify", False) kwargs.setdefault("stream", False) try: return req_method(method, url, **kwargs) except requests.exceptions.RequestException as e: # 获取更详细的错误信息 error_msg = str(e) if str(e) else f"未知网络错误 (URL: {url}, Method: {method.upper()})" logger.debug(f"请求失败: {error_msg}") if raise_exception: raise return None def get(self, url: str, params: dict = None, **kwargs) -> Optional[str]: """ 发送GET请求 :param url: 请求的URL :param params: 请求的参数 :param kwargs: 其他请求参数,如headers, cookies, proxies等 :return: 响应的内容,若发生RequestException则返回None """ response = self.request(method="get", url=url, params=params, **kwargs) if response: try: content = str(response.content, "utf-8") return content except Exception as e: logger.debug(f"处理响应内容失败: {e}") return None finally: response.close() return None def post(self, url: str, data: Any = None, json: dict = None, **kwargs) -> Optional[Response]: """ 发送POST请求 :param url: 请求的URL :param data: 请求的数据 :param json: 请求的JSON数据 :param kwargs: 其他请求参数,如headers, cookies, proxies等 :return: HTTP响应对象,若发生RequestException则返回None """ return self.request(method="post", url=url, data=data, json=json, **kwargs) def put(self, url: str, data: Any = None, **kwargs) -> Optional[Response]: """ 发送PUT请求 :param url: 请求的URL :param data: 请求的数据 :param kwargs: 其他请求参数,如headers, cookies, proxies等 :return: HTTP响应对象,若发生RequestException则返回None """ return self.request(method="put", url=url, data=data, **kwargs) def get_res(self, url: str, params: dict = None, data: Any = None, json: dict = None, allow_redirects: bool = True, raise_exception: bool = False, **kwargs) -> Optional[Response]: """ 发送GET请求并返回响应对象 :param url: 请求的URL :param params: 请求的参数 :param data: 请求的数据 :param json: 请求的JSON数据 :param allow_redirects: 是否允许重定向 :param raise_exception: 是否在发生异常时抛出异常,否则默认拦截异常返回None :param kwargs: 其他请求参数,如headers, cookies, proxies等 :return: HTTP响应对象,若发生RequestException则返回None :raises: requests.exceptions.RequestException 仅raise_exception为True时会抛出 """ return self.request(method="get", url=url, params=params, data=data, json=json, allow_redirects=allow_redirects, raise_exception=raise_exception, **kwargs) @contextmanager def get_stream(self, url: str, params: dict = None, **kwargs): """ 获取流式响应的上下文管理器,适用于大文件下载 :param url: 请求的URL :param params: 请求的参数 :param kwargs: 其他请求参数 """ kwargs['stream'] = True response = self.request(method="get", url=url, params=params, **kwargs) try: yield response finally: if response: response.close() def post_res(self, url: str, data: Any = None, params: dict = None, allow_redirects: bool = True, files: Any = None, json: dict = None, raise_exception: bool = False, **kwargs) -> Optional[Response]: """ 发送POST请求并返回响应对象 :param url: 请求的URL :param data: 请求的数据 :param params: 请求的参数 :param allow_redirects: 是否允许重定向 :param files: 请求的文件 :param json: 请求的JSON数据 :param raise_exception: 是否在发生异常时抛出异常,否则默认拦截异常返回None :param kwargs: 其他请求参数,如headers, cookies, proxies等 :return: HTTP响应对象,若发生RequestException则返回None :raises: requests.exceptions.RequestException 仅raise_exception为True时会抛出 """ return self.request(method="post", url=url, data=data, params=params, allow_redirects=allow_redirects, files=files, json=json, raise_exception=raise_exception, **kwargs) def put_res(self, url: str, data: Any = None, params: dict = None, allow_redirects: bool = True, files: Any = None, json: dict = None, raise_exception: bool = False, **kwargs) -> Optional[Response]: """ 发送PUT请求并返回响应对象 :param url: 请求的URL :param data: 请求的数据 :param params: 请求的参数 :param allow_redirects: 是否允许重定向 :param files: 请求的文件 :param json: 请求的JSON数据 :param raise_exception: 是否在发生异常时抛出异常,否则默认拦截异常返回None :param kwargs: 其他请求参数,如headers, cookies, proxies等 :return: HTTP响应对象,若发生RequestException则返回None :raises: requests.exceptions.RequestException 仅raise_exception为True时会抛出 """ return self.request(method="put", url=url, data=data, params=params, allow_redirects=allow_redirects, files=files, json=json, raise_exception=raise_exception, **kwargs) def delete_res(self, url: str, data: Any = None, params: dict = None, allow_redirects: bool = True, raise_exception: bool = False, **kwargs) -> Optional[Response]: """ 发送DELETE请求并返回响应对象 :param url: 请求的URL :param data: 请求的数据 :param params: 请求的参数 :param allow_redirects: 是否允许重定向 :param raise_exception: 是否在发生异常时抛出异常,否则默认拦截异常返回None :param kwargs: 其他请求参数,如headers, cookies, proxies等 :return: HTTP响应对象,若发生RequestException则返回None :raises: requests.exceptions.RequestException 仅raise_exception为True时会抛出 """ return self.request(method="delete", url=url, data=data, params=params, allow_redirects=allow_redirects, raise_exception=raise_exception, **kwargs) def get_json(self, url: str, params: dict = None, **kwargs) -> Optional[dict]: """ 发送GET请求并返回JSON数据,自动关闭连接 :param url: 请求的URL :param params: 请求的参数 :param kwargs: 其他请求参数 :return: JSON数据,若发生异常则返回None """ response = self.request(method="get", url=url, params=params, **kwargs) if response: try: data = response.json() return data except Exception as e: logger.debug(f"解析JSON失败: {e}") return None finally: response.close() return None def post_json(self, url: str, data: Any = None, json: dict = None, **kwargs) -> Optional[dict]: """ 发送POST请求并返回JSON数据,自动关闭连接 :param url: 请求的URL :param data: 请求的数据 :param json: 请求的JSON数据 :param kwargs: 其他请求参数 :return: JSON数据,若发生异常则返回None """ if json is None: json = {} response = self.request(method="post", url=url, data=data, json=json, **kwargs) if response: try: data = response.json() return data except Exception as e: logger.debug(f"解析JSON失败: {e}") return None finally: response.close() return None @staticmethod def parse_cache_control(header: str) -> Tuple[str, Optional[int]]: """ 解析 Cache-Control 头,返回 cache_directive 和 max_age :param header: Cache-Control 头部的字符串 :return: cache_directive 和 max_age """ cache_directive = "" max_age = None if not header: return cache_directive, max_age directives = [directive.strip() for directive in header.split(",")] for directive in directives: if directive.startswith("max-age"): try: max_age = int(directive.split("=")[1]) except Exception as e: logger.debug(f"Invalid max-age directive in Cache-Control header: {directive}, {e}") elif directive in {"no-cache", "private", "public", "no-store", "must-revalidate"}: cache_directive = directive return cache_directive, max_age @staticmethod def generate_cache_headers(etag: Optional[str], cache_control: Optional[str] = "public", max_age: Optional[int] = 86400) -> dict: """ 生成 HTTP 响应的 ETag 和 Cache-Control 头 :param etag: 响应的 ETag 值。如果为 None,则不添加 ETag 头部。 :param cache_control: Cache-Control 指令,例如 "public"、"private" 等。默认为 "public" :param max_age: Cache-Control 的 max-age 值(秒)。默认为 86400 秒(1天) :return: HTTP 头部的字典 """ cache_headers = {} if etag: cache_headers["ETag"] = etag if cache_control and max_age is not None: cache_headers["Cache-Control"] = f"{cache_control}, max-age={max_age}" elif cache_control: cache_headers["Cache-Control"] = cache_control elif max_age is not None: cache_headers["Cache-Control"] = f"max-age={max_age}" return cache_headers @staticmethod def detect_encoding_from_html_response(response: Response, performance_mode: bool = False, confidence_threshold: float = 0.8): """ 根据HTML响应内容探测编码信息 :param response: HTTP 响应对象 :param performance_mode: 是否使用性能模式,默认为 False (兼容模式) :param confidence_threshold: chardet 检测置信度阈值,默认为 0.8 :return: 解析得到的字符编码 """ fallback_encoding = None try: if not performance_mode: # 兼容模式:使用chardet分析后,再处理 BOM 和 meta 信息 # 1. 使用 chardet 库进一步分析内容 detection = chardet.detect(response.content) if detection["confidence"] > confidence_threshold: return detection.get("encoding") # 保存 chardet 的结果备用 fallback_encoding = detection.get("encoding") # 2. 检查响应体中的 BOM 标记(例如 UTF-8 BOM) if response.content[:3] == b"\xef\xbb\xbf": # UTF-8 BOM return "utf-8" # 3. 如果是 HTML 响应体,检查其中的 标签 if re.search(r"charset=[\"']?utf-8[\"']?", response.text, re.IGNORECASE): return "utf-8" # 4. 尝试从 response headers 中获取编码信息 content_type = response.headers.get("Content-Type", "") if re.search(r"charset=[\"']?utf-8[\"']?", content_type, re.IGNORECASE): return "utf-8" else: # 性能模式:优先从 headers 和 BOM 标记获取,最后使用 chardet 分析 # 1. 尝试从 response headers 中获取编码信息 content_type = response.headers.get("Content-Type", "") if re.search(r"charset=[\"']?utf-8[\"']?", content_type, re.IGNORECASE): return "utf-8" # 2. 检查响应体中的 BOM 标记(例如 UTF-8 BOM) if response.content[:3] == b"\xef\xbb\xbf": return "utf-8" # 3. 如果是 HTML 响应体,检查其中的 标签 if re.search(r"charset=[\"']?utf-8[\"']?", response.text, re.IGNORECASE): return "utf-8" # 4. 使用 chardet 库进一步分析内容 detection = chardet.detect(response.content) if detection.get("confidence", 0) > confidence_threshold: return detection.get("encoding") # 保存 chardet 的结果备用 fallback_encoding = detection.get("encoding") # 5. 如果上述方法都无法确定,信任 chardet 的结果(即使置信度较低),否则返回默认字符集 return fallback_encoding or "utf-8" except Exception as e: logger.debug(f"Error when detect_encoding_from_response: {str(e)}") return fallback_encoding or "utf-8" @staticmethod def get_decoded_html_content(response: Response, performance_mode: bool = False, confidence_threshold: float = 0.8) -> str: """ 获取HTML响应的解码文本内容 :param response: HTTP 响应对象 :param performance_mode: 是否使用性能模式,默认为 False (兼容模式) :param confidence_threshold: chardet 检测置信度阈值,默认为 0.8 :return: 解码后的响应文本内容 """ try: if not response: return "" if response.content: # 1. 获取编码信息 encoding = (RequestUtils.detect_encoding_from_html_response(response, performance_mode, confidence_threshold) or response.apparent_encoding) # 2. 根据解析得到的编码进行解码 try: # 尝试用推测的编码解码 return response.content.decode(encoding) except Exception as e: logger.debug(f"Decoding failed, error message: {str(e)}") # 如果解码失败,尝试 fallback 使用 apparent_encoding response.encoding = response.apparent_encoding return response.text else: return response.text except Exception as e: logger.debug(f"Error when getting decoded content: {str(e)}") return response.text class AsyncRequestUtils: """ 异步HTTP请求工具类,提供异步HTTP请求的基本功能 """ def __init__(self, headers: dict = None, ua: str = None, cookies: Union[str, dict] = None, proxies: dict = None, client: httpx.AsyncClient = None, timeout: int = None, referer: str = None, content_type: str = None, accept_type: str = None): """ :param headers: 请求头部信息 :param ua: User-Agent字符串 :param cookies: Cookie字符串或字典 :param proxies: 代理设置 :param client: httpx.AsyncClient实例,如果为None则创建新的客户端 :param timeout: 请求超时时间,默认为20秒 :param referer: Referer头部信息 :param content_type: 请求的Content-Type,默认为 "application/x-www-form-urlencoded; charset=UTF-8" :param accept_type: Accept头部信息,默认为 "application/json" """ self._proxies = self._convert_proxies_for_httpx(proxies) self._client = client self._timeout = timeout or 20 if not content_type: content_type = "application/x-www-form-urlencoded; charset=UTF-8" if headers: # 过滤掉None值的headers self._headers = {k: v for k, v in headers.items() if v is not None} else: if ua and ua == settings.USER_AGENT: caller_name = get_caller() if caller_name: ua = f"{settings.USER_AGENT} Plugin/{caller_name}" self._headers = {} if ua: self._headers["User-Agent"] = ua if content_type: self._headers["Content-Type"] = content_type if accept_type: self._headers["Accept"] = accept_type if referer: self._headers["referer"] = referer if cookies: if isinstance(cookies, str): self._cookies = cookie_parse(cookies) else: self._cookies = cookies else: self._cookies = None @staticmethod def _convert_proxies_for_httpx(proxies: dict) -> Optional[str]: """ 将requests格式的代理配置转换为httpx兼容的格式 :param proxies: requests格式的代理配置 {"http": "http://proxy:port", "https": "http://proxy:port"} :return: httpx兼容的代理字符串或None """ if not proxies: return None # 如果已经是字符串格式,直接返回 if isinstance(proxies, str): return proxies # 如果是字典格式,提取http或https代理 if isinstance(proxies, dict): # 优先使用https代理,如果没有则使用http代理 proxy_url = proxies.get("https") or proxies.get("http") if proxy_url: return proxy_url return None @asynccontextmanager async def response_manager(self, method: str, url: str, **kwargs): """ 异步响应管理器上下文管理器,确保响应对象被正确关闭 :param method: HTTP方法 :param url: 请求的URL :param kwargs: 其他请求参数 """ response = None try: response = await self.request(method=method, url=url, **kwargs) yield response finally: if response: try: await response.aclose() except Exception as e: logger.debug(f"关闭异步响应失败: {e}") async def request(self, method: str, url: str, raise_exception: bool = False, **kwargs) -> Optional[httpx.Response]: """ 发起异步HTTP请求 :param method: HTTP方法,如 get, post, put 等 :param url: 请求的URL :param raise_exception: 是否在发生异常时抛出异常,否则默认拦截异常返回None :param kwargs: 其他请求参数,如headers, cookies, proxies等 :return: HTTP响应对象 :raises: httpx.RequestError 仅raise_exception为True时会抛出 """ if self._client is None: # 创建临时客户端 async with httpx.AsyncClient( proxy=self._proxies, timeout=self._timeout, verify=False, follow_redirects=True, cookies=self._cookies # 在创建客户端时传入Cookie ) as client: return await self._make_request(client, method, url, raise_exception, **kwargs) else: return await self._make_request(self._client, method, url, raise_exception, **kwargs) async def _make_request(self, client: httpx.AsyncClient, method: str, url: str, raise_exception: bool = False, **kwargs) -> Optional[httpx.Response]: """ 执行实际的异步请求 """ kwargs.setdefault("headers", self._headers) # Cookie已经在AsyncClient创建时设置,不要在request时再设置,否则会被覆盖 # kwargs.setdefault("cookies", self._cookies) try: return await client.request(method, url, **kwargs) except httpx.RequestError as e: # 获取更详细的错误信息 error_msg = str(e) if str(e) else f"未知网络错误 (URL: {url}, Method: {method.upper()})" logger.debug(f"异步请求失败: {error_msg}") if raise_exception: raise return None async def get(self, url: str, params: dict = None, **kwargs) -> Optional[str]: """ 发送异步GET请求 :param url: 请求的URL :param params: 请求的参数 :param kwargs: 其他请求参数,如headers, cookies, proxies等 :return: 响应的内容,若发生RequestError则返回None """ response = await self.request(method="get", url=url, params=params, **kwargs) if response: try: content = response.text return content except Exception as e: logger.debug(f"处理异步响应内容失败: {e}") return None finally: await response.aclose() # 确保连接被关闭 return None async def post(self, url: str, data: Any = None, json: dict = None, **kwargs) -> Optional[httpx.Response]: """ 发送异步POST请求 :param url: 请求的URL :param data: 请求的数据 :param json: 请求的JSON数据 :param kwargs: 其他请求参数,如headers, cookies, proxies等 :return: HTTP响应对象,若发生RequestError则返回None """ return await self.request(method="post", url=url, data=data, json=json, **kwargs) async def put(self, url: str, data: Any = None, **kwargs) -> Optional[httpx.Response]: """ 发送异步PUT请求 :param url: 请求的URL :param data: 请求的数据 :param kwargs: 其他请求参数,如headers, cookies, proxies等 :return: HTTP响应对象,若发生RequestError则返回None """ return await self.request(method="put", url=url, data=data, **kwargs) async def get_res(self, url: str, params: dict = None, data: Any = None, json: dict = None, allow_redirects: bool = True, raise_exception: bool = False, **kwargs) -> Optional[httpx.Response]: """ 发送异步GET请求并返回响应对象 :param url: 请求的URL :param params: 请求的参数 :param data: 请求的数据 :param json: 请求的JSON数据 :param allow_redirects: 是否允许重定向 :param raise_exception: 是否在发生异常时抛出异常,否则默认拦截异常返回None :param kwargs: 其他请求参数,如headers, cookies, proxies等 :return: HTTP响应对象,若发生RequestError则返回None :raises: httpx.RequestError 仅raise_exception为True时会抛出 """ return await self.request(method="get", url=url, params=params, data=data, json=json, follow_redirects=allow_redirects, raise_exception=raise_exception, **kwargs) @asynccontextmanager async def get_stream(self, url: str, params: dict = None, **kwargs): """ 获取异步流式响应的上下文管理器,适用于大文件下载 :param url: 请求的URL :param params: 请求的参数 :param kwargs: 其他请求参数 """ kwargs['stream'] = True response = await self.request(method="get", url=url, params=params, **kwargs) try: yield response finally: if response: await response.aclose() async def post_res(self, url: str, data: Any = None, params: dict = None, allow_redirects: bool = True, files: Any = None, json: dict = None, raise_exception: bool = False, **kwargs) -> Optional[httpx.Response]: """ 发送异步POST请求并返回响应对象 :param url: 请求的URL :param data: 请求的数据 :param params: 请求的参数 :param allow_redirects: 是否允许重定向 :param files: 请求的文件 :param json: 请求的JSON数据 :param raise_exception: 是否在发生异常时抛出异常,否则默认拦截异常返回None :param kwargs: 其他请求参数,如headers, cookies, proxies等 :return: HTTP响应对象,若发生RequestError则返回None :raises: httpx.RequestError 仅raise_exception为True时会抛出 """ return await self.request(method="post", url=url, data=data, params=params, follow_redirects=allow_redirects, files=files, json=json, raise_exception=raise_exception, **kwargs) async def put_res(self, url: str, data: Any = None, params: dict = None, allow_redirects: bool = True, files: Any = None, json: dict = None, raise_exception: bool = False, **kwargs) -> Optional[httpx.Response]: """ 发送异步PUT请求并返回响应对象 :param url: 请求的URL :param data: 请求的数据 :param params: 请求的参数 :param allow_redirects: 是否允许重定向 :param files: 请求的文件 :param json: 请求的JSON数据 :param raise_exception: 是否在发生异常时抛出异常,否则默认拦截异常返回None :param kwargs: 其他请求参数,如headers, cookies, proxies等 :return: HTTP响应对象,若发生RequestError则返回None :raises: httpx.RequestError 仅raise_exception为True时会抛出 """ return await self.request(method="put", url=url, data=data, params=params, follow_redirects=allow_redirects, files=files, json=json, raise_exception=raise_exception, **kwargs) async def delete_res(self, url: str, data: Any = None, params: dict = None, allow_redirects: bool = True, raise_exception: bool = False, **kwargs) -> Optional[httpx.Response]: """ 发送异步DELETE请求并返回响应对象 :param url: 请求的URL :param data: 请求的数据 :param params: 请求的参数 :param allow_redirects: 是否允许重定向 :param raise_exception: 是否在发生异常时抛出异常,否则默认拦截异常返回None :param kwargs: 其他请求参数,如headers, cookies, proxies等 :return: HTTP响应对象,若发生RequestError则返回None :raises: httpx.RequestError 仅raise_exception为True时会抛出 """ return await self.request(method="delete", url=url, data=data, params=params, follow_redirects=allow_redirects, raise_exception=raise_exception, **kwargs) async def get_json(self, url: str, params: dict = None, **kwargs) -> Optional[dict]: """ 发送异步GET请求并返回JSON数据,自动关闭连接 :param url: 请求的URL :param params: 请求的参数 :param kwargs: 其他请求参数 :return: JSON数据,若发生异常则返回None """ response = await self.request(method="get", url=url, params=params, **kwargs) if response: try: data = response.json() return data except Exception as e: logger.debug(f"解析异步JSON失败: {e}") return None finally: await response.aclose() return None async def post_json(self, url: str, data: Any = None, json: dict = None, **kwargs) -> Optional[dict]: """ 发送异步POST请求并返回JSON数据,自动关闭连接 :param url: 请求的URL :param data: 请求的数据 :param json: 请求的JSON数据 :param kwargs: 其他请求参数 :return: JSON数据,若发生异常则返回None """ if json is None: json = {} response = await self.request(method="post", url=url, data=data, json=json, **kwargs) if response: try: data = response.json() return data except Exception as e: logger.debug(f"解析异步JSON失败: {e}") return None finally: await response.aclose() return None ================================================ FILE: app/utils/ip.py ================================================ import ipaddress import socket from urllib.parse import urlparse class IpUtils: @staticmethod def is_ipv4(ip): """ 判断是不是ipv4 """ try: socket.inet_pton(socket.AF_INET, ip) except AttributeError: # no inet_pton here,sorry try: socket.inet_aton(ip) except socket.error: return False return ip.count('.') == 3 except socket.error: # not a valid ip return False return True @staticmethod def is_ipv6(ip): """ 判断是不是ipv6 """ try: socket.inet_pton(socket.AF_INET6, ip) except socket.error: # not a valid ip return False return True @staticmethod def is_internal(hostname): """ 判断一个host是内网还是外网 """ hostname = urlparse(hostname).hostname if IpUtils.is_ip(hostname): return IpUtils.is_private_ip(hostname) else: return IpUtils.is_internal_domain(hostname) @staticmethod def is_ip(addr): """ 判断是不是ip """ try: socket.inet_aton(addr) return True except socket.error: return False @staticmethod def is_internal_domain(domain): """ 判断域名是否为内部域名 """ # 获取域名对应的 IP 地址 try: ip = socket.gethostbyname(domain) except socket.error: return False # 判断 IP 地址是否属于内网 IP 地址范围 return IpUtils.is_private_ip(ip) @staticmethod def is_private_ip(ip_str): """ 判断是不是内网ip """ try: return ipaddress.ip_address(ip_str.strip()).is_private except Exception as e: print(str(e)) return False ================================================ FILE: app/utils/limit.py ================================================ import functools import inspect import threading import time from collections import deque from typing import Any, Tuple, List, Callable, Optional from app.log import logger from app.schemas import RateLimitExceededException, LimitException # 抽象基类 class BaseRateLimiter: """ 限流器基类,定义了限流器的通用接口,用于子类实现不同的限流策略 所有限流器都必须实现 can_call、reset 方法 """ def __init__(self, source: str = "", enable_logging: bool = True): """ 初始化 BaseRateLimiter 实例 :param source: 业务来源或上下文信息,默认为空字符串 :param enable_logging: 是否启用日志记录,默认为 True """ self.source = source self.enable_logging = enable_logging self.lock = threading.Lock() @property def reset_on_success(self) -> bool: """ 是否在成功调用后自动重置限流器状态,默认为 False """ return False def can_call(self) -> Tuple[bool, str]: """ 检查是否可以进行调用 :return: 如果允许调用,返回 True 和空消息,否则返回 False 和限流消息 """ raise NotImplementedError def reset(self): """ 重置限流状态 """ raise NotImplementedError def trigger_limit(self): """ 触发限流 """ pass def record_call(self): """ 记录一次调用 """ pass def format_log(self, message: str) -> str: """ 格式化日志消息 :param message: 日志内容 :return: 格式化后的日志消息 """ return f"[{self.source}] {message}" if self.source else message def log(self, level: str, message: str): """ 根据日志级别记录日志 :param level: 日志级别 :param message: 日志内容 """ if self.enable_logging: log_method = getattr(logger, level, None) if not callable(log_method): log_method = logger.info log_method(self.format_log(message)) def log_info(self, message: str): """ 记录信息日志 """ self.log("info", message) def log_warning(self, message: str): """ 记录警告日志 """ self.log("warning", message) # 指数退避限流器 class ExponentialBackoffRateLimiter(BaseRateLimiter): """ 基于指数退避的限流器,用于处理单次调用频率的控制 每次触发限流时,等待时间会成倍增加,直到达到最大等待时间 """ def __init__( self, base_wait: float = 60.0, max_wait: float = 600.0, backoff_factor: float = 2.0, source: str = "", enable_logging: bool = True, ): """ 初始化 ExponentialBackoffRateLimiter 实例 :param base_wait: 基础等待时间(秒),默认值为 60 秒(1 分钟) :param max_wait: 最大等待时间(秒),默认值为 600 秒(10 分钟) :param backoff_factor: 等待时间的递增倍数,默认值为 2.0,表示指数退避 :param source: 业务来源或上下文信息,默认值为 "" :param enable_logging: 是否启用日志记录,默认为 True """ super().__init__(source, enable_logging) self.next_allowed_time = 0.0 self.current_wait = base_wait self.base_wait = base_wait self.max_wait = max_wait self.backoff_factor = backoff_factor self.source = source @property def reset_on_success(self) -> bool: """ 指数退避限流器在调用成功后应重置等待时间 """ return True def can_call(self) -> Tuple[bool, str]: """ 检查是否可以进行调用,如果当前时间超过下一次允许调用的时间,则允许调用 :return: 如果允许调用,返回 True 和空消息,否则返回 False 和限流消息 """ current_time = time.time() with self.lock: if current_time >= self.next_allowed_time: return True, "" wait_time = self.next_allowed_time - current_time message = f"限流期间,跳过调用,将在 {wait_time:.2f} 秒后允许继续调用" self.log_info(message) return False, self.format_log(message) def reset(self): """ 重置等待时间 当调用成功时调用此方法,重置当前等待时间为基础等待时间 """ with self.lock: if self.next_allowed_time != 0 or self.current_wait > self.base_wait: self.log_info(f"调用成功,重置限流等待时间为 {self.base_wait} 秒") self.next_allowed_time = 0.0 self.current_wait = self.base_wait def trigger_limit(self): """ 触发限流 当触发限流异常时调用此方法,增加下一次允许调用的时间并更新当前等待时间 """ current_time = time.time() with self.lock: self.next_allowed_time = current_time + self.current_wait self.current_wait = min( self.current_wait * self.backoff_factor, self.max_wait ) wait_time = self.next_allowed_time - current_time self.log_warning(f"触发限流,将在 {wait_time:.2f} 秒后允许继续调用") # 时间窗口限流器 class WindowRateLimiter(BaseRateLimiter): """ 基于时间窗口的限流器,用于限制在特定时间窗口内的调用次数 如果超过允许的最大调用次数,则限流直到窗口期结束 """ def __init__( self, max_calls: int, window_seconds: float, source: str = "", enable_logging: bool = True, ): """ 初始化 WindowRateLimiter 实例 :param max_calls: 在时间窗口内允许的最大调用次数 :param window_seconds: 时间窗口的持续时间(秒) :param source: 业务来源或上下文信息,默认值为 "" :param enable_logging: 是否启用日志记录,默认为 True """ super().__init__(source, enable_logging) self.max_calls = max_calls self.window_seconds = window_seconds self.call_times = deque() def can_call(self) -> Tuple[bool, str]: """ 检查是否可以进行调用,如果在时间窗口内的调用次数少于最大允许次数,则允许调用。 :return: 如果允许调用,返回 True 和空消息,否则返回 False 和限流消息 """ current_time = time.time() with self.lock: # 清理超出时间窗口的调用记录 while ( self.call_times and current_time - self.call_times[0] > self.window_seconds ): self.call_times.popleft() if len(self.call_times) < self.max_calls: return True, "" else: wait_time = self.window_seconds - (current_time - self.call_times[0]) message = f"限流期间,跳过调用,将在 {wait_time:.2f} 秒后允许继续调用" self.log_info(message) return False, self.format_log(message) def reset(self): """ 重置时间窗口内的调用记录 当调用成功时调用此方法,清空时间窗口内的调用记录 """ with self.lock: self.call_times.clear() def record_call(self): """ 记录当前时间戳,用于限流检查 """ current_time = time.time() with self.lock: self.call_times.append(current_time) # 组合限流器 class CompositeRateLimiter(BaseRateLimiter): """ 组合限流器,可以组合多个限流策略 当任意一个限流策略触发限流时,都会阻止调用 """ def __init__( self, limiters: List[BaseRateLimiter], source: str = "", enable_logging: bool = True, ): """ 初始化 CompositeRateLimiter 实例 :param limiters: 要组合的限流器列表 :param source: 业务来源或上下文信息,默认值为 "" :param enable_logging: 是否启用日志记录,默认为 True """ super().__init__(source, enable_logging) self.limiters = limiters def can_call(self) -> Tuple[bool, str]: """ 检查是否可以进行调用,当组合的任意限流器触发限流时,阻止调用。 :return: 如果所有限流器都允许调用,返回 True 和空消息,否则返回 False 和限流信息。 """ for limiter in self.limiters: can_call, message = limiter.can_call() if not can_call: return False, message return True, "" def reset(self): """ 重置所有组合的限流器状态 """ for limiter in self.limiters: limiter.reset() def record_call(self): """ 记录所有组合的限流器的调用时间 """ for limiter in self.limiters: limiter.record_call() # 通用装饰器:自定义限流器实例 def rate_limit_handler( limiter: BaseRateLimiter, raise_on_limit: bool = False ) -> Callable: """ 通用装饰器,允许用户传递自定义的限流器实例,用于处理限流逻辑 该装饰器可灵活支持任意继承自 BaseRateLimiter 的限流器 :param limiter: 限流器实例,必须继承自 BaseRateLimiter :param raise_on_limit: 控制在限流时是否抛出异常,默认为 False :return: 装饰器函数 """ def decorator(func: Callable) -> Callable: @functools.wraps(func) def wrapper(*args, **kwargs) -> Optional[Any]: # 检查是否传入了 "raise_exception" 参数,优先使用该参数,否则使用默认的 raise_on_limit 值 raise_exception = kwargs.get("raise_exception", raise_on_limit) # 检查是否可以进行调用,调用 limiter.can_call() 方法 can_call, message = limiter.can_call() if not can_call: # 如果调用受限,并且 raise_exception 为 True,则抛出限流异常 if raise_exception: raise RateLimitExceededException(message) # 如果不抛出异常,则返回 None 表示跳过调用 return None # 如果调用允许,执行目标函数,并记录一次调用 try: result = func(*args, **kwargs) limiter.record_call() if limiter.reset_on_success: limiter.reset() return result except LimitException as e: # 如果目标函数触发了限流相关的异常,执行限流器的触发逻辑(如递增等待时间) limiter.trigger_limit() logger.error(limiter.format_log(f"触发限流:{str(e)}")) # 如果 raise_exception 为 True,则抛出异常,否则返回 None if raise_exception: raise e return None @functools.wraps(func) async def async_wrapper(*args, **kwargs) -> Optional[Any]: # 检查是否传入了 "raise_exception" 参数,优先使用该参数,否则使用默认的 raise_on_limit 值 raise_exception = kwargs.get("raise_exception", raise_on_limit) # 检查是否可以进行调用,调用 limiter.can_call() 方法 can_call, message = limiter.can_call() if not can_call: # 如果调用受限,并且 raise_exception 为 True,则抛出限流异常 if raise_exception: raise RateLimitExceededException(message) # 如果不抛出异常,则返回 None 表示跳过调用 return None # 如果调用允许,执行目标函数,并记录一次调用 try: result = await func(*args, **kwargs) limiter.record_call() if limiter.reset_on_success: limiter.reset() return result except LimitException as e: # 如果目标函数触发了限流相关的异常,执行限流器的触发逻辑(如递增等待时间) limiter.trigger_limit() logger.error(limiter.format_log(f"触发限流:{str(e)}")) # 如果 raise_exception 为 True,则抛出异常,否则返回 None if raise_exception: raise e return None # 根据函数类型返回相应的包装器 if inspect.iscoroutinefunction(func): return async_wrapper else: return wrapper return decorator # 装饰器:指数退避限流 def rate_limit_exponential( base_wait: float = 60.0, max_wait: float = 600.0, backoff_factor: float = 2.0, raise_on_limit: bool = False, source: str = "", enable_logging: bool = True, ) -> Callable: """ 装饰器,用于应用指数退避限流策略 通过逐渐增加调用等待时间控制调用频率。每次触发限流时,等待时间会成倍增加,直到达到最大等待时间 :param base_wait: 基础等待时间(秒),默认值为 60 秒(1 分钟) :param max_wait: 最大等待时间(秒),默认值为 600 秒(10 分钟) :param backoff_factor: 等待时间递增的倍数,默认值为 2.0,表示指数退避 :param raise_on_limit: 控制在限流时是否抛出异常,默认为 False :param source: 业务来源或上下文信息,默认为空字符串 :param enable_logging: 是否启用日志记录,默认为 True :return: 装饰器函数 """ # 实例化 ExponentialBackoffRateLimiter,并传入相关参数 limiter = ExponentialBackoffRateLimiter( base_wait, max_wait, backoff_factor, source, enable_logging ) # 使用通用装饰器逻辑包装该限流器 return rate_limit_handler(limiter, raise_on_limit) # 装饰器:时间窗口限流 def rate_limit_window( max_calls: int, window_seconds: float, raise_on_limit: bool = False, source: str = "", enable_logging: bool = True, ) -> Callable: """ 装饰器,用于应用时间窗口限流策略 在固定的时间窗口内限制调用次数,当调用次数超过最大值时,触发限流,直到时间窗口结束 :param max_calls: 时间窗口内允许的最大调用次数 :param window_seconds: 时间窗口的持续时间(秒) :param raise_on_limit: 控制在限流时是否抛出异常,默认为 False :param source: 业务来源或上下文信息,默认为空字符串 :param enable_logging: 是否启用日志记录,默认为 True :return: 装饰器函数 """ # 实例化 WindowRateLimiter,并传入相关参数 limiter = WindowRateLimiter(max_calls, window_seconds, source, enable_logging) # 使用通用装饰器逻辑包装该限流器 return rate_limit_handler(limiter, raise_on_limit) class QpsRateLimiter: """ 速率控制器,精确控制 QPS """ def __init__(self, qps: float | int): if qps <= 0: qps = float("inf") self.interval = 1.0 / qps self.lock = threading.Lock() self.next_call_time = time.monotonic() def acquire(self) -> None: """ 获取调用许可,阻塞直到满足速率限制 """ sleep_duration = 0 with self.lock: now = time.monotonic() sleep_duration = self.next_call_time - now self.next_call_time = max(now, self.next_call_time) + self.interval if sleep_duration > 0: time.sleep(sleep_duration) class RateStats: """ 请求速率统计:记录时间戳,计算 QPS / QPM / QPH """ def __init__(self, window_seconds: float = 7200, source: str = ""): """ :param window_seconds: 统计窗口(秒),默认 2 小时,用于计算 QPH :param source: 日志来源标识 """ self._window = window_seconds self._source = source self._lock = threading.Lock() self._timestamps: deque = deque() def record(self) -> None: """ 记录一次请求 """ t = time.time() with self._lock: self._timestamps.append(t) while self._timestamps and t - self._timestamps[0] > self._window: self._timestamps.popleft() def _count_since(self, seconds: float) -> int: t = time.time() with self._lock: return sum(1 for ts in self._timestamps if t - ts <= seconds) def get_qps(self) -> float: """ 最近 1 秒内请求数 """ return self._count_since(1.0) def get_qpm(self) -> float: """ 最近 1 分钟内请求数 """ return self._count_since(60.0) def get_qph(self) -> float: """ 最近 1 小时内请求数 """ return self._count_since(3600.0) def log_stats(self, level: str = "info") -> None: """ 输出当前 QPS/QPM/QPH """ qps, qpm, qph = self.get_qps(), self.get_qpm(), self.get_qph() msg = f"QPS={qps} QPM={qpm} QPH={qph}" if self._source: msg = f"[{self._source}] {msg}" log_fn = getattr(logger, level, logger.info) log_fn(msg) ================================================ FILE: app/utils/mixins.py ================================================ import inspect from app.core.event import eventmanager, Event from app.log import logger from app.schemas.types import EventType class ConfigReloadMixin: """配置重载混入类 继承此 Mixin 类的类,会在配置变更时自动调用 on_config_changed 方法。 在类中定义 CONFIG_WATCH 集合,指定需要监听的配置项 重写 on_config_changed 方法实现具体的重载逻辑 可选地重写 get_reload_name 方法提供模块名称(用于日志显示) """ def __init_subclass__(cls, **kwargs): super().__init_subclass__(**kwargs) config_watch = getattr(cls, 'CONFIG_WATCH', None) if not config_watch: return # 检查 on_config_changed 方法是否为异步 is_async = inspect.iscoroutinefunction(cls.on_config_changed) method_name = 'handle_config_changed' # 创建事件处理函数 def create_handler(is_async): if is_async: async def wrapper(self: ConfigReloadMixin, event: Event): if not event: return changed_keys = getattr(event.event_data, "key", set()) & config_watch if not changed_keys: return logger.info(f"配置 {', '.join(changed_keys)} 变更,重载 {self.get_reload_name()}...") await self.on_config_changed() else: def wrapper(self: ConfigReloadMixin, event: Event): if not event: return changed_keys = getattr(event.event_data, "key", set()) & config_watch if not changed_keys: return logger.info(f"配置 {', '.join(changed_keys)} 变更,重载 {self.get_reload_name()}...") self.on_config_changed() return wrapper # 创建并设置处理函数 handler = create_handler(is_async) handler.__module__ = cls.__module__ handler.__qualname__ = f'{cls.__name__}.{method_name}' setattr(cls, method_name, handler) # 添加为事件处理器 eventmanager.add_event_listener(EventType.ConfigChanged, handler) def on_config_changed(self): """子类重写此方法实现具体重载逻辑""" pass def get_reload_name(self): """功能/模块名称""" return self.__class__.__name__ ================================================ FILE: app/utils/object.py ================================================ import ast import dis import inspect import textwrap from types import FunctionType from typing import Any, Callable, get_type_hints class ObjectUtils: @staticmethod def is_obj(obj: Any): if isinstance(obj, list) \ or isinstance(obj, dict) \ or isinstance(obj, tuple): return True elif isinstance(obj, int) \ or isinstance(obj, float) \ or isinstance(obj, bool) \ or isinstance(obj, bytes) \ or isinstance(obj, str): return False return True @staticmethod def is_objstr(obj: Any): if not isinstance(obj, str): return False return str(obj).startswith("{") \ or str(obj).startswith("[") \ or str(obj).startswith("(") @staticmethod def arguments(func: Callable) -> int: """ 返回函数的参数个数 """ signature = inspect.signature(func) parameters = signature.parameters return len(list(parameters.keys())) @staticmethod def check_method(func: Callable[..., Any]) -> bool: """ 检查函数是否已实现 """ try: src = inspect.getsource(func) tree = ast.parse(textwrap.dedent(src)) node = tree.body[0] if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): return True body = node.body for stmt in body: # 跳过 pass if isinstance(stmt, ast.Pass): continue # 跳过 docstring 或 ... if isinstance(stmt, ast.Expr): expr = stmt.value if isinstance(expr, ast.Constant): if isinstance(expr.value, str) or expr.value is Ellipsis: continue # 检查 raise NotImplementedError if isinstance(stmt, ast.Raise): exc = stmt.exc if isinstance(exc, ast.Call) and getattr(exc.func, "id", None) == "NotImplementedError": continue if isinstance(exc, ast.Name) and exc.id == "NotImplementedError": continue return True return False except Exception as err: print(err) # 源代码分析失败时,进行字节码分析 code_obj = func.__code__ # type: ignore[attr-defined] instructions = list(dis.get_instructions(code_obj)) # 检查是否为仅返回None的简单结构 if len(instructions) == 2: first, second = instructions if (first.opname == 'LOAD_CONST' and second.opname == 'RETURN_VALUE'): # 验证加载的常量是否为None const_index = first.arg if (const_index < len(code_obj.co_consts) and code_obj.co_consts[const_index] is None): # 未实现的空函数 return False # 其他情况认为已实现 return True @staticmethod def check_signature(func: FunctionType, *args) -> bool: """ 检查输出与函数的参数类型是否一致 """ # 获取函数的参数信息 signature = inspect.signature(func) parameters = signature.parameters if len(args) != len(parameters): return False try: # 获取解析后的类型提示 type_hints = get_type_hints(func) except TypeError: type_hints = {} for arg, (param_name, param) in zip(args, parameters.items()): # 优先使用解析后的类型提示 param_type = type_hints.get(param_name, None) if param_type is None: # 处理原始注解(可能为字符串或Cython类型) param_annotation = param.annotation if param_annotation is inspect.Parameter.empty: continue # 处理字符串类型的注解 if isinstance(param_annotation, str): # 尝试解析字符串为实际类型 module = inspect.getmodule(func) global_vars = module.__dict__ if module else globals() try: param_type = eval(param_annotation, global_vars) except Exception as err: print(str(err)) continue else: param_type = param_annotation if param_type is None: continue if not isinstance(arg, param_type): return False return True ================================================ FILE: app/utils/otp.py ================================================ import pyotp class OtpUtils: @staticmethod def generate_secret_key(username: str) -> (str, str): try: secret = pyotp.random_base32() uri = pyotp.totp.TOTP(secret).provisioning_uri(name='MoviePilot', issuer_name='MoviePilot(' + username + ')') return secret, uri except Exception as err: print(str(err)) return "", "" @staticmethod def is_legal(otp_uri: str, password: str) -> bool: """ 校验二次验证是否正确 """ try: return pyotp.TOTP(pyotp.parse_uri(otp_uri).secret).verify(password) except Exception as err: print(str(err)) return False @staticmethod def check(secret: str, password: str) -> bool: """ 校验二次验证是否正确 """ try: totp = pyotp.TOTP(secret) return totp.verify(password) except Exception as err: print(str(err)) return False @staticmethod def get_secret(otp_uri: str) -> str: """ 获取uri中的secret """ try: return pyotp.parse_uri(otp_uri).secret except Exception as err: print(str(err)) return "" ================================================ FILE: app/utils/security.py ================================================ from hashlib import sha256 from pathlib import Path from typing import List, Optional, Set, Union from urllib.parse import quote, urlparse from anyio import Path as AsyncPath from app.log import logger class SecurityUtils: @staticmethod def is_safe_path(base_path: Path, user_path: Path, allowed_suffixes: Optional[Union[Set[str], List[str]]] = None) -> bool: """ 验证用户提供的路径是否在基准目录内,并检查文件类型是否合法,防止目录遍历攻击 :param base_path: 基准目录,允许访问的根目录 :param user_path: 用户提供的路径,需检查其是否位于基准目录内 :param allowed_suffixes: 允许的文件后缀名集合,用于验证文件类型 :return: 如果用户路径安全且位于基准目录内,且文件类型合法,返回 True;否则返回 False :raises Exception: 如果解析路径时发生错误,则捕获并记录异常 """ try: # resolve() 将相对路径转换为绝对路径,并处理符号链接和'..' base_path_resolved = base_path.resolve() user_path_resolved = user_path.resolve() # 检查用户路径是否在基准目录或基准目录的子目录内 if base_path_resolved != user_path_resolved and base_path_resolved not in user_path_resolved.parents: return False if allowed_suffixes is not None: allowed_suffixes = set(allowed_suffixes) if user_path.suffix.lower() not in allowed_suffixes: return False return True except Exception as e: logger.debug(f"Error occurred while validating paths: {e}") return False @staticmethod async def async_is_safe_path(base_path: AsyncPath, user_path: AsyncPath, allowed_suffixes: Optional[Union[Set[str], List[str]]] = None) -> bool: """ 异步验证用户提供的路径是否在基准目录内,并检查文件类型是否合法,防止目录遍历攻击 :param base_path: 基准目录,允许访问的根目录 :param user_path: 用户提供的路径,需检查其是否位于基准目录内 :param allowed_suffixes: 允许的文件后缀名集合,用于验证文件类型 :return: 如果用户路径安全且位于基准目录内,且文件类型合法,返回 True;否则返回 False :raises Exception: 如果解析路径时发生错误,则捕获并记录异常 """ try: # resolve() 将相对路径转换为绝对路径,并处理符号链接和'..' base_path_resolved = await base_path.resolve() user_path_resolved = await user_path.resolve() # 检查用户路径是否在基准目录或基准目录的子目录内 if base_path_resolved != user_path_resolved and base_path_resolved not in user_path_resolved.parents: return False if allowed_suffixes is not None: allowed_suffixes = set(allowed_suffixes) if user_path.suffix.lower() not in allowed_suffixes: return False return True except Exception as e: logger.debug(f"Error occurred while validating paths: {e}") return False @staticmethod def is_safe_url(url: str, allowed_domains: Union[Set[str], List[str]], strict: bool = False) -> bool: """ 验证URL是否在允许的域名列表中,包括带有端口的域名 :param url: 需要验证的 URL :param allowed_domains: 允许的域名集合,域名可以包含端口 :param strict: 是否严格匹配一级域名(默认为 False,允许多级域名) :return: 如果URL合法且在允许的域名列表中,返回 True;否则返回 False """ try: # 解析URL parsed_url = urlparse(url) # 如果 URL 没有包含有效的 scheme,或者无法从中提取到有效的 netloc,则认为该 URL 是无效的 if not parsed_url.scheme or not parsed_url.netloc: return False # 仅允许 http 或 https 协议 if parsed_url.scheme not in {"http", "https"}: return False # 获取完整的 netloc(包括 IP 和端口)并转换为小写 netloc = parsed_url.netloc.lower() if not netloc: return False # 检查每个允许的域名 allowed_domains = {d.lower() for d in allowed_domains} for domain in allowed_domains: parsed_allowed_url = urlparse(domain) allowed_netloc = parsed_allowed_url.netloc or parsed_allowed_url.path if strict: # 严格模式下,要求完全匹配域名和端口 if netloc == allowed_netloc: return True else: # 非严格模式下,允许子域名匹配 if netloc == allowed_netloc or netloc.endswith('.' + allowed_netloc): return True return False except Exception as e: logger.debug(f"Error occurred while validating URL: {e}") return False @staticmethod def sanitize_url_path(url: str, max_length: int = 120) -> str: """ 将 URL 的路径部分进行编码,确保合法字符,并对路径长度进行压缩处理(如果超出最大长度) :param url: 需要处理的 URL :param max_length: 路径允许的最大长度,超出时进行压缩 :return: 处理后的路径字符串 """ # 解析 URL,获取路径部分 parsed_url = urlparse(url) path = parsed_url.path.lstrip("/") # 对路径中的特殊字符进行编码 safe_path = quote(path) # 如果路径过长,进行压缩处理 if len(safe_path) > max_length: # 使用 SHA-256 对路径进行哈希,取前 16 位作为压缩后的路径 hash_value = sha256(safe_path.encode()).hexdigest()[:16] # 使用哈希值代替过长的路径,同时保留文件扩展名 file_extension = Path(safe_path).suffix.lower() if Path(safe_path).suffix else "" safe_path = f"compressed_{hash_value}{file_extension}" return safe_path ================================================ FILE: app/utils/singleton.py ================================================ import abc import threading import weakref class Singleton(abc.ABCMeta, type): """ 类单例模式(按参数) """ _instances: dict = {} def __call__(cls, *args, **kwargs): key = (cls, args, frozenset(kwargs.items())) if key not in cls._instances: cls._instances[key] = super().__call__(*args, **kwargs) return cls._instances[key] class AbstractSingleton(abc.ABC, metaclass=Singleton): """ 抽像类单例模式 """ pass class SingletonClass(abc.ABCMeta, type): """ 类单例模式(按类) """ _instances: dict = {} def __call__(cls, *args, **kwargs): if cls not in cls._instances: cls._instances[cls] = super().__call__(*args, **kwargs) return cls._instances[cls] class AbstractSingletonClass(abc.ABC, metaclass=SingletonClass): """ 抽像类单例模式(按类) """ pass class WeakSingleton(abc.ABCMeta, type): """ 弱引用单例模式 - 当没有强引用时自动清理 """ _instances: weakref.WeakKeyDictionary = weakref.WeakKeyDictionary() _lock = threading.RLock() def __call__(cls, *args, **kwargs): with cls._lock: if cls not in cls._instances: cls._instances[cls] = super().__call__(*args, **kwargs) return cls._instances[cls] ================================================ FILE: app/utils/site.py ================================================ from lxml import etree from app.utils.string import StringUtils class SiteUtils: @classmethod def is_logged_in(cls, html_text: str) -> bool: """ 判断站点是否已经登陆 :param html_text: :return: """ html = etree.HTML(html_text) try: if not StringUtils.is_valid_html_element(html): return False # 存在明显的密码输入框,说明未登录 if html.xpath("//input[@type='password']"): return False # 是否存在登出和用户面板等链接 xpaths = [ '//a[contains(@href, "logout")' ' or contains(@data-url, "logout")' ' or contains(@href, "mybonus") ' ' or contains(@onclick, "logout")' ' or contains(@href, "usercp")' ' or contains(@lay-on, "logout")]', '//form[contains(@action, "logout")]', '//div[@class="user-info-side"]', '//a[@id="myitem"]' ] for xpath in xpaths: if html.xpath(xpath): return True return False finally: if html is not None: del html @classmethod def is_checkin(cls, html_text: str) -> bool: """ 判断站点是否已经签到 :return True已签到 False未签到 """ html = etree.HTML(html_text) try: if not StringUtils.is_valid_html_element(html): return False # 站点签到支持的识别XPATH xpaths = [ '//a[@id="signed"]', '//a[contains(@href, "attendance")]', '//a[contains(text(), "签到")]', '//a/b[contains(text(), "签 到")]', '//span[@id="sign_in"]/a', '//a[contains(@href, "addbonus")]', '//input[@class="dt_button"][contains(@value, "打卡")]', '//a[contains(@href, "sign_in")]', '//a[contains(@onclick, "do_signin")]', '//a[@id="do-attendance"]', '//shark-icon-button[@href="attendance.php"]' ] for xpath in xpaths: if html.xpath(xpath): return False return True finally: if html is not None: del html ================================================ FILE: app/utils/string.py ================================================ import bisect import datetime import hashlib import random import re from typing import Union, Tuple, Optional, Any, List, Generator from urllib import parse import cn2an import dateparser import dateutil.parser from app.schemas.types import MediaType _special_domains = [ 'u2.dmhy.org', 'pt.ecust.pp.ua', 'pt.gtkpw.xyz', 'pt.gtk.pw' ] # 内置版本号转换字典 _version_map = {"stable": -1, "rc": -2, "beta": -3, "alpha": -4} # 不符合的版本号 _other_version = -5 _max_media_title_words = 10 _min_media_title_length = 2 _non_media_title_pattern = re.compile(r"^#|^请[问帮你]|[??]$|^继续$") _chat_intent_pattern = re.compile(r"帮我|请问|怎么|如何|为什么|可以|能否|推荐|介绍|谢谢|想看|找一下|搜一下") _media_feature_pattern = re.compile( r"第\s*[0-9一二三四五六七八九十百零]+\s*[季集]|S\d{1,2}(?:E\d{1,4})?|E\d{1,4}|(?:19|20)\d{2}", re.IGNORECASE ) _media_separator_pattern = re.compile(r"[\s\-_.::·'\"()\[\]【】]+") _media_sentence_punctuation_pattern = re.compile(r"[,。!?!?,;;]") _media_title_char_pattern = re.compile(r"[\u4e00-\u9fffA-Za-z]") class StringUtils: @staticmethod def num_filesize(text: Union[str, int, float]) -> int: """ 将文件大小文本转化为字节 """ if not text: return 0 if not isinstance(text, str): text = str(text) if text.isdigit(): return int(text) text = text.replace(",", "").replace(" ", "").upper() size = re.sub(r"[KMGTPI]*B?", "", text, flags=re.IGNORECASE) try: size = float(size) except ValueError: return 0 if text.find("PB") != -1 or text.find("PIB") != -1: size *= 1024 ** 5 elif text.find("TB") != -1 or text.find("TIB") != -1: size *= 1024 ** 4 elif text.find("GB") != -1 or text.find("GIB") != -1: size *= 1024 ** 3 elif text.find("MB") != -1 or text.find("MIB") != -1: size *= 1024 ** 2 elif text.find("KB") != -1 or text.find("KIB") != -1: size *= 1024 return round(size) @staticmethod def str_timelong(time_sec: Union[str, int, float]) -> str: """ 将数字转换为时间描述 """ if not isinstance(time_sec, int) or not isinstance(time_sec, float): try: time_sec = float(time_sec) except ValueError: return "" d = [(0, '秒'), (60 - 1, '分'), (3600 - 1, '小时'), (86400 - 1, '天')] s = [x[0] for x in d] index = bisect.bisect_left(s, time_sec) - 1 if index == -1: return str(time_sec) else: b, u = d[index] return str(round(time_sec / (b + 1))) + u @staticmethod def str_secends(time_sec: Union[str, int, float]) -> str: """ 将秒转为时分秒字符串 """ hours = time_sec // 3600 remainder_seconds = time_sec % 3600 minutes = remainder_seconds // 60 seconds = remainder_seconds % 60 time: str = str(int(seconds)) + '秒' if minutes: time = str(int(minutes)) + '分' + time if hours: time = str(int(hours)) + '时' + time return time @staticmethod def is_chinese(word: Union[str, list]) -> bool: """ 判断是否含有中文 """ if not word: return False if isinstance(word, list): word = " ".join(word) chn = re.compile(r'[\u4e00-\u9fff]') if chn.search(word): return True else: return False @staticmethod def is_japanese(word: str) -> bool: """ 判断是否含有日文 """ jap = re.compile(r'[\u3040-\u309F\u30A0-\u30FF]') if jap.search(word): return True else: return False @staticmethod def is_korean(word: str) -> bool: """ 判断是否包含韩文 """ kor = re.compile(r'[\uAC00-\uD7FF]') if kor.search(word): return True else: return False @staticmethod def is_all_chinese(word: str) -> bool: """ 判断是否全是中文 """ for ch in word: if ch == ' ': continue if '\u4e00' <= ch <= '\u9fff': continue else: return False return True @staticmethod def is_english_word(word: str) -> bool: """ 判断是否为英文单词,有空格时返回False """ return word.encode().isalpha() @staticmethod def str_int(text: str) -> int: """ web字符串转int :param text: :return: """ if text: text = text.strip() if not text: return 0 try: return int(text.replace(',', '')) except ValueError: return 0 @staticmethod def str_float(text: str) -> float: """ web字符串转float :param text: :return: """ if text: text = text.strip() if not text: return 0.0 try: text = text.replace(',', '') if text: return float(text) except ValueError: pass return 0.0 @staticmethod def clear(text: Union[list, str], replace_word: str = "", allow_space: bool = False) -> Union[list, str]: """ 忽略特殊字符 """ # 需要忽略的特殊字符 CONVERT_EMPTY_CHARS = r"[、.。,,·::;;!!'’\"“”()()\[\]【】「」\-—―\+\|\\_/&#~~]" if not text: return text if not isinstance(text, list): text = re.sub(r"[\u200B-\u200D\uFEFF]", "", re.sub(r"%s" % CONVERT_EMPTY_CHARS, replace_word, text), flags=re.IGNORECASE) if not allow_space: return re.sub(r"\s+", "", text) else: return re.sub(r"\s+", " ", text).strip() else: return [StringUtils.clear(x) for x in text] @staticmethod def clear_upper(text: Optional[str]) -> str: """ 去除特殊字符,同时大写 """ if not text: return "" return StringUtils.clear(text).upper().strip() @staticmethod def str_filesize(size: Union[str, float, int], pre: int = 2) -> str: """ 将字节计算为文件大小描述(带单位的格式化后返回) """ if size is None: return "" size = re.sub(r"\s|B|iB", "", str(size), re.I) if size.replace(".", "").isdigit(): try: size = float(size) d = [(1024 - 1, 'K'), (1024 ** 2 - 1, 'M'), (1024 ** 3 - 1, 'G'), (1024 ** 4 - 1, 'T')] s = [x[0] for x in d] index = bisect.bisect_left(s, size) - 1 # noqa if index == -1: return str(size) + "B" else: b, u = d[index] return str(round(size / (b + 1), pre)) + u except ValueError: return "" if re.findall(r"[KMGTP]", size, re.I): return size else: return size + "B" @staticmethod def format_size(size_bytes: int) -> str: """ 将字节转换为人类可读格式 """ if not size_bytes or size_bytes == 0: return "0 B" units = ["B", "KB", "MB", "GB", "TB", "PB"] size = float(size_bytes) unit_index = 0 while size >= 1024 and unit_index < len(units) - 1: size /= 1024 unit_index += 1 # 保留两位小数 if unit_index == 0: return f"{int(size)} {units[unit_index]}" return f"{size:.2f} {units[unit_index]}" @staticmethod def url_equal(url1: str, url2: str) -> bool: """ 比较两个地址是否为同一个网站 """ if not url1 or not url2: return False if url1.startswith("http"): url1 = parse.urlparse(url1).netloc if url2.startswith("http"): url2 = parse.urlparse(url2).netloc if url1.replace("www.", "") == url2.replace("www.", ""): return True return False @staticmethod def get_url_netloc(url: str) -> Tuple[str, str]: """ 获取URL的协议和域名部分 """ if not url: return "", "" if not url.startswith("http"): return "http", url addr = parse.urlparse(url) return addr.scheme, addr.netloc @staticmethod def get_url_domain(url: str) -> str: """ 获取URL的域名部分,只保留最后两级 """ if not url: return "" for domain in _special_domains: if domain in url: return domain _, netloc = StringUtils.get_url_netloc(url) if netloc: locs = netloc.split(".") if len(locs) > 3: return netloc return ".".join(locs[-2:]) return "" @staticmethod def get_url_sld(url: str) -> str: """ 获取URL的二级域名部分,不含端口,若为IP则返回IP """ if not url: return "" _, netloc = StringUtils.get_url_netloc(url) if not netloc: return "" netloc = netloc.split(":")[0].split(".") if len(netloc) >= 2: return netloc[-2] return netloc[0] @staticmethod def get_url_host(url: str) -> str: """ 获取URL的一级域名 """ if not url: return "" _, netloc = StringUtils.get_url_netloc(url) if not netloc: return "" return netloc.split(".")[-2] @staticmethod def get_base_url(url: str) -> str: """ 获取URL根地址 """ if not url: return "" scheme, netloc = StringUtils.get_url_netloc(url) return f"{scheme}://{netloc}" @staticmethod def clear_file_name(name: str) -> Optional[str]: if not name: return None return re.sub(r"[*?\\/\"<>~|]", "", name, flags=re.IGNORECASE).replace(":", ":") @staticmethod def generate_random_str(randomlength: int = 16) -> str: """ 生成一个指定长度的随机字符串 """ random_str = '' base_str = 'ABCDEFGHIGKLMNOPQRSTUVWXYZabcdefghigklmnopqrstuvwxyz0123456789' length = len(base_str) - 1 for i in range(randomlength): random_str += base_str[random.randint(0, length)] return random_str @staticmethod def get_time(date: Any) -> Optional[datetime.datetime]: try: return dateutil.parser.parse(date) except dateutil.parser.ParserError: return None @staticmethod def unify_datetime_str(datetime_str: str) -> str: """ 日期时间格式化 统一转成 2020-10-14 07:48:04 这种格式 # 场景1: 带有时区的日期字符串 eg: Sat, 15 Oct 2022 14:02:54 +0800 # 场景2: 中间带T的日期字符串 eg: 2020-10-14T07:48:04 # 场景3: 中间带T的日期字符串 eg: 2020-10-14T07:48:04.208 # 场景4: 日期字符串以GMT结尾 eg: Fri, 14 Oct 2022 07:48:04 GMT # 场景5: 日期字符串以UTC结尾 eg: Fri, 14 Oct 2022 07:48:04 UTC # 场景6: 日期字符串以Z结尾 eg: Fri, 14 Oct 2022 07:48:04Z # 场景7: 日期字符串为相对时间 eg: 1 month, 2 days ago :param datetime_str: :return: """ # 传入的参数如果是None 或者空字符串 直接返回 if not datetime_str: return datetime_str try: return dateparser.parse(datetime_str).strftime('%Y-%m-%d %H:%M:%S') except Exception as e: print(str(e)) return datetime_str @staticmethod def format_timestamp(timestamp: str, date_format: str = '%Y-%m-%d %H:%M:%S') -> str: """ 时间戳转日期 :param timestamp: :param date_format: :return: """ if isinstance(timestamp, str) and not timestamp.isdigit(): return timestamp try: return datetime.datetime.fromtimestamp(int(timestamp)).strftime(date_format) except Exception as e: print(str(e)) return timestamp @staticmethod def str_to_timestamp(date_str: str) -> float: """ 日期转时间戳 :param date_str: :return: """ if not date_str: return 0 try: return dateparser.parse(date_str).timestamp() except Exception as e: print(str(e)) return 0 @staticmethod def to_bool(text: str, default_val: bool = False) -> bool: """ 字符串转bool :param text: 要转换的值 :param default_val: 默认值 :return: """ if isinstance(text, str) and not text: return default_val if isinstance(text, bool): return text if isinstance(text, int) or isinstance(text, float): return True if text > 0 else False if isinstance(text, str) and text.lower() in ['y', 'true', '1', 'yes', 'on']: return True return False @staticmethod def str_from_cookiejar(cj: dict) -> str: """ 将cookiejar转换为字符串 :param cj: :return: """ return '; '.join(['='.join(item) for item in cj.items()]) @staticmethod def get_idlist(content: str, dicts: List[dict]): """ 从字符串中提取id列表 :param content: 字符串 :param dicts: 字典 :return: """ if not content: return [] id_list = [] content_list = content.split() for dic in dicts: if dic.get('name') in content_list and dic.get('id') not in id_list: id_list.append(dic.get('id')) content = content.replace(dic.get('name'), '') return id_list, re.sub(r'\s+', ' ', content).strip() @staticmethod def md5_hash(data: Any) -> str: """ MD5 HASH """ if not data: return "" return hashlib.md5(str(data).encode()).hexdigest() @staticmethod def str_timehours(minutes: int) -> str: """ 将分钟转换成小时和分钟 :param minutes: :return: """ if not minutes: return "" hours = minutes // 60 minutes = minutes % 60 if hours: return "%s小时%s分" % (hours, minutes) else: return "%s分钟" % minutes @staticmethod def str_amount(amount: object, curr="$") -> str: """ 格式化显示金额 """ if not amount: return "0" return curr + format(amount, ",") @staticmethod def count_words(text: str) -> int: """ 计算字符串中包含的单词或汉字的数量,需要兼容中英文混合的情况 :param text: 要计算的字符串 :return: 字符串中包含的词数量 """ if not text: return 0 # 使用正则表达式匹配汉字和英文单词 chinese_pattern = '[\u4e00-\u9fa5]' english_pattern = '[a-zA-Z]+' # 匹配汉字和英文单词 chinese_matches = re.findall(chinese_pattern, text) english_matches = re.findall(english_pattern, text) # 过滤掉空格和数字 chinese_words = [word for word in chinese_matches if word.isalpha()] english_words = [word for word in english_matches if word.isalpha()] # 计算汉字和英文单词的数量 chinese_count = len(chinese_words) english_count = len(english_words) return chinese_count + english_count @staticmethod def is_media_title_like(text: str) -> bool: """ 判断文本是否像影视剧名称 """ if not text: return False text = re.sub(r'\s+', ' ', text).strip() if not text: return False if _non_media_title_pattern.search(text) \ or StringUtils.count_words(text) > _max_media_title_words: return False if "://" in text or text.startswith("magnet:?"): return False if _chat_intent_pattern.search(text): return False if _media_sentence_punctuation_pattern.search(text): return False # 先移除季/集/年份等媒体特征,再移除分隔符,只保留核心名称用于最终判定 candidate = _media_feature_pattern.sub("", text) candidate = _media_separator_pattern.sub("", candidate) return len(candidate) >= _min_media_title_length and _media_title_char_pattern.search(candidate) is not None @staticmethod def split_text(text: str, max_length: int) -> Generator: """ 把文本拆分为固定字节长度的数组,优先按换行拆分,避免单词内拆分 """ if not text: yield '' # 分行 lines = re.split('\n', text) buf = '' for line in lines: if len(line.encode('utf-8')) > max_length: # 超长行继续拆分 blank = "" if re.match(r'^[A-Za-z0-9.\s]+', line): # 英文行按空格拆分 parts = line.split() blank = " " else: # 中文行按字符拆分 parts = line part = '' for p in parts: if len((part + p).encode('utf-8')) > max_length: # 超长则Yield yield (buf + part).strip() buf = '' part = f"{blank}{p}" else: part = f"{part}{blank}{p}" if part: # 将最后的部分追加到buf buf += part else: if len((buf + "\n" + line).encode('utf-8')) > max_length: # buf超长则Yield yield buf.strip() buf = line else: # 短行直接追加到buf if buf: buf = f"{buf}\n{line}" else: buf = line if buf: # 处理文本末尾剩余部分 yield buf.strip() @staticmethod def get_keyword(content: str) \ -> Tuple[Optional[MediaType], Optional[str], Optional[int], Optional[int], Optional[str], Optional[str]]: """ 从搜索关键字中拆分中年份、季、集、类型 """ if not content: return None, None, None, None, None, None # 去掉查询中的电影或电视剧关键字 mtype = MediaType.TV if re.search(r'^(电视剧|动漫|\s+电视剧|\s+动漫)', content) else None content = re.sub(r'^(电影|电视剧|动漫|\s+电影|\s+电视剧|\s+动漫)', '', content).strip() # 稍微切一下剧集吧 season_num = None episode_num = None season_re = re.search(r'第\s*([0-9一二三四五六七八九十]+)\s*季', content, re.IGNORECASE) if season_re: mtype = MediaType.TV season_num = int(cn2an.cn2an(season_re.group(1), mode='smart')) episode_re = re.search(r'第\s*([0-9一二三四五六七八九十百零]+)\s*集', content, re.IGNORECASE) if episode_re: mtype = MediaType.TV episode_num = int(cn2an.cn2an(episode_re.group(1), mode='smart')) if episode_num and not season_num: season_num = 1 year_re = re.search(r'[\s(]+(\d{4})[\s)]*', content) year = year_re.group(1) if year_re else None key_word = re.sub( r'第\s*[0-9一二三四五六七八九十]+\s*季|第\s*[0-9一二三四五六七八九十百零]+\s*集|[\s(]+(\d{4})[\s)]*', '', content, flags=re.IGNORECASE).strip() key_word = re.sub(r'\s+', ' ', key_word) if key_word else year return mtype, key_word, season_num, episode_num, year, content @staticmethod def str_title(s: Optional[str]) -> str: """ 大写首字母兼容None """ return s.title() if s else s @staticmethod def escape_markdown(content: str) -> str: """ Escapes Markdown characters in a string of Markdown. Credits to: simonsmh :param content: The string of Markdown to escape. :type content: :obj:`str` :return: The escaped string. :rtype: :obj:`str` """ parses = re.sub(r"([_*\[\]()~`>#+\-=|.!{}])", r"\\\1", content) reparse = re.sub(r"\\\\([_*\[\]()~`>#+\-=|.!{}])", r"\1", parses) return reparse @staticmethod def get_domain_address(address: str, prefix: bool = True) -> Tuple[Optional[str], Optional[int]]: """ 从地址中获取域名和端口号 :param address: 地址 :param prefix:返回域名是否要包含协议前缀 """ if not address: return None, None # 去掉末尾的/ address = address.rstrip("/") if prefix and not address.startswith("http"): # 如果需要包含协议前缀,但地址不包含协议前缀,则添加 address = "http://" + address elif not prefix and address.startswith("http"): # 如果不需要包含协议前缀,但地址包含协议前缀,则去掉 address = address.split("://")[-1] # 拆分域名和端口号 parts = address.split(":") if len(parts) > 3: # 处理不希望包含多个冒号的情况(除了协议后的冒号) return None, None elif len(parts) == 3: port = int(parts[-1]) # 不含端口地址 domain = ":".join(parts[:-1]).rstrip('/') elif len(parts) == 2: port = 443 if address.startswith("https") else 80 domain = address else: return None, None return domain, port @staticmethod def str_series(array: List[int]) -> str: """ 将季集列表转化为字符串简写 """ # 确保数组按照升序排列 array.sort() result = [] start = array[0] end = array[0] for i in range(1, len(array)): if array[i] == end + 1: end = array[i] else: if start == end: result.append(str(start)) else: result.append(f"{start}-{end}") start = array[i] end = array[i] # 处理最后一个序列 if start == end: result.append(str(start)) else: result.append(f"{start}-{end}") return ",".join(result) @staticmethod def format_ep(nums: List[int]) -> str: """ 将剧集列表格式化为连续区间 """ if not nums: return "" if len(nums) == 1: return f"E{nums[0]:02d}" # 将数组升序排序 nums.sort() formatted_ranges = [] start = nums[0] end = nums[0] for i in range(1, len(nums)): if nums[i] == end + 1: end = nums[i] else: if start == end: formatted_ranges.append(f"E{start:02d}") else: formatted_ranges.append(f"E{start:02d}-E{end:02d}") start = end = nums[i] if start == end: formatted_ranges.append(f"E{start:02d}") else: formatted_ranges.append(f"E{start:02d}-E{end:02d}") formatted_string = "、".join(formatted_ranges) return formatted_string @staticmethod def is_number(text: str) -> bool: """ 判断字符是否为可以转换为整数或者浮点数 """ if not text: return False try: float(text) return True except ValueError: return False @staticmethod def find_common_prefix(str1: str, str2: str) -> str: if not str1 or not str2: return '' common_prefix = [] min_len = min(len(str1), len(str2)) for i in range(min_len): if str1[i] == str2[i]: common_prefix.append(str1[i]) else: break return ''.join(common_prefix) @staticmethod def compare_version(v1: str, compare_type: str, v2: str, verbose: bool = False) \ -> Tuple[Optional[bool], str | Exception] | Optional[bool]: """ 比较两个版本号的大小 :param v1: 比对的来源版本号 :param v2: 比对的目标版本号 :param verbose: 是否输出比对结果的时候输出详细消息,默认 False 不输出 :param compare_type: 识别模式。支持直接使用符号进行比对 'ge' or '>=' :来源 >= 目标 'le' or '<=' :来源 <= 目标 'eq' or '==' :来源 == 目标 'gt' or '>' :来源 > 目标 'lt' or '<' :来源 < 目标 :return """ def __preprocess_version(version: str) -> list: """ 预处理版本号,去除首尾空字符串与换行符,去除开头大小写v,并拆分版本号 """ return re.split(r'[.-]', version.strip().lstrip('vV')) def __conversion_version(version_list) -> list: """ 英文字符转换为数字 :param version_list : 版本号列表,格式:['1', '2', '3', 'beta'] """ result = [] for item in version_list: # stable = -1,rc = -2,beta = -3,alpha = -4 if item.isdigit(): result.append(int(item)) # 其余不符合的,都为-5 else: value = _version_map.get(item, _other_version) result.append(value) return result try: if not v1 or not v2: raise ValueError("要比较的版本号不全") if not compare_type: raise ValueError("缺少比对模式,无法比对") if compare_type not in {"ge", "gt", "le", "lt", "eq", "==", ">=", ">", "<=", "<"}: raise ValueError(f"设置的版本比对模式 {compare_type} 不是有效的模式!") # 拆分获取版本号各个分段值做成列表 v1_list = __conversion_version(__preprocess_version(version=v1)) v2_list = __conversion_version(__preprocess_version(version=v2)) # 补全版本号位置,保持长度一致 max_length = max(len(v1_list), len(v2_list)) v1_list += [0] * (max_length - len(v1_list)) v2_list += [0] * (max_length - len(v2_list)) ver_comparison, ver_comparison_err = None, None for v1_value, v2_value in zip(v1_list, v2_list): # 来源==目标 if compare_type in {"eq", "=="}: if v1_value != v2_value: ver_comparison, ver_comparison_err = None, "不等于" break else: ver_comparison, ver_comparison_err = "等于", None # 来源>=目标 elif compare_type in {"ge", ">="}: if v1_value > v2_value: ver_comparison, ver_comparison_err = "大于", None break elif v1_value < v2_value: ver_comparison, ver_comparison_err = None, "小于" break else: ver_comparison, ver_comparison_err = "等于", None # 来源>目标 elif compare_type in {"gt", ">"}: if v1_value > v2_value: ver_comparison, ver_comparison_err = "大于", None break elif v1_value < v2_value: ver_comparison, ver_comparison_err = None, "小于" break else: ver_comparison, ver_comparison_err = None, "等于" # 来源<=目标 elif compare_type in {"le", "<="}: if v1_value > v2_value: ver_comparison, ver_comparison_err = None, "大于" break elif v1_value < v2_value: ver_comparison, ver_comparison_err = "小于", None break else: ver_comparison, ver_comparison_err = "等于", None # 来源<目标 elif compare_type in {"lt", "<"}: if v1_value > v2_value: ver_comparison, ver_comparison_err = None, "大于" break elif v1_value < v2_value: ver_comparison, ver_comparison_err = "小于", None break else: ver_comparison, ver_comparison_err = None, "等于" msg = f"版本号 {v1} {ver_comparison if ver_comparison else ver_comparison_err} 目标版本号 {v2} !" return (True if ver_comparison else False, msg) if verbose else True if ver_comparison else False except Exception as e: return (None, e) if verbose else None @staticmethod def diff_time_str(time_str: str): """ 输入YYYY-MM-DD HH24:MI:SS 格式的时间字符串,返回距离现在的剩余时间:xx天xx小时xx分钟 """ if not time_str: return '' try: time_obj = datetime.datetime.strptime(time_str, '%Y-%m-%d %H:%M:%S') except ValueError: return time_str now = datetime.datetime.now() diff = time_obj - now diff_seconds = diff.seconds diff_days = diff.days diff_hours = diff_seconds // 3600 diff_minutes = (diff_seconds % 3600) // 60 if diff_days > 0: return f'{diff_days}天{diff_hours}小时{diff_minutes}分钟' elif diff_hours > 0: return f'{diff_hours}小时{diff_minutes}分钟' elif diff_minutes > 0: return f'{diff_minutes}分钟' else: return '' @staticmethod def safe_strip(value) -> Optional[str]: """ 去除字符串两端的空白字符 :return: 如果输入值不是 None,返回去除空白字符后的字符串,否则返回 None """ return value.strip() if value is not None else None @staticmethod def is_valid_html_element(elem) -> bool: """ 检查elem是否为有效的HTML元素。元素必须为非None并且具有非零长度。 :param elem: 要检查的HTML元素 :return: 如果elem有效(非None且长度大于0),返回True;否则返回False """ return elem is not None and len(elem) > 0 @staticmethod def is_link(text: str) -> bool: """ 检查文件是否为链接地址,支持各类协议 :param text: 要检查的文本 :return: 如果URL有效,返回True;否则返回False """ if not text: return False # 检查是否以http、https、ftp等协议开头 if re.match(r'^(http|https|ftp|ftps|sftp|ws|wss)://', text): return True # 检查是否为IP地址或域名 if re.match(r'^[a-zA-Z0-9.-]+(\.[a-zA-Z]{2,})?$', text): return True return False @staticmethod def is_magnet_link(content: Union[str, bytes]) -> bool: """ 判断内容是否为磁力链接 """ if not content: return False if isinstance(content, str) and content.startswith("magnet:"): return True if isinstance(content, bytes) and content.startswith(b"magnet:"): return True return False @staticmethod def natural_sort_key(text: str) -> List[Union[int, str]]: """ 自然排序 将字符串拆分为数字和非数字部分,数字部分转换为整数,非数字部分转换为小写字母 :param text: 要处理的字符串 :return 用于排序的数字和字符串列表 """ if text is None: return [] if not isinstance(text, str): text = str(text) return [int(part) if part.isdigit() else part.lower() for part in re.split(r'(\d+)', text)] ================================================ FILE: app/utils/structures.py ================================================ from typing import Dict, List, Set, TypeVar, Any, Union K = TypeVar("K") V = TypeVar("V") class DictUtils: @staticmethod def filter_keys_to_subset(source: Dict[K, V], reference: Dict[K, V]) -> Dict[K, V]: """ 过滤 source 字典,使其键成为 reference 字典键的子集 :param source: 要被过滤的字典 :param reference: 参考字典,定义允许的键 :return: 过滤后的字典,只包含在 reference 中存在的键 """ if not isinstance(source, dict) or not isinstance(reference, dict): return {} return {key: value for key, value in source.items() if key in reference} @staticmethod def is_keys_subset(source: Dict[K, V], reference: Dict[K, V]) -> bool: """ 判断 source 字典的键是否为 reference 字典键的子集 :param source: 要检查的字典 :param reference: 参考字典 :return: 如果 source 的键是 reference 的键子集,则返回 True,否则返回 False """ if not isinstance(source, dict) or not isinstance(reference, dict): return False return all(key in reference for key in source) class ListUtils: @staticmethod def flatten(nested_list: Union[List[List[Any]], List[Any]]) -> List[Any]: """ 将嵌套的列表展平成单个列表 :param nested_list: 嵌套的列表 :return: 展平后的列表 """ if not isinstance(nested_list, list): return [] # 检查是否嵌套,若不嵌套直接返回 if not any(isinstance(sublist, list) for sublist in nested_list): return nested_list return [item for sublist in nested_list if isinstance(sublist, list) for item in sublist] class SetUtils: @staticmethod def flatten(nested_sets: Union[Set[Set[Any]], Set[Any]]) -> Set[Any]: """ 将嵌套的集合展开为单个集合 :param nested_sets: 嵌套的集合 :return: 展开的集合 """ if not isinstance(nested_sets, set): return set() # 检查是否嵌套,若不嵌套直接返回 if not any(isinstance(subset, set) for subset in nested_sets): return nested_sets return {item for subset in nested_sets if isinstance(subset, set) for item in subset} ================================================ FILE: app/utils/system.py ================================================ import datetime import hashlib import os import platform import re import shutil import subprocess import sys import uuid from pathlib import Path from typing import List, Optional, Tuple, Union import psutil from app import schemas class SystemUtils: """ 系统工具类,提供系统相关的操作和信息获取方法。 """ @staticmethod def execute(cmd: str) -> str: """ 执行命令,获得返回结果 """ try: with os.popen(cmd) as p: return p.readline().strip() except Exception as err: print(str(err)) return "" @staticmethod def execute_with_subprocess(pip_command: list) -> Tuple[bool, str]: """ 执行命令并捕获标准输出和错误输出,记录日志。 :param pip_command: 要执行的命令,以列表形式提供 :return: (命令是否成功, 输出信息或错误信息) """ try: # 使用 subprocess.run 捕获标准输出和标准错误 result = subprocess.run(pip_command, check=True, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) # 合并 stdout 和 stderr output = result.stdout + result.stderr return True, output except subprocess.CalledProcessError as e: error_message = f"命令:{' '.join(pip_command)},执行失败,错误信息:{e.stderr.strip()}" return False, error_message except Exception as e: error_message = f"未知错误,命令:{' '.join(pip_command)},错误:{str(e)}" return False, error_message @staticmethod def is_docker() -> bool: """ 判断是否为Docker环境 """ return Path("/.dockerenv").exists() @staticmethod def is_synology() -> bool: """ 判断是否为群晖系统 """ if SystemUtils.is_windows(): return False return "synology" in SystemUtils.execute('uname -a') @staticmethod def is_windows() -> bool: """ 判断是否为Windows系统 """ return os.name == "nt" @staticmethod def is_frozen() -> bool: """ 判断是否为冻结的二进制文件 """ return getattr(sys, 'frozen', False) @staticmethod def is_macos() -> bool: """ 判断是否为MacOS系统 """ return platform.system() == 'Darwin' @staticmethod def is_aarch64() -> bool: """ 判断是否为ARM64架构 """ return platform.machine().lower() in ('aarch64', 'arm64') @staticmethod def is_aarch() -> bool: """ 判断是否为ARM32架构 """ arch_name = platform.machine().lower() return arch_name.startswith(('arm', 'aarch')) and arch_name not in ('aarch64', 'arm64') @staticmethod def is_x86_64() -> bool: """ 判断是否为AMD64架构 """ return platform.machine().lower() in ('amd64', 'x86_64') @staticmethod def is_x86_32() -> bool: """ 判断是否为AMD32架构 """ return platform.machine().lower() in ('i386', 'i686', 'x86', '386', 'x86_32') @staticmethod def platform() -> str: """ 获取系统平台 """ if SystemUtils.is_windows(): return "Windows" elif SystemUtils.is_macos(): return "MacOS" elif SystemUtils.is_aarch64(): return "Arm64" else: return "Linux" @staticmethod def cpu_arch() -> str: """ 获取CPU架构 """ if SystemUtils.is_x86_64(): return "x86_64" elif SystemUtils.is_x86_32(): return "x86_32" elif SystemUtils.is_aarch64(): return "Arm64" elif SystemUtils.is_aarch(): return "Arm32" else: return platform.machine() @staticmethod def copy(src: Path, dest: Path) -> Tuple[int, str]: """ 复制 """ try: shutil.copy2(src, dest) return 0, "" except Exception as err: return -1, str(err) @staticmethod def move(src: Path, dest: Path) -> Tuple[int, str]: """ 移动 """ try: # 直接移动到目标路径,避免中间改名步骤触发目录监控 shutil.move(src, dest) return 0, "" except Exception as err: return -1, str(err) @staticmethod def link(src: Path, dest: Path) -> Tuple[int, str]: """ 硬链接 """ try: # 准备目标路径,增加后缀 .mp tmp_path = dest.with_suffix(dest.suffix + ".mp") # 检查目标路径是否已存在,如果存在则先unlink if tmp_path.exists(): tmp_path.unlink() tmp_path.hardlink_to(src) # 硬链接完成,移除 .mp 后缀 shutil.move(tmp_path, dest) return 0, "" except Exception as err: return -1, str(err) @staticmethod def softlink(src: Path, dest: Path) -> Tuple[int, str]: """ 软链接 """ try: dest.symlink_to(src) return 0, "" except Exception as err: return -1, str(err) @staticmethod def list_files(directory: Path, extensions: list = None, min_filesize: int = 0, recursive: bool = True) -> List[Path]: """ 获取目录下所有指定扩展名的文件(包括子目录) :param directory: 指定的父目录 :param extensions: 需要包含的扩展名列表,例如 ['mkv', 'mp4'] :param min_filesize: 文件最低大小,单位 MB :param recursive: 是否递归查找,可选参数,默认 True :return: 文件 Path 列表 """ if not min_filesize: min_filesize = 0 if not directory.exists(): return [] if directory.is_file(): return [directory] files = [] # 预编译正则表达式 if extensions: pattern = re.compile(r".*(" + "|".join(extensions) + r")$", re.IGNORECASE) else: pattern = re.compile(r".*") def _scan_directory(dir_path: Path, is_recursive: bool): try: with os.scandir(dir_path) as entries: for entry in entries: try: if entry.is_file(follow_symlinks=False): entry_path = Path(entry.path) if (pattern.match(entry.name) and (min_filesize <= 0 or entry.stat().st_size >= min_filesize * 1024 * 1024)): files.append(entry_path) elif entry.is_dir() and is_recursive: _scan_directory(Path(entry.path), is_recursive) except (OSError, PermissionError): continue except (OSError, PermissionError): pass _scan_directory(directory, recursive) return files @staticmethod def exits_files(directory: Path, extensions: list, min_filesize: int = 0, recursive: bool = True) -> bool: """ 判断目录下是否存在指定扩展名的文件 :param directory: 指定的父目录 :param extensions: 需要包含的扩展名列表,例如 ['mkv', 'mp4'] :param min_filesize: 文件最低大小,单位 MB :param recursive: 是否递归查找,可选参数,默认 True :return: True存在 False不存在 """ if not directory.exists(): return False # 预编译正则表达式 if extensions: pattern = re.compile(r".*(" + "|".join(extensions) + r")$", re.IGNORECASE) else: pattern = re.compile(r".*") if directory.is_file(): # 检查单个文件是否符合条件 if extensions and not pattern.match(directory.name): return False if min_filesize > 0 and directory.stat().st_size < min_filesize * 1024 * 1024: return False return True def _search_files(dir_path: Path, is_recursive: bool) -> bool: try: with os.scandir(dir_path) as entries: for entry in entries: try: if entry.is_file(follow_symlinks=False): # 检查文件是否符合条件 if (pattern.match(entry.name) and (min_filesize <= 0 or entry.stat().st_size >= min_filesize * 1024 * 1024)): return True elif entry.is_dir() and is_recursive: # 递归搜索子目录 if _search_files(Path(entry.path), is_recursive): return True except (OSError, PermissionError): continue except (OSError, PermissionError): pass return False return _search_files(directory, recursive) @staticmethod def list_sub_files(directory: Path, extensions: list) -> List[Path]: """ 列出当前目录下的所有指定扩展名的文件(不包括子目录) """ if not directory.exists(): return [] if directory.is_file(): return [directory] files = [] # 预编译正则表达式 if extensions: pattern = re.compile(r".*(" + "|".join(extensions) + r")$", re.IGNORECASE) else: pattern = re.compile(r".*") try: with os.scandir(directory) as entries: for entry in entries: if entry.is_file() and pattern.match(entry.name): files.append(Path(entry.path)) except OSError: pass return files @staticmethod def list_sub_directory(directory: Path) -> List[Path]: """ 列出当前目录下的所有子目录(不递归) """ if not directory.exists(): return [] if directory.is_file(): return [] dirs = [] # 遍历目录 for path in directory.iterdir(): if path.is_dir(): if not SystemUtils.is_windows() and path.name.startswith("."): continue if path.name == "@eaDir": continue dirs.append(path) return dirs @staticmethod def list_sub_file(directory: Path) -> List[Path]: """ 列出当前目录下的所有子目录和文件(不递归) """ if not directory.exists(): return [] if directory.is_file(): return [directory] items = [] # 遍历目录 for path in directory.iterdir(): if path.is_file(): items.append(path) return items @staticmethod def get_directory_size(path: Path) -> int: """ 计算目录的大小 参数: directory_path (Path): 目录路径 返回: int: 目录的大小(以字节为单位) """ if not path or not path.exists(): return 0 def _calc_dir_size(dir_path): total = 0 try: with os.scandir(dir_path) as entries: for entry in entries: if entry.is_file(): total += entry.stat().st_size elif entry.is_dir(): total += _calc_dir_size(entry.path) except OSError: pass return total return _calc_dir_size(path) if path.is_dir() else path.stat().st_size @staticmethod def space_usage(dir_list: Union[Path, List[Path]]) -> Tuple[float, float]: """ 计算多个目录的总可用空间/剩余空间(单位:Byte),并去除重复磁盘 """ if not dir_list: return 0.0, 0.0 if not isinstance(dir_list, list): dir_list = [dir_list] # 存储不重复的磁盘 disk_set = set() # 存储总剩余空间 total_free_space = 0.0 # 存储总空间 total_space = 0.0 for dir_path in dir_list: if not dir_path: continue if not dir_path.exists(): continue # 获取目录所在磁盘 if os.name == "nt": disk = dir_path.drive else: disk = os.stat(dir_path).st_dev # 如果磁盘未出现过,则计算其剩余空间并加入总剩余空间中 if disk not in disk_set: disk_set.add(disk) total_space += SystemUtils.total_space(dir_path) total_free_space += SystemUtils.free_space(dir_path) return total_space, total_free_space @staticmethod def free_space(path: Path) -> float: """ 获取指定路径的剩余空间(单位:Byte) """ if not os.path.exists(path): return 0.0 return psutil.disk_usage(str(path)).free @staticmethod def total_space(path: Path) -> float: """ 获取指定路径的总空间(单位:Byte) """ if not os.path.exists(path): return 0.0 return psutil.disk_usage(str(path)).total @staticmethod def processes() -> List[schemas.ProcessInfo]: """ 获取所有进程 """ processes = [] for proc in psutil.process_iter(['pid', 'name', 'create_time', 'memory_info', 'status']): try: if proc.status() != psutil.STATUS_ZOMBIE: runtime = datetime.datetime.now() - datetime.datetime.fromtimestamp( int(getattr(proc, 'create_time', 0)())) mem_info = getattr(proc, 'memory_info', None)() if mem_info is not None: mem_mb = round(mem_info.rss / (1024 * 1024), 1) processes.append(schemas.ProcessInfo( pid=proc.pid, name=proc.name(), run_time=runtime.seconds, memory=mem_mb )) except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess): pass return processes @staticmethod def is_bluray_dir(dir_path: Path) -> bool: """ 判断是否为蓝光原盘目录 (该方法已弃用,改用`StorageChain().is_bluray_folder)` """ if not dir_path.is_dir(): return False # 蓝光原盘目录必备的文件或文件夹 required_files = ['BDMV', 'CERTIFICATE'] # 检查目录下是否存在所需文件或文件夹 for item in required_files: if (dir_path / item).exists(): return True return False @staticmethod def get_windows_drives(): """ 获取Windows所有盘符 """ vols = [] for i in range(65, 91): vol = chr(i) + ':' if os.path.isdir(vol): vols.append(vol) return vols @staticmethod def cpu_usage(): """ 获取CPU使用率 """ return psutil.cpu_percent() @staticmethod def memory_usage() -> List[int]: """ 获取当前程序的内存使用量和使用率 """ current_process = psutil.Process() process_memory = current_process.memory_info().rss system_memory = psutil.virtual_memory().total process_memory_percent = (process_memory / system_memory) * 100 return [process_memory, int(process_memory_percent)] @staticmethod def network_usage() -> List[int]: """ 获取当前网络流量(上行和下行流量,单位:bytes/s) """ import time # 获取初始网络统计 net_io_1 = psutil.net_io_counters() time.sleep(1) # 等待1秒 # 获取1秒后的网络统计 net_io_2 = psutil.net_io_counters() # 计算1秒内的流量变化 upload_speed = net_io_2.bytes_sent - net_io_1.bytes_sent download_speed = net_io_2.bytes_recv - net_io_1.bytes_recv return [upload_speed, download_speed] @staticmethod def is_hardlink(src: Path, dest: Path) -> bool: """ 判断是否为硬链接(可能无法支持宿主机挂载smb盘符映射docker的场景) """ try: if not src.exists() or not dest.exists(): return False if src.is_file(): # 如果是文件,直接比较文件 return src.samefile(dest) else: for src_file in src.glob("**/*"): if src_file.is_dir(): continue # 计算目标文件路径 relative_path = src_file.relative_to(src) target_file = dest.joinpath(relative_path) # 检查是否是硬链接 if not target_file.exists() or not src_file.samefile(target_file): return False return True except Exception as e: print(f"Error occurred: {e}") return False @staticmethod def is_network_filesystem(directory: Path) -> bool: """ 检测是否为网络文件系统 :param directory: 目录路径 :return: 是否为网络文件系统 """ try: system = platform.system() if system == 'Linux': # 检查挂载信息 result = subprocess.run(['df', '-T', str(directory)], capture_output=True, text=True, timeout=5) if result.returncode == 0: output = result.stdout.lower() # 以下本地文件系统含有fuse关键字 local_fs = [ "fuse.shfs", # Unraid "zfuse.zfsv", # 极空间(zfuse.zfsv2、zfuse.zfsv3、...) # TBD ] if any(fs in output for fs in local_fs): return False network_fs = ['nfs', 'cifs', 'smbfs', 'fuse', 'sshfs', 'ftpfs'] return any(fs in output for fs in network_fs) elif system == 'Darwin': # macOS 检查 result = subprocess.run(['df', '-T', str(directory)], capture_output=True, text=True, timeout=5) if result.returncode == 0: output = result.stdout.lower() return 'nfs' in output or 'smbfs' in output elif system == 'Windows': # Windows 检查网络驱动器 return str(directory).startswith('\\\\') except Exception as e: print(f"Error occurred: {e}") return False @staticmethod def is_same_disk(src: Path, dest: Path) -> bool: """ 判断两个路径是否在同一磁盘 """ if not src.exists() or not dest.exists(): return False if os.name == "nt": return src.drive == dest.drive return os.stat(src).st_dev == os.stat(dest).st_dev @staticmethod def get_config_path(config_dir: Optional[str] = None) -> Path: """ 获取配置路径 """ if not config_dir: config_dir = os.getenv("CONFIG_DIR") if config_dir: return Path(config_dir) if SystemUtils.is_docker(): return Path("/config") elif SystemUtils.is_frozen(): return Path(sys.executable).parent / "config" else: return Path(__file__).parents[2] / "config" @staticmethod def get_env_path() -> Path: """ 获取配置路径 """ return SystemUtils.get_config_path() / "app.env" @staticmethod def clear(temp_path: Path, days: int): """ 清理指定目录中指定天数前的文件,递归删除子文件及空文件夹 """ if not temp_path.exists(): return # 遍历目录及子目录中的所有文件和文件夹 for file in temp_path.rglob('*'): # 如果是文件并且符合时间条件,则删除 if file.is_file() and ( datetime.datetime.now() - datetime.datetime.fromtimestamp(file.stat().st_mtime)).days > days: file.unlink() # 删除空的文件夹 for folder in sorted(temp_path.rglob('*'), reverse=True): # 确保是空文件夹 if folder.is_dir() and not any(folder.iterdir()): folder.rmdir() @staticmethod def generate_user_unique_id(): """ 根据优先级依次尝试生成稳定唯一ID: 1. 文件系统唯一标识符。 2. MAC 地址。 3. 主机名。 """ def get_filesystem_unique_id(): """ 获取文件系统的唯一标识符。 使用根目录的设备号和 inode。 """ try: stat_info = os.stat("/") fs_id = f"{stat_info.st_dev}-{stat_info.st_ino}" return hashlib.sha256(fs_id.encode("utf-8")).hexdigest() except Exception as e: print(str(e)) return None def get_mac_address_id(): """ 获取设备的 MAC 地址并生成唯一标识符。 """ try: mac_address = uuid.getnode() if (mac_address >> 40) % 2: # 检查是否是虚拟MAC地址 raise ValueError("MAC地址可能是虚拟地址") mac_str = f"{mac_address:012x}" return hashlib.sha256(mac_str.encode("utf-8")).hexdigest() except Exception as e: print(str(e)) return None for method in [get_filesystem_unique_id, get_mac_address_id]: unique_id = method() if unique_id: return unique_id return None ================================================ FILE: app/utils/timer.py ================================================ import datetime import random from typing import List class TimerUtils: @staticmethod def random_scheduler(num_executions: int = 1, begin_hour: int = 7, end_hour: int = 23, min_interval: int = 20, max_interval: int = 40) -> List[datetime.datetime]: """ 按执行次数生成随机定时器 :param num_executions: 执行次数 :param begin_hour: 开始时间 :param end_hour: 结束时间 :param min_interval: 最小间隔分钟 :param max_interval: 最大间隔分钟 """ trigger: list = [] # 当前时间 now = datetime.datetime.now() # 创建随机的时间触发器 random_trigger = now.replace(hour=begin_hour, minute=0, second=0, microsecond=0) for _ in range(num_executions): # 随机生成下一个任务的时间间隔 interval_minutes = random.randint(min_interval, max_interval) random_interval = datetime.timedelta(minutes=interval_minutes) # 记录上一个任务的时间触发器 last_random_trigger = random_trigger # 更新当前时间为下一个任务的时间触发器 random_trigger += random_interval # 达到结束时间或者时间出现倒退时退出 if random_trigger.hour > end_hour \ or random_trigger.hour < last_random_trigger.hour: break # 添加到队列 trigger.append(random_trigger) return trigger @staticmethod def random_even_scheduler(num_executions: int = 1, begin_hour: int = 7, end_hour: int = 23) -> List[datetime.datetime]: """ 按执行次数尽可能平均生成随机定时器 :param num_executions: 执行次数 :param begin_hour: 计划范围开始的小时数 :param end_hour: 计划范围结束的小时数 """ trigger_times = [] start_time = datetime.datetime.now().replace(hour=begin_hour, minute=0, second=0, microsecond=0) end_time = datetime.datetime.now().replace(hour=end_hour, minute=0, second=0, microsecond=0) # 计算范围内的总分钟数 total_minutes = int((end_time - start_time).total_seconds() / 60) # 计算每个执行时间段的平均长度 segment_length = total_minutes // num_executions for i in range(num_executions): # 在每个段内随机选择一个点 start_segment = segment_length * i end_segment = start_segment + segment_length minute = random.randint(start_segment, end_segment - 1) trigger_time = start_time + datetime.timedelta(minutes=minute) trigger_times.append(trigger_time) return trigger_times @staticmethod def time_difference(input_datetime: datetime) -> str: """ 判断输入时间与当前的时间差,如果输入时间大于当前时间则返回时间差,否则返回空字符串 """ if not input_datetime: return "" current_datetime = datetime.datetime.now(datetime.timezone.utc).astimezone() time_difference = input_datetime - current_datetime if time_difference.total_seconds() < 0: return "" days = time_difference.days hours, remainder = divmod(time_difference.seconds, 3600) minutes, second = divmod(remainder, 60) time_difference_string = "" if days > 0: time_difference_string += f"{days}天" if hours > 0: time_difference_string += f"{hours}小时" if minutes > 0: time_difference_string += f"{minutes}分钟" if not time_difference_string and second: time_difference_string = f"{second}秒" return time_difference_string @staticmethod def diff_minutes(input_datetime: datetime) -> int: """ 计算当前时间与输入时间的分钟差 """ if not input_datetime: return 0 time_difference = datetime.datetime.now() - input_datetime return int(time_difference.total_seconds() / 60) ================================================ FILE: app/utils/tokens.py ================================================ import re class Tokens: _text: str = "" _index: int = 0 _tokens: list = [] def __init__(self, text): self._text = text self._tokens = [] self.load_text(text) def load_text(self, text): splitted_text = re.split(r"\.|\s+|\(|\)|\[|]|-|【|】|/|~|;|&|\||#|_|「|」|~", text) for sub_text in splitted_text: if sub_text: self._tokens.append(sub_text) def cur(self): if self._index >= len(self._tokens): return None else: token = self._tokens[self._index] return token def get_next(self): token = self.cur() if token: self._index = self._index + 1 return token def peek(self): index = self._index + 1 if index >= len(self._tokens): return None else: return self._tokens[index] @property def tokens(self): return self._tokens ================================================ FILE: app/utils/ugreen_crypto.py ================================================ from __future__ import annotations import base64 import hashlib import json import os import uuid from dataclasses import dataclass from typing import Any, Mapping, Sequence from urllib.parse import quote, urlencode, urlsplit, urlunsplit from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric import padding from cryptography.hazmat.primitives.ciphers.aead import AESGCM @dataclass class UgreenEncryptedRequest: url: str headers: dict[str, str] params: dict[str, str] json: dict[str, Any] | None aes_key: str plain_query: str class UgreenCrypto: """ 绿联接口请求加解密工具。 """ def __init__( self, public_key: str, token: str | None = None, client_id: str | None = None, client_version: str | None = "76363", ug_agent: str | None = "PC/WEB", language: str = "zh-CN", ) -> None: self.public_key_pem = self.normalize_public_key(public_key) self.public_key = serialization.load_pem_public_key( self.public_key_pem.encode("utf-8") ) self.token = token self.client_id = client_id self.client_version = client_version self.ug_agent = ug_agent self.language = language @staticmethod def normalize_public_key(public_key: str) -> str: key = (public_key or "").strip().strip('"').replace("\\n", "\n") if "BEGIN" in key: return key if key.endswith("\n") else f"{key}\n" return ( "-----BEGIN RSA PUBLIC KEY-----\n" f"{key}\n" "-----END RSA PUBLIC KEY-----\n" ) @staticmethod def generate_aes_key() -> str: return uuid.uuid4().hex @staticmethod def _flatten_query(prefix: str, value: Any) -> list[tuple[str, str]]: pairs: list[tuple[str, str]] = [] if isinstance(value, Mapping): for key, item in value.items(): next_prefix = f"{prefix}[{key}]" if prefix else str(key) pairs.extend(UgreenCrypto._flatten_query(next_prefix, item)) return pairs if isinstance(value, Sequence) and not isinstance( value, (str, bytes, bytearray) ): for item in value: next_prefix = f"{prefix}[]" pairs.extend(UgreenCrypto._flatten_query(next_prefix, item)) return pairs if isinstance(value, bool): pairs.append((prefix, "true" if value else "false")) return pairs if value is None: pairs.append((prefix, "")) return pairs pairs.append((prefix, str(value))) return pairs @classmethod def encode_query(cls, params: Mapping[str, Any] | None) -> str: if not params: return "" pairs: list[tuple[str, str]] = [] for key, value in params.items(): pairs.extend(cls._flatten_query(str(key), value)) return urlencode(pairs, doseq=False, quote_via=quote, safe="") def rsa_encrypt_long(self, plaintext: str) -> str: if not plaintext: return "" key_size = self.public_key.key_size // 8 max_chunk = key_size - 11 encrypted_chunks: list[bytes] = [] raw = plaintext.encode("utf-8") for start in range(0, len(raw), max_chunk): chunk = raw[start : start + max_chunk] encrypted_chunks.append( self.public_key.encrypt(chunk, padding.PKCS1v15()) ) return base64.b64encode(b"".join(encrypted_chunks)).decode("utf-8") @staticmethod def aes_gcm_encrypt(plaintext: str, aes_key: str) -> str: iv = os.urandom(12) cipher = AESGCM(aes_key.encode("utf-8")) encrypted = cipher.encrypt(iv, plaintext.encode("utf-8"), None) # encrypt 返回 ciphertext + tag return base64.b64encode(iv + encrypted).decode("utf-8") @staticmethod def aes_gcm_decrypt(payload_b64: str, aes_key: str) -> str: raw = base64.b64decode(payload_b64) iv = raw[:12] encrypted = raw[12:] cipher = AESGCM(aes_key.encode("utf-8")) plain = cipher.decrypt(iv, encrypted, None) return plain.decode("utf-8") @staticmethod def build_security_key(token: str) -> str: return hashlib.md5(token.encode("utf-8")).hexdigest() @staticmethod def _normalize_body(data: Any) -> str: if isinstance(data, str): return data if isinstance(data, (bytes, bytearray)): return bytes(data).decode("utf-8") return json.dumps(data, ensure_ascii=False, separators=(",", ":")) def encrypt_body(self, data: Any, aes_key: str) -> dict[str, str]: plain = self._normalize_body(data) return { "encrypt_req_body": self.aes_gcm_encrypt(plain, aes_key), "req_body_sha256": hashlib.sha256(plain.encode("utf-8")).hexdigest(), } def build_headers( self, aes_key: str, token: str | None = None, extra_headers: Mapping[str, str] | None = None, encrypt_token: bool = True, ) -> dict[str, str]: token_value = token if token is not None else self.token headers: dict[str, str] = dict(extra_headers or {}) if self.client_id: headers.setdefault("Client-Id", self.client_id) if self.client_version: headers.setdefault("Client-Version", self.client_version) if self.ug_agent: headers.setdefault("UG-Agent", self.ug_agent) headers.setdefault("X-Specify-Language", self.language) headers.setdefault("Accept", "application/json, text/plain, */*") if token_value: headers["X-Ugreen-Security-Key"] = self.build_security_key(token_value) headers["X-Ugreen-Security-Code"] = self.rsa_encrypt_long(aes_key) headers["X-Ugreen-Token"] = ( self.rsa_encrypt_long(token_value) if encrypt_token else token_value ) return headers def build_encrypted_request( self, url: str, method: str = "GET", params: Mapping[str, Any] | None = None, data: Any | None = None, extra_headers: Mapping[str, str] | None = None, token: str | None = None, encrypt_token: bool = True, encrypt_body: bool = True, ) -> UgreenEncryptedRequest: """ 构建绿联加密请求。 关键点: - 传入的是明文 `params`; - 方法内部会将其序列化并加密成 `encrypt_query`; - 业务侧不需要、也不应该手工拼接 `encrypt_query`。 """ parsed = urlsplit(url) clean_url = urlunsplit( (parsed.scheme, parsed.netloc, parsed.path, "", parsed.fragment) ) url_query_plain = parsed.query input_query_plain = self.encode_query(params) plain_query = "&".join(filter(None, [url_query_plain, input_query_plain])) aes_key = self.generate_aes_key() encrypted_query = self.aes_gcm_encrypt(plain_query, aes_key) req_json = None if data is not None: req_json = self.encrypt_body(data, aes_key) if encrypt_body else data headers = self.build_headers( aes_key=aes_key, token=token, extra_headers=extra_headers, encrypt_token=encrypt_token, ) if req_json is not None: headers.setdefault("Content-Type", "application/json") _ = method # 保留参数,便于上层统一调用 return UgreenEncryptedRequest( url=clean_url, headers=headers, # 绿联接口约定:查询参数统一透传为 encrypt_query params={"encrypt_query": encrypted_query}, json=req_json, aes_key=aes_key, plain_query=plain_query, ) def decrypt_response(self, response_json: Any, aes_key: str) -> Any: if not isinstance(response_json, Mapping): return response_json encrypted = response_json.get("encrypt_resp_body") if not encrypted: return response_json plain = self.aes_gcm_decrypt(str(encrypted), aes_key) try: return json.loads(plain) except json.JSONDecodeError: return plain ================================================ FILE: app/utils/url.py ================================================ import mimetypes from pathlib import Path from typing import Optional, Union, Tuple from urllib import parse from urllib.parse import parse_qs, urlencode, urljoin, urlparse, urlunparse from app.log import logger class UrlUtils: @staticmethod def standardize_base_url(host: str) -> str: """ 标准化提供的主机地址,确保它以http://或https://开头,并且以斜杠(/)结尾 :param host: 提供的主机地址字符串 :return: 标准化后的主机地址字符串 """ if not host: return host if not host.endswith("/"): host += "/" if not host.startswith("http://") and not host.startswith("https://"): host = "http://" + host return host @staticmethod def adapt_request_url(host: str, endpoint: str) -> Optional[str]: """ 基于传入的host,适配请求的URL,确保每个请求的URL是完整的,用于在发送请求前自动处理和修正请求的URL :param host: 主机头 :param endpoint: 端点 :return: 完整的请求URL字符串 """ if not host and not endpoint: return None if endpoint.startswith(("http://", "https://")): return endpoint host = UrlUtils.standardize_base_url(host) return urljoin(host, endpoint) if host else endpoint @staticmethod def combine_url(host: str, path: Optional[str] = None, query: Optional[dict] = None) -> Optional[str]: """ 使用给定的主机头、路径和查询参数组合生成完整的URL :param host: str, 主机头,例如 https://example.com :param path: Optional[str], 包含路径和可能已经包含的查询参数的端点,例如 /path/to/resource?current=1 :param query: Optional[dict], 可选,额外的查询参数,例如 {"key": "value"} :return: str, 完整的请求URL字符串 """ try: # 如果路径为空,则默认为 '/' if path is None: path = '/' host = UrlUtils.standardize_base_url(host) # 使用 urljoin 合并 host 和 path url = urljoin(host, path) # 解析当前 URL 的组成部分 url_parts = urlparse(url) # 解析已存在的查询参数,并与额外的查询参数合并 query_params = parse_qs(url_parts.query) if query: for key, value in query.items(): query_params[key] = value # 重新构建查询字符串 query_string = urlencode(query_params, doseq=True) # 构建完整的 URL new_url_parts = url_parts._replace(query=query_string) complete_url = urlunparse(new_url_parts) return str(complete_url) except Exception as e: logger.debug(f"Error combining URL: {e}") return None @staticmethod def get_mime_type(path_or_url: Union[str, Path], default_type: str = "application/octet-stream") -> str: """ 根据文件路径或 URL 获取 MIME 类型,如果无法获取则返回默认类型 :param path_or_url: 文件路径 (Path) 或 URL (str) :param default_type: 无法获取类型时返回的默认 MIME 类型 :return: 获取到的 MIME 类型或默认类型 """ try: # 如果是 Path 类型,转换为字符串 if isinstance(path_or_url, Path): path_or_url = str(path_or_url) # 尝试根据路径或 URL 获取 MIME 类型 mime_type, _ = mimetypes.guess_type(path_or_url) # 如果无法推测到类型,返回默认类型 if not mime_type: return default_type return mime_type except Exception as e: logger.debug(f"Error get_mime_type: {e}") return default_type @staticmethod def quote(s: str) -> str: """ 将字符串编码为 URL 安全的格式 :param s: 要编码的字符串 :return: 编码后的字符串 """ return parse.quote(s) @staticmethod def parse_url_params(url: str) -> Optional[Tuple[str, str, int, str]]: """ 解析给定的 URL,并提取协议、主机名、端口和路径信息 :param url: str 需要解析的 URL 字符串 可以是完整的 URL(例如:"http://example.com:8080/path")或不带协议的地址(例如:"example.com:1234") :return: Optional[Tuple[str, str, int, str]] - str: 协议(例如:"http", "https") - str: 主机名或 IP 地址(例如:"example.com", "192.168.1.1") - int: 端口号(例如:80, 443) - str: URL 的路径部分(例如:"/", "/path") 如果输入地址无效或无法解析,则返回 None """ try: if not url: return None url = UrlUtils.standardize_base_url(host=url) parsed = urlparse(url) if not parsed.hostname: return None protocol = parsed.scheme hostname = parsed.hostname port = parsed.port or (443 if protocol == "https" else 80) path = parsed.path or "/" return protocol, hostname, port, path except Exception as e: logger.debug(f"Error parse_url_params: {e}") return None ================================================ FILE: app/utils/web.py ================================================ from app.utils.http import RequestUtils class WebUtils: @staticmethod def get_location(ip: str): """ 查询IP所属地 """ return WebUtils.get_location1(ip) or WebUtils.get_location2(ip) @staticmethod def get_location1(ip: str): """ https://api.mir6.com/api/ip { "code": 200, "msg": "success", "data": { "ip": "240e:97c:2f:1::5c", "dec": "47925092370311863177116789888333643868", "country": "中国", "countryCode": "CN", "province": "广东省", "city": "广州市", "districts": "", "idc": "", "isp": "中国电信", "net": "数据中心", "zipcode": "510000", "areacode": "020", "protocol": "IPv6", "location": "中国[CN] 广东省 广州市", "myip": "125.89.7.89", "time": "2023-09-01 17:28:23" } } """ try: r = RequestUtils().get_res(f"https://api.mir6.com/api/ip?ip={ip}&type=json") if r: return r.json().get("data", {}).get("location") or '' except Exception as err: print(str(err)) return "" @staticmethod def get_location2(ip: str): """ https://whois.pconline.com.cn/ipJson.jsp?json=true&ip= { "ip": "122.8.12.22", "pro": "上海市", "proCode": "310000", "city": "上海市", "cityCode": "310000", "region": "", "regionCode": "0", "addr": "上海市 铁通", "regionNames": "", "err": "" } """ try: r = RequestUtils().get_res(f"https://whois.pconline.com.cn/ipJson.jsp?json=true&ip={ip}") if r: return r.json().get("addr") or '' except Exception as err: print(str(err)) return "" ================================================ FILE: app/workflow/__init__.py ================================================ import threading from time import sleep from typing import Dict, Any, Optional from typing import List, Tuple from app.core.config import global_vars from app.core.event import eventmanager, Event from app.db.models import Workflow from app.db.workflow_oper import WorkflowOper from app.helper.module import ModuleHelper from app.log import logger from app.schemas import ActionContext, Action from app.schemas.types import EventType from app.utils.singleton import Singleton class WorkFlowManager(metaclass=Singleton): """ 工作流管理器 """ def __init__(self): # 所有动作定义 self._lock = threading.Lock() self._actions: Dict[str, Any] = {} self._event_workflows: Dict[str, List[int]] = {} self.init() def init(self): """ 初始化 """ def filter_func(obj: Any): """ 过滤函数,确保只加载新定义的类 """ if not isinstance(obj, type): return False if not hasattr(obj, 'execute') or not hasattr(obj, "name"): return False if obj.__name__ == "BaseAction": return False return obj.__module__.startswith("app.workflow.actions") # 加载所有动作 self._actions = {} actions = ModuleHelper.load( "app.workflow.actions", filter_func=lambda _, obj: filter_func(obj) ) for action in actions: logger.debug(f"加载动作: {action.__name__}") try: self._actions[action.__name__] = action except Exception as err: logger.error(f"加载动作失败: {action.__name__} - {err}") # 加载工作流事件触发器 self.load_workflow_events() def stop(self): """ 停止 """ self._actions = {} self._event_workflows = {} def excute(self, workflow_id: int, action: Action, context: ActionContext = None) -> Tuple[bool, str, ActionContext]: """ 执行工作流动作 """ if not context: context = ActionContext() if action.type in self._actions: # 实例化之前,清理掉类对象的数据 # 实例化 action_obj = self._actions[action.type](action.id) # 执行 logger.info(f"执行动作: {action.id} - {action.name}") try: result_context = action_obj.execute(workflow_id, action.data, context) except Exception as err: logger.error(f"{action.name} 执行失败: {err}") return False, f"{err}", context loop = action.data.get("loop") loop_interval = action.data.get("loop_interval") if loop and loop_interval: while not action_obj.done: if global_vars.is_workflow_stopped(workflow_id): break # 等待 logger.info(f"{action.name} 等待 {loop_interval} 秒后继续执行 ...") sleep(loop_interval) # 执行 logger.info(f"继续执行动作: {action.id} - {action.name}") result_context = action_obj.execute(workflow_id, action.data, result_context) if action_obj.success: logger.info(f"{action.name} 执行成功") else: logger.error(f"{action.name} 执行失败!") return action_obj.success, action_obj.message, result_context else: logger.error(f"未找到动作: {action.type} - {action.name}") return False, " ", context def list_actions(self) -> List[dict]: """ 获取所有动作 """ return [ { "type": key, "name": action.name, "description": action.description, "data": { "label": action.name, **action.data } } for key, action in self._actions.items() ] def update_workflow_event(self, workflow: Workflow): """ 更新工作流事件触发器 """ # 确保先移除旧的事件监听器 self.remove_workflow_event(workflow_id=workflow.id, event_type_str=workflow.event_type) # 如果工作流是事件触发类型且未被禁用 if workflow.trigger_type == "event" and workflow.state != 'P': # 注册事件触发器 self.register_workflow_event(workflow.id, workflow.event_type) def load_workflow_events(self, workflow_id: Optional[int] = None): """ 加载工作流触发事件 """ workflows = [] if workflow_id: workflow = WorkflowOper().get(workflow_id) if workflow: workflows = [workflow] else: workflows = WorkflowOper().get_event_triggered_workflows() try: for workflow in workflows: self.update_workflow_event(workflow) except Exception as e: logger.error(f"加载事件触发工作流失败: {e}") def register_workflow_event(self, workflow_id: int, event_type_str: str): """ 注册工作流事件触发器 """ try: event_type = EventType(event_type_str) except ValueError: logger.error(f"无效的事件类型: {event_type_str}") return if event_type in EventType: # 确保先移除旧的事件监听器 self.remove_workflow_event(workflow_id, event_type.value) with self._lock: # 添加新的事件监听器 eventmanager.add_event_listener(event_type, self._handle_event) # 记录工作流事件触发器 if event_type.value not in self._event_workflows: self._event_workflows[event_type.value] = [] self._event_workflows[event_type.value].append(workflow_id) logger.info(f"已注册工作流 {workflow_id} 事件触发器: {event_type.value}") def remove_workflow_event(self, workflow_id: int, event_type_str: str): """ 移除工作流事件触发器 """ try: event_type = EventType(event_type_str) except ValueError: logger.error(f"无效的事件类型: {event_type_str}") return if event_type in EventType: with self._lock: eventmanager.remove_event_listener(event_type, self._handle_event) if event_type.value in self._event_workflows: if workflow_id in self._event_workflows[event_type.value]: self._event_workflows[event_type.value].remove(workflow_id) if not self._event_workflows[event_type.value]: del self._event_workflows[event_type.value] logger.info(f"已移除工作流 {workflow_id} 事件触发器") def _handle_event(self, event: Event): """ 处理事件,触发相应的工作流 """ try: event_type_str = str(event.event_type.value) with self._lock: if event_type_str not in self._event_workflows: return workflow_ids = self._event_workflows[event_type_str].copy() for workflow_id in workflow_ids: self._trigger_workflow(workflow_id, event) except Exception as e: logger.error(f"处理工作流事件失败: {e}") def _trigger_workflow(self, workflow_id: int, event: Event): """ 触发工作流执行 """ try: # 检查工作流是否存在且启用 workflow = WorkflowOper().get(workflow_id) if not workflow or workflow.state == 'P': return # 检查事件条件 if not self._check_event_conditions(workflow, event): logger.debug(f"工作流 {workflow.name} 事件条件不匹配,跳过执行") return # 检查工作流是否正在运行 if workflow.state == 'R': logger.warning(f"工作流 {workflow.name} 正在运行中,跳过重复触发") return logger.info(f"事件 {event.event_type.value} 触发工作流: {workflow.name}") # 发送工作流执行事件以启动工作流 eventmanager.send_event(EventType.WorkflowExecute, { "workflow_id": workflow_id, }) except Exception as e: logger.error(f"触发工作流 {workflow_id} 失败: {e}") def _check_event_conditions(self, workflow, event: Event) -> bool: """ 检查事件是否满足工作流的触发条件 """ if not workflow.event_conditions: return True conditions = workflow.event_conditions event_data = event.event_data or {} # 检查字段匹配条件 for field, expected_value in conditions.items(): if field not in event_data: return False actual_value = event_data[field] # 支持多种条件匹配方式 if isinstance(expected_value, dict): # 复杂条件匹配 if not self._check_complex_condition(actual_value, expected_value): return False else: # 简单值匹配 if actual_value != expected_value: return False return True @staticmethod def _check_complex_condition(actual_value: any, condition: dict) -> bool: """ 检查复杂条件匹配 支持的操作符:equals, not_equals, contains, not_contains, in, not_in, regex """ for operator, expected_value in condition.items(): if operator == "equals": if actual_value != expected_value: return False elif operator == "not_equals": if actual_value == expected_value: return False elif operator == "contains": if expected_value not in str(actual_value): return False elif operator == "not_contains": if expected_value in str(actual_value): return False elif operator == "in": if actual_value not in expected_value: return False elif operator == "not_in": if actual_value in expected_value: return False elif operator == "regex": import re if not re.search(expected_value, str(actual_value)): return False return True def get_event_workflows(self) -> dict: """ 获取所有事件触发的工作流 """ with self._lock: return self._event_workflows.copy() ================================================ FILE: app/workflow/actions/__init__.py ================================================ from abc import ABC, abstractmethod from typing import Union from app.chain import ChainBase from app.db.systemconfig_oper import SystemConfigOper from app.schemas import ActionContext, ActionParams class ActionChain(ChainBase): pass class BaseAction(ABC): """ 工作流动作基类 """ # 动作ID _action_id = None # 完成标志 _done_flag = False # 执行信息 _message = "" # 缓存键值 _cache_key = "WorkflowCache-%s" def __init__(self, action_id: str): self._action_id = action_id self.systemconfigoper = SystemConfigOper() @classmethod @property @abstractmethod def name(cls) -> str: # noqa pass @classmethod @property @abstractmethod def description(cls) -> str: # noqa pass @classmethod @property @abstractmethod def data(cls) -> dict: # noqa pass @property def done(self) -> bool: """ 判断动作是否完成 """ return self._done_flag @property @abstractmethod def success(self) -> bool: """ 判断动作是否成功 """ pass @property def message(self) -> str: """ 执行信息 """ return self._message def job_done(self, message: str = None): """ 标记动作完成 """ self._message = message self._done_flag = True def check_cache(self, workflow_id: int, key: str) -> bool: """ 检查是否处理过 """ workflow_key = self._cache_key % workflow_id workflow_cache = self.systemconfigoper.get(workflow_key) or {} action_cache = workflow_cache.get(self._action_id) or [] return key in action_cache def save_cache(self, workflow_id: int, data: Union[list, str]): """ 保存缓存 """ workflow_key = self._cache_key % workflow_id workflow_cache = self.systemconfigoper.get(workflow_key) or {} action_cache = workflow_cache.get(self._action_id) or [] if isinstance(data, list): action_cache.extend(data) else: action_cache.append(data) workflow_cache[self._action_id] = action_cache self.systemconfigoper.set(workflow_key, workflow_cache) @abstractmethod def execute(self, workflow_id: int, params: ActionParams, context: ActionContext) -> ActionContext: """ 执行动作 """ raise NotImplementedError ================================================ FILE: app/workflow/actions/add_download.py ================================================ from typing import Optional from pydantic import Field from app.workflow.actions import BaseAction from app.chain.download import DownloadChain from app.chain.media import MediaChain from app.core.config import global_vars from app.core.metainfo import MetaInfo from app.log import logger from app.schemas import ActionParams, ActionContext, DownloadTask, MediaType class AddDownloadParams(ActionParams): """ 添加下载资源参数 """ downloader: Optional[str] = Field(default=None, description="下载器") save_path: Optional[str] = Field(default=None, description="保存路径, 支持:, 如rclone:/MP, smb:/server/share/Movies等") labels: Optional[str] = Field(default=None, description="标签(,分隔)") only_lack: Optional[bool] = Field(default=False, description="仅下载缺失的资源") class AddDownloadAction(BaseAction): """ 添加下载资源 """ def __init__(self, action_id: str): super().__init__(action_id) self._added_downloads = [] self._has_error = False @classmethod @property def name(cls) -> str: # noqa return "添加下载" @classmethod @property def description(cls) -> str: # noqa return "根据资源列表添加下载任务" @classmethod @property def data(cls) -> dict: # noqa return AddDownloadParams().model_dump() @property def success(self) -> bool: return not self._has_error def execute(self, workflow_id: int, params: dict, context: ActionContext) -> ActionContext: """ 将上下文中的torrents添加到下载任务中 """ params = AddDownloadParams(**params) _started = False for t in context.torrents: if global_vars.is_workflow_stopped(workflow_id): break # 检查缓存 cache_key = f"{t.torrent_info.site}-{t.torrent_info.title}" if self.check_cache(workflow_id, cache_key): logger.info(f"{t.torrent_info.title} 已添加过下载,跳过") continue if not t.meta_info: t.meta_info = MetaInfo(title=t.torrent_info.title, subtitle=t.torrent_info.description) if not t.media_info: t.media_info = MediaChain().recognize_media(meta=t.meta_info) if not t.media_info: self._has_error = True logger.warning(f"{t.torrent_info.title} 未识别到媒体信息,无法下载") continue if params.only_lack: exists_info = DownloadChain().media_exists(t.media_info) if exists_info: if t.media_info.type == MediaType.MOVIE: # 电影 logger.warning(f"{t.torrent_info.title} 媒体库中已存在,跳过") continue else: # 电视剧 exists_seasons = exists_info.seasons or {} if len(t.meta_info.season_list) > 1: # 多季不下载 logger.warning(f"{t.meta_info.title} 有多季,跳过") continue else: exists_episodes = exists_seasons.get(t.meta_info.begin_season) if exists_episodes: if set(t.meta_info.episode_list).issubset(exists_episodes): logger.warning( f"{t.meta_info.title} 第 {t.meta_info.begin_season} 季第 {t.meta_info.episode_list} 集已存在,跳过") continue _started = True did = DownloadChain().download_single(context=t, downloader=params.downloader, save_path=params.save_path, label=params.labels) if did: self._added_downloads.append(did) # 保存缓存 self.save_cache(workflow_id, cache_key) if self._added_downloads: logger.info(f"已添加 {len(self._added_downloads)} 个下载任务") context.downloads.extend( [DownloadTask(download_id=did, downloader=params.downloader) for did in self._added_downloads] ) elif _started: self._has_error = True self.job_done(f"已添加 {len(self._added_downloads)} 个下载任务") return context ================================================ FILE: app/workflow/actions/add_subscribe.py ================================================ from app.workflow.actions import BaseAction from app.chain.subscribe import SubscribeChain from app.core.config import settings, global_vars from app.core.context import MediaInfo from app.db.subscribe_oper import SubscribeOper from app.log import logger from app.schemas import ActionParams, ActionContext class AddSubscribeParams(ActionParams): """ 添加订阅参数 """ pass class AddSubscribeAction(BaseAction): """ 添加订阅 """ def __init__(self, action_id: str): super().__init__(action_id) self._added_subscribes = [] self._has_error = False @classmethod @property def name(cls) -> str: # noqa return "添加订阅" @classmethod @property def description(cls) -> str: # noqa return "根据媒体列表添加订阅" @classmethod @property def data(cls) -> dict: # noqa return AddSubscribeParams().model_dump() @property def success(self) -> bool: return not self._has_error def execute(self, workflow_id: int, params: dict, context: ActionContext) -> ActionContext: """ 将medias中的信息添加订阅,如果订阅不存在的话 """ _started = False for media in context.medias: if global_vars.is_workflow_stopped(workflow_id): break # 检查缓存 cache_key = f"{media.type}-{media.title}-{media.year}-{media.season}" if self.check_cache(workflow_id, cache_key): logger.info(f"{media.title} {media.year} 已添加过订阅,跳过") continue mediainfo = MediaInfo() mediainfo.from_dict(media.model_dump()) subscribechain = SubscribeChain() if subscribechain.exists(mediainfo): logger.info(f"{media.title} 已存在订阅") continue # 添加订阅 _started = True sid, message = subscribechain.add(mtype=mediainfo.type, title=mediainfo.title, year=mediainfo.year, tmdbid=mediainfo.tmdb_id, season=mediainfo.season, doubanid=mediainfo.douban_id, bangumiid=mediainfo.bangumi_id, username=settings.SUPERUSER) if sid: self._added_subscribes.append(sid) # 保存缓存 self.save_cache(workflow_id, cache_key) if self._added_subscribes: logger.info(f"已添加 {len(self._added_subscribes)} 个订阅") for sid in self._added_subscribes: context.subscribes.append(SubscribeOper().get(sid)) elif _started: self._has_error = True self.job_done(f"已添加 {len(self._added_subscribes)} 个订阅") return context ================================================ FILE: app/workflow/actions/fetch_downloads.py ================================================ from app.workflow.actions import BaseAction, ActionChain from app.core.config import global_vars from app.schemas import ActionParams, ActionContext from app.log import logger class FetchDownloadsParams(ActionParams): """ 获取下载任务参数 """ pass class FetchDownloadsAction(BaseAction): """ 获取下载任务 """ def __init__(self, action_id: str): super().__init__(action_id) self._downloads = [] @classmethod @property def name(cls) -> str: # noqa return "获取下载任务" @classmethod @property def description(cls) -> str: # noqa return "获取下载队列中的任务状态" @classmethod @property def data(cls) -> dict: # noqa return FetchDownloadsParams().model_dump() @property def success(self) -> bool: return self.done def execute(self, workflow_id: int, params: dict, context: ActionContext) -> ActionContext: """ 更新downloads中的下载任务状态 """ __all_complete = False for download in self._downloads: if global_vars.is_workflow_stopped(workflow_id): break logger.info(f"获取下载任务 {download.download_id} 状态 ...") torrents = ActionChain().list_torrents(hashs=[download.download_id]) if not torrents: download.completed = True continue for t in torrents: download.path = t.path if t.progress >= 100: logger.info(f"下载任务 {download.download_id} 已完成") download.completed = True else: logger.info(f"下载任务 {download.download_id} 未完成") download.completed = False if all([d.completed for d in self._downloads]): self.job_done() return context ================================================ FILE: app/workflow/actions/fetch_medias.py ================================================ from typing import List, Optional from pydantic import Field from app.workflow.actions import BaseAction from app.chain.recommend import RecommendChain from app.schemas import ActionParams, ActionContext from app.core.config import settings, global_vars from app.core.event import eventmanager from app.log import logger from app.schemas import RecommendSourceEventData, MediaInfo from app.schemas.types import ChainEventType from app.utils.http import RequestUtils class FetchMediasParams(ActionParams): """ 获取媒体数据参数 """ source_type: Optional[str] = Field(default="ranking", description="来源") sources: Optional[List[str]] = Field(default=[], description="榜单") api_path: Optional[str] = Field(default=None, description="API路径") class FetchMediasAction(BaseAction): """ 获取媒体数据 """ def __init__(self, action_id: str): super().__init__(action_id) self._medias = [] self._has_error = False self.__inner_sources = [ { "func": RecommendChain().tmdb_trending, "name": '流行趋势', "api_path": "recommend/tmdb_trending" }, { "func": RecommendChain().douban_movie_showing, "name": '正在热映', "api_path": "recommend/douban_showing" }, { "func": RecommendChain().bangumi_calendar, "name": 'Bangumi每日放送', "api_path": "recommend/bangumi_calendar" }, { "func": RecommendChain().tmdb_movies, "name": 'TMDB热门电影', "api_path": "recommend/tmdb_movies" }, { "func": RecommendChain().tmdb_tvs, "name": 'TMDB热门电视剧', "api_path": "recommend/tmdb_tvs?with_original_language=zh|en|ja|ko" }, { "func": RecommendChain().douban_movie_hot, "name": '豆瓣热门电影', "api_path": "recommend/douban_movie_hot" }, { "func": RecommendChain().douban_tv_hot, "name": '豆瓣热门电视剧', "api_path": "recommend/douban_tv_hot" }, { "func": RecommendChain().douban_tv_animation, "name": '豆瓣热门动漫', "api_path": "recommend/douban_tv_animation" }, { "func": RecommendChain().douban_movies, "name": '豆瓣最新电影', "api_path": "recommend/douban_movies" }, { "func": RecommendChain().douban_tvs, "name": '豆瓣最新电视剧', "api_path": "recommend/douban_tvs" }, { "func": RecommendChain().douban_movie_top250, "name": '豆瓣电影TOP250', "api_path": "recommend/douban_movie_top250" }, { "func": RecommendChain().douban_tv_weekly_chinese, "name": '豆瓣国产剧集榜', "api_path": "recommend/douban_tv_weekly_chinese" }, { "func": RecommendChain().douban_tv_weekly_global, "name": '豆瓣全球剧集榜', "api_path": "recommend/douban_tv_weekly_global" } ] # 广播事件,请示额外的推荐数据源支持 event_data = RecommendSourceEventData() event = eventmanager.send_event(ChainEventType.RecommendSource, event_data) # 使用事件返回的上下文数据 if event and event.event_data: event_data: RecommendSourceEventData = event.event_data if event_data.extra_sources: self.__inner_sources.extend([s.model_dump() for s in event_data.extra_sources]) @classmethod @property def name(cls) -> str: # noqa return "获取媒体数据" @classmethod @property def description(cls) -> str: # noqa return "获取榜单等媒体数据列表" @classmethod @property def data(cls) -> dict: # noqa return FetchMediasParams().model_dump() @property def success(self) -> bool: return not self._has_error def __get_source(self, source: str): """ 获取数据源 """ for s in self.__inner_sources: if s['api_path'] == source: return s return None def execute(self, workflow_id: int, params: dict, context: ActionContext) -> ActionContext: """ 获取媒体数据,填充到medias """ params = FetchMediasParams(**params) try: if params.source_type == "ranking": for api_path in params.sources: if global_vars.is_workflow_stopped(workflow_id): break source = self.__get_source(api_path) if not source: continue logger.info(f"获取媒体数据 {source} ...") name = source.get("name") results = [] if source.get("func"): results = source['func']() else: # 调用内部API获取数据 api_url = f"http://127.0.0.1:{settings.PORT}/api/v1/{source['api_path']}?token={settings.API_TOKEN}" res = RequestUtils(timeout=15).post_res(api_url) if res: results = res.json() if results: logger.info(f"{name} 获取到 {len(results)} 条数据") self._medias.extend([MediaInfo(**r) for r in results]) else: logger.error(f"{name} 获取数据失败") else: # 调用内部API获取数据 api_url = f"http://127.0.0.1:{settings.PORT}{params.api_path}?token={settings.API_TOKEN}" res = RequestUtils(timeout=15).post_res(api_url) if res: results = res.json() if results: logger.info(f"{params.api_path} 获取到 {len(results)} 条数据") self._medias.extend([MediaInfo(**r) for r in results]) except Exception as e: logger.error(f"获取媒体数据失败: {e}") self._has_error = True if self._medias: context.medias.extend(self._medias) self.job_done(f"获取到 {len(self._medias)} 条媒数据") return context ================================================ FILE: app/workflow/actions/fetch_rss.py ================================================ from typing import Optional from pydantic import Field from app.workflow.actions import BaseAction, ActionChain from app.core.config import settings, global_vars from app.core.context import Context from app.core.metainfo import MetaInfo from app.helper.rss import RssHelper from app.log import logger from app.schemas import ActionParams, ActionContext, TorrentInfo class FetchRssParams(ActionParams): """ 获取RSS资源列表参数 """ url: str = Field(default=None, description="RSS地址") proxy: Optional[bool] = Field(default=False, description="是否使用代理") timeout: Optional[int] = Field(default=15, description="超时时间") content_type: Optional[str] = Field(default=None, description="Content-Type") referer: Optional[str] = Field(default=None, description="Referer") ua: Optional[str] = Field(default=None, description="User-Agent") match_media: Optional[bool] = Field(default=False, description="匹配媒体信息") class FetchRssAction(BaseAction): """ 获取RSS资源列表 """ def __init__(self, action_id: str): super().__init__(action_id) self._rss_torrents = [] self._has_error = False @classmethod @property def name(cls) -> str: # noqa return "获取RSS资源" @classmethod @property def description(cls) -> str: # noqa return "订阅RSS地址获取资源" @classmethod @property def data(cls) -> dict: # noqa return FetchRssParams().model_dump() @property def success(self) -> bool: return not self._has_error def execute(self, workflow_id: int, params: dict, context: ActionContext) -> ActionContext: """ 请求RSS地址获取数据,并解析为资源列表 """ params = FetchRssParams(**params) if not params.url: return context headers = {} if params.content_type: headers["Content-Type"] = params.content_type if params.referer: headers["Referer"] = params.referer if params.ua: headers["User-Agent"] = params.ua rss_items = RssHelper().parse(url=params.url, proxy=settings.PROXY if params.proxy else None, timeout=params.timeout, headers=headers) if rss_items is None or rss_items is False: logger.error(f'RSS地址 {params.url} 请求失败!') self._has_error = True return context if not rss_items: logger.error(f'RSS地址 {params.url} 未获取到RSS数据!') return context # 组装种子 for item in rss_items: if global_vars.is_workflow_stopped(workflow_id): break if not item.get("title"): continue torrentinfo = TorrentInfo( title=item.get("title"), enclosure=item.get("enclosure"), page_url=item.get("link"), size=item.get("size"), pubdate=item["pubdate"].strftime("%Y-%m-%d %H:%M:%S") if item.get("pubdate") else None, ) meta = MetaInfo(title=torrentinfo.title, subtitle=torrentinfo.description) mediainfo = None if params.match_media: mediainfo = ActionChain().recognize_media(meta) if not mediainfo: logger.warning(f"{torrentinfo.title} 未识别到媒体信息") continue self._rss_torrents.append(Context(meta_info=meta, media_info=mediainfo, torrent_info=torrentinfo)) if self._rss_torrents: logger.info(f"获取到 {len(self._rss_torrents)} 个RSS资源") context.torrents.extend(self._rss_torrents) self.job_done(f"获取到 {len(self._rss_torrents)} 个资源") return context ================================================ FILE: app/workflow/actions/fetch_torrents.py ================================================ import random import time from typing import Optional, List from pydantic import Field from app.workflow.actions import BaseAction from app.chain.search import SearchChain from app.core.config import global_vars from app.log import logger from app.schemas import ActionParams, ActionContext, MediaType class FetchTorrentsParams(ActionParams): """ 获取站点资源参数 """ search_type: Optional[str] = Field(default="keyword", description="搜索类型") name: Optional[str] = Field(default=None, description="资源名称") year: Optional[str] = Field(default=None, description="年份") type: Optional[str] = Field(default=None, description="资源类型 (电影/电视剧)") season: Optional[int] = Field(default=None, description="季度") sites: Optional[List[int]] = Field(default=[], description="站点列表") match_media: Optional[bool] = Field(default=False, description="匹配媒体信息") class FetchTorrentsAction(BaseAction): """ 搜索站点资源 """ def __init__(self, action_id: str): super().__init__(action_id) self._torrents = [] @classmethod @property def name(cls) -> str: # noqa return "搜索站点资源" @classmethod @property def description(cls) -> str: # noqa return "搜索站点种子资源列表" @classmethod @property def data(cls) -> dict: # noqa return FetchTorrentsParams().model_dump() @property def success(self) -> bool: return self.done def execute(self, workflow_id: int, params: dict, context: ActionContext) -> ActionContext: """ 搜索站点,获取资源列表 """ params = FetchTorrentsParams(**params) searchchain = SearchChain() if params.search_type == "keyword": # 按关键字搜索 torrents = searchchain.search_by_title(title=params.name, sites=params.sites) for torrent in torrents: if global_vars.is_workflow_stopped(workflow_id): break if params.year and torrent.meta_info.year != params.year: continue if params.type and torrent.media_info and torrent.media_info.type != MediaType(params.type): continue if params.season and torrent.meta_info.begin_season != params.season: continue # 识别媒体信息 if params.match_media: torrent.media_info = searchchain.recognize_media(torrent.meta_info) if not torrent.media_info: logger.warning(f"{torrent.torrent_info.title} 未识别到媒体信息") continue self._torrents.append(torrent) else: # 搜索媒体列表 for media in context.medias: if global_vars.is_workflow_stopped(workflow_id): break torrents = searchchain.search_by_id(tmdbid=media.tmdb_id, doubanid=media.douban_id, mtype=MediaType(media.type), sites=params.sites) for torrent in torrents: self._torrents.append(torrent) # 随机休眠 5-30秒 sleep_time = random.randint(5, 30) logger.info(f"随机休眠 {sleep_time} 秒 ...") time.sleep(sleep_time) if self._torrents: context.torrents.extend(self._torrents) logger.info(f"共搜索到 {len(self._torrents)} 条资源") self.job_done(f"搜索到 {len(self._torrents)} 个资源") return context ================================================ FILE: app/workflow/actions/filter_medias.py ================================================ from typing import Optional from pydantic import Field from app.workflow.actions import BaseAction from app.core.config import global_vars from app.log import logger from app.schemas import ActionParams, ActionContext class FilterMediasParams(ActionParams): """ 过滤媒体数据参数 """ type: Optional[str] = Field(default=None, description="媒体类型 (电影/电视剧)") vote: Optional[float] = Field(default=None, description="评分(支持小数)") year: Optional[str] = Field(default=None, description="年份") class FilterMediasAction(BaseAction): """ 过滤媒体数据 """ def __init__(self, action_id: str): super().__init__(action_id) self._medias = [] @classmethod @property def name(cls) -> str: # noqa return "过滤媒体数据" @classmethod @property def description(cls) -> str: # noqa return "对媒体数据列表进行过滤" @classmethod @property def data(cls) -> dict: # noqa return FilterMediasParams().model_dump() @property def success(self) -> bool: return self.done def execute(self, workflow_id: int, params: dict, context: ActionContext) -> ActionContext: """ 过滤medias中媒体数据 """ params = FilterMediasParams(**params) for media in context.medias: if global_vars.is_workflow_stopped(workflow_id): break if params.type and media.type != params.type: continue if params.vote is not None and media.vote_average < params.vote: continue if params.year and media.year != params.year: continue self._medias.append(media) logger.info(f"过滤后剩余 {len(self._medias)} 条媒体数据") context.medias = self._medias self.job_done(f"过滤后剩余 {len(self._medias)} 条媒体数据") return context ================================================ FILE: app/workflow/actions/filter_torrents.py ================================================ from typing import Optional, List from pydantic import Field from app.workflow.actions import BaseAction, ActionChain from app.core.config import global_vars from app.helper.torrent import TorrentHelper from app.log import logger from app.schemas import ActionParams, ActionContext class FilterTorrentsParams(ActionParams): """ 过滤资源数据参数 """ rule_groups: Optional[List[str]] = Field(default=[], description="规则组") quality: Optional[str] = Field(default=None, description="资源质量") resolution: Optional[str] = Field(default=None, description="资源分辨率") effect: Optional[str] = Field(default=None, description="特效") include: Optional[str] = Field(default=None, description="包含规则") exclude: Optional[str] = Field(default=None, description="排除规则") size: Optional[str] = Field(default=None, description="资源大小范围(MB)") class FilterTorrentsAction(BaseAction): """ 过滤资源数据 """ def __init__(self, action_id: str): super().__init__(action_id) self._torrents = [] @classmethod @property def name(cls) -> str: # noqa return "过滤资源" @classmethod @property def description(cls) -> str: # noqa return "对资源列表数据进行过滤" @classmethod @property def data(cls) -> dict: # noqa return FilterTorrentsParams().model_dump() @property def success(self) -> bool: return self.done def execute(self, workflow_id: int, params: dict, context: ActionContext) -> ActionContext: """ 过滤torrents中的资源 """ params = FilterTorrentsParams(**params) for torrent in context.torrents: if global_vars.is_workflow_stopped(workflow_id): break if TorrentHelper().filter_torrent( torrent_info=torrent.torrent_info, filter_params={ "quality": params.quality, "resolution": params.resolution, "effect": params.effect, "include": params.include, "exclude": params.exclude, "size": params.size } ): if ActionChain().filter_torrents( rule_groups=params.rule_groups, torrent_list=[torrent.torrent_info], mediainfo=torrent.media_info ): self._torrents.append(torrent) logger.info(f"过滤后剩余 {len(self._torrents)} 个资源") context.torrents = self._torrents self.job_done(f"过滤后剩余 {len(self._torrents)} 个资源") return context ================================================ FILE: app/workflow/actions/invoke_plugin.py ================================================ from pydantic import Field from app.workflow.actions import BaseAction from app.core.plugin import PluginManager from app.log import logger from app.schemas import ActionParams, ActionContext class InvokePluginParams(ActionParams): """ 调用插件动作参数 """ plugin_id: str = Field(default=None, description="插件ID") action_id: str = Field(default=None, description="动作ID") action_params: dict = Field(default={}, description="动作参数") class InvokePluginAction(BaseAction): """ 调用插件 """ def __init__(self, action_id: str): super().__init__(action_id) self._success = False @classmethod @property def name(cls) -> str: # noqa return "调用插件" @classmethod @property def description(cls) -> str: # noqa return "调用插件提供的动作" @classmethod @property def data(cls) -> dict: # noqa return InvokePluginParams().model_dump() @property def success(self) -> bool: return self._success def execute(self, workflow_id: int, params: dict, context: ActionContext) -> ActionContext: """ 执行插件定义的动作 """ params = InvokePluginParams(**params) if not params.plugin_id or not params.action_id: return context try: plugin_actions = PluginManager().get_plugin_actions(params.plugin_id) if not plugin_actions: logger.error(f"插件不存在: {params.plugin_id}") return context actions = plugin_actions[0].get("actions", []) action = next((action for action in actions if action.get("action_id") == params.action_id), None) if not action or not action.get("func"): logger.error(f"插件动作不存在: {params.plugin_id} - {params.action_id}") return context # 执行插件动作 self._success, context = action["func"](context, **params.action_params) except Exception as e: self._success = False logger.error(f"调用插件动作失败: {e}") return context self.job_done() return context ================================================ FILE: app/workflow/actions/note.py ================================================ from app.workflow.actions import BaseAction from app.schemas import ActionContext class NoteAction(BaseAction): """ 备注 """ @classmethod @property def name(cls) -> str: # noqa return "备注" @classmethod @property def description(cls) -> str: # noqa return "给工作流添加备注" @classmethod @property def data(cls) -> dict: # noqa return {} @property def success(self) -> bool: return True def execute(self, workflow_id: int, params: dict, context: ActionContext) -> ActionContext: return context ================================================ FILE: app/workflow/actions/scan_file.py ================================================ from pathlib import Path from typing import Optional from pydantic import Field from app.workflow.actions import BaseAction from app.chain.storage import StorageChain from app.core.config import global_vars, settings from app.log import logger from app.schemas import ActionParams, ActionContext class ScanFileParams(ActionParams): """ 整理文件参数 """ # 存储 storage: Optional[str] = Field(default="local", description="存储") directory: Optional[str] = Field(default=None, description="目录") class ScanFileAction(BaseAction): """ 整理文件 """ def __init__(self, action_id: str): super().__init__(action_id) self._fileitems = [] self._has_error = False @classmethod @property def name(cls) -> str: # noqa return "扫描目录" @classmethod @property def description(cls) -> str: # noqa return "扫描目录文件到队列" @classmethod @property def data(cls) -> dict: # noqa return ScanFileParams().model_dump() @property def success(self) -> bool: return not self._has_error def execute(self, workflow_id: int, params: dict, context: ActionContext) -> ActionContext: """ 扫描目录中的所有文件,记录到fileitems """ params = ScanFileParams(**params) if not params.storage or not params.directory: return context storagechain = StorageChain() fileitem = storagechain.get_file_item(params.storage, Path(params.directory)) if not fileitem: logger.error(f"目录不存在: 【{params.storage}】{params.directory}") self._has_error = True return context files = storagechain.list_files(fileitem, recursion=True) for file in files: if global_vars.is_workflow_stopped(workflow_id): break media_exts = settings.RMT_MEDIAEXT + settings.RMT_SUBEXT + settings.RMT_AUDIOEXT if not file.extension or f".{file.extension.lower()}" not in media_exts: continue # 添加文件到队列,而不是目录 self._fileitems.append(file) if self._fileitems: context.fileitems.extend(self._fileitems) self.job_done(f"扫描到 {len(self._fileitems)} 个文件") return context ================================================ FILE: app/workflow/actions/scrape_file.py ================================================ from pathlib import Path from app.workflow.actions import BaseAction from app.core.config import global_vars from app.schemas import ActionParams, ActionContext from app.chain.media import MediaChain from app.chain.storage import StorageChain from app.core.metainfo import MetaInfoPath from app.log import logger class ScrapeFileParams(ActionParams): """ 刮削文件参数 """ pass class ScrapeFileAction(BaseAction): """ 刮削文件 """ def __init__(self, action_id: str): super().__init__(action_id) self._scraped_files = [] self._has_error = False @classmethod @property def name(cls) -> str: # noqa return "刮削文件" @classmethod @property def description(cls) -> str: # noqa return "刮削媒体信息和图片" @classmethod @property def data(cls) -> dict: # noqa return ScrapeFileParams().model_dump() @property def success(self) -> bool: return not self._has_error def execute(self, workflow_id: int, params: dict, context: ActionContext) -> ActionContext: """ 刮削fileitems中的所有文件 """ # 失败次数 _failed_count = 0 for fileitem in context.fileitems: if global_vars.is_workflow_stopped(workflow_id): break if fileitem in self._scraped_files: continue if not StorageChain().exists(fileitem): continue # 检查缓存 cache_key = f"{fileitem.path}" if self.check_cache(workflow_id, cache_key): logger.info(f"{fileitem.path} 已刮削过,跳过") continue meta = MetaInfoPath(Path(fileitem.path)) mediachain = MediaChain() mediainfo = mediachain.recognize_media(meta) if not mediainfo: _failed_count += 1 logger.info(f"{fileitem.path} 未识别到媒体信息,无法刮削") continue mediachain.scrape_metadata(fileitem=fileitem, meta=meta, mediainfo=mediainfo) self._scraped_files.append(fileitem) # 保存缓存 self.save_cache(workflow_id, cache_key) if not self._scraped_files and _failed_count: self._has_error = True self.job_done(f"成功刮削 {len(self._scraped_files)} 个文件,失败 {_failed_count} 个") return context ================================================ FILE: app/workflow/actions/send_event.py ================================================ from app.workflow.actions import BaseAction from app.core.event import eventmanager from app.schemas import ActionParams, ActionContext from app.schemas.types import ChainEventType class SendEventParams(ActionParams): """ 发送事件参数 """ pass class SendEventAction(BaseAction): """ 发送事件 """ @classmethod @property def name(cls) -> str: # noqa return "发送事件" @classmethod @property def description(cls) -> str: # noqa return "发送任务执行事件" @classmethod @property def data(cls) -> dict: # noqa return SendEventParams().model_dump() @property def success(self) -> bool: return self.done def execute(self, workflow_id: int, params: dict, context: ActionContext) -> ActionContext: """ 发送工作流事件,以更插件干预工作流执行 """ # 触发资源下载事件,更新执行上下文 event = eventmanager.send_event(ChainEventType.WorkflowExecution, context) if event and event.event_data: context = event.event_data self.job_done() return context ================================================ FILE: app/workflow/actions/send_message.py ================================================ from typing import List, Optional, Union from pydantic import Field from app.workflow.actions import BaseAction, ActionChain from app.schemas import ActionParams, ActionContext, Notification from app.core.config import settings class SendMessageParams(ActionParams): """ 发送消息参数 """ client: Optional[List[str]] = Field(default=[], description="消息渠道") userid: Optional[Union[str, int]] = Field(default=None, description="用户ID") class SendMessageAction(BaseAction): """ 发送消息 """ def __init__(self, action_id: str): super().__init__(action_id) @classmethod @property def name(cls) -> str: # noqa return "发送消息" @classmethod @property def description(cls) -> str: # noqa return "发送任务执行消息" @classmethod @property def data(cls) -> dict: # noqa return SendMessageParams().model_dump() @property def success(self) -> bool: return self.done def execute(self, workflow_id: int, params: dict, context: ActionContext) -> ActionContext: """ 发送messages中的消息 """ params = SendMessageParams(**params) msg_text = f"当前进度:{context.progress}%" index = 1 if context.execute_history: for history in context.execute_history: if not history.message: continue msg_text += f"\n{index}. {history.action}:{history.message}" index += 1 # 发送消息 if not params.client: params.client = [""] for client in params.client: ActionChain().post_message( Notification( source=client, userid=params.userid, title="【工作流执行结果】", text=msg_text, link=settings.MP_DOMAIN("#/workflow") ) ) self.job_done() return context ================================================ FILE: app/workflow/actions/transfer_file.py ================================================ import copy from pathlib import Path from typing import Optional from pydantic import Field from app.workflow.actions import BaseAction from app.core.config import global_vars from app.db.transferhistory_oper import TransferHistoryOper from app.schemas import ActionParams, ActionContext from app.chain.storage import StorageChain from app.chain.transfer import TransferChain from app.log import logger class TransferFileParams(ActionParams): """ 整理文件参数 """ # 来源 source: Optional[str] = Field(default="downloads", description="来源") class TransferFileAction(BaseAction): """ 整理文件 """ def __init__(self, action_id: str): super().__init__(action_id) self._fileitems = [] self._has_error = False @classmethod @property def name(cls) -> str: # noqa return "整理文件" @classmethod @property def description(cls) -> str: # noqa return "整理队列中的文件" @classmethod @property def data(cls) -> dict: # noqa return TransferFileParams().model_dump() @property def success(self) -> bool: return not self._has_error def execute(self, workflow_id: int, params: dict, context: ActionContext) -> ActionContext: """ 从 downloads / fileitems 中整理文件,记录到fileitems """ def check_continue(): """ 检查是否继续整理文件 """ if global_vars.is_workflow_stopped(workflow_id): return False return True params = TransferFileParams(**params) # 失败次数 _failed_count = 0 storagechain = StorageChain() transferchain = TransferChain() transferhis = TransferHistoryOper() if params.source == "downloads": # 从下载任务中整理文件 for download in context.downloads: if global_vars.is_workflow_stopped(workflow_id): break if not download.completed: logger.info(f"下载任务 {download.download_id} 未完成") continue # 检查缓存 cache_key = f"{download.download_id}" if self.check_cache(workflow_id, cache_key): logger.info(f"{download.path} 已整理过,跳过") continue fileitem = storagechain.get_file_item(storage="local", path=Path(download.path)) if not fileitem: logger.info(f"文件 {download.path} 不存在") continue transferd = transferhis.get_by_src(fileitem.path, storage=fileitem.storage) if transferd: # 已经整理过的文件不再整理 continue logger.info(f"开始整理文件 {download.path} ...") state, errmsg = transferchain.do_transfer(fileitem, background=False) if not state: _failed_count += 1 logger.error(f"整理文件 {download.path} 失败: {errmsg}") continue logger.info(f"整理文件 {download.path} 完成") self._fileitems.append(fileitem) self.save_cache(workflow_id, cache_key) else: # 从 fileitems 中整理文件 for fileitem in copy.deepcopy(context.fileitems): if not check_continue(): break # 检查缓存 cache_key = f"{fileitem.path}" if self.check_cache(workflow_id, cache_key): logger.info(f"{fileitem.path} 已整理过,跳过") continue transferd = transferhis.get_by_src(fileitem.path, storage=fileitem.storage) if transferd: # 已经整理过的文件不再整理 continue logger.info(f"开始整理文件 {fileitem.path} ...") state, errmsg = transferchain.do_transfer(fileitem, background=False, continue_callback=check_continue) if not state: _failed_count += 1 logger.error(f"整理文件 {fileitem.path} 失败: {errmsg}") continue logger.info(f"整理文件 {fileitem.path} 完成") # 从 fileitems 中移除已整理的文件 context.fileitems.remove(fileitem) self._fileitems.append(fileitem) # 记录已整理的文件 self.save_cache(workflow_id, cache_key) if self._fileitems: context.fileitems.extend(self._fileitems) elif _failed_count: self._has_error = True self.job_done(f"整理成功 {len(self._fileitems)} 个文件,失败 {_failed_count} 个") return context ================================================ FILE: config/category.yaml ================================================ ####### 配置说明 ####### # 1. 该配置文件用于配置电影和电视剧的分类策略,配置后程序会按照配置的分类策略名称进行分类,配置文件采用yaml格式,需要严格附合语法规则 # 2. 配置文件中的一级分类名称:`movie`、`tv` 为固定名称不可修改,二级名称同时也是目录名称,会按先后顺序匹配,匹配后程序会按这个名称建立二级目录 # 3. 支持的分类条件: # `original_language` 语种,具体含义参考下方字典 # `production_countries` 国家或地区(电影)、`origin_country` 国家或地区(电视剧),具体含义参考下方字典 # `genre_ids` 内容类型,具体含义参考下方字典 # `release_year` 发行年份,格式:YYYY,电影实际对应`release_date`字段,电视剧实际对应`first_air_date`字段,支持范围设定,如:`YYYY-YYYY` # themoviedb 详情API返回的其它一级字段 # 4. 配置多项条件时需要同时满足,一个条件需要匹配多个值是使用`,`分隔 # 5. !条件值表示排除该值 # 配置电影的分类策略 movie: # 分类名同时也是目录名 动画电影: # 匹配 genre_ids 内容类型,16是动漫 genre_ids: '16' 华语电影: # 匹配语种 original_language: 'zh,cn,bo,za' # 未匹配以上条件时,分类为外语电影 外语电影: # 配置电视剧的分类策略 tv: # 分类名同时也是目录名 国漫: # 匹配 genre_ids 内容类型,16是动漫 genre_ids: '16' # 匹配 origin_country 国家,CN是中国大陆,TW是中国台湾,HK是中国香港 origin_country: 'CN,TW,HK' 日番: # 匹配 genre_ids 内容类型,16是动漫 genre_ids: '16' # 匹配 origin_country 国家,JP是日本 origin_country: 'JP' 纪录片: # 匹配 genre_ids 内容类型,99是纪录片 genre_ids: '99' 儿童: # 匹配 genre_ids 内容类型,10762是儿童 genre_ids: '10762' 综艺: # 匹配 genre_ids 内容类型,10764 10767都是综艺 genre_ids: '10764,10767' 国产剧: # 匹配 origin_country 国家,CN是中国大陆,TW是中国台湾,HK是中国香港 origin_country: 'CN,TW,HK' 欧美剧: # 匹配 origin_country 国家,主要欧美国家列表 origin_country: 'US,FR,GB,DE,ES,IT,NL,PT,RU,UK' 日韩剧: # 匹配 origin_country 国家,主要亚洲国家列表 origin_country: 'JP,KP,KR,TH,IN,SG' # 未匹配以上分类,则命名为未分类 未分类: ## genre_ids 内容类型 字典,注意部分中英文是不一样的 # 28 Action # 12 Adventure # 16 Animation # 35 Comedy # 80 Crime # 99 Documentary # 18 Drama # 10751 Family # 14 Fantasy # 36 History # 27 Horror # 10402 Music # 9648 Mystery # 10749 Romance # 878 Science Fiction # 10770 TV Movie # 53 Thriller # 10752 War # 37 Western # 28 动作 # 12 冒险 # 16 动画 # 35 喜剧 # 80 犯罪 # 99 纪录 # 18 剧情 # 10751 家庭 # 14 奇幻 # 36 历史 # 27 恐怖 # 10402 音乐 # 9648 悬疑 # 10749 爱情 # 878 科幻 # 10770 电视电影 # 53 惊悚 # 10752 战争 # 37 西部 ## original_language 语种 字典 # af 南非语 # ar 阿拉伯语 # az 阿塞拜疆语 # be 比利时语 # bg 保加利亚语 # ca 加泰隆语 # cs 捷克语 # cy 威尔士语 # da 丹麦语 # de 德语 # dv 第维埃语 # el 希腊语 # en 英语 # eo 世界语 # es 西班牙语 # et 爱沙尼亚语 # eu 巴士克语 # fa 法斯语 # fi 芬兰语 # fo 法罗语 # fr 法语 # gl 加里西亚语 # gu 古吉拉特语 # he 希伯来语 # hi 印地语 # hr 克罗地亚语 # hu 匈牙利语 # hy 亚美尼亚语 # id 印度尼西亚语 # is 冰岛语 # it 意大利语 # ja 日语 # ka 格鲁吉亚语 # kk 哈萨克语 # kn 卡纳拉语 # ko 朝鲜语 # kok 孔卡尼语 # ky 吉尔吉斯语 # lt 立陶宛语 # lv 拉脱维亚语 # mi 毛利语 # mk 马其顿语 # mn 蒙古语 # mr 马拉地语 # ms 马来语 # mt 马耳他语 # nb 挪威语(伯克梅尔) # nl 荷兰语 # ns 北梭托语 # pa 旁遮普语 # pl 波兰语 # pt 葡萄牙语 # qu 克丘亚语 # ro 罗马尼亚语 # ru 俄语 # sa 梵文 # se 北萨摩斯语 # sk 斯洛伐克语 # sl 斯洛文尼亚语 # sq 阿尔巴尼亚语 # sv 瑞典语 # sw 斯瓦希里语 # syr 叙利亚语 # ta 泰米尔语 # te 泰卢固语 # th 泰语 # tl 塔加路语 # tn 茨瓦纳语 # tr 土耳其语 # ts 宗加语 # tt 鞑靼语 # uk 乌克兰语 # ur 乌都语 # uz 乌兹别克语 # vi 越南语 # xh 班图语 # zh 中文 # cn 中文 # zu 祖鲁语 ## origin_country/production_countries 国家地区 字典 # AR 阿根廷 # AU 澳大利亚 # BE 比利时 # BR 巴西 # CA 加拿大 # CH 瑞士 # CL 智利 # CO 哥伦比亚 # CZ 捷克 # DE 德国 # DK 丹麦 # EG 埃及 # ES 西班牙 # FR 法国 # GR 希腊 # HK 香港 # IL 以色列 # IN 印度 # IQ 伊拉克 # IR 伊朗 # IT 意大利 # JP 日本 # MM 缅甸 # MO 澳门 # MX 墨西哥 # MY 马来西亚 # NL 荷兰 # NO 挪威 # PH 菲律宾 # PK 巴基斯坦 # PL 波兰 # RU 俄罗斯 # SE 瑞典 # SG 新加坡 # TH 泰国 # TR 土耳其 # US 美国 # VN 越南 # CN 中国 内地 # GB 英国 # TW 中国台湾 # NZ 新西兰 # SA 沙特阿拉伯 # LA 老挝 # KP 朝鲜 北朝鲜 # KR 韩国 南朝鲜 # PT 葡萄牙 # MN 蒙古国 蒙古 ================================================ FILE: database/env.py ================================================ from logging.config import fileConfig from sqlalchemy import engine_from_config from sqlalchemy import pool from alembic import context from app.db import Base # this is the Alembic Config object, which provides # access to the values within the .ini file in use. config = context.config # Interpret the config file for Python logging. # This line sets up loggers basically. if config.config_file_name is not None: fileConfig(config.config_file_name) # add your model's MetaData object here # for 'autogenerate' support # from myapp import mymodel # target_metadata = mymodel.Base.metadata target_metadata = Base.metadata # other values from the config, defined by the needs of env.py, # can be acquired: # my_important_option = config.get_main_option("my_important_option") # ... etc. def run_migrations_offline() -> None: """Run migrations in 'offline' mode. This configures the context with just a URL and not an Engine, though an Engine is acceptable here as well. By skipping the Engine creation we don't even need a DBAPI to be available. Calls to context.execute() here emit the given string to the script output. """ url = config.get_main_option("sqlalchemy.url") # 根据数据库类型配置不同的参数 if url and "postgresql" in url: # PostgreSQL配置 context.configure( url=url, target_metadata=target_metadata, literal_binds=True, dialect_opts={"paramstyle": "named"}, ) else: # SQLite配置 context.configure( url=url, target_metadata=target_metadata, literal_binds=True, dialect_opts={"paramstyle": "named"}, render_as_batch=True ) with context.begin_transaction(): context.run_migrations() def run_migrations_online() -> None: """Run migrations in 'online' mode. In this scenario we need to create an Engine and associate a connection with the context. """ connectable = engine_from_config( config.get_section(config.config_ini_section), prefix="sqlalchemy.", poolclass=pool.NullPool, ) with connectable.connect() as connection: url = config.get_main_option("sqlalchemy.url") # 根据数据库类型配置不同的参数 if url and "postgresql" in url: # PostgreSQL配置 context.configure( connection=connection, target_metadata=target_metadata ) else: # SQLite配置 context.configure( connection=connection, target_metadata=target_metadata, render_as_batch=True ) with context.begin_transaction(): context.run_migrations() if context.is_offline_mode(): run_migrations_offline() else: run_migrations_online() ================================================ FILE: database/gen.py ================================================ import importlib from pathlib import Path from alembic.config import Config as AlembicConfig from alembic.command import revision as alembic_revision from app.core.config import settings # 导入模块,避免建表缺失 for module in Path(__file__).with_name("models").glob("*.py"): importlib.import_module(f"app.db.models.{module.stem}") db_version = input("请输入版本号:") db_location = settings.CONFIG_PATH / 'user.db' script_location = settings.ROOT_PATH / 'database' alembic_cfg = AlembicConfig() alembic_cfg.set_main_option('script_location', str(script_location)) alembic_cfg.set_main_option('sqlalchemy.url', f"sqlite:///{db_location}") alembic_revision(alembic_cfg, db_version, True) ================================================ FILE: database/script.py.mako ================================================ """${message} Revision ID: ${up_revision} Revises: ${down_revision | comma,n} Create Date: ${create_date} """ from alembic import op import sqlalchemy as sa ${imports if imports else ""} # revision identifiers, used by Alembic. revision = ${repr(up_revision)} down_revision = ${repr(down_revision)} branch_labels = ${repr(branch_labels)} depends_on = ${repr(depends_on)} def upgrade() -> None: ${upgrades if upgrades else "pass"} def downgrade() -> None: ${downgrades if downgrades else "pass"} ================================================ FILE: database/versions/0fb94bf69b38_2_0_2.py ================================================ """2.0.2 Revision ID: 0fb94bf69b38 Revises: 262735d025da Create Date: 2024-09-30 10:03:58.546036 """ import contextlib from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = '0fb94bf69b38' down_revision = '262735d025da' branch_labels = None depends_on = None def upgrade() -> None: # ### commands auto generated by Alembic - please adjust! ### # 站点数据统计增加站点名称 conn = op.get_bind() inspector = sa.inspect(conn) columns = inspector.get_columns('siteuserdata') # 检查 'name' 字段是否已存在 if not any(c['name'] == 'name' for c in columns): op.add_column('siteuserdata', sa.Column('name', sa.String(), nullable=True)) # ### end Alembic commands ### def downgrade() -> None: pass ================================================ FILE: database/versions/262735d025da_2_0_1.py ================================================ """2.0.1 Revision ID: 262735d025da Revises: 294b007932ef Create Date: 2024-09-11 08:07:02.753307 """ from app.db.systemconfig_oper import SystemConfigOper from app.schemas.types import SystemConfigKey # revision identifiers, used by Alembic. revision = '262735d025da' down_revision = '294b007932ef' branch_labels = None depends_on = None def upgrade() -> None: # ### commands auto generated by Alembic - please adjust! ### # 初始化消息通知范围 _systemconfig = SystemConfigOper() if not _systemconfig.get(SystemConfigKey.NotificationSwitchs): _systemconfig.set(SystemConfigKey.NotificationSwitchs, [ { 'type': '资源下载', 'action': 'all', }, { 'type': '整理入库', 'action': 'all', }, { 'type': '订阅', 'action': 'all', }, { 'type': '站点', 'action': 'admin', }, { 'type': '媒体服务器', 'action': 'admin', }, { 'type': '手动处理', 'action': 'admin', }, { 'type': '插件', 'action': 'admin', }, { 'type': '其它', 'action': 'admin', }, ]) # ### end Alembic commands ### def downgrade() -> None: pass ================================================ FILE: database/versions/279a949d81b6_2_1_1.py ================================================ """2.1.1 Revision ID: 279a949d81b6 Revises: ca5461f314f2 Create Date: 2025-02-14 19:02:24.989349 """ from app.chain.torrents import TorrentsChain # revision identifiers, used by Alembic. revision = '279a949d81b6' down_revision = 'ca5461f314f2' branch_labels = None depends_on = None def upgrade() -> None: # 清理一次缓存 TorrentsChain().clear_torrents() def downgrade() -> None: pass ================================================ FILE: database/versions/294b007932ef_2_0_0.py ================================================ """2.0.0 Revision ID: 294b007932ef Revises: Create Date: 2024-07-20 08:43:40.741251 """ import secrets from app.core.config import settings from app.core.security import get_password_hash from app.db import SessionFactory from app.db.models import * from app.db.systemconfig_oper import SystemConfigOper from app.log import logger from app.schemas.types import SystemConfigKey # revision identifiers, used by Alembic. revision = '294b007932ef' down_revision = None branch_labels = None depends_on = None def upgrade() -> None: """ v2.0.0 数据库初始化 """ with SessionFactory() as db: # 初始化超级管理员 _user = User.get_by_name(db=db, name=settings.SUPERUSER) if not _user: if settings.SUPERUSER_PASSWORD: init_password = settings.SUPERUSER_PASSWORD else: # 生成随机密码 init_password = secrets.token_urlsafe(16) logger.info( f"【超级管理员初始密码】{init_password} 请登录系统后在设定中修改。 注:该密码只会显示一次,请注意保存。") _user = User( name=settings.SUPERUSER, hashed_password=get_password_hash(init_password), email="admin@movie-pilot.org", is_superuser=True, avatar="" ) _user.create(db) # 初始化本地存储 _systemconfig = SystemConfigOper() if not _systemconfig.get(SystemConfigKey.Storages): _systemconfig.set(SystemConfigKey.Storages, [ { "type": "local", "name": "本地", "config": {} }, { "type": "alipan", "name": "阿里云盘", "config": {} }, { "type": "u115", "name": "115网盘", "config": {} }, { "type": "rclone", "name": "RClone", "config": {} } ]) def downgrade() -> None: pass ================================================ FILE: database/versions/3891a5e722a1_2_1_7.py ================================================ """2.1.7 Revision ID: 3891a5e722a1 Revises: 3df653756eec Create Date: 2025-06-28 08:40:14.516836 """ from alembic import op import sqlalchemy as sa from sqlalchemy.dialects import sqlite from app.db.systemconfig_oper import SystemConfigOper from app.schemas.types import SystemConfigKey # revision identifiers, used by Alembic. revision = '3891a5e722a1' down_revision = '3df653756eec' branch_labels = None depends_on = None def upgrade() -> None: # ### commands auto generated by Alembic - please adjust! ### # rename AList存储 _systemconfig = SystemConfigOper() _storages = _systemconfig.get(SystemConfigKey.Storages) if _storages: for storage in _storages: if storage["type"] == "alist": storage["name"] = "OpenList" break _systemconfig.set(SystemConfigKey.Storages, _storages) # ### end Alembic commands ### def downgrade() -> None: pass ================================================ FILE: database/versions/3df653756eec_2_1_6.py ================================================ """2.1.6 Revision ID: 3df653756eec Revises: 486e56a62dcb Create Date: 2025-06-11 19:52:57.185355 """ import json from app.db import SessionFactory from app.db.models import User # revision identifiers, used by Alembic. revision = '3df653756eec' down_revision = '486e56a62dcb' branch_labels = None depends_on = None def upgrade() -> None: # ### commands auto generated by Alembic - please adjust! ### with SessionFactory() as db: # 所有用户 users = User.list(db) for user in users: if user.is_superuser: continue if not user.permissions: permissions = { "discovery": True, "search": True, "subscribe": True, "manage": False, } user.update(db, { "permissions": permissions, }) # ### end Alembic commands ### def downgrade() -> None: pass ================================================ FILE: database/versions/41ef1dd7467c_2_2_2.py ================================================ """2.2.2 Revision ID: 41ef1dd7467c Revises: a946dae52526 Create Date: 2026-01-13 13:02:41.614029 """ from alembic import op from sqlalchemy import text from app.log import logger # revision identifiers, used by Alembic. revision = "41ef1dd7467c" down_revision = "a946dae52526" branch_labels = None depends_on = None def upgrade() -> None: # systemconfig表 去重 connection = op.get_bind() select_stmt = text( """ SELECT id, key, value FROM SystemConfig WHERE id NOT IN ( SELECT MAX(id) FROM SystemConfig GROUP BY key ) """ ) to_delete = connection.execute(select_stmt).fetchall() for row in to_delete: logger.warn( f"已删除重复的 SystemConfig 项:key={row.key}, value={row.value}, id={row.id}" ) delete_stmt = text("DELETE FROM SystemConfig WHERE id = :id") connection.execute(delete_stmt, {"id": row.id}) logger.info("SystemConfig 表去重操作已完成。") def downgrade() -> None: pass ================================================ FILE: database/versions/4666ce24a443_2_1_8.py ================================================ """2.1.8 Revision ID: 4666ce24a443 Revises: 3891a5e722a1 Create Date: 2025-07-22 13:54:04.196126 """ import contextlib import sqlalchemy as sa from alembic import op # revision identifiers, used by Alembic. revision = '4666ce24a443' down_revision = '3891a5e722a1' branch_labels = None depends_on = None def upgrade() -> None: conn = op.get_bind() inspector = sa.inspect(conn) columns = inspector.get_columns('workflow') if not any(c['name'] == 'trigger_type' for c in columns): op.add_column('workflow', sa.Column('trigger_type', sa.String(), nullable=True, default='timer')) if not any(c['name'] == 'event_type' for c in columns): op.add_column('workflow', sa.Column('event_type', sa.String(), nullable=True)) if not any(c['name'] == 'event_conditions' for c in columns): op.add_column('workflow', sa.Column('event_conditions', sa.JSON(), nullable=True, default={})) def downgrade() -> None: pass ================================================ FILE: database/versions/486e56a62dcb_2_1_5.py ================================================ """2.1.5 Revision ID: 486e56a62dcb Revises: 89d24811e894 Create Date: 2025-05-13 19:49:51.271319 """ import re from app.db.systemconfig_oper import SystemConfigOper from app.schemas.types import SystemConfigKey # revision identifiers, used by Alembic. revision = '486e56a62dcb' down_revision = '89d24811e894' branch_labels = None depends_on = None def upgrade() -> None: # ### commands auto generated by Alembic - please adjust! ### ### 将消息模板中的 `season`(为单数字, 且重命名需要这个字段)替换为 `season_fmt`(Sxx格式字符串) ### _systemconfig = SystemConfigOper() templates = _systemconfig.get(SystemConfigKey.NotificationTemplates) if isinstance(templates, dict): _re = r'(?<={{)(?![^}]*[%|])(\s*)season(\s*)(?=}})|(?<={%)if\s+(?![^%]*[%|])season\s*(?=%)' for k, v in templates.items(): # 替换season为season_fmt result = re.sub(_re, r'\1season_fmt\2', v) templates[k] = result # 将更新后的模板存回系统配置 _systemconfig.set(SystemConfigKey.NotificationTemplates, templates) # ### end Alembic commands ### def downgrade() -> None: pass ================================================ FILE: database/versions/4b544f5d3b07_2_1_3.py ================================================ """2.1.3 Revision ID: 4b544f5d3b07 Revises: 610bb05ddeef Create Date: 2025-04-03 11:21:42.780337 """ import contextlib from alembic import op import sqlalchemy as sa from sqlalchemy.dialects import sqlite # revision identifiers, used by Alembic. revision = '4b544f5d3b07' down_revision = '610bb05ddeef' branch_labels = None depends_on = None def upgrade() -> None: conn = op.get_bind() inspector = sa.inspect(conn) # 检查并添加 downloadhistory.episode_group dh_columns = inspector.get_columns('downloadhistory') if not any(c['name'] == 'episode_group' for c in dh_columns): op.add_column('downloadhistory', sa.Column('episode_group', sa.String, nullable=True)) # 检查并添加 subscribe.episode_group s_columns = inspector.get_columns('subscribe') if not any(c['name'] == 'episode_group' for c in s_columns): op.add_column('subscribe', sa.Column('episode_group', sa.String, nullable=True)) # 检查并添加 subscribehistory.episode_group sh_columns = inspector.get_columns('subscribehistory') if not any(c['name'] == 'episode_group' for c in sh_columns): op.add_column('subscribehistory', sa.Column('episode_group', sa.String, nullable=True)) # 检查并添加 transferhistory.episode_group th_columns = inspector.get_columns('transferhistory') if not any(c['name'] == 'episode_group' for c in th_columns): op.add_column('transferhistory', sa.Column('episode_group', sa.String, nullable=True)) def downgrade() -> None: pass ================================================ FILE: database/versions/55390f1f77c1_2_0_9.py ================================================ """2.0.9 Revision ID: 55390f1f77c1 Revises: bf28a012734c Create Date: 2024-12-24 13:29:32.225532 """ import contextlib import sqlalchemy as sa from alembic import op # revision identifiers, used by Alembic. revision = '55390f1f77c1' down_revision = 'bf28a012734c' branch_labels = None depends_on = None def upgrade() -> None: conn = op.get_bind() inspector = sa.inspect(conn) columns = inspector.get_columns('transferhistory') if not any(c['name'] == 'downloader' for c in columns): op.add_column('transferhistory', sa.Column('downloader', sa.String(), nullable=True)) def downgrade() -> None: pass ================================================ FILE: database/versions/58edfac72c32_2_2_3.py ================================================ """2.2.3 添加 downloadhistory.custom_words 字段,用于整理时应用订阅识别词 Revision ID: 58edfac72c32 Revises: 41ef1dd7467c Create Date: 2026-01-19 """ from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = "58edfac72c32" down_revision = "41ef1dd7467c" branch_labels = None depends_on = None def upgrade() -> None: conn = op.get_bind() inspector = sa.inspect(conn) # 检查并添加 downloadhistory.custom_words dh_columns = inspector.get_columns('downloadhistory') if not any(c['name'] == 'custom_words' for c in dh_columns): op.add_column('downloadhistory', sa.Column('custom_words', sa.String, nullable=True)) def downgrade() -> None: # 降级时删除字段 op.drop_column('downloadhistory', 'custom_words') ================================================ FILE: database/versions/5b3355c964bb_2_2_0.py ================================================ """2.2.0 Revision ID: 5b3355c964bb Revises: d58298a0879f Create Date: 2025-08-19 12:27:08.451371 """ import sqlalchemy as sa from alembic import op from app.log import logger from app.core.config import settings # revision identifiers, used by Alembic. revision = '5b3355c964bb' down_revision = 'd58298a0879f' branch_labels = None depends_on = None def upgrade() -> None: # ### commands auto generated by Alembic - please adjust! ### if settings.DB_TYPE.lower() == "postgresql": # 将SQLite的Sequence转换为PostgreSQL的Identity fix_postgresql_sequences() # ### end Alembic commands ### def fix_postgresql_sequences(): """ 修复PostgreSQL数据库中的序列问题 将SQLite迁移过来的Sequence转换为PostgreSQL的Identity """ connection = op.get_bind() # 获取所有表名 result = connection.execute(sa.text(""" SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_type = 'BASE TABLE' """)) tables = [row[0] for row in result.fetchall()] logger.info(f"发现 {len(tables)} 个表需要检查序列") for table_name in tables: fix_table_sequence(connection, table_name) def fix_table_sequence(connection, table_name): """ 修复单个表的序列 """ try: # 跳过alembic_version表,它没有id列 if table_name == 'alembic_version': logger.debug(f"跳过表 {table_name},这是Alembic版本表") return # 检查表是否有id列 result = connection.execute(sa.text(f""" SELECT is_identity, column_default FROM information_schema.columns WHERE table_name = '{table_name}' AND column_name = 'id' """)) id_column = result.fetchone() if not id_column: logger.debug(f"表 {table_name} 没有id列,跳过") return is_identity, column_default = id_column # 检查是否已经是Identity类型 if is_identity == 'YES' or (column_default and 'GENERATED BY DEFAULT AS IDENTITY' in column_default): logger.debug(f"表 {table_name} 的id列已经是Identity类型,跳过") return # 检查是否有序列 logger.info(f"表 {table_name} 存在序列,需要修复") convert_to_identity(connection, table_name) except Exception as e: logger.error(f"修复表 {table_name} 序列时出错: {e}") # 回滚当前事务,避免影响后续操作 connection.rollback() def convert_to_identity(connection, table_name): """ 将序列转换为Identity,保持原有约束不变 """ try: # 获取当前序列的最大值 result = connection.execute(sa.text(f""" SELECT COALESCE(MAX(id), 0) + 1 as next_value FROM "{table_name}" """)) next_value = result.fetchone()[0] # 直接修改列属性,添加Identity,保持其他约束不变 # 这种方式不会删除主键约束和索引 connection.execute(sa.text(f""" ALTER TABLE "{table_name}" ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY (START WITH {next_value}) """)) logger.info(f"表 {table_name} 序列已转换为Identity,起始值为 {next_value}") except Exception as e: # 如果是已经存在的Identity错误,则忽略 if "already an identity column" in str(e): logger.warn(f"表 {table_name} 的id列已经是Identity类型,忽略此错误: {e}") return logger.error(f"转换表 {table_name} 序列时出错: {e}") raise ================================================ FILE: database/versions/610bb05ddeef_2_1_2.py ================================================ """2.1.2 Revision ID: 610bb05ddeef Revises: 279a949d81b6 Create Date: 2025-02-24 07:52:00.042837 """ import contextlib from alembic import op import sqlalchemy as sa from sqlalchemy.dialects import sqlite # revision identifiers, used by Alembic. revision = '610bb05ddeef' down_revision = '279a949d81b6' branch_labels = None depends_on = None def upgrade() -> None: conn = op.get_bind() inspector = sa.inspect(conn) columns = inspector.get_columns('workflow') if not any(c['name'] == 'flows' for c in columns): op.add_column('workflow', sa.Column('flows', sa.JSON(), nullable=True)) def downgrade() -> None: pass ================================================ FILE: database/versions/89d24811e894_2_1_4.py ================================================ """2.1.4 Revision ID: 89d24811e894 Revises: 4b544f5d3b07 Create Date: 2025-05-03 17:29:07.635618 """ from app.db.systemconfig_oper import SystemConfigOper from app.schemas.types import SystemConfigKey # revision identifiers, used by Alembic. revision = '89d24811e894' down_revision = '4b544f5d3b07' branch_labels = None depends_on = None def upgrade() -> None: # ### commands auto generated by Alembic - please adjust! ### value = { "organizeSuccess": """ { 'title': '{{ title_year }}' '{% if season_episode %} {{ season_episode }}{% endif %} 已入库', 'text': '{% if vote_average %}评分:{{ vote_average }},{% endif %}' '类型:{{ type }}' '{% if category %},类别:{{ category }}{% endif %}' '{% if resource_term %},质量:{{ resource_term }}{% endif %},' '共{{ file_count }}个文件,大小:{{ total_size }}' '{% if err_msg %},以下文件处理失败:{{ err_msg }}{% endif %}' }""", "downloadAdded": """ { 'title': '{{ title_year }}' '{% if download_episodes %} {{ season_fmt }} {{ download_episodes }}{% else %}{{ season_episode }}{% endif %} 开始下载', 'text': '{% if site_name %}站点:{{ site_name }}{% endif %}' '{% if resource_term %}\\n质量:{{ resource_term }}{% endif %}' '{% if size %}\\n大小:{{ size }}{% endif %}' '{% if torrent_title %}\\n种子:{{ torrent_title }}{% endif %}' '{% if pubdate %}\\n发布时间:{{ pubdate }}{% endif %}' '{% if freedate %}\\n免费时间:{{ freedate }}{% endif %}' '{% if seeders %}\\n做种数:{{ seeders }}{% endif %}' '{% if volume_factor %}\\n促销:{{ volume_factor }}{% endif %}' '{% if hit_and_run %}\\nHit&Run:{{ hit_and_run }}{% endif %}' '{% if labels %}\\n标签:{{ labels }}{% endif %}' '{% if description %}\\n描述:{{ description }}{% endif %}' }""", "subscribeAdded": "{'title': '{{ title_year }}{% if season_fmt %} {{ season_fmt }}{% endif %} 已添加订阅'}", "subscribeComplete": """ { 'title': '{{ title_year }}' '{% if season_fmt %} {{ season_fmt }}{% endif %} 已完成{{ msgstr }}', 'text': '{% if vote_average %}评分:{{ vote_average }}{% endif %}' '{% if username %},来自用户:{{ username }}{% endif %}' '{% if actors %}\\n演员:{{ actors }}{% endif %}' '{% if overview %}\\n简介:{{ overview }}{% endif %}' }""" } _systemconfig = SystemConfigOper() if not _systemconfig.get(SystemConfigKey.NotificationTemplates): _systemconfig.set(SystemConfigKey.NotificationTemplates, value) # ### end Alembic commands ### def downgrade() -> None: pass ================================================ FILE: database/versions/a295e41830a6_2_0_6.py ================================================ """2.0.6 Revision ID: a295e41830a6 Revises: ecf3c693fdf3 Create Date: 2024-11-14 12:49:13.838120 """ from app.db.systemconfig_oper import SystemConfigOper from app.schemas.types import SystemConfigKey # revision identifiers, used by Alembic. revision = 'a295e41830a6' down_revision = 'ecf3c693fdf3' branch_labels = None depends_on = None def upgrade() -> None: # ### commands auto generated by Alembic - please adjust! ### # 初始化AList存储 _systemconfig = SystemConfigOper() _storages = _systemconfig.get(SystemConfigKey.Storages) if _storages: if "alist" not in [storage["type"] for storage in _storages]: _storages.append({ "type": "alist", "name": "AList", "config": {} }) _systemconfig.set(SystemConfigKey.Storages, _storages) # ### end Alembic commands ### def downgrade() -> None: pass ================================================ FILE: database/versions/a73f2dbf5c09_2_0_4.py ================================================ """2.0.4 Revision ID: a73f2dbf5c09 Revises: e2dbe1421fa4 Create Date: 2024-10-16 15:05:01.775429 """ from app.db.systemconfig_oper import SystemConfigOper from app.schemas.types import SystemConfigKey # revision identifiers, used by Alembic. revision = 'a73f2dbf5c09' down_revision = 'e2dbe1421fa4' branch_labels = None depends_on = None def upgrade() -> None: # ### commands auto generated by Alembic - please adjust! ### # 初始化下载优先规则 SystemConfigOper().set(SystemConfigKey.TorrentsPriority, ["torrent", "upload", "seeder"]) # ### end Alembic commands ### def downgrade() -> None: pass ================================================ FILE: database/versions/a946dae52526_2_2_1.py ================================================ """2.2.1 Revision ID: a946dae52526 Revises: 5b3355c964bb Create Date: 2025-08-20 17:50:00.000000 """ import sqlalchemy as sa from alembic import op from app.log import logger from app.core.config import settings # revision identifiers, used by Alembic. revision = 'a946dae52526' down_revision = '5b3355c964bb' branch_labels = None depends_on = None def upgrade() -> None: """ 升级:将SiteUserData表的userid字段从Integer改为String """ connection = op.get_bind() if settings.DB_TYPE.lower() == "postgresql": # PostgreSQL数据库迁移 migrate_postgresql_userid(connection) def downgrade() -> None: """ 降级:将SiteUserData表的userid字段从String改回Integer """ pass def migrate_postgresql_userid(connection): """ PostgreSQL数据库userid字段迁移 """ try: logger.info("开始PostgreSQL数据库userid字段迁移...") # 1. 创建临时列 connection.execute(sa.text(""" ALTER TABLE siteuserdata ADD COLUMN userid_new VARCHAR """)) # 2. 将现有数据转换为字符串并复制到新列 connection.execute(sa.text(""" UPDATE siteuserdata SET userid_new = CAST(userid AS VARCHAR) WHERE userid IS NOT NULL """)) # 3. 删除旧列 connection.execute(sa.text(""" ALTER TABLE siteuserdata DROP COLUMN userid """)) # 4. 重命名新列 connection.execute(sa.text(""" ALTER TABLE siteuserdata RENAME COLUMN userid_new TO userid """)) logger.info("PostgreSQL数据库userid字段迁移完成") except Exception as e: logger.error(f"PostgreSQL数据库userid字段迁移失败: {e}") raise ================================================ FILE: database/versions/bf28a012734c_2_0_8.py ================================================ """2.0.8 Revision ID: bf28a012734c Revises: eaf9cbc49027 Create Date: 2024-12-23 18:29:31.202143 """ import contextlib import sqlalchemy as sa from alembic import op # revision identifiers, used by Alembic. revision = 'bf28a012734c' down_revision = 'eaf9cbc49027' branch_labels = None depends_on = None def upgrade() -> None: conn = op.get_bind() inspector = sa.inspect(conn) columns = inspector.get_columns('downloadhistory') if not any(c['name'] == 'downloader' for c in columns): op.add_column('downloadhistory', sa.Column('downloader', sa.String(), nullable=True)) def downgrade() -> None: pass ================================================ FILE: database/versions/ca5461f314f2_2_1_0.py ================================================ """2.1.0 Revision ID: ca5461f314f2 Revises: 55390f1f77c1 Create Date: 2025-02-06 18:28:00.644571 """ import contextlib import sqlalchemy as sa from alembic import op # revision identifiers, used by Alembic. revision = 'ca5461f314f2' down_revision = '55390f1f77c1' branch_labels = None depends_on = None def upgrade() -> None: conn = op.get_bind() inspector = sa.inspect(conn) # 检查并添加 subscribe.mediaid s_columns = inspector.get_columns('subscribe') if not any(c['name'] == 'mediaid' for c in s_columns): op.add_column('subscribe', sa.Column('mediaid', sa.String(), nullable=True)) # 检查并创建索引 s_indexes = inspector.get_indexes('subscribe') if not any(i['name'] == 'ix_subscribe_mediaid' for i in s_indexes): op.create_index('ix_subscribe_mediaid', 'subscribe', ['mediaid'], unique=False) # 检查并添加 subscribehistory.mediaid sh_columns = inspector.get_columns('subscribehistory') if not any(c['name'] == 'mediaid' for c in sh_columns): op.add_column('subscribehistory', sa.Column('mediaid', sa.String(), nullable=True)) def downgrade() -> None: pass ================================================ FILE: database/versions/d58298a0879f_2_1_9.py ================================================ """2.1.9 Revision ID: d58298a0879f Revises: 4666ce24a443 Create Date: 2025-08-19 11:56:39.652032 """ # revision identifiers, used by Alembic. revision = 'd58298a0879f' down_revision = '4666ce24a443' branch_labels = None depends_on = None def upgrade() -> None: pass def downgrade() -> None: pass ================================================ FILE: database/versions/e2dbe1421fa4_2_0_3.py ================================================ """2.0.3 Revision ID: e2dbe1421fa4 Revises: 0fb94bf69b38 Create Date: 2024-10-09 13:44:13.926529 """ import contextlib from alembic import op import sqlalchemy as sa from app.log import logger from app.db import SessionFactory from app.db.models import UserConfig # revision identifiers, used by Alembic. revision = 'e2dbe1421fa4' down_revision = '0fb94bf69b38' branch_labels = None depends_on = None def upgrade() -> None: conn = op.get_bind() inspector = sa.inspect(conn) # 检查并添加 downloadhistory.media_category dh_columns = inspector.get_columns('downloadhistory') if not any(c['name'] == 'media_category' for c in dh_columns): op.add_column('downloadhistory', sa.Column('media_category', sa.String(), nullable=True)) # 检查并添加 subscribe 表的列 sub_columns = inspector.get_columns('subscribe') if not any(c['name'] == 'custom_words' for c in sub_columns): op.add_column('subscribe', sa.Column('custom_words', sa.String(), nullable=True)) if not any(c['name'] == 'media_category' for c in sub_columns): op.add_column('subscribe', sa.Column('media_category', sa.String(), nullable=True)) if not any(c['name'] == 'filter_groups' for c in sub_columns): op.add_column('subscribe', sa.Column('filter_groups', sa.JSON(), nullable=True)) # 定义需要检查和转换的表和列 columns_to_alter = { 'subscribe': 'note', 'downloadhistory': 'note', 'mediaserveritem': 'note', 'message': 'note', 'plugindata': 'value', 'site': 'note', 'sitestatistic': 'note', 'systemconfig': 'value', 'userconfig': 'value' } for table, column_name in columns_to_alter.items(): try: cols = inspector.get_columns(table) # 找到对应的列信息 target_col = next((c for c in cols if c['name'] == column_name), None) # 如果列存在且类型不是JSON,则进行修改 if target_col and not isinstance(target_col['type'], sa.JSON): # PostgreSQL需要指定USING子句来处理类型转换 if conn.dialect.name == 'postgresql': op.alter_column(table, column_name, existing_type=sa.String(), type_=sa.JSON(), postgresql_using=f'"{column_name}"::json') else: op.alter_column(table, column_name, existing_type=sa.String(), type_=sa.JSON()) except Exception as e: logger.error(f"Could not alter column {column_name} in table {table}: {e}") with SessionFactory() as db: UserConfig.truncate(db) def downgrade() -> None: pass ================================================ FILE: database/versions/eaf9cbc49027_2_0_7.py ================================================ """2.0.7 Revision ID: eaf9cbc49027 Revises: a295e41830a6 Create Date: 2024-11-16 00:26:09.505188 """ import contextlib from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = 'eaf9cbc49027' down_revision = 'a295e41830a6' branch_labels = None depends_on = None def upgrade() -> None: conn = op.get_bind() inspector = sa.inspect(conn) # 检查并添加 site.downloader site_columns = inspector.get_columns('site') if not any(c['name'] == 'downloader' for c in site_columns): op.add_column('site', sa.Column('downloader', sa.String(), nullable=True)) # 检查并添加 subscribe.downloader subscribe_columns = inspector.get_columns('subscribe') if not any(c['name'] == 'downloader' for c in subscribe_columns): op.add_column('subscribe', sa.Column('downloader', sa.String(), nullable=True)) def downgrade() -> None: pass ================================================ FILE: database/versions/ecf3c693fdf3_2_0_5.py ================================================ """2.0.5 Revision ID: ecf3c693fdf3 Revises: a73f2dbf5c09 Create Date: 2024-10-21 12:36:20.631963 """ import contextlib from alembic import op import sqlalchemy as sa from app.log import logger # revision identifiers, used by Alembic. revision = 'ecf3c693fdf3' down_revision = 'a73f2dbf5c09' branch_labels = None depends_on = None def upgrade() -> None: conn = op.get_bind() inspector = sa.inspect(conn) table_name = 'subscribehistory' columns = inspector.get_columns(table_name) try: sites_col = next((c for c in columns if c['name'] == 'sites'), None) # 如果 'sites' 列存在且类型不是 JSON,则进行修改 if sites_col and not isinstance(sites_col['type'], sa.JSON): if conn.dialect.name == 'postgresql': op.alter_column(table_name, 'sites', existing_type=sa.String(), type_=sa.JSON(), postgresql_using='sites::json') else: op.alter_column(table_name, 'sites', existing_type=sa.String(), type_=sa.JSON()) except Exception as e: logger.error(f"Could not alter column 'sites' in table {table_name}: {e}") if not any(c['name'] == 'custom_words' for c in columns): op.add_column(table_name, sa.Column('custom_words', sa.String(), nullable=True)) if not any(c['name'] == 'media_category' for c in columns): op.add_column(table_name, sa.Column('media_category', sa.String(), nullable=True)) if not any(c['name'] == 'filter_groups' for c in columns): op.add_column(table_name, sa.Column('filter_groups', sa.JSON(), nullable=True)) def downgrade() -> None: pass ================================================ FILE: docker/Dockerfile ================================================ FROM python:3.12.8-slim-bookworm AS base # 准备软件包 FROM base AS prepare_package ENV LANG="C.UTF-8" \ TZ="Asia/Shanghai" \ HOME="/moviepilot" \ CONFIG_DIR="/config" \ TERM="xterm" \ DISPLAY=:987 \ PUID=0 \ PGID=0 \ UMASK=000 \ VENV_PATH="/opt/venv" ENV PATH="${VENV_PATH}/bin:${PATH}" RUN apt-get update && apt-get install -y --no-install-recommends \ nginx \ gettext-base \ locales \ procps \ gosu \ bash \ curl \ wget \ busybox \ tini \ jq \ fuse3 \ rsync \ ffmpeg \ nano \ libjemalloc2 \ && dpkg-reconfigure --frontend noninteractive tzdata \ && curl https://rclone.org/install.sh | bash \ && ln -s /usr/lib/*-linux-gnu/libjemalloc.so.2 /usr/local/lib/libjemalloc.so \ && apt-get autoremove -y \ && apt-get clean \ && rm -rf \ /tmp/* \ /var/lib/apt/lists/* \ /var/tmp/* # 准备 python 环境 FROM base AS prepare_venv # 设置环境变量 ENV LANG="C.UTF-8" \ TZ="Asia/Shanghai" \ VENV_PATH="/opt/venv" ENV PATH="${VENV_PATH}/bin:${PATH}" # 安装系统构建依赖 RUN apt-get update && apt-get install -y --no-install-recommends \ build-essential \ curl \ busybox \ jq \ wget # 安装 Python 构建依赖并创建虚拟环境 WORKDIR /app COPY requirements.in requirements.in RUN python3 -m venv ${VENV_PATH} \ && pip install --upgrade "pip<25.0" \ && pip install "Cython" "pip-tools<7.5" \ && pip-compile requirements.in \ && pip install -r requirements.txt # 下载准备代码 FROM prepare_package AS prepare_code WORKDIR /app COPY . . RUN FRONTEND_VERSION=$(sed -n "s/^FRONTEND_VERSION\s*=\s*'\([^']*\)'/\1/p" /app/version.py) \ && curl -sL "https://github.com/jxxghp/MoviePilot-Frontend/releases/download/${FRONTEND_VERSION}/dist.zip" | busybox unzip -d / - \ && mv /dist /public \ && curl -sL "https://github.com/jxxghp/MoviePilot-Plugins/archive/refs/heads/main.zip" | busybox unzip -d /tmp - \ && mv -f /tmp/MoviePilot-Plugins-main/plugins.v2/* /app/app/plugins/ \ && cat /tmp/MoviePilot-Plugins-main/package.json | jq -r 'to_entries[] | select(.value.v2 == true) | .key' | awk '{print tolower($0)}' | \ while read -r i; do if [ ! -d "/app/app/plugins/$i" ]; then mv "/tmp/MoviePilot-Plugins-main/plugins/$i" "/app/app/plugins/"; else echo "跳过 $i"; fi; done \ && curl -sL "https://github.com/jxxghp/MoviePilot-Resources/archive/refs/heads/main.zip" | busybox unzip -d /tmp - \ && mv -f /tmp/MoviePilot-Resources-main/resources.v2/* /app/app/helper/ # final 阶段: 安装运行时依赖和配置最终镜像 FROM prepare_package AS final ENV LD_PRELOAD="/usr/local/lib/libjemalloc.so" # python 环境 COPY --from=prepare_venv --chmod=777 ${VENV_PATH} ${VENV_PATH} # playwright 环境 RUN playwright install-deps chromium \ && playwright install-deps firefox \ && apt-get autoremove -y \ && apt-get clean \ && rm -rf \ /tmp/* \ /var/lib/apt/lists/* \ /var/tmp/* # 准备运行代码 WORKDIR /app COPY --from=prepare_code /app /app COPY --from=prepare_code /public /public RUN cp -f /app/docker/nginx.common.conf /etc/nginx/common.conf \ && cp -f /app/docker/nginx.template.conf /etc/nginx/nginx.template.conf \ && cp -f /app/docker/update.sh /usr/local/bin/mp_update.sh \ && cp -f /app/docker/entrypoint.sh /entrypoint.sh \ && cp -f /app/docker/docker_http_proxy.conf /etc/nginx/docker_http_proxy.conf \ && chmod +x /entrypoint.sh /usr/local/bin/mp_update.sh \ && mkdir -p ${HOME} \ && groupadd -r moviepilot -g 918 \ && useradd -r moviepilot -g moviepilot -d ${HOME} -s /bin/bash -u 918 \ && python_ver=$(python3 -V | awk '{print $2}') \ && echo "/app/" > ${VENV_PATH}/lib/python${python_ver%.*}/site-packages/app.pth \ && echo 'fs.inotify.max_user_watches=5242880' >> /etc/sysctl.conf \ && echo 'fs.inotify.max_user_instances=5242880' >> /etc/sysctl.conf \ && echo "zh_CN.UTF-8 UTF-8" >> /etc/locale.gen \ && locale-gen zh_CN.UTF-8 EXPOSE 3000 VOLUME [ "${CONFIG_DIR}" ] ENTRYPOINT [ "/usr/bin/tini", "-g", "--", "/entrypoint.sh" ] ================================================ FILE: docker/cert.sh ================================================ #!/bin/bash set -e Green="\033[32m" Red="\033[31m" Yellow='\033[33m' Font="\033[0m" INFO="[${Green}INFO${Font}]" ERROR="[${Red}ERROR${Font}]" WARN="[${Yellow}WARN${Font}]" function INFO() { echo -e "${INFO} ${1}" } function ERROR() { echo -e "${ERROR} ${1}" } function WARN() { echo -e "${WARN} ${1}" } # 核心条件验证 if [ "${ENABLE_SSL}" = "true" ] && \ [ "${AUTO_ISSUE_CERT}" = "true" ] && \ [ -n "${SSL_DOMAIN}" ]; then # 创建证书目录 mkdir -p /config/certs/"${SSL_DOMAIN}" chown moviepilot:moviepilot /config/certs -R # 安装acme.sh(使用官方安装脚本) if [ ! -d "/config/acme.sh" ]; then INFO "→ 安装acme.sh..." # 设置安装环境变量 export LE_WORKING_DIR="/config/acme.sh" export LE_CONFIG_HOME="/config/acme.sh/data" export LE_CERT_HOME="/config/certs" # 执行官方安装命令(添加错误处理) INFO "正在下载并安装 acme.sh..." # 构建安装命令 INSTALL_CMD="curl -sSL https://get.acme.sh | sh -s -- --install-online" if [ -n "${SSL_EMAIL}" ]; then INSTALL_CMD="${INSTALL_CMD} --accountemail ${SSL_EMAIL}" else WARN "未设置SSL_EMAIL,建议配置邮箱用于证书过期提醒" fi if ! eval "${INSTALL_CMD}"; then ERROR "acme.sh 安装失败" exit 1 fi # 验证安装是否成功 if [ ! -f "/config/acme.sh/acme.sh" ]; then ERROR "acme.sh 安装后文件不存在,安装可能失败" exit 1 fi INFO "acme.sh 安装成功" fi # 签发证书(仅当证书不存在时) if [ ! -f "/config/certs/${SSL_DOMAIN}/fullchain.pem" ]; then # 必要参数检查 REQUIRED_VARS=("DNS_PROVIDER") for var in "${REQUIRED_VARS[@]}"; do eval "value=\${${var}}" [ -z "$value" ] && { ERROR "必须设置环境变量: ${var}"; exit 1; } done INFO "→ 签发证书: ${SSL_DOMAIN} (DNS验证方式: ${DNS_PROVIDER})" # 加载ACME环境变量(带安全过滤) INFO "正在加载ACME环境变量..." env | grep '^ACME_ENV_' | while read -r line; do key="${line#ACME_ENV_}" key="${key%%=*}" value="${line#ACME_ENV_${key}=}" # 过滤非法变量名 if [[ "$key" =~ ^[a-zA-Z_][a-zA-Z0-9_]*$ ]]; then export "$key"="$value" INFO "已加载环境变量: ${key}=******" else WARN "跳过无效变量名: ${key}" fi done # 签发证书(添加错误处理) INFO "正在签发证书..." if ! /config/acme.sh/acme.sh --issue \ --dns "${DNS_PROVIDER}" \ --domain "${SSL_DOMAIN}" \ --key-file /config/certs/"${SSL_DOMAIN}"/privkey.pem \ --fullchain-file /config/certs/"${SSL_DOMAIN}"/fullchain.pem \ --reloadcmd "nginx -s reload" \ --force; then ERROR "证书签发失败" exit 1 fi # 创建稳定符号链接 ln -sf /config/certs/"${SSL_DOMAIN}" /config/certs/latest INFO "证书签发成功" else INFO "证书已存在,跳过签发步骤" fi # 配置自动更新任务 INFO "→ 配置cron自动更新..." echo "0 3 * * * /config/acme.sh/acme.sh --cron --home /config/acme.sh && nginx -s reload" > /etc/cron.d/acme chmod 644 /etc/cron.d/acme service cron start elif [ "${ENABLE_SSL}" = "true" ] && [ "${AUTO_ISSUE_CERT}" = "true" ] && [ -z "${SSL_DOMAIN}" ]; then WARN "已启用自动签发证书但未设置SSL_DOMAIN,跳过证书管理" elif [ "${ENABLE_SSL}" = "true" ] && [ "${AUTO_ISSUE_CERT}" = "false" ]; then INFO "SSL已启用但自动签发证书已禁用,将使用手动配置的证书" # 检查证书文件是否存在 if [ -f "/config/certs/latest/fullchain.pem" ] && [ -f "/config/certs/latest/privkey.pem" ]; then INFO "检测到证书文件,SSL配置正常" else WARN "未检测到证书文件,请确保手动配置了正确的证书路径" fi fi ================================================ FILE: docker/docker_http_proxy.conf ================================================ worker_processes 1; user root; daemon on; pid /var/run/nginx_proxy.pid; events { worker_connections 1024; } http { include mime.types; default_type application/octet-stream; upstream docker { server unix:/var/run/docker.sock fail_timeout=0; } server { listen 127.0.0.1:38379; server_name localhost; access_log /dev/stdout combined; error_log /dev/stdout; location / { proxy_pass http://docker; proxy_redirect off; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; client_max_body_size 10m; client_body_buffer_size 128k; proxy_connect_timeout 90; proxy_send_timeout 120; proxy_read_timeout 120; proxy_buffer_size 4k; proxy_buffers 4 32k; proxy_busy_buffers_size 64k; proxy_temp_file_write_size 64k; } } } ================================================ FILE: docker/entrypoint.sh ================================================ #!/bin/bash # shellcheck shell=bash # shellcheck disable=SC2016 # shellcheck disable=SC2155 Green="\033[32m" Red="\033[31m" Yellow='\033[33m' Font="\033[0m" INFO="[${Green}INFO${Font}]" ERROR="[${Red}ERROR${Font}]" WARN="[${Yellow}WARN${Font}]" function INFO() { echo -e "${INFO} ${1}" } function ERROR() { echo -e "${ERROR} ${1}" } function WARN() { echo -e "${WARN} ${1}" } # 设置虚拟环境路径(兼容群晖等系统必须这样配置) VENV_PATH="${VENV_PATH:-/opt/venv}" export PATH="${VENV_PATH}/bin:$PATH" # 校正设置目录 CONFIG_DIR="${CONFIG_DIR:-/config}" # 记录非系统环境(docker容器表)提供的变量 declare -ga VARS_SET_BY_SCRIPT=() # 环境变量补全 # 优先级: 系统环境变量 -> .env 文件 (即使为空字符串) -> 预设默认值 # 精准适配 Python 端 set_key (quote_mode="always", 单引号包裹, \' 转义) function load_config_from_app_env() { local env_file="${CONFIG_DIR}/app.env" # 定义 ["变量名"]="预设默认值" # 禁止填入 CONFIG_DIR 变量,ACME_ENV_ 开头的变量暂时不处理,还是交由 cert.sh 处理 declare -A vars_and_default_values=( # update.sh ["PIP_PROXY"]="" ["GITHUB_PROXY"]="" ["PROXY_HOST"]="" ["GITHUB_TOKEN"]="" ["MOVIEPILOT_AUTO_UPDATE"]="release" # database ["DB_TYPE"]="sqlite" ["DB_POSTGRESQL_HOST"]="localhost" ["DB_POSTGRESQL_PORT"]="5432" ["DB_POSTGRESQL_DATABASE"]="moviepilot" ["DB_POSTGRESQL_USERNAME"]="moviepilot" ["DB_POSTGRESQL_PASSWORD"]="moviepilot" ["DB_POSTGRESQL_POOL_SIZE"]="20" ["DB_POSTGRESQL_MAX_OVERFLOW"]="30" # cert ["ENABLE_SSL"]="false" ["SSL_DOMAIN"]="" ["NGINX_PORT"]="3000" ["PORT"]="3001" ["NGINX_CLIENT_MAX_BODY_SIZE"]="10m" ) INFO "开始加载配置 (配置文件: ${env_file})..." shopt -s extglob declare -A values_from_env_file if [ -f "${env_file}" ]; then INFO "检测到 ${env_file} 文件,尝试解析..." while IFS= read -r line || [ -n "$line" ]; do if [[ "$line" =~ ^[[:space:]]*# || -z "$line" ]]; then continue fi local key_in_file value_raw_in_file if [[ "$line" =~ ^[[:space:]]*([A-Za-z_][A-Za-z0-9_]*)[[:space:]]*=(.*) ]]; then key_in_file="${BASH_REMATCH[1]}" value_raw_in_file="${BASH_REMATCH[2]}" if [[ -n "${vars_and_default_values[$key_in_file]+_}" ]]; then local temp_val_after_initial_trim temp_val_after_initial_trim="${value_raw_in_file#"${value_raw_in_file%%[![:space:]]*}"}" temp_val_after_initial_trim="${temp_val_after_initial_trim%"${temp_val_after_initial_trim##*[![:space:]]}"}" local val_before_quote_check="${temp_val_after_initial_trim}" if [[ ! ("${temp_val_after_initial_trim:0:1}" == "'" && "${temp_val_after_initial_trim: -1}" == "'") ]]; then if [[ "${temp_val_after_initial_trim}" =~ ^(.*)[[:space:]]+# ]]; then val_before_quote_check="${BASH_REMATCH[1]}" val_before_quote_check="${val_before_quote_check%%+([[:space:]])}" elif [[ "${temp_val_after_initial_trim:0:1}" == "#" ]]; then val_before_quote_check="" fi fi local parsed_value_from_file if [[ "${val_before_quote_check:0:1}" == "'" && "${val_before_quote_check: -1}" == "'" && ${#val_before_quote_check} -ge 2 ]]; then parsed_value_from_file="${val_before_quote_check:1:${#val_before_quote_check}-2}" parsed_value_from_file="${parsed_value_from_file//\\\'/__MP_PARSER_SQUOTE__}" parsed_value_from_file="${parsed_value_from_file//__MP_PARSER_SQUOTE__/\'}" elif [ -z "${val_before_quote_check}" ]; then parsed_value_from_file="" else WARN "位于 ${env_file} 中的键 ${key_in_file} 对应值 ${val_before_quote_check} 未按规范使用单引号包裹,将采用字面量解析。" parsed_value_from_file="${val_before_quote_check}" fi values_from_env_file["${key_in_file}"]="${parsed_value_from_file}" fi else WARN "跳过 ${env_file} 中格式不正确的行: $line" fi done < <(sed -e '1s/^\xEF\xBB\xBF//' -e 's/\r$//g' "${env_file}") INFO "${env_file} 解析完毕。" else INFO "${env_file} 文件不存在,跳过文件加载。" fi INFO "正在根据优先级确定并导出配置值..." for var_name in "${!vars_and_default_values[@]}"; do local fallback_value="${vars_and_default_values[$var_name]}" local final_value local value_source="未设置" # 标志变量是否来自初始环境 local set_by_initial_env=false # 检查变量是否在环境中已设置(可能为空) if eval "[ -n \"\${${var_name}+x}\" ]"; then # 获取其值 final_value="$(eval echo \"\$"${var_name}"\")" value_source="系统环境变量" set_by_initial_env=true elif [[ -n "${values_from_env_file["${var_name}"]+_}" ]]; then final_value="${values_from_env_file["${var_name}"]}" value_source=".env 文件" else final_value="${fallback_value}" value_source="内置默认值" fi # 不论来源如何,都导出变量,以便脚本的其余部分和子进程使用 # (例如 envsubst, mp_update.sh, cert.sh) if declare -gx "${var_name}=${final_value}"; then if [ -z "${final_value}" ]; then INFO "变量 ${var_name}, 值为空 (来源: ${value_source})。" else INFO "变量 ${var_name}, 值: ${final_value} (来源: ${value_source})。" fi # 如果变量不是来自初始环境变量,则记录下来以便稍后 unset if ! ${set_by_initial_env}; then # 检查是否已在数组中,避免重复添加 local found_in_script_vars=false for item in "${VARS_SET_BY_SCRIPT[@]}"; do if [[ "$item" == "$var_name" ]]; then found_in_script_vars=true break fi done if ! ${found_in_script_vars}; then VARS_SET_BY_SCRIPT+=("${var_name}") fi fi else ERROR "导出变量 ${var_name}, 值: '${final_value}'失败 (来源: ${value_source}) " fi done shopt -u extglob INFO "配置加载流程执行完毕。" } # 优雅退出 function graceful_exit() { local exit_code=${1:-0} local reason=${2:-python_exit} if [ "$reason" = "signal" ]; then INFO "→ 收到停止信号,执行精准清理程序..." else INFO "→ 主进程已退出 (代码: $exit_code),执行清理程序..." fi # 第一步:停止前端 Nginx # 默认配置启动的 Nginx,默认 PID 在 /var/run/nginx.pid INFO "→ [1/3] 正在关闭前端 Nginx..." nginx -c /etc/nginx/nginx.conf -s stop 2>/dev/null || true # 第二步:等待 Python 退出 # 由于使用了 tini -g,Python 已经收到了信号,我们只需等待 if [ -n "$PYTHON_PID" ] && ps -p "$PYTHON_PID" > /dev/null; then INFO "→ [2/3] 正在等待 Python (PID: $PYTHON_PID) 完成清理..." # 这里的 wait 会阻塞,直到 Python 真正退出 wait "$PYTHON_PID" 2>/dev/null || true fi # 第三步:最后关闭 Docker Proxy # 必须指定配置文件路径,否则 nginx -s stop 找不到它 INFO "→ [3/3] 后端已安全退出,正在关闭 Docker Proxy..." if [ -S "/var/run/docker.sock" ]; then nginx -c /etc/nginx/docker_http_proxy.conf -s stop 2>/dev/null || true fi # 根据退出码判断最终日志性质 # 0: 正常退出 # 130/143: 被系统信号终止(通常也视为预期的清理退出) if [ "$exit_code" -eq 0 ] || [ "$exit_code" -eq 130 ] || [ "$exit_code" -eq 143 ]; then INFO "→ 所有服务已按序清理,容器正常退出 (ExitCode: $exit_code)。" else # 非预期退出码,使用 ERROR 级别并加重提示 ERROR "→ 清理完成,但主进程检测到异常退出 (ExitCode: $exit_code)!" fi exit "$exit_code" } # 使用env配置 load_config_from_app_env # 生成HTTPS配置块 if [ "${ENABLE_SSL}" = "true" ]; then export HTTPS_SERVER_CONF=$(cat < /etc/nginx/nginx.conf # 自动更新 cd / source /usr/local/bin/mp_update.sh cd /app || exit # 更改 moviepilot userid 和 groupid groupmod -o -g "${PGID}" moviepilot usermod -o -u "${PUID}" moviepilot # 更改文件权限 chown -R moviepilot:moviepilot \ "${HOME}" \ /app \ /public \ "${CONFIG_DIR}" \ /var/lib/nginx \ /var/log/nginx chown moviepilot:moviepilot /etc/hosts /tmp # 下载浏览器内核 if [[ "$HTTPS_PROXY" =~ ^https?:// ]] || [[ "$HTTPS_PROXY" =~ ^https?:// ]] || [[ "$PROXY_HOST" =~ ^https?:// ]]; then HTTPS_PROXY="${HTTPS_PROXY:-${https_proxy:-$PROXY_HOST}}" gosu moviepilot:moviepilot playwright install ${PLAYWRIGHT_BROWSER_TYPE:-chromium} else gosu moviepilot:moviepilot playwright install ${PLAYWRIGHT_BROWSER_TYPE:-chromium} fi # 证书管理 source /app/docker/cert.sh # 启动前端nginx服务 INFO "→ 启动前端nginx服务..." nginx # 捕获信号并跳转到函数 trap 'graceful_exit 130 "signal"' SIGINT trap 'graceful_exit 143 "signal"' SIGTERM # 启动docker http proxy nginx if [ -S "/var/run/docker.sock" ]; then INFO "→ 启动 Docker Proxy..." nginx -c /etc/nginx/docker_http_proxy.conf # 上面nginx是通过root启动的,会将目录权限改成root,所以需要重新再设置一遍权限 chown -R moviepilot:moviepilot \ /var/lib/nginx \ /var/log/nginx fi # 设置后端服务权限掩码 umask "${UMASK}" # 清除非系统环境导入的变量,保证转移到 dumb-init 的时候,不会带入不必要的环境变量 INFO "准备为 Python 应用清理的非系统环境导入的变量..." if [ ${#VARS_SET_BY_SCRIPT[@]} -gt 0 ]; then for var_to_unset in "${VARS_SET_BY_SCRIPT[@]}"; do # 再次确认变量确实存在于当前环境中(虽然理论上应该存在) if eval "[ -n \"\${${var_to_unset}+x}\" ]"; then INFO "取消设置环境变量: ${var_to_unset}" unset "${var_to_unset}" else WARN "变量 ${var_to_unset} 已不存在,无需取消设置。" fi done else INFO "没有由非系统环境导入的变量需要清理。" fi # 启动后端服务 INFO "→ 启动后端服务..." if [ "${START_NOGOSU:-false}" = "true" ]; then "${VENV_PATH}/bin/python3" app/main.py > /dev/stdout 2> /dev/stderr & else gosu moviepilot:moviepilot "${VENV_PATH}/bin/python3" app/main.py > /dev/stdout 2> /dev/stderr & PYTHON_PID=$! # 等待 Python 进程退出。 # 如果收到信号,trap 会中断 wait,并执行 graceful_exit。 # 如果 Python 正常退出,wait 会结束,然后我们手动调用 graceful_exit。 wait "$PYTHON_PID" 2>/dev/null exit_code=$? # 如果 Python 自己退出了(非信号触发),执行清理 graceful_exit "$exit_code" "python_exit" ================================================ FILE: docker/nginx.common.conf ================================================ # 公共根目录 root /public; # 主应用路由 location / { expires off; add_header Cache-Control "no-cache, no-store, must-revalidate"; try_files $uri $uri/ /index.html; } # 本地CookieCloud location /cookiecloud { proxy_pass http://backend_api; rewrite ^.+mock-server/?(.*)$ /$1 break; proxy_http_version 1.1; proxy_buffering off; proxy_cache off; proxy_redirect off; proxy_set_header Connection ""; proxy_set_header Upgrade $http_upgrade; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header Host $http_host; proxy_set_header X-Nginx-Proxy true; # 超时设置 proxy_read_timeout 600s; } # SSE特殊配置 location ~ ^/api/v1/system/(message|progress/) { # SSE MIME类型设置 default_type text/event-stream; # 禁用缓存 add_header Cache-Control no-cache; add_header X-Accel-Buffering no; proxy_buffering off; proxy_cache off; # 代理设置 proxy_pass http://backend_api; proxy_http_version 1.1; proxy_set_header Connection ""; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; # 超时设置 proxy_read_timeout 3600s; } # API代理配置 location /api { proxy_pass http://backend_api; rewrite ^.+mock-server/?(.*)$ /$1 break; proxy_http_version 1.1; proxy_buffering off; proxy_cache off; proxy_redirect off; proxy_set_header Connection ""; proxy_set_header Upgrade $http_upgrade; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header Host $http_host; proxy_set_header X-Nginx-Proxy true; # 超时设置 proxy_read_timeout 600s; } # 图片类静态资源 location ~* \.(png|jpg|jpeg|gif|ico|svg)$ { expires 1y; add_header Cache-Control "public, immutable"; } # JS 和 CSS 静态资源缓存(排除 /api/v1 路径) location ~* ^/(?!api/v1).*\.(js|css)$ { try_files $uri =404; expires 30d; add_header Cache-Control "public"; add_header Vary Accept-Encoding; } # assets目录 location /assets { expires 1y; add_header Cache-Control "public, immutable"; } # 站点图标 location /api/v1/site/icon/ { # 站点图标缓存 proxy_cache my_cache; # 缓存响应码为200和302的请求1小时 proxy_cache_valid 200 302 1h; # 缓存其他响应码的请求5分钟 proxy_cache_valid any 5m; # 缓存键的生成规则 proxy_cache_key "$scheme$request_method$host$request_uri"; proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504; # 向后端API转发请求 proxy_pass http://backend_api; } ================================================ FILE: docker/nginx.template.conf ================================================ user moviepilot; worker_processes auto; pid /var/run/nginx.pid; worker_cpu_affinity auto; events { worker_connections 1024; } http { # 设置缓存路径和缓存区大小 proxy_cache_path /tmp levels=1:2 keys_zone=my_cache:10m max_size=100m inactive=60m use_temp_path=off; sendfile on; keepalive_timeout 3600; client_max_body_size ${NGINX_CLIENT_MAX_BODY_SIZE}; gzip on; gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript; gzip_proxied any; gzip_min_length 256; gzip_vary on; gzip_comp_level 6; # HTTP server { include /etc/nginx/mime.types; default_type application/octet-stream; listen ${NGINX_PORT}; listen [::]:${NGINX_PORT}; server_name moviepilot; # 公共配置 include common.conf; } # HTTPS ${HTTPS_SERVER_CONF} upstream backend_api { # 后端API的地址和端口 server 127.0.0.1:${PORT}; # 可以添加更多后端服务器作为负载均衡 } } ================================================ FILE: docker/update.sh ================================================ #!/bin/bash # shellcheck shell=bash # shellcheck disable=SC2086 # shellcheck disable=SC2144 Green="\033[32m" Red="\033[31m" Yellow='\033[33m' Font="\033[0m" INFO="[${Green}INFO${Font}]" ERROR="[${Red}ERROR${Font}]" WARN="[${Yellow}WARN${Font}]" function INFO() { echo -e "${INFO} ${1}" } function ERROR() { echo -e "${ERROR} ${1}" } function WARN() { echo -e "${WARN} ${1}" } # 设置虚拟环境路径(兼容群晖等系统必须这样配置) VENV_PATH="${VENV_PATH:-/opt/venv}" export PATH="${VENV_PATH}/bin:$PATH" # 下载及解压 function download_and_unzip() { local retries=0 local max_retries=3 local url="$1" local target_dir="$2" INFO "→ 正在下载 ${url}..." while [ $retries -lt $max_retries ]; do if curl ${CURL_OPTIONS} "${url}" ${CURL_HEADERS} | busybox unzip -d ${TMP_PATH} - > /dev/null; then if [ -e ${TMP_PATH}/MoviePilot-* ]; then mv ${TMP_PATH}/MoviePilot-* ${TMP_PATH}/"${target_dir}" fi break else WARN "下载 ${url} 失败,正在进行第 $((retries + 1)) 次重试..." retries=$((retries + 1)) fi done if [ $retries -eq $max_retries ]; then ERROR "下载 ${url} 失败,已达到最大重试次数!" return 1 else return 0 fi } # 下载程序资源,$1: 后端版本路径 function install_backend_and_download_resources() { # 更新后端程序 if ! download_and_unzip "${GITHUB_PROXY}https://github.com/jxxghp/MoviePilot/archive/refs/${1}" "App"; then WARN "后端程序下载失败,继续使用旧的程序来启动..." return 1 fi INFO "后端程序下载成功" # 检查依赖是否有变化 INFO "→ 检查依赖变化..." if [ -f "${TMP_PATH}/App/requirements.in" ]; then if ! cmp -s /app/requirements.in "${TMP_PATH}/App/requirements.in"; then INFO "检测到依赖变化,正在更新虚拟环境..." # 备份当前requirements.txt cp /app/requirements.txt /tmp/requirements.txt.backup # 复制新的requirements.in cp "${TMP_PATH}/App/requirements.in" /app/requirements.in # 重新编译依赖 if ! ${VENV_PATH}/bin/pip-compile /app/requirements.in; then ERROR "依赖编译失败,恢复原依赖" cp /tmp/requirements.txt.backup /app/requirements.txt return 1 fi # 安装新依赖 if ! ${VENV_PATH}/bin/pip install ${PIP_OPTIONS} --root-user-action=ignore -r /app/requirements.txt; then ERROR "依赖安装失败,恢复原依赖" cp /tmp/requirements.txt.backup /app/requirements.txt return 1 fi INFO "依赖更新成功" else INFO "依赖无变化,跳过依赖更新" fi else WARN "未找到requirements.in文件,跳过依赖检查" fi # 如果是"heads/v2.zip",则查找v2开头的最新版本号 if [[ "${1}" == "heads/v2.zip" ]]; then INFO "→ 正在获取前端最新版本号..." # 获取所有发布的版本列表,并筛选出以v2开头的版本号 releases=$(curl ${CURL_OPTIONS} "https://api.github.com/repos/jxxghp/MoviePilot-Frontend/releases" ${CURL_HEADERS} | jq -r '.[].tag_name' | grep "^v2\.") if [ -z "$releases" ]; then WARN "未找到任何v2前端版本,继续启动..." return 1 else # 找到最新的v2版本 frontend_version=$(echo "$releases" | sort -V | tail -n 1) fi INFO "前端最新版本号:${frontend_version}" else INFO "→ 正在获取前端版本号..." # 从后端文件中读取前端版本号 frontend_version=$(sed -n "s/^FRONTEND_VERSION\s*=\s*'\([^']*\)'/\1/p" ${TMP_PATH}/App/version.py) if [[ "${frontend_version}" != *v* ]]; then WARN "前端版本号获取失败,继续启动..." return 1 fi INFO "前端版本号:${frontend_version}" fi # 更新前端程序 if ! download_and_unzip "${GITHUB_PROXY}https://github.com/jxxghp/MoviePilot-Frontend/releases/download/${frontend_version}/dist.zip" "dist"; then WARN "前端程序下载失败,继续使用旧的程序来启动..." return 1 fi INFO "前端程序下载成功" # 备份插件目录 INFO "→ 正在备份插件目录..." rm -rf /plugins mkdir -p /plugins cp -a /app/app/plugins/* /plugins/ rm -f /plugins/__init__.py # 备份站点资源 INFO "→ 正在备份站点资源目录..." rm -rf /resources_bakcup mkdir /resources_bakcup cp -a /app/app/helper/user.sites.v2.bin /resources_bakcup cp -a /app/app/helper/sites.cp* /resources_bakcup # 清空程序目录 rm -rf /app mkdir -p /app # 复制新后端程序 cp -a ${TMP_PATH}/App/* /app/ # 复制新前端程序 rm -rf /public mkdir -p /public cp -a ${TMP_PATH}/dist/* /public/ INFO "程序部分更新成功,前端版本:${frontend_version},后端版本:${1}" # 恢复插件目录 cp -a /plugins/* /app/app/plugins/ # 更新站点资源 INFO "→ 开始更新站点资源..." if ! download_and_unzip "${GITHUB_PROXY}https://github.com/jxxghp/MoviePilot-Resources/archive/refs/heads/main.zip" "Resources"; then cp -a /resources_bakcup/* /app/app/helper/ rm -rf /resources_bakcup WARN "站点资源下载失败,继续使用旧的资源来启动..." return 1 fi # 复制新站点资源 cp -a ${TMP_PATH}/Resources/resources.v2/* /app/app/helper/ INFO "站点资源更新成功" # 清理临时目录 rm -rf "${TMP_PATH}" return 0 } function test_connectivity_pip() { ${VENV_PATH}/bin/pip uninstall -y pip-hello-world > /dev/null 2>&1 case "$1" in 0) if [[ -n "${PIP_PROXY}" ]]; then if ${VENV_PATH}/bin/pip install -i ${PIP_PROXY} pip-hello-world > /dev/null 2>&1; then PIP_OPTIONS="-i ${PIP_PROXY}" PIP_LOG="镜像代理模式" return 0 fi fi return 1 ;; 1) if [[ -n "${PROXY_HOST}" ]]; then if ${VENV_PATH}/bin/pip install --proxy=${PROXY_HOST} pip-hello-world > /dev/null 2>&1; then PIP_OPTIONS="--proxy=${PROXY_HOST}" PIP_LOG="全局代理模式" return 0 fi fi return 1 ;; 2) PIP_OPTIONS="" PIP_LOG="不使用代理" return 0 ;; esac } # 测试Github连通性 function test_connectivity_github() { case "$1" in 0) if [[ -n "${GITHUB_PROXY}" ]]; then if curl -sL "${GITHUB_PROXY}https://raw.githubusercontent.com/jxxghp/MoviePilot/main/README.md" > /dev/null 2>&1; then GITHUB_LOG="镜像代理模式" return 0 fi fi return 1 ;; 1) if [[ -n "${PROXY_HOST}" ]]; then if curl -sL -x ${PROXY_HOST} https://raw.githubusercontent.com/jxxghp/MoviePilot/main/README.md > /dev/null 2>&1; then CURL_OPTIONS="-sL -x ${PROXY_HOST}" GITHUB_LOG="全局代理模式" return 0 fi fi return 1 ;; 2) CURL_OPTIONS="-sL" GITHUB_LOG="不使用代理" return 0 ;; esac } # 版本号比较 function compare_versions() { local v1="$1" local v2="$2" # 去掉开头的 v 或 V v1="${v1#[vV]}" v2="${v2#[vV]}" local current_ver_parts=() local release_ver_parts=() IFS='.-' read -ra current_ver_parts <<< "$v1" IFS='.-' read -ra release_ver_parts <<< "$v2" local i local current_ver local release_ver for ((i = 0; i < ${#current_ver_parts[@]} || i < ${#release_ver_parts[@]}; i++)); do # 版本号不足位补 0 local current_ver_part="${current_ver_parts[i]:-0}" local release_ver_part="${release_ver_parts[i]:-0}" current_ver=$(get_priority "$current_ver_part") release_ver=$(get_priority "$release_ver_part") # 任意一个为-5,不在合法版本号内,无法比较 if (( current_ver == -5 || release_ver == -5 )); then ERROR "存在不合法版本号,无法判断,跳过更新步骤..." return 1 else if (( current_ver > release_ver )); then WARN "当前版本高于远程版本,跳过更新步骤..." return 1 elif (( current_ver < release_ver )); then INFO "发现新版本,开始自动升级..." install_backend_and_download_resources "tags/$2.zip" return 0 else continue fi fi done WARN "当前版本已是最新版本,跳过更新步骤..." } # 优先级转换 function get_priority() { local version="$1" if [[ $version =~ ^[0-9]+$ ]]; then echo $version else case $version in "stable") echo -1 ;; "rc") echo -2 ;; "beta") echo -3 ;; "alpha") echo -4 ;; # 非数字的不合法版本号 *) echo -5 ;; esac fi } if [[ "${MOVIEPILOT_AUTO_UPDATE}" = "true" ]] || [[ "${MOVIEPILOT_AUTO_UPDATE}" = "release" ]] || [[ "${MOVIEPILOT_AUTO_UPDATE}" = "dev" ]]; then TMP_PATH=$(mktemp -d) if [ ! -d "${TMP_PATH}" ]; then # 如果自动生成 tmp 文件夹失败则手动指定,避免出现数据丢失等情况 TMP_PATH=/tmp/mp_update_path if [ -d /tmp/mp_update_path ]; then rm -rf /tmp/mp_update_path fi mkdir -p /tmp/mp_update_path fi # 优先级:镜像站 > 全局 > 不代理 # pip retries=0 while true; do if test_connectivity_pip ${retries}; then break else retries=$((retries + 1)) fi done # Github retries=0 while true; do if test_connectivity_github ${retries}; then break else retries=$((retries + 1)) fi done INFO "PIP:${PIP_LOG},Github:${GITHUB_LOG}" if [ -n "${GITHUB_TOKEN}" ]; then CURL_HEADERS="--oauth2-bearer ${GITHUB_TOKEN}" else CURL_HEADERS="" fi if [ "${MOVIEPILOT_AUTO_UPDATE}" = "dev" ]; then INFO "Dev 更新模式" install_backend_and_download_resources "heads/v2.zip" else INFO "Release 更新模式" old_version=$(grep -m -1 "^\s*APP_VERSION\s*=\s*" /app/version.py | tr -d '\r\n' | awk -F'#' '{print $1}' | sed 's/^[[:space:]]*//; s/[[:space:]]*$//') if [[ "${old_version}" == *APP_VERSION* ]]; then current_version=$(echo "${old_version}" | sed -rn "s/APP_VERSION\s*=\s*['\"](.*)['\"]/\1/gp") INFO "当前版本号:${current_version}" # 获取所有发布的版本列表,并筛选出以v2开头的版本号 releases=$(curl ${CURL_OPTIONS} "https://api.github.com/repos/jxxghp/MoviePilot/releases" ${CURL_HEADERS} | jq -r '.[].tag_name' | grep "^v2\.") if [ -z "$releases" ]; then WARN "未找到任何v2后端版本,继续启动..." else # 找到最新的v2版本 latest_v2=$(echo "$releases" | sort -V | tail -n 1) INFO "最新的v2后端版本号:${latest_v2}" # 使用版本号比较函数进行比较,并下载最新版本 compare_versions "${current_version}" "${latest_v2}" fi else WARN "当前版本号获取失败,继续启动..." fi fi if [ -d "${TMP_PATH}" ]; then rm -rf "${TMP_PATH}" fi elif [[ "${MOVIEPILOT_AUTO_UPDATE}" = "false" ]]; then INFO "程序自动升级已关闭,如需自动升级请在创建容器时设置环境变量:MOVIEPILOT_AUTO_UPDATE=release" else INFO "MOVIEPILOT_AUTO_UPDATE 变量设置错误" fi ================================================ FILE: docs/development-setup.md ================================================ ## 开发环境设置指南 本文档旨在帮助开发者快速设置开发环境,并介绍如何使用 `pip-tools` 管理依赖项和使用 `safety` 进行安全检查。 ### 环境准备 在开始之前,请确保您的系统已安装以下软件: - **Python 3.12 或更高版本** (暂时兼容 3.11 ,推荐使用 3.12+) - **pip** (Python 包管理器) - **Git** (用于版本控制) ### 1. 创建虚拟环境 在项目根目录下创建并激活虚拟环境: - 在 Windows 上: ```bash python -m venv venv .\venv\Scripts\activate ``` - 在 macOS/Linux 上: ```bash python3 -m venv venv source venv/bin/activate ``` 虚拟环境确保项目的依赖项与系统全局环境隔离,防止冲突。 ### 2. 使用 pip-tools 管理依赖项 我们使用 `pip-tools` 来管理项目的 Python 依赖项,这有助于保持 `requirements.txt` 文件的一致性和更新性。 #### 安装 pip-tools 首先,您需要安装 `pip-tools` 以便管理依赖项: ```bash pip install pip-tools ``` #### 管理依赖项 1. **修改 `requirements.in` 文件**: `requirements.in` 文件是项目依赖项的源文件。要添加或更新依赖项,请直接编辑该文件。 2. **更新特定的依赖项**: 如果你只想更新 `requirements.in` 中的某个特定依赖包,而不影响其他依赖项,可以使用 `--upgrade-package` 选项,指定要升级的包: ```bash pip-compile --upgrade-package requirements.in ``` 例如,要只升级 `requests` 这个包,你可以运行以下命令: ```bash pip-compile --upgrade-package requests requirements.in ``` 3. **全量更新依赖项**: 如果你想更新 `requirements.in` 中的所有依赖包,运行以下命令生成或更新 `requirements.txt` 文件: ```bash pip-compile requirements.in ``` 这将根据 `requirements.in` 中指定的依赖项生成一个锁定的 `requirements.txt` 文件。 4. **安装依赖项**: 使用以下命令安装 `requirements.txt` 文件中列出的依赖项: ```bash pip install -r requirements.txt ``` ### 3. 运行安全检查 我们使用 `safety` 工具来检查依赖项中是否存在已知的安全漏洞。请确保在每次更新依赖项后都运行安全检查,以确保项目的安全性。 #### 安装 safety 您可以使用以下命令安装 `safety`: ```bash pip install safety ``` #### 执行安全检查 运行以下命令以检查 `requirements.txt` 文件中列出的依赖项是否存在安全漏洞: ```bash safety check -r requirements.txt --policy-file=safety.policy.yml > safety_report.txt ``` 这将生成一个名为 `safety_report.txt` 的报告文件,您可以查看其中的漏洞报告并进行相应处理。 ### 4. 提交代码前的检查 在提交代码之前,请确保完成以下步骤: 1. **确保依赖项已更新**:如果您对 `requirements.in` 进行了更改,请重新生成 `requirements.txt` 并安装依赖项。 2. **运行安全检查**:确保 `safety` 检查通过,没有新的安全漏洞。 3. **运行测试**:如果项目中包含测试,请确保所有测试都通过。运行以下命令以执行测试: ```bash pytest ``` ### 5. 参考资源 - [pip-tools 官方文档](https://github.com/jazzband/pip-tools) - [safety 官方文档](https://pyup.io/safety/) ================================================ FILE: docs/mcp-api.md ================================================ # MoviePilot MCP (Model Context Protocol) API 文档 MoviePilot 实现了标准的 **Model Context Protocol (MCP)**,允许 AI 智能体(如 Claude, GPT 等)直接调用 MoviePilot 的功能进行媒体管理、搜索、订阅和下载。 ## 1. 基础信息 * **基础路径**: `/api/v1/mcp` * **协议版本**: `2025-11-25, 2025-06-18, 2024-11-05` * **传输协议**: HTTP (JSON-RPC 2.0) * **认证方式**: * Header: `X-API-KEY: <你的API_KEY>` * Query: `?apikey=<你的API_KEY>` ## 2. 标准 MCP 协议 (JSON-RPC 2.0) ### 端点 **POST** `/api/v1/mcp` ### 支持的方法 - `initialize`: 初始化会话,协商协议版本和能力。 - `notifications/initialized`: 客户端确认初始化完成。 - `tools/list`: 获取可用工具列表。 - `tools/call`: 调用特定工具。 - `ping`: 连接存活检测。 --- ## 4. 客户端配置示例 ### Claude Desktop (Anthropic) 在Claude Desktop的配置文件中添加MoviePilot的MCP服务器配置: **macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json` **Windows**: `%APPDATA%\Claude\claude_desktop_config.json` 使用请求头方式: ```json { "mcpServers": { "moviepilot": { "url": "http://localhost:3001/api/v1/mcp", "headers": { "X-API-KEY": "your_api_key_here" } } } } ``` 或使用查询参数方式: ```json { "mcpServers": { "moviepilot": { "url": "http://localhost:3001/api/v1/mcp?apikey=your_api_key_here" } } } ``` ## 5. 错误码说明 | 错误码 | 消息 | 说明 | | :--- | :--- | :--- | | -32700 | Parse error | JSON 格式错误 | | -32600 | Invalid Request | 无效的 JSON-RPC 请求 | | -32601 | Method not found | 方法不存在 | | -32602 | Invalid params | 参数验证失败 | | -32002 | Session not found | 会话不存在或已过期 | | -32003 | Not initialized | 会话未完成初始化流程 | | -32603 | Internal error | 服务器内部错误 | ## 6. RESTful API 所有工具相关的API端点都在 `/api/v1/mcp` 路径下(保持向后兼容)。 ### 1. 列出所有工具 **GET** `/api/v1/mcp/tools` 获取所有可用的MCP工具列表。 **认证**: 需要API KEY,在请求头中添加 `X-API-KEY: ` 或在查询参数中添加 `apikey=` **响应示例**: ```json [ { "name": "add_subscribe", "description": "Add media subscription to create automated download rules...", "inputSchema": { "type": "object", "properties": { "title": { "type": "string", "description": "The title of the media to subscribe to" }, "year": { "type": "string", "description": "Release year of the media" }, ... }, "required": ["title", "year", "media_type"] } }, ... ] ``` ### 2. 调用工具 **POST** `/api/v1/mcp/tools/call` 调用指定的MCP工具。 **认证**: 需要API KEY,在请求头中添加 `X-API-KEY: ` 或在查询参数中添加 `apikey=` **请求体**: ```json { "tool_name": "add_subscribe", "arguments": { "title": "流浪地球", "year": "2019", "media_type": "movie" } } ``` **响应示例**: ```json { "success": true, "result": "成功添加订阅:流浪地球 (2019)", "error": null } ``` **错误响应示例**: ```json { "success": false, "result": null, "error": "调用工具失败: 参数验证失败" } ``` ### 3. 获取工具详情 **GET** `/api/v1/mcp/tools/{tool_name}` 获取指定工具的详细信息。 **认证**: 需要API KEY,在请求头中添加 `X-API-KEY: ` 或在查询参数中添加 `apikey=` **路径参数**: - `tool_name`: 工具名称 **响应示例**: ```json { "name": "add_subscribe", "description": "Add media subscription to create automated download rules...", "inputSchema": { "type": "object", "properties": { "title": { "type": "string", "description": "The title of the media to subscribe to" }, ... }, "required": ["title", "year", "media_type"] } } ``` ### 4. 获取工具参数Schema **GET** `/api/v1/mcp/tools/{tool_name}/schema` 获取指定工具的参数Schema(JSON Schema格式)。 **认证**: 需要API KEY,在请求头中添加 `X-API-KEY: ` 或在查询参数中添加 `apikey=` **路径参数**: - `tool_name`: 工具名称 **响应示例**: ```json { "type": "object", "properties": { "title": { "type": "string", "description": "The title of the media to subscribe to" }, "year": { "type": "string", "description": "Release year of the media" }, ... }, "required": ["title", "year", "media_type"] } ``` ================================================ FILE: docs/postgresql-setup.md ================================================ # PostgreSQL 数据库配置指南 MoviePilot 现在支持 PostgreSQL 数据库,您可以根据需要选择使用 SQLite 或 PostgreSQL。 ## 配置选项 ### 1. 数据库类型选择 在 `config/app.env` 文件中设置: ```bash # 使用 SQLite(默认) DB_TYPE=sqlite # 使用 PostgreSQL DB_TYPE=postgresql ``` ### 2. PostgreSQL 配置参数 当 `DB_TYPE=postgresql` 时,以下配置生效: ```bash # PostgreSQL 主机地址 DB_POSTGRESQL_HOST=localhost # PostgreSQL 端口 DB_POSTGRESQL_PORT=5432 # PostgreSQL 数据库名 DB_POSTGRESQL_DATABASE=moviepilot # PostgreSQL 用户名 DB_POSTGRESQL_USERNAME=moviepilot # PostgreSQL 密码 DB_POSTGRESQL_PASSWORD=moviepilot # PostgreSQL 连接池大小 DB_POSTGRESQL_POOL_SIZE=20 # PostgreSQL 连接池溢出数量 DB_POSTGRESQL_MAX_OVERFLOW=30 ``` ## Docker 部署 ### 使用外部 PostgreSQL 如果您想使用外部的 PostgreSQL 服务: 1. 确保外部 PostgreSQL 服务已启动并可访问 2. 设置环境变量指向外部服务: ```bash DB_TYPE=postgresql DB_POSTGRESQL_HOST=your-postgresql-host DB_POSTGRESQL_PORT=5432 DB_POSTGRESQL_DATABASE=moviepilot DB_POSTGRESQL_USERNAME=your-username DB_POSTGRESQL_PASSWORD=your-password ``` ## 数据迁移 ### 从 SQLite 迁移到 PostgreSQL 1. 备份现有的 SQLite 数据库文件(`config/user.db`) 2. 修改配置为 PostgreSQL 3. 启动应用,数据库表会自动创建 4. 使用数据库迁移工具或手动导入数据 #### 注意事项 完成数据迁移后需要对postgresql中的表进行索引初始值进行更新,否则会出现唯一索引已存在的异常 例如: ```json 【EventType.SiteUpdated 事件处理出错】 SiteChain.cache_site_userdata (psycopg2.errors.UniqueViolation) duplicate key value violates unique constraint "siteuserdata_pkey" DETAIL: Key (id)=(18) already exists. [SQL: INSERT INTO siteuserdata (domain, name, username, userid, user_level, join_at, bonus, upload, download, ratio, seeding, leeching, seeding_size, leeching_size, seeding_info, message_unread, message_unread_contents, err_msg, updated_day, updated_time) VALUES (%(domain)s, %(name)s, %(username)s, %(userid)s, %(user_level)s, %(join_at)s, %(bonus)s, %(upload)s, %(download)s, %(ratio)s, %(seeding)s, %(leeching)s, %(seeding_size)s, %(leeching_size)s, %(seeding_info)s::JSON, %(message_unread)s, %(message_unread_contents)s::JSON, %(err_msg)s, %(updated_day)s, %(updated_time)s) RETURNING siteuserdata.id] [parameters: {'domain': 'btschool.club', 'name': '学校', 'username': None, 'userid': None, 'user_level': None, 'join_at': None, 'bonus': 0.0, 'upload': 0, 'download': 0, 'ratio': 0.0, 'seeding': 0, 'leeching': 0, 'seeding_size': 0, 'leeching_size': 0, 'seeding_info': '[]', 'message_unread': 0, 'message_unread_contents': '[]', 'err_msg': '未检测到已登陆,请检查cookies是否过期', 'updated_day': '2025-08-22', 'updated_time': '09:52:01'}] (Background on this error at: https://sqlalche.me/e/20/gkpj) ``` 需要对每一个表分别执行下面的语句(下面的SQL以`workflowc`数据表为例,每张表请自行修改,其中`user`表因为关键字原因,应该写成`public.user`的方式): ```sql DO $$ DECLARE max_id INTEGER; BEGIN -- 查询最大 ID 值 SELECT COALESCE(MAX(id), 0) INTO max_id FROM workflow; -- 调整序列 EXECUTE format('ALTER SEQUENCE workflow_id_seq RESTART WITH %s', max_id + 1); END $$; ``` ### 从 PostgreSQL 迁移到 SQLite 1. 导出 PostgreSQL 数据 2. 修改配置为 SQLite 3. 启动应用,数据库表会自动创建 4. 导入数据到 SQLite ## 数据备份 ### PostgreSQL 数据备份 PostgreSQL 数据存储在 `${CONFIG_DIR}/postgresql/` 目录中,您可以通过以下方式进行备份: #### 1. 文件级备份 ```bash # 备份整个PostgreSQL数据目录 tar -czf postgresql_backup_$(date +%Y%m%d_%H%M%S).tar.gz config/postgresql/ ``` #### 2. 数据库级备份 ```bash # 进入容器 docker exec -it moviepilot bash # 使用pg_dump备份 pg_dump -h localhost -U moviepilot -d moviepilot > /config/moviepilot_backup.sql # 或使用pg_dumpall备份所有数据库 pg_dumpall -h localhost -U moviepilot > /config/all_databases_backup.sql ``` #### 3. 恢复数据 ```bash # 恢复单个数据库 psql -h localhost -U moviepilot -d moviepilot < /config/moviepilot_backup.sql # 恢复所有数据库 psql -h localhost -U moviepilot < /config/all_databases_backup.sql ``` ## 性能优化 ### PostgreSQL 优化建议 1. **连接池配置**: - 根据应用负载调整 `DB_POSTGRESQL_POOL_SIZE` - 设置合适的 `DB_POSTGRESQL_MAX_OVERFLOW` 2. **数据库配置**: - 调整 `shared_buffers` - 配置 `work_mem` - 设置合适的 `maintenance_work_mem` 3. **索引优化**: - 为常用查询字段添加索引 - 定期执行 `VACUUM` 和 `ANALYZE` ## 故障排除 ### 常见问题 1. **连接失败**: - 检查 PostgreSQL 服务是否启动 - 验证连接参数是否正确 - 确认网络连接和防火墙设置 2. **权限问题**: - 确保用户有足够的数据库权限 - 检查 `pg_hba.conf` 配置 3. **性能问题**: - 监控连接池使用情况 - 检查慢查询日志 - 优化数据库配置 ### 日志查看 PostgreSQL 相关日志可以在以下位置查看: - Docker 容器:`${CONFIG_DIR}/postgresql/logs/` - 系统日志:`journalctl -u postgresql` ## 注意事项 1. **兼容性**:PostgreSQL 支持从 MoviePilot v2.0 开始 2. **备份**:建议定期备份数据库 3. **版本**:建议使用 PostgreSQL 12 或更高版本 4. **字符集**:确保使用 UTF-8 字符集 ## 技术支持 如果遇到问题,请: 1. 查看应用日志 2. 检查 PostgreSQL 日志 3. 在 GitHub Issues 中报告问题 ================================================ FILE: frozen.spec ================================================ # -*- mode: python ; coding: utf-8 -*- def collect_pkg_data(package: str, include_py_files: bool = False, subdir: str = None): """ Collect all data files from the given package. """ from pathlib import Path from PyInstaller.utils.hooks import get_package_paths, PY_IGNORE_EXTENSIONS from PyInstaller.building.datastruct import TOC data_toc = TOC() # Accept only strings as packages. if type(package) is not str: raise ValueError try: pkg_base, pkg_dir = get_package_paths(package) except ValueError: return data_toc if subdir: pkg_path = Path(pkg_dir) / subdir else: pkg_path = Path(pkg_dir) # Walk through all file in the given package, looking for data files. if not pkg_path.exists(): return data_toc for file in pkg_path.rglob('*'): if file.is_file(): extension = file.suffix if not include_py_files and (extension in PY_IGNORE_EXTENSIONS): continue data_toc.append((str(file.relative_to(pkg_base)), str(file), 'DATA')) return data_toc def collect_local_submodules(package: str): """ Collect all local submodules from the given package. """ import os from pathlib import Path package_dir = Path(package.replace('.', os.sep)) submodules = [package] # Walk through all file in the given package, looking for data files. if not package_dir.exists(): return [] for file in package_dir.rglob('*.py'): if file.name == '__init__.py': module = f"{file.parent}".replace(os.sep, '.') else: module = f"{file.parent}.{file.stem}".replace(os.sep, '.') if module not in submodules: submodules.append(module) return submodules hiddenimports = [ 'passlib.handlers.bcrypt', 'app.modules', 'app.plugins', ] + collect_local_submodules('app.modules') + collect_local_submodules('app.plugins') block_cipher = None a = Analysis( ['app/main.py'], pathex=[], binaries=[], datas=[], hiddenimports=hiddenimports, hookspath=[], hooksconfig={}, runtime_hooks=[], excludes=[], noarchive=False, ) pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher) exe = EXE( pyz, a.scripts, a.binaries, a.zipfiles, a.datas + [('./app.ico', './app.ico', 'DATA')], collect_pkg_data('config'), collect_pkg_data('nginx'), collect_pkg_data('cf_clearance'), collect_pkg_data('zhconv'), collect_pkg_data('cn2an'), collect_pkg_data('Pinyin2Hanzi'), collect_pkg_data('database', include_py_files=True), collect_pkg_data('app.helper'), [], name='MoviePilot', debug=False, bootloader_ignore_signals=False, strip=False, upx=True, upx_exclude=[], runtime_tmpdir=None, console=False, disable_windowed_traceback=False, argv_emulation=False, target_arch=None, codesign_identity=None, entitlements_file=None, icon="app.ico" ) ================================================ FILE: requirements.in ================================================ Cython~=3.1.2 pydantic>=2.0.0,<3.0.0 pydantic-settings>=2.0.0,<3.0.0 SQLAlchemy~=2.0.41 uvicorn~=0.34.3 fastapi~=0.115.14 passlib~=1.7.4 PyJWT~=2.10.1 python-multipart~=0.0.9 aiofiles~=24.1.0 aioshutil~=1.5 alembic~=1.16.2 bcrypt~=4.0.1 regex~=2024.11.6 cn2an~=0.5.19 dateparser~=1.2.2 python-dateutil~=2.8.2 zhconv~=1.4.3 anitopy~=2.1.1 requests[socks]~=2.32.4 urllib3~=2.5.0 lxml~=6.0.0 pyquery~=2.0.1 ruamel.yaml~=0.18.14 APScheduler~=3.11.0 cryptography~=45.0.4 pytz~=2025.2 pycryptodome~=3.23.0 qbittorrent-api==2025.5.0 plexapi~=4.17.0 transmission-rpc~=4.3.0 Jinja2~=3.1.6 pyparsing~=3.2.3 func_timeout==4.3.5 bs4~=0.0.2 beautifulsoup4~=4.13.4 pillow~=11.2.1 pillow-avif-plugin~=1.5.2 pyTelegramBotAPI~=4.27.0 telegramify-markdown~=0.5.2 playwright~=1.53.0 cf_clearance~=0.31.0 torrentool~=1.2.0 slack-bolt~=1.23.0 slack-sdk~=3.35.0 discord.py==2.6.4 chardet~=5.2.0 starlette~=0.46.2 PyVirtualDisplay~=3.0 psutil~=7.0.0 python-dotenv~=1.1.1 python-hosts~=1.1.2 watchdog~=6.0.0 watchfiles~=1.1.0 cacheout~=0.16.0 click~=8.2.1 requests-cache~=1.2.1 parse~=1.20.2 docker~=7.1.0 pywin32==310; platform_system == "Windows" cachetools~=6.1.0 fast-bencode~=1.1.7 pystray~=0.19.5 pyotp~=2.9.0 webauthn~=2.7.0 Pinyin2Hanzi~=0.1.1 pywebpush~=2.0.3 aiopathlib~=0.6.0 asynctempfile~=0.5.0 aiosqlite~=0.21.0 psycopg2-binary~=2.9.10 asyncpg~=0.30.0 jieba~=0.42.1 rsa~=4.9 redis~=6.2.0 async_timeout~=5.0.1; python_full_version < "3.11.3" packaging~=25.0 oss2~=2.19.1 tqdm~=4.67.1 setuptools~=78.1.0 pympler~=1.1 smbprotocol~=1.15.0 setproctitle~=1.3.6 httpx[socks]~=0.28.1 langchain~=0.3.27 langchain-core~=0.3.76 langchain-community~=0.3.29 langchain-openai~=0.3.33 langchain-google-genai~=2.0.10 langchain-deepseek~=0.1.4 langchain-experimental~=0.3.4 openai~=1.108.2 google-generativeai~=0.8.5 ddgs~=9.10.0 websocket-client~=1.8.0 ================================================ FILE: requirements.txt ================================================ -r requirements.in ================================================ FILE: safety.policy.yml ================================================ security: ignore-unpinned-requirements: False ignore-vulnerabilities: 70612: reason: The official statement indicates that this vulnerability is not valid because users should use sandboxing when handling untrusted templates. 65532: reason: Legacy issue related to tvdbapi usage. 40100: reason: Legacy issue related to tvdbapi usage. 68094: reason: This vulnerability is resolved by upgrading `python-multipart` to version 0.0.9. 65293: reason: This vulnerability is resolved by upgrading `python-multipart` to version 0.0.9. 64930: reason: This vulnerability is resolved by upgrading `python-multipart` to version 0.0.9. ================================================ FILE: setup.py ================================================ from setuptools import setup, Extension from Cython.Build import cythonize import glob # 递归获取所有.py文件 sources = glob.glob("app/**/*.py", recursive=True) # 移除不需要编译的文件 sources.remove("app/main.py") # 配置编译参数(可选优化选项) extensions = [ Extension( name=path.replace("/", ".").replace(".py", ""), sources=[path], extra_compile_args=["-O3", "-ffast-math"], ) for path in sources ] setup( name="MoviePilot", author="jxxghp", ext_modules=cythonize( extensions, build_dir="build", compiler_directives={ "language_level": "3", "auto_pickle": False, "embedsignature": True, "annotation_typing": True, "infer_types": True, "binding": True, } ), script_args=["build_ext", "-j8", "--inplace"], ) ================================================ FILE: skills/moviepilot-cli/SKILL.md ================================================ --- name: moviepilot-cli description: Use this skill when the user wants to find, download, or subscribe to a movie or TV show (including anime); asks about download or subscription status; needs to check or organize the media library; or mentions MoviePilot directly. Covers the full media acquisition workflow via MoviePilot — searching TMDB, filtering and downloading torrents from PT indexer sites, managing subscriptions for automatic episode tracking, and handling library organization, site accounts, filter rules, and schedulers. --- # MoviePilot CLI > **Path note:** All script paths in this skill are relative to this skill file. Use `scripts/mp-cli.js` to interact with the MoviePilot backend. ## Discover Commands ```bash node scripts/mp-cli.js list # list all available commands node scripts/mp-cli.js show # show parameters, required fields, and usage ``` Always run `show ` before calling a command. Do not guess parameter names or argument formats. ## Command Groups | Category | Commands | | ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Media Search | search_media, recognize_media, query_media_detail, get_recommendations, search_person, search_person_credits | | Torrent | search_torrents, get_search_results | | Download | add_download, query_download_tasks, delete_download, query_downloaders | | Subscription | add_subscribe, query_subscribes, update_subscribe, delete_subscribe, search_subscribe, query_subscribe_history, query_popular_subscribes, query_subscribe_shares | | Library | query_library_exists, query_library_latest, transfer_file, scrape_metadata, query_transfer_history | | Files | list_directory, query_directory_settings | | Sites | query_sites, query_site_userdata, test_site, update_site, update_site_cookie | | System | query_schedulers, run_scheduler, query_workflows, run_workflow, query_rule_groups, query_episode_schedule, send_message | ## Gotchas - **Don't guess command parameters.** Parameter names vary per command and are not inferrable. Always run `show ` first. - **`search_torrents` results are cached server-side.** `get_search_results` reads from that cache — always run `search_torrents` first in the same session before filtering. - **Omitting `sites` uses the user's configured default sites**, not all available sites. Only call `query_sites` and pass `sites=` when the user explicitly asks for a specific site. - **TMDB season numbers don't always match fan-labeled seasons.** Anime and long-running shows often split one TMDB season into parts. Always validate with `query_media_detail` when the user mentions a specific season. - **`add_download` is irreversible without manual cleanup.** Always present torrent details and wait for explicit confirmation before calling it. - **`get_search_results` filter params are ANDed.** Combining multiple fields can silently exclude valid results. If results come back empty, drop the most restrictive filter and retry before reporting failure. - **`volume_factor` and `freedate_diff` indicate promotional status.** `volume_factor` describes the discount type (e.g. `免费` = free download, `2X` = double upload only, `2X免费` = free download + double upload, `普通` = no discount). `freedate_diff` is the remaining free window (e.g. `2天3小时`); empty means no active promotion. Always include both fields when presenting results — they are critical for the user to pick the best-value torrent. ## Common Workflows ### Search and Download ```bash # 1. Search TMDB to get tmdb_id node scripts/mp-cli.js search_media title="流浪地球2" media_type="movie" # [TV only, only if user specified a season] Validate season — see "Season Validation" section below node scripts/mp-cli.js query_media_detail tmdb_id=... media_type="tv" # 2. Search torrents using tmdb_id — results are cached server-side # Response includes available filter options (resolution, release group, etc.) # [Optional] If the user specifies sites, first run query_sites to get IDs, then pass them via sites param node scripts/mp-cli.js query_sites # get site IDs node scripts/mp-cli.js search_torrents tmdb_id=791373 media_type="movie" # use user's default sites node scripts/mp-cli.js search_torrents tmdb_id=791373 media_type="movie" sites='1,3' # override with specific sites # 3. Present ALL available filter_options to the user and ask which ones to apply # Show every field and its values — do not pre-select or omit any # e.g. "分辨率: 1080p, 2160p;字幕组: CMCT, PTer;请问需要筛选哪些条件?" # 4. Filter cached results based on user preferences and your own judgment # Filter params are ANDed — if results come back empty, drop the most restrictive field and retry node scripts/mp-cli.js get_search_results resolution='1080p' # [Optional] Re-check available filter options from cached results (same shape as search_torrents; returns filter options only) node scripts/mp-cli.js get_search_results show_filter_options=true # 5. Present ALL filtered results as a numbered list — do not pre-select or discard any # Show for each: index, title, size, seeders, resolution, release group, volume_factor, freedate_diff # Let the user pick by number; only then proceed to step 6 # 6. After user confirms selection, check library and subscriptions before downloading node scripts/mp-cli.js query_library_exists tmdb_id=123456 media_type="movie" node scripts/mp-cli.js query_subscribes tmdb_id=123456 # If already in library or subscribed, warn the user and ask for confirmation to proceed # 7. Add download # Single item: node scripts/mp-cli.js add_download torrent_url="abc1234:1" # Multiple items: node scripts/mp-cli.js add_download torrent_url="abc1234:1,def5678:2" ``` ### Add Subscription ```bash # 1. Search to get tmdb_id (required for accurate identification) node scripts/mp-cli.js search_media title="黑镜" media_type="tv" # 2. Subscribe — the system will auto-download new episodes node scripts/mp-cli.js add_subscribe title="黑镜" year="2011" media_type="tv" tmdb_id=42009 ``` ### Manage Subscriptions ```bash node scripts/mp-cli.js query_subscribes status=R # list active node scripts/mp-cli.js update_subscribe subscribe_id=123 resolution="1080p" # update filters node scripts/mp-cli.js search_subscribe subscribe_id=123 # search missing episodes node scripts/mp-cli.js delete_subscribe subscribe_id=123 # remove ``` ## Season Validation (only when user specifies a season) Skip this section if the user did not mention a specific season. **Step 1 — Verify the season exists:** ```bash node scripts/mp-cli.js query_media_detail tmdb_id= media_type="tv" ``` Check `season_info` against the season the user requested: - **Season exists:** use that season number directly, then proceed to torrent search. - **Season does not exist:** anime and long-running shows often split one TMDB season into multiple parts that fans call separate seasons. Use the latest available season number and continue to Step 2. **Step 2 — Identify the correct episode range:** ```bash node scripts/mp-cli.js query_episode_schedule tmdb_id= season= ``` Use `air_date` to find a block of recently-aired episodes that likely corresponds to what the user calls the missing season. If no such block exists, tell the user the content is unavailable. Otherwise, confirm the episode range with the user before proceeding to torrent search. ## Error Handling | Error | Resolution | | --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | No search results | Retry with an alternative title (e.g. English title). If still empty, ask the user to confirm the title or provide the TMDB ID directly. | | Download failure | Run `query_downloaders` to check downloader health, then `query_download_tasks` to check if the task already exists (duplicate tasks are rejected). If both are normal, report findings to the user, suggest checking storage space, and mention it may be a network error — suggest retrying later. | | Missing configuration | Ask the user for the backend host and API key. Once provided, run `node scripts/mp-cli.js -h -k ` (no command) to save the config persistently — subsequent commands will use it automatically. | ================================================ FILE: skills/moviepilot-cli/scripts/mp-cli.js ================================================ #!/usr/bin/env node 'use strict'; const fs = require('fs'); const os = require('os'); const path = require('path'); const http = require('http'); const https = require('https'); const SCRIPT_NAME = process.env.MP_SCRIPT_NAME || path.basename(process.argv[1] || 'mp-cli.js'); const CONFIG_DIR = path.join(os.homedir(), '.config', 'moviepilot_cli'); const CONFIG_FILE = path.join(CONFIG_DIR, 'config'); let commandsJson = []; let commandsLoaded = false; let optHost = ''; let optKey = ''; const envHost = process.env.MP_HOST || ''; const envKey = process.env.MP_API_KEY || ''; let mpHost = ''; let mpApiKey = ''; function fail(message) { console.error(message); process.exit(1); } function spacePad(text = '', targetCol = 0) { const spaces = text.length < targetCol ? targetCol - text.length + 2 : 2; return ' '.repeat(spaces); } function printBox(title, lines) { const rightPadding = 0; const contentWidth = lines.reduce((max, line) => Math.max(max, line.length), title.length) + rightPadding; const innerWidth = contentWidth + 2; const topLabel = `─ ${title}`; console.error(`┌${topLabel}${'─'.repeat(Math.max(innerWidth - topLabel.length, 0))}┐`); for (const line of lines) { console.error(`│ ${line}${' '.repeat(contentWidth - line.length)} │`); } console.error(`└${'─'.repeat(innerWidth)}┘`); } function readConfig() { let cfgHost = ''; let cfgKey = ''; if (!fs.existsSync(CONFIG_FILE)) { return { cfgHost, cfgKey }; } const content = fs.readFileSync(CONFIG_FILE, 'utf8'); for (const line of content.split(/\r?\n/)) { if (!line.trim() || /^\s*#/.test(line)) { continue; } const index = line.indexOf('='); if (index === -1) { continue; } const key = line.slice(0, index).replace(/\s+/g, ''); const value = line.slice(index + 1); if (key === 'MP_HOST') { cfgHost = value; } else if (key === 'MP_API_KEY') { cfgKey = value; } } return { cfgHost, cfgKey }; } function saveConfig(host, key) { fs.mkdirSync(CONFIG_DIR, { recursive: true }); fs.writeFileSync(CONFIG_FILE, `MP_HOST=${host}\nMP_API_KEY=${key}\n`, 'utf8'); fs.chmodSync(CONFIG_FILE, 0o600); } function loadConfig() { const { cfgHost: initialHost, cfgKey: initialKey } = readConfig(); let cfgHost = initialHost; let cfgKey = initialKey; if (optHost || optKey) { const nextHost = optHost || cfgHost; const nextKey = optKey || cfgKey; saveConfig(nextHost, nextKey); cfgHost = nextHost; cfgKey = nextKey; } mpHost = optHost || mpHost || envHost || cfgHost; mpApiKey = optKey || mpApiKey || envKey || cfgKey; } function normalizeType(schema = {}) { if (schema.type) { return schema.type; } if (Array.isArray(schema.anyOf)) { const candidate = schema.anyOf.find((item) => item && item.type && item.type !== 'null'); return candidate?.type || 'string'; } return 'string'; } function normalizeItemType(schema = {}) { const items = schema.items; if (!items) { return null; } if (items.type) { return items.type; } if (Array.isArray(items.anyOf)) { const candidate = items.anyOf.find((item) => item && item.type && item.type !== 'null'); return candidate?.type || null; } return null; } function normalizeCommand(tool = {}) { const properties = tool?.inputSchema?.properties || {}; const required = Array.isArray(tool?.inputSchema?.required) ? tool.inputSchema.required : []; const fields = Object.entries(properties) .filter(([fieldName]) => fieldName !== 'explanation') .map(([fieldName, schema]) => ({ name: fieldName, type: normalizeType(schema), description: schema?.description || '', required: required.includes(fieldName), item_type: normalizeItemType(schema), })); return { name: tool?.name, description: tool?.description || '', fields, }; } function request(method, targetUrl, headers = {}, body, timeout = 120000) { return new Promise((resolve, reject) => { let url; try { url = new URL(targetUrl); } catch (error) { reject(new Error(`Invalid URL: ${targetUrl}`)); return; } const transport = url.protocol === 'https:' ? https : http; const req = transport.request( { method, hostname: url.hostname, port: url.port || undefined, path: `${url.pathname}${url.search}`, headers, }, (res) => { const chunks = []; res.on('data', (chunk) => chunks.push(chunk)); res.on('end', () => { resolve({ statusCode: res.statusCode ? String(res.statusCode) : '', body: Buffer.concat(chunks).toString('utf8'), }); }); } ); req.setTimeout(timeout, () => { req.destroy(new Error(`Request timed out after ${timeout}ms`)); }); req.on('error', reject); if (body !== undefined) { req.write(body); } req.end(); }); } async function loadCommandsJson() { if (commandsLoaded) { return; } const { statusCode, body } = await request('GET', `${mpHost}/api/v1/mcp/tools`, { 'X-API-KEY': mpApiKey, }); if (statusCode !== '200') { console.error(`Error: failed to load command definitions (HTTP ${statusCode || 'unknown'})`); process.exit(1); } let response; try { response = JSON.parse(body); } catch { fail('Error: backend returned invalid JSON for command definitions'); } commandsJson = Array.isArray(response) ? response.map((tool) => normalizeCommand(tool)) : []; commandsLoaded = true; } async function loadCommandJson(commandName) { const { statusCode, body } = await request('GET', `${mpHost}/api/v1/mcp/tools/${commandName}`, { 'X-API-KEY': mpApiKey, }); if (statusCode === '404') { console.error(`Error: command '${commandName}' not found`); console.error(`Run 'node ${SCRIPT_NAME} list' to see available commands`); process.exit(1); } if (statusCode !== '200') { console.error(`Error: failed to load command definition (HTTP ${statusCode || 'unknown'})`); process.exit(1); } let response; try { response = JSON.parse(body); } catch { fail(`Error: backend returned invalid JSON for command '${commandName}'`); } return normalizeCommand(response); } function ensureConfig() { loadConfig(); let ok = true; if (!mpHost) { console.error('Error: backend host is not configured.'); console.error(' Use: -h HOST to set it'); console.error(' Or set environment variable: MP_HOST=http://localhost:3001'); ok = false; } if (!mpApiKey) { console.error('Error: API key is not configured.'); console.error(' Use: -k KEY to set it'); console.error(' Or set environment variable: MP_API_KEY=your_key'); ok = false; } if (!ok) { process.exit(1); } } function printValue(value) { if (typeof value === 'string') { process.stdout.write(`${value}\n`); return; } process.stdout.write(`${JSON.stringify(value)}\n`); } function formatUsageValue(field) { if (field?.type === 'array') { return "','"; } return ''; } async function cmdList() { await loadCommandsJson(); const sortedCommands = [...commandsJson].sort((left, right) => left.name.localeCompare(right.name)); for (const command of sortedCommands) { process.stdout.write(`${command.name}\n`); } } async function cmdShow(commandName) { if (!commandName) { fail(`Usage: ${SCRIPT_NAME} show `); } const command = await loadCommandJson(commandName); const commandLabel = 'Command:'; const descriptionLabel = 'Description:'; const paramsLabel = 'Parameters:'; const usageLabel = 'Usage:'; const detailLabelWidth = Math.max( commandLabel.length, descriptionLabel.length, paramsLabel.length, usageLabel.length ); process.stdout.write(`${commandLabel} ${command.name}\n`); process.stdout.write(`${descriptionLabel} ${command.description || '(none)'}\n\n`); if (command.fields.length === 0) { process.stdout.write(`${paramsLabel}${spacePad(paramsLabel, detailLabelWidth)}(none)\n`); } else { const fieldLines = command.fields.map((field) => [ field.required ? `${field.name}*` : field.name, field.type, field.description, ]); const nameWidth = Math.max(...fieldLines.map(([name]) => name.length), 0); const typeWidth = Math.max(...fieldLines.map(([, type]) => type.length), 0); process.stdout.write(`${paramsLabel}\n`); for (const [fieldName, fieldType, fieldDesc] of fieldLines) { process.stdout.write( ` ${fieldName}${spacePad(fieldName, nameWidth)}${fieldType}${spacePad(fieldType, typeWidth)}${fieldDesc}\n` ); } } const usageLine = `${command.name}`; const reqPart = command.fields .filter((field) => field.required) .map((field) => ` ${field.name}=${formatUsageValue(field)}`) .join(''); const optPart = command.fields .filter((field) => !field.required) .map((field) => ` [${field.name}=${formatUsageValue(field)}]`) .join(''); process.stdout.write(`\n${usageLabel} ${usageLine}${reqPart}${optPart}\n`); } function buildArguments(pairs) { const args = { explanation: 'CLI invocation' }; for (const kv of pairs) { if (!kv.includes('=')) { fail(`Error: argument must be in key=value format, got: '${kv}'`); } const index = kv.indexOf('='); args[kv.slice(0, index)] = kv.slice(index + 1); } return args; } async function cmdRun(commandName, pairs) { if (!commandName) { fail(`Usage: ${SCRIPT_NAME} [key=value ...]`); } const requestBody = JSON.stringify({ tool_name: commandName, arguments: buildArguments(pairs), }); const { statusCode, body } = await request( 'POST', `${mpHost}/api/v1/mcp/tools/call`, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(requestBody), 'X-API-KEY': mpApiKey, }, requestBody ); if (statusCode && statusCode !== '200' && statusCode !== '201') { console.error(`Warning: HTTP status ${statusCode}`); } try { const parsed = JSON.parse(body); if (Object.prototype.hasOwnProperty.call(parsed, 'error') && parsed.error) { printValue(parsed); return; } if (Object.prototype.hasOwnProperty.call(parsed, 'result')) { if (typeof parsed.result === 'string') { try { printValue(JSON.parse(parsed.result)); } catch { printValue(parsed.result); } } else { printValue(parsed.result); } return; } printValue(parsed); } catch { process.stdout.write(`${body}\n`); } } function printUsage() { const { cfgHost, cfgKey } = readConfig(); let effectiveHost = mpHost || envHost || cfgHost; let effectiveKey = mpApiKey || envKey || cfgKey; if (optHost) { effectiveHost = optHost; } if (optKey) { effectiveKey = optKey; } if (!effectiveHost || !effectiveKey) { const warningLines = []; if (!effectiveHost) { const opt = '-h HOST'; const desc = 'set backend host'; warningLines.push(`${opt}${spacePad(opt)}${desc}`); } if (!effectiveKey) { const opt = '-k KEY'; const desc = 'set API key'; warningLines.push(`${opt}${spacePad(opt)}${desc}`); } printBox('Warning: not configured', warningLines); console.error(''); } process.stdout.write(`Usage: ${SCRIPT_NAME} [-h HOST] [-k KEY] [COMMAND] [ARGS...]\n\n`); const optionWidth = Math.max('-h HOST'.length, '-k KEY'.length); process.stdout.write('Options:\n'); process.stdout.write(` -h HOST${spacePad('-h HOST', optionWidth)}backend host\n`); process.stdout.write(` -k KEY${spacePad('-k KEY', optionWidth)}API key\n\n`); const commandWidth = Math.max( '(no command)'.length, 'list'.length, 'show '.length, ' [k=v...]'.length ); process.stdout.write('Commands:\n'); process.stdout.write( ` (no command)${spacePad('(no command)', commandWidth)}save config when -h and -k are provided\n` ); process.stdout.write(` list${spacePad('list', commandWidth)}list all commands\n`); process.stdout.write( ` show ${spacePad('show ', commandWidth)}show command details and usage example\n` ); process.stdout.write( ` [k=v...]${spacePad(' [k=v...]', commandWidth)}run a command\n` ); } async function main() { const args = []; const argv = process.argv.slice(2); for (let index = 0; index < argv.length; index += 1) { const arg = argv[index]; if (arg === '--help' || arg === '-?') { printUsage(); process.exit(0); } if (arg === '-h') { index += 1; optHost = argv[index] || ''; continue; } if (arg === '-k') { index += 1; optKey = argv[index] || ''; continue; } if (arg === '--') { args.push(...argv.slice(index + 1)); break; } if (arg.startsWith('-')) { console.error(`Unknown option: ${arg}`); printUsage(); process.exit(1); } args.push(arg); } if ((optHost && !optKey) || (!optHost && optKey)) { fail('Error: -h and -k must be provided together'); } const command = args[0] || ''; if (command === 'list') { ensureConfig(); await cmdList(); return; } if (command === 'show') { ensureConfig(); await cmdShow(args[1] || ''); return; } if (!command) { if (optHost || optKey) { loadConfig(); process.stdout.write('Configuration saved.\n'); return; } printUsage(); return; } ensureConfig(); await cmdRun(command, args.slice(1)); } main().catch((error) => { fail(`Error: ${error.message}`); }); ================================================ FILE: tests/__init__.py ================================================ ================================================ FILE: tests/cases/__init__.py ================================================ ================================================ FILE: tests/cases/files.py ================================================ #!/usr/bin/env python # -*- coding:utf-8 -*- # 文件列表结构 list[tuple(名称, 子文件列表 或 文件大小)] bluray_files = [ ( "FOLDER", [ ( "Digimon", [ ( "Digimon BluRay (2055)", [ ( "BDMV", [ ( "STREAM", [ ("00000.m2ts", 104857600), ("00001.m2ts", 104857600), ], ), ], ), ("CERTIFICATE", None), ], ), ( "Digimon BluRay (2099)", [ ( "BDMV", [ ( "STREAM", [ ("00000.m2ts", 104857600), ("00001.m2ts", 104857600), ("00002.m2ts.!qB", 104857600), ], ), ], ), ("CERTIFICATE", None), ], ), ("Digimon (2199)", [("Digimon.2199.mp4", 104857600)]), ], ), ( "Pokemon BluRay (2016)", [ ( "BDMV", [ ( "STREAM", [ ("00000.m2ts", 104857600), ("00001.m2ts", 104857600), ], ) ], ), ("CERTIFICATE", None), ], ), ( "Pokemon BluRay (2021)", [ ( "BDMV", [ ( "STREAM", [ ("00000.m2ts", 104857600), ("00001.m2ts", 104857600), ], ) ], ), ("CERTIFICATE", None), ], ), ( "Pokemon (2028)", [ ("Pokemon.2028.mkv", 104857600), ("Pokemon.2028.hdr.mkv.!qB", 104857600), ], ), ("Pokemon.2029.mp4", 104857600), ("Pokemon.2039.mp4", 104857600), ("Pokemon (2030)", [("S", 104857600)]), ("Pokemon (2031)", [("Pokemon (2031).mp4", 104857600)]), ], ) ] ================================================ FILE: tests/cases/groups.py ================================================ release_group_cases = [ # 0ff 组(示例结构) { "domain": "0ff", "groups": [ {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-FFAB", "group": "FFAB"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-FFWEB", "group": "FFWEB"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-FFCD", "group": "FFCD"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-FFEDU", "group": "FFEDU"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-FFEB", "group": "FFEB"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-FFTV", "group": "FFTV"} ] }, # audiences 组(示例结构) { "domain": "audiences", "groups": [ {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-Audies", "group": "Audies"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-ADE", "group": "ADE"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-ADAudio", "group": "ADAudio"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-ADEbook", "group": "ADEbook"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-ADMusic", "group": "ADMusic"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-ADWeb", "group": "ADWeb"} ] }, # ---- 以下为新增结构化部分 ---- # beitai 组 { "domain": "beitai", "groups": [ {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-BeiTai", "group": "BeiTai"} ] }, # btschool 组 { "domain": "btschool", "groups": [ {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-BtsCHOOL", "group": "BtsCHOOL"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-BtsHD", "group": "BtsHD"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-BtsPAD", "group": "BtsPAD"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-BtsTV", "group": "BtsTV"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-Zone", "group": "Zone"} ] }, # carpt 组 { "domain": "carpt", "groups": [ {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-CarPT", "group": "CarPT"} ] }, # chd 组 { "domain": "chd", "groups": [ {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-CHD", "group": "CHD"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-CHDBits", "group": "CHDBits"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-CHDPAD", "group": "CHDPAD"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-CHDTV", "group": "CHDTV"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-CHDHKTV", "group": "CHDHKTV"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-CHDWEB", "group": "CHDWEB"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-StBOX", "group": "StBOX"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-OneHD", "group": "OneHD"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-Lee", "group": "Lee"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-xiaopie", "group": "xiaopie"} ] }, # eastgame 组 { "domain": "eastgame", "groups": [ {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-TLF", "group": "TLF"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-iNT-TLF", "group": "iNT-TLF"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-HALFC-TLF", "group": "HALFC-TLF"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-MiniSD-TLF", "group": "MiniSD-TLF"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-MiniHD-TLF", "group": "MiniHD-TLF"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-MiniFHD-TLF", "group": "MiniFHD-TLF"} ] }, # gainbound 组 { "domain": "gainbound", "groups": [ {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-DGB", "group": "DGB"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-GBWEB", "group": "GBWEB"} ] }, # hares 组 { "domain": "hares", "groups": [ {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-Hares", "group": "Hares"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-HaresMV", "group": "HaresMV"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-HaresTV", "group": "HaresTV"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-HaresWeb", "group": "HaresWeb"} ] }, # hdarea 组 { "domain": "hdarea", "groups": [ {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-HDApad", "group": "HDApad"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-HDArea", "group": "HDArea"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-HDATV", "group": "HDATV"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-EPiC", "group": "EPiC"} ] }, # hdchina 组 { "domain": "hdchina", "groups": [ {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-HDC", "group": "HDC"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-HDChina", "group": "HDChina"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-HDCTV", "group": "HDCTV"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-k9611", "group": "k9611"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-tudou", "group": "tudou"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-iHD", "group": "iHD"} ] }, # hddolby 组 { "domain": "hddolby", "groups": [ {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-Dream", "group": "Dream"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-DBTV", "group": "DBTV"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-HDo", "group": "HDo"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-QHStudIo", "group": "QHStudIo"} ] }, # hdfans 组 { "domain": "hdfans", "groups": [ {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-beAst", "group": "beAst"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-beAstTV", "group": "beAstTV"} ] }, # hdhome 组 { "domain": "hdhome", "groups": [ {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-HDH", "group": "HDH"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-HDHome", "group": "HDHome"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-HDHPad", "group": "HDHPad"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-HDHTV", "group": "HDHTV"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-HDHWEB", "group": "HDHWEB"} ] }, # hdpt 组 { "domain": "hdpt", "groups": [ {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-HDPT", "group": "HDPT"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-HDPTWeb", "group": "HDPTWeb"} ] }, # hdsky 组 { "domain": "hdsky", "groups": [ {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-HDS", "group": "HDS"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-HDSky", "group": "HDSky"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-HDSTV", "group": "HDSTV"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-HDSPad", "group": "HDSPad"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-HDSWEB", "group": "HDSWEB"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-AQLJ", "group": "AQLJ"} ] }, # hdzone 组 { "domain": "hdzone", "groups": [ {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-HDZ", "group": "HDZ"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-HDZone", "group": "HDZone"} ] }, # hhanclub 组 { "domain": "hhanclub", "groups": [ {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-HHWEB", "group": "HHWEB"} ] }, # htpt 组 { "domain": "htpt", "groups": [ {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-HTPT", "group": "HTPT"} ] }, # keepfrds 组 { "domain": "keepfrds", "groups": [ {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-FRDS", "group": "FRDS"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-Yumi@FRDS", "group": "Yumi@FRDS"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-cXcY@FRDS", "group": "cXcY@FRDS"} ] }, # lemonhd 组 { "domain": "lemonhd", "groups": [ {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-LeagueCD", "group": "LeagueCD"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-LeagueHD", "group": "LeagueHD"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-LeagueMV", "group": "LeagueMV"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-LeagueTV", "group": "LeagueTV"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-LeagueNF", "group": "LeagueNF"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-LeagueWEB", "group": "LeagueWEB"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-LHD", "group": "LHD"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-i18n", "group": "i18n"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-CiNT", "group": "CiNT"} ] }, # mteam 组 { "domain": "mteam", "groups": [ {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-MTeam", "group": "MTeam"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-MTeamTV", "group": "MTeamTV"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-MPAD", "group": "MPAD"} ] }, # ourbits 组 { "domain": "ourbits", "groups": [ {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-OurBits", "group": "OurBits"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-OurTV", "group": "OurTV"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-FLTTH", "group": "FLTTH"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-Ao", "group": "Ao"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-PbK", "group": "PbK"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-MGs", "group": "MGs"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-iLoveHD", "group": "iLoveHD"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-iLoveTV", "group": "iLoveTV"} ] }, # panda 组 { "domain": "panda", "groups": [ {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-Panda", "group": "Panda"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-AilMWeb", "group": "AilMWeb"} ] }, # piggo 组 { "domain": "piggo", "groups": [ {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-PiGoNF", "group": "PiGoNF"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-PiGoHB", "group": "PiGoHB"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-PiGoWEB", "group": "PiGoWEB"} ] }, # pterclub 组 { "domain": "pterclub", "groups": [ {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-PTer", "group": "PTer"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-PTerDIY", "group": "PTerDIY"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-PTerGame", "group": "PTerGame"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-PTerMV", "group": "PTerMV"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-PTerTV", "group": "PTerTV"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-PTerWEB", "group": "PTerWEB"} ] }, # pthome 组 { "domain": "pthome", "groups": [ {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-PTH", "group": "PTH"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-PTHAudio", "group": "PTHAudio"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-PTHeBook", "group": "PTHeBook"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-PTHmusic", "group": "PTHmusic"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-PTHome", "group": "PTHome"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-PTHtv", "group": "PTHtv"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-PTHWEB", "group": "PTHWEB"} ] }, # ptsbao 组 { "domain": "ptsbao", "groups": [ {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-PTsbao", "group": "PTsbao"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-OPS", "group": "OPS"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-FFansAIeNcE", "group": "FFansAIeNcE"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-FFansBD", "group": "FFansBD"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-FFansDVD", "group": "FFansDVD"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-FFansDIY", "group": "FFansDIY"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-FFansTV", "group": "FFansTV"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-FFansWEB", "group": "FFansWEB"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-FHDMv", "group": "FHDMv"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-SGXT", "group": "SGXT"} ] }, # putao 组 { "domain": "putao", "groups": [ {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-PuTao", "group": "PuTao"} ] }, # ssd 组 { "domain": "ssd", "groups": [ {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-CMCT", "group": "CMCT"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-CMCT@制作者", "group": "CMCT"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-CMCTV", "group": "CMCTV"} ] }, # sharkpt 组 { "domain": "sharkpt", "groups": [ {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-Shark", "group": "Shark"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-SharkWEB", "group": "SharkWEB"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-SharkDIY", "group": "SharkDIY"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-SharkTV", "group": "SharkTV"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-SharkMV", "group": "SharkMV"} ] }, # tjupt 组 { "domain": "tjupt", "groups": [ {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-TJUPT", "group": "TJUPT"} ] }, # ttg 组 { "domain": "ttg", "groups": [ {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-TTG", "group": "TTG"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-WiKi", "group": "WiKi"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-NGB", "group": "NGB"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-DoA", "group": "DoA"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-ARiN", "group": "ARiN"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-ExREN", "group": "ExREN"} ] }, # others 组 { "domain": "others", "groups": [ {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-BMDru", "group": "BMDru"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-BeyondHD", "group": "BeyondHD"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-BTN", "group": "BTN"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-Cfandora", "group": "Cfandora"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-Ctrlhd", "group": "Ctrlhd"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-CMRG", "group": "CMRG"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-DON", "group": "DON"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-EVO", "group": "EVO"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-FLUX", "group": "FLUX"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-HONE", "group": "HONE"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-HONEyG", "group": "HONEyG"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-NoGroup", "group": "NoGroup"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-NTb", "group": "NTb"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-NTG", "group": "NTG"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-PandaMoon", "group": "PandaMoon"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-SMURF", "group": "SMURF"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-TEPES", "group": "TEPES"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-Taengoo", "group": "Taengoo"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-TrollHD ", "group": "TrollHD "}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-UBWEB", "group": "UBWEB"} ] }, # anime 组 { "domain": "anime", "groups": [ {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-ANi", "group": "ANi"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-HYSUB", "group": "HYSUB"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-KTXP", "group": "KTXP"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-LoliHouse", "group": "LoliHouse"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-MCE", "group": "MCE"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-Nekomoe kissaten", "group": "Nekomoe kissaten"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-SweetSub", "group": "SweetSub"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-MingY", "group": "MingY"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-Lilith-Raws", "group": "Lilith-Raws"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-NC-Raws", "group": "NC-Raws"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-织梦字幕组", "group": "织梦字幕组"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-枫叶字幕组", "group": "枫叶字幕组"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-猎户手抄部", "group": "猎户手抄部"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-喵萌奶茶屋", "group": "喵萌奶茶屋"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-漫猫字幕社", "group": "漫猫字幕社"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-霜庭云花Sub", "group": "霜庭云花Sub"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-北宇治字幕组", "group": "北宇治字幕组"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-氢气烤肉架", "group": "氢气烤肉架"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-云歌字幕组", "group": "云歌字幕组"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-萌樱字幕组", "group": "萌樱字幕组"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-极影字幕社", "group": "极影字幕社"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-悠哈璃羽字幕社", "group": "悠哈璃羽字幕社"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-❀拨雪寻春❀", "group": "❀拨雪寻春❀"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-沸羊羊制作", "group": "沸羊羊制作"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-沸羊羊字幕组", "group": "沸羊羊字幕组"}, { "title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-桜都字幕组", "group": "桜都字幕组", }, { "title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-樱都字幕组", "group": "樱都字幕组", }, ] }, # frog 组 { "domain": "frog", "groups": [ {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-FROG", "group": "FROG"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-FROGE", "group": "FROGE"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-FROGWeb", "group": "FROGWeb"}, ] }, # ubits 组 { "domain": "ubits", "groups": [ {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-UBits", "group": "UBits"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-UBWEB", "group": "UBWEB"}, {"title": "Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-UBTV", "group": "UBTV"}, ] }, ] ================================================ FILE: tests/cases/meta.py ================================================ meta_cases = [{ "title": "The Long Season 2017 2160p WEB-DL H265 120FPS AAC-XXX", "subtitle": "", "target": { "type": "未知", "cn_name": "", "en_name": "The Long Season", "year": "2017", "part": "", "season": "", "episode": "", "restype": "WEB-DL", "pix": "2160p", "video_codec": "H265", "audio_codec": "AAC", "fps": 120 } }, { "title": "Cherry Season S01 2014 2160p 60fps WEB-DL H265 AAC-XXX", "subtitle": "", "target": { "type": "电视剧", "cn_name": "", "en_name": "Cherry Season", "year": "2014", "part": "", "season": "S01", "episode": "", "restype": "WEB-DL", "pix": "2160p", "video_codec": "H265", "audio_codec": "AAC", "fps": 60 } }, { "title": "【爪爪字幕组】★7月新番[欢迎来到实力至上主义的教室 第二季/Youkoso Jitsuryoku Shijou Shugi no Kyoushitsu e S2][11][1080p][HEVC][GB][MP4][招募翻译校对]", "subtitle": "", "target": { "type": "电视剧", "cn_name": "欢迎来到实力至上主义的教室", "en_name": "Youkoso Jitsuryoku Shijou Shugi No Kyoushitsu E", "year": "", "part": "", "season": "S02", "episode": "E11", "restype": "", "pix": "1080p", "video_codec": "HEVC", "audio_codec": "", "fps": None } }, { "title": "National.Parks.Adventure.AKA.America.Wild:.National.Parks.Adventure.3D.2016.1080p.Blu-ray.AVC.TrueHD.7.1", "subtitle": "", "target": { "type": "未知", "cn_name": "", "en_name": "National Parks Adventure", "year": "2016", "part": "", "season": "", "episode": "", "restype": "BluRay 3D", "pix": "1080p", "video_codec": "AVC", "audio_codec": "TrueHD 7.1", "fps": None } }, { "title": "[秋叶原冥途战争][Akiba Maid Sensou][2022][WEB-DL][1080][TV Series][第01话][LeagueWEB]", "subtitle": "", "target": { "type": "电视剧", "cn_name": "", "en_name": "Akiba Maid Sensou", "year": "2022", "part": "", "season": "S01", "episode": "E01", "restype": "", "pix": "1080p", "video_codec": "", "audio_codec": "", "fps": None } }, { "title": "哆啦A梦:大雄的宇宙小战争 2021 (2022) - 1080p.mp4", "subtitle": "", "target": { "type": "未知", "cn_name": "哆啦A梦:大雄的宇宙小战争 2021", "en_name": "", "year": "2022", "part": "", "season": "", "episode": "", "restype": "", "pix": "1080p", "video_codec": "", "audio_codec": "", "fps": None } }, { "title": "新精武门1991 (1991).mkv", "subtitle": "", "target": { "type": "未知", "cn_name": "新精武门1991", "en_name": "", "year": "1991", "part": "", "season": "", "episode": "", "restype": "", "pix": "", "video_codec": "", "audio_codec": "", "fps": None } }, { "title": "24 S01 1080p WEB-DL AAC2.0 H.264-BTN", "subtitle": "", "target": { "type": "电视剧", "cn_name": "", "en_name": "24", "year": "", "part": "", "season": "S01", "episode": "", "restype": "WEB-DL", "pix": "1080p", "video_codec": "H264", "audio_codec": "AAC 2.0", "fps": None } }, { "title": "Qi Refining for 3000 Years S01E06 2022 1080p B-Blobal WEB-DL X264 AAC-AnimeS@AdWeb", "subtitle": "", "target": { "type": "电视剧", "cn_name": "", "en_name": "Qi Refining For 3000 Years", "year": "2022", "part": "", "season": "S01", "episode": "E06", "restype": "WEB-DL", "pix": "1080p", "video_codec": "x264", "audio_codec": "AAC", "fps": None } }, { "title": "Noumin Kanren no Skill Bakka Agetetara Naze ka Tsuyoku Natta S01E02 2022 1080p B-Global WEB-DL X264 AAC-AnimeS@ADWeb[2022年10月新番]", "subtitle": "", "target": { "type": "电视剧", "cn_name": "", "en_name": "Noumin Kanren No Skill Bakka Agetetara Naze Ka Tsuyoku Natta", "year": "2022", "part": "", "season": "S01", "episode": "E02", "restype": "WEB-DL", "pix": "1080p", "video_codec": "x264", "audio_codec": "AAC", "fps": None } }, { "title": "dou luo da lu S01E229 2018 2160p WEB-DL H265 AAC-ADWeb[[国漫连载] 斗罗大陆 第229集 4k | 国语中字]", "subtitle": "", "target": { "type": "电视剧", "cn_name": "", "en_name": "Dou Luo Da Lu", "year": "2018", "part": "", "season": "S01", "episode": "E229", "restype": "WEB-DL", "pix": "2160p", "video_codec": "H265", "audio_codec": "AAC", "fps": None } }, { "title": "Thor Love and Thunder (2022) [1080p] [WEBRip] [5.1]", "subtitle": "", "target": { "type": "未知", "cn_name": "", "en_name": "Thor Love And Thunder", "year": "2022", "part": "", "season": "", "episode": "", "restype": "", "pix": "1080p", "video_codec": "", "audio_codec": "5.1", "fps": None } }, { "title": "[Animations(动画片)][[诛仙][Jade Dynasty][2022][WEB-DL][2160][TV Series][TV 08][LeagueWEB]][诛仙/诛仙动画 第一季 第08集 | 类型:动画 [国语中字]][680.12 MB]", "subtitle": "", "target": { "type": "电视剧", "cn_name": "", "en_name": "Jade Dynasty", "year": "2022", "part": "", "season": "S01", "episode": "E08", "restype": "", "pix": "", "video_codec": "", "audio_codec": "", "fps": None } }, { "title": "钢铁侠2 (2010) 1080p AC3.mp4", "subtitle": "", "target": { "type": "未知", "cn_name": "钢铁侠2", "en_name": "", "year": "2010", "part": "", "season": "", "episode": "", "restype": "", "pix": "1080p", "video_codec": "", "audio_codec": "AC3", "fps": None } }, { "title": "Wonder Woman 1984 2020 BluRay 1080p Atmos TrueHD 7.1 X264-EPiC", "subtitle": "", "target": { "type": "未知", "cn_name": "", "en_name": "Wonder Woman 1984", "year": "2020", "part": "", "season": "", "episode": "", "restype": "BluRay", "pix": "1080p", "video_codec": "x264", "audio_codec": "Atmos TrueHD 7.1", "fps": None } }, { "title": "9-1-1 - S04E03 - Future Tense WEBDL-1080p.mp4", "subtitle": "", "target": { "type": "电视剧", "cn_name": "", "en_name": "9 1 1", "year": "", "part": "", "season": "S04", "episode": "E03", "restype": "WEB-DL", "pix": "1080p", "video_codec": "", "audio_codec": "", "fps": None } }, { "title": "【幻月字幕组】【22年日剧】【据幸存的六人所说】【04】【1080P】【中日双语】", "subtitle": "", "target": { "type": "电视剧", "cn_name": "据幸存的六人所说", "en_name": "", "year": "", "part": "", "season": "S01", "episode": "E04", "restype": "", "pix": "1080p", "video_codec": "", "audio_codec": "", "fps": None } }, { "title": "【爪爪字幕组】★7月新番[即使如此依旧步步进逼/Soredemo Ayumu wa Yosetekuru][09][1080p][HEVC][GB][MP4][招募翻译校对]", "subtitle": "", "target": { "type": "电视剧", "cn_name": "", "en_name": "Soredemo Ayumu Wa Yosetekuru", "year": "", "part": "", "season": "S01", "episode": "E09", "restype": "", "pix": "1080p", "video_codec": "HEVC", "audio_codec": "", "fps": None } }, { "title": "[猎户不鸽发布组] 不死者之王 第四季 OVERLORD Ⅳ [02] [1080p] [简中内封] [2022年7月番]", "subtitle": "", "target": { "type": "电视剧", "cn_name": "不死者之王", "en_name": "Overlord Ⅳ", "year": "", "part": "", "season": "S04", "episode": "E02", "restype": "", "pix": "1080p", "video_codec": "", "audio_codec": "", "fps": None } }, { "title": "[GM-Team][国漫][寻剑 第1季][Sword Quest Season 1][2002][02][AVC][GB][1080P]", "subtitle": "", "target": { "type": "电视剧", "cn_name": "", "en_name": "Sword Quest", "year": "2002", "part": "", "season": "S01", "episode": "E02", "restype": "", "pix": "1080p", "video_codec": "AVC", "audio_codec": "", "fps": None } }, { "title": " [猎户不鸽发布组] 组长女儿与照料专员 / 组长女儿与保姆 Kumichou Musume to Sewagakari [09] [1080p+] [简中内嵌] [2022年7月番]", "subtitle": "", "target": { "type": "电视剧", "cn_name": "组长女儿与保姆", "en_name": "Kumichou Musume To Sewagakari", "year": "", "part": "", "season": "S01", "episode": "E09", "restype": "", "pix": "1080p", "video_codec": "", "audio_codec": "", "fps": None } }, { "title": "Nande Koko ni Sensei ga!? 2019 Blu-ray Remux 1080p AVC LPCM-7³ ACG", "subtitle": "", "target": { "type": "未知", "cn_name": "", "en_name": "Nande Koko Ni Sensei Ga!?", "year": "2019", "part": "", "season": "", "episode": "", "restype": "BluRay REMUX", "pix": "1080p", "video_codec": "AVC", "audio_codec": "LPCM 7³", "fps": None } }, { "title": "30.Rock.S02E01.1080p.UHD.BluRay.X264-BORDURE.mkv", "subtitle": "", "target": { "type": "电视剧", "cn_name": "", "en_name": "30 Rock", "year": "", "part": "", "season": "S02", "episode": "E01", "restype": "UHD BluRay", "pix": "1080p", "video_codec": "x264", "audio_codec": "", "fps": None } }, { "title": "[Gal to Kyouryuu][02][BDRIP][1080P][H264_FLAC].mkv", "subtitle": "", "target": { "type": "电视剧", "cn_name": "", "en_name": "Gal To Kyouryuu", "year": "", "part": "", "season": "S01", "episode": "E02", "restype": "", "pix": "1080p", "video_codec": "H264", "audio_codec": "FLAC", "fps": None } }, { "title": "[AI-Raws] 逆境無頼カイジ #13 (BD HEVC 1920x1080 yuv444p10le FLAC)[7CFEE642].mkv", "subtitle": "", "target": { "type": "电视剧", "cn_name": "逆境無頼カイジ", "en_name": "", "year": "", "part": "", "season": "S01", "episode": "E13", "restype": "BD", "pix": "1080p", "video_codec": "HEVC", "audio_codec": "FLAC", "fps": None } }, { "title": "Mr. Robot - S02E06 - eps2.4_m4ster-s1ave.aes SDTV.mp4", "subtitle": "", "target": { "type": "电视剧", "cn_name": "", "en_name": "Mr Robot", "year": "", "part": "", "season": "S02", "episode": "E06", "restype": "", "pix": "", "video_codec": "", "audio_codec": "", "fps": None } }, { "title": "[神印王座][Throne of Seal][2022][WEB-DL][2160][TV Series][TV 22][LeagueWEB] 神印王座 第一季 第22集 | 类型:动画 [国语中字][967.44 MB]", "subtitle": "", "target": { "type": "电视剧", "cn_name": "", "en_name": "Throne Of Seal", "year": "2022", "part": "", "season": "S01", "episode": "E22", "restype": "", "pix": "", "video_codec": "", "audio_codec": "", "fps": None } }, { "title": "S02E1000.mkv", "subtitle": "", "target": { "type": "电视剧", "cn_name": "", "en_name": "", "year": "", "part": "", "season": "S02", "episode": "E1000", "restype": "", "pix": "", "video_codec": "", "audio_codec": "", "fps": None } }, { "title": "西部世界 12.mkv", "subtitle": "", "target": { "type": "电视剧", "cn_name": "西部世界", "en_name": "", "year": "", "part": "", "season": "S01", "episode": "E12", "restype": "", "pix": "", "video_codec": "", "audio_codec": "", "fps": None } }, { "title": "[ANi] OVERLORD 第四季 - 04 [1080P][Baha][WEB-DL][AAC AVC][CHT].mp4", "subtitle": "", "target": { "type": "电视剧", "cn_name": "", "en_name": "Overlord", "year": "", "part": "", "season": "S04", "episode": "E04", "restype": "", "pix": "1080p", "video_codec": "AVC", "audio_codec": "AAC", "fps": None } }, { "title": "[SweetSub&LoliHouse] Made in Abyss S2 - 03v2 [WebRip 1080p HEVC-10bit AAC ASSx2].mkv", "subtitle": "", "target": { "type": "电视剧", "cn_name": "", "en_name": "Made In Abyss", "year": "", "part": "", "season": "S02", "episode": "E03", "restype": "", "pix": "1080p", "video_codec": "", "audio_codec": "AAC", "fps": None } }, { "title": "[GM-Team][国漫][斗破苍穹 第5季][Fights Break Sphere V][2022][05][HEVC][GB][4K]", "subtitle": "", "target": { "type": "电视剧", "cn_name": "", "en_name": "Fights Break Sphere V", "year": "2022", "part": "", "season": "S05", "episode": "E05", "restype": "", "pix": "2160p", "video_codec": "HEVC", "audio_codec": "", "fps": None } }, { "title": "Ousama Ranking S01E02-[1080p][BDRIP][X265.FLAC].mkv", "subtitle": "", "target": { "type": "电视剧", "cn_name": "", "en_name": "Ousama Ranking", "year": "", "part": "", "season": "S01", "episode": "E02", "restype": "BDRIP", "pix": "1080p", "video_codec": "x265", "audio_codec": "FLAC", "fps": None } }, { "title": "[Nekomoe kissaten&LoliHouse] Soredemo Ayumu wa Yosetekuru - 01v2 [WebRip 1080p HEVC-10bit EAC3 ASSx2].mkv", "subtitle": "", "target": { "type": "电视剧", "cn_name": "", "en_name": "Soredemo Ayumu Wa Yosetekuru", "year": "", "part": "", "season": "S01", "episode": "E01", "restype": "", "pix": "1080p", "video_codec": "", "audio_codec": "EAC3", "fps": None } }, { "title": "[喵萌奶茶屋&LoliHouse] 金装的薇尔梅 / Kinsou no Vermeil - 01 [WebRip 1080p HEVC-10bit AAC][简繁内封字幕]", "subtitle": "", "target": { "type": "电视剧", "cn_name": "金装的薇尔梅", "en_name": "Kinsou No Vermeil", "year": "", "part": "", "season": "S01", "episode": "E01", "restype": "", "pix": "1080p", "video_codec": "", "audio_codec": "AAC", "fps": None } }, { "title": "Hataraku.Maou-sama.S02E05.2022.1080p.CR.WEB-DL.X264.AAC-ADWeb.mkv", "subtitle": "", "target": { "type": "电视剧", "cn_name": "", "en_name": "Hataraku Maou Sama", "year": "2022", "part": "", "season": "S02", "episode": "E05", "restype": "WEB-DL", "pix": "1080p", "video_codec": "x264", "audio_codec": "AAC", "fps": None } }, { "title": "The Witch Part 2:The Other One 2022 1080p WEB-DL AAC5.1 H264-tG1R0", "subtitle": "", "target": { "type": "未知", "cn_name": "", "en_name": "The Witch Part 2:The Other One", "year": "2022", "part": "", "season": "", "episode": "", "restype": "WEB-DL", "pix": "1080p", "video_codec": "H264", "audio_codec": "AAC 5.1", "fps": None } }, { "title": "一夜新娘 - S02E07 - 第 7 集.mp4", "subtitle": "", "target": { "type": "电视剧", "cn_name": "一夜新娘", "en_name": "", "year": "", "part": "", "season": "S02", "episode": "E07", "restype": "", "pix": "", "video_codec": "", "audio_codec": "", "fps": None } }, { "title": "[ANi] 處刑少女的生存之道 - 07 [1080P][Baha][WEB-DL][AAC AVC][CHT].mp4", "subtitle": "", "target": { "type": "电视剧", "cn_name": "處刑少女的生存之道", "en_name": "", "year": "", "part": "", "season": "S01", "episode": "E07", "restype": "", "pix": "1080p", "video_codec": "AVC", "audio_codec": "AAC", "fps": None } }, { "title": "Stand-up.Comedy.S01E01.PartA.2022.1080p.WEB-DL.H264.AAC-TJUPT.mp4", "subtitle": "", "target": { "type": "电视剧", "cn_name": "", "en_name": "Stand Up Comedy", "year": "2022", "part": "PartA", "season": "S01", "episode": "E01", "restype": "WEB-DL", "pix": "1080p", "video_codec": "H264", "audio_codec": "AAC", "fps": None } }, { "title": "教父3.The.Godfather.Part.III.1990.1080p.NF.WEBRip.H264.DDP5.1-PTerWEB.mkv", "subtitle": "", "target": { "type": "未知", "cn_name": "教父3", "en_name": "The Godfather Part Iii", "year": "1990", "part": "", "season": "", "episode": "", "restype": "WEBRip", "pix": "1080p", "video_codec": "H264", "audio_codec": "DDP 5.1", "fps": None } }, { "title": "A.Quiet.Place.Part.II.2020.1080p.UHD.BluRay.DD+7.1.DoVi.X265-PuTao", "subtitle": "", "target": { "type": "未知", "cn_name": "", "en_name": "A Quiet Place Part Ii", "year": "2020", "part": "", "season": "", "episode": "", "restype": "UHD BluRay DoVi", "pix": "1080p", "video_codec": "x265", "audio_codec": "DD+ 7.1", "fps": None } }, { "title": "Childhood.In.A.Capsule.S01E16.2022.1080p.KKTV.WEB-DL.X264.AAC-ADWeb.mkv", "subtitle": "", "target": { "type": "电视剧", "cn_name": "", "en_name": "Childhood In A Capsule", "year": "2022", "part": "", "season": "S01", "episode": "E16", "restype": "WEB-DL", "pix": "1080p", "video_codec": "x264", "audio_codec": "AAC", "fps": None } }, { "title": "[桜都字幕组] 异世界归来的舅舅 / Isekai Ojisan [01][1080p][简体内嵌]", "subtitle": "", "target": { "type": "电视剧", "cn_name": "", "en_name": "Isekai Ojisan", "year": "", "part": "", "season": "S01", "episode": "E01", "restype": "", "pix": "1080p", "video_codec": "", "audio_codec": "", "fps": None } }, { "title": "【喵萌奶茶屋】★04月新番★[夏日重現/Summer Time Rendering][15][720p][繁日雙語][招募翻譯片源]", "subtitle": "", "target": { "type": "电视剧", "cn_name": "", "en_name": "Summer Time Rendering", "year": "", "part": "", "season": "S01", "episode": "E15", "restype": "", "pix": "720p", "video_codec": "", "audio_codec": "", "fps": None } }, { "title": "[NC-Raws] 打工吧!魔王大人 第二季 / Hataraku Maou-sama!! - 02 (B-Global 1920x1080 HEVC AAC MKV)", "subtitle": "", "target": { "type": "电视剧", "cn_name": "打工吧!魔王大人", "en_name": "Hataraku Maou-Sama!!", "year": "", "part": "", "season": "S02", "episode": "E02", "restype": "", "pix": "1080p", "video_codec": "HEVC", "audio_codec": "AAC", "fps": None } }, { "title": "The Witch Part 2 The Other One 2022 1080p WEB-DL AAC5.1 H.264-tG1R0", "subtitle": "", "target": { "type": "未知", "cn_name": "", "en_name": "The Witch Part 2 The Other One", "year": "2022", "part": "", "season": "", "episode": "", "restype": "WEB-DL", "pix": "1080p", "video_codec": "H264", "audio_codec": "AAC 5.1", "fps": None } }, { "title": "The 355 2022 BluRay 1080p DTS-HD MA5.1 X265.10bit-BeiTai", "subtitle": "", "target": { "type": "未知", "cn_name": "", "en_name": "The 355", "year": "2022", "part": "", "season": "", "episode": "", "restype": "BluRay", "pix": "1080p", "video_codec": "x265 10bit", "audio_codec": "DTS-HD MA 5.1", "fps": None } }, { "title": "Sense8 s01-s02 2015-2017 1080P WEB-DL X265 AC3£cXcY@FRDS", "subtitle": "", "target": { "type": "电视剧", "cn_name": "", "en_name": "Sense8", "year": "2015", "part": "", "season": "S01-S02", "episode": "", "restype": "WEB-DL", "pix": "1080p", "video_codec": "x265", "audio_codec": "", "fps": None } }, { "title": "The Heart of Genius S01 13-14 2022 1080p WEB-DL H264 AAC", "subtitle": "", "target": { "type": "电视剧", "cn_name": "", "en_name": "The Heart Of Genius", "year": "2022", "part": "", "season": "S01", "episode": "E13-E14", "restype": "WEB-DL", "pix": "1080p", "video_codec": "H264", "audio_codec": "AAC", "fps": None } }, { "title": "The Heart of Genius E13-14 2022 1080p WEB-DL H264 AAC", "subtitle": "", "target": { "type": "电视剧", "cn_name": "", "en_name": "The Heart Of Genius", "year": "2022", "part": "", "season": "S01", "episode": "E13-E14", "restype": "WEB-DL", "pix": "1080p", "video_codec": "H264", "audio_codec": "AAC", "fps": None } }, { "title": "2022.8.2.Twelve.Monkeys.1995.GBR.4K.REMASTERED.BluRay.1080p.X264.DTS [3.4 GB]", "subtitle": "", "target": { "type": "未知", "cn_name": "", "en_name": "Twelve Monkeys", "year": "1995", "part": "", "season": "", "episode": "", "restype": "BluRay", "pix": "4k", "video_codec": "x264", "audio_codec": "DTS", "fps": None } }, { "title": "[NC-Raws] 王者天下 第四季 - 17 (Baha 1920x1080 AVC AAC MP4) [3B1AA7BB].mp4", "subtitle": "", "target": { "type": "电视剧", "cn_name": "王者天下", "en_name": "", "year": "", "part": "", "season": "S04", "episode": "E17", "restype": "", "pix": "1080p", "video_codec": "AVC", "audio_codec": "AAC", "fps": None } }, { "title": "Sense8 S2E1 2015-2017 1080P WEB-DL X265 AC3£cXcY@FRDS", "subtitle": "", "target": { "type": "电视剧", "cn_name": "", "en_name": "Sense8", "year": "2015", "part": "", "season": "S02", "episode": "E01", "restype": "WEB-DL", "pix": "1080p", "video_codec": "x265", "audio_codec": "", "fps": None } }, { "title": "[xyx98]传颂之物/Utawarerumono/うたわれるもの[BDrip][1920x1080][TV 01-26 Fin][hevc-yuv420p10 flac_ac3][ENG PGS]", "subtitle": "", "target": { "type": "电视剧", "cn_name": "", "en_name": "うたわれるもの", "year": "", "part": "", "season": "S01", "episode": "E01-E26", "restype": "", "pix": "1080p", "video_codec": "", "audio_codec": "flac", "fps": None } }, { "title": "[云歌字幕组][7月新番][欢迎来到实力至上主义的教室 第二季][01][X264 10bit][1080p][简体中文].mp4", "subtitle": "", "target": { "type": "电视剧", "cn_name": "欢迎来到实力至上主义的教室", "en_name": "", "year": "", "part": "", "season": "S02", "episode": "E01", "restype": "", "pix": "1080p", "video_codec": "X264", "audio_codec": "", "fps": None } }, { "title": "[诛仙][Jade Dynasty][2022][WEB-DL][2160][TV Series][TV 04][LeagueWEB]", "subtitle": "", "target": { "type": "电视剧", "cn_name": "", "en_name": "Jade Dynasty", "year": "2022", "part": "", "season": "S01", "episode": "E04", "restype": "", "pix": "", "video_codec": "", "audio_codec": "", "fps": None } }, { "title": "Rick and Morty.S06E06.JuRicksic.Mort.1080p.HMAX.WEBRip.DD5.1.X264-NTb[rartv]", "subtitle": "", "target": { "type": "电视剧", "cn_name": "", "en_name": "Rick And Morty", "year": "", "part": "", "season": "S06", "episode": "E06", "restype": "WEBRip", "pix": "1080p", "video_codec": "x264", "audio_codec": "DD 5.1", "fps": None } }, { "title": "rick and Morty.S06E05.JuRicksic.Mort.1080p.HMAX.WEBRip.DD5.1.X264-NTb[rartv]", "subtitle": "", "target": { "type": "电视剧", "cn_name": "", "en_name": "Rick And Morty", "year": "", "part": "", "season": "S06", "episode": "E05", "restype": "WEBRip", "pix": "1080p", "video_codec": "x264", "audio_codec": "DD 5.1", "fps": None } }, { "title": "[Hall_of_C] 诛仙 Zhu Xian (Jade Dynasty) - Episode 19", "subtitle": "", "target": { "type": "电视剧", "cn_name": "诛仙", "en_name": "Zhu Xian Jade Dynasty", "year": "", "part": "", "season": "S01", "episode": "E19", "restype": "", "pix": "", "video_codec": "", "audio_codec": "", "fps": None } }, { "title": "I Woke Up a Vampire S02 2023 2160p NF WEB-DL DDP5.1 Atmos H 265-HHWEB", "subtitle": "醒来变成吸血鬼 第二季 | 全8集 | 4K | 类型: 喜剧/家庭/奇幻 | 导演: TommyLynch | 主演: NikoCeci/ZebastinBorjeau/安娜·阿劳约/KaileenAngelicChang/KrisSiddiqi", "target": { "type": "电视剧", "cn_name": "", "en_name": "I Woke Up A Vampire", "year": "2023", "part": "", "season": "S02", "episode": "", "restype": "WEB-DL", "pix": "2160p", "video_codec": "H265", "audio_codec": "DDP 5.1 Atmos", "fps": None } }, { "title": "Shadows of the Void S01 2024 1080p WEB-DL H264 AAC-HHWEB", "subtitle": "虚无边境 | 第01-02集 | 1080p | 类型: 动画 | 导演: 巴西 | 主演: 山新/周一菡/皇贞季/Kenz/李佳怡 [内嵌中字]", "target": { "type": "电视剧", "cn_name": "", "en_name": "Shadows Of The Void", "year": "2024", "part": "", "season": "S01", "episode": "E01-E02", "restype": "WEB-DL", "pix": "1080p", "video_codec": "H264", "audio_codec": "AAC", "fps": None } }, { "title": "【极影字幕社】★1月新番 Metallic Rouge/金属口红 第13话 GB 1080P MP4(字幕社招人内详)", "subtitle": "", "target": { "type": "电视剧", "cn_name": "金属口红", "en_name": "Metallic Rouge", "year": "", "part": "", "season": "S01", "episode": "E13", "restype": "", "pix": "1080p", "video_codec": "", "audio_codec": "", "fps": None } }, { "title": "Mai Xiang S01 2019 2160p WEB-DL H.265 DDP2.0-HHWEB", "subtitle": "麦香 | 全36集 | 4K | 类型:剧情/爱情/家庭 | 主演:傅晶/章呈赫/王伟/沙景昌/何音", "target": { "type": "电视剧", "cn_name": "麦香", "en_name": "Mai Xiang", "year": "2019", "part": "", "season": "S01", "episode": "", "restype": "WEB-DL", "pix": "2160p", "video_codec": "H265", "audio_codec": "DDP 2.0", "fps": None } }, { "path": "/volume1/电视剧/西部世界 第二季 (2016)/5.mkv", "target": { "type": "电视剧", "cn_name": "西部世界", "en_name": "", "year": "2016", "part": "", "season": "S02", "episode": "E05", "restype": "", "pix": "", "video_codec": "", "audio_codec": "", "fps": None } }, { "path": "/movies/The Vampire Diaries (2009) [tmdbid=18165]/The.Vampire.Diaries.S01E01.1080p.mkv", "target": { "type": "电视剧", "cn_name": "", "en_name": "The Vampire Diaries", "year": "2009", "part": "", "season": "S01", "episode": "E01", "restype": "", "pix": "1080p", "video_codec": "", "audio_codec": "", "tmdbid": 18165, "fps": None } }, { "path": "/movies/Inception (2010) [tmdbid-27205]/Inception.2010.1080p.mkv", "target": { "type": "未知", "cn_name": "", "en_name": "Inception", "year": "2010", "part": "", "season": "", "episode": "", "restype": "", "pix": "1080p", "video_codec": "", "audio_codec": "", "tmdbid": 27205, "fps": None } }, { "path": "/movies/Breaking Bad (2008) [tmdb=1396]/Season 2/", "target": { "type": "电视剧", "cn_name": "", "en_name": "Breaking Bad", "year": "2008", "part": "", "season": "S02", "episode": "", "restype": "", "pix": "", "video_codec": "", "audio_codec": "", "tmdbid": 1396 } }, { "path": "/movies/Breaking Bad (2008) [tmdb=1396]/S2/", "target": { "type": "电视剧", "cn_name": "", "en_name": "Breaking Bad", "year": "2008", "part": "", "season": "S02", "episode": "", "restype": "", "pix": "", "video_codec": "", "audio_codec": "", "tmdbid": 1396 } }, { "path": "/movies/Breaking Bad (2008) [tmdb=1396]/Season 1/Breaking.Bad.S01E01.1080p.mkv", "target": { "type": "电视剧", "cn_name": "", "en_name": "Breaking Bad", "year": "2008", "part": "", "season": "S01", "episode": "E01", "restype": "", "pix": "1080p", "video_codec": "", "audio_codec": "", "tmdbid": 1396, "fps": None } }, { "path": "/tv/Game of Thrones (2011) {tmdb=1399}/Season 1/Game.of.Thrones.S01E01.1080p.mkv", "target": { "type": "电视剧", "cn_name": "", "en_name": "Game Of Thrones", "year": "2011", "part": "", "season": "S01", "episode": "E01", "restype": "", "pix": "1080p", "video_codec": "", "audio_codec": "", "tmdbid": 1399, "fps": None } }, { "path": "/movies/Avatar (2009) {tmdb-19995}/Avatar.2009.1080p.mkv", "target": { "type": "未知", "cn_name": "", "en_name": "Avatar", "year": "2009", "part": "", "season": "", "episode": "", "restype": "", "pix": "1080p", "video_codec": "", "audio_codec": "", "tmdbid": 19995, "fps": None } }, { "path": "/movies/DouBan_IMDB.TOP250.Movies.Mixed.Collection.20240501.FRDS/为奴十二年.12.Years.a.Slave.2013.BluRay.1080p.x265.10bit.2Audio.MNHD-FRDS/12.Years.a.Slave.2013.BluRay.1080p.x265.10bit.2Audio.MNHD-FRDS.mkv", "target": { "type": "未知", "cn_name": "", "en_name": "12 Years A Slave", "year": "2013", "part": "", "season": "", "episode": "", "restype": "BluRay", "pix": "1080p", "video_codec": "x265 10bit", "audio_codec": "2Audio" } }] ================================================ FILE: tests/manual/ugreen_media_cli.py ================================================ from __future__ import annotations import argparse import base64 import getpass import json import os import sys import uuid from typing import Any, Mapping from urllib.parse import urlsplit, urlunsplit # 兼容直接运行脚本:避免 app/utils 被放在 sys.path 首位导致标准库模块被同名文件遮蔽 if __name__ == "__main__" and __package__ is None: script_dir = os.path.dirname(os.path.abspath(__file__)) project_root = os.path.abspath(os.path.join(script_dir, "..", "..")) if script_dir in sys.path: sys.path.remove(script_dir) if project_root not in sys.path: sys.path.insert(0, project_root) import requests from app.utils.ugreen_crypto import UgreenCrypto class UgreenLoginError(Exception): pass def _normalize_base_url(raw: str) -> str: value = (raw or "").strip() if not value: raise UgreenLoginError("服务器地址不能为空") if not value.startswith(("http://", "https://")): value = f"http://{value}" parsed = urlsplit(value) if not parsed.netloc: raise UgreenLoginError(f"无效服务器地址: {raw}") return urlunsplit((parsed.scheme, parsed.netloc, "", "", "")).rstrip("/") def _json_or_raise(resp: requests.Response, stage: str) -> dict[str, Any]: try: data = resp.json() except Exception as exc: # pragma: no cover - 网络异常路径 raise UgreenLoginError( f"{stage} 返回非 JSON,HTTP {resp.status_code},响应片段: {resp.text[:200]}" ) from exc if not isinstance(data, dict): raise UgreenLoginError(f"{stage} 返回格式异常: {type(data).__name__}") return data def _decode_public_key(raw: str) -> str: value = (raw or "").strip() if not value: raise UgreenLoginError("未获取到公钥") if "BEGIN" in value: return value try: return base64.b64decode(value).decode("utf-8") except Exception as exc: raise UgreenLoginError("公钥解码失败") from exc def _raise_if_failed(payload: Mapping[str, Any], stage: str) -> None: if payload.get("code") == 200: return raise UgreenLoginError( f"{stage}失败: code={payload.get('code')} msg={payload.get('msg')}" ) def _build_common_headers( client_id: str, client_version: str, language: str ) -> dict[str, str]: return { "Accept": "application/json, text/plain, */*", "Client-Id": client_id, "Client-Version": client_version, "UG-Agent": "PC/WEB", "X-Specify-Language": language, } def _login_and_get_access( session: requests.Session, base_url: str, username: str, password: str, keepalive: bool, headers: Mapping[str, str], timeout: float, verify_ssl: bool, ) -> tuple[str, str]: check_resp = session.post( f"{base_url}/ugreen/v1/verify/check", json={"username": username}, headers=dict(headers), timeout=timeout, verify=verify_ssl, ) check_json = _json_or_raise(check_resp, "获取登录公钥") _raise_if_failed(check_json, "获取登录公钥") rsa_token = ( check_resp.headers.get("x-rsa-token") or check_resp.headers.get("X-Rsa-Token") or check_json.get("xRsaToken") or check_json.get("x-rsa-token") ) if not rsa_token: data = check_json.get("data") if isinstance(data, Mapping): rsa_token = data.get("xRsaToken") or data.get("x-rsa-token") if not rsa_token: raise UgreenLoginError("登录公钥为空(x-rsa-token)") login_public_key = _decode_public_key(str(rsa_token)) encrypted_password = UgreenCrypto(public_key=login_public_key).rsa_encrypt_long( password ) login_payload = { "username": username, "password": encrypted_password, "keepalive": keepalive, "otp": True, "is_simple": True, } login_resp = session.post( f"{base_url}/ugreen/v1/verify/login", json=login_payload, headers=dict(headers), timeout=timeout, verify=verify_ssl, ) login_json = _json_or_raise(login_resp, "登录") _raise_if_failed(login_json, "登录") data = login_json.get("data") if not isinstance(data, Mapping): raise UgreenLoginError("登录成功但响应 data 为空") token = str(data.get("token") or "").strip() public_key = str(data.get("public_key") or "").strip() if not token: raise UgreenLoginError("登录成功但未拿到 token") if not public_key: raise UgreenLoginError("登录成功但未拿到 public_key") return token, _decode_public_key(public_key) def _fetch_media_lib( session: requests.Session, base_url: str, token: str, public_key: str, client_id: str, client_version: str, language: str, page: int, page_size: int, timeout: float, verify_ssl: bool, ) -> Any: crypto = UgreenCrypto( public_key=public_key, token=token, client_id=client_id, client_version=client_version, ug_agent="PC/WEB", language=language, ) req = crypto.build_encrypted_request( url=f"{base_url}/ugreen/v1/video/homepage/media_list", method="GET", params={"page": page, "page_size": page_size}, ) media_resp = session.get( req.url, headers=req.headers, params=req.params, timeout=timeout, verify=verify_ssl, ) media_json = _json_or_raise(media_resp, "获取媒体库") return crypto.decrypt_response(media_json, req.aes_key) def parse_args(argv: list[str]) -> argparse.Namespace: parser = argparse.ArgumentParser( description="登录绿联 NAS 并调用媒体库接口(自动处理请求加密/响应解密)" ) parser.add_argument("--host", help="服务器地址,例如: http://192.168.20.101:9999") parser.add_argument("--username", help="用户名") parser.add_argument("--password", help="密码(不传则交互输入)") parser.add_argument("--client-id", help="可选,默认自动生成 UUID-WEB") parser.add_argument("--client-version", default="76363", help="默认: 76363") parser.add_argument("--language", default="zh-CN", help="默认: zh-CN") parser.add_argument("--page", type=int, default=1, help="默认: 1") parser.add_argument("--page-size", type=int, default=50, help="默认: 50") parser.add_argument("--timeout", type=float, default=20.0, help="默认: 20 秒") parser.add_argument("--insecure", action="store_true", help="忽略 HTTPS 证书校验") parser.add_argument( "--no-keepalive", action="store_true", help="关闭保持登录(默认保持登录)", ) parser.add_argument("--pretty", action="store_true", help="美化输出 JSON") parser.add_argument("--output", help="将解密后的结果写入文件") return parser.parse_args(argv) def main(argv: list[str] | None = None) -> int: args = parse_args(argv or sys.argv[1:]) host = args.host or input("服务器地址: ").strip() username = args.username or input("用户名: ").strip() password = args.password or getpass.getpass("密码: ") client_id = (args.client_id or f"{uuid.uuid4()}-WEB").strip() keepalive = not args.no_keepalive verify_ssl = not args.insecure try: base_url = _normalize_base_url(host) if args.insecure: requests.packages.urllib3.disable_warnings() # type: ignore[attr-defined] session = requests.Session() headers = _build_common_headers( client_id=client_id, client_version=args.client_version, language=args.language, ) token, public_key = _login_and_get_access( session=session, base_url=base_url, username=username, password=password, keepalive=keepalive, headers=headers, timeout=args.timeout, verify_ssl=verify_ssl, ) decoded = _fetch_media_lib( session=session, base_url=base_url, token=token, public_key=public_key, client_id=client_id, client_version=args.client_version, language=args.language, page=args.page, page_size=args.page_size, timeout=args.timeout, verify_ssl=verify_ssl, ) if isinstance(decoded, Mapping): if decoded.get("code") != 200: raise UgreenLoginError( f"媒体库接口失败: code={decoded.get('code')} msg={decoded.get('msg')}" ) media_count = None data = decoded.get("data") if isinstance(data, Mapping) and isinstance(data.get("media_lib_info_list"), list): media_count = len(data["media_lib_info_list"]) print( f"调用成功: code={decoded.get('code')} msg={decoded.get('msg')} " f"media_lib_info_list={media_count}" ) text = json.dumps( decoded, ensure_ascii=False, indent=2 if args.pretty else None, separators=(",", ":") if not args.pretty else None, ) if args.output: with open(args.output, "w", encoding="utf-8") as f: f.write(text) f.write("\n") print(f"解密结果已写入: {args.output}") else: print(text) return 0 except UgreenLoginError as exc: print(f"错误: {exc}", file=sys.stderr) return 1 except requests.RequestException as exc: print(f"网络错误: {exc}", file=sys.stderr) return 2 if __name__ == "__main__": raise SystemExit(main()) ================================================ FILE: tests/run.py ================================================ import unittest from tests.test_bluray import BluRayTest from tests.test_mediascrape import ( TestMediaScrapingPaths, TestMediaScrapingNFO, TestMediaScrapingImages, TestMediaScrapingTVDirectory, TestMediaScrapeEvents ) from tests.test_metainfo import MetaInfoTest from tests.test_object import ObjectUtilsTest if __name__ == '__main__': suite = unittest.TestSuite() # 测试名称识别 suite.addTest(MetaInfoTest('test_metainfo')) suite.addTest(MetaInfoTest('test_emby_format_ids')) suite.addTest(ObjectUtilsTest('test_check_method')) # 测试自定义识别词功能 suite.addTest(MetaInfoTest('test_metainfopath_with_custom_words')) suite.addTest(MetaInfoTest('test_metainfopath_without_custom_words')) suite.addTest(MetaInfoTest('test_metainfopath_with_empty_custom_words')) suite.addTest(MetaInfoTest('test_custom_words_apply_words_recording')) # 测试蓝光目录识别 suite.addTest(BluRayTest()) # 测试媒体刮削 suite.addTest(unittest.TestLoader().loadTestsFromTestCase(TestMediaScrapingPaths)) suite.addTest(unittest.TestLoader().loadTestsFromTestCase(TestMediaScrapingNFO)) suite.addTest(unittest.TestLoader().loadTestsFromTestCase(TestMediaScrapingImages)) suite.addTest(unittest.TestLoader().loadTestsFromTestCase(TestMediaScrapingTVDirectory)) suite.addTest(unittest.TestLoader().loadTestsFromTestCase(TestMediaScrapeEvents)) # 运行测试 runner = unittest.TextTestRunner() runner.run(suite) ================================================ FILE: tests/test_bluray.py ================================================ #!/usr/bin/env python # -*- coding:utf-8 -*- from pathlib import Path from typing import Optional from unittest import TestCase from unittest.mock import patch from app import schemas from app.chain.media import MediaChain from app.chain.storage import StorageChain from app.chain.transfer import TransferChain from app.core.context import MediaInfo from app.core.event import Event from app.core.metainfo import MetaInfoPath from app.db.models.transferhistory import TransferHistory from app.log import logger from app.schemas.types import EventType from tests.cases.files import bluray_files class BluRayTest(TestCase): def __init__(self, methodName="test"): super().__init__(methodName) self.__history = [] self.__root = schemas.FileItem( path="/", name="", type="dir", extension="", size=0 ) self.__all = {self.__root.path: self.__root} def __build_child(parent: schemas.FileItem, files: list[tuple[str, list | int]]): parent.children = [] for name, children in files: sep = "" if parent.path.endswith("/") else "/" file_item = schemas.FileItem( path=f"{parent.path}{sep}{name}", name=name, extension=Path(name).suffix[1:], basename=Path(name).stem, type="file" if isinstance(children, int) else "dir", size=children if isinstance(children, int) else 0, ) parent.children.append(file_item) self.__all[file_item.path] = file_item if isinstance(children, list): __build_child(file_item, children) __build_child(self.__root, bluray_files) def _test_do_transfer(self): def __test_do_transfer(path: str): self.__history.clear() TransferChain().do_transfer( force=False, background=False, fileitem=StorageChain().get_file_item(None, Path(path)), ) return self.__history self.assertEqual( [ "/FOLDER/Digimon/Digimon BluRay (2055)", "/FOLDER/Digimon/Digimon BluRay (2099)", "/FOLDER/Digimon/Digimon (2199)/Digimon.2199.mp4", ], __test_do_transfer("/FOLDER/Digimon"), ) self.assertEqual( [ "/FOLDER/Digimon/Digimon BluRay (2055)", ], __test_do_transfer("/FOLDER/Digimon/Digimon BluRay (2055)"), ) self.assertEqual( [ "/FOLDER/Digimon/Digimon BluRay (2055)", ], __test_do_transfer("/FOLDER/Digimon/Digimon BluRay (2055)/BDMV"), ) self.assertEqual( [ "/FOLDER/Digimon/Digimon BluRay (2055)", ], __test_do_transfer("/FOLDER/Digimon/Digimon BluRay (2055)/BDMV/STREAM"), ) self.assertEqual( [ "/FOLDER/Digimon/Digimon BluRay (2055)", ], __test_do_transfer( "/FOLDER/Digimon/Digimon BluRay (2055)/BDMV/STREAM/00001.m2ts" ), ) self.assertEqual( [ "/FOLDER/Digimon/Digimon (2199)/Digimon.2199.mp4", ], __test_do_transfer("/FOLDER/Digimon/Digimon (2199)"), ) self.assertEqual( [ "/FOLDER/Digimon/Digimon (2199)/Digimon.2199.mp4", ], __test_do_transfer("/FOLDER/Digimon/Digimon (2199)/Digimon.2199.mp4"), ) self.assertEqual( [ "/FOLDER/Pokemon.2029.mp4", ], __test_do_transfer("/FOLDER/Pokemon.2029.mp4"), ) self.assertEqual( [ "/FOLDER/Digimon/Digimon BluRay (2055)", "/FOLDER/Digimon/Digimon BluRay (2099)", "/FOLDER/Digimon/Digimon (2199)/Digimon.2199.mp4", "/FOLDER/Pokemon BluRay (2016)", "/FOLDER/Pokemon BluRay (2021)", "/FOLDER/Pokemon (2028)/Pokemon.2028.mkv", "/FOLDER/Pokemon.2029.mp4", "/FOLDER/Pokemon.2039.mp4", "/FOLDER/Pokemon (2031)/Pokemon (2031).mp4", ], __test_do_transfer("/"), ) def _test_scrape_metadata(self, mock_metadata_nfo): def __test_scrape_metadata(path: str, excepted_nfo_count: int = 1): """ 分别测试手动和自动刮削 """ fileitem = StorageChain().get_file_item(None, Path(path)) meta = MetaInfoPath(Path(fileitem.path)) mediainfo = MediaInfo(tmdb_info={"id": 1, "title": "Test"}) # 测试手动刮削 logger.debug(f"测试手动刮削 {path}") mock_metadata_nfo.call_count = 0 MediaChain().scrape_metadata( fileitem=fileitem, meta=meta, mediainfo=mediainfo, overwrite=True ) # 确保调用了指定次数的metadata_nfo self.assertEqual(mock_metadata_nfo.call_count, excepted_nfo_count) # 测试自动刮削 logger.debug(f"测试自动刮削 {path}") mock_metadata_nfo.call_count = 0 MediaChain().scrape_metadata_event( Event( event_type=EventType.MetadataScrape, event_data={ "meta": meta, "mediainfo": mediainfo, "fileitem": fileitem, "file_list": [fileitem.path], "overwrite": False, }, ) ) # 调用了指定次数的metadata_nfo self.assertEqual(mock_metadata_nfo.call_count, excepted_nfo_count) # 刮削原盘目录 __test_scrape_metadata("/FOLDER/Digimon/Digimon BluRay (2099)") # 刮削电影文件 __test_scrape_metadata("/FOLDER/Digimon/Digimon (2199)/Digimon.2199.mp4") # 刮削电影目录 __test_scrape_metadata("/FOLDER", excepted_nfo_count=2) @patch("app.chain.ChainBase.metadata_img", return_value=None) # 避免获取图片 @patch("app.chain.ChainBase.__init__", return_value=None) # 避免不必要的模块初始化 @patch("app.db.transferhistory_oper.TransferHistoryOper.get_by_src") @patch("app.chain.storage.StorageChain.list_files") @patch("app.chain.storage.StorageChain.get_parent_item") @patch("app.chain.storage.StorageChain.get_file_item") def test( self, mock_get_file_item, mock_get_parent_item, mock_list_files, mock_get_by_src, *_, ): def get_file_item(storage: str, path: Path): path_posix = path.as_posix() return self.__all.get(path_posix) def get_parent_item(fileitem: schemas.FileItem): return get_file_item(None, Path(fileitem.path).parent) def list_files(fileitem: schemas.FileItem, recursion: bool = False): if fileitem.type != "dir": return None if recursion: result = [] file_path = f"{fileitem.path}/" for path, item in self.__all.items(): if path.startswith(file_path): result.append(item) return result else: return fileitem.children def get_by_src(src: str, storage: Optional[str] = None): self.__history.append(src) result = TransferHistory() result.status = True return result mock_get_file_item.side_effect = get_file_item mock_get_parent_item.side_effect = get_parent_item mock_list_files.side_effect = list_files mock_get_by_src.side_effect = get_by_src self._test_do_transfer() with patch( "app.chain.media.MediaChain.metadata_nfo", return_value=None ) as mock: self._test_scrape_metadata(mock_metadata_nfo=mock) ================================================ FILE: tests/test_mediascrape.py ================================================ import sys import unittest from pathlib import Path from unittest.mock import patch, MagicMock sys.modules['app.helper.sites'] = MagicMock() sys.modules['app.db.systemconfig_oper'] = MagicMock() sys.modules['app.db.systemconfig_oper'].SystemConfigOper.return_value.get.return_value = None from app import schemas from app.chain.media import MediaChain, ScrapingOption from app.core.context import MediaInfo from app.core.event import Event from app.core.metainfo import MetaInfo from app.schemas.types import EventType, MediaType, ScrapingTarget, ScrapingMetadata, ScrapingPolicy class TestMediaScrapingPaths(unittest.TestCase): def setUp(self): self.media_chain = MediaChain() self.media_chain.storagechain = MagicMock() def test_movie_file_nfo_path(self): fileitem = schemas.FileItem(path="/movies/avatar.mkv", name="avatar.mkv", type="file", storage="local") parent_item = schemas.FileItem(path="/movies", name="movies", type="dir", storage="local") self.media_chain.storagechain.get_parent_item.return_value = parent_item target_item, target_path = self.media_chain._get_target_fileitem_and_path( current_fileitem=fileitem, item_type=ScrapingTarget.MOVIE, metadata_type=ScrapingMetadata.NFO ) self.assertEqual(target_item, parent_item) self.assertEqual(target_path, Path("/movies/avatar.nfo")) def test_movie_dir_nfo_path(self): fileitem = schemas.FileItem(path="/movies/Avatar (2009)", name="Avatar (2009)", type="dir", storage="local") target_item, target_path = self.media_chain._get_target_fileitem_and_path( current_fileitem=fileitem, item_type=ScrapingTarget.MOVIE, metadata_type=ScrapingMetadata.NFO ) self.assertEqual(target_item, fileitem) self.assertEqual(target_path, Path("/movies/Avatar (2009)/Avatar (2009).nfo")) def test_tv_dir_nfo_path(self): fileitem = schemas.FileItem(path="/tv/Show", name="Show", type="dir", storage="local") target_item, target_path = self.media_chain._get_target_fileitem_and_path( current_fileitem=fileitem, item_type=ScrapingTarget.TV, metadata_type=ScrapingMetadata.NFO ) self.assertEqual(target_item, fileitem) self.assertEqual(target_path, Path("/tv/Show/tvshow.nfo")) def test_season_dir_nfo_path(self): fileitem = schemas.FileItem(path="/tv/Show/Season 1", name="Season 1", type="dir", storage="local") target_item, target_path = self.media_chain._get_target_fileitem_and_path( current_fileitem=fileitem, item_type=ScrapingTarget.SEASON, metadata_type=ScrapingMetadata.NFO ) self.assertEqual(target_item, fileitem) self.assertEqual(target_path, Path("/tv/Show/Season 1/season.nfo")) def test_episode_file_nfo_path(self): fileitem = schemas.FileItem(path="/tv/Show/Season 1/S01E01.mp4", name="S01E01.mp4", type="file", storage="local") parent_item = schemas.FileItem(path="/tv/Show/Season 1", name="Season 1", type="dir", storage="local") self.media_chain.storagechain.get_parent_item.return_value = parent_item target_item, target_path = self.media_chain._get_target_fileitem_and_path( current_fileitem=fileitem, item_type=ScrapingTarget.EPISODE, metadata_type=ScrapingMetadata.NFO ) self.assertEqual(target_item, parent_item) self.assertEqual(target_path, Path("/tv/Show/Season 1/S01E01.nfo")) class TestMediaScrapingNFO(unittest.TestCase): def setUp(self): self.media_chain = MediaChain() self.media_chain.storagechain = MagicMock() self.media_chain.metadata_nfo = MagicMock(return_value="") self.media_chain._save_file = MagicMock() self.media_chain.scraping_policies = MagicMock() self.fileitem = schemas.FileItem(path="/movies/Avatar (2009)", name="Avatar (2009)", type="dir", storage="local") self.meta = MetaInfo("Avatar (2009)") self.mediainfo = MediaInfo() def test_scrape_nfo_off(self): self.media_chain.scraping_policies.option.return_value = ScrapingOption("movie", "nfo", ScrapingPolicy.SKIP) self.media_chain._scrape_nfo_generic(self.fileitem, self.meta, self.mediainfo, ScrapingTarget.MOVIE) self.media_chain.metadata_nfo.assert_not_called() self.media_chain._save_file.assert_not_called() def test_scrape_nfo_on_exists_skip(self): self.media_chain.scraping_policies.option.return_value = ScrapingOption("movie", "nfo", ScrapingPolicy.MISSINGONLY) # mock file exists self.media_chain.storagechain.get_file_item.return_value = schemas.FileItem(path="/movies/Avatar (2009)/Avatar (2009).nfo", name="Avatar (2009).nfo", type="file", storage="local") self.media_chain._scrape_nfo_generic(self.fileitem, self.meta, self.mediainfo, ScrapingTarget.MOVIE) self.media_chain.metadata_nfo.assert_not_called() self.media_chain._save_file.assert_not_called() def test_scrape_nfo_on_not_exists_scrape(self): self.media_chain.scraping_policies.option.return_value = ScrapingOption("movie", "nfo", ScrapingPolicy.MISSINGONLY) # mock file not exists self.media_chain.storagechain.get_file_item.return_value = None self.media_chain._scrape_nfo_generic(self.fileitem, self.meta, self.mediainfo, ScrapingTarget.MOVIE) self.media_chain.metadata_nfo.assert_called_once() self.media_chain._save_file.assert_called_once() def test_scrape_nfo_overwrite_exists_scrape(self): self.media_chain.scraping_policies.option.return_value = ScrapingOption("movie", "nfo", ScrapingPolicy.OVERWRITE) # mock file exists self.media_chain.storagechain.get_file_item.return_value = schemas.FileItem(path="/movies/Avatar (2009)/Avatar (2009).nfo", name="Avatar (2009).nfo", type="file", storage="local") self.media_chain._scrape_nfo_generic(self.fileitem, self.meta, self.mediainfo, ScrapingTarget.MOVIE) self.media_chain.metadata_nfo.assert_called_once() self.media_chain._save_file.assert_called_once() class TestMediaScrapingImages(unittest.TestCase): def setUp(self): self.media_chain = MediaChain() self.original_download = self.media_chain._download_and_save_image self.media_chain.storagechain = MagicMock() self.media_chain.metadata_img = MagicMock() self.media_chain._download_and_save_image = MagicMock() self.media_chain.scraping_policies = MagicMock() def tearDown(self): self.media_chain._download_and_save_image = self.original_download def test_scrape_images_mapping(self): fileitem = schemas.FileItem(path="/movies/Avatar", name="Avatar", type="dir", storage="local") mediainfo = MediaInfo() self.media_chain.metadata_img.return_value = { "poster.jpg": "http://poster", "fanart.jpg": "http://fanart", "logo.png": "http://logo" } self.media_chain.scraping_policies.option.return_value = ScrapingOption("movie", "poster", ScrapingPolicy.OVERWRITE) self.media_chain.storagechain.get_file_item.return_value = None self.media_chain._scrape_images_generic(fileitem, mediainfo, ScrapingTarget.MOVIE) # Check download called for mapped metadata calls = self.media_chain._download_and_save_image.call_args_list self.assertEqual(len(calls), 3) urls = [call.kwargs["url"] for call in calls] self.assertIn("http://poster", urls) self.assertIn("http://fanart", urls) self.assertIn("http://logo", urls) def test_scrape_images_season_filter(self): fileitem = schemas.FileItem(path="/tv/Show/Season 1", name="Season 1", type="dir", storage="local") mediainfo = MediaInfo() self.media_chain.metadata_img.return_value = { "season01-poster.jpg": "http://season01", "season02-poster.jpg": "http://season02" } self.media_chain.scraping_policies.option.return_value = ScrapingOption("season", "poster", ScrapingPolicy.OVERWRITE) self.media_chain.storagechain.get_file_item.return_value = None self.media_chain._scrape_images_generic(fileitem, mediainfo, ScrapingTarget.SEASON, season_number=1) calls = self.media_chain._download_and_save_image.call_args_list self.assertEqual(len(calls), 1) self.assertEqual(calls[0].kwargs["url"], "http://season01") @patch("app.chain.media.RequestUtils") @patch("app.chain.media.NamedTemporaryFile") @patch("app.chain.media.Path.chmod") @patch("app.chain.media.settings") def test_download_and_save_image(self, mock_settings, mock_chmod, mock_temp_file, mock_request_utils): # We need to test _download_and_save_image directly so we remove mock self.media_chain = MediaChain() self.media_chain._download_and_save_image = self.original_download self.media_chain.storagechain = MagicMock() fileitem = schemas.FileItem(path="/movies/Avatar", name="Avatar", type="dir", storage="local") target_path = Path("/movies/Avatar/poster.jpg") url = "http://poster" # mock temp file tmp_mock = MagicMock() tmp_mock.name = "/tmp/mockfile" mock_temp_file.return_value.__enter__.return_value = tmp_mock # mock stream mock_stream = MagicMock() mock_stream.status_code = 200 mock_stream.iter_content.return_value = [b"data1", b"data2"] mock_instance = mock_request_utils.return_value mock_instance.get_stream.return_value.__enter__.return_value = mock_stream self.media_chain.storagechain.upload_file.return_value = fileitem self.media_chain._download_and_save_image(fileitem, target_path, url) mock_request_utils.assert_called_with(proxies=mock_settings.PROXY, ua=mock_settings.NORMAL_USER_AGENT) mock_instance.get_stream.assert_called_with(url=url) tmp_mock.write.assert_any_call(b"data1") tmp_mock.write.assert_any_call(b"data2") mock_chmod.assert_called() self.media_chain.storagechain.upload_file.assert_called_once() call_args = self.media_chain.storagechain.upload_file.call_args.kwargs self.assertEqual(call_args["fileitem"], fileitem) self.assertEqual(call_args["new_name"], "poster.jpg") class TestMediaScrapingTVDirectory(unittest.TestCase): def setUp(self): self.media_chain = MediaChain() self.media_chain.storagechain = MagicMock() self.media_chain._scrape_nfo_generic = MagicMock() self.media_chain._scrape_images_generic = MagicMock() @patch("app.chain.media.settings") def test_initialize_tv_directory_specials(self, mock_settings): # mock specials directory recognition mock_settings.RENAME_FORMAT_S0_NAMES = ["Specials", "SPs"] mock_settings.RMT_MEDIAEXT = [".mp4", ".mkv"] fileitem = schemas.FileItem(path="/tv/Show/Specials", name="Specials", type="dir", storage="local") meta = MetaInfo("Show") mediainfo = MediaInfo(type=MediaType.TV) self.media_chain.storagechain.list_files.return_value = [] self.media_chain._handle_tv_scraping(fileitem, meta, mediainfo, init_folder=True, parent=None, overwrite=False, recursive=True) self.media_chain._scrape_nfo_generic.assert_called_with( current_fileitem=fileitem, meta=meta, mediainfo=mediainfo, item_type=ScrapingTarget.SEASON, overwrite=False, season_number=0 ) self.media_chain._scrape_images_generic.assert_called_with( current_fileitem=fileitem, mediainfo=mediainfo, item_type=ScrapingTarget.SEASON, parent_fileitem=None, overwrite=False, season_number=0 ) def test_initialize_tv_directory_season(self): fileitem = schemas.FileItem(path="/tv/Show/Season 1", name="Season 1", type="dir", storage="local") meta = MetaInfo("Show") mediainfo = MediaInfo(type=MediaType.TV) self.media_chain.storagechain.list_files.return_value = [] self.media_chain._handle_tv_scraping(fileitem, meta, mediainfo, init_folder=True, parent=None, overwrite=False, recursive=True) self.media_chain._scrape_nfo_generic.assert_called_with( current_fileitem=fileitem, meta=meta, mediainfo=mediainfo, item_type=ScrapingTarget.SEASON, overwrite=False, season_number=1 ) class TestMediaScrapeEvents(unittest.TestCase): def setUp(self): self.media_chain = MediaChain() @patch("app.chain.media.MediaChain.scrape_metadata") @patch("app.chain.media.StorageChain.get_item") @patch("app.chain.media.StorageChain.get_parent_item") def test_scrape_metadata_event_file( self, mock_get_parent, mock_get_item, mock_scrape_metadata ): fileitem = schemas.FileItem(path="/movies/movie.mkv", name="movie.mkv", type="file", storage="local") parent_item = schemas.FileItem(path="/movies", name="movies", type="dir", storage="local") mock_get_item.return_value = fileitem mock_get_parent.return_value = parent_item mediainfo = MediaInfo() event = Event( event_type=EventType.MetadataScrape, event_data={ "fileitem": fileitem, "mediainfo": mediainfo, "overwrite": True } ) self.media_chain.scrape_metadata_event(event) mock_scrape_metadata.assert_called_once_with( fileitem=fileitem, mediainfo=mediainfo, init_folder=False, parent=parent_item, overwrite=True ) @patch("app.chain.media.MediaChain.scrape_metadata") @patch("app.chain.media.StorageChain.get_item") @patch("app.chain.media.StorageChain.is_bluray_folder") def test_scrape_metadata_event_dir_bluray( self, mock_is_bluray, mock_get_item, mock_scrape_metadata ): fileitem = schemas.FileItem(path="/movies/bluray_movie", name="bluray_movie", type="dir", storage="local") mock_get_item.return_value = fileitem mock_is_bluray.return_value = True mediainfo = MediaInfo() event = Event( event_type=EventType.MetadataScrape, event_data={ "fileitem": fileitem, "file_list": ["/movies/bluray_movie/BDMV/index.bdmv"], "mediainfo": mediainfo, "overwrite": False } ) self.media_chain.scrape_metadata_event(event) mock_scrape_metadata.assert_called_once_with( fileitem=fileitem, mediainfo=mediainfo, init_folder=True, recursive=False, overwrite=False ) @patch("app.chain.media.MediaChain.scrape_metadata") @patch("app.chain.media.StorageChain.get_item") @patch("app.chain.media.StorageChain.is_bluray_folder") @patch("app.chain.media.StorageChain.get_file_item") def test_scrape_metadata_event_dir_with_filelist( self, mock_get_file_item, mock_is_bluray, mock_get_item, mock_scrape_metadata ): fileitem = schemas.FileItem(path="/tv/show", name="show", type="dir", storage="local") mock_get_item.return_value = fileitem mock_is_bluray.return_value = False def side_effect_get_file_item(storage, path): path_str = str(path) return schemas.FileItem(path=path_str, name=Path(path_str).name, type="dir" if "." not in path_str else "file", storage="local") mock_get_file_item.side_effect = side_effect_get_file_item mediainfo = MediaInfo() event = Event( event_type=EventType.MetadataScrape, event_data={ "fileitem": fileitem, "file_list": ["/tv/show/Season 1/S01E01.mp4"], "mediainfo": mediainfo, "overwrite": True } ) self.media_chain.scrape_metadata_event(event) calls = mock_scrape_metadata.call_args_list self.assertEqual(len(calls), 3) paths = [call.kwargs['fileitem'].path for call in calls] self.assertIn("/tv/show", paths) self.assertIn("/tv/show/Season 1", paths) self.assertIn("/tv/show/Season 1/S01E01.mp4", paths) @patch("app.chain.media.MediaChain.scrape_metadata") @patch("app.chain.media.StorageChain.get_item") def test_scrape_metadata_event_dir_full( self, mock_get_item, mock_scrape_metadata ): fileitem = schemas.FileItem(path="/movies/movie", name="movie", type="dir", storage="local") mock_get_item.return_value = fileitem mediainfo = MediaInfo() meta = MetaInfo("movie") event = Event( event_type=EventType.MetadataScrape, event_data={ "fileitem": fileitem, "meta": meta, "mediainfo": mediainfo, "overwrite": True } ) self.media_chain.scrape_metadata_event(event) mock_scrape_metadata.assert_called_once_with( fileitem=fileitem, meta=meta, mediainfo=mediainfo, init_folder=True, overwrite=True ) @patch("app.chain.media.MediaChain._handle_movie_scraping") @patch("app.chain.media.MediaChain.recognize_by_meta") def test_scrape_metadata_movie( self, mock_recognize, mock_handle_movie ): fileitem = schemas.FileItem(path="/movies/movie.mkv", name="movie.mkv", type="file", storage="local") meta = MetaInfo("Movie") mediainfo = MediaInfo(type=MediaType.MOVIE) self.media_chain.scrape_metadata( fileitem=fileitem, meta=meta, mediainfo=mediainfo, init_folder=True, overwrite=False, recursive=True ) mock_recognize.assert_not_called() mock_handle_movie.assert_called_once_with( fileitem=fileitem, meta=meta, mediainfo=mediainfo, init_folder=True, parent=None, overwrite=False, recursive=True ) @patch("app.chain.media.MediaChain._handle_tv_scraping") @patch("app.chain.media.MediaChain.recognize_by_meta") def test_scrape_metadata_tv( self, mock_recognize, mock_handle_tv ): fileitem = schemas.FileItem(path="/tv/show", name="show", type="dir", storage="local") meta = MetaInfo("Show") mediainfo = MediaInfo(type=MediaType.TV) self.media_chain.scrape_metadata( fileitem=fileitem, meta=meta, mediainfo=mediainfo, init_folder=True, overwrite=False, recursive=True ) mock_handle_tv.assert_called_once_with( fileitem=fileitem, meta=meta, mediainfo=mediainfo, init_folder=True, parent=None, overwrite=False, recursive=True ) @patch("app.chain.media.MediaChain._handle_movie_scraping") @patch("app.chain.media.MediaChain.recognize_by_meta") def test_scrape_metadata_recognize_fallback( self, mock_recognize, mock_handle_movie ): fileitem = schemas.FileItem(path="/movies/movie.mkv", name="movie.mkv", type="file", storage="local") mediainfo = MediaInfo(type=MediaType.MOVIE) mock_recognize.return_value = mediainfo self.media_chain.scrape_metadata( fileitem=fileitem, init_folder=True, overwrite=False, recursive=True ) mock_recognize.assert_called_once() mock_handle_movie.assert_called_once() args, kwargs = mock_handle_movie.call_args self.assertEqual(kwargs['mediainfo'], mediainfo) self.assertEqual(kwargs['meta'].name, "Movie") @patch("app.chain.media.MediaChain._handle_movie_scraping") @patch("app.chain.media.MediaChain._handle_tv_scraping") def test_scrape_metadata_invalid_extension( self, mock_handle_tv, mock_handle_movie ): fileitem = schemas.FileItem(path="/movies/movie.txt", name="movie.txt", type="file", storage="local") self.media_chain.scrape_metadata( fileitem=fileitem ) mock_handle_movie.assert_not_called() mock_handle_tv.assert_not_called() @patch("app.chain.media.MediaChain.scrape_metadata") @patch("app.chain.media.StorageChain.get_item") @patch("app.chain.media.StorageChain.is_bluray_folder") @patch("app.chain.media.StorageChain.get_file_item") def test_scrape_metadata_event_dir_with_multiple_files( self, mock_get_file_item, mock_is_bluray, mock_get_item, mock_scrape_metadata ): fileitem = schemas.FileItem(path="/movies/collection", name="collection", type="dir", storage="local") mock_get_item.return_value = fileitem mock_is_bluray.return_value = False def side_effect_get_file_item(storage, path): path_str = str(path) return schemas.FileItem(path=path_str, name=Path(path_str).name, type="dir" if "." not in path_str else "file", storage="local") mock_get_file_item.side_effect = side_effect_get_file_item mediainfo = MediaInfo() event = Event( event_type=EventType.MetadataScrape, event_data={ "fileitem": fileitem, "file_list": [ "/movies/collection/movie1.mp4", "/movies/collection/movie2.mkv", "/movies/collection/movie3.avi" ], "mediainfo": mediainfo, "overwrite": True } ) self.media_chain.scrape_metadata_event(event) calls = mock_scrape_metadata.call_args_list # Should scrape directory and then each file item self.assertEqual(len(calls), 4) paths = [call.kwargs['fileitem'].path for call in calls] self.assertIn("/movies/collection", paths) self.assertIn("/movies/collection/movie1.mp4", paths) self.assertIn("/movies/collection/movie2.mkv", paths) self.assertIn("/movies/collection/movie3.avi", paths) @patch("app.chain.media.MediaChain.scrape_metadata") @patch("app.chain.media.StorageChain.get_item") @patch("app.chain.media.StorageChain.is_bluray_folder") @patch("app.chain.media.StorageChain.get_file_item") def test_scrape_metadata_event_dir_with_tv_multi_seasons_episodes( self, mock_get_file_item, mock_is_bluray, mock_get_item, mock_scrape_metadata ): fileitem = schemas.FileItem(path="/tv/MultiSeasonShow", name="MultiSeasonShow", type="dir", storage="local") mock_get_item.return_value = fileitem mock_is_bluray.return_value = False def side_effect_get_file_item(storage, path): path_str = str(path) return schemas.FileItem(path=path_str, name=Path(path_str).name, type="dir" if "." not in path_str else "file", storage="local") mock_get_file_item.side_effect = side_effect_get_file_item mediainfo = MediaInfo() event = Event( event_type=EventType.MetadataScrape, event_data={ "fileitem": fileitem, "file_list": [ "/tv/MultiSeasonShow/Season 1/S01E01.mp4", "/tv/MultiSeasonShow/Season 1/S01E02.mp4", "/tv/MultiSeasonShow/Season 2/S02E01.mkv", "/tv/MultiSeasonShow/Season 2/S02E02.mkv", "/tv/MultiSeasonShow/Specials/S00E01.mp4" ], "mediainfo": mediainfo, "overwrite": False } ) self.media_chain.scrape_metadata_event(event) calls = mock_scrape_metadata.call_args_list # main dir + 3 season dirs + 5 episode files self.assertEqual(len(calls), 9) paths = [call.kwargs['fileitem'].path for call in calls] self.assertIn("/tv/MultiSeasonShow", paths) self.assertIn("/tv/MultiSeasonShow/Season 1", paths) self.assertIn("/tv/MultiSeasonShow/Season 2", paths) self.assertIn("/tv/MultiSeasonShow/Specials", paths) self.assertIn("/tv/MultiSeasonShow/Season 1/S01E01.mp4", paths) self.assertIn("/tv/MultiSeasonShow/Season 1/S01E02.mp4", paths) self.assertIn("/tv/MultiSeasonShow/Season 2/S02E01.mkv", paths) self.assertIn("/tv/MultiSeasonShow/Season 2/S02E02.mkv", paths) self.assertIn("/tv/MultiSeasonShow/Specials/S00E01.mp4", paths) @patch("app.chain.media.MediaChain.recognize_by_meta") def test_scrape_metadata_recognize_fail( self, mock_recognize ): fileitem = schemas.FileItem(path="/movies/movie.mkv", name="movie.mkv", type="file", storage="local") mock_recognize.return_value = None with patch('app.chain.media.logger.warn') as mock_logger: self.media_chain.scrape_metadata( fileitem=fileitem ) mock_logger.assert_called_with(f"{Path(fileitem.path)} 无法识别文件媒体信息!") if __name__ == "__main__": unittest.main() ================================================ FILE: tests/test_metainfo.py ================================================ # -*- coding: utf-8 -*- from pathlib import Path from unittest import TestCase from app.core.metainfo import MetaInfo, MetaInfoPath from tests.cases.meta import meta_cases class MetaInfoTest(TestCase): def setUp(self) -> None: pass def tearDown(self) -> None: pass def test_metainfo(self): for info in meta_cases: if info.get("path"): meta_info = MetaInfoPath(path=Path(info.get("path"))) else: meta_info = MetaInfo(title=info.get("title"), subtitle=info.get("subtitle"), custom_words=["#"]) target = { "type": meta_info.type.value, "cn_name": meta_info.cn_name or "", "en_name": meta_info.en_name or "", "year": meta_info.year or "", "part": meta_info.part or "", "season": meta_info.season, "episode": meta_info.episode, "restype": meta_info.edition, "pix": meta_info.resource_pix or "", "video_codec": meta_info.video_encode or "", "audio_codec": meta_info.audio_encode or "", "fps": meta_info.fps or None } # 检查tmdbid if info.get("target").get("tmdbid"): target["tmdbid"] = meta_info.tmdbid self.assertEqual(target, info.get("target")) def test_emby_format_ids(self): """ 测试Emby格式ID识别 """ # 测试文件路径 test_paths = [ # 文件名中包含tmdbid ("/movies/The Vampire Diaries (2009) [tmdbid=18165]/The.Vampire.Diaries.S01E01.1080p.mkv", 18165), # 目录名中包含tmdbid ("/movies/Inception (2010) [tmdbid-27205]/Inception.2010.1080p.mkv", 27205), # 父目录名中包含tmdbid ("/movies/Breaking Bad (2008) [tmdb=1396]/Season 1/Breaking.Bad.S01E01.1080p.mkv", 1396), # 祖父目录名中包含tmdbid ("/tv/Game of Thrones (2011) {tmdb=1399}/Season 1/Game.of.Thrones.S01E01.1080p.mkv", 1399), # 测试{tmdb-xxx}格式 ("/movies/Avatar (2009) {tmdb-19995}/Avatar.2009.1080p.mkv", 19995), ] for path_str, expected_tmdbid in test_paths: meta = MetaInfoPath(Path(path_str)) self.assertEqual(meta.tmdbid, expected_tmdbid, f"路径 {path_str} 期望的tmdbid为 {expected_tmdbid},实际识别为 {meta.tmdbid}") def test_metainfopath_with_custom_words(self): """测试 MetaInfoPath 使用自定义识别词""" # 测试替换词:将"测试替换"替换为空 custom_words = ["测试替换 => "] path = Path("/movies/电影测试替换名称 (2024)/movie.mkv") meta = MetaInfoPath(path, custom_words=custom_words) # 验证替换生效:cn_name 不应包含"测试替换" if meta.cn_name: self.assertNotIn("测试替换", meta.cn_name) def test_metainfopath_without_custom_words(self): """测试 MetaInfoPath 不传入自定义识别词""" path = Path("/movies/Normal Movie (2024)/movie.mkv") meta = MetaInfoPath(path) # 验证正常识别,不报错 self.assertIsNotNone(meta) def test_metainfopath_with_empty_custom_words(self): """测试 MetaInfoPath 传入空的自定义识别词""" path = Path("/movies/Test Movie (2024)/movie.mkv") meta = MetaInfoPath(path, custom_words=[]) # 验证不报错,正常识别 self.assertIsNotNone(meta) def test_custom_words_apply_words_recording(self): """测试 apply_words 记录功能""" custom_words = ["替换词 => 新词"] title = "电影替换词.2024.mkv" meta = MetaInfo(title=title, custom_words=custom_words) # 验证 apply_words 属性存在 self.assertTrue(hasattr(meta, 'apply_words')) # 如果替换词被应用,应该记录在 apply_words 中 if meta.apply_words: self.assertIn("替换词 => 新词", meta.apply_words) ================================================ FILE: tests/test_object.py ================================================ from unittest import TestCase from app.utils.object import ObjectUtils class ObjectUtilsTest(TestCase): def test_check_method(self): def implemented_function(): return "Hello" def pass_function(): pass def docstring_function(): """This is a docstring.""" def ellipsis_function(): ... def not_implemented_function(): raise NotImplementedError def not_implemented_function_with_call(): raise NotImplementedError() async def multiple_lines_async_def(_param1: str, _param2: str): pass def empty_function(): return self.assertTrue(ObjectUtils.check_method(implemented_function)) self.assertFalse(ObjectUtils.check_method(pass_function)) self.assertFalse(ObjectUtils.check_method(docstring_function)) self.assertFalse(ObjectUtils.check_method(ellipsis_function)) self.assertFalse(ObjectUtils.check_method(not_implemented_function)) self.assertFalse(ObjectUtils.check_method(not_implemented_function_with_call)) self.assertFalse(ObjectUtils.check_method(multiple_lines_async_def)) self.assertTrue(ObjectUtils.check_method(empty_function)) ================================================ FILE: tests/test_release_group.py ================================================ from unittest import TestCase from tests.cases.groups import release_group_cases from app.core.meta.releasegroup import ReleaseGroupsMatcher class MetaInfoTest(TestCase): def test_release_group(self): for info in release_group_cases: print(f"开始测试 {info.get('domain')}") for item in info.get('groups', []): release_group = ReleaseGroupsMatcher().match(item.get("title")) print(f"\tmatch release group {release_group}, should be: {item.get('group')}") self.assertEqual(item.get("group"), release_group) print(f"完成 {info.get('domain')}") ================================================ FILE: tests/test_string.py ================================================ from unittest import TestCase from app.utils.string import StringUtils class StringUtilsTest(TestCase): def test_is_media_title_like_true(self): self.assertTrue(StringUtils.is_media_title_like("盗梦空间")) self.assertTrue(StringUtils.is_media_title_like("The Lord of the Rings")) self.assertTrue(StringUtils.is_media_title_like("庆余年 第2季")) self.assertTrue(StringUtils.is_media_title_like("The Office S01E01")) self.assertTrue(StringUtils.is_media_title_like("权力的游戏 Game of Thrones")) self.assertTrue(StringUtils.is_media_title_like("Spider-Man: No Way Home 2021")) def test_is_media_title_like_false(self): self.assertFalse(StringUtils.is_media_title_like("")) self.assertFalse(StringUtils.is_media_title_like(" ")) self.assertFalse(StringUtils.is_media_title_like("a")) self.assertFalse(StringUtils.is_media_title_like("第2季")) self.assertFalse(StringUtils.is_media_title_like("S01E01")) self.assertFalse(StringUtils.is_media_title_like("#推荐电影")) self.assertFalse(StringUtils.is_media_title_like("请帮我推荐一部电影")) self.assertFalse(StringUtils.is_media_title_like("盗梦空间怎么样?")) self.assertFalse(StringUtils.is_media_title_like("我想看盗梦空间")) self.assertFalse(StringUtils.is_media_title_like("继续")) ================================================ FILE: tests/test_telegram.py ================================================ # -*- coding: utf-8 -*- """ Telegram模块单元测试 """ import unittest from app.core.context import MediaInfo, Context, TorrentInfo from app.core.metainfo import MetaInfo from app.modules.telegram.telegram import Telegram from app.schemas.types import MediaType class TestTelegram(unittest.TestCase): def setUp(self): """测试前准备""" # 创建Telegram实例,使用虚假的token和chat_id防止真实发送 self.telegram = Telegram(TELEGRAM_TOKEN='', TELEGRAM_CHAT_ID='') def tearDown(self): """测试后清理""" pass def test_send_msg_success(self): """测试发送普通消息成功""" # 调用send_msg方法 result = self.telegram.send_msg( title="📥 开始下载\n唐朝诡事录 (2022)S03E31-E32", text="\n🕒 时间: 2025-11-21 18:14:51\n🎭 类别: 国产剧\n🌐 站点: 天空\n🌟 质量: WEB-DL 2160p\n💾 大小: 1.68G\n⚡️ 促销: 未知\n🚨 H&R: 否\n📛 名称: \nStrange Tales of Tang Dynasty S03E31-E32 2025 2160p WEB-DL DDP5.1 H265-Pure@HDSWEB [唐朝诡事录之长安3 / 唐朝诡事录3 / 唐朝诡事录 第三部 / 唐朝诡事录·长安 / 唐诡3 / Horror Stories of Tang Dynasty Ⅲ / Strange Legend of Tang Dynasty Ⅲ 第3季 第31-32集 | 主演: 杨旭文 杨志刚 郜思雯 [内封简繁英多国软字幕] 【去头尾广告纯享版】[非伪去头] *发现未去净的广告或片头片尾,奖励魔力1W]" ) # 验证返回值 self.assertTrue(result is True) def test_send_msg_with_longtext(self): """测试发送长消息""" result = self.telegram.send_msg( title="MoviePilot助手", text="好的,为您推荐一些近期热门的电视剧:\n\n* *怪奇物语 (Stranger Things)* - 2016年,TMDB评分8.6\n* *小丑回魂:欢迎来到德里镇* - 2025年,TMDB评分8.0\n* *维京传奇* - 2013年,TMDB评分8.1\n* *地狱客栈* - 2024年,TMDB评分8.7\n* *超人回来了* - 2013年,TMDB评分7.7\n\n还有一些经典剧集也一直很受欢迎:\n\n* *法律与秩序:特殊受害者* - 1999年,TMDB评分7.9\n* *实习医生格蕾* - 2005年,TMDB评分8.2\n* *邪恶力量* - 2005年,TMDB评分8.3\n* *菜鸟老警* - 2018年,TMDB评分8.5\n* *猎魔人* - 2019年,TMDB评分8.0\n* *海军罪案调查处* - 2003年,TMDB评分7.6\n* *塔尔萨之王* - 2022年,TMDB评分8.3\n* *武士生死斗* - 2025年,TMDB评分8.1\n* *嗜血法医* - 2006年,TMDB评分8.2\n* *辛普森一家* - 1989年,TMDB评分8.0\n* *无耻之徒* - 2011年,TMDB评分8.2\n* *绝命毒师* - 2008年,TMDB评分8.9\n* *法律与秩序* - 1990年,TMDB评分7.4\n* *权力的游戏* - 2011年,TMDB评分8.5\n\n您对哪部剧比较感兴趣,或者想了解更多信息呢?好的,为您推荐一些近期热门的电视剧:\n\n* *怪奇物语 (Stranger Things)* - 2016年,TMDB评分8.6\n* *小丑回魂:欢迎来到德里镇* - 2025年,TMDB评分8.0\n* *维京传奇* - 2013年,TMDB评分8.1\n* *地狱客栈* - 2024年,TMDB评分8.7\n* *超人回来了* - 2013年,TMDB评分7.7\n\n还有一些经典剧集也一直很受欢迎:\n\n* *法律与秩序:特殊受害者* - 1999年,TMDB评分7.9\n* *实习医生格蕾* - 2005年,TMDB评分8.2\n* *邪恶力量* - 2005年,TMDB评分8.3\n* *菜鸟老警* - 2018年,TMDB评分8.5\n* *猎魔人* - 2019年,TMDB评分8.0\n* *海军罪案调查处* - 2003年,TMDB评分7.6\n* *塔尔萨之王* - 2022年,TMDB评分8.3\n* *武士生死斗* - 2025年,TMDB评分8.1\n* *嗜血法医* - 2006年,TMDB评分8.2\n* *辛普森一家* - 1989年,TMDB评分8.0\n* *无耻之徒* - 2011年,TMDB评分8.2\n* *绝命毒师* - 2008年,TMDB评分8.9\n* *法律与秩序* - 1990年,TMDB评分7.4\n* *权力的游戏* - 2011年,TMDB评分8.5\n\n您对哪部剧比较感兴趣,或者想了解更多信息呢?好的,为您推荐一些近期热门的电视剧:\n\n* *怪奇物语 (Stranger Things)* - 2016年,TMDB评分8.6\n* *小丑回魂:欢迎来到德里镇* - 2025年,TMDB评分8.0\n* *维京传奇* - 2013年,TMDB评分8.1\n* *地狱客栈* - 2024年,TMDB评分8.7\n* *超人回来了* - 2013年,TMDB评分7.7\n\n还有一些经典剧集也一直很受欢迎:\n\n* *法律与秩序:特殊受害者* - 1999年,TMDB评分7.9\n* *实习医生格蕾* - 2005年,TMDB评分8.2\n* *邪恶力量* - 2005年,TMDB评分8.3\n* *菜鸟老警* - 2018年,TMDB评分8.5\n* *猎魔人* - 2019年,TMDB评分8.0\n* *海军罪案调查处* - 2003年,TMDB评分7.6\n* *塔尔萨之王* - 2022年,TMDB评分8.3\n* *武士生死斗* - 2025年,TMDB评分8.1\n* *嗜血法医* - 2006年,TMDB评分8.2\n* *辛普森一家* - 1989年,TMDB评分8.0\n* *无耻之徒* - 2011年,TMDB评分8.2\n* *绝命毒师* - 2008年,TMDB评分8.9\n* *法律与秩序* - 1990年,TMDB评分7.4\n* *权力的游戏* - 2011年,TMDB评分8.5\n\n您对哪部剧比较感兴趣,或者想了解更多信息呢?好的,为您推荐一些近期热门的电视剧:\n\n* *怪奇物语 (Stranger Things)* - 2016年,TMDB评分8.6\n* *小丑回魂:欢迎来到德里镇* - 2025年,TMDB评分8.0\n* *维京传奇* - 2013年,TMDB评分8.1\n* *地狱客栈* - 2024年,TMDB评分8.7\n* *超人回来了* - 2013年,TMDB评分7.7\n\n还有一些经典剧集也一直很受欢迎:\n\n* *法律与秩序:特殊受害者* - 1999年,TMDB评分7.9\n* *实习医生格蕾* - 2005年,TMDB评分8.2\n* *邪恶力量* - 2005年,TMDB评分8.3\n* *菜鸟老警* - 2018年,TMDB评分8.5\n* *猎魔人* - 2019年,TMDB评分8.0\n* *海军罪案调查处* - 2003年,TMDB评分7.6\n* *塔尔萨之王* - 2022年,TMDB评分8.3\n* *武士生死斗* - 2025年,TMDB评分8.1\n* *嗜血法医* - 2006年,TMDB评分8.2\n* *辛普森一家* - 1989年,TMDB评分8.0\n* *无耻之徒* - 2011年,TMDB评分8.2\n* *绝命毒师* - 2008年,TMDB评分8.9\n* *法律与秩序* - 1990年,TMDB评分7.4\n* *权力的游戏* - 2011年,TMDB评分8.5\n\n您对哪部剧比较感兴趣,或者想了解更多信息呢?好的,为您推荐一些近期热门的电视剧:\n\n* *怪奇物语 (Stranger Things)* - 2016年,TMDB评分8.6\n* *小丑回魂:欢迎来到德里镇* - 2025年,TMDB评分8.0\n* *维京传奇* - 2013年,TMDB评分8.1\n* *地狱客栈* - 2024年,TMDB评分8.7\n* *超人回来了* - 2013年,TMDB评分7.7\n\n还有一些经典剧集也一直很受欢迎:\n\n* *法律与秩序:特殊受害者* - 1999年,TMDB评分7.9\n* *实习医生格蕾* - 2005年,TMDB评分8.2\n* *邪恶力量* - 2005年,TMDB评分8.3\n* *菜鸟老警* - 2018年,TMDB评分8.5\n* *猎魔人* - 2019年,TMDB评分8.0\n* *海军罪案调查处* - 2003年,TMDB评分7.6\n* *塔尔萨之王* - 2022年,TMDB评分8.3\n* *武士生死斗* - 2025年,TMDB评分8.1\n* *嗜血法医* - 2006年,TMDB评分8.2\n* *辛普森一家* - 1989年,TMDB评分8.0\n* *无耻之徒* - 2011年,TMDB评分8.2\n* *绝命毒师* - 2008年,TMDB评分8.9\n* *法律与秩序* - 1990年,TMDB评分7.4\n* *权力的游戏* - 2011年,TMDB评分8.5\n\n您对哪部剧比较感兴趣,或者想了解更多信息呢?好的,为您推荐一些近期热门的电视剧:\n\n* *怪奇物语 (Stranger Things)* - 2016年,TMDB评分8.6\n* *小丑回魂:欢迎来到德里镇* - 2025年,TMDB评分8.0\n* *维京传奇* - 2013年,TMDB评分8.1\n* *地狱客栈* - 2024年,TMDB评分8.7\n* *超人回来了* - 2013年,TMDB评分7.7\n\n还有一些经典剧集也一直很受欢迎:\n\n* *法律与秩序:特殊受害者* - 1999年,TMDB评分7.9\n* *实习医生格蕾* - 2005年,TMDB评分8.2\n* *邪恶力量* - 2005年,TMDB评分8.3\n* *菜鸟老警* - 2018年,TMDB评分8.5\n* *猎魔人* - 2019年,TMDB评分8.0\n* *海军罪案调查处* - 2003年,TMDB评分7.6\n* *塔尔萨之王* - 2022年,TMDB评分8.3\n* *武士生死斗* - 2025年,TMDB评分8.1\n* *嗜血法医* - 2006年,TMDB评分8.2\n* *辛普森一家* - 1989年,TMDB评分8.0\n* *无耻之徒* - 2011年,TMDB评分8.2\n* *绝命毒师* - 2008年,TMDB评分8.9\n* *法律与秩序* - 1990年,TMDB评分7.4\n* *权力的游戏* - 2011年,TMDB评分8.5\n\n您对哪部剧比较感兴趣,或者想了解更多信息呢?好的,为您推荐一些近期热门的电视剧:\n\n* *怪奇物语 (Stranger Things)* - 2016年,TMDB评分8.6\n* *小丑回魂:欢迎来到德里镇* - 2025年,TMDB评分8.0\n* *维京传奇* - 2013年,TMDB评分8.1\n* *地狱客栈* - 2024年,TMDB评分8.7\n* *超人回来了* - 2013年,TMDB评分7.7\n\n还有一些经典剧集也一直很受欢迎:\n\n* *法律与秩序:特殊受害者* - 1999年,TMDB评分7.9\n* *实习医生格蕾* - 2005年,TMDB评分8.2\n* *邪恶力量* - 2005年,TMDB评分8.3\n* *菜鸟老警* - 2018年,TMDB评分8.5\n* *猎魔人* - 2019年,TMDB评分8.0\n* *海军罪案调查处* - 2003年,TMDB评分7.6\n* *塔尔萨之王* - 2022年,TMDB评分8.3\n* *武士生死斗* - 2025年,TMDB评分8.1\n* *嗜血法医* - 2006年,TMDB评分8.2\n* *辛普森一家* - 1989年,TMDB评分8.0\n* *无耻之徒* - 2011年,TMDB评分8.2\n* *绝命毒师* - 2008年,TMDB评分8.9\n* *法律与秩序* - 1990年,TMDB评分7.4\n* *权力的游戏* - 2011年,TMDB评分8.5\n\n您对哪部剧比较感兴趣,或者想了解更多信息呢?好的,为您推荐一些近期热门的电视剧:\n\n* *怪奇物语 (Stranger Things)* - 2016年,TMDB评分8.6\n* *小丑回魂:欢迎来到德里镇* - 2025年,TMDB评分8.0\n* *维京传奇* - 2013年,TMDB评分8.1\n* *地狱客栈* - 2024年,TMDB评分8.7\n* *超人回来了* - 2013年,TMDB评分7.7\n\n还有一些经典剧集也一直很受欢迎:\n\n* *法律与秩序:特殊受害者* - 1999年,TMDB评分7.9\n* *实习医生格蕾* - 2005年,TMDB评分8.2\n* *邪恶力量* - 2005年,TMDB评分8.3\n* *菜鸟老警* - 2018年,TMDB评分8.5\n* *猎魔人* - 2019年,TMDB评分8.0\n* *海军罪案调查处* - 2003年,TMDB评分7.6\n* *塔尔萨之王* - 2022年,TMDB评分8.3\n* *武士生死斗* - 2025年,TMDB评分8.1\n* *嗜血法医* - 2006年,TMDB评分8.2\n* *辛普森一家* - 1989年,TMDB评分8.0\n* *无耻之徒* - 2011年,TMDB评分8.2\n* *绝命毒师* - 2008年,TMDB评分8.9\n* *法律与秩序* - 1990年,TMDB评分7.4\n* *权力的游戏* - 2011年,TMDB评分8.5\n\n您对哪部剧比较感兴趣,或者想了解更多信息呢?好的,为您推荐一些近期热门的电视剧:\n\n* *怪奇物语 (Stranger Things)* - 2016年,TMDB评分8.6\n* *小丑回魂:欢迎来到德里镇* - 2025年,TMDB评分8.0\n* *维京传奇* - 2013年,TMDB评分8.1\n* *地狱客栈* - 2024年,TMDB评分8.7\n* *超人回来了* - 2013年,TMDB评分7.7\n\n还有一些经典剧集也一直很受欢迎:\n\n* *法律与秩序:特殊受害者* - 1999年,TMDB评分7.9\n* *实习医生格蕾* - 2005年,TMDB评分8.2\n* *邪恶力量* - 2005年,TMDB评分8.3\n* *菜鸟老警* - 2018年,TMDB评分8.5\n* *猎魔人* - 2019年,TMDB评分8.0\n* *海军罪案调查处* - 2003年,TMDB评分7.6\n* *塔尔萨之王* - 2022年,TMDB评分8.3\n* *武士生死斗* - 2025年,TMDB评分8.1\n* *嗜血法医* - 2006年,TMDB评分8.2\n* *辛普森一家* - 1989年,TMDB评分8.0\n* *无耻之徒* - 2011年,TMDB评分8.2\n* *绝命毒师* - 2008年,TMDB评分8.9\n* *法律与秩序* - 1990年,TMDB评分7.4\n* *权力的游戏* - 2011年,TMDB评分8.5\n\n您对哪部剧比较感兴趣,或者想了解更多信息呢?好的,为您推荐一些近期热门的电视剧:\n\n* *怪奇物语 (Stranger Things)* - 2016年,TMDB评分8.6\n* *小丑回魂:欢迎来到德里镇* - 2025年,TMDB评分8.0\n* *维京传奇* - 2013年,TMDB评分8.1\n* *地狱客栈* - 2024年,TMDB评分8.7\n* *超人回来了* - 2013年,TMDB评分7.7\n\n还有一些经典剧集也一直很受欢迎:\n\n* *法律与秩序:特殊受害者* - 1999年,TMDB评分7.9\n* *实习医生格蕾* - 2005年,TMDB评分8.2\n* *邪恶力量* - 2005年,TMDB评分8.3\n* *菜鸟老警* - 2018年,TMDB评分8.5\n* *猎魔人* - 2019年,TMDB评分8.0\n* *海军罪案调查处* - 2003年,TMDB评分7.6\n* *塔尔萨之王* - 2022年,TMDB评分8.3\n* *武士生死斗* - 2025年,TMDB评分8.1\n* *嗜血法医* - 2006年,TMDB评分8.2\n* *辛普森一家* - 1989年,TMDB评分8.0\n* *无耻之徒* - 2011年,TMDB评分8.2\n* *绝命毒师* - 2008年,TMDB评分8.9\n* *法律与秩序* - 1990年,TMDB评分7.4\n* *权力的游戏* - 2011年,TMDB评分8.5\n\n您对哪部剧比较感兴趣,或者想了解更多信息呢?好的,为您推荐一些近期热门的电视剧:\n\n* *怪奇物语 (Stranger Things)* - 2016年,TMDB评分8.6\n* *小丑回魂:欢迎来到德里镇* - 2025年,TMDB评分8.0\n* *维京传奇* - 2013年,TMDB评分8.1\n* *地狱客栈* - 2024年,TMDB评分8.7\n* *超人回来了* - 2013年,TMDB评分7.7\n\n还有一些经典剧集也一直很受欢迎:\n\n* *法律与秩序:特殊受害者* - 1999年,TMDB评分7.9\n* *实习医生格蕾* - 2005年,TMDB评分8.2\n* *邪恶力量* - 2005年,TMDB评分8.3\n* *菜鸟老警* - 2018年,TMDB评分8.5\n* *猎魔人* - 2019年,TMDB评分8.0\n* *海军罪案调查处* - 2003年,TMDB评分7.6\n* *塔尔萨之王* - 2022年,TMDB评分8.3\n* *武士生死斗* - 2025年,TMDB评分8.1\n* *嗜血法医* - 2006年,TMDB评分8.2\n* *辛普森一家* - 1989年,TMDB评分8.0\n* *无耻之徒* - 2011年,TMDB评分8.2\n* *绝命毒师* - 2008年,TMDB评分8.9\n* *法律与秩序* - 1990年,TMDB评分7.4\n* *权力的游戏* - 2011年,TMDB评分8.5\n\n您对哪部剧比较感兴趣,或者想了解更多信息呢?好的,为您推荐一些近期热门的电视剧:\n\n* *怪奇物语 (Stranger Things)* - 2016年,TMDB评分8.6\n* *小丑回魂:欢迎来到德里镇* - 2025年,TMDB评分8.0\n* *维京传奇* - 2013年,TMDB评分8.1\n* *地狱客栈* - 2024年,TMDB评分8.7\n* *超人回来了* - 2013年,TMDB评分7.7\n\n还有一些经典剧集也一直很受欢迎:\n\n* *法律与秩序:特殊受害者* - 1999年,TMDB评分7.9\n* *实习医生格蕾* - 2005年,TMDB评分8.2\n* *邪恶力量* - 2005年,TMDB评分8.3\n* *菜鸟老警* - 2018年,TMDB评分8.5\n* *猎魔人* - 2019年,TMDB评分8.0\n* *海军罪案调查处* - 2003年,TMDB评分7.6\n* *塔尔萨之王* - 2022年,TMDB评分8.3\n* *武士生死斗* - 2025年,TMDB评分8.1\n* *嗜血法医* - 2006年,TMDB评分8.2\n* *辛普森一家* - 1989年,TMDB评分8.0\n* *无耻之徒* - 2011年,TMDB评分8.2\n* *绝命毒师* - 2008年,TMDB评分8.9\n* *法律与秩序* - 1990年,TMDB评分7.4\n* *权力的游戏* - 2011年,TMDB评分8.5\n\n您对哪部剧比较感兴趣,或者想了解更多信息呢?", ) def test_send_medias_msg_success(self): """测试发送媒体列表消息成功""" # 创建模拟的媒体信息列表 media1 = MediaInfo() media1.type = MediaType.MOVIE media1.title = "测试电影1" media1.year = "2023" media1.vote_average = 8.5 media1.poster_path = "https://raw.githubusercontent.com/jxxghp/MoviePilot-Frontend/refs/heads/v2/public/logo.png" media1.tmdb_id=123123 media2 = MediaInfo() media2.type = MediaType.TV media2.title = "测试电视剧1" media2.year = "2023" media2.vote_average = 9.0 media2.poster_path = "https://raw.githubusercontent.com/jxxghp/MoviePilot-Frontend/refs/heads/v2/public/logo.png" medias = [media1, media2] result = self.telegram.send_medias_msg( medias=medias, title="推荐媒体列表" ) self.assertTrue(result is True) def test_send_medias_msg_without_vote_average(self): """测试发送无评分的媒体列表消息""" # 创建模拟的媒体信息列表(无评分) media1 = MediaInfo() media1.type = MediaType.MOVIE media1.title = "测试电影1" media1.year = "2023" media1.poster_path = "https://raw.githubusercontent.com/jxxghp/MoviePilot-Frontend/refs/heads/v2/public/logo.png" media1.tmdb_id=123123 medias = [media1] result = self.telegram.send_medias_msg( medias=medias, title="推荐媒体列表" ) self.assertTrue(result is True) def test_send_medias_msg_with_link_and_buttons(self): """测试发送带链接和按钮的媒体列表消息""" media1 = MediaInfo() media1.type = MediaType.MOVIE media1.title = "测试*-|\.电影1" media1.year = "2023" media1.vote_average = 8.5 media1.poster_path = "https://raw.githubusercontent.com/jxxghp/MoviePilot-Frontend/refs/heads/v2/public/logo.png" media1.tmdb_id=123123 medias = [media1] buttons = [[ {"text": "测试按钮", "callback_data": "test_callback"} ]] result = self.telegram.send_medias_msg( medias=medias, title="推荐媒体列表", link="http://example.com", buttons=buttons ) self.assertTrue(result is True) def test_send_torrents_msg_success(self): """测试发送种子列表消息成功""" # 创建模拟的种子信息 media_info = MediaInfo() media_info.type = MediaType.TV media_info.title = "唐朝诡事录" media_info.year = "2025" media_info.poster_path = "https://raw.githubusercontent.com/jxxghp/MoviePilot-Frontend/refs/heads/v2/public/logo.png" torrent_info = TorrentInfo() torrent_info.site_name = "测试*-|\.站点" torrent_info.title = "唐朝诡事录" torrent_info.description = "唐朝诡事录之长安3 / 唐朝诡事录3 / 唐朝诡事录 第三部 / 唐朝诡事录·长安 / 唐诡3 / Horror Stories of Tang Dynasty Ⅲ / Strange Legend of Tang Dynasty Ⅲ 第3季 第31-32集 | 主演: 杨旭文 杨志刚 郜思雯 [内封简繁英多国软字幕] 【去头尾广告纯享版】[非伪去头] *发现未去净的广告或片头片尾,奖励魔力1W" torrent_info.page_url = "http://example.com/torrent" torrent_info.size = 1024 * 1024 * 1024 # 1GB torrent_info.seeders = 10 torrent_info.uploadvolumefactor = 1.0 torrent_info.downloadvolumefactor = 0.0 meta_info = MetaInfo(title="唐朝诡事录") context = Context() context.media_info = media_info context.torrent_info = torrent_info context.meta_info = meta_info torrents = [context] result = self.telegram.send_torrents_msg( torrents=torrents, title="种子列表" ) self.assertTrue(result is True) def test_send_torrents_msg_with_link_and_buttons(self): """测试发送带链接和按钮的种子列表消息""" media_info = MediaInfo() media_info.type = MediaType.MOVIE media_info.title = "^测试电影~_测试_" media_info.year = "2023" media_info.poster_path = "https://raw.githubusercontent.com/jxxghp/MoviePilot-Frontend/refs/heads/v2/public/logo.png" torrent_info = TorrentInfo() torrent_info.site_name = "^测试~站点_测试_" torrent_info.title = "测试种子标题" torrent_info.description = "测试种子描述" torrent_info.page_url = "http://example.com/torrent" torrent_info.size = 1024 * 1024 * 1024 # 1GB torrent_info.seeders = 10 torrent_info.uploadvolumefactor = 1.0 torrent_info.downloadvolumefactor = 0.0 meta_info = MetaInfo(title="测试种子标题") context = Context() context.media_info = media_info context.torrent_info = torrent_info context.meta_info = meta_info torrents = [context] buttons = [[ {"text": "测试按钮", "callback_data": "test_callback"} ]] result = self.telegram.send_torrents_msg( torrents=torrents, title="种子列表", link="http://example.com", buttons=buttons ) self.assertTrue(result is True) def test_send_msg_with_buttons_and_link(self): """测试发送带按钮和链接的消息""" buttons = [[ {"text": "测试按钮", "callback_data": "test_callback"} ]] result = self.telegram.send_msg( title="测试标题", text="*测试内容*", link="http://example.com", buttons=buttons ) # 验证返回值 self.assertTrue(result is True) def test_send_msg_with_url_buttons(self): """测试发送带URL按钮的消息""" buttons = [[ {"text": "URL按钮", "url": "http://example.com"} ]] result = self.telegram.send_msg( title="测试标题", text="测试内容", buttons=buttons ) # 验证返回值 self.assertTrue(result is True) def test_send_msg_markdown_escaping(self): """测试Markdown特殊字符转义""" result = self.telegram.send_msg( title="测试标题", text="_测试_||内容||" ) # 验证返回值 self.assertTrue(result is True) if __name__ == '__main__': unittest.main() ================================================ FILE: tests/test_transfer_history_retransfer.py ================================================ from types import ModuleType, SimpleNamespace import sys # The endpoint import pulls in a wide plugin/helper graph. Some optional modules are # not present in this test environment, so stub them before importing the endpoint. sys.modules.setdefault("app.helper.sites", ModuleType("app.helper.sites")) setattr(sys.modules["app.helper.sites"], "SitesHelper", object) from app.api.endpoints.transfer import manual_transfer from app.schemas import ManualTransferItem def test_manual_transfer_from_history_preserves_download_context(monkeypatch): history = SimpleNamespace( status=0, mode="copy", src_fileitem={"storage": "local", "path": "/downloads/test.mkv", "name": "test.mkv", "type": "file"}, dest_fileitem=None, downloader="qbittorrent", download_hash="abc123", type="电视剧", tmdbid="100", doubanid="200", seasons="S01", episodes="E01-E02", episode_group="WEB-DL", ) captured = {} def fake_get(_db, logid): assert logid == 1 return history class FakeTransferChain: def manual_transfer(self, **kwargs): captured.update(kwargs) return True, "" monkeypatch.setattr("app.api.endpoints.transfer.TransferHistory.get", fake_get) monkeypatch.setattr("app.api.endpoints.transfer.TransferChain", FakeTransferChain) resp = manual_transfer( transer_item=ManualTransferItem(logid=1, from_history=True), background=True, db=object(), _="token", ) assert resp.success is True assert captured["downloader"] == "qbittorrent" assert captured["download_hash"] == "abc123" assert captured["episode_group"] == "WEB-DL" assert captured["season"] == 1 ================================================ FILE: tests/test_ugreen_api.py ================================================ import unittest from types import SimpleNamespace from unittest.mock import patch from app.modules.ugreen.api import Api class _FakeResponse: def __init__(self, payload: dict, headers: dict | None = None): self._payload = payload self.headers = headers or {} def json(self): return self._payload class _FakeSession: def __init__(self, get_responses=None, post_responses=None): self._get_responses = list(get_responses or []) self._post_responses = list(post_responses or []) self.calls: list[tuple[str, dict]] = [] self.cookies = SimpleNamespace( get_dict=lambda: {}, update=lambda *_args, **_kwargs: None, ) def get(self, *args, **kwargs): if args: kwargs = {"url": args[0], **kwargs} self.calls.append(("GET", kwargs)) return self._get_responses.pop(0) if self._get_responses else _FakeResponse({}) def post(self, *args, **kwargs): if args: kwargs = {"url": args[0], **kwargs} self.calls.append(("POST", kwargs)) return self._post_responses.pop(0) if self._post_responses else _FakeResponse({}) @staticmethod def close(): return None class _FakeCrypto: def __init__(self, *args, **kwargs): pass @staticmethod def rsa_encrypt_long(raw: str) -> str: return f"enc:{raw}" @staticmethod def build_encrypted_request(url: str, method: str = "GET", params=None, **kwargs): return SimpleNamespace(url=url, headers={}, params=params or {}, json=None, aes_key="k") @staticmethod def decrypt_response(payload, aes_key): return payload class UgreenApiVerifySslTest(unittest.TestCase): def test_request_json_default_verify_ssl_true(self): api = Api(host="https://example.com") fake_session = _FakeSession( get_responses=[_FakeResponse({"code": 200})], post_responses=[_FakeResponse({"code": 200})], ) api._session = fake_session api._request_json(url="https://example.com/a", method="GET") api._request_json(url="https://example.com/b", method="POST", json_data={"x": 1}) self.assertEqual(fake_session.calls[0][1].get("verify"), True) self.assertEqual(fake_session.calls[1][1].get("verify"), True) def test_login_logout_follow_verify_ssl_flag(self): api = Api(host="https://example.com", verify_ssl=False) fake_session = _FakeSession( get_responses=[_FakeResponse({})], post_responses=[ _FakeResponse({"code": 200, "msg": "ok", "data": {}}, headers={"x-rsa-token": "BEGIN TEST"}), _FakeResponse( { "code": 200, "msg": "ok", "data": { "token": "token-value", "public_key": "BEGIN LOGIN KEY", "static_token": "static-token", "is_ugk": False, }, } ), ], ) api._session = fake_session with patch("app.modules.ugreen.api.UgreenCrypto", _FakeCrypto): token = api.login("tester", "pwd") self.assertEqual(token, "token-value") api.logout() self.assertEqual(len(fake_session.calls), 3) self.assertEqual(fake_session.calls[0][0], "POST") self.assertEqual(fake_session.calls[1][0], "POST") self.assertEqual(fake_session.calls[2][0], "GET") self.assertEqual(fake_session.calls[0][1].get("verify"), False) self.assertEqual(fake_session.calls[1][1].get("verify"), False) self.assertEqual(fake_session.calls[2][1].get("verify"), False) if __name__ == "__main__": unittest.main() ================================================ FILE: tests/test_ugreen_crypto.py ================================================ import base64 import hashlib import json import unittest from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric import padding, rsa from app.utils.ugreen_crypto import UgreenCrypto def _generate_rsa_keys() -> tuple[str, rsa.RSAPrivateKey]: private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) public_pem = private_key.public_key().public_bytes( encoding=serialization.Encoding.PEM, format=serialization.PublicFormat.PKCS1, ).decode("utf-8") return public_pem, private_key def _rsa_decrypt_long(private_key: rsa.RSAPrivateKey, payload_b64: str) -> str: encrypted = base64.b64decode(payload_b64) chunk_size = private_key.key_size // 8 plain_chunks = [] for start in range(0, len(encrypted), chunk_size): chunk = encrypted[start : start + chunk_size] plain_chunks.append(private_key.decrypt(chunk, padding.PKCS1v15())) return b"".join(plain_chunks).decode("utf-8") class UgreenCryptoTest(unittest.TestCase): def setUp(self): self.public_key, self.private_key = _generate_rsa_keys() self.token = "demo-token-for-test" self.crypto = UgreenCrypto( public_key=self.public_key, token=self.token, client_id="test-client-id", ) def test_rsa_encrypt_long(self): plain = "A" * 400 encrypted = self.crypto.rsa_encrypt_long(plain) self.assertEqual(plain, _rsa_decrypt_long(self.private_key, encrypted)) def test_build_encrypted_request_and_decrypt_response(self): req = self.crypto.build_encrypted_request( url="http://127.0.0.1:9999/ugreen/v1/video/homepage/media_list", params={"page": 1, "page_size": 50}, data={"foo": "bar", "count": 2}, ) self.assertEqual( req.plain_query, "page=1&page_size=50", ) self.assertEqual( req.plain_query, self.crypto.aes_gcm_decrypt(req.params["encrypt_query"], req.aes_key), ) self.assertEqual( req.headers["X-Ugreen-Security-Key"], hashlib.md5(self.token.encode("utf-8")).hexdigest(), ) self.assertEqual( req.aes_key, _rsa_decrypt_long(self.private_key, req.headers["X-Ugreen-Security-Code"]), ) self.assertEqual( self.token, _rsa_decrypt_long(self.private_key, req.headers["X-Ugreen-Token"]), ) encrypted_body = req.json["encrypt_req_body"] body_plain = self.crypto.aes_gcm_decrypt(encrypted_body, req.aes_key) self.assertEqual(json.loads(body_plain), {"foo": "bar", "count": 2}) self.assertEqual( req.json["req_body_sha256"], hashlib.sha256(body_plain.encode("utf-8")).hexdigest(), ) server_payload = {"code": 0, "msg": "ok", "data": {"items": [1, 2, 3]}} resp = { "encrypt_resp_body": self.crypto.aes_gcm_encrypt( json.dumps(server_payload, ensure_ascii=False, separators=(",", ":")), req.aes_key, ) } decoded = self.crypto.decrypt_response(resp, req.aes_key) self.assertEqual(decoded, server_payload) if __name__ == "__main__": unittest.main() ================================================ FILE: tests/test_ugreen_mediaserver.py ================================================ import unittest from unittest.mock import patch import importlib.util import sys import types from pathlib import Path from app import schemas try: from app.api.endpoints import dashboard as dashboard_endpoint except Exception: dashboard_endpoint = None def _load_ugreen_class(): """ 在测试中动态加载 Ugreen,避免受可选依赖(如 pyquery/sqlalchemy)影响。 """ module_name = "_test_ugreen_module" if module_name in sys.modules: return sys.modules[module_name].Ugreen # 轻量日志桩 if "app.log" not in sys.modules: log_module = types.ModuleType("app.log") class _Logger: def info(self, *_args, **_kwargs): pass def warning(self, *_args, **_kwargs): pass def error(self, *_args, **_kwargs): pass def debug(self, *_args, **_kwargs): pass log_module.logger = _Logger() sys.modules["app.log"] = log_module # SystemConfigOper 桩 if "app.db.systemconfig_oper" not in sys.modules: db_module = types.ModuleType("app.db.systemconfig_oper") class _SystemConfigOper: @staticmethod def get(_key): return {} @staticmethod def set(_key, _value): return None db_module.SystemConfigOper = _SystemConfigOper sys.modules["app.db.systemconfig_oper"] = db_module # app.modules / app.modules.ugreen / app.modules.ugreen.api 桩 if "app.modules" not in sys.modules: pkg = types.ModuleType("app.modules") pkg.__path__ = [] sys.modules["app.modules"] = pkg if "app.modules.ugreen" not in sys.modules: subpkg = types.ModuleType("app.modules.ugreen") subpkg.__path__ = [] sys.modules["app.modules.ugreen"] = subpkg if "app.modules.ugreen.api" not in sys.modules: api_module = types.ModuleType("app.modules.ugreen.api") class _Api: host = "" token = None api_module.Api = _Api sys.modules["app.modules.ugreen.api"] = api_module ugreen_path = Path(__file__).resolve().parents[1] / "app" / "modules" / "ugreen" / "ugreen.py" spec = importlib.util.spec_from_file_location(module_name, ugreen_path) module = importlib.util.module_from_spec(spec) sys.modules[module_name] = module assert spec and spec.loader spec.loader.exec_module(module) return module.Ugreen Ugreen = _load_ugreen_class() class _FakeUgreenApi: host = "http://127.0.0.1:9999" token = "test-token" @staticmethod def video_all(classification: int, page: int = 1, page_size: int = 1): if classification == -102: return {"total_num": 12} if classification == -103: return {"total_num": 34} return {"total_num": 0} class UgreenScanModeTest(unittest.TestCase): def test_resolve_scan_type(self): resolve = Ugreen._Ugreen__resolve_scan_type self.assertEqual(resolve(scan_mode="new_and_modified"), 1) self.assertEqual(resolve(scan_mode="supplement_missing"), 2) self.assertEqual(resolve(scan_mode="full_override"), 3) self.assertEqual(resolve(scan_mode="1"), 1) self.assertEqual(resolve(scan_mode="2"), 2) self.assertEqual(resolve(scan_mode="3"), 3) self.assertEqual(resolve(scan_type=1), 1) self.assertEqual(resolve(scan_type=2), 2) self.assertEqual(resolve(scan_type=3), 3) self.assertEqual(resolve(scan_mode="unknown"), 2) self.assertEqual(resolve(), 2) class UgreenVerifySslTest(unittest.TestCase): def test_resolve_verify_ssl(self): resolve = Ugreen._Ugreen__resolve_verify_ssl self.assertEqual(resolve(True), True) self.assertEqual(resolve(False), False) self.assertEqual(resolve("true"), True) self.assertEqual(resolve("1"), True) self.assertEqual(resolve("false"), False) self.assertEqual(resolve("0"), False) self.assertEqual(resolve(None), True) class UgreenStatisticTest(unittest.TestCase): def test_get_medias_count_episode_is_none(self): ugreen = Ugreen.__new__(Ugreen) ugreen._host = "http://127.0.0.1:9999" ugreen._username = "tester" ugreen._password = "secret" ugreen._userinfo = {"name": "tester"} ugreen._api = _FakeUgreenApi() stat = ugreen.get_medias_count() self.assertEqual(stat.movie_count, 12) self.assertEqual(stat.tv_count, 34) self.assertIsNone(stat.episode_count) class DashboardStatisticTest(unittest.TestCase): @unittest.skipIf(dashboard_endpoint is None, "dashboard endpoint dependencies are missing") def test_statistic_all_episode_missing(self): mocked_stats = [ schemas.Statistic(movie_count=10, tv_count=20, episode_count=None, user_count=2), schemas.Statistic(movie_count=1, tv_count=2, episode_count=None, user_count=1), ] with patch( "app.api.endpoints.dashboard.DashboardChain.media_statistic", return_value=mocked_stats, ): ret = dashboard_endpoint.statistic(name="ugreen", _=None) self.assertEqual(ret.movie_count, 11) self.assertEqual(ret.tv_count, 22) self.assertEqual(ret.user_count, 3) self.assertIsNone(ret.episode_count) @unittest.skipIf(dashboard_endpoint is None, "dashboard endpoint dependencies are missing") def test_statistic_mixed_episode_count(self): mocked_stats = [ schemas.Statistic(movie_count=10, tv_count=20, episode_count=None, user_count=2), schemas.Statistic(movie_count=1, tv_count=2, episode_count=6, user_count=1), ] with patch( "app.api.endpoints.dashboard.DashboardChain.media_statistic", return_value=mocked_stats, ): ret = dashboard_endpoint.statistic(name="all", _=None) self.assertEqual(ret.movie_count, 11) self.assertEqual(ret.tv_count, 22) self.assertEqual(ret.user_count, 3) self.assertEqual(ret.episode_count, 6) if __name__ == "__main__": unittest.main() ================================================ FILE: version.py ================================================ APP_VERSION = 'v2.9.15' FRONTEND_VERSION = 'v2.9.15'