[
  {
    "path": ".dockerignore",
    "content": "# Git\n.github\n.git\n.gitignore\n\n# Documentation\ndocs/\nREADME.md\nLICENSE\n\n# Development files\n.pylintrc\n*.pyc\n__pycache__/\n*.pyo\n*.pyd\n.Python\n*.so\n.pytest_cache/\n.coverage\nhtmlcov/\n.tox/\n.nox/\n.hypothesis/\n.mypy_cache/\n.dmypy.json\ndmypy.json\n\n# Virtual environments\nvenv/\nenv/\nENV/\nenv.bak/\nvenv.bak/\n\n# IDE\n.vscode/\n.idea/\n*.swp\n*.swo\n*~\n\n# OS\n.DS_Store\n.DS_Store?\n._*\n.Spotlight-V100\n.Trashes\nehthumbs.db\nThumbs.db\n\n# Logs\n*.log\nlogs/\n\n# Temporary files\n*.tmp\n*.temp\ntmp/\ntemp/\n\n# Database\n*.db\n*.sqlite\n*.sqlite3\n\n# Test files\ntests/\ntest_*\n*_test.py\n\n# Build artifacts\nbuild/\ndist/\n*.egg-info/\n\n# Docker\nDockerfile*\ndocker-compose*\n.dockerignore\n\n# Other\napp.ico\nfrozen.spec"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/bug_report.yml",
    "content": "name: 问题反馈\ndescription: File a bug report\ntitle: \"[错误报告]: 请在此处简单描述你的问题\"\nlabels: [\"bug\"]\nbody:\n  - type: markdown\n    attributes:\n      value: |\n        请确认以下信息：\n        1. 请按此模板提交issues，不按模板提交的问题将直接关闭。\n        2. 如果你的问题可以直接在以往 issue 或者 Telegram频道 中找到，那么你的 issue 将会被直接关闭。\n        3. **$\\color{red}{提交问题务必描述清楚、附上日志}$**，描述不清导致无法理解和分析的问题会被直接关闭。\n        4. 此仓库为后端仓库，如果是前端 WebUI 问题请在[前端仓库](https://github.com/jxxghp/MoviePilot-Frontend)提 issue。\n        5. **$\\color{red}{不要通过issues来寻求解决你的环境问题、配置安装类问题、咨询类问题}$**，否则直接关闭并加入用户 $\\color{red}{黑名单}$ !实在没有精力陪一波又一波的伸手党玩。\n  - type: checkboxes\n    id: ensure\n    attributes:\n      label: 确认\n      description: 在提交 issue 之前，请确认你已经阅读并确认以下内容\n      options:\n        - label: 我的版本是最新版本，我的版本号与 [version](https://github.com/jxxghp/MoviePilot/releases/latest) 相同。\n          required: true\n        - label: 我已经 [issue](https://github.com/jxxghp/MoviePilot/issues) 中搜索过，确认我的问题没有被提出过。\n          required: true\n        - label: 我已经 [Telegram频道](https://t.me/moviepilot_channel) 中搜索过，确认我的问题没有被提出过。\n          required: true\n        - label: 我已经修改标题，将标题中的 描述 替换为我遇到的问题。\n          required: true\n  - type: input\n    id: version\n    attributes:\n      label: 当前程序版本\n      description: 遇到问题时程序所在的版本号\n    validations:\n      required: true\n  - type: dropdown\n    id: environment\n    attributes:\n      label: 运行环境\n      description: 当前程序运行环境\n      options:\n        - Docker\n        - Windows\n    validations:\n      required: true\n  - type: dropdown\n    id: type\n    attributes:\n      label: 问题类型\n      description: 你在以下哪个部分碰到了问题\n      options:\n        - 主程序运行问题\n        - 插件问题\n        - 其他问题\n    validations:\n      required: true\n  - type: textarea\n    id: what-happened\n    attributes:\n      label: 问题描述\n      description: 请详细描述你碰到的问题\n      placeholder: \"问题描述\"\n    validations:\n      required: true\n  - type: textarea\n    id: logs\n    attributes:\n      label: 发生问题时系统日志和配置文件\n      description: 问题出现时，程序运行日志请复制到这里。\n      render: bash\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/config.yml",
    "content": "blank_issues_enabled: false\ncontact_links:\n  - name: 项目讨论\n    url: https://github.com/jxxghp/MoviePilot/discussions/new/choose\n    about: discussion\n  - name: Telegram 频道\n    url: https://t.me/moviepilot_channel\n    about: 更新日志\n  - name: Telegram 交流群\n    url: https://t.me/moviepilot_official\n    about: 交流互助\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/feature_request.yml",
    "content": "name: 功能改进\ndescription: Feature Request\ntitle: \"[Feature Request]: \"\nlabels: [\"feature request\"]\nbody:\n  - type: markdown\n    attributes:\n      value: |\n        请说明你希望添加的功能。\n  - type: input\n    id: version\n    attributes:\n      label: 当前程序版本\n      description: 目前使用的程序版本\n    validations:\n      required: true\n  - type: dropdown\n    id: environment\n    attributes:\n      label: 运行环境\n      description: 当前程序运行环境\n      options:\n        - Docker\n        - Windows\n    validations:\n      required: true\n  - type: dropdown\n    id: type\n    attributes:\n      label: 功能改进类型\n      description: 你需要在下面哪个方面改进功能\n      options:\n        - 主程序\n        - 插件\n        - 其他\n    validations:\n      required: true\n  - type: textarea\n    id: feature-request\n    attributes:\n      label: 功能改进\n      description: 请详细描述需要改进或者添加的功能。\n      placeholder: \"功能改进\"\n    validations:\n      required: true\n  - type: textarea\n    id: references\n    attributes:\n      label: 参考资料\n      description: 可以列举一些参考资料，但是不要引用同类但商业化软件的任何内容。\n      placeholder: \"参考资料\"\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/rfc.yml",
    "content": "name: 功能提案\ndescription: Request for Comments\ntitle: \"[RFC]\"\nlabels: [\"RFC\"]\nbody:\n  - type: markdown\n    attributes:\n      value: |\n        一份提案(RFC)定位为 **「在某功能/重构的具体开发前，用于开发者间 review 技术设计/方案的文档」**，\n        目的是让协作的开发者间清晰的知道「要做什么」和「具体会怎么做」，以及所有的开发者都能公开透明的参与讨论；\n        以便评估和讨论产生的影响 (遗漏的考虑、向后兼容性、与现有功能的冲突)，\n        因此提案侧重在对解决问题的 **方案、设计、步骤** 的描述上。\n\n        如果仅希望讨论是否添加或改进某功能本身，请使用 -> [Issue: 功能改进](https://github.com/jxxghp/MoviePilot/issues/new?assignees=&labels=feature+request&projects=&template=feature_request.yml&title=%5BFeature+Request%5D%3A+)\n  - type: textarea\n    id: background\n    attributes:\n      label: 背景 or 问题\n      description: 简单描述遇到的什么问题或需要改动什么。可以引用其他 issue、讨论、文档等。\n    validations:\n      required: true\n  - type: textarea\n    id: goal\n    attributes:\n      label: \"目标 & 方案简述\"\n      description: 简单描述提案此提案实现后，**预期的目标效果**，以及简单大致描述会采取的方案/步骤，可能会/不会产生什么影响。\n    validations:\n      required: true\n  - type: textarea\n    id: design\n    attributes:\n      label: \"方案设计 & 实现步骤\"\n      description: |\n        详细描述你设计的具体方案，可以考虑拆分列表或要点，一步步描述具体打算如何实现的步骤和相关细节。\n        这部份不需要一次性写完整，即使在创建完此提案 issue 后，依旧可以再次编辑修改。\n    validations:\n      required: false\n  - type: textarea\n    id: alternative\n    attributes:\n      label: \"替代方案 & 对比\"\n      description: |\n        [可选] 为来实现目标效果，还考虑过什么其他方案，有什么对比？\n    validations:\n      required: false\n"
  },
  {
    "path": ".github/workflows/beta.yml",
    "content": "name: MoviePilot Builder Beta\non:\n  workflow_dispatch:\n\njobs:\n  Docker-build:\n    runs-on: ubuntu-latest\n    name: Build Docker Image\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v4\n\n      - name: Release version\n        id: release_version\n        run: |\n          app_version=$(cat version.py |sed -ne \"s/APP_VERSION\\s=\\s'v\\(.*\\)'/\\1/gp\")\n          echo \"app_version=$app_version\" >> $GITHUB_ENV\n\n      - name: Docker Meta\n        id: meta\n        uses: docker/metadata-action@v5\n        with:\n          images: |\n            ${{ secrets.DOCKER_USERNAME }}/moviepilot-v2\n            ghcr.io/${{ github.repository }}\n          tags: |\n            type=raw,value=beta\n\n      - name: Set Up QEMU\n        uses: docker/setup-qemu-action@v3\n\n      - name: Set Up Buildx\n        uses: docker/setup-buildx-action@v3\n\n      - name: Login DockerHub\n        uses: docker/login-action@v3\n        with:\n          username: ${{ secrets.DOCKER_USERNAME }}\n          password: ${{ secrets.DOCKER_PASSWORD }}\n\n      - name: Login GitHub Container Registry\n        uses: docker/login-action@v3\n        with:\n          registry: ghcr.io\n          username: ${{ github.actor }}\n          password: ${{ secrets.GITHUB_TOKEN }}\n\n      - name: Build Image\n        uses: docker/build-push-action@v5\n        with:\n          context: .\n          file: docker/Dockerfile\n          platforms: |\n            linux/amd64\n            linux/arm64/v8\n          push: true\n          tags: ${{ steps.meta.outputs.tags }}\n          labels: ${{ steps.meta.outputs.labels }}\n          cache-from: type=gha, scope=${{ github.workflow }}-docker\n          cache-to: type=gha, scope=${{ github.workflow }}-docker\n"
  },
  {
    "path": ".github/workflows/build.yml",
    "content": "name: MoviePilot Builder v2\non:\n  workflow_dispatch:\n  push:\n    branches:\n      - v2\n    paths:\n      - 'version.py'\n\njobs:\n  Docker-build:\n    runs-on: ubuntu-latest\n    name: Build Docker Image\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v4\n\n      - name: Release version\n        id: release_version\n        run: |\n          app_version=$(cat version.py |sed -ne \"s/APP_VERSION\\s=\\s'v\\(.*\\)'/\\1/gp\")\n          echo \"app_version=$app_version\" >> $GITHUB_ENV\n\n      - name: Docker Meta\n        id: meta\n        uses: docker/metadata-action@v5\n        with:\n          images: |\n            ${{ secrets.DOCKER_USERNAME }}/moviepilot-v2\n            ${{ secrets.DOCKER_USERNAME }}/moviepilot\n            ghcr.io/${{ github.repository }}\n          tags: |\n            type=raw,value=${{ env.app_version }}\n            type=raw,value=latest\n\n      - name: Set Up QEMU\n        uses: docker/setup-qemu-action@v3\n\n      - name: Set Up Buildx\n        uses: docker/setup-buildx-action@v3\n\n      - name: Login DockerHub\n        uses: docker/login-action@v3\n        with:\n          username: ${{ secrets.DOCKER_USERNAME }}\n          password: ${{ secrets.DOCKER_PASSWORD }}\n\n      - name: Login GitHub Container Registry\n        uses: docker/login-action@v3\n        with:\n          registry: ghcr.io\n          username: ${{ github.actor }}\n          password: ${{ secrets.GITHUB_TOKEN }}\n\n      - name: Build Image\n        uses: docker/build-push-action@v5\n        with:\n          context: .\n          file: docker/Dockerfile\n          platforms: |\n            linux/amd64\n            linux/arm64/v8\n          push: true\n          tags: ${{ steps.meta.outputs.tags }}\n          labels: ${{ steps.meta.outputs.labels }}\n          cache-from: type=gha, scope=${{ github.workflow }}-docker\n          cache-to: type=gha, scope=${{ github.workflow }}-docker\n\n      - name: Get existing release body\n        id: get_release_body\n        continue-on-error: true\n        run: |\n          release_body=$(curl -s -H \"Authorization: token ${{ secrets.GITHUB_TOKEN }}\" \\\n            \"https://api.github.com/repos/${{ github.repository }}/releases/tags/v${{ env.app_version }}\" | \\\n            jq -r '.body // \"\"')\n          echo \"RELEASE_BODY<<EOF\" >> $GITHUB_ENV\n          echo \"$release_body\" >> $GITHUB_ENV\n          echo \"EOF\" >> $GITHUB_ENV\n\n      - name: Delete Release\n        uses: dev-drprasad/delete-tag-and-release@v1.1\n        continue-on-error: true\n        with:\n          tag_name: v${{ env.app_version }}\n          delete_release: true\n          github_token: ${{ secrets.GITHUB_TOKEN }}\n\n      - name: Generate Release\n        uses: softprops/action-gh-release@v2\n        with:\n          tag_name: v${{ env.app_version }}\n          name: v${{ env.app_version }}\n          body: ${{ env.RELEASE_BODY }}\n          draft: false\n          prerelease: false\n          make_latest: true\n        env:\n          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n"
  },
  {
    "path": ".github/workflows/issues.yml",
    "content": "name: Close inactive issues\non:\n workflow_dispatch:\n\n schedule:\n   # Github Action 只支持 UTC 时间。\n   # '0 18 * * *' 对应 UTC 时间的 18:00，也就是中国时区 (UTC+8) 的第二天凌晨 02:00。\n   - cron: \"0 18 * * *\"\n\njobs:\n close-issues:\n   runs-on: ubuntu-latest\n   permissions:\n     issues: write\n     pull-requests: write\n   steps:\n     - uses: actions/stale@v5\n       with:\n         # 标记 stale 标签时间\n         days-before-issue-stale: 30\n         # 关闭 issues 标签时间\n         days-before-issue-close: 14\n         # 自定义标签名\n         stale-issue-label: \"stale\"\n         stale-issue-message: \"此问题已过时，因为它已打开 30 天且没有任何活动。\"\n         close-issue-message: \"此问题已关闭，因为它在标记为 stale 后，已处于无更新状态 14 天。\"\n         # 忽略所有的 Pull Request，只处理 Issue\n         days-before-pr-stale: -1\n         days-before-pr-close: -1\n         # 排除带有RFC标签的issue\n         exempt-issue-labels: \"RFC\"\n         operations-per-run: 500\n         repo-token: ${{ secrets.GITHUB_TOKEN }}"
  },
  {
    "path": ".github/workflows/pylint.yml",
    "content": "name: Pylint Code Quality Check\n\non:\n  # 允许手动触发\n  workflow_dispatch:\n\njobs:\n  pylint:\n    runs-on: ubuntu-latest\n    name: Pylint Code Quality Check\n\n    steps:\n    - name: Checkout code\n      uses: actions/checkout@v4\n\n    - name: Set up Python\n      uses: actions/setup-python@v5\n      with:\n        python-version: '3.12'\n        cache: 'pip'\n\n    - name: Cache pip dependencies\n      uses: actions/cache@v4\n      with:\n        path: ~/.cache/pip\n        key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt', '**/requirements.in') }}\n        restore-keys: |\n          ${{ runner.os }}-pip-\n\n    - name: Install dependencies\n      run: |\n        python -m pip install --upgrade pip setuptools wheel\n        pip install pylint\n        # 安装项目依赖\n        if [ -f requirements.txt ]; then\n          echo \"📦 安装 requirements.txt 中的依赖...\"\n          pip install -r requirements.txt\n        elif [ -f requirements.in ]; then\n          echo \"📦 安装 requirements.in 中的依赖...\"\n          pip install -r requirements.in\n        else\n          echo \"⚠️ 未找到依赖文件，仅安装 pylint\"\n        fi\n\n    - name: Verify pylint config\n      run: |\n        # 检查项目中的pylint配置文件是否存在\n        if [ -f .pylintrc ]; then\n          echo \"✅ 找到项目配置文件: .pylintrc\"\n          echo \"配置文件内容预览:\"\n          head -10 .pylintrc\n        else\n          echo \"❌ 未找到 .pylintrc 配置文件\"\n          exit 1\n        fi\n    - name: Run pylint\n      run: |\n        # 运行pylint，检查主要的Python文件\n        echo \"🚀 运行 Pylint 错误检查...\"\n\n        # 检查主要目录 - 只关注错误，如果有错误则退出\n        echo \"📂 检查 app/ 目录...\"\n        pylint app/ --output-format=colorized --reports=yes --score=yes\n\n        # 检查根目录的Python文件\n        echo \"📂 检查根目录 Python 文件...\"\n        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\n          echo \"检查文件: $file\"\n          pylint \"$file\" --output-format=colorized || exit 1\n        done\n\n        # 生成详细报告\n        echo \"📊 生成 Pylint 详细报告...\"\n        pylint app/ --output-format=json > pylint-report.json || true\n\n        # 显示评分（仅供参考）\n        echo \"📈 Pylint 评分（仅供参考）:\"\n        pylint app/ --score=yes --reports=no | tail -2 || true\n\n    - name: Upload pylint report\n      uses: actions/upload-artifact@v4\n      if: always()\n      with:\n        name: pylint-report\n        path: pylint-report.json\n\n    - name: Summary\n      run: |\n        echo \"🎉 Pylint 检查完成！\"\n        echo \"✅ 没有发现语法错误或严重问题\"\n        echo \"📊 详细报告已保存为构建工件\""
  },
  {
    "path": ".gitignore",
    "content": ".idea/\n*.c\n*.so\n*.pyd\nbuild/\ncython_cache/\ndist/\nnginx/\ntest.py\nsafety_report.txt\napp/helper/sites.py\napp/helper/*.so\napp/helper/*.pyd\napp/helper/*.bin\napp/plugins/**\n!app/plugins/__init__.py\nconfig/cookies/**\nconfig/user.db*\nconfig/sites/**\nconfig/logs/\nconfig/temp/\nconfig/cache/\n*.pyc\n*.log\n.vscode\nvenv\n\n# Pylint\npylint-report.json\n.pylint.d/\n\n# AI\n.claude/\n"
  },
  {
    "path": ".pylintrc",
    "content": "[MASTER]\n# 指定Python路径\ninit-hook='import sys; sys.path.append(\".\")'\n\n# 忽略的文件和目录\nignore=.git,__pycache__,.venv,build,dist,tests,docs\n\n# 并行作业数量\njobs=0\n\n[MESSAGES CONTROL]\n# 只关注错误级别的问题，禁用警告、约定和重构建议\n# E = Error (错误) - 会导致构建失败\n# W = Warning (警告) - 仅显示，不会失败\n# R = Refactor (重构建议) - 仅显示，不会失败\n# C = Convention (约定) - 仅显示，不会失败\n# I = Information (信息) - 仅显示，不会失败\n\n# 禁用大部分警告、约定和重构建议，只保留错误和重要警告\ndisable=all\nenable=error,\n       syntax-error,\n       undefined-variable,\n       used-before-assignment,\n       unreachable,\n       return-outside-function,\n       yield-outside-function,\n       continue-in-finally,\n       nonlocal-without-binding,\n       undefined-loop-variable,\n       redefined-builtin,\n       not-callable,\n       assignment-from-no-return,\n       no-value-for-parameter,\n       too-many-function-args,\n       unexpected-keyword-arg,\n       redundant-keyword-arg,\n       import-error,\n       relative-beyond-top-level\n\n[REPORTS]\n# 设置报告格式\noutput-format=colorized\nreports=yes\nscore=yes\n\n[FORMAT]\n# 最大行长度\nmax-line-length=120\n# 缩进大小\nindent-string='    '\n\n[DESIGN]\n# 最大参数数量\nmax-args=10\n# 最大本地变量数量\nmax-locals=20\n# 最大分支数量\nmax-branches=15\n# 最大语句数量\nmax-statements=50\n# 最大父类数量\nmax-parents=7\n# 最大属性数量\nmax-attributes=10\n# 最小公共方法数量\nmin-public-methods=1\n# 最大公共方法数量\nmax-public-methods=25\n\n[SIMILARITIES]\n# 最小相似行数\nmin-similarity-lines=6\n# 忽略注释\nignore-comments=yes\n# 忽略文档字符串\nignore-docstrings=yes\n# 忽略导入\nignore-imports=yes\n\n[TYPECHECK]\n# 生成缺失成员提示的类列表\ngenerated-members=requests.packages.urllib3"
  },
  {
    "path": "LICENSE",
    "content": "                    GNU GENERAL PUBLIC LICENSE\n                       Version 3, 29 June 2007\n\n Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>\n Everyone is permitted to copy and distribute verbatim copies\n of this license document, but changing it is not allowed.\n\n                            Preamble\n\n  The GNU General Public License is a free, copyleft license for\nsoftware and other kinds of works.\n\n  The licenses for most software and other practical works are designed\nto take away your freedom to share and change the works.  By contrast,\nthe GNU General Public License is intended to guarantee your freedom to\nshare and change all versions of a program--to make sure it remains free\nsoftware for all its users.  We, the Free Software Foundation, use the\nGNU General Public License for most of our software; it applies also to\nany other work released this way by its authors.  You can apply it to\nyour programs, too.\n\n  When we speak of free software, we are referring to freedom, not\nprice.  Our General Public Licenses are designed to make sure that you\nhave the freedom to distribute copies of free software (and charge for\nthem if you wish), that you receive source code or can get it if you\nwant it, that you can change the software or use pieces of it in new\nfree programs, and that you know you can do these things.\n\n  To protect your rights, we need to prevent others from denying you\nthese rights or asking you to surrender the rights.  Therefore, you have\ncertain responsibilities if you distribute copies of the software, or if\nyou modify it: responsibilities to respect the freedom of others.\n\n  For example, if you distribute copies of such a program, whether\ngratis or for a fee, you must pass on to the recipients the same\nfreedoms that you received.  You must make sure that they, too, receive\nor can get the source code.  And you must show them these terms so they\nknow their rights.\n\n  Developers that use the GNU GPL protect your rights with two steps:\n(1) assert copyright on the software, and (2) offer you this License\ngiving you legal permission to copy, distribute and/or modify it.\n\n  For the developers' and authors' protection, the GPL clearly explains\nthat there is no warranty for this free software.  For both users' and\nauthors' sake, the GPL requires that modified versions be marked as\nchanged, so that their problems will not be attributed erroneously to\nauthors of previous versions.\n\n  Some devices are designed to deny users access to install or run\nmodified versions of the software inside them, although the manufacturer\ncan do so.  This is fundamentally incompatible with the aim of\nprotecting users' freedom to change the software.  The systematic\npattern of such abuse occurs in the area of products for individuals to\nuse, which is precisely where it is most unacceptable.  Therefore, we\nhave designed this version of the GPL to prohibit the practice for those\nproducts.  If such problems arise substantially in other domains, we\nstand ready to extend this provision to those domains in future versions\nof the GPL, as needed to protect the freedom of users.\n\n  Finally, every program is threatened constantly by software patents.\nStates should not allow patents to restrict development and use of\nsoftware on general-purpose computers, but in those that do, we wish to\navoid the special danger that patents applied to a free program could\nmake it effectively proprietary.  To prevent this, the GPL assures that\npatents cannot be used to render the program non-free.\n\n  The precise terms and conditions for copying, distribution and\nmodification follow.\n\n                       TERMS AND CONDITIONS\n\n  0. Definitions.\n\n  \"This License\" refers to version 3 of the GNU General Public License.\n\n  \"Copyright\" also means copyright-like laws that apply to other kinds of\nworks, such as semiconductor masks.\n\n  \"The Program\" refers to any copyrightable work licensed under this\nLicense.  Each licensee is addressed as \"you\".  \"Licensees\" and\n\"recipients\" may be individuals or organizations.\n\n  To \"modify\" a work means to copy from or adapt all or part of the work\nin a fashion requiring copyright permission, other than the making of an\nexact copy.  The resulting work is called a \"modified version\" of the\nearlier work or a work \"based on\" the earlier work.\n\n  A \"covered work\" means either the unmodified Program or a work based\non the Program.\n\n  To \"propagate\" a work means to do anything with it that, without\npermission, would make you directly or secondarily liable for\ninfringement under applicable copyright law, except executing it on a\ncomputer or modifying a private copy.  Propagation includes copying,\ndistribution (with or without modification), making available to the\npublic, and in some countries other activities as well.\n\n  To \"convey\" a work means any kind of propagation that enables other\nparties to make or receive copies.  Mere interaction with a user through\na computer network, with no transfer of a copy, is not conveying.\n\n  An interactive user interface displays \"Appropriate Legal Notices\"\nto the extent that it includes a convenient and prominently visible\nfeature that (1) displays an appropriate copyright notice, and (2)\ntells the user that there is no warranty for the work (except to the\nextent that warranties are provided), that licensees may convey the\nwork under this License, and how to view a copy of this License.  If\nthe interface presents a list of user commands or options, such as a\nmenu, a prominent item in the list meets this criterion.\n\n  1. Source Code.\n\n  The \"source code\" for a work means the preferred form of the work\nfor making modifications to it.  \"Object code\" means any non-source\nform of a work.\n\n  A \"Standard Interface\" means an interface that either is an official\nstandard defined by a recognized standards body, or, in the case of\ninterfaces specified for a particular programming language, one that\nis widely used among developers working in that language.\n\n  The \"System Libraries\" of an executable work include anything, other\nthan the work as a whole, that (a) is included in the normal form of\npackaging a Major Component, but which is not part of that Major\nComponent, and (b) serves only to enable use of the work with that\nMajor Component, or to implement a Standard Interface for which an\nimplementation is available to the public in source code form.  A\n\"Major Component\", in this context, means a major essential component\n(kernel, window system, and so on) of the specific operating system\n(if any) on which the executable work runs, or a compiler used to\nproduce the work, or an object code interpreter used to run it.\n\n  The \"Corresponding Source\" for a work in object code form means all\nthe source code needed to generate, install, and (for an executable\nwork) run the object code and to modify the work, including scripts to\ncontrol those activities.  However, it does not include the work's\nSystem Libraries, or general-purpose tools or generally available free\nprograms which are used unmodified in performing those activities but\nwhich are not part of the work.  For example, Corresponding Source\nincludes interface definition files associated with source files for\nthe work, and the source code for shared libraries and dynamically\nlinked subprograms that the work is specifically designed to require,\nsuch as by intimate data communication or control flow between those\nsubprograms and other parts of the work.\n\n  The Corresponding Source need not include anything that users\ncan regenerate automatically from other parts of the Corresponding\nSource.\n\n  The Corresponding Source for a work in source code form is that\nsame work.\n\n  2. Basic Permissions.\n\n  All rights granted under this License are granted for the term of\ncopyright on the Program, and are irrevocable provided the stated\nconditions are met.  This License explicitly affirms your unlimited\npermission to run the unmodified Program.  The output from running a\ncovered work is covered by this License only if the output, given its\ncontent, constitutes a covered work.  This License acknowledges your\nrights of fair use or other equivalent, as provided by copyright law.\n\n  You may make, run and propagate covered works that you do not\nconvey, without conditions so long as your license otherwise remains\nin force.  You may convey covered works to others for the sole purpose\nof having them make modifications exclusively for you, or provide you\nwith facilities for running those works, provided that you comply with\nthe terms of this License in conveying all material for which you do\nnot control copyright.  Those thus making or running the covered works\nfor you must do so exclusively on your behalf, under your direction\nand control, on terms that prohibit them from making any copies of\nyour copyrighted material outside their relationship with you.\n\n  Conveying under any other circumstances is permitted solely under\nthe conditions stated below.  Sublicensing is not allowed; section 10\nmakes it unnecessary.\n\n  3. Protecting Users' Legal Rights From Anti-Circumvention Law.\n\n  No covered work shall be deemed part of an effective technological\nmeasure under any applicable law fulfilling obligations under article\n11 of the WIPO copyright treaty adopted on 20 December 1996, or\nsimilar laws prohibiting or restricting circumvention of such\nmeasures.\n\n  When you convey a covered work, you waive any legal power to forbid\ncircumvention of technological measures to the extent such circumvention\nis effected by exercising rights under this License with respect to\nthe covered work, and you disclaim any intention to limit operation or\nmodification of the work as a means of enforcing, against the work's\nusers, your or third parties' legal rights to forbid circumvention of\ntechnological measures.\n\n  4. Conveying Verbatim Copies.\n\n  You may convey verbatim copies of the Program's source code as you\nreceive it, in any medium, provided that you conspicuously and\nappropriately publish on each copy an appropriate copyright notice;\nkeep intact all notices stating that this License and any\nnon-permissive terms added in accord with section 7 apply to the code;\nkeep intact all notices of the absence of any warranty; and give all\nrecipients a copy of this License along with the Program.\n\n  You may charge any price or no price for each copy that you convey,\nand you may offer support or warranty protection for a fee.\n\n  5. Conveying Modified Source Versions.\n\n  You may convey a work based on the Program, or the modifications to\nproduce it from the Program, in the form of source code under the\nterms of section 4, provided that you also meet all of these conditions:\n\n    a) The work must carry prominent notices stating that you modified\n    it, and giving a relevant date.\n\n    b) The work must carry prominent notices stating that it is\n    released under this License and any conditions added under section\n    7.  This requirement modifies the requirement in section 4 to\n    \"keep intact all notices\".\n\n    c) You must license the entire work, as a whole, under this\n    License to anyone who comes into possession of a copy.  This\n    License will therefore apply, along with any applicable section 7\n    additional terms, to the whole of the work, and all its parts,\n    regardless of how they are packaged.  This License gives no\n    permission to license the work in any other way, but it does not\n    invalidate such permission if you have separately received it.\n\n    d) If the work has interactive user interfaces, each must display\n    Appropriate Legal Notices; however, if the Program has interactive\n    interfaces that do not display Appropriate Legal Notices, your\n    work need not make them do so.\n\n  A compilation of a covered work with other separate and independent\nworks, which are not by their nature extensions of the covered work,\nand which are not combined with it such as to form a larger program,\nin or on a volume of a storage or distribution medium, is called an\n\"aggregate\" if the compilation and its resulting copyright are not\nused to limit the access or legal rights of the compilation's users\nbeyond what the individual works permit.  Inclusion of a covered work\nin an aggregate does not cause this License to apply to the other\nparts of the aggregate.\n\n  6. Conveying Non-Source Forms.\n\n  You may convey a covered work in object code form under the terms\nof sections 4 and 5, provided that you also convey the\nmachine-readable Corresponding Source under the terms of this License,\nin one of these ways:\n\n    a) Convey the object code in, or embodied in, a physical product\n    (including a physical distribution medium), accompanied by the\n    Corresponding Source fixed on a durable physical medium\n    customarily used for software interchange.\n\n    b) Convey the object code in, or embodied in, a physical product\n    (including a physical distribution medium), accompanied by a\n    written offer, valid for at least three years and valid for as\n    long as you offer spare parts or customer support for that product\n    model, to give anyone who possesses the object code either (1) a\n    copy of the Corresponding Source for all the software in the\n    product that is covered by this License, on a durable physical\n    medium customarily used for software interchange, for a price no\n    more than your reasonable cost of physically performing this\n    conveying of source, or (2) access to copy the\n    Corresponding Source from a network server at no charge.\n\n    c) Convey individual copies of the object code with a copy of the\n    written offer to provide the Corresponding Source.  This\n    alternative is allowed only occasionally and noncommercially, and\n    only if you received the object code with such an offer, in accord\n    with subsection 6b.\n\n    d) Convey the object code by offering access from a designated\n    place (gratis or for a charge), and offer equivalent access to the\n    Corresponding Source in the same way through the same place at no\n    further charge.  You need not require recipients to copy the\n    Corresponding Source along with the object code.  If the place to\n    copy the object code is a network server, the Corresponding Source\n    may be on a different server (operated by you or a third party)\n    that supports equivalent copying facilities, provided you maintain\n    clear directions next to the object code saying where to find the\n    Corresponding Source.  Regardless of what server hosts the\n    Corresponding Source, you remain obligated to ensure that it is\n    available for as long as needed to satisfy these requirements.\n\n    e) Convey the object code using peer-to-peer transmission, provided\n    you inform other peers where the object code and Corresponding\n    Source of the work are being offered to the general public at no\n    charge under subsection 6d.\n\n  A separable portion of the object code, whose source code is excluded\nfrom the Corresponding Source as a System Library, need not be\nincluded in conveying the object code work.\n\n  A \"User Product\" is either (1) a \"consumer product\", which means any\ntangible personal property which is normally used for personal, family,\nor household purposes, or (2) anything designed or sold for incorporation\ninto a dwelling.  In determining whether a product is a consumer product,\ndoubtful cases shall be resolved in favor of coverage.  For a particular\nproduct received by a particular user, \"normally used\" refers to a\ntypical or common use of that class of product, regardless of the status\nof the particular user or of the way in which the particular user\nactually uses, or expects or is expected to use, the product.  A product\nis a consumer product regardless of whether the product has substantial\ncommercial, industrial or non-consumer uses, unless such uses represent\nthe only significant mode of use of the product.\n\n  \"Installation Information\" for a User Product means any methods,\nprocedures, authorization keys, or other information required to install\nand execute modified versions of a covered work in that User Product from\na modified version of its Corresponding Source.  The information must\nsuffice to ensure that the continued functioning of the modified object\ncode is in no case prevented or interfered with solely because\nmodification has been made.\n\n  If you convey an object code work under this section in, or with, or\nspecifically for use in, a User Product, and the conveying occurs as\npart of a transaction in which the right of possession and use of the\nUser Product is transferred to the recipient in perpetuity or for a\nfixed term (regardless of how the transaction is characterized), the\nCorresponding Source conveyed under this section must be accompanied\nby the Installation Information.  But this requirement does not apply\nif neither you nor any third party retains the ability to install\nmodified object code on the User Product (for example, the work has\nbeen installed in ROM).\n\n  The requirement to provide Installation Information does not include a\nrequirement to continue to provide support service, warranty, or updates\nfor a work that has been modified or installed by the recipient, or for\nthe User Product in which it has been modified or installed.  Access to a\nnetwork may be denied when the modification itself materially and\nadversely affects the operation of the network or violates the rules and\nprotocols for communication across the network.\n\n  Corresponding Source conveyed, and Installation Information provided,\nin accord with this section must be in a format that is publicly\ndocumented (and with an implementation available to the public in\nsource code form), and must require no special password or key for\nunpacking, reading or copying.\n\n  7. Additional Terms.\n\n  \"Additional permissions\" are terms that supplement the terms of this\nLicense by making exceptions from one or more of its conditions.\nAdditional permissions that are applicable to the entire Program shall\nbe treated as though they were included in this License, to the extent\nthat they are valid under applicable law.  If additional permissions\napply only to part of the Program, that part may be used separately\nunder those permissions, but the entire Program remains governed by\nthis License without regard to the additional permissions.\n\n  When you convey a copy of a covered work, you may at your option\nremove any additional permissions from that copy, or from any part of\nit.  (Additional permissions may be written to require their own\nremoval in certain cases when you modify the work.)  You may place\nadditional permissions on material, added by you to a covered work,\nfor which you have or can give appropriate copyright permission.\n\n  Notwithstanding any other provision of this License, for material you\nadd to a covered work, you may (if authorized by the copyright holders of\nthat material) supplement the terms of this License with terms:\n\n    a) Disclaiming warranty or limiting liability differently from the\n    terms of sections 15 and 16 of this License; or\n\n    b) Requiring preservation of specified reasonable legal notices or\n    author attributions in that material or in the Appropriate Legal\n    Notices displayed by works containing it; or\n\n    c) Prohibiting misrepresentation of the origin of that material, or\n    requiring that modified versions of such material be marked in\n    reasonable ways as different from the original version; or\n\n    d) Limiting the use for publicity purposes of names of licensors or\n    authors of the material; or\n\n    e) Declining to grant rights under trademark law for use of some\n    trade names, trademarks, or service marks; or\n\n    f) Requiring indemnification of licensors and authors of that\n    material by anyone who conveys the material (or modified versions of\n    it) with contractual assumptions of liability to the recipient, for\n    any liability that these contractual assumptions directly impose on\n    those licensors and authors.\n\n  All other non-permissive additional terms are considered \"further\nrestrictions\" within the meaning of section 10.  If the Program as you\nreceived it, or any part of it, contains a notice stating that it is\ngoverned by this License along with a term that is a further\nrestriction, you may remove that term.  If a license document contains\na further restriction but permits relicensing or conveying under this\nLicense, you may add to a covered work material governed by the terms\nof that license document, provided that the further restriction does\nnot survive such relicensing or conveying.\n\n  If you add terms to a covered work in accord with this section, you\nmust place, in the relevant source files, a statement of the\nadditional terms that apply to those files, or a notice indicating\nwhere to find the applicable terms.\n\n  Additional terms, permissive or non-permissive, may be stated in the\nform of a separately written license, or stated as exceptions;\nthe above requirements apply either way.\n\n  8. Termination.\n\n  You may not propagate or modify a covered work except as expressly\nprovided under this License.  Any attempt otherwise to propagate or\nmodify it is void, and will automatically terminate your rights under\nthis License (including any patent licenses granted under the third\nparagraph of section 11).\n\n  However, if you cease all violation of this License, then your\nlicense from a particular copyright holder is reinstated (a)\nprovisionally, unless and until the copyright holder explicitly and\nfinally terminates your license, and (b) permanently, if the copyright\nholder fails to notify you of the violation by some reasonable means\nprior to 60 days after the cessation.\n\n  Moreover, your license from a particular copyright holder is\nreinstated permanently if the copyright holder notifies you of the\nviolation by some reasonable means, this is the first time you have\nreceived notice of violation of this License (for any work) from that\ncopyright holder, and you cure the violation prior to 30 days after\nyour receipt of the notice.\n\n  Termination of your rights under this section does not terminate the\nlicenses of parties who have received copies or rights from you under\nthis License.  If your rights have been terminated and not permanently\nreinstated, you do not qualify to receive new licenses for the same\nmaterial under section 10.\n\n  9. Acceptance Not Required for Having Copies.\n\n  You are not required to accept this License in order to receive or\nrun a copy of the Program.  Ancillary propagation of a covered work\noccurring solely as a consequence of using peer-to-peer transmission\nto receive a copy likewise does not require acceptance.  However,\nnothing other than this License grants you permission to propagate or\nmodify any covered work.  These actions infringe copyright if you do\nnot accept this License.  Therefore, by modifying or propagating a\ncovered work, you indicate your acceptance of this License to do so.\n\n  10. Automatic Licensing of Downstream Recipients.\n\n  Each time you convey a covered work, the recipient automatically\nreceives a license from the original licensors, to run, modify and\npropagate that work, subject to this License.  You are not responsible\nfor enforcing compliance by third parties with this License.\n\n  An \"entity transaction\" is a transaction transferring control of an\norganization, or substantially all assets of one, or subdividing an\norganization, or merging organizations.  If propagation of a covered\nwork results from an entity transaction, each party to that\ntransaction who receives a copy of the work also receives whatever\nlicenses to the work the party's predecessor in interest had or could\ngive under the previous paragraph, plus a right to possession of the\nCorresponding Source of the work from the predecessor in interest, if\nthe predecessor has it or can get it with reasonable efforts.\n\n  You may not impose any further restrictions on the exercise of the\nrights granted or affirmed under this License.  For example, you may\nnot impose a license fee, royalty, or other charge for exercise of\nrights granted under this License, and you may not initiate litigation\n(including a cross-claim or counterclaim in a lawsuit) alleging that\nany patent claim is infringed by making, using, selling, offering for\nsale, or importing the Program or any portion of it.\n\n  11. Patents.\n\n  A \"contributor\" is a copyright holder who authorizes use under this\nLicense of the Program or a work on which the Program is based.  The\nwork thus licensed is called the contributor's \"contributor version\".\n\n  A contributor's \"essential patent claims\" are all patent claims\nowned or controlled by the contributor, whether already acquired or\nhereafter acquired, that would be infringed by some manner, permitted\nby this License, of making, using, or selling its contributor version,\nbut do not include claims that would be infringed only as a\nconsequence of further modification of the contributor version.  For\npurposes of this definition, \"control\" includes the right to grant\npatent sublicenses in a manner consistent with the requirements of\nthis License.\n\n  Each contributor grants you a non-exclusive, worldwide, royalty-free\npatent license under the contributor's essential patent claims, to\nmake, use, sell, offer for sale, import and otherwise run, modify and\npropagate the contents of its contributor version.\n\n  In the following three paragraphs, a \"patent license\" is any express\nagreement or commitment, however denominated, not to enforce a patent\n(such as an express permission to practice a patent or covenant not to\nsue for patent infringement).  To \"grant\" such a patent license to a\nparty means to make such an agreement or commitment not to enforce a\npatent against the party.\n\n  If you convey a covered work, knowingly relying on a patent license,\nand the Corresponding Source of the work is not available for anyone\nto copy, free of charge and under the terms of this License, through a\npublicly available network server or other readily accessible means,\nthen you must either (1) cause the Corresponding Source to be so\navailable, or (2) arrange to deprive yourself of the benefit of the\npatent license for this particular work, or (3) arrange, in a manner\nconsistent with the requirements of this License, to extend the patent\nlicense to downstream recipients.  \"Knowingly relying\" means you have\nactual knowledge that, but for the patent license, your conveying the\ncovered work in a country, or your recipient's use of the covered work\nin a country, would infringe one or more identifiable patents in that\ncountry that you have reason to believe are valid.\n\n  If, pursuant to or in connection with a single transaction or\narrangement, you convey, or propagate by procuring conveyance of, a\ncovered work, and grant a patent license to some of the parties\nreceiving the covered work authorizing them to use, propagate, modify\nor convey a specific copy of the covered work, then the patent license\nyou grant is automatically extended to all recipients of the covered\nwork and works based on it.\n\n  A patent license is \"discriminatory\" if it does not include within\nthe scope of its coverage, prohibits the exercise of, or is\nconditioned on the non-exercise of one or more of the rights that are\nspecifically granted under this License.  You may not convey a covered\nwork if you are a party to an arrangement with a third party that is\nin the business of distributing software, under which you make payment\nto the third party based on the extent of your activity of conveying\nthe work, and under which the third party grants, to any of the\nparties who would receive the covered work from you, a discriminatory\npatent license (a) in connection with copies of the covered work\nconveyed by you (or copies made from those copies), or (b) primarily\nfor and in connection with specific products or compilations that\ncontain the covered work, unless you entered into that arrangement,\nor that patent license was granted, prior to 28 March 2007.\n\n  Nothing in this License shall be construed as excluding or limiting\nany implied license or other defenses to infringement that may\notherwise be available to you under applicable patent law.\n\n  12. No Surrender of Others' Freedom.\n\n  If conditions are imposed on you (whether by court order, agreement or\notherwise) that contradict the conditions of this License, they do not\nexcuse you from the conditions of this License.  If you cannot convey a\ncovered work so as to satisfy simultaneously your obligations under this\nLicense and any other pertinent obligations, then as a consequence you may\nnot convey it at all.  For example, if you agree to terms that obligate you\nto collect a royalty for further conveying from those to whom you convey\nthe Program, the only way you could satisfy both those terms and this\nLicense would be to refrain entirely from conveying the Program.\n\n  13. Use with the GNU Affero General Public License.\n\n  Notwithstanding any other provision of this License, you have\npermission to link or combine any covered work with a work licensed\nunder version 3 of the GNU Affero General Public License into a single\ncombined work, and to convey the resulting work.  The terms of this\nLicense will continue to apply to the part which is the covered work,\nbut the special requirements of the GNU Affero General Public License,\nsection 13, concerning interaction through a network will apply to the\ncombination as such.\n\n  14. Revised Versions of this License.\n\n  The Free Software Foundation may publish revised and/or new versions of\nthe GNU General Public License from time to time.  Such new versions will\nbe similar in spirit to the present version, but may differ in detail to\naddress new problems or concerns.\n\n  Each version is given a distinguishing version number.  If the\nProgram specifies that a certain numbered version of the GNU General\nPublic License \"or any later version\" applies to it, you have the\noption of following the terms and conditions either of that numbered\nversion or of any later version published by the Free Software\nFoundation.  If the Program does not specify a version number of the\nGNU General Public License, you may choose any version ever published\nby the Free Software Foundation.\n\n  If the Program specifies that a proxy can decide which future\nversions of the GNU General Public License can be used, that proxy's\npublic statement of acceptance of a version permanently authorizes you\nto choose that version for the Program.\n\n  Later license versions may give you additional or different\npermissions.  However, no additional obligations are imposed on any\nauthor or copyright holder as a result of your choosing to follow a\nlater version.\n\n  15. Disclaimer of Warranty.\n\n  THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY\nAPPLICABLE LAW.  EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT\nHOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM \"AS IS\" WITHOUT WARRANTY\nOF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,\nTHE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR\nPURPOSE.  THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM\nIS WITH YOU.  SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF\nALL NECESSARY SERVICING, REPAIR OR CORRECTION.\n\n  16. Limitation of Liability.\n\n  IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING\nWILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS\nTHE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY\nGENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE\nUSE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF\nDATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD\nPARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),\nEVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF\nSUCH DAMAGES.\n\n  17. Interpretation of Sections 15 and 16.\n\n  If the disclaimer of warranty and limitation of liability provided\nabove cannot be given local legal effect according to their terms,\nreviewing courts shall apply local law that most closely approximates\nan absolute waiver of all civil liability in connection with the\nProgram, unless a warranty or assumption of liability accompanies a\ncopy of the Program in return for a fee.\n\n                     END OF TERMS AND CONDITIONS\n\n            How to Apply These Terms to Your New Programs\n\n  If you develop a new program, and you want it to be of the greatest\npossible use to the public, the best way to achieve this is to make it\nfree software which everyone can redistribute and change under these terms.\n\n  To do so, attach the following notices to the program.  It is safest\nto attach them to the start of each source file to most effectively\nstate the exclusion of warranty; and each file should have at least\nthe \"copyright\" line and a pointer to where the full notice is found.\n\n    <one line to give the program's name and a brief idea of what it does.>\n    Copyright (C) <year>  <name of author>\n\n    This program is free software: you can redistribute it and/or modify\n    it under the terms of the GNU General Public License as published by\n    the Free Software Foundation, either version 3 of the License, or\n    (at your option) any later version.\n\n    This program is distributed in the hope that it will be useful,\n    but WITHOUT ANY WARRANTY; without even the implied warranty of\n    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n    GNU General Public License for more details.\n\n    You should have received a copy of the GNU General Public License\n    along with this program.  If not, see <https://www.gnu.org/licenses/>.\n\nAlso add information on how to contact you by electronic and paper mail.\n\n  If the program does terminal interaction, make it output a short\nnotice like this when it starts in an interactive mode:\n\n    <program>  Copyright (C) <year>  <name of author>\n    This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.\n    This is free software, and you are welcome to redistribute it\n    under certain conditions; type `show c' for details.\n\nThe hypothetical commands `show w' and `show c' should show the appropriate\nparts of the General Public License.  Of course, your program's commands\nmight be different; for a GUI interface, you would use an \"about box\".\n\n  You should also get your employer (if you work as a programmer) or school,\nif any, to sign a \"copyright disclaimer\" for the program, if necessary.\nFor more information on this, and how to apply and follow the GNU GPL, see\n<https://www.gnu.org/licenses/>.\n\n  The GNU General Public License does not permit incorporating your program\ninto proprietary programs.  If your program is a subroutine library, you\nmay consider it more useful to permit linking proprietary applications with\nthe library.  If this is what you want to do, use the GNU Lesser General\nPublic License instead of this License.  But first, please read\n<https://www.gnu.org/licenses/why-not-lgpl.html>.\n"
  },
  {
    "path": "README.md",
    "content": "# MoviePilot\n\n![GitHub Repo stars](https://img.shields.io/github/stars/jxxghp/MoviePilot?style=for-the-badge)\n![GitHub forks](https://img.shields.io/github/forks/jxxghp/MoviePilot?style=for-the-badge)\n![GitHub contributors](https://img.shields.io/github/contributors/jxxghp/MoviePilot?style=for-the-badge)\n![GitHub repo size](https://img.shields.io/github/repo-size/jxxghp/MoviePilot?style=for-the-badge)\n![GitHub issues](https://img.shields.io/github/issues/jxxghp/MoviePilot?style=for-the-badge)\n![Docker Pulls](https://img.shields.io/docker/pulls/jxxghp/moviepilot?style=for-the-badge)\n![Docker Pulls V2](https://img.shields.io/docker/pulls/jxxghp/moviepilot-v2?style=for-the-badge)\n![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20Linux%20%7C%20Synology-blue?style=for-the-badge)\n\n\n基于 [NAStool](https://github.com/NAStool/nas-tools) 部分代码重新设计，聚焦自动化核心需求，减少问题同时更易于扩展和维护。\n\n# 仅用于学习交流使用，请勿在任何国内平台宣传该项目！\n\n发布频道：https://t.me/moviepilot_channel\n\n## 主要特性\n\n- 前后端分离，基于FastApi + Vue3。\n- 聚焦核心需求，简化功能和设置，部分设置项可直接使用默认值。\n- 重新设计了用户界面，更加美观易用。\n\n## 安装使用\n\n官方Wiki：https://wiki.movie-pilot.org\n\n### 为 AI Agent 添加 Skills\n```shell\nnpx skills add https://github.com/jxxghp/MoviePilot\n```\n\n## 参与开发\n\nAPI文档：https://api.movie-pilot.org\n\nMCP工具API文档：详见 [docs/mcp-api.md](docs/mcp-api.md)\n\n本地运行需要 `Python 3.12`、`Node JS v20.12.1`\n\n- 克隆主项目 [MoviePilot](https://github.com/jxxghp/MoviePilot) \n```shell\ngit clone https://github.com/jxxghp/MoviePilot\n```\n- 克隆资源项目 [MoviePilot-Resources](https://github.com/jxxghp/MoviePilot-Resources) ，将 `resources` 目录下对应平台及版本的库 `.so`/`.pyd`/`.bin` 文件复制到 `app/helper` 目录\n```shell\ngit clone https://github.com/jxxghp/MoviePilot-Resources\n```\n- 安装后端依赖，运行 `main.py` 启动后端服务，默认监听端口：`3001`，API文档地址：`http://localhost:3001/docs`\n```shell\ncd MoviePilot\npip install -r requirements.txt\npython3 -m app.main\n```\n- 克隆前端项目 [MoviePilot-Frontend](https://github.com/jxxghp/MoviePilot-Frontend)\n```shell\ngit clone https://github.com/jxxghp/MoviePilot-Frontend\n```\n- 安装前端依赖，运行前端项目，访问：`http://localhost:5173`\n```shell\nyarn\nyarn dev\n```\n- 参考 [插件开发指引](https://wiki.movie-pilot.org/zh/plugindev) 在 `app/plugins` 目录下开发插件代码\n\n## 相关项目\n\n- [MoviePilot-Frontend](https://github.com/jxxghp/MoviePilot-Frontend)\n- [MoviePilot-Resources](https://github.com/jxxghp/MoviePilot-Resources)\n- [MoviePilot-Plugins](https://github.com/jxxghp/MoviePilot-Plugins)\n- [MoviePilot-Server](https://github.com/jxxghp/MoviePilot-Server)\n- [MoviePilot-Wiki](https://github.com/jxxghp/MoviePilot-Wiki)\n\n## 免责申明\n\n- 本软件仅供学习交流使用，任何人不得将本软件用于商业用途，任何人不得将本软件用于违法犯罪活动，软件对用户行为不知情，一切责任由使用者承担。\n- 本软件代码开源，基于开源代码进行修改，人为去除相关限制导致软件被分发、传播并造成责任事件的，需由代码修改发布者承担全部责任，不建议对用户认证机制进行规避或修改并公开发布。\n- 本项目不接受捐赠，没有在任何地方发布捐赠信息页面，软件本身不收费也不提供任何收费相关服务，请仔细辨别避免误导。\n\n## 贡献者\n\n<a href=\"https://github.com/jxxghp/MoviePilot/graphs/contributors\">\n  <img src=\"https://contrib.rocks/image?repo=jxxghp/MoviePilot\" />\n</a>\n"
  },
  {
    "path": "app/__init__.py",
    "content": ""
  },
  {
    "path": "app/agent/__init__.py",
    "content": "import asyncio\nfrom typing import Dict, List, Any, Union\nimport json\nimport tiktoken\n\nfrom langchain.agents import AgentExecutor\nfrom langchain.prompts import ChatPromptTemplate, MessagesPlaceholder\nfrom langchain_community.callbacks import get_openai_callback\nfrom langchain_core.chat_history import InMemoryChatMessageHistory\nfrom langchain_core.messages import HumanMessage, AIMessage, ToolCall, ToolMessage, SystemMessage, trim_messages\nfrom langchain_core.runnables import RunnablePassthrough, RunnableLambda\nfrom langchain_core.runnables.history import RunnableWithMessageHistory\nfrom langchain.agents.format_scratchpad.openai_tools import format_to_openai_tool_messages\nfrom langchain.agents.output_parsers.openai_tools import OpenAIToolsAgentOutputParser\n\nfrom app.agent.callback import StreamingCallbackHandler\nfrom app.agent.memory import conversation_manager\nfrom app.agent.prompt import prompt_manager\nfrom app.agent.tools.factory import MoviePilotToolFactory\nfrom app.chain import ChainBase\nfrom app.core.config import settings\nfrom app.helper.llm import LLMHelper\nfrom app.helper.message import MessageHelper\nfrom app.log import logger\nfrom app.schemas import Notification\n\n\nclass AgentChain(ChainBase):\n    pass\n\n\nclass MoviePilotAgent:\n    \"\"\"\n    MoviePilot AI智能体\n    \"\"\"\n\n    def __init__(self, session_id: str, user_id: str = None,\n                 channel: str = None, source: str = None, username: str = None):\n        self.session_id = session_id\n        self.user_id = user_id\n        self.channel = channel  # 消息渠道\n        self.source = source  # 消息来源\n        self.username = username  # 用户名\n\n        # 消息助手\n        self.message_helper = MessageHelper()\n\n        # 回调处理器\n        self.callback_handler = StreamingCallbackHandler(\n            session_id=session_id\n        )\n\n        # LLM模型\n        self.llm = self._initialize_llm()\n\n        # 工具\n        self.tools = self._initialize_tools()\n\n        # 提示词模板\n        self.prompt = self._initialize_prompt()\n\n        # Agent执行器\n        self.agent_executor = self._create_agent_executor()\n\n    def _initialize_llm(self):\n        \"\"\"\n        初始化LLM模型\n        \"\"\"\n        return LLMHelper.get_llm(streaming=True, callbacks=[self.callback_handler])\n\n    def _initialize_tools(self) -> List:\n        \"\"\"\n        初始化工具列表\n        \"\"\"\n        return MoviePilotToolFactory.create_tools(\n            session_id=self.session_id,\n            user_id=self.user_id,\n            channel=self.channel,\n            source=self.source,\n            username=self.username,\n            callback_handler=self.callback_handler\n        )\n\n    @staticmethod\n    def _initialize_session_store() -> Dict[str, InMemoryChatMessageHistory]:\n        \"\"\"\n        初始化内存存储\n        \"\"\"\n        return {}\n\n    def get_session_history(self, session_id: str) -> InMemoryChatMessageHistory:\n        \"\"\"\n        获取会话历史\n        \"\"\"\n        chat_history = InMemoryChatMessageHistory()\n        messages: List[dict] = conversation_manager.get_recent_messages_for_agent(\n            session_id=session_id,\n            user_id=self.user_id\n        )\n        if messages:\n            for msg in messages:\n                if msg.get(\"role\") == \"user\":\n                    chat_history.add_message(HumanMessage(content=msg.get(\"content\", \"\")))\n                elif msg.get(\"role\") == \"agent\":\n                    chat_history.add_message(AIMessage(content=msg.get(\"content\", \"\")))\n                elif msg.get(\"role\") == \"tool_call\":\n                    metadata = msg.get(\"metadata\", {})\n                    chat_history.add_message(\n                        AIMessage(\n                            content=msg.get(\"content\", \"\"),\n                            tool_calls=[\n                                ToolCall(\n                                    id=metadata.get(\"call_id\"),\n                                    name=metadata.get(\"tool_name\"),\n                                    args=metadata.get(\"parameters\"),\n                                )\n                            ]\n                        )\n                    )\n                elif msg.get(\"role\") == \"tool_result\":\n                    metadata = msg.get(\"metadata\", {})\n                    chat_history.add_message(ToolMessage(\n                        content=msg.get(\"content\", \"\"),\n                        tool_call_id=metadata.get(\"call_id\", \"unknown\")\n                    ))\n                elif msg.get(\"role\") == \"system\":\n                    chat_history.add_message(SystemMessage(content=msg.get(\"content\", \"\")))\n        \n        return chat_history\n\n    @staticmethod\n    def _initialize_prompt() -> ChatPromptTemplate:\n        \"\"\"\n        初始化提示词模板\n        \"\"\"\n        try:\n            prompt_template = ChatPromptTemplate.from_messages([\n                (\"system\", \"{system_prompt}\"),\n                MessagesPlaceholder(variable_name=\"chat_history\"),\n                (\"user\", \"{input}\"),\n                MessagesPlaceholder(variable_name=\"agent_scratchpad\"),\n            ])\n            logger.info(\"LangChain提示词模板初始化成功\")\n            return prompt_template\n        except Exception as e:\n            logger.error(f\"初始化提示词失败: {e}\")\n            raise e\n\n    @staticmethod\n    def _token_counter(messages: List[Union[HumanMessage, AIMessage, ToolMessage, SystemMessage]]) -> int:\n        \"\"\"\n        通用的Token计数器\n        \"\"\"\n        try:\n            # 尝试从模型获取编码集，如果失败则回退到 cl100k_base (大多数现代模型使用的编码)\n            try:\n                encoding = tiktoken.encoding_for_model(settings.LLM_MODEL)\n            except KeyError:\n                encoding = tiktoken.get_encoding(\"cl100k_base\")\n\n            num_tokens = 0\n            for message in messages:\n                # 基础开销 (每个消息大约 3 个 token)\n                num_tokens += 3\n                \n                # 1. 处理文本内容 (content)\n                if isinstance(message.content, str):\n                    num_tokens += len(encoding.encode(message.content))\n                elif isinstance(message.content, list):\n                    for part in message.content:\n                        if isinstance(part, dict) and part.get(\"type\") == \"text\":\n                            num_tokens += len(encoding.encode(part.get(\"text\", \"\")))\n\n                # 2. 处理工具调用 (仅 AIMessage 包含 tool_calls)\n                if getattr(message, \"tool_calls\", None):\n                    for tool_call in message.tool_calls:\n                        # 函数名\n                        num_tokens += len(encoding.encode(tool_call.get(\"name\", \"\")))\n                        # 参数 (转为 JSON 估算)\n                        args_str = json.dumps(tool_call.get(\"args\", {}), ensure_ascii=False)\n                        num_tokens += len(encoding.encode(args_str))\n                        # 额外的结构开销 (ID 等)\n                        num_tokens += 3\n\n                # 3. 处理角色权重\n                num_tokens += 1\n\n            # 加上回复的起始 Token (大约 3 个 token)\n            num_tokens += 3\n            return num_tokens\n        except Exception as e:\n            logger.error(f\"Token计数失败: {e}\")\n            # 发生错误时返回一个保守的估算值\n            return len(str(messages)) // 4\n\n    def _create_agent_executor(self) -> RunnableWithMessageHistory:\n        \"\"\"\n        创建Agent执行器\n        \"\"\"\n        try:\n            # 消息裁剪器，防止上下文超出限制\n            base_trimmer = trim_messages(\n                max_tokens=settings.LLM_MAX_CONTEXT_TOKENS * 1000 * 0.8,\n                strategy=\"last\",\n                token_counter=self._token_counter,\n                include_system=True,\n                allow_partial=False,\n                start_on=\"human\",\n            )\n            \n            # 包装trimmer，在裁剪后验证工具调用的完整性\n            def validated_trimmer(messages):\n                # 如果输入是 PromptValue，转换为消息列表\n                if hasattr(messages, \"to_messages\"):\n                    messages = messages.to_messages()\n                trimmed = base_trimmer.invoke(messages)\n\n                # 二次校验：确保不出现 broken tool chains\n                # 1. AIMessage with tool_calls 必须紧跟着对应的 ToolMessage\n                # 2. ToolMessage 必须有对应的 AIMessage 前置\n                safe_messages = []\n                i = 0\n                while i < len(trimmed):\n                    msg = trimmed[i]\n\n                    if isinstance(msg, AIMessage) and getattr(msg, \"tool_calls\", None):\n                        # 检查工具调用序列是否完整\n                        tool_calls = msg.tool_calls\n                        is_valid_sequence = True\n                        tool_results = []\n                        \n                        # 向后查找对应的 ToolMessage\n                        temp_i = i + 1\n                        for tool_call in tool_calls:\n                            if temp_i >= len(trimmed):\n                                is_valid_sequence = False\n                                break\n                            \n                            next_msg = trimmed[temp_i]\n                            if isinstance(next_msg, ToolMessage) and next_msg.tool_call_id == tool_call.get(\"id\"):\n                                tool_results.append(next_msg)\n                                temp_i += 1\n                            else:\n                                is_valid_sequence = False\n                                break\n                        \n                        if is_valid_sequence:\n                            # 序列完整，保留消息\n                            safe_messages.append(msg)\n                            safe_messages.extend(tool_results)\n                            i = temp_i  # 跳过已处理的工具结果\n                        else:\n                            # 序列不完整，丢弃该 AIMessage（后续的孤立 ToolMessage 会在下一次循环被当做 orphaned 处理掉）\n                            logger.warning(f\"移除无效的工具调用链: {len(tool_calls)} calls, incomplete results\")\n                            i += 1\n                        continue\n\n                    if isinstance(msg, ToolMessage):\n                        # 如果在这里遇到 ToolMessage，说明它没有被上面的逻辑消费，则是孤立的（或者顺序错乱）\n                        logger.warning(\"移除孤立的 ToolMessage\")\n                        i += 1\n                        continue\n\n                    # 其他类型的消息直接保留\n                    safe_messages.append(msg)\n                    i += 1\n\n                if len(safe_messages) < len(messages):\n                    logger.info(f\"LangChain消息上下文已裁剪: {len(messages)} -> {len(safe_messages)}\")\n                return safe_messages\n            \n            # 创建Agent执行链\n            agent = (\n                RunnablePassthrough.assign(\n                    agent_scratchpad=lambda x: format_to_openai_tool_messages(\n                        x[\"intermediate_steps\"]\n                    )\n                )\n                | self.prompt\n                | RunnableLambda(validated_trimmer)\n                | self.llm.bind_tools(self.tools)\n                | OpenAIToolsAgentOutputParser()\n            )\n            executor = AgentExecutor(\n                agent=agent,\n                tools=self.tools,\n                verbose=settings.LLM_VERBOSE,\n                max_iterations=settings.LLM_MAX_ITERATIONS,\n                return_intermediate_steps=True,\n                handle_parsing_errors=True,\n                early_stopping_method=\"force\"\n            )\n            return RunnableWithMessageHistory(\n                executor,\n                self.get_session_history,\n                input_messages_key=\"input\",\n                history_messages_key=\"chat_history\"\n            )\n        except Exception as e:\n            logger.error(f\"创建Agent执行器失败: {e}\")\n            raise e\n\n    async def _summarize_history(self):\n        \"\"\"\n        总结提炼之前的对话和工具执行情况，并把会话总结变成新的系统提示词取代之前的对话\n        \"\"\"\n        try:\n            # 获取当前历史记录\n            chat_history = self.get_session_history(self.session_id)\n            messages = chat_history.messages\n            if not messages:\n                return\n\n            logger.info(f\"会话 {self.session_id} 历史消息长度已超过 90%，开始总结并重置上下文...\")\n\n            # 将消息转换为摘要所需的文本格式\n            history_text = \"\"\n            for msg in messages:\n                if isinstance(msg, HumanMessage):\n                    history_text += f\"用户: {msg.content}\\n\"\n                elif isinstance(msg, AIMessage):\n                    history_text += f\"智能体: {msg.content}\\n\"\n                    if getattr(msg, \"tool_calls\", None):\n                        for tool_call in msg.tool_calls:\n                            history_text += f\"智能体调用工具: {tool_call.get('name')}，参数: {tool_call.get('args')}\\n\"\n                elif isinstance(msg, ToolMessage):\n                    history_text += f\"工具响应: {msg.content}\\n\"\n                elif isinstance(msg, SystemMessage):\n                    history_text += f\"系统: {msg.content}\\n\"\n\n            # 摘要提示词\n            summary_prompt = (\n                \"Please provide a comprehensive and highly informational summary of the preceding conversation and tool executions. \"\n                \"Your goal is to condense the history while retaining all critical details for future reference. \"\n                \"Ensure you include:\\n\"\n                \"1. User's core intents, specific requests, and any mentioned preferences.\\n\"\n                \"2. Names of movies, TV shows, or other key entities discussed.\\n\"\n                \"3. A concise log of tool calls made and their specific results/outcomes.\\n\"\n                \"4. The current status of any tasks and any pending actions.\\n\"\n                \"5. Any important context that would be necessary for the agent to continue the conversation seamlessly.\\n\"\n                \"The summary should be dense with information and serve as the primary context for the next stage of the interaction.\"\n            )\n\n            # 调用 LLM 进行总结 (非流式)\n            summary_llm = LLMHelper.get_llm(streaming=False)\n            response = await summary_llm.ainvoke([\n                SystemMessage(content=summary_prompt),\n                HumanMessage(content=f\"Here is the conversation history to summarize:\\n{history_text}\")\n            ])\n            summary_content = str(response.content)\n\n            if not summary_content:\n                logger.warning(\"总结生成失败，跳过重置逻辑。\")\n                return\n\n            # 清空原有的会话记录并插入新的系统总结\n            await conversation_manager.clear_memory(self.session_id, self.user_id)\n            await conversation_manager.add_conversation(\n                session_id=self.session_id,\n                user_id=self.user_id,\n                role=\"system\",\n                content=f\"<history_summary>\\n{summary_content}\\n</history_summary>\"\n            )\n            logger.info(f\"会话 {self.session_id} 历史摘要替换完成。\")\n        except Exception as e:\n            logger.error(f\"执行会话总结出错: {str(e)}\")\n\n    async def process_message(self, message: str) -> str:\n        \"\"\"\n        处理用户消息\n        \"\"\"\n        try:\n            # 检查上下文长度是否超过 90%\n            history = self.get_session_history(self.session_id)\n            if self._token_counter(history.messages) > settings.LLM_MAX_CONTEXT_TOKENS * 1000 * 0.9:\n                await self._summarize_history()\n\n            # 添加用户消息到记忆\n            await conversation_manager.add_conversation(\n                self.session_id,\n                user_id=self.user_id,\n                role=\"user\",\n                content=message\n            )\n\n            # 构建输入上下文\n            input_context = {\n                \"system_prompt\": prompt_manager.get_agent_prompt(channel=self.channel),\n                \"input\": message\n            }\n\n            # 执行Agent\n            logger.info(f\"Agent执行推理: session_id={self.session_id}, input={message}\")\n\n            result = await self._execute_agent(input_context)\n\n            # 获取Agent回复\n            agent_message = await self.callback_handler.get_message()\n\n            # 发送Agent回复给用户（通过原渠道）\n            if agent_message:\n                # 发送回复\n                await self.send_agent_message(agent_message)\n\n                # 添加Agent回复到记忆\n                await conversation_manager.add_conversation(\n                    session_id=self.session_id,\n                    user_id=self.user_id,\n                    role=\"agent\",\n                    content=agent_message\n                )\n            else:\n                agent_message = result.get(\"output\") or \"很抱歉，智能体出错了，未能生成回复内容。\"\n                await self.send_agent_message(agent_message)\n\n            return agent_message\n\n        except Exception as e:\n            error_message = f\"处理消息时发生错误: {str(e)}\"\n            logger.error(error_message)\n            # 发送错误消息给用户（通过原渠道）\n            await self.send_agent_message(error_message)\n            return error_message\n\n    async def _execute_agent(self, input_context: Dict[str, Any]) -> Dict[str, Any]:\n        \"\"\"\n        执行LangChain Agent\n        \"\"\"\n        try:\n            with get_openai_callback() as cb:\n                result = await self.agent_executor.ainvoke(\n                    input_context,\n                    config={\"configurable\": {\"session_id\": self.session_id}},\n                    callbacks=[self.callback_handler]\n                )\n                logger.info(f\"LLM调用消耗: \\n{cb}\")\n\n                if cb.total_tokens > 0:\n                    result[\"token_usage\"] = {\n                        \"prompt_tokens\": cb.prompt_tokens,\n                        \"completion_tokens\": cb.completion_tokens,\n                        \"total_tokens\": cb.total_tokens\n                    }\n            return result\n        except asyncio.CancelledError:\n            logger.info(f\"Agent执行被取消: session_id={self.session_id}\")\n            return {\n                \"output\": \"任务已取消\",\n                \"intermediate_steps\": [],\n                \"token_usage\": {}\n            }\n        except Exception as e:\n            logger.error(f\"Agent执行失败: {e}\")\n            return {\n                \"output\": str(e),\n                \"intermediate_steps\": [],\n                \"token_usage\": {}\n            }\n\n    async def send_agent_message(self, message: str, title: str = \"MoviePilot助手\"):\n        \"\"\"\n        通过原渠道发送消息给用户\n        \"\"\"\n        await AgentChain().async_post_message(\n            Notification(\n                channel=self.channel,\n                source=self.source,\n                userid=self.user_id,\n                username=self.username,\n                title=title,\n                text=message\n            )\n        )\n\n    async def cleanup(self):\n        \"\"\"\n        清理智能体资源\n        \"\"\"\n        logger.info(f\"MoviePilot智能体已清理: session_id={self.session_id}\")\n\n\nclass AgentManager:\n    \"\"\"\n    AI智能体管理器\n    \"\"\"\n\n    def __init__(self):\n        self.active_agents: Dict[str, MoviePilotAgent] = {}\n\n    @staticmethod\n    async def initialize():\n        \"\"\"\n        初始化管理器\n        \"\"\"\n        await conversation_manager.initialize()\n\n    async def close(self):\n        \"\"\"\n        关闭管理器\n        \"\"\"\n        await conversation_manager.close()\n        # 清理所有活跃的智能体\n        for agent in self.active_agents.values():\n            await agent.cleanup()\n        self.active_agents.clear()\n\n    async def process_message(self, session_id: str, user_id: str, message: str,\n                              channel: str = None, source: str = None, username: str = None) -> str:\n        \"\"\"\n        处理用户消息\n        \"\"\"\n        # 获取或创建Agent实例\n        if session_id not in self.active_agents:\n            logger.info(f\"创建新的AI智能体实例，session_id: {session_id}, user_id: {user_id}\")\n            agent = MoviePilotAgent(\n                session_id=session_id,\n                user_id=user_id,\n                channel=channel,\n                source=source,\n                username=username\n            )\n            self.active_agents[session_id] = agent\n        else:\n            agent = self.active_agents[session_id]\n            agent.user_id = user_id  # 确保user_id是最新的\n            # 更新渠道信息\n            if channel:\n                agent.channel = channel\n            if source:\n                agent.source = source\n            if username:\n                agent.username = username\n\n        # 处理消息\n        return await agent.process_message(message)\n\n    async def clear_session(self, session_id: str, user_id: str):\n        \"\"\"\n        清空会话\n        \"\"\"\n        if session_id in self.active_agents:\n            agent = self.active_agents[session_id]\n            await agent.cleanup()\n            del self.active_agents[session_id]\n            await conversation_manager.clear_memory(session_id, user_id)\n            logger.info(f\"会话 {session_id} 的记忆已清空\")\n\n\n# 全局智能体管理器实例\nagent_manager = AgentManager()\n"
  },
  {
    "path": "app/agent/callback/__init__.py",
    "content": "import threading\n\nfrom langchain_core.callbacks import AsyncCallbackHandler\n\nfrom app.log import logger\n\n\nclass StreamingCallbackHandler(AsyncCallbackHandler):\n    \"\"\"\n    流式输出回调处理器\n    \"\"\"\n\n    def __init__(self, session_id: str):\n        self._lock = threading.Lock()\n        self.session_id = session_id\n        self.current_message = \"\"\n\n    async def get_message(self):\n        \"\"\"\n        获取当前消息内容，获取后清空\n        \"\"\"\n        with self._lock:\n            if not self.current_message:\n                return \"\"\n            msg = self.current_message\n            logger.info(f\"Agent消息: {msg}\")\n            self.current_message = \"\"\n            return msg\n\n    async def on_llm_new_token(self, token: str, **kwargs):\n        \"\"\"\n        处理新的token\n        \"\"\"\n        if not token:\n            return\n        with self._lock:\n            # 缓存当前消息\n            self.current_message += token\n\n"
  },
  {
    "path": "app/agent/memory/__init__.py",
    "content": "\"\"\"对话记忆管理器\"\"\"\n\nimport asyncio\nimport json\nfrom datetime import datetime, timedelta\nfrom typing import Dict, List, Optional, Any\n\nfrom app.core.config import settings\nfrom app.helper.redis import AsyncRedisHelper\nfrom app.log import logger\nfrom app.schemas.agent import ConversationMemory\n\n\nclass ConversationMemoryManager:\n    \"\"\"\n    对话记忆管理器\n    \"\"\"\n\n    def __init__(self):\n        # 内存中的会话记忆缓存\n        self.memory_cache: Dict[str, ConversationMemory] = {}\n        # 使用现有的Redis助手\n        self.redis_helper = AsyncRedisHelper()\n        # 内存缓存清理任务（Redis通过TTL自动过期）\n        self.cleanup_task: Optional[asyncio.Task] = None\n\n    async def initialize(self):\n        \"\"\"\n        初始化记忆管理器\n        \"\"\"\n        try:\n            # 启动内存缓存清理任务（Redis通过TTL自动过期）\n            self.cleanup_task = asyncio.create_task(self._cleanup_expired_memories())\n            logger.info(\"对话记忆管理器初始化完成\")\n\n        except Exception as e:\n            logger.warning(f\"Redis连接失败，将使用内存存储: {e}\")\n\n    async def close(self):\n        \"\"\"\n        关闭记忆管理器\n        \"\"\"\n        if self.cleanup_task:\n            self.cleanup_task.cancel()\n            try:\n                await self.cleanup_task\n            except asyncio.CancelledError:\n                pass\n\n        await self.redis_helper.close()\n\n        logger.info(\"对话记忆管理器已关闭\")\n\n    @staticmethod\n    def _get_memory_key(session_id: str, user_id: str):\n        \"\"\"\n        计算内存Key\n        \"\"\"\n        return f\"{user_id}:{session_id}\" if user_id else session_id\n\n    @staticmethod\n    def _get_redis_key(session_id: str, user_id: str):\n        \"\"\"\n        计算Redis Key\n        \"\"\"\n        return f\"agent_memory:{user_id}:{session_id}\" if user_id else f\"agent_memory:{session_id}\"\n\n    def _get_memory(self, session_id: str, user_id: str):\n        \"\"\"\n        获取内存中的记忆\n        \"\"\"\n        cache_key = self._get_memory_key(session_id, user_id)\n        return self.memory_cache.get(cache_key)\n    \n    async def _get_redis(self, session_id: str, user_id: str) -> Optional[ConversationMemory]:\n        \"\"\"\n        从Redis获取记忆\n        \"\"\"\n        if settings.CACHE_BACKEND_TYPE == \"redis\":\n            try:\n                redis_key = self._get_redis_key(session_id, user_id)\n                memory_data = await self.redis_helper.get(redis_key, region=\"AI_AGENT\")\n                if memory_data:\n                    memory_dict = json.loads(memory_data) if isinstance(memory_data, str) else memory_data\n                    memory = ConversationMemory(**memory_dict)\n                    return memory\n            except Exception as e:\n                logger.warning(f\"从Redis加载记忆失败: {e}\")\n        return None\n\n    async def get_conversation(self, session_id: str, user_id: str) -> ConversationMemory:\n        \"\"\"\n        获取会话记忆\n        \"\"\"\n        # 首先检查缓存\n        conversion = self._get_memory(session_id, user_id)\n        if conversion:\n            return conversion\n\n        # 尝试从Redis加载\n        memory = await self._get_redis(session_id, user_id)\n        if memory:\n            # 加载到内存缓存\n            self._save_memory(memory)\n            return memory\n\n        # 创建新的记忆\n        memory = ConversationMemory(session_id=session_id, user_id=user_id)\n        await self._save_conversation(memory)\n\n        return memory\n\n    async def set_title(self, session_id: str, user_id: str, title: str):\n        \"\"\"\n        设置会话标题\n        \"\"\"\n        memory = await self.get_conversation(session_id=session_id, user_id=user_id)\n        memory.title = title\n        memory.updated_at = datetime.now()\n        await self._save_conversation(memory)\n\n    async def get_title(self, session_id: str, user_id: str) -> Optional[str]:\n        \"\"\"\n        获取会话标题\n        \"\"\"\n        memory = await self.get_conversation(session_id=session_id, user_id=user_id)\n        return memory.title\n\n    async def list_sessions(self, user_id: str, limit: int = 100) -> List[Dict[str, Any]]:\n        \"\"\"\n        列出历史会话摘要（按更新时间倒序）\n\n        - 当启用Redis时：遍历 `agent_memory:*` 键并读取摘要\n        - 当未启用Redis时：基于内存缓存返回\n        \"\"\"\n        sessions: List[ConversationMemory] = []\n        # 从Redis遍历\n        if settings.CACHE_BACKEND_TYPE == \"redis\":\n            try:\n                # 使用Redis助手的items方法遍历所有键\n                async for key, value in self.redis_helper.items(region=\"AI_AGENT\"):\n                    if key.startswith(\"agent_memory:\"):\n                        try:\n                            # 解析键名获取user_id和session_id\n                            key_parts = key.split(\":\")\n                            if len(key_parts) >= 3:\n                                key_user_id = key_parts[2] if len(key_parts) > 3 else None\n                                if not user_id or key_user_id == user_id:\n                                    data = value if isinstance(value, dict) else json.loads(value)\n                                    memory = ConversationMemory(**data)\n                                    sessions.append(memory)\n                        except Exception as err:\n                            logger.warning(f\"解析Redis记忆数据失败: {err}\")\n                            continue\n            except Exception as e:\n                logger.warning(f\"遍历Redis会话失败: {e}\")\n\n        # 合并内存缓存（确保包含近期的会话）\n        for cache_key, memory in self.memory_cache.items():\n            # 如果指定了user_id，只返回该用户的会话\n            if not user_id or memory.user_id == user_id:\n                sessions.append(memory)\n\n        # 去重（以 session_id 为键，取最近updated）\n        uniq: Dict[str, ConversationMemory] = {}\n        for mem in sessions:\n            existed = uniq.get(mem.session_id)\n            if (not existed) or (mem.updated_at > existed.updated_at):\n                uniq[mem.session_id] = mem\n\n        # 排序并裁剪\n        sorted_list = sorted(uniq.values(), key=lambda m: m.updated_at, reverse=True)[:limit]\n        return [\n            {\n                \"session_id\": m.session_id,\n                \"title\": m.title or \"新会话\",\n                \"message_count\": len(m.messages),\n                \"created_at\": m.created_at.isoformat(),\n                \"updated_at\": m.updated_at.isoformat(),\n            }\n            for m in sorted_list\n        ]\n\n    async def add_conversation(\n            self,\n            session_id: str,\n            user_id: str,\n            role: str,\n            content: str,\n            metadata: Optional[Dict[str, Any]] = None\n    ):\n        \"\"\"\n        添加消息到记忆\n        \"\"\"\n        memory = await self.get_conversation(session_id=session_id, user_id=user_id)\n\n        message = {\n            \"role\": role,\n            \"content\": content,\n            \"timestamp\": datetime.now().isoformat(),\n            \"metadata\": metadata or {}\n        }\n\n        memory.messages.append(message)\n        memory.updated_at = datetime.now()\n\n        # 限制消息数量，避免记忆过大\n        max_messages = settings.LLM_MAX_MEMORY_MESSAGES\n        if len(memory.messages) > max_messages:\n            # 保留最近的消息，但保留第一条系统消息\n            system_messages = [msg for msg in memory.messages if msg[\"role\"] == \"system\"]\n            recent_messages = memory.messages[-(max_messages - len(system_messages)):]\n            memory.messages = system_messages + recent_messages\n\n        await self._save_conversation(memory)\n\n        logger.debug(f\"消息已添加到记忆: session_id={session_id}, user_id={user_id}, role={role}\")\n\n    def get_recent_messages_for_agent(\n            self,\n            session_id: str,\n            user_id: str\n    ) -> List[Dict[str, Any]]:\n        \"\"\"\n        为Agent获取最近的消息（仅内存缓存）\n\n        如果消息Token数量超过模型最大上下文长度的阀值，会自动进行摘要裁剪\n        \"\"\"\n        cache_key = self._get_memory_key(session_id, user_id)\n        memory = self.memory_cache.get(cache_key)\n        if not memory:\n            return []\n\n        # 获取所有消息\n        return memory.messages[:-1]\n\n    async def get_recent_messages(\n            self,\n            session_id: str,\n            user_id: str,\n            limit: int = 10,\n            role_filter: Optional[list] = None\n    ) -> List[Dict[str, Any]]:\n        \"\"\"\n        获取最近的消息\n        \"\"\"\n        memory = await self.get_conversation(session_id=session_id, user_id=user_id)\n\n        messages = memory.messages\n        if role_filter:\n            messages = [msg for msg in messages if msg[\"role\"] in role_filter]\n\n        return messages[-limit:] if messages else []\n\n    async def get_context(self, session_id: str, user_id: str) -> Dict[str, Any]:\n        \"\"\"\n        获取会话上下文\n        \"\"\"\n        memory = await self.get_conversation(session_id=session_id, user_id=user_id)\n        return memory.context\n\n    async def clear_memory(self, session_id: str, user_id: str):\n        \"\"\"\n        清空会话记忆\n        \"\"\"\n        cache_key = f\"{user_id}:{session_id}\" if user_id else session_id\n        if cache_key in self.memory_cache:\n            del self.memory_cache[cache_key]\n\n        if settings.CACHE_BACKEND_TYPE == \"redis\":\n            redis_key = self._get_redis_key(session_id, user_id)\n            await self.redis_helper.delete(redis_key, region=\"AI_AGENT\")\n\n        logger.info(f\"会话记忆已清空: session_id={session_id}, user_id={user_id}\")\n\n    def _save_memory(self, memory: ConversationMemory):\n        \"\"\"\n        保存记忆到内存\n        \"\"\"\n        cache_key = self._get_memory_key(memory.session_id, memory.user_id)\n        self.memory_cache[cache_key] = memory\n\n    async def _save_redis(self, memory: ConversationMemory):\n        \"\"\"\n        保存记忆到Redis\n        \"\"\"\n        if settings.CACHE_BACKEND_TYPE == \"redis\":\n            try:\n                memory_dict = memory.model_dump()\n                redis_key = self._get_redis_key(memory.session_id, memory.user_id)\n                ttl = int(timedelta(days=settings.LLM_REDIS_MEMORY_RETENTION_DAYS).total_seconds())\n                await self.redis_helper.set(\n                    redis_key,\n                    memory_dict,\n                    ttl=ttl,\n                    region=\"AI_AGENT\"\n                )\n            except Exception as e:\n                logger.warning(f\"保存记忆到Redis失败: {e}\")\n\n    async def _save_conversation(self, memory: ConversationMemory):\n        \"\"\"\n        保存记忆到存储\n\n        Redis中的记忆会自动通过TTL机制过期，无需手动清理\n        \"\"\"\n        # 更新内存缓存\n        self._save_memory(memory)\n\n        # 保存到Redis，设置TTL自动过期\n        await self._save_redis(memory)\n\n\n    async def _cleanup_expired_memories(self):\n        \"\"\"\n        清理内存中过期记忆的后台任务\n\n        注意：Redis中的记忆通过TTL机制自动过期，这里只清理内存缓存\n        \"\"\"\n        while True:\n            try:\n                # 每小时清理一次\n                await asyncio.sleep(3600)\n\n                current_time = datetime.now()\n                expired_sessions = []\n\n                # 只检查内存缓存中的过期记忆\n                # Redis中的记忆会通过TTL自动过期，无需手动处理\n                for cache_key, memory in self.memory_cache.items():\n                    if (current_time - memory.updated_at).days > settings.LLM_MEMORY_RETENTION_DAYS:\n                        expired_sessions.append(cache_key)\n\n                # 只清理内存缓存，不删除Redis中的键（Redis会自动过期）\n                for cache_key in expired_sessions:\n                    if cache_key in self.memory_cache:\n                        del self.memory_cache[cache_key]\n\n                if expired_sessions:\n                    logger.info(f\"清理了{len(expired_sessions)}个过期内存会话记忆\")\n\n            except asyncio.CancelledError:\n                break\n            except Exception as e:\n                logger.error(f\"清理记忆时发生错误: {e}\")\n\nconversation_manager = ConversationMemoryManager()\n"
  },
  {
    "path": "app/agent/prompt/Agent Prompt.txt",
    "content": "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.\n\nAll your responses must be in **Chinese (中文)**.\n\nYou 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.\n\nCore Capabilities:\n1. Media Search & Recognition\n- Identify movies, TV shows, and anime across various metadata providers.\n- Recognize media info from fuzzy filenames or incomplete titles.\n2. Subscription Management\n- Create complex rules for automated downloading of new episodes.\n- Monitor trending movies/shows for automated suggestions.\n3. Download Control\n- Intelligent torrent searching across private/public trackers.\n- Filter resources by quality (4K/1080p), codec (H265/H264), and release groups.\n4. System Status & Organization\n- Monitor download progress and server health.\n- Manage file transfers, renaming, and library cleanup.\n\n<communication>\n- Use Markdown for structured data like movie lists, download statuses, or technical details.\n- 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.\n- ALWAYS use backticks for media titles (e.g., `Interstellar`), file paths, or specific parameters.\n- Optimize your writing for clarity and readability, using bold text for key information.\n- Provide comprehensive details for media (year, rating, resolution) to help users make informed decisions.\n- Do not stop for approval for read-only operations. Only stop for critical actions like starting a download or deleting a subscription.\n\nImportant Notes:\n- User-Centric: Your tone should be helpful, professional, and media-savvy. \n- 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.\n- Contextual Memory: Remember if the user preferred a specific version previously and prioritize similar results in future searches.\n</communication>\n\n<status_update_spec>\nDefinition: Provide a brief progress narrative (1-3 sentences) explaining what you have searched, what you found, and what you are about to execute.\n- **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.\n- Use natural tenses: \"I've found...\", \"I'm checking...\", \"I will now add...\".\n- Skip redundant updates if no significant progress has been made since the last message.\n</status_update_spec>\n\n<summary_spec>\nAt the end of your session/turn, provide a concise summary of your actions.\n- Highlight key results: \"Subscribed to `Stranger Things`\", \"Added `Avatar` 4K to download queue\".\n- Use bullet points for multiple actions.\n- Do not repeat the internal execution steps; focus on the outcome for the user.\n</summary_spec>\n\n<flow>\n1. Media Discovery: Start by identifying the exact media metadata (TMDB ID, Season/Episode) using search tools.\n2. Context Checking: Verify current status (Is it already in the library? Is it already subscribed?).\n3. Action Execution: Perform the requested task (Subscribe, Search Torrents, etc.) with a brief status update.\n4. Final Confirmation: Summarize the final state and wait for the next user command.\n</flow>\n\n<tool_calling_strategy>\n- 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.\n- Information Depth: If a search returns ambiguous results, use `query_media_detail` or `recognize_media` to resolve the ambiguity before proceeding.\n- 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.\n</tool_calling_strategy>\n\n<media_management_rules>\n1. 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.\n2. Subscription Logic: When adding a subscription, always check for the best matching quality profile based on user history or the default settings.\n3. Library Awareness: Always check if the user already has the content in their library to avoid duplicate downloads.\n4. 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., \"尝试从其他站点进行搜索\").\n</media_management_rules>\n\n<markdown_spec>\nSpecific markdown rules:\n{markdown_spec}\n</markdown_spec>"
  },
  {
    "path": "app/agent/prompt/__init__.py",
    "content": "\"\"\"提示词管理器\"\"\"\nfrom pathlib import Path\nfrom typing import Dict\n\nfrom app.log import logger\nfrom app.schemas import ChannelCapability, ChannelCapabilities, MessageChannel, ChannelCapabilityManager\n\n\nclass PromptManager:\n    \"\"\"\n    提示词管理器\n    \"\"\"\n\n    def __init__(self, prompts_dir: str = None):\n        if prompts_dir is None:\n            self.prompts_dir = Path(__file__).parent\n        else:\n            self.prompts_dir = Path(prompts_dir)\n        self.prompts_cache: Dict[str, str] = {}\n\n    def load_prompt(self, prompt_name: str) -> str:\n        \"\"\"\n        加载指定的提示词\n        \"\"\"\n        if prompt_name in self.prompts_cache:\n            return self.prompts_cache[prompt_name]\n\n        prompt_file = self.prompts_dir / prompt_name\n        try:\n            with open(prompt_file, 'r', encoding='utf-8') as f:\n                content = f.read().strip()\n            # 缓存提示词\n            self.prompts_cache[prompt_name] = content\n            logger.info(f\"提示词加载成功: {prompt_name}，长度：{len(content)} 字符\")\n            return content\n        except FileNotFoundError:\n            logger.error(f\"提示词文件不存在: {prompt_file}\")\n            raise\n        except Exception as e:\n            logger.error(f\"加载提示词失败: {prompt_name}, 错误: {e}\")\n            raise\n\n    def get_agent_prompt(self, channel: str = None) -> str:\n        \"\"\"\n        获取智能体提示词\n        :param channel: 消息渠道（Telegram、微信、Slack等）\n        :return: 提示词内容\n        \"\"\"\n        # 基础提示词\n        base_prompt = self.load_prompt(\"Agent Prompt.txt\")\n\n        # 识别渠道\n        msg_channel = next((c for c in MessageChannel if c.value.lower() == channel.lower()), None) if channel else None\n        if msg_channel:\n            # 获取渠道能力说明\n            caps = ChannelCapabilityManager.get_capabilities(msg_channel)\n            if caps:\n                base_prompt = base_prompt.replace(\n                    \"{markdown_spec}\",\n                    self._generate_formatting_instructions(caps)\n                )\n\n        return base_prompt\n\n    @staticmethod\n    def _generate_formatting_instructions(caps: ChannelCapabilities) -> str:\n        \"\"\"\n        根据渠道能力动态生成格式指令\n        \"\"\"\n        instructions = []\n        if ChannelCapability.RICH_TEXT not in caps.capabilities:\n            instructions.append(\"- Formatting: Use **Plain Text ONLY**. The channel does NOT support Markdown.\")\n            instructions.append(\n                \"- No Markdown Symbols: NEVER use `**`, `*`, `__`, or `[` blocks. Use natural text to emphasize (e.g., using ALL CAPS or separators).\")\n            instructions.append(\n                \"- Lists: Use plain text symbols like `>` or `*` at the start of lines, followed by manual line breaks.\")\n            instructions.append(\"- Links: Paste URLs directly as text.\")\n        return \"\\n\".join(instructions)\n\n    def clear_cache(self):\n        \"\"\"\n        清空缓存\n        \"\"\"\n        self.prompts_cache.clear()\n        logger.info(\"提示词缓存已清空\")\n\n\nprompt_manager = PromptManager()\n"
  },
  {
    "path": "app/agent/tools/__init__.py",
    "content": ""
  },
  {
    "path": "app/agent/tools/base.py",
    "content": "import json\nimport uuid\nfrom abc import ABCMeta, abstractmethod\nfrom typing import Any, Optional\n\nfrom langchain.tools import BaseTool\nfrom pydantic import PrivateAttr\n\nfrom app.agent import StreamingCallbackHandler, conversation_manager\nfrom app.chain import ChainBase\nfrom app.log import logger\nfrom app.schemas import Notification\n\n\nclass ToolChain(ChainBase):\n    pass\n\n\nclass MoviePilotTool(BaseTool, metaclass=ABCMeta):\n    \"\"\"\n    MoviePilot专用工具基类\n    \"\"\"\n\n    _session_id: str = PrivateAttr()\n    _user_id: str = PrivateAttr()\n    _channel: str = PrivateAttr(default=None)\n    _source: str = PrivateAttr(default=None)\n    _username: str = PrivateAttr(default=None)\n    _callback_handler: StreamingCallbackHandler = PrivateAttr(default=None)\n\n    def __init__(self, session_id: str, user_id: str, **kwargs):\n        super().__init__(**kwargs)\n        self._session_id = session_id\n        self._user_id = user_id\n\n    def _run(self, *args: Any, **kwargs: Any) -> Any:\n        pass\n\n    async def _arun(self, **kwargs) -> str:\n        \"\"\"\n        异步运行工具\n        \"\"\"\n        # 获取工具调用前的agent消息\n        agent_message = await self._callback_handler.get_message()\n\n        # 生成唯一的工具调用ID\n        call_id = f\"call_{str(uuid.uuid4())[:16]}\"\n\n        # 记忆工具调用\n        await conversation_manager.add_conversation(\n            session_id=self._session_id,\n            user_id=self._user_id,\n            role=\"tool_call\",\n            content=agent_message,\n            metadata={\n                \"call_id\": call_id,\n                \"tool_name\": self.name,\n                \"parameters\": kwargs\n            }\n        )\n\n        # 获取执行工具说明,优先使用工具自定义的提示消息，如果没有则使用 explanation\n        tool_message = self.get_tool_message(**kwargs)\n        if not tool_message:\n            explanation = kwargs.get(\"explanation\")\n            if explanation:\n                tool_message = explanation\n\n        # 合并agent消息和工具执行消息，一起发送\n        messages = []\n        if agent_message:\n            messages.append(agent_message)\n        if tool_message:\n            messages.append(f\"⚙️ => {tool_message}\")\n\n        # 发送合并后的消息\n        if messages:\n            merged_message = \"\\n\\n\".join(messages)\n            await self.send_tool_message(merged_message, title=\"MoviePilot助手\")\n\n        logger.debug(f'Executing tool {self.name} with args: {kwargs}')\n\n        # 执行工具，捕获异常确保结果总是被存储到记忆中\n        try:\n            result = await self.run(**kwargs)\n            logger.debug(f'Tool {self.name} executed with result: {result}')\n        except Exception as e:\n            # 记录异常详情\n            error_message = f\"工具执行异常 ({type(e).__name__}): {str(e)}\"\n            logger.error(f'Tool {self.name} execution failed: {e}', exc_info=True)\n            result = error_message\n\n        # 记忆工具调用结果\n        if isinstance(result, str):\n            formated_result = result\n        elif isinstance(result, (int, float)):\n            formated_result = str(result)\n        else:\n            formated_result = json.dumps(result, ensure_ascii=False, indent=2)\n\n        await conversation_manager.add_conversation(\n            session_id=self._session_id,\n            user_id=self._user_id,\n            role=\"tool_result\",\n            content=formated_result,\n            metadata={\n                \"call_id\": call_id,\n                \"tool_name\": self.name,\n            }\n        )\n\n        return result\n\n    def get_tool_message(self, **kwargs) -> Optional[str]:\n        \"\"\"\n        获取工具执行时的友好提示消息\n        \n        子类可以重写此方法，根据实际参数生成个性化的提示消息。\n        如果返回 None 或空字符串，将回退使用 explanation 参数。\n        \n        Args:\n            **kwargs: 工具的所有参数（包括 explanation）\n            \n        Returns:\n            str: 友好的提示消息，如果返回 None 或空字符串则使用 explanation\n        \"\"\"\n        return None\n\n    @abstractmethod\n    async def run(self, **kwargs) -> str:\n        raise NotImplementedError\n\n    def set_message_attr(self, channel: str, source: str, username: str):\n        \"\"\"\n        设置消息属性\n        \"\"\"\n        self._channel = channel\n        self._source = source\n        self._username = username\n\n    def set_callback_handler(self, callback_handler: StreamingCallbackHandler):\n        \"\"\"\n        设置回调处理器\n        \"\"\"\n        self._callback_handler = callback_handler\n\n    async def send_tool_message(self, message: str, title: str = \"\"):\n        \"\"\"\n        发送工具消息\n        \"\"\"\n        await ToolChain().async_post_message(\n            Notification(\n                channel=self._channel,\n                source=self._source,\n                userid=self._user_id,\n                username=self._username,\n                title=title,\n                text=message\n            )\n        )\n"
  },
  {
    "path": "app/agent/tools/factory.py",
    "content": "from typing import List, Callable\n\nfrom app.agent.tools.impl.add_download import AddDownloadTool\nfrom app.agent.tools.impl.add_subscribe import AddSubscribeTool\nfrom app.agent.tools.impl.update_subscribe import UpdateSubscribeTool\nfrom app.agent.tools.impl.search_subscribe import SearchSubscribeTool\nfrom app.agent.tools.impl.get_recommendations import GetRecommendationsTool\nfrom app.agent.tools.impl.query_downloaders import QueryDownloadersTool\nfrom app.agent.tools.impl.query_download_tasks import QueryDownloadTasksTool\nfrom app.agent.tools.impl.query_library_exists import QueryLibraryExistsTool\nfrom app.agent.tools.impl.query_library_latest import QueryLibraryLatestTool\nfrom app.agent.tools.impl.query_sites import QuerySitesTool\nfrom app.agent.tools.impl.update_site import UpdateSiteTool\nfrom app.agent.tools.impl.query_site_userdata import QuerySiteUserdataTool\nfrom app.agent.tools.impl.test_site import TestSiteTool\nfrom app.agent.tools.impl.query_subscribes import QuerySubscribesTool\nfrom app.agent.tools.impl.query_subscribe_shares import QuerySubscribeSharesTool\nfrom app.agent.tools.impl.query_rule_groups import QueryRuleGroupsTool\nfrom app.agent.tools.impl.query_popular_subscribes import QueryPopularSubscribesTool\nfrom app.agent.tools.impl.query_subscribe_history import QuerySubscribeHistoryTool\nfrom app.agent.tools.impl.delete_subscribe import DeleteSubscribeTool\nfrom app.agent.tools.impl.search_media import SearchMediaTool\nfrom app.agent.tools.impl.search_person import SearchPersonTool\nfrom app.agent.tools.impl.search_person_credits import SearchPersonCreditsTool\nfrom app.agent.tools.impl.recognize_media import RecognizeMediaTool\nfrom app.agent.tools.impl.scrape_metadata import ScrapeMetadataTool\nfrom app.agent.tools.impl.query_episode_schedule import QueryEpisodeScheduleTool\nfrom app.agent.tools.impl.query_media_detail import QueryMediaDetailTool\nfrom app.agent.tools.impl.search_torrents import SearchTorrentsTool\nfrom app.agent.tools.impl.get_search_results import GetSearchResultsTool\nfrom app.agent.tools.impl.search_web import SearchWebTool\nfrom app.agent.tools.impl.send_message import SendMessageTool\nfrom app.agent.tools.impl.query_schedulers import QuerySchedulersTool\nfrom app.agent.tools.impl.run_scheduler import RunSchedulerTool\nfrom app.agent.tools.impl.query_workflows import QueryWorkflowsTool\nfrom app.agent.tools.impl.run_workflow import RunWorkflowTool\nfrom app.agent.tools.impl.update_site_cookie import UpdateSiteCookieTool\nfrom app.agent.tools.impl.delete_download import DeleteDownloadTool\nfrom app.agent.tools.impl.query_directory_settings import QueryDirectorySettingsTool\nfrom app.agent.tools.impl.list_directory import ListDirectoryTool\nfrom app.agent.tools.impl.query_transfer_history import QueryTransferHistoryTool\nfrom app.agent.tools.impl.transfer_file import TransferFileTool\nfrom app.agent.tools.impl.execute_command import ExecuteCommandTool\nfrom app.core.plugin import PluginManager\nfrom app.log import logger\nfrom .base import MoviePilotTool\n\n\nclass MoviePilotToolFactory:\n    \"\"\"\n    MoviePilot工具工厂\n    \"\"\"\n\n    @staticmethod\n    def create_tools(session_id: str, user_id: str,\n                     channel: str = None, source: str = None, username: str = None,\n                     callback_handler: Callable = None) -> List[MoviePilotTool]:\n        \"\"\"\n        创建MoviePilot工具列表\n        \"\"\"\n        tools = []\n        tool_definitions = [\n            SearchMediaTool,\n            SearchPersonTool,\n            SearchPersonCreditsTool,\n            RecognizeMediaTool,\n            ScrapeMetadataTool,\n            QueryEpisodeScheduleTool,\n            QueryMediaDetailTool,\n            AddSubscribeTool,\n            UpdateSubscribeTool,\n            SearchSubscribeTool,\n            SearchTorrentsTool,\n            GetSearchResultsTool,\n            SearchWebTool,\n            AddDownloadTool,\n            QuerySubscribesTool,\n            QuerySubscribeSharesTool,\n            QueryPopularSubscribesTool,\n            QueryRuleGroupsTool,\n            QuerySubscribeHistoryTool,\n            DeleteSubscribeTool,\n            QueryDownloadTasksTool,\n            DeleteDownloadTool,\n            QueryDownloadersTool,\n            QuerySitesTool,\n            UpdateSiteTool,\n            QuerySiteUserdataTool,\n            TestSiteTool,\n            UpdateSiteCookieTool,\n            GetRecommendationsTool,\n            QueryLibraryExistsTool,\n            QueryLibraryLatestTool,\n            QueryDirectorySettingsTool,\n            ListDirectoryTool,\n            QueryTransferHistoryTool,\n            TransferFileTool,\n            SendMessageTool,\n            QuerySchedulersTool,\n            RunSchedulerTool,\n            QueryWorkflowsTool,\n            RunWorkflowTool,\n            ExecuteCommandTool\n        ]\n        # 创建内置工具\n        for ToolClass in tool_definitions:\n            tool = ToolClass(\n                session_id=session_id,\n                user_id=user_id\n            )\n            tool.set_message_attr(channel=channel, source=source, username=username)\n            tool.set_callback_handler(callback_handler=callback_handler)\n            tools.append(tool)\n        \n        # 加载插件提供的工具\n        plugin_tools_count = 0\n        plugin_tools_info = PluginManager().get_plugin_agent_tools()\n        for plugin_info in plugin_tools_info:\n            plugin_id = plugin_info.get(\"plugin_id\")\n            plugin_name = plugin_info.get(\"plugin_name\")\n            tool_classes = plugin_info.get(\"tools\", [])\n            for ToolClass in tool_classes:\n                try:\n                    # 验证工具类是否继承自 MoviePilotTool\n                    if not issubclass(ToolClass, MoviePilotTool):\n                        logger.warning(f\"插件 {plugin_name}({plugin_id}) 提供的工具类 {ToolClass.__name__} 未继承自 MoviePilotTool，已跳过\")\n                        continue\n                    # 创建工具实例\n                    tool = ToolClass(\n                        session_id=session_id,\n                        user_id=user_id\n                    )\n                    tool.set_message_attr(channel=channel, source=source, username=username)\n                    tool.set_callback_handler(callback_handler=callback_handler)\n                    tools.append(tool)\n                    plugin_tools_count += 1\n                    logger.debug(f\"成功加载插件 {plugin_name}({plugin_id}) 的工具: {ToolClass.__name__}\")\n                except Exception as e:\n                    logger.error(f\"加载插件 {plugin_name}({plugin_id}) 的工具 {ToolClass.__name__} 失败: {str(e)}\")\n        \n        builtin_tools_count = len(tool_definitions)\n        if plugin_tools_count > 0:\n            logger.info(f\"成功创建 {len(tools)} 个MoviePilot工具（内置工具: {builtin_tools_count} 个，插件工具: {plugin_tools_count} 个）\")\n        else:\n            logger.info(f\"成功创建 {len(tools)} 个MoviePilot工具\")\n        return tools\n"
  },
  {
    "path": "app/agent/tools/impl/__init__.py",
    "content": ""
  },
  {
    "path": "app/agent/tools/impl/_torrent_search_utils.py",
    "content": "\"\"\"种子搜索工具辅助函数\"\"\"\n\nimport re\nfrom typing import List, Optional\n\nfrom app.core.context import Context\nfrom app.utils.crypto import HashUtils\nfrom app.utils.string import StringUtils\n\nSEARCH_RESULT_CACHE_FILE = \"__search_result__\"\nTORRENT_RESULT_LIMIT = 50\n\n\ndef build_torrent_ref(context: Optional[Context]) -> str:\n    \"\"\"生成用于下载校验的短引用\"\"\"\n    if not context or not context.torrent_info:\n        return \"\"\n    return HashUtils.sha1(context.torrent_info.enclosure or \"\")[:7]\n\n\ndef sort_season_options(options: List[str]) -> List[str]:\n    \"\"\"按前端逻辑排序季集选项\"\"\"\n    if len(options) <= 1:\n        return options\n\n    parsed_options = []\n    for index, option in enumerate(options):\n        match = re.match(r\"^S(\\d+)(?:-S(\\d+))?\\s*(?:E(\\d+)(?:-E(\\d+))?)?$\", option or \"\")\n        if not match:\n            parsed_options.append({\n                \"original\": option,\n                \"season_num\": 0,\n                \"episode_num\": 0,\n                \"max_episode_num\": 0,\n                \"is_whole_season\": False,\n                \"index\": index,\n            })\n            continue\n\n        episode_num = int(match.group(3)) if match.group(3) else 0\n        max_episode_num = int(match.group(4)) if match.group(4) else episode_num\n        parsed_options.append({\n            \"original\": option,\n            \"season_num\": int(match.group(1)),\n            \"episode_num\": episode_num,\n            \"max_episode_num\": max_episode_num,\n            \"is_whole_season\": not match.group(3),\n            \"index\": index,\n        })\n\n    whole_seasons = [item for item in parsed_options if item[\"is_whole_season\"]]\n    episodes = [item for item in parsed_options if not item[\"is_whole_season\"]]\n\n    whole_seasons.sort(key=lambda item: (-item[\"season_num\"], item[\"index\"]))\n    episodes.sort(\n        key=lambda item: (\n            -item[\"season_num\"],\n            -(item[\"max_episode_num\"] or item[\"episode_num\"]),\n            -item[\"episode_num\"],\n            item[\"index\"],\n        )\n    )\n    return [item[\"original\"] for item in whole_seasons + episodes]\n\n\ndef append_option(options: List[str], value: Optional[str]) -> None:\n    \"\"\"按前端逻辑收集去重后的筛选项\"\"\"\n    if value and value not in options:\n        options.append(value)\n\n\ndef build_filter_options(items: List[Context]) -> dict:\n    \"\"\"从搜索结果中构建筛选项汇总\"\"\"\n    filter_options = {\n        \"site\": [],\n        \"season\": [],\n        \"freeState\": [],\n        \"edition\": [],\n        \"resolution\": [],\n        \"videoCode\": [],\n        \"releaseGroup\": [],\n    }\n\n    for item in items:\n        torrent_info = item.torrent_info\n        meta_info = item.meta_info\n        append_option(filter_options[\"site\"], getattr(torrent_info, \"site_name\", None))\n        append_option(filter_options[\"season\"], getattr(meta_info, \"season_episode\", None))\n        append_option(filter_options[\"freeState\"], getattr(torrent_info, \"volume_factor\", None))\n        append_option(filter_options[\"edition\"], getattr(meta_info, \"edition\", None))\n        append_option(filter_options[\"resolution\"], getattr(meta_info, \"resource_pix\", None))\n        append_option(filter_options[\"videoCode\"], getattr(meta_info, \"video_encode\", None))\n        append_option(filter_options[\"releaseGroup\"], getattr(meta_info, \"resource_team\", None))\n\n    filter_options[\"season\"] = sort_season_options(filter_options[\"season\"])\n    return filter_options\n\n\ndef match_filter(filter_values: Optional[List[str]], value: Optional[str]) -> bool:\n    \"\"\"匹配前端同款多选筛选规则\"\"\"\n    return not filter_values or bool(value and value in filter_values)\n\n\ndef filter_contexts(items: List[Context],\n                    site: Optional[List[str]] = None,\n                    season: Optional[List[str]] = None,\n                    free_state: Optional[List[str]] = None,\n                    video_code: Optional[List[str]] = None,\n                    edition: Optional[List[str]] = None,\n                    resolution: Optional[List[str]] = None,\n                    release_group: Optional[List[str]] = None) -> List[Context]:\n    \"\"\"按前端同款维度筛选结果\"\"\"\n    filtered_items = []\n    for item in items:\n        torrent_info = item.torrent_info\n        meta_info = item.meta_info\n        if (\n            match_filter(site, getattr(torrent_info, \"site_name\", None))\n            and match_filter(free_state, getattr(torrent_info, \"volume_factor\", None))\n            and match_filter(season, getattr(meta_info, \"season_episode\", None))\n            and match_filter(release_group, getattr(meta_info, \"resource_team\", None))\n            and match_filter(video_code, getattr(meta_info, \"video_encode\", None))\n            and match_filter(resolution, getattr(meta_info, \"resource_pix\", None))\n            and match_filter(edition, getattr(meta_info, \"edition\", None))\n        ):\n            filtered_items.append(item)\n    return filtered_items\n\n\ndef simplify_search_result(context: Context, index: int) -> dict:\n    \"\"\"精简单条搜索结果\"\"\"\n    simplified = {}\n    torrent_info = context.torrent_info\n    meta_info = context.meta_info\n    media_info = context.media_info\n\n    if torrent_info:\n        simplified[\"torrent_info\"] = {\n            \"title\": torrent_info.title,\n            \"size\": StringUtils.format_size(torrent_info.size),\n            \"seeders\": torrent_info.seeders,\n            \"peers\": torrent_info.peers,\n            \"site_name\": torrent_info.site_name,\n            \"torrent_url\": f\"{build_torrent_ref(context)}:{index}\",\n            \"page_url\": torrent_info.page_url,\n            \"volume_factor\": torrent_info.volume_factor,\n            \"freedate_diff\": torrent_info.freedate_diff,\n            \"pubdate\": torrent_info.pubdate,\n        }\n\n    if media_info:\n        simplified[\"media_info\"] = {\n            \"title\": media_info.title,\n            \"en_title\": media_info.en_title,\n            \"year\": media_info.year,\n            \"type\": media_info.type.value if media_info.type else None,\n            \"season\": media_info.season,\n            \"tmdb_id\": media_info.tmdb_id,\n        }\n\n    if meta_info:\n        simplified[\"meta_info\"] = {\n            \"name\": meta_info.name,\n            \"cn_name\": meta_info.cn_name,\n            \"en_name\": meta_info.en_name,\n            \"year\": meta_info.year,\n            \"type\": meta_info.type.value if meta_info.type else None,\n            \"begin_season\": meta_info.begin_season,\n            \"season_episode\": meta_info.season_episode,\n            \"resource_team\": meta_info.resource_team,\n            \"video_encode\": meta_info.video_encode,\n            \"edition\": meta_info.edition,\n            \"resource_pix\": meta_info.resource_pix,\n        }\n\n    return simplified\n"
  },
  {
    "path": "app/agent/tools/impl/add_download.py",
    "content": "\"\"\"添加下载工具\"\"\"\n\nimport re\nfrom pathlib import Path\nfrom typing import List, Optional, Type\n\nfrom pydantic import BaseModel, Field\n\nfrom app.agent.tools.base import MoviePilotTool, ToolChain\nfrom app.chain.search import SearchChain\nfrom app.chain.download import DownloadChain\nfrom app.core.config import settings\nfrom app.core.context import Context\nfrom app.core.metainfo import MetaInfo\nfrom app.db.site_oper import SiteOper\nfrom app.helper.directory import DirectoryHelper\nfrom app.log import logger\nfrom app.schemas import TorrentInfo, FileURI\nfrom app.utils.crypto import HashUtils\n\n\nclass AddDownloadInput(BaseModel):\n    \"\"\"添加下载工具的输入参数模型\"\"\"\n    explanation: str = Field(..., description=\"Clear explanation of why this tool is being used in the current context\")\n    torrent_url: List[str] = Field(\n        ...,\n        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.\"\n    )\n    downloader: Optional[str] = Field(None,\n                                      description=\"Name of the downloader to use (optional, uses default if not specified)\")\n    save_path: Optional[str] = Field(None,\n                                     description=\"Directory path where the downloaded files should be saved. Using `<storage>:<path>` for remote storage. e.g. rclone:/MP, smb:/server/share/Movies. (optional, uses default path if not specified)\")\n    labels: Optional[str] = Field(None,\n                                  description=\"Comma-separated list of labels/tags to assign to the download (optional, e.g., 'movie,hd,bluray')\")\n\n\nclass AddDownloadTool(MoviePilotTool):\n    name: str = \"add_download\"\n    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.\"\n    args_schema: Type[BaseModel] = AddDownloadInput\n\n    def get_tool_message(self, **kwargs) -> Optional[str]:\n        \"\"\"根据下载参数生成友好的提示消息\"\"\"\n        torrent_urls = self._normalize_torrent_urls(kwargs.get(\"torrent_url\"))\n        downloader = kwargs.get(\"downloader\")\n\n        if torrent_urls:\n            if len(torrent_urls) == 1:\n                if self._is_torrent_ref(torrent_urls[0]):\n                    message = f\"正在添加下载任务: 资源 {torrent_urls[0]}\"\n                else:\n                    message = \"正在添加下载任务: 直链或磁力链接\"\n            else:\n                message = f\"正在批量添加下载任务: 共 {len(torrent_urls)} 个资源\"\n        else:\n            message = \"正在添加下载任务\"\n        if downloader:\n            message += f\" [下载器: {downloader}]\"\n\n        return message\n\n    @staticmethod\n    def _build_torrent_ref(context: Context) -> str:\n        \"\"\"生成用于校验缓存项的短引用\"\"\"\n        if not context or not context.torrent_info:\n            return \"\"\n        return HashUtils.sha1(context.torrent_info.enclosure or \"\")[:7]\n\n    @staticmethod\n    def _is_torrent_ref(torrent_ref: Optional[str]) -> bool:\n        \"\"\"判断是否为内部搜索结果引用\"\"\"\n        if not torrent_ref:\n            return False\n        return bool(re.fullmatch(r\"[0-9a-f]{7}:\\d+\", str(torrent_ref).strip()))\n\n    @staticmethod\n    def _is_direct_download_url(torrent_url: Optional[str]) -> bool:\n        \"\"\"判断是否为允许直传下载器的下载内容\"\"\"\n        if not torrent_url:\n            return False\n        value = str(torrent_url).strip()\n        return value.startswith(\"http://\") or value.startswith(\"https://\") or value.startswith(\"magnet:\")\n\n    @classmethod\n    def _resolve_cached_context(cls, torrent_ref: str) -> Optional[Context]:\n        \"\"\"从最近一次搜索缓存中解析种子上下文，仅支持 hash:id 格式\"\"\"\n        ref = str(torrent_ref).strip()\n        if \":\" not in ref:\n            return None\n        try:\n            ref_hash, ref_index = ref.split(\":\", 1)\n            index = int(ref_index)\n        except (TypeError, ValueError):\n            return None\n\n        if index < 1:\n            return None\n\n        results = SearchChain().last_search_results() or []\n        if index > len(results):\n            return None\n        context = results[index - 1]\n        if not ref_hash or cls._build_torrent_ref(context) != ref_hash:\n            return None\n        return context\n\n    @staticmethod\n    def _merge_labels_with_system_tag(labels: Optional[str]) -> Optional[str]:\n        \"\"\"合并用户标签与系统默认标签，确保任务可被系统管理\"\"\"\n        system_tag = (settings.TORRENT_TAG or \"\").strip()\n        user_labels = [item.strip() for item in (labels or \"\").split(\",\") if item.strip()]\n\n        if system_tag and system_tag not in user_labels:\n            user_labels.append(system_tag)\n\n        return \",\".join(user_labels) if user_labels else None\n\n    @staticmethod\n    def _format_failed_result(failed_messages: List[str]) -> str:\n        \"\"\"统一格式化失败结果\"\"\"\n        return \", \".join([message for message in failed_messages if message])\n\n    @staticmethod\n    def _build_failure_message(torrent_ref: str, error_msg: Optional[str] = None) -> str:\n        \"\"\"构造失败提示\"\"\"\n        normalized_error = (error_msg or \"\").strip()\n        prefix = \"添加种子任务失败：\"\n        if normalized_error.startswith(prefix):\n            normalized_error = normalized_error[len(prefix):].lstrip()\n        if AddDownloadTool._is_direct_download_url(normalized_error):\n            normalized_error = \"\"\n        if normalized_error:\n            return f\"{torrent_ref} {normalized_error}\"\n        if AddDownloadTool._is_torrent_ref(torrent_ref):\n            return torrent_ref\n        return \"\"\n\n    @classmethod\n    def _normalize_torrent_urls(cls, torrent_url: Optional[List[str] | str]) -> List[str]:\n        \"\"\"统一规范 torrent_url 输入，保留所有非空值\"\"\"\n        if torrent_url is None:\n            return []\n\n        if isinstance(torrent_url, str):\n            candidates = torrent_url.split(\",\")\n        else:\n            candidates = torrent_url\n\n        return [str(item).strip() for item in candidates if item and str(item).strip()]\n\n    @staticmethod\n    def _resolve_direct_download_dir(save_path: Optional[str]) -> Optional[Path]:\n        \"\"\"解析直接下载使用的目录，优先使用 save_path，其次使用默认下载目录\"\"\"\n        if save_path:\n            return Path(save_path)\n\n        download_dirs = DirectoryHelper().get_download_dirs()\n        if not download_dirs:\n            return None\n\n        dir_conf = download_dirs[0]\n        if not dir_conf.download_path:\n            return None\n\n        return Path(FileURI(storage=dir_conf.storage or \"local\", path=dir_conf.download_path).uri)\n\n    async def run(self, torrent_url: Optional[List[str]] = None,\n                  downloader: Optional[str] = None, save_path: Optional[str] = None,\n                  labels: Optional[str] = None, **kwargs) -> str:\n        logger.info(\n            f\"执行工具: {self.name}, 参数: torrent_url={torrent_url}, downloader={downloader}, save_path={save_path}, labels={labels}\")\n\n        try:\n            torrent_inputs = self._normalize_torrent_urls(torrent_url)\n            if not torrent_inputs:\n                return \"错误：torrent_url 不能为空。\"\n\n            download_chain = DownloadChain()\n            merged_labels = self._merge_labels_with_system_tag(labels)\n            success_count = 0\n            failed_messages = []\n\n            for torrent_input in torrent_inputs:\n                if self._is_torrent_ref(torrent_input):\n                    cached_context = self._resolve_cached_context(torrent_input)\n                    if not cached_context or not cached_context.torrent_info:\n                        failed_messages.append(f\"{torrent_input} 引用无效，请重新使用 get_search_results 查看搜索结果\")\n                        continue\n\n                    cached_torrent = cached_context.torrent_info\n                    site_name = cached_torrent.site_name\n                    torrent_title = cached_torrent.title or torrent_input\n                    torrent_description = cached_torrent.description\n                    enclosure = cached_torrent.enclosure\n\n                    if not site_name:\n                        failed_messages.append(f\"{torrent_input} 缺少站点名称\")\n                        continue\n\n                    siteinfo = await SiteOper().async_get_by_name(site_name)\n                    if not siteinfo:\n                        failed_messages.append(f\"{torrent_input} 未找到站点信息 {site_name}\")\n                        continue\n\n                    torrent_info = TorrentInfo(\n                        title=torrent_title,\n                        description=torrent_description,\n                        enclosure=enclosure,\n                        site_name=site_name,\n                        site_ua=siteinfo.ua,\n                        site_cookie=siteinfo.cookie,\n                        site_proxy=siteinfo.proxy,\n                        site_order=siteinfo.pri,\n                        site_downloader=siteinfo.downloader\n                    )\n                    meta_info = MetaInfo(title=torrent_title, subtitle=torrent_description)\n                    media_info = cached_context.media_info if cached_context.media_info else None\n                    if not media_info:\n                        media_info = await ToolChain().async_recognize_media(meta=meta_info)\n                    if not media_info:\n                        failed_messages.append(f\"{torrent_input} 无法识别媒体信息\")\n                        continue\n\n                    context = Context(\n                        torrent_info=torrent_info,\n                        meta_info=meta_info,\n                        media_info=media_info\n                    )\n                else:\n                    if not self._is_direct_download_url(torrent_input):\n                        failed_messages.append(\n                            f\"{torrent_input} 不是有效的下载内容，非 hash:id 时仅支持 http://、https:// 或 magnet: 开头\"\n                        )\n                        continue\n                    download_dir = self._resolve_direct_download_dir(save_path)\n                    if not download_dir:\n                        failed_messages.append(f\"{torrent_input} 缺少保存路径，且系统未配置可用下载目录\")\n                        continue\n                    result = download_chain.download(\n                        content=torrent_input,\n                        download_dir=download_dir,\n                        cookie=None,\n                        label=merged_labels,\n                        downloader=downloader\n                    )\n                    if result:\n                        _, did, _, error_msg = result\n                    else:\n                        did, error_msg = None, \"未找到下载器\"\n                    if did:\n                        success_count += 1\n                    else:\n                        failed_messages.append(self._build_failure_message(torrent_input, error_msg))\n                    continue\n\n                did, error_msg = download_chain.download_single(\n                    context=context,\n                    downloader=downloader,\n                    save_path=save_path,\n                    label=merged_labels,\n                    return_detail=True\n                )\n                if did:\n                    success_count += 1\n                else:\n                    failed_messages.append(self._build_failure_message(torrent_input, error_msg))\n\n            if success_count and not failed_messages:\n                return \"任务添加成功\"\n\n            if success_count:\n                return f\"部分任务添加失败：{self._format_failed_result(failed_messages)}\"\n\n            return f\"任务添加失败：{self._format_failed_result(failed_messages)}\"\n        except Exception as e:\n            logger.error(f\"添加下载任务失败: {e}\", exc_info=True)\n            return f\"添加下载任务时发生错误: {str(e)}\"\n"
  },
  {
    "path": "app/agent/tools/impl/add_subscribe.py",
    "content": "\"\"\"添加订阅工具\"\"\"\n\nfrom typing import Optional, Type, List\n\nfrom pydantic import BaseModel, Field\n\nfrom app.agent.tools.base import MoviePilotTool\nfrom app.chain.subscribe import SubscribeChain\nfrom app.log import logger\nfrom app.schemas.types import MediaType\n\n\nclass AddSubscribeInput(BaseModel):\n    \"\"\"添加订阅工具的输入参数模型\"\"\"\n    explanation: str = Field(..., description=\"Clear explanation of why this tool is being used in the current context\")\n    title: str = Field(..., description=\"The title of the media to subscribe to (e.g., 'The Matrix', 'Breaking Bad')\")\n    year: str = Field(..., description=\"Release year of the media (required for accurate identification)\")\n    media_type: str = Field(...,\n                            description=\"Allowed values: movie, tv\")\n    season: Optional[int] = Field(None,\n                                  description=\"Season number for TV shows (optional, if not specified will subscribe to all seasons)\")\n    tmdb_id: Optional[int] = Field(None,\n                                   description=\"TMDB database ID for precise media identification (optional, can be obtained from search_media tool)\")\n    douban_id: Optional[str] = Field(None,\n                                     description=\"Douban ID for precise media identification (optional, alternative to tmdb_id)\")\n    start_episode: Optional[int] = Field(None,\n                                          description=\"Starting episode number for TV shows (optional, defaults to 1 if not specified)\")\n    total_episode: Optional[int] = Field(None,\n                                          description=\"Total number of episodes for TV shows (optional, will be auto-detected from TMDB if not specified)\")\n    quality: Optional[str] = Field(None,\n                                   description=\"Quality filter as regular expression (optional, e.g., 'BluRay|WEB-DL|HDTV')\")\n    resolution: Optional[str] = Field(None,\n                                      description=\"Resolution filter as regular expression (optional, e.g., '1080p|720p|2160p')\")\n    effect: Optional[str] = Field(None,\n                                  description=\"Effect filter as regular expression (optional, e.g., 'HDR|DV|SDR')\")\n    filter_groups: Optional[List[str]] = Field(None,\n                               description=\"List of filter rule group names to apply (optional, can be obtained from query_rule_groups tool)\")\n    sites: Optional[List[int]] = Field(None,\n                           description=\"List of site IDs to search from (optional, can be obtained from query_sites tool)\")\n\n\nclass AddSubscribeTool(MoviePilotTool):\n    name: str = \"add_subscribe\"\n    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.\"\n    args_schema: Type[BaseModel] = AddSubscribeInput\n\n    def get_tool_message(self, **kwargs) -> Optional[str]:\n        \"\"\"根据订阅参数生成友好的提示消息\"\"\"\n        title = kwargs.get(\"title\", \"\")\n        year = kwargs.get(\"year\", \"\")\n        media_type = kwargs.get(\"media_type\", \"\")\n        season = kwargs.get(\"season\")\n        \n        message = f\"正在添加订阅: {title}\"\n        if year:\n            message += f\" ({year})\"\n        if media_type:\n            message += f\" [{media_type}]\"\n        if season:\n            message += f\" 第{season}季\"\n        \n        return message\n\n    async def run(self, title: str, year: str, media_type: str,\n                  season: Optional[int] = None, tmdb_id: Optional[int] = None,\n                  douban_id: Optional[str] = None,\n                  start_episode: Optional[int] = None, total_episode: Optional[int] = None,\n                  quality: Optional[str] = None, resolution: Optional[str] = None,\n                  effect: Optional[str] = None, filter_groups: Optional[List[str]] = None,\n                  sites: Optional[List[int]] = None, **kwargs) -> str:\n        logger.info(\n            f\"执行工具: {self.name}, 参数: title={title}, year={year}, media_type={media_type}, \"\n            f\"season={season}, tmdb_id={tmdb_id}, douban_id={douban_id}, start_episode={start_episode}, \"\n            f\"total_episode={total_episode}, quality={quality}, resolution={resolution}, \"\n            f\"effect={effect}, filter_groups={filter_groups}, sites={sites}\")\n\n        try:\n            subscribe_chain = SubscribeChain()\n            media_type_enum = MediaType.from_agent(media_type)\n            if not media_type_enum:\n                return f\"错误：无效的媒体类型 '{media_type}'，支持的类型：'movie', 'tv'\"\n\n            # 构建额外的订阅参数\n            subscribe_kwargs = {}\n            if start_episode is not None:\n                subscribe_kwargs['start_episode'] = start_episode\n            if total_episode is not None:\n                subscribe_kwargs['total_episode'] = total_episode\n            if quality:\n                subscribe_kwargs['quality'] = quality\n            if resolution:\n                subscribe_kwargs['resolution'] = resolution\n            if effect:\n                subscribe_kwargs['effect'] = effect\n            if filter_groups:\n                subscribe_kwargs['filter_groups'] = filter_groups\n            if sites:\n                subscribe_kwargs['sites'] = sites\n\n            sid, message = await subscribe_chain.async_add(\n                mtype=media_type_enum,\n                title=title,\n                year=year,\n                tmdbid=tmdb_id,\n                doubanid=douban_id,\n                season=season,\n                username=self._user_id,\n                **subscribe_kwargs\n            )\n            if sid:\n                if message and \"已存在\" in message:\n                    return f\"订阅已存在：{title} ({year})。如需修改参数请先删除旧订阅。\"\n\n                result_msg = f\"成功添加订阅：{title} ({year})\"\n                if subscribe_kwargs:\n                    params = []\n                    if start_episode is not None:\n                        params.append(f\"开始集数: {start_episode}\")\n                    if total_episode is not None:\n                        params.append(f\"总集数: {total_episode}\")\n                    if quality:\n                        params.append(f\"质量过滤: {quality}\")\n                    if resolution:\n                        params.append(f\"分辨率过滤: {resolution}\")\n                    if effect:\n                        params.append(f\"特效过滤: {effect}\")\n                    if filter_groups:\n                        params.append(f\"规则组: {', '.join(filter_groups)}\")\n                    if sites:\n                        params.append(f\"站点: {', '.join(map(str, sites))}\")\n                    if params:\n                        result_msg += f\"\\n配置参数: {', '.join(params)}\"\n                return result_msg\n            else:\n                return f\"添加订阅失败：{message}\"\n        except Exception as e:\n            logger.error(f\"添加订阅失败: {e}\", exc_info=True)\n            return f\"添加订阅时发生错误: {str(e)}\"\n"
  },
  {
    "path": "app/agent/tools/impl/delete_download.py",
    "content": "\"\"\"删除下载任务工具\"\"\"\n\nfrom typing import Optional, Type\n\nfrom pydantic import BaseModel, Field\n\nfrom app.agent.tools.base import MoviePilotTool\nfrom app.chain.download import DownloadChain\nfrom app.log import logger\n\n\nclass DeleteDownloadInput(BaseModel):\n    \"\"\"删除下载任务工具的输入参数模型\"\"\"\n    explanation: str = Field(..., description=\"Clear explanation of why this tool is being used in the current context\")\n    hash: str = Field(..., description=\"Task hash (can be obtained from query_download_tasks tool)\")\n    downloader: Optional[str] = Field(None, description=\"Name of specific downloader (optional, if not provided will search all downloaders)\")\n    delete_files: Optional[bool] = Field(False, description=\"Whether to delete downloaded files along with the task (default: False, only removes the task from downloader)\")\n\n\nclass DeleteDownloadTool(MoviePilotTool):\n    name: str = \"delete_download\"\n    description: str = \"Delete a download task from the downloader by task hash only. Optionally specify the downloader name and whether to delete downloaded files.\"\n    args_schema: Type[BaseModel] = DeleteDownloadInput\n\n    def get_tool_message(self, **kwargs) -> Optional[str]:\n        \"\"\"根据删除参数生成友好的提示消息\"\"\"\n        hash_value = kwargs.get(\"hash\", \"\")\n        downloader = kwargs.get(\"downloader\")\n        delete_files = kwargs.get(\"delete_files\", False)\n        \n        message = f\"正在删除下载任务: {hash_value}\"\n        if downloader:\n            message += f\" [下载器: {downloader}]\"\n        if delete_files:\n            message += \" (包含文件)\"\n        \n        return message\n\n    async def run(self, hash: str, downloader: Optional[str] = None,\n                  delete_files: Optional[bool] = False, **kwargs) -> str:\n        logger.info(f\"执行工具: {self.name}, 参数: hash={hash}, downloader={downloader}, delete_files={delete_files}\")\n\n        try:\n            download_chain = DownloadChain()\n\n            # 仅支持通过hash删除任务\n            if len(hash) != 40 or not all(c in '0123456789abcdefABCDEF' for c in hash):\n                return \"参数错误：hash 格式无效，请先使用 query_download_tasks 工具获取正确的 hash。\"\n            \n            # 删除下载任务\n            # remove_torrents 支持 delete_file 参数，可以控制是否删除文件\n            result = download_chain.remove_torrents(hashs=[hash], downloader=downloader, delete_file=delete_files)\n            \n            if result:\n                files_info = \"（包含文件）\" if delete_files else \"（不包含文件）\"\n                return f\"成功删除下载任务：{hash} {files_info}\"\n            else:\n                return f\"删除下载任务失败：{hash}，请检查任务是否存在或下载器是否可用\"\n        except Exception as e:\n            logger.error(f\"删除下载任务失败: {e}\", exc_info=True)\n            return f\"删除下载任务时发生错误: {str(e)}\"\n\n"
  },
  {
    "path": "app/agent/tools/impl/delete_subscribe.py",
    "content": "\"\"\"删除订阅工具\"\"\"\n\nfrom typing import Optional, Type\n\nfrom pydantic import BaseModel, Field\n\nfrom app.agent.tools.base import MoviePilotTool\nfrom app.core.event import eventmanager\nfrom app.db.subscribe_oper import SubscribeOper\nfrom app.helper.subscribe import SubscribeHelper\nfrom app.log import logger\nfrom app.schemas.types import EventType\n\n\nclass DeleteSubscribeInput(BaseModel):\n    \"\"\"删除订阅工具的输入参数模型\"\"\"\n    explanation: str = Field(..., description=\"Clear explanation of why this tool is being used in the current context\")\n    subscribe_id: int = Field(..., description=\"The ID of the subscription to delete (can be obtained from query_subscribes tool)\")\n\n\nclass DeleteSubscribeTool(MoviePilotTool):\n    name: str = \"delete_subscribe\"\n    description: str = \"Delete a media subscription by its ID. This will remove the subscription and stop automatic downloads for that media.\"\n    args_schema: Type[BaseModel] = DeleteSubscribeInput\n\n    def get_tool_message(self, **kwargs) -> Optional[str]:\n        \"\"\"根据删除参数生成友好的提示消息\"\"\"\n        subscribe_id = kwargs.get(\"subscribe_id\")\n        return f\"正在删除订阅 (ID: {subscribe_id})\"\n\n    async def run(self, subscribe_id: int, **kwargs) -> str:\n        logger.info(f\"执行工具: {self.name}, 参数: subscribe_id={subscribe_id}\")\n\n        try:\n            subscribe_oper = SubscribeOper()\n            # 获取订阅信息\n            subscribe = await subscribe_oper.async_get(subscribe_id)\n            if not subscribe:\n                return f\"订阅 ID {subscribe_id} 不存在\"\n            \n            # 在删除之前获取订阅信息（用于事件）\n            subscribe_info = subscribe.to_dict()\n            \n            # 删除订阅\n            subscribe_oper.delete(subscribe_id)\n            \n            # 发送事件\n            await eventmanager.async_send_event(EventType.SubscribeDeleted, {\n                \"subscribe_id\": subscribe_id,\n                \"subscribe_info\": subscribe_info\n            })\n            \n            # 统计订阅\n            SubscribeHelper().sub_done_async({\n                \"tmdbid\": subscribe.tmdbid,\n                \"doubanid\": subscribe.doubanid\n            })\n            \n            return f\"成功删除订阅：{subscribe.name} ({subscribe.year})\"\n        except Exception as e:\n            logger.error(f\"删除订阅失败: {e}\", exc_info=True)\n            return f\"删除订阅时发生错误: {str(e)}\"\n\n"
  },
  {
    "path": "app/agent/tools/impl/execute_command.py",
    "content": "\"\"\"执行Shell命令工具\"\"\"\n\nimport asyncio\nfrom typing import Optional, Type\n\nfrom pydantic import BaseModel, Field\n\nfrom app.agent.tools.base import MoviePilotTool\nfrom app.log import logger\n\n\nclass ExecuteCommandInput(BaseModel):\n    \"\"\"执行Shell命令工具的输入参数模型\"\"\"\n    explanation: str = Field(..., description=\"Clear explanation of why this command is being executed\")\n    command: str = Field(..., description=\"The shell command to execute\")\n    timeout: Optional[int] = Field(60, description=\"Max execution time in seconds (default: 60)\")\n\n\nclass ExecuteCommandTool(MoviePilotTool):\n    name: str = \"execute_command\"\n    description: str = \"Safely execute shell commands on the server. Useful for system maintenance, checking status, or running custom scripts. Includes timeout and output limits.\"\n    args_schema: Type[BaseModel] = ExecuteCommandInput\n\n    def get_tool_message(self, **kwargs) -> Optional[str]:\n        \"\"\"根据命令生成友好的提示消息\"\"\"\n        command = kwargs.get(\"command\", \"\")\n        return f\"正在执行系统命令: {command}\"\n\n    async def run(self, command: str, timeout: Optional[int] = 60, **kwargs) -> str:\n        logger.info(f\"执行工具: {self.name}, 参数: command={command}, timeout={timeout}\")\n\n        # 简单安全过滤\n        forbidden_keywords = [\"rm -rf /\", \":(){ :|:& };:\", \"dd if=/dev/zero\", \"mkfs\", \"reboot\", \"shutdown\"]\n        for keyword in forbidden_keywords:\n            if keyword in command:\n                return f\"错误：命令包含禁止使用的关键字 '{keyword}'\"\n\n        try:\n            # 执行命令\n            process = await asyncio.create_subprocess_shell(\n                command,\n                stdout=asyncio.subprocess.PIPE,\n                stderr=asyncio.subprocess.PIPE\n            )\n\n            try:\n                # 等待完成，带超时\n                stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=timeout)\n                \n                # 处理输出\n                stdout_str = stdout.decode('utf-8', errors='replace').strip()\n                stderr_str = stderr.decode('utf-8', errors='replace').strip()\n                exit_code = process.returncode\n\n                result = f\"命令执行完成 (退出码: {exit_code})\"\n                if stdout_str:\n                    result += f\"\\n\\n标准输出:\\n{stdout_str}\"\n                if stderr_str:\n                    result += f\"\\n\\n错误输出:\\n{stderr_str}\"\n                \n                # 如果没有输出\n                if not stdout_str and not stderr_str:\n                    result += \"\\n\\n(无输出内容)\"\n                \n                # 限制输出长度，防止上下文过长\n                if len(result) > 3000:\n                    result = result[:3000] + \"\\n\\n...(输出内容过长，已截断)\"\n                \n                return result\n\n            except asyncio.TimeoutError:\n                # 超时处理\n                try:\n                    process.kill()\n                except ProcessLookupError:\n                    pass\n                return f\"命令执行超时 (限制: {timeout}秒)\"\n\n        except Exception as e:\n            logger.error(f\"执行命令失败: {e}\", exc_info=True)\n            return f\"执行命令时发生错误: {str(e)}\"\n"
  },
  {
    "path": "app/agent/tools/impl/get_recommendations.py",
    "content": "\"\"\"获取推荐工具\"\"\"\n\nimport json\nfrom typing import Optional, Type\n\nfrom pydantic import BaseModel, Field\n\nfrom app.agent.tools.base import MoviePilotTool\nfrom app.chain.recommend import RecommendChain\nfrom app.log import logger\nfrom app.schemas.types import MediaType, media_type_to_agent\n\n\nclass GetRecommendationsInput(BaseModel):\n    \"\"\"获取推荐工具的输入参数模型\"\"\"\n    explanation: str = Field(..., description=\"Clear explanation of why this tool is being used in the current context\")\n    source: Optional[str] = Field(\"tmdb_trending\",\n                                  description=\"Recommendation source: \"\n                                  \"'tmdb_trending' for TMDB trending content, \"\n                                  \"'tmdb_movies' for TMDB popular movies, \"\n                                  \"'tmdb_tvs' for TMDB popular TV shows, \"\n                                  \"'douban_hot' for Douban popular content, \"\n                                  \"'douban_movie_hot' for Douban hot movies, \"\n                                  \"'douban_tv_hot' for Douban hot TV shows, \"\n                                  \"'douban_movie_showing' for Douban movies currently showing, \"\n                                  \"'douban_movies' for Douban latest movies, \"\n                                  \"'douban_tvs' for Douban latest TV shows, \"\n                                  \"'douban_movie_top250' for Douban movie TOP250, \"\n                                  \"'douban_tv_weekly_chinese' for Douban Chinese TV weekly chart, \"\n                                  \"'douban_tv_weekly_global' for Douban global TV weekly chart, \"\n                                  \"'douban_tv_animation' for Douban popular animation, \"\n                                  \"'bangumi_calendar' for Bangumi anime calendar\")\n    media_type: Optional[str] = Field(\"all\",\n                                      description=\"Allowed values: movie, tv, all\")\n    limit: Optional[int] = Field(20,\n                                 description=\"Maximum number of recommendations to return (default: 20, maximum: 100)\")\n\n\nclass GetRecommendationsTool(MoviePilotTool):\n    name: str = \"get_recommendations\"\n    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.\"\n    args_schema: Type[BaseModel] = GetRecommendationsInput\n\n    def get_tool_message(self, **kwargs) -> Optional[str]:\n        \"\"\"根据推荐参数生成友好的提示消息\"\"\"\n        source = kwargs.get(\"source\", \"tmdb_trending\")\n        media_type = kwargs.get(\"media_type\", \"all\")\n        limit = kwargs.get(\"limit\", 20)\n        \n        source_map = {\n            \"tmdb_trending\": \"TMDB流行趋势\",\n            \"tmdb_movies\": \"TMDB热门电影\",\n            \"tmdb_tvs\": \"TMDB热门电视剧\",\n            \"douban_hot\": \"豆瓣热门\",\n            \"douban_movie_hot\": \"豆瓣热门电影\",\n            \"douban_tv_hot\": \"豆瓣热门电视剧\",\n            \"douban_movie_showing\": \"豆瓣正在热映\",\n            \"douban_movies\": \"豆瓣最新电影\",\n            \"douban_tvs\": \"豆瓣最新电视剧\",\n            \"douban_movie_top250\": \"豆瓣电影TOP250\",\n            \"douban_tv_weekly_chinese\": \"豆瓣国产剧集榜\",\n            \"douban_tv_weekly_global\": \"豆瓣全球剧集榜\",\n            \"douban_tv_animation\": \"豆瓣热门动漫\",\n            \"bangumi_calendar\": \"番组计划\"\n        }\n        source_desc = source_map.get(source, source)\n        \n        message = f\"正在获取推荐: {source_desc}\"\n        if media_type != \"all\":\n            message += f\" [{media_type}]\"\n        message += f\" (限制: {limit}条)\"\n        \n        return message\n\n    async def run(self, source: Optional[str] = \"tmdb_trending\",\n                  media_type: Optional[str] = \"all\", limit: Optional[int] = 20, **kwargs) -> str:\n        logger.info(f\"执行工具: {self.name}, 参数: source={source}, media_type={media_type}, limit={limit}\")\n        try:\n            if media_type != \"all\":\n                media_type_enum = MediaType.from_agent(media_type)\n                if not media_type_enum:\n                    return f\"错误：无效的媒体类型 '{media_type}'，支持的类型：'movie', 'tv', 'all'\"\n                media_type = media_type_enum.to_agent()  # 归一化为 \"movie\"/\"tv\"\n\n            recommend_chain = RecommendChain()\n            results = []\n            if source == \"tmdb_trending\":\n                # async_tmdb_trending 只接受 page 参数，返回固定数量的结果\n                # 如果需要限制数量，需要在返回后截取\n                results = await recommend_chain.async_tmdb_trending(page=1)\n                if limit and limit > 0:\n                    results = results[:limit]\n            elif source == \"tmdb_movies\":\n                # async_tmdb_movies 接受 page 参数，返回固定数量的结果\n                results = await recommend_chain.async_tmdb_movies(page=1)\n                if limit and limit > 0:\n                    results = results[:limit]\n            elif source == \"tmdb_tvs\":\n                # async_tmdb_tvs 接受 page 参数，返回固定数量的结果\n                results = await recommend_chain.async_tmdb_tvs(page=1)\n                if limit and limit > 0:\n                    results = results[:limit]\n            elif source == \"douban_hot\":\n                if media_type == \"movie\":\n                    results = await recommend_chain.async_douban_movie_hot(page=1, count=limit)\n                elif media_type == \"tv\":\n                    results = await recommend_chain.async_douban_tv_hot(page=1, count=limit)\n                else:  # all\n                    results.extend(await recommend_chain.async_douban_movie_hot(page=1, count=limit))\n                    results.extend(await recommend_chain.async_douban_tv_hot(page=1, count=limit))\n            elif source == \"douban_movie_hot\":\n                results = await recommend_chain.async_douban_movie_hot(page=1, count=limit)\n            elif source == \"douban_tv_hot\":\n                results = await recommend_chain.async_douban_tv_hot(page=1, count=limit)\n            elif source == \"douban_movie_showing\":\n                results = await recommend_chain.async_douban_movie_showing(page=1, count=limit)\n            elif source == \"douban_movies\":\n                results = await recommend_chain.async_douban_movies(page=1, count=limit)\n            elif source == \"douban_tvs\":\n                results = await recommend_chain.async_douban_tvs(page=1, count=limit)\n            elif source == \"douban_movie_top250\":\n                results = await recommend_chain.async_douban_movie_top250(page=1, count=limit)\n            elif source == \"douban_tv_weekly_chinese\":\n                results = await recommend_chain.async_douban_tv_weekly_chinese(page=1, count=limit)\n            elif source == \"douban_tv_weekly_global\":\n                results = await recommend_chain.async_douban_tv_weekly_global(page=1, count=limit)\n            elif source == \"douban_tv_animation\":\n                results = await recommend_chain.async_douban_tv_animation(page=1, count=limit)\n            elif source == \"bangumi_calendar\":\n                results = await recommend_chain.async_bangumi_calendar(page=1, count=limit)\n            else:\n                # 不支持的推荐来源\n                supported_sources = [\n                    \"tmdb_trending\", \"tmdb_movies\", \"tmdb_tvs\",\n                    \"douban_hot\", \"douban_movie_hot\", \"douban_tv_hot\",\n                    \"douban_movie_showing\", \"douban_movies\", \"douban_tvs\",\n                    \"douban_movie_top250\", \"douban_tv_weekly_chinese\",\n                    \"douban_tv_weekly_global\", \"douban_tv_animation\",\n                    \"bangumi_calendar\"\n                ]\n                return f\"不支持的推荐来源: {source}。支持的来源包括: {', '.join(supported_sources)}\"\n\n            if results:\n                # 限制最多20条结果\n                total_count = len(results)\n                limited_results = results[:20]\n                # 精简字段，只保留关键信息\n                simplified_results = []\n                for r in limited_results:\n                    # r 应该是字典格式（to_dict的结果），但为了安全起见进行检查\n                    if not isinstance(r, dict):\n                        logger.warning(f\"推荐结果格式异常，跳过: {type(r)}\")\n                        continue\n                    \n                    simplified = {\n                        \"title\": r.get(\"title\"),\n                        \"en_title\": r.get(\"en_title\"),\n                        \"year\": r.get(\"year\"),\n                        \"type\": media_type_to_agent(r.get(\"type\")),\n                        \"season\": r.get(\"season\"),\n                        \"tmdb_id\": r.get(\"tmdb_id\"),\n                        \"imdb_id\": r.get(\"imdb_id\"),\n                        \"douban_id\": r.get(\"douban_id\"),\n                        \"vote_average\": r.get(\"vote_average\"),\n                        \"poster_path\": r.get(\"poster_path\"),\n                        \"detail_link\": r.get(\"detail_link\")\n                    }\n                    simplified_results.append(simplified)\n                result_json = json.dumps(simplified_results, ensure_ascii=False, indent=2)\n                # 如果结果被裁剪，添加提示信息\n                if total_count > 20:\n                    return f\"注意：推荐结果共找到 {total_count} 条，为节省上下文空间，仅显示前 20 条结果。\\n\\n{result_json}\"\n                return result_json\n            return \"未找到推荐内容。\"\n        except Exception as e:\n            logger.error(f\"获取推荐失败: {e}\", exc_info=True)\n            return f\"获取推荐时发生错误: {str(e)}\"\n"
  },
  {
    "path": "app/agent/tools/impl/get_search_results.py",
    "content": "\"\"\"获取搜索结果工具\"\"\"\n\nimport json\nimport re\nfrom typing import List, Optional, Type\n\nfrom pydantic import BaseModel, Field\n\nfrom app.agent.tools.base import MoviePilotTool\nfrom app.chain.search import SearchChain\nfrom app.log import logger\nfrom ._torrent_search_utils import (\n    TORRENT_RESULT_LIMIT,\n    build_filter_options,\n    filter_contexts,\n    simplify_search_result,\n)\n\n\nclass GetSearchResultsInput(BaseModel):\n    \"\"\"获取搜索结果工具的输入参数模型\"\"\"\n    explanation: str = Field(..., description=\"Clear explanation of why this tool is being used in the current context\")\n    site: Optional[List[str]] = Field(None, description=\"Site name filters\")\n    season: Optional[List[str]] = Field(None, description=\"Season or episode filters\")\n    free_state: Optional[List[str]] = Field(None, description=\"Promotion state filters\")\n    video_code: Optional[List[str]] = Field(None, description=\"Video codec filters\")\n    edition: Optional[List[str]] = Field(None, description=\"Edition filters\")\n    resolution: Optional[List[str]] = Field(None, description=\"Resolution filters\")\n    release_group: Optional[List[str]] = Field(None, description=\"Release group filters\")\n    title_pattern: Optional[str] = Field(None, description=\"Regular expression pattern to filter torrent titles (e.g., '4K|2160p|UHD', '1080p.*BluRay')\")\n    show_filter_options: Optional[bool] = Field(False, description=\"Whether to return only optional filter options for re-checking available conditions\")\n\nclass GetSearchResultsTool(MoviePilotTool):\n    name: str = \"get_search_results\"\n    description: str = \"Get cached torrent search results from search_torrents with optional filters. Returns at most the first 50 matches.\"\n    args_schema: Type[BaseModel] = GetSearchResultsInput\n\n    def get_tool_message(self, **kwargs) -> Optional[str]:\n        return \"正在获取搜索结果\"\n\n    async def run(self, site: Optional[List[str]] = None, season: Optional[List[str]] = None,\n                  free_state: Optional[List[str]] = None, video_code: Optional[List[str]] = None,\n                  edition: Optional[List[str]] = None, resolution: Optional[List[str]] = None,\n                  release_group: Optional[List[str]] = None, title_pattern: Optional[str] = None,\n                  show_filter_options: bool = False,\n                  **kwargs) -> str:\n        logger.info(\n            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}\")\n\n        try:\n            items = await SearchChain().async_last_search_results() or []\n            if not items:\n                return \"没有可用的搜索结果，请先使用 search_torrents 搜索\"\n\n            if show_filter_options:\n                payload = {\n                    \"total_count\": len(items),\n                    \"filter_options\": build_filter_options(items),\n                }\n                return json.dumps(payload, ensure_ascii=False, indent=2)\n\n            regex_pattern = None\n            if title_pattern:\n                try:\n                    regex_pattern = re.compile(title_pattern, re.IGNORECASE)\n                except re.error as e:\n                    logger.warning(f\"正则表达式编译失败: {title_pattern}, 错误: {e}\")\n                    return f\"正则表达式格式错误: {str(e)}\"\n\n            filtered_items = filter_contexts(\n                items=items,\n                site=site,\n                season=season,\n                free_state=free_state,\n                video_code=video_code,\n                edition=edition,\n                resolution=resolution,\n                release_group=release_group,\n            )\n            if regex_pattern:\n                filtered_items = [\n                    item for item in filtered_items\n                    if item.torrent_info and item.torrent_info.title\n                    and regex_pattern.search(item.torrent_info.title)\n                ]\n            if not filtered_items:\n                return \"没有符合筛选条件的搜索结果，请调整筛选条件\"\n\n            total_count = len(filtered_items)\n            filtered_ids = {id(item) for item in filtered_items}\n            matched_indices = [index for index, item in enumerate(items, start=1) if id(item) in filtered_ids]\n            limited_items = filtered_items[:TORRENT_RESULT_LIMIT]\n            limited_indices = matched_indices[:TORRENT_RESULT_LIMIT]\n            results = [\n                simplify_search_result(item, index)\n                for item, index in zip(limited_items, limited_indices)\n            ]\n            payload = {\n                \"total_count\": total_count,\n                \"results\": results,\n            }\n            if total_count > TORRENT_RESULT_LIMIT:\n                payload[\"message\"] = f\"搜索结果共找到 {total_count} 条，仅显示前 {TORRENT_RESULT_LIMIT} 条结果。\"\n            return json.dumps(payload, ensure_ascii=False, indent=2)\n        except Exception as e:\n            error_message = f\"获取搜索结果失败: {str(e)}\"\n            logger.error(f\"获取搜索结果失败: {e}\", exc_info=True)\n            return error_message\n"
  },
  {
    "path": "app/agent/tools/impl/list_directory.py",
    "content": "\"\"\"查询文件系统目录内容工具\"\"\"\n\nimport json\nfrom datetime import datetime\nfrom pathlib import Path\nfrom typing import Optional, Type\n\nfrom pydantic import BaseModel, Field\n\nfrom app.agent.tools.base import MoviePilotTool\nfrom app.chain.storage import StorageChain\nfrom app.log import logger\nfrom app.schemas.file import FileItem\nfrom app.utils.string import StringUtils\n\n\nclass ListDirectoryInput(BaseModel):\n    \"\"\"查询文件系统目录内容工具的输入参数模型\"\"\"\n    explanation: str = Field(..., description=\"Clear explanation of why this tool is being used in the current context\")\n    path: str = Field(..., description=\"Directory path to list contents (e.g., '/home/user/downloads' or 'C:/Downloads')\")\n    storage: Optional[str] = Field(\"local\", description=\"Storage type (default: 'local' for local file system, can be 'smb', 'alist', etc.)\")\n    sort_by: Optional[str] = Field(\"name\", description=\"Sort order: 'name' for alphabetical sorting, 'time' for modification time sorting (default: 'name')\")\n\n\nclass ListDirectoryTool(MoviePilotTool):\n    name: str = \"list_directory\"\n    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.\"\n    args_schema: Type[BaseModel] = ListDirectoryInput\n\n    def get_tool_message(self, **kwargs) -> Optional[str]:\n        \"\"\"根据目录参数生成友好的提示消息\"\"\"\n        path = kwargs.get(\"path\", \"\")\n        storage = kwargs.get(\"storage\", \"local\")\n        \n        message = f\"正在查询目录: {path}\"\n        if storage != \"local\":\n            message += f\" [存储: {storage}]\"\n        \n        return message\n\n    async def run(self, path: str, storage: Optional[str] = \"local\",\n                  sort_by: Optional[str] = \"name\", **kwargs) -> str:\n        logger.info(f\"执行工具: {self.name}, 参数: path={path}, storage={storage}, sort_by={sort_by}\")\n\n        try:\n            # 规范化路径\n            if not path:\n                return \"错误：路径不能为空\"\n            \n            # 确保路径格式正确\n            if storage == \"local\":\n                # 本地路径处理\n                if not path.startswith(\"/\") and not (len(path) > 1 and path[1] == \":\"):\n                    # 相对路径，尝试转换为绝对路径\n                    path = str(Path(path).resolve())\n            else:\n                # 远程存储路径，确保以/开头\n                if not path.startswith(\"/\"):\n                    path = \"/\" + path\n            \n            # 创建FileItem\n            fileitem = FileItem(\n                storage=storage or \"local\",\n                path=path,\n                type=\"dir\"\n            )\n            \n            # 查询目录内容\n            storage_chain = StorageChain()\n            file_list = storage_chain.list_files(fileitem, recursion=False)\n            \n            if file_list is None:\n                return f\"无法访问目录：{path}，请检查路径是否正确或存储是否可用\"\n            \n            if not file_list:\n                return f\"目录 {path} 为空\"\n            \n            # 排序\n            if sort_by == \"time\":\n                file_list.sort(key=lambda x: x.modify_time or 0, reverse=True)\n            else:\n                # 默认按名称排序（目录优先，然后按名称）\n                file_list.sort(key=lambda x: (\n                    0 if x.type == \"dir\" else 1,\n                    StringUtils.natural_sort_key(x.name or \"\")\n                ))\n            \n            # 限制返回数量\n            total_count = len(file_list)\n            limited_list = file_list[:20]\n            \n            # 转换为字典格式\n            simplified_items = []\n            for item in limited_list:\n                # 格式化文件大小\n                size_str = None\n                if item.size:\n                    size_str = StringUtils.str_filesize(item.size)\n                \n                # 格式化修改时间\n                modify_time_str = None\n                if item.modify_time:\n                    try:\n                        modify_time_str = datetime.fromtimestamp(item.modify_time).strftime(\"%Y-%m-%d %H:%M:%S\")\n                    except (ValueError, OSError):\n                        modify_time_str = str(item.modify_time)\n                \n                simplified = {\n                    \"name\": item.name,\n                    \"type\": item.type,\n                    \"path\": item.path,\n                    \"size\": size_str,\n                    \"modify_time\": modify_time_str\n                }\n                # 如果是文件，添加扩展名\n                if item.type == \"file\" and item.extension:\n                    simplified[\"extension\"] = item.extension\n                simplified_items.append(simplified)\n            \n            result_json = json.dumps(simplified_items, ensure_ascii=False, indent=2)\n            \n            # 如果结果被裁剪，添加提示信息\n            if total_count > 20:\n                return f\"注意：目录中共有 {total_count} 个项目，为节省上下文空间，仅显示前 20 个项目。\\n\\n{result_json}\"\n            else:\n                return result_json\n        except Exception as e:\n            logger.error(f\"查询目录内容失败: {e}\", exc_info=True)\n            return f\"查询目录内容时发生错误: {str(e)}\"\n\n"
  },
  {
    "path": "app/agent/tools/impl/query_directory_settings.py",
    "content": "\"\"\"查询系统目录设置工具\"\"\"\n\nimport json\nfrom typing import Optional, Type\n\nfrom pydantic import BaseModel, Field\n\nfrom app.agent.tools.base import MoviePilotTool\nfrom app.helper.directory import DirectoryHelper\nfrom app.log import logger\n\n\nclass QueryDirectorySettingsInput(BaseModel):\n    \"\"\"查询系统目录设置工具的输入参数模型\"\"\"\n    explanation: str = Field(..., description=\"Clear explanation of why this tool is being used in the current context\")\n    directory_type: Optional[str] = Field(\"all\",\n                                          description=\"Filter directories by type: 'download' for download directories, 'library' for media library directories, 'all' for all directories\")\n    storage_type: Optional[str] = Field(\"all\",\n                                        description=\"Filter directories by storage type: 'local' for local storage, 'remote' for remote storage, 'all' for all storage types\")\n    name: Optional[str] = Field(None,\n                               description=\"Filter directories by name (partial match, optional)\")\n\n\nclass QueryDirectorySettingsTool(MoviePilotTool):\n    name: str = \"query_directory_settings\"\n    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.\"\n    args_schema: Type[BaseModel] = QueryDirectorySettingsInput\n\n    def get_tool_message(self, **kwargs) -> Optional[str]:\n        \"\"\"根据查询参数生成友好的提示消息\"\"\"\n        directory_type = kwargs.get(\"directory_type\", \"all\")\n        storage_type = kwargs.get(\"storage_type\", \"all\")\n        name = kwargs.get(\"name\")\n        \n        parts = [\"正在查询目录配置\"]\n        \n        if directory_type != \"all\":\n            type_map = {\"download\": \"下载目录\", \"library\": \"媒体库目录\"}\n            parts.append(f\"类型: {type_map.get(directory_type, directory_type)}\")\n        \n        if storage_type != \"all\":\n            storage_map = {\"local\": \"本地存储\", \"remote\": \"远程存储\"}\n            parts.append(f\"存储: {storage_map.get(storage_type, storage_type)}\")\n        \n        if name:\n            parts.append(f\"名称: {name}\")\n        \n        return \" | \".join(parts) if len(parts) > 1 else parts[0]\n\n    async def run(self, directory_type: Optional[str] = \"all\",\n                  storage_type: Optional[str] = \"all\",\n                  name: Optional[str] = None, **kwargs) -> str:\n        logger.info(f\"执行工具: {self.name}, 参数: directory_type={directory_type}, storage_type={storage_type}, name={name}\")\n\n        try:\n            directory_helper = DirectoryHelper()\n            \n            # 根据目录类型获取目录列表\n            if directory_type == \"download\":\n                dirs = directory_helper.get_download_dirs()\n            elif directory_type == \"library\":\n                dirs = directory_helper.get_library_dirs()\n            else:\n                dirs = directory_helper.get_dirs()\n            \n            # 按存储类型过滤\n            filtered_dirs = []\n            for d in dirs:\n                # 按存储类型过滤\n                if storage_type == \"local\":\n                    # 对于下载目录，检查 storage；对于媒体库目录，检查 library_storage\n                    if directory_type == \"download\" and d.storage != \"local\":\n                        continue\n                    elif directory_type == \"library\" and d.library_storage != \"local\":\n                        continue\n                    elif directory_type == \"all\":\n                        # 检查是否有本地存储配置\n                        if d.download_path and d.storage != \"local\":\n                            continue\n                        if d.library_path and d.library_storage != \"local\":\n                            continue\n                elif storage_type == \"remote\":\n                    # 对于下载目录，检查 storage；对于媒体库目录，检查 library_storage\n                    if directory_type == \"download\" and d.storage == \"local\":\n                        continue\n                    elif directory_type == \"library\" and d.library_storage == \"local\":\n                        continue\n                    elif directory_type == \"all\":\n                        # 检查是否有远程存储配置\n                        if d.download_path and d.storage == \"local\":\n                            continue\n                        if d.library_path and d.library_storage == \"local\":\n                            continue\n                \n                # 按名称过滤（部分匹配）\n                if name and d.name and name.lower() not in d.name.lower():\n                    continue\n                \n                filtered_dirs.append(d)\n            \n            if filtered_dirs:\n                # 转换为字典格式，只保留关键信息\n                simplified_dirs = []\n                for d in filtered_dirs:\n                    simplified = {\n                        \"name\": d.name,\n                        \"priority\": d.priority,\n                        \"storage\": d.storage,\n                        \"download_path\": d.download_path,\n                        \"library_path\": d.library_path,\n                        \"library_storage\": d.library_storage,\n                        \"media_type\": d.media_type,\n                        \"media_category\": d.media_category,\n                        \"monitor_type\": d.monitor_type,\n                        \"monitor_mode\": d.monitor_mode,\n                        \"transfer_type\": d.transfer_type,\n                        \"overwrite_mode\": d.overwrite_mode,\n                        \"renaming\": d.renaming,\n                        \"scraping\": d.scraping,\n                        \"notify\": d.notify,\n                        \"download_type_folder\": d.download_type_folder,\n                        \"download_category_folder\": d.download_category_folder,\n                        \"library_type_folder\": d.library_type_folder,\n                        \"library_category_folder\": d.library_category_folder\n                    }\n                    simplified_dirs.append(simplified)\n                \n                result_json = json.dumps(simplified_dirs, ensure_ascii=False, indent=2)\n                return result_json\n            return \"未找到相关目录配置\"\n        except Exception as e:\n            logger.error(f\"查询系统目录设置失败: {e}\", exc_info=True)\n            return f\"查询系统目录设置时发生错误: {str(e)}\"\n\n"
  },
  {
    "path": "app/agent/tools/impl/query_download_tasks.py",
    "content": "\"\"\"查询下载工具\"\"\"\n\nimport json\nfrom typing import Optional, Type, List, Union\n\nfrom pydantic import BaseModel, Field\n\nfrom app.agent.tools.base import MoviePilotTool\nfrom app.chain.download import DownloadChain\nfrom app.db.downloadhistory_oper import DownloadHistoryOper\nfrom app.log import logger\nfrom app.schemas import TransferTorrent, DownloadingTorrent\nfrom app.schemas.types import TorrentStatus, media_type_to_agent\n\n\nclass QueryDownloadTasksInput(BaseModel):\n    \"\"\"查询下载工具的输入参数模型\"\"\"\n    explanation: str = Field(..., description=\"Clear explanation of why this tool is being used in the current context\")\n    downloader: Optional[str] = Field(None,\n                                      description=\"Name of specific downloader to query (optional, if not provided queries all configured downloaders)\")\n    status: Optional[str] = Field(\"all\",\n                                  description=\"Filter downloads by status: 'downloading' for active downloads, 'completed' for finished downloads, 'paused' for paused downloads, 'all' for all downloads\")\n    hash: Optional[str] = Field(None, description=\"Query specific download task by hash (optional, if provided will search for this specific task regardless of status)\")\n    title: Optional[str] = Field(None, description=\"Query download tasks by title/name (optional, supports partial match, searches all tasks if provided)\")\n\n\nclass QueryDownloadTasksTool(MoviePilotTool):\n    name: str = \"query_download_tasks\"\n    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.\"\n    args_schema: Type[BaseModel] = QueryDownloadTasksInput\n\n    @staticmethod\n    def _get_all_torrents(download_chain: DownloadChain, downloader: Optional[str] = None) -> List[Union[TransferTorrent, DownloadingTorrent]]:\n        \"\"\"\n        查询所有状态的任务（包括下载中和已完成的任务）\n        \"\"\"\n        all_torrents = []\n        # 查询正在下载的任务\n        downloading_torrents = download_chain.list_torrents(\n            downloader=downloader, \n            status=TorrentStatus.DOWNLOADING\n        ) or []\n        all_torrents.extend(downloading_torrents)\n        \n        # 查询已完成的任务（可转移状态）\n        transfer_torrents = download_chain.list_torrents(\n            downloader=downloader,\n            status=TorrentStatus.TRANSFER\n        ) or []\n        all_torrents.extend(transfer_torrents)\n        \n        return all_torrents\n\n    @staticmethod\n    def _format_progress(progress: Optional[float]) -> Optional[str]:\n        \"\"\"\n        将下载进度格式化为保留一位小数的百分比字符串\n        \"\"\"\n        try:\n            if progress is None:\n                return None\n            return f\"{float(progress):.1f}%\"\n        except (TypeError, ValueError):\n            return None\n\n    def get_tool_message(self, **kwargs) -> Optional[str]:\n        \"\"\"根据查询参数生成友好的提示消息\"\"\"\n        downloader = kwargs.get(\"downloader\")\n        status = kwargs.get(\"status\", \"all\")\n        hash_value = kwargs.get(\"hash\")\n        title = kwargs.get(\"title\")\n        \n        parts = [\"正在查询下载任务\"]\n        \n        if downloader:\n            parts.append(f\"下载器: {downloader}\")\n        \n        if status != \"all\":\n            status_map = {\"downloading\": \"下载中\", \"completed\": \"已完成\", \"paused\": \"已暂停\"}\n            parts.append(f\"状态: {status_map.get(status, status)}\")\n        \n        if hash_value:\n            parts.append(f\"Hash: {hash_value[:8]}...\")\n        elif title:\n            parts.append(f\"标题: {title}\")\n        \n        return \" | \".join(parts) if len(parts) > 1 else parts[0]\n\n    async def run(self, downloader: Optional[str] = None,\n                  status: Optional[str] = \"all\",\n                  hash: Optional[str] = None,\n                  title: Optional[str] = None, **kwargs) -> str:\n        logger.info(f\"执行工具: {self.name}, 参数: downloader={downloader}, status={status}, hash={hash}, title={title}\")\n        try:\n            download_chain = DownloadChain()\n            \n            # 如果提供了hash，直接查询该hash的任务（不限制状态）\n            if hash:\n                torrents = download_chain.list_torrents(downloader=downloader, hashs=[hash]) or []\n                if not torrents:\n                    return f\"未找到hash为 {hash} 的下载任务（该任务可能已完成、已删除或不存在）\"\n                # 转换为DownloadingTorrent格式\n                downloads = []\n                for torrent in torrents:\n                    # 获取下载历史信息\n                    history = DownloadHistoryOper().get_by_hash(torrent.hash)\n                    if history:\n                        torrent.media = {\n                            \"tmdbid\": history.tmdbid,\n                            \"type\": history.type,\n                            \"title\": history.title,\n                            \"season\": history.seasons,\n                            \"episode\": history.episodes,\n                            \"image\": history.image,\n                        }\n                        torrent.userid = history.userid\n                        torrent.username = history.username\n                    downloads.append(torrent)\n                filtered_downloads = downloads\n            elif title:\n                # 如果提供了title，查询所有任务并搜索匹配的标题\n                # 查询所有状态的任务\n                all_torrents = self._get_all_torrents(download_chain, downloader)\n                filtered_downloads = []\n                title_lower = title.lower()\n                for torrent in all_torrents:\n                    # 获取下载历史信息\n                    history = DownloadHistoryOper().get_by_hash(torrent.hash)\n                    \n                    # 检查标题或名称是否匹配（包括下载历史中的标题）\n                    matched = False\n                    # 检查torrent的title和name字段\n                    if (title_lower in (torrent.title or \"\").lower()) or \\\n                       (title_lower in (torrent.name or \"\").lower()):\n                        matched = True\n                    # 检查下载历史中的标题\n                    if history and history.title:\n                        if title_lower in history.title.lower():\n                            matched = True\n                    \n                    if matched:\n                        if history:\n                            torrent.media = {\n                                \"tmdbid\": history.tmdbid,\n                                \"type\": history.type,\n                                \"title\": history.title,\n                                \"season\": history.seasons,\n                                \"episode\": history.episodes,\n                                \"image\": history.image,\n                            }\n                            torrent.userid = history.userid\n                            torrent.username = history.username\n                        filtered_downloads.append(torrent)\n                if not filtered_downloads:\n                    return f\"未找到标题包含 '{title}' 的下载任务\"\n            else:\n                # 根据status决定查询方式\n                if status == \"downloading\":\n                    # 如果status为下载中，使用downloading方法\n                    downloads = download_chain.downloading(name=downloader) or []\n                    filtered_downloads = []\n                    for dl in downloads:\n                        if downloader and dl.downloader != downloader:\n                            continue\n                        filtered_downloads.append(dl)\n                else:\n                    # 其他状态（completed、paused、all），使用list_torrents查询所有任务\n                    # 查询所有状态的任务\n                    all_torrents = self._get_all_torrents(download_chain, downloader)\n                    filtered_downloads = []\n                    for torrent in all_torrents:\n                        if downloader and torrent.downloader != downloader:\n                            continue\n                        # 根据status过滤\n                        if status == \"completed\":\n                            # 已完成的任务（state为seeding或completed）\n                            if torrent.state not in [\"seeding\", \"completed\"]:\n                                continue\n                        elif status == \"paused\":\n                            # 已暂停的任务\n                            if torrent.state != \"paused\":\n                                continue\n                        # status == \"all\" 时不过滤\n                        # 获取下载历史信息\n                        history = DownloadHistoryOper().get_by_hash(torrent.hash)\n                        if history:\n                            torrent.media = {\n                                \"tmdbid\": history.tmdbid,\n                                \"type\": history.type,\n                                \"title\": history.title,\n                                \"season\": history.seasons,\n                                \"episode\": history.episodes,\n                                \"image\": history.image,\n                            }\n                            torrent.userid = history.userid\n                            torrent.username = history.username\n                        filtered_downloads.append(torrent)\n            if filtered_downloads:\n                # 限制最多20条结果\n                total_count = len(filtered_downloads)\n                limited_downloads = filtered_downloads[:20]\n                # 精简字段，只保留关键信息\n                simplified_downloads = []\n                for d in limited_downloads:\n                    simplified = {\n                        \"downloader\": d.downloader,\n                        \"hash\": d.hash,\n                        \"title\": d.title,\n                        \"name\": d.name,\n                        \"year\": d.year,\n                        \"season_episode\": d.season_episode,\n                        \"size\": d.size,\n                        \"progress\": self._format_progress(d.progress),\n                        \"state\": d.state,\n                        \"upspeed\": d.upspeed,\n                        \"dlspeed\": d.dlspeed,\n                        \"left_time\": d.left_time\n                    }\n                    # 精简 media 字段\n                    if d.media:\n                        simplified[\"media\"] = {\n                            \"tmdbid\": d.media.get(\"tmdbid\"),\n                            \"type\": media_type_to_agent(d.media.get(\"type\")),\n                            \"title\": d.media.get(\"title\"),\n                            \"season\": d.media.get(\"season\"),\n                            \"episode\": d.media.get(\"episode\")\n                        }\n                    simplified_downloads.append(simplified)\n                result_json = json.dumps(simplified_downloads, ensure_ascii=False, indent=2)\n                # 如果结果被裁剪，添加提示信息\n                if total_count > 20:\n                    return f\"注意：查询结果共找到 {total_count} 条，为节省上下文空间，仅显示前 20 条结果。\\n\\n{result_json}\"\n                \n                # 如果查询的是特定hash或title，添加明确的状态信息\n                if hash:\n                    return f\"找到hash为 {hash} 的下载任务：\\n\\n{result_json}\"\n                elif title:\n                    return f\"找到 {total_count} 个标题包含 '{title}' 的下载任务：\\n\\n{result_json}\"\n                \n                return result_json\n            return \"未找到相关下载任务\"\n        except Exception as e:\n            logger.error(f\"查询下载失败: {e}\", exc_info=True)\n            return f\"查询下载时发生错误: {str(e)}\"\n"
  },
  {
    "path": "app/agent/tools/impl/query_downloaders.py",
    "content": "\"\"\"查询下载器工具\"\"\"\n\nimport json\nfrom typing import Optional, Type\n\nfrom pydantic import BaseModel, Field\n\nfrom app.agent.tools.base import MoviePilotTool\nfrom app.db.systemconfig_oper import SystemConfigOper\nfrom app.log import logger\nfrom app.schemas.types import SystemConfigKey\n\n\nclass QueryDownloadersInput(BaseModel):\n    \"\"\"查询下载器工具的输入参数模型\"\"\"\n    explanation: str = Field(..., description=\"Clear explanation of why this tool is being used in the current context\")\n\n\nclass QueryDownloadersTool(MoviePilotTool):\n    name: str = \"query_downloaders\"\n    description: str = \"Query downloader configuration and list all available downloaders. Shows downloader status, connection details, and configuration settings.\"\n    args_schema: Type[BaseModel] = QueryDownloadersInput\n\n    def get_tool_message(self, **kwargs) -> Optional[str]:\n        \"\"\"生成友好的提示消息\"\"\"\n        return \"正在查询下载器配置\"\n\n    async def run(self, **kwargs) -> str:\n        logger.info(f\"执行工具: {self.name}\")\n        try:\n            system_config_oper = SystemConfigOper()\n            downloaders_config = system_config_oper.get(SystemConfigKey.Downloaders)\n            if downloaders_config:\n                return json.dumps(downloaders_config, ensure_ascii=False, indent=2)\n            return \"未配置下载器。\"\n        except Exception as e:\n            logger.error(f\"查询下载器失败: {e}\")\n            return f\"查询下载器时发生错误: {str(e)}\"\n"
  },
  {
    "path": "app/agent/tools/impl/query_episode_schedule.py",
    "content": "\"\"\"查询剧集上映时间工具\"\"\"\n\nimport json\nfrom typing import Optional, Type\n\nfrom pydantic import BaseModel, Field\n\nfrom app.agent.tools.base import MoviePilotTool\nfrom app.chain.tmdb import TmdbChain\nfrom app.log import logger\n\n\nclass QueryEpisodeScheduleInput(BaseModel):\n    \"\"\"查询剧集上映时间工具的输入参数模型\"\"\"\n    explanation: str = Field(..., description=\"Clear explanation of why this tool is being used in the current context\")\n    tmdb_id: int = Field(..., description=\"TMDB ID of the TV series (can be obtained from search_media tool)\")\n    season: int = Field(..., description=\"Season number to query\")\n    episode_group: Optional[str] = Field(None, description=\"Episode group ID (optional)\")\n\n\nclass QueryEpisodeScheduleTool(MoviePilotTool):\n    name: str = \"query_episode_schedule\"\n    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.\"\n    args_schema: Type[BaseModel] = QueryEpisodeScheduleInput\n\n    def get_tool_message(self, **kwargs) -> Optional[str]:\n        \"\"\"根据查询参数生成友好的提示消息\"\"\"\n        tmdb_id = kwargs.get(\"tmdb_id\")\n        season = kwargs.get(\"season\")\n        episode_group = kwargs.get(\"episode_group\")\n\n        message = f\"正在查询剧集上映时间: TMDB ID {tmdb_id} 第{season}季\"\n        if episode_group:\n            message += f\" (剧集组: {episode_group})\"\n\n        return message\n\n    async def run(self, tmdb_id: int, season: int, episode_group: Optional[str] = None, **kwargs) -> str:\n        logger.info(f\"执行工具: {self.name}, 参数: tmdb_id={tmdb_id}, season={season}, episode_group={episode_group}\")\n\n        try:\n            # 获取集列表\n            tmdb_chain = TmdbChain()\n            episodes = await tmdb_chain.async_tmdb_episodes(\n                tmdbid=tmdb_id,\n                season=season,\n                episode_group=episode_group\n            )\n\n            if not episodes:\n                return json.dumps({\n                    \"success\": False,\n                    \"message\": f\"未找到 TMDB ID {tmdb_id} 第{season}季的集信息\"\n                }, ensure_ascii=False)\n\n            # 过滤掉没有上映日期的集，并构建每集的详细信息\n            episode_list = []\n            for episode in episodes:\n                air_date = episode.air_date\n                \n                # 过滤掉没有上映日期的数据\n                if not air_date:\n                    continue\n                \n                episode_info = {\n                    \"episode_number\": episode.episode_number,\n                    \"name\": episode.name,\n                    \"air_date\": air_date,\n                    \"runtime\": episode.runtime,\n                    \"vote_average\": episode.vote_average,\n                    \"still_path\": episode.still_path,\n                    \"episode_type\": episode.episode_type,\n                    \"season_number\": episode.season_number\n                }\n                episode_list.append(episode_info)\n\n            if not episode_list:\n                return json.dumps({\n                    \"success\": False,\n                    \"message\": f\"未找到 TMDB ID {tmdb_id} 第{season}季的播出时间信息（所有集都没有播出日期）\"\n                }, ensure_ascii=False)\n\n            # 按播出日期排序\n            episode_list.sort(key=lambda x: (x[\"air_date\"] or \"\", x[\"episode_number\"] or 0))\n\n            result = {\n                \"season\": season,\n                \"total_episodes\": len(episodes),\n                \"episodes_with_air_date\": len(episode_list),\n                \"episodes\": episode_list\n            }\n\n            return json.dumps(result, ensure_ascii=False, indent=2)\n\n        except Exception as e:\n            error_message = f\"查询剧集上映时间失败: {str(e)}\"\n            logger.error(f\"查询剧集上映时间失败: {e}\", exc_info=True)\n            return json.dumps({\n                \"success\": False,\n                \"message\": error_message,\n                \"tmdb_id\": tmdb_id,\n                \"season\": season\n            }, ensure_ascii=False)\n"
  },
  {
    "path": "app/agent/tools/impl/query_library_exists.py",
    "content": "\"\"\"查询媒体库工具\"\"\"\n\nimport json\nfrom typing import Optional, Type\n\nfrom pydantic import BaseModel, Field\n\nfrom app.agent.tools.base import MoviePilotTool\nfrom app.chain.mediaserver import MediaServerChain\nfrom app.log import logger\nfrom app.schemas.types import MediaType, media_type_to_agent\n\n\nclass QueryLibraryExistsInput(BaseModel):\n    \"\"\"查询媒体库工具的输入参数模型\"\"\"\n    explanation: str = Field(..., description=\"Clear explanation of why this tool is being used in the current context\")\n    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.\")\n    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.\")\n    media_type: Optional[str] = Field(None, description=\"Allowed values: movie, tv\")\n\n\nclass QueryLibraryExistsTool(MoviePilotTool):\n    name: str = \"query_library_exists\"\n    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.\"\n    args_schema: Type[BaseModel] = QueryLibraryExistsInput\n\n    def get_tool_message(self, **kwargs) -> Optional[str]:\n        \"\"\"根据查询参数生成友好的提示消息\"\"\"\n        tmdb_id = kwargs.get(\"tmdb_id\")\n        douban_id = kwargs.get(\"douban_id\")\n        media_type = kwargs.get(\"media_type\")\n\n        if tmdb_id:\n            message = f\"正在查询媒体库: TMDB={tmdb_id}\"\n        elif douban_id:\n            message = f\"正在查询媒体库: 豆瓣={douban_id}\"\n        else:\n            message = \"正在查询媒体库\"\n        if media_type:\n            message += f\" [{media_type}]\"\n        return message\n\n    async def run(self, tmdb_id: Optional[int] = None, douban_id: Optional[str] = None,\n                  media_type: Optional[str] = None, **kwargs) -> str:\n        logger.info(f\"执行工具: {self.name}, 参数: tmdb_id={tmdb_id}, douban_id={douban_id}, media_type={media_type}\")\n        try:\n            if not tmdb_id and not douban_id:\n                return \"参数错误：tmdb_id 和 douban_id 至少需要提供一个，请先使用 search_media 工具获取媒体 ID。\"\n\n            media_type_enum = None\n            if media_type:\n                media_type_enum = MediaType.from_agent(media_type)\n                if not media_type_enum:\n                    return f\"错误：无效的媒体类型 '{media_type}'，支持的类型：'movie', 'tv'\"\n\n            media_chain = MediaServerChain()\n            mediainfo = media_chain.recognize_media(\n                tmdbid=tmdb_id,\n                doubanid=douban_id,\n                mtype=media_type_enum,\n            )\n            if not mediainfo:\n                media_id = f\"TMDB={tmdb_id}\" if tmdb_id else f\"豆瓣={douban_id}\"\n                return f\"未识别到媒体信息: {media_id}\"\n\n            # 2. 调用媒体服务器接口实时查询存在信息\n            existsinfo = media_chain.media_exists(mediainfo=mediainfo)\n\n            if not existsinfo:\n                return \"媒体库中未找到相关媒体\"\n\n            # 3. 如果找到了，获取详细信息并组装结果\n            result_items = []\n            if existsinfo.itemid and existsinfo.server:\n                iteminfo = media_chain.iteminfo(server=existsinfo.server, item_id=existsinfo.itemid)\n                if iteminfo:\n                    # 使用 model_dump() 转换为字典格式\n                    item_dict = iteminfo.model_dump(exclude_none=True)\n\n                    # 对于电视剧，补充已存在的季集详情及进度统计\n                    if existsinfo.type == MediaType.TV:\n                        # 注入已存在集信息 (Dict[int, list])\n                        item_dict[\"seasoninfo\"] = existsinfo.seasons\n\n                        # 统计库中已存在的季集总数\n                        if existsinfo.seasons:\n                            item_dict[\"existing_episodes_count\"] = sum(len(e) for e in existsinfo.seasons.values())\n                            item_dict[\"seasons_existing_count\"] = {str(s): len(e) for s, e in existsinfo.seasons.items()}\n\n                            # 如果识别到了元数据，补充总计对比和进度概览\n                            if mediainfo.seasons:\n                                item_dict[\"seasons_total_count\"] = {str(s): len(e) for s, e in mediainfo.seasons.items()}\n                                # 进度概览，例如 \"Season 1\": \"3/12\"\n                                item_dict[\"seasons_progress\"] = {\n                                    f\"第{s}季\": f\"{len(existsinfo.seasons.get(s, []))}/{len(mediainfo.seasons.get(s, []))} 集\"\n                                    for s in mediainfo.seasons.keys() if (s in existsinfo.seasons or s > 0)\n                                }\n\n                    result_items.append(item_dict)\n\n            if result_items:\n                return json.dumps(result_items, ensure_ascii=False)\n\n            # 如果找到了但没有获取到 iteminfo，返回基本信息\n            result_dict = {\n                \"title\": mediainfo.title,\n                \"year\": mediainfo.year,\n                \"type\": media_type_to_agent(existsinfo.type),\n                \"server\": existsinfo.server,\n                \"server_type\": existsinfo.server_type,\n                \"itemid\": existsinfo.itemid,\n                \"seasons\": existsinfo.seasons if existsinfo.seasons else {}\n            }\n            if existsinfo.type == MediaType.TV and existsinfo.seasons:\n                result_dict[\"existing_episodes_count\"] = sum(len(e) for e in existsinfo.seasons.values())\n                result_dict[\"seasons_existing_count\"] = {str(s): len(e) for s, e in existsinfo.seasons.items()}\n                if mediainfo.seasons:\n                    result_dict[\"seasons_total_count\"] = {str(s): len(e) for s, e in mediainfo.seasons.items()}\n\n            return json.dumps([result_dict], ensure_ascii=False)\n        except Exception as e:\n            logger.error(f\"查询媒体库失败: {e}\", exc_info=True)\n            return f\"查询媒体库时发生错误: {str(e)}\"\n\n"
  },
  {
    "path": "app/agent/tools/impl/query_library_latest.py",
    "content": "\"\"\"查询媒体服务器最近入库影片工具\"\"\"\n\nimport json\nfrom typing import Optional, Type\n\nfrom pydantic import BaseModel, Field\n\nfrom app.agent.tools.base import MoviePilotTool\nfrom app.chain.mediaserver import MediaServerChain\nfrom app.helper.service import ServiceConfigHelper\nfrom app.log import logger\n\n\nclass QueryLibraryLatestInput(BaseModel):\n    \"\"\"查询媒体服务器最近入库影片工具的输入参数模型\"\"\"\n    explanation: str = Field(..., description=\"Clear explanation of why this tool is being used in the current context\")\n    server: Optional[str] = Field(None, description=\"Media server name (optional, if not specified queries all enabled media servers)\")\n    count: Optional[int] = Field(20, description=\"Number of items to return (default: 20)\")\n\n\nclass QueryLibraryLatestTool(MoviePilotTool):\n    name: str = \"query_library_latest\"\n    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.\"\n    args_schema: Type[BaseModel] = QueryLibraryLatestInput\n\n    def get_tool_message(self, **kwargs) -> Optional[str]:\n        \"\"\"根据查询参数生成友好的提示消息\"\"\"\n        server = kwargs.get(\"server\")\n        count = kwargs.get(\"count\", 20)\n        \n        parts = [\"正在查询媒体服务器最近入库影片\"]\n        \n        if server:\n            parts.append(f\"服务器: {server}\")\n        else:\n            parts.append(\"所有服务器\")\n        \n        parts.append(f\"数量: {count}条\")\n        \n        return \" | \".join(parts)\n\n    async def run(self, server: Optional[str] = None, count: Optional[int] = 20, **kwargs) -> str:\n        logger.info(f\"执行工具: {self.name}, 参数: server={server}, count={count}\")\n        try:\n            media_chain = MediaServerChain()\n            results = []\n            \n            # 如果没有指定服务器，获取所有启用的媒体服务器\n            if not server:\n                mediaservers = ServiceConfigHelper.get_mediaserver_configs()\n                enabled_servers = [ms.name for ms in mediaservers if ms.enabled]\n                \n                if not enabled_servers:\n                    return \"未找到启用的媒体服务器\"\n                \n                # 遍历所有启用的服务器\n                for server_name in enabled_servers:\n                    latest_items = media_chain.latest(server=server_name, count=count, username=self._username)\n                    if latest_items:\n                        for item in latest_items:\n                            item_dict = item.model_dump(exclude_none=True)\n                            item_dict[\"server\"] = server_name\n                            results.append(item_dict)\n            else:\n                # 查询指定服务器\n                latest_items = media_chain.latest(server=server, count=count, username=self._username)\n                if latest_items:\n                    for item in latest_items:\n                        item_dict = item.model_dump(exclude_none=True)\n                        item_dict[\"server\"] = server\n                        results.append(item_dict)\n            \n            if not results:\n                server_info = f\"服务器 {server}\" if server else \"所有服务器\"\n                return f\"未找到 {server_info} 的最近入库影片\"\n            \n            # 限制返回数量，避免结果过多\n            if len(results) > count:\n                results = results[:count]\n            \n            return json.dumps(results, ensure_ascii=False, indent=2)\n            \n        except Exception as e:\n            logger.error(f\"查询媒体服务器最近入库影片失败: {e}\", exc_info=True)\n            return f\"查询媒体服务器最近入库影片时发生错误: {str(e)}\"\n\n"
  },
  {
    "path": "app/agent/tools/impl/query_media_detail.py",
    "content": "\"\"\"查询媒体详情工具\"\"\"\n\nimport json\nfrom typing import Optional, Type\n\nfrom pydantic import BaseModel, Field\n\nfrom app.agent.tools.base import MoviePilotTool\nfrom app.chain.media import MediaChain\nfrom app.log import logger\nfrom app.schemas.types import MediaType\n\n\nclass QueryMediaDetailInput(BaseModel):\n    \"\"\"查询媒体详情工具的输入参数模型\"\"\"\n    explanation: str = Field(..., description=\"Clear explanation of why this tool is being used in the current context\")\n    tmdb_id: Optional[int] = Field(None, description=\"TMDB ID of the media (movie or TV series, can be obtained from search_media tool)\")\n    douban_id: Optional[str] = Field(None, description=\"Douban ID of the media (alternative to tmdb_id)\")\n    media_type: str = Field(..., description=\"Allowed values: movie, tv\")\n\n\nclass QueryMediaDetailTool(MoviePilotTool):\n    name: str = \"query_media_detail\"\n    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.\"\n    args_schema: Type[BaseModel] = QueryMediaDetailInput\n\n    def get_tool_message(self, **kwargs) -> Optional[str]:\n        \"\"\"根据查询参数生成友好的提示消息\"\"\"\n        tmdb_id = kwargs.get(\"tmdb_id\")\n        douban_id = kwargs.get(\"douban_id\")\n        if tmdb_id:\n            return f\"正在查询媒体详情: TMDB ID {tmdb_id}\"\n        return f\"正在查询媒体详情: 豆瓣 ID {douban_id}\"\n\n    async def run(self, media_type: str, tmdb_id: Optional[int] = None, douban_id: Optional[str] = None, **kwargs) -> str:\n        logger.info(f\"执行工具: {self.name}, 参数: tmdb_id={tmdb_id}, douban_id={douban_id}, media_type={media_type}\")\n\n        if tmdb_id is None and douban_id is None:\n            return json.dumps({\n                \"success\": False,\n                \"message\": \"必须提供 tmdb_id 或 douban_id 之一\"\n            }, ensure_ascii=False)\n\n        try:\n            media_chain = MediaChain()\n\n            media_type_enum = MediaType.from_agent(media_type)\n            if not media_type_enum:\n                return json.dumps({\n                    \"success\": False,\n                    \"message\": f\"无效的媒体类型 '{media_type}'，支持的类型：'movie', 'tv'\"\n                }, ensure_ascii=False)\n\n            mediainfo = await media_chain.async_recognize_media(tmdbid=tmdb_id, doubanid=douban_id, mtype=media_type_enum)\n\n            if not mediainfo:\n                id_info = f\"TMDB ID {tmdb_id}\" if tmdb_id else f\"豆瓣 ID {douban_id}\"\n                return json.dumps({\n                    \"success\": False,\n                    \"message\": f\"未找到 {id_info} 的媒体信息\"\n                }, ensure_ascii=False)\n\n            # 精简 genres - 只保留名称\n            genres = [g.get(\"name\") for g in (mediainfo.genres or []) if g.get(\"name\")]\n\n            # 精简 directors - 只保留姓名和职位\n            directors = [\n                {\n                    \"name\": d.get(\"name\"),\n                    \"job\": d.get(\"job\")\n                }\n                for d in (mediainfo.directors or [])\n                if d.get(\"name\")\n            ]\n\n            # 精简 actors - 只保留姓名和角色\n            actors = [\n                {\n                    \"name\": a.get(\"name\"),\n                    \"character\": a.get(\"character\")\n                }\n                for a in (mediainfo.actors or [])\n                if a.get(\"name\")\n            ]\n\n            # 构建基础媒体详情信息\n            result = {\n                \"status\": mediainfo.status,\n                \"genres\": genres,\n                \"directors\": directors,\n                \"actors\": actors\n            }\n\n            # 如果是电视剧，添加电视剧特有信息\n            if mediainfo.type == MediaType.TV:\n                # 精简 season_info - 只保留基础摘要\n                season_info = [\n                    {\n                        \"season_number\": s.get(\"season_number\"),\n                        \"name\": s.get(\"name\"),\n                        \"episode_count\": s.get(\"episode_count\"),\n                        \"air_date\": s.get(\"air_date\")\n                    }\n                    for s in (mediainfo.season_info or [])\n                    if s.get(\"season_number\") is not None\n                ]\n\n                result.update({\n                    \"number_of_seasons\": mediainfo.number_of_seasons,\n                    \"number_of_episodes\": mediainfo.number_of_episodes,\n                    \"first_air_date\": mediainfo.first_air_date,\n                    \"last_air_date\": mediainfo.last_air_date,\n                    \"season_info\": season_info\n                })\n\n            return json.dumps(result, ensure_ascii=False, indent=2)\n\n        except Exception as e:\n            error_message = f\"查询媒体详情失败: {str(e)}\"\n            logger.error(f\"查询媒体详情失败: {e}\", exc_info=True)\n            return json.dumps({\n                \"success\": False,\n                \"message\": error_message,\n                \"tmdb_id\": tmdb_id,\n                \"douban_id\": douban_id\n            }, ensure_ascii=False)\n"
  },
  {
    "path": "app/agent/tools/impl/query_popular_subscribes.py",
    "content": "\"\"\"查询热门订阅工具\"\"\"\n\nimport json\nfrom typing import Optional, Type\n\nimport cn2an\nfrom pydantic import BaseModel, Field\n\nfrom app.agent.tools.base import MoviePilotTool\nfrom app.core.context import MediaInfo\nfrom app.helper.subscribe import SubscribeHelper\nfrom app.log import logger\nfrom app.schemas.types import MediaType, media_type_to_agent\n\n\nclass QueryPopularSubscribesInput(BaseModel):\n    \"\"\"查询热门订阅工具的输入参数模型\"\"\"\n    explanation: str = Field(..., description=\"Clear explanation of why this tool is being used in the current context\")\n    media_type: str = Field(..., description=\"Allowed values: movie, tv\")\n    page: Optional[int] = Field(1, description=\"Page number for pagination (default: 1)\")\n    count: Optional[int] = Field(30, description=\"Number of items per page (default: 30)\")\n    min_sub: Optional[int] = Field(None, description=\"Minimum number of subscribers filter (optional, e.g., 5)\")\n    genre_id: Optional[int] = Field(None, description=\"Filter by genre ID (optional)\")\n    min_rating: Optional[float] = Field(None, description=\"Minimum rating filter (optional, e.g., 7.5)\")\n    max_rating: Optional[float] = Field(None, description=\"Maximum rating filter (optional, e.g., 10.0)\")\n    sort_type: Optional[str] = Field(None, description=\"Sort type (optional, e.g., 'count', 'rating')\")\n\n\nclass QueryPopularSubscribesTool(MoviePilotTool):\n    name: str = \"query_popular_subscribes\"\n    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.\"\n    args_schema: Type[BaseModel] = QueryPopularSubscribesInput\n\n    def get_tool_message(self, **kwargs) -> Optional[str]:\n        \"\"\"根据查询参数生成友好的提示消息\"\"\"\n        media_type = kwargs.get(\"media_type\", \"\")\n        page = kwargs.get(\"page\", 1)\n        min_sub = kwargs.get(\"min_sub\")\n        min_rating = kwargs.get(\"min_rating\")\n        max_rating = kwargs.get(\"max_rating\")\n        \n        parts = [f\"正在查询热门订阅 [{media_type}]\"]\n        \n        if min_sub:\n            parts.append(f\"最少订阅: {min_sub}\")\n        if min_rating:\n            parts.append(f\"最低评分: {min_rating}\")\n        if max_rating:\n            parts.append(f\"最高评分: {max_rating}\")\n        if page > 1:\n            parts.append(f\"第{page}页\")\n        \n        return \" | \".join(parts) if len(parts) > 1 else parts[0]\n\n    async def run(self, media_type: str,\n                  page: Optional[int] = 1,\n                  count: Optional[int] = 30,\n                  min_sub: Optional[int] = None,\n                  genre_id: Optional[int] = None,\n                  min_rating: Optional[float] = None,\n                  max_rating: Optional[float] = None,\n                  sort_type: Optional[str] = None, **kwargs) -> str:\n        logger.info(\n            f\"执行工具: {self.name}, 参数: media_type={media_type}, page={page}, count={count}, min_sub={min_sub}, \"\n            f\"genre_id={genre_id}, min_rating={min_rating}, max_rating={max_rating}, sort_type={sort_type}\")\n\n        try:\n            if page is None or page < 1:\n                page = 1\n            if count is None or count < 1:\n                count = 30\n            media_type_enum = MediaType.from_agent(media_type)\n            if not media_type_enum:\n                return f\"错误：无效的媒体类型 '{media_type}'，支持的类型：'movie', 'tv'\"\n\n            subscribe_helper = SubscribeHelper()\n            subscribes = await subscribe_helper.async_get_statistic(\n                stype=media_type_enum.to_agent(),\n                page=page,\n                count=count,\n                genre_id=genre_id,\n                min_rating=min_rating,\n                max_rating=max_rating,\n                sort_type=sort_type\n            )\n\n            if not subscribes:\n                return \"未找到热门订阅数据（可能订阅统计功能未启用）\"\n\n            # 转换为MediaInfo格式并过滤\n            ret_medias = []\n            for sub in subscribes:\n                # 订阅人数\n                subscriber_count = sub.get(\"count\", 0)\n                # 如果设置了最小订阅人数，进行过滤\n                if min_sub and subscriber_count < min_sub:\n                    continue\n\n                media = MediaInfo()\n                raw_type = str(sub.get(\"type\") or \"\").strip().lower()\n                if raw_type in [\"movie\", \"电影\"]:\n                    media.type = MediaType.MOVIE\n                elif raw_type in [\"tv\", \"电视剧\"]:\n                    media.type = MediaType.TV\n                else:\n                    # 跳过无法识别类型的数据，避免单条脏数据导致整批失败\n                    logger.warning(f\"跳过未知媒体类型: {sub.get('type')}\")\n                    continue\n                media.tmdb_id = sub.get(\"tmdbid\")\n                # 处理标题\n                title = sub.get(\"name\")\n                season = sub.get(\"season\")\n                if season and int(season) > 1 and media.tmdb_id:\n                    # 小写数据转大写\n                    season_str = cn2an.an2cn(season, \"low\")\n                    title = f\"{title} 第{season_str}季\"\n                media.title = title\n                media.year = sub.get(\"year\")\n                media.douban_id = sub.get(\"doubanid\")\n                media.bangumi_id = sub.get(\"bangumiid\")\n                media.tvdb_id = sub.get(\"tvdbid\")\n                media.imdb_id = sub.get(\"imdbid\")\n                media.season = sub.get(\"season\")\n                media.vote_average = sub.get(\"vote\")\n                media.poster_path = sub.get(\"poster\")\n                media.backdrop_path = sub.get(\"backdrop\")\n                media.popularity = subscriber_count\n                ret_medias.append(media)\n\n            if not ret_medias:\n                return \"未找到符合条件的热门订阅\"\n\n            # 转换为字典格式，只保留关键信息\n            simplified_medias = []\n            for media in ret_medias:\n                media_dict = media.to_dict()\n                simplified = {\n                    \"type\": media_type_to_agent(media_dict.get(\"type\")),\n                    \"title\": media_dict.get(\"title\"),\n                    \"year\": media_dict.get(\"year\"),\n                    \"tmdb_id\": media_dict.get(\"tmdb_id\"),\n                    \"douban_id\": media_dict.get(\"douban_id\"),\n                    \"bangumi_id\": media_dict.get(\"bangumi_id\"),\n                    \"tvdb_id\": media_dict.get(\"tvdb_id\"),\n                    \"imdb_id\": media_dict.get(\"imdb_id\"),\n                    \"season\": media_dict.get(\"season\"),\n                    \"vote_average\": media_dict.get(\"vote_average\"),\n                    \"poster_path\": media_dict.get(\"poster_path\"),\n                    \"backdrop_path\": media_dict.get(\"backdrop_path\"),\n                    \"popularity\": media_dict.get(\"popularity\"),  # 订阅人数\n                    \"subscriber_count\": media_dict.get(\"popularity\")  # 明确标注为订阅人数\n                }\n                simplified_medias.append(simplified)\n\n            result_json = json.dumps(simplified_medias, ensure_ascii=False, indent=2)\n\n            pagination_info = f\"第 {page} 页，每页 {count} 条，共 {len(simplified_medias)} 条结果\"\n\n            return f\"{pagination_info}\\n\\n{result_json}\"\n        except Exception as e:\n            logger.error(f\"查询热门订阅失败: {e}\", exc_info=True)\n            return f\"查询热门订阅时发生错误: {str(e)}\"\n\n"
  },
  {
    "path": "app/agent/tools/impl/query_rule_groups.py",
    "content": "\"\"\"查询规则组工具\"\"\"\n\nimport json\nfrom typing import Optional, Type\n\nfrom pydantic import BaseModel, Field\n\nfrom app.agent.tools.base import MoviePilotTool\nfrom app.helper.rule import RuleHelper\nfrom app.log import logger\n\n\nclass QueryRuleGroupsInput(BaseModel):\n    \"\"\"查询规则组工具的输入参数模型\"\"\"\n    explanation: str = Field(..., description=\"Clear explanation of why this tool is being used in the current context\")\n\n\nclass QueryRuleGroupsTool(MoviePilotTool):\n    name: str = \"query_rule_groups\"\n    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.\"\n    args_schema: Type[BaseModel] = QueryRuleGroupsInput\n\n    def get_tool_message(self, **kwargs) -> Optional[str]:\n        \"\"\"根据查询参数生成友好的提示消息\"\"\"\n        return \"正在查询所有规则组\"\n\n    async def run(self, **kwargs) -> str:\n        logger.info(f\"执行工具: {self.name}\")\n        \n        try:\n            rule_helper = RuleHelper()\n            rule_groups = rule_helper.get_rule_groups()\n            \n            if not rule_groups:\n                return json.dumps({\n                    \"message\": \"未找到任何规则组\",\n                    \"rule_groups\": []\n                }, ensure_ascii=False, indent=2)\n            \n            # 精简字段，过滤掉 rule_string 避免结果过大\n            simplified_groups = []\n            for group in rule_groups:\n                simplified = {\n                    \"name\": group.name,\n                    \"media_type\": group.media_type,\n                    \"category\": group.category\n                }\n                simplified_groups.append(simplified)\n            \n            result = {\n                \"message\": f\"找到 {len(simplified_groups)} 个规则组\",\n                \"rule_groups\": simplified_groups\n            }\n            \n            return json.dumps(result, ensure_ascii=False, indent=2)\n            \n        except Exception as e:\n            error_message = f\"查询规则组失败: {str(e)}\"\n            logger.error(f\"查询规则组失败: {e}\", exc_info=True)\n            return json.dumps({\n                \"success\": False,\n                \"message\": error_message,\n                \"rule_groups\": []\n            }, ensure_ascii=False)\n\n"
  },
  {
    "path": "app/agent/tools/impl/query_schedulers.py",
    "content": "\"\"\"查询定时服务工具\"\"\"\n\nimport json\nfrom typing import Optional, Type\n\nfrom pydantic import BaseModel, Field\n\nfrom app.agent.tools.base import MoviePilotTool\nfrom app.log import logger\nfrom app.scheduler import Scheduler\n\n\nclass QuerySchedulersInput(BaseModel):\n    \"\"\"查询定时服务工具的输入参数模型\"\"\"\n    explanation: str = Field(..., description=\"Clear explanation of why this tool is being used in the current context\")\n\n\nclass QuerySchedulersTool(MoviePilotTool):\n    name: str = \"query_schedulers\"\n    description: str = \"Query scheduled tasks and list all available scheduler jobs. Shows job status, next run time, and provider information.\"\n    args_schema: Type[BaseModel] = QuerySchedulersInput\n\n    def get_tool_message(self, **kwargs) -> Optional[str]:\n        \"\"\"生成友好的提示消息\"\"\"\n        return \"正在查询定时服务\"\n\n    async def run(self, **kwargs) -> str:\n        logger.info(f\"执行工具: {self.name}\")\n        try:\n            scheduler = Scheduler()\n            schedulers = scheduler.list()\n            if schedulers:\n                # 转换为字典列表以便JSON序列化\n                schedulers_list = []\n                for s in schedulers:\n                    schedulers_list.append({\n                        \"id\": s.id,\n                        \"name\": s.name,\n                        \"provider\": s.provider,\n                        \"status\": s.status,\n                        \"next_run\": s.next_run\n                    })\n                result_json = json.dumps(schedulers_list, ensure_ascii=False, indent=2)\n                # 限制最多30条结果\n                total_count = len(schedulers_list)\n                if total_count > 30:\n                    limited_schedulers = schedulers_list[:30]\n                    limited_json = json.dumps(limited_schedulers, ensure_ascii=False, indent=2)\n                    return f\"注意：查询结果共找到 {total_count} 条，为节省上下文空间，仅显示前 30 条结果。\\n\\n{limited_json}\"\n                return result_json\n            return \"未找到定时服务\"\n        except Exception as e:\n            logger.error(f\"查询定时服务失败: {e}\", exc_info=True)\n            return f\"查询定时服务时发生错误: {str(e)}\"\n\n"
  },
  {
    "path": "app/agent/tools/impl/query_site_userdata.py",
    "content": "\"\"\"查询站点用户数据工具\"\"\"\n\nimport json\nfrom typing import Optional, Type\n\nfrom pydantic import BaseModel, Field\n\nfrom app.agent.tools.base import MoviePilotTool\nfrom app.db import AsyncSessionFactory\nfrom app.db.models.site import Site\nfrom app.db.models.siteuserdata import SiteUserData\nfrom app.log import logger\n\n\nclass QuerySiteUserdataInput(BaseModel):\n    \"\"\"查询站点用户数据工具的输入参数模型\"\"\"\n    explanation: str = Field(..., description=\"Clear explanation of why this tool is being used in the current context\")\n    site_id: int = Field(..., description=\"The ID of the site to query user data for (can be obtained from query_sites tool)\")\n    workdate: Optional[str] = Field(None, description=\"Work date to query (optional, format: 'YYYY-MM-DD', if not specified returns latest data)\")\n\n\nclass QuerySiteUserdataTool(MoviePilotTool):\n    name: str = \"query_site_userdata\"\n    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.\"\n    args_schema: Type[BaseModel] = QuerySiteUserdataInput\n\n    def get_tool_message(self, **kwargs) -> Optional[str]:\n        \"\"\"根据查询参数生成友好的提示消息\"\"\"\n        site_id = kwargs.get(\"site_id\")\n        workdate = kwargs.get(\"workdate\")\n        \n        message = f\"正在查询站点 #{site_id} 的用户数据\"\n        if workdate:\n            message += f\" (日期: {workdate})\"\n        else:\n            message += \" (最新数据)\"\n        \n        return message\n\n    async def run(self, site_id: int, workdate: Optional[str] = None, **kwargs) -> str:\n        logger.info(f\"执行工具: {self.name}, 参数: site_id={site_id}, workdate={workdate}\")\n        \n        try:\n            # 获取数据库会话\n            async with AsyncSessionFactory() as db:\n                # 获取站点\n                site = await Site.async_get(db, site_id)\n                if not site:\n                    return json.dumps({\n                        \"success\": False,\n                        \"message\": f\"站点不存在: {site_id}\"\n                    }, ensure_ascii=False)\n                \n                # 获取站点用户数据\n                user_data_list = await SiteUserData.async_get_by_domain(\n                    db, \n                    domain=site.domain, \n                    workdate=workdate\n                )\n                \n                if not user_data_list:\n                    return json.dumps({\n                        \"success\": False,\n                        \"message\": f\"站点 {site.name} ({site.domain}) 暂无用户数据\",\n                        \"site_id\": site_id,\n                        \"site_name\": site.name,\n                        \"site_domain\": site.domain,\n                        \"workdate\": workdate\n                    }, ensure_ascii=False)\n                \n                # 格式化用户数据\n                result = {\n                    \"success\": True,\n                    \"site_id\": site_id,\n                    \"site_name\": site.name,\n                    \"site_domain\": site.domain,\n                    \"workdate\": workdate,\n                    \"data_count\": len(user_data_list),\n                    \"user_data\": []\n                }\n                \n                for user_data in user_data_list:\n                    # 格式化上传/下载量（转换为可读格式）\n                    upload_gb = user_data.upload / (1024 ** 3) if user_data.upload else 0\n                    download_gb = user_data.download / (1024 ** 3) if user_data.download else 0\n                    seeding_size_gb = user_data.seeding_size / (1024 ** 3) if user_data.seeding_size else 0\n                    leeching_size_gb = user_data.leeching_size / (1024 ** 3) if user_data.leeching_size else 0\n                    \n                    user_data_dict = {\n                        \"domain\": user_data.domain,\n                        \"name\": user_data.name,\n                        \"username\": user_data.username,\n                        \"userid\": user_data.userid,\n                        \"user_level\": user_data.user_level,\n                        \"join_at\": user_data.join_at,\n                        \"bonus\": user_data.bonus,\n                        \"upload\": user_data.upload,\n                        \"upload_gb\": round(upload_gb, 2),\n                        \"download\": user_data.download,\n                        \"download_gb\": round(download_gb, 2),\n                        \"ratio\": round(user_data.ratio, 2) if user_data.ratio else 0,\n                        \"seeding\": int(user_data.seeding) if user_data.seeding else 0,\n                        \"leeching\": int(user_data.leeching) if user_data.leeching else 0,\n                        \"seeding_size\": user_data.seeding_size,\n                        \"seeding_size_gb\": round(seeding_size_gb, 2),\n                        \"leeching_size\": user_data.leeching_size,\n                        \"leeching_size_gb\": round(leeching_size_gb, 2),\n                        \"seeding_info\": user_data.seeding_info if user_data.seeding_info else [],\n                        \"message_unread\": user_data.message_unread,\n                        \"message_unread_contents\": user_data.message_unread_contents if user_data.message_unread_contents else [],\n                        \"err_msg\": user_data.err_msg,\n                        \"updated_day\": user_data.updated_day,\n                        \"updated_time\": user_data.updated_time\n                    }\n                    result[\"user_data\"].append(user_data_dict)\n                \n                # 如果有多条数据，只返回最新的（按更新时间排序）\n                if len(result[\"user_data\"]) > 1:\n                    result[\"user_data\"].sort(\n                        key=lambda x: (x.get(\"updated_day\", \"\"), x.get(\"updated_time\", \"\")), \n                        reverse=True\n                    )\n                    result[\"message\"] = f\"找到 {len(result['user_data'])} 条数据，显示最新的一条\"\n                    result[\"user_data\"] = [result[\"user_data\"][0]]\n                \n                return json.dumps(result, ensure_ascii=False, indent=2)\n        \n        except Exception as e:\n            error_message = f\"查询站点用户数据失败: {str(e)}\"\n            logger.error(f\"查询站点用户数据失败: {e}\", exc_info=True)\n            return json.dumps({\n                \"success\": False,\n                \"message\": error_message,\n                \"site_id\": site_id\n            }, ensure_ascii=False)\n\n"
  },
  {
    "path": "app/agent/tools/impl/query_sites.py",
    "content": "\"\"\"查询站点工具\"\"\"\n\nimport json\nfrom typing import Optional, Type\n\nfrom pydantic import BaseModel, Field\n\nfrom app.agent.tools.base import MoviePilotTool\nfrom app.db.site_oper import SiteOper\nfrom app.log import logger\n\n\nclass QuerySitesInput(BaseModel):\n    \"\"\"查询站点工具的输入参数模型\"\"\"\n    explanation: str = Field(..., description=\"Clear explanation of why this tool is being used in the current context\")\n    status: Optional[str] = Field(\"all\",\n                                  description=\"Filter sites by status: 'active' for enabled sites, 'inactive' for disabled sites, 'all' for all sites\")\n    name: Optional[str] = Field(None,\n                                description=\"Filter sites by name (partial match, optional)\")\n\n\nclass QuerySitesTool(MoviePilotTool):\n    name: str = \"query_sites\"\n    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).\"\n    args_schema: Type[BaseModel] = QuerySitesInput\n\n    def get_tool_message(self, **kwargs) -> Optional[str]:\n        \"\"\"根据查询参数生成友好的提示消息\"\"\"\n        status = kwargs.get(\"status\", \"all\")\n        name = kwargs.get(\"name\")\n        \n        parts = [\"正在查询站点\"]\n        \n        if status != \"all\":\n            status_map = {\"active\": \"已启用\", \"inactive\": \"已禁用\"}\n            parts.append(f\"状态: {status_map.get(status, status)}\")\n        \n        if name:\n            parts.append(f\"名称: {name}\")\n        \n        return \" | \".join(parts) if len(parts) > 1 else parts[0]\n\n    async def run(self, status: Optional[str] = \"all\", name: Optional[str] = None, **kwargs) -> str:\n        logger.info(f\"执行工具: {self.name}, 参数: status={status}, name={name}\")\n        try:\n            site_oper = SiteOper()\n            # 获取所有站点（按优先级排序）\n            sites = await site_oper.async_list()\n            filtered_sites = []\n            for site in sites:\n                # 按状态过滤\n                if status == \"active\" and not site.is_active:\n                    continue\n                if status == \"inactive\" and site.is_active:\n                    continue\n                # 按名称过滤（部分匹配）\n                if name and name.lower() not in (site.name or \"\").lower():\n                    continue\n                filtered_sites.append(site)\n            if filtered_sites:\n                # 精简字段，只保留关键信息\n                simplified_sites = []\n                for s in filtered_sites:\n                    simplified = {\n                        \"id\": s.id,\n                        \"name\": s.name,\n                        \"domain\": s.domain,\n                        \"url\": s.url,\n                        \"pri\": s.pri,\n                        \"is_active\": s.is_active,\n                        \"downloader\": s.downloader,\n                        \"proxy\": s.proxy,\n                        \"timeout\": s.timeout\n                    }\n                    simplified_sites.append(simplified)\n                result_json = json.dumps(simplified_sites, ensure_ascii=False, indent=2)\n                return result_json\n            return \"未找到相关站点\"\n        except Exception as e:\n            logger.error(f\"查询站点失败: {e}\", exc_info=True)\n            return f\"查询站点时发生错误: {str(e)}\"\n\n"
  },
  {
    "path": "app/agent/tools/impl/query_subscribe_history.py",
    "content": "\"\"\"查询订阅历史工具\"\"\"\n\nimport json\nfrom typing import Optional, Type\n\nfrom pydantic import BaseModel, Field\n\nfrom app.agent.tools.base import MoviePilotTool\nfrom app.db import AsyncSessionFactory\nfrom app.db.models.subscribehistory import SubscribeHistory\nfrom app.log import logger\nfrom app.schemas.types import media_type_to_agent\n\n\nclass QuerySubscribeHistoryInput(BaseModel):\n    \"\"\"查询订阅历史工具的输入参数模型\"\"\"\n    explanation: str = Field(..., description=\"Clear explanation of why this tool is being used in the current context\")\n    media_type: Optional[str] = Field(\"all\", description=\"Allowed values: movie, tv, all\")\n    name: Optional[str] = Field(None, description=\"Filter by media name (partial match, optional)\")\n\n\nclass QuerySubscribeHistoryTool(MoviePilotTool):\n    name: str = \"query_subscribe_history\"\n    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.\"\n    args_schema: Type[BaseModel] = QuerySubscribeHistoryInput\n\n    def get_tool_message(self, **kwargs) -> Optional[str]:\n        \"\"\"根据查询参数生成友好的提示消息\"\"\"\n        media_type = kwargs.get(\"media_type\", \"all\")\n        name = kwargs.get(\"name\")\n        \n        parts = [\"正在查询订阅历史\"]\n        \n        if media_type != \"all\":\n            parts.append(f\"类型: {media_type}\")\n        if name:\n            parts.append(f\"名称: {name}\")\n        \n        return \" | \".join(parts) if len(parts) > 1 else parts[0]\n\n    async def run(self, media_type: Optional[str] = \"all\",\n                  name: Optional[str] = None, **kwargs) -> str:\n        logger.info(f\"执行工具: {self.name}, 参数: media_type={media_type}, name={name}\")\n\n        try:\n            if media_type not in [\"all\", \"movie\", \"tv\"]:\n                return f\"错误：无效的媒体类型 '{media_type}'，支持的类型：'movie', 'tv', 'all'\"\n\n            # 获取数据库会话\n            async with AsyncSessionFactory() as db:\n                # 根据类型查询\n                if media_type == \"all\":\n                    # 查询所有类型，需要分别查询电影和电视剧\n                    movie_history = await SubscribeHistory.async_list_by_type(db, mtype=\"movie\", page=1, count=100)\n                    tv_history = await SubscribeHistory.async_list_by_type(db, mtype=\"tv\", page=1, count=100)\n                    all_history = list(movie_history) + list(tv_history)\n                    # 按日期排序\n                    all_history.sort(key=lambda x: x.date or \"\", reverse=True)\n                else:\n                    # 查询指定类型\n                    all_history = await SubscribeHistory.async_list_by_type(db, mtype=media_type, page=1, count=100)\n                \n                # 按名称过滤\n                filtered_history = []\n                if name:\n                    name_lower = name.lower()\n                    for record in all_history:\n                        if record.name and name_lower in record.name.lower():\n                            filtered_history.append(record)\n                else:\n                    filtered_history = all_history\n                \n                if not filtered_history:\n                    return \"未找到相关订阅历史记录\"\n                \n                # 限制最多30条\n                total_count = len(filtered_history)\n                limited_history = filtered_history[:30]\n                \n                # 转换为字典格式，只保留关键信息\n                simplified_records = []\n                for record in limited_history:\n                    simplified = {\n                        \"id\": record.id,\n                        \"name\": record.name,\n                        \"year\": record.year,\n                        \"type\": media_type_to_agent(record.type),\n                        \"season\": record.season,\n                        \"tmdbid\": record.tmdbid,\n                        \"doubanid\": record.doubanid,\n                        \"bangumiid\": record.bangumiid,\n                        \"poster\": record.poster,\n                        \"vote\": record.vote,\n                        \"total_episode\": record.total_episode,\n                        \"date\": record.date,\n                        \"username\": record.username\n                    }\n                    # 添加过滤规则信息（如果有）\n                    if record.filter:\n                        simplified[\"filter\"] = record.filter\n                    if record.quality:\n                        simplified[\"quality\"] = record.quality\n                    if record.resolution:\n                        simplified[\"resolution\"] = record.resolution\n                    simplified_records.append(simplified)\n                \n                result_json = json.dumps(simplified_records, ensure_ascii=False, indent=2)\n                \n                # 如果结果被裁剪，添加提示信息\n                if total_count > 30:\n                    return f\"注意：查询结果共找到 {total_count} 条，为节省上下文空间，仅显示前 30 条结果。\\n\\n{result_json}\"\n                \n                return result_json\n        except Exception as e:\n            logger.error(f\"查询订阅历史失败: {e}\", exc_info=True)\n            return f\"查询订阅历史时发生错误: {str(e)}\"\n\n"
  },
  {
    "path": "app/agent/tools/impl/query_subscribe_shares.py",
    "content": "\"\"\"查询订阅分享工具\"\"\"\n\nimport json\nfrom typing import Optional, Type\n\nfrom pydantic import BaseModel, Field\n\nfrom app.agent.tools.base import MoviePilotTool\nfrom app.helper.subscribe import SubscribeHelper\nfrom app.log import logger\n\n\nclass QuerySubscribeSharesInput(BaseModel):\n    \"\"\"查询订阅分享工具的输入参数模型\"\"\"\n    explanation: str = Field(..., description=\"Clear explanation of why this tool is being used in the current context\")\n    name: Optional[str] = Field(None, description=\"Filter shares by media name (partial match, optional)\")\n    page: Optional[int] = Field(1, description=\"Page number for pagination (default: 1)\")\n    count: Optional[int] = Field(30, description=\"Number of items per page (default: 30)\")\n    genre_id: Optional[int] = Field(None, description=\"Filter by genre ID (optional)\")\n    min_rating: Optional[float] = Field(None, description=\"Minimum rating filter (optional, e.g., 7.5)\")\n    max_rating: Optional[float] = Field(None, description=\"Maximum rating filter (optional, e.g., 10.0)\")\n    sort_type: Optional[str] = Field(None, description=\"Sort type (optional, e.g., 'count', 'rating')\")\n\n\nclass QuerySubscribeSharesTool(MoviePilotTool):\n    name: str = \"query_subscribe_shares\"\n    description: str = \"Query shared subscriptions from other users. Shows popular subscriptions shared by the community with filtering and pagination support.\"\n    args_schema: Type[BaseModel] = QuerySubscribeSharesInput\n\n    def get_tool_message(self, **kwargs) -> Optional[str]:\n        \"\"\"根据查询参数生成友好的提示消息\"\"\"\n        name = kwargs.get(\"name\")\n        page = kwargs.get(\"page\", 1)\n        min_rating = kwargs.get(\"min_rating\")\n        max_rating = kwargs.get(\"max_rating\")\n        \n        parts = [\"正在查询订阅分享\"]\n        \n        if name:\n            parts.append(f\"名称: {name}\")\n        if min_rating:\n            parts.append(f\"最低评分: {min_rating}\")\n        if max_rating:\n            parts.append(f\"最高评分: {max_rating}\")\n        if page > 1:\n            parts.append(f\"第{page}页\")\n        \n        return \" | \".join(parts) if len(parts) > 1 else parts[0]\n\n    async def run(self, name: Optional[str] = None,\n                  page: Optional[int] = 1,\n                  count: Optional[int] = 30,\n                  genre_id: Optional[int] = None,\n                  min_rating: Optional[float] = None,\n                  max_rating: Optional[float] = None,\n                  sort_type: Optional[str] = None, **kwargs) -> str:\n        logger.info(\n            f\"执行工具: {self.name}, 参数: name={name}, page={page}, count={count}, genre_id={genre_id}, \"\n            f\"min_rating={min_rating}, max_rating={max_rating}, sort_type={sort_type}\")\n\n        try:\n            if page is None or page < 1:\n                page = 1\n            if count is None or count < 1:\n                count = 30\n\n            subscribe_helper = SubscribeHelper()\n            shares = await subscribe_helper.async_get_shares(\n                name=name,\n                page=page,\n                count=count,\n                genre_id=genre_id,\n                min_rating=min_rating,\n                max_rating=max_rating,\n                sort_type=sort_type\n            )\n\n            if not shares:\n                return \"未找到订阅分享数据（可能订阅分享功能未启用）\"\n\n            # 简化字段，只保留关键信息\n            simplified_shares = []\n            for share in shares:\n                simplified = {\n                    \"id\": share.get(\"id\"),\n                    \"name\": share.get(\"name\"),\n                    \"year\": share.get(\"year\"),\n                    \"type\": share.get(\"type\"),\n                    \"season\": share.get(\"season\"),\n                    \"tmdbid\": share.get(\"tmdbid\"),\n                    \"doubanid\": share.get(\"doubanid\"),\n                    \"bangumiid\": share.get(\"bangumiid\"),\n                    \"poster\": share.get(\"poster\"),\n                    \"vote\": share.get(\"vote\"),\n                    \"share_title\": share.get(\"share_title\"),\n                    \"share_comment\": share.get(\"share_comment\"),\n                    \"share_user\": share.get(\"share_user\"),\n                    \"fork_count\": share.get(\"fork_count\", 0)\n                }\n                # 截断过长的描述\n                if simplified.get(\"description\") and len(simplified[\"description\"]) > 200:\n                    simplified[\"description\"] = simplified[\"description\"][:200] + \"...\"\n                simplified_shares.append(simplified)\n\n            result_json = json.dumps(simplified_shares, ensure_ascii=False, indent=2)\n\n            pagination_info = f\"第 {page} 页，每页 {count} 条，共 {len(simplified_shares)} 条结果\"\n\n            return f\"{pagination_info}\\n\\n{result_json}\"\n        except Exception as e:\n            logger.error(f\"查询订阅分享失败: {e}\", exc_info=True)\n            return f\"查询订阅分享时发生错误: {str(e)}\"\n\n"
  },
  {
    "path": "app/agent/tools/impl/query_subscribes.py",
    "content": "\"\"\"查询订阅工具\"\"\"\n\nimport json\nfrom typing import Optional, Type\n\nfrom pydantic import BaseModel, Field\n\nfrom app.agent.tools.base import MoviePilotTool\nfrom app.db.subscribe_oper import SubscribeOper\nfrom app.log import logger\nfrom app.schemas.types import MediaType, media_type_to_agent\n\n\nclass QuerySubscribesInput(BaseModel):\n    \"\"\"查询订阅工具的输入参数模型\"\"\"\n    explanation: str = Field(..., description=\"Clear explanation of why this tool is being used in the current context\")\n    status: Optional[str] = Field(\"all\",\n                                  description=\"Filter subscriptions by status: 'R' for enabled subscriptions, 'S' for paused ones, 'all' for all subscriptions\")\n    media_type: Optional[str] = Field(\"all\",\n                                      description=\"Allowed values: movie, tv, all\")\n    tmdb_id: Optional[int] = Field(None, description=\"Filter by TMDB ID to check if a specific media is already subscribed\")\n    douban_id: Optional[str] = Field(None, description=\"Filter by Douban ID to check if a specific media is already subscribed\")\n\n\nclass QuerySubscribesTool(MoviePilotTool):\n    name: str = \"query_subscribes\"\n    description: str = \"Query subscription status and list all user subscriptions. Shows active subscriptions, their download status, and configuration details.\"\n    args_schema: Type[BaseModel] = QuerySubscribesInput\n\n    def get_tool_message(self, **kwargs) -> Optional[str]:\n        \"\"\"根据查询参数生成友好的提示消息\"\"\"\n        status = kwargs.get(\"status\", \"all\")\n        media_type = kwargs.get(\"media_type\", \"all\")\n        \n        parts = [\"正在查询订阅\"]\n        \n        # 根据状态过滤条件生成提示\n        if status != \"all\":\n            status_map = {\"R\": \"已启用\", \"S\": \"已暂停\"}\n            parts.append(f\"状态: {status_map.get(status, status)}\")\n        \n        # 根据媒体类型过滤条件生成提示\n        if media_type != \"all\":\n            parts.append(f\"类型: {media_type}\")\n        \n        return \" | \".join(parts) if len(parts) > 1 else parts[0]\n\n    async def run(self, status: Optional[str] = \"all\", media_type: Optional[str] = \"all\",\n                  tmdb_id: Optional[int] = None, douban_id: Optional[str] = None, **kwargs) -> str:\n        logger.info(f\"执行工具: {self.name}, 参数: status={status}, media_type={media_type}, tmdb_id={tmdb_id}, douban_id={douban_id}\")\n        try:\n            if media_type != \"all\" and not MediaType.from_agent(media_type):\n                return f\"错误：无效的媒体类型 '{media_type}'，支持的类型：'movie', 'tv', 'all'\"\n\n            subscribe_oper = SubscribeOper()\n            subscribes = await subscribe_oper.async_list()\n            filtered_subscribes = []\n            for sub in subscribes:\n                if status != \"all\" and sub.state != status:\n                    continue\n                if media_type != \"all\" and sub.type != MediaType.from_agent(media_type).value:\n                    continue\n                if tmdb_id is not None and sub.tmdbid != tmdb_id:\n                    continue\n                if douban_id is not None and sub.doubanid != douban_id:\n                    continue\n                filtered_subscribes.append(sub)\n            if filtered_subscribes:\n                # 限制最多50条结果\n                total_count = len(filtered_subscribes)\n                limited_subscribes = filtered_subscribes[:50]\n                # 精简字段，只保留关键信息\n                simplified_subscribes = []\n                for s in limited_subscribes:\n                    simplified = {\n                        \"id\": s.id,\n                        \"name\": s.name,\n                        \"year\": s.year,\n                        \"type\": media_type_to_agent(s.type),\n                        \"season\": s.season,\n                        \"tmdbid\": s.tmdbid,\n                        \"doubanid\": s.doubanid,\n                        \"bangumiid\": s.bangumiid,\n                        \"poster\": s.poster,\n                        \"vote\": s.vote,\n                        \"state\": s.state,\n                        \"total_episode\": s.total_episode,\n                        \"lack_episode\": s.lack_episode,\n                        \"last_update\": s.last_update,\n                        \"username\": s.username\n                    }\n                    simplified_subscribes.append(simplified)\n                result_json = json.dumps(simplified_subscribes, ensure_ascii=False, indent=2)\n                # 如果结果被裁剪，添加提示信息\n                if total_count > 50:\n                    return f\"注意：查询结果共找到 {total_count} 条，为节省上下文空间，仅显示前 50 条结果。\\n\\n{result_json}\"\n                return result_json\n            return \"未找到相关订阅\"\n        except Exception as e:\n            logger.error(f\"查询订阅失败: {e}\", exc_info=True)\n            return f\"查询订阅时发生错误: {str(e)}\"\n"
  },
  {
    "path": "app/agent/tools/impl/query_transfer_history.py",
    "content": "\"\"\"查询整理历史记录工具\"\"\"\n\nimport json\nfrom typing import Optional, Type\n\nimport jieba\nfrom pydantic import BaseModel, Field\n\nfrom app.agent.tools.base import MoviePilotTool\nfrom app.db import AsyncSessionFactory\nfrom app.db.models.transferhistory import TransferHistory\nfrom app.log import logger\nfrom app.schemas.types import media_type_to_agent\n\n\nclass QueryTransferHistoryInput(BaseModel):\n    \"\"\"查询整理历史记录工具的输入参数模型\"\"\"\n    explanation: str = Field(..., description=\"Clear explanation of why this tool is being used in the current context\")\n    title: Optional[str] = Field(None, description=\"Search by title (optional, supports partial match)\")\n    status: Optional[str] = Field(\"all\",\n                                  description=\"Filter by status: 'success' for successful transfers, 'failed' for failed transfers, 'all' for all records (default: 'all')\")\n    page: Optional[int] = Field(1, description=\"Page number for pagination (default: 1, each page contains 30 records)\")\n\n\nclass QueryTransferHistoryTool(MoviePilotTool):\n    name: str = \"query_transfer_history\"\n    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.\"\n    args_schema: Type[BaseModel] = QueryTransferHistoryInput\n\n    def get_tool_message(self, **kwargs) -> Optional[str]:\n        \"\"\"根据查询参数生成友好的提示消息\"\"\"\n        title = kwargs.get(\"title\")\n        status = kwargs.get(\"status\", \"all\")\n        page = kwargs.get(\"page\", 1)\n\n        parts = [\"正在查询整理历史\"]\n\n        if title:\n            parts.append(f\"标题: {title}\")\n        if status != \"all\":\n            status_map = {\"success\": \"成功\", \"failed\": \"失败\"}\n            parts.append(f\"状态: {status_map.get(status, status)}\")\n        if page > 1:\n            parts.append(f\"第{page}页\")\n\n        return \" | \".join(parts) if len(parts) > 1 else parts[0]\n\n    async def run(self, title: Optional[str] = None,\n                  status: Optional[str] = \"all\",\n                  page: Optional[int] = 1, **kwargs) -> str:\n        logger.info(f\"执行工具: {self.name}, 参数: title={title}, status={status}, page={page}\")\n\n        try:\n            # 处理状态参数\n            status_bool = None\n            if status == \"success\":\n                status_bool = True\n            elif status == \"failed\":\n                status_bool = False\n\n            # 处理页码参数\n            if page is None or page < 1:\n                page = 1\n\n            # 每页记录数\n            count = 50\n\n            # 获取数据库会话\n            async with AsyncSessionFactory() as db:\n                # 处理标题搜索\n                if title:\n                    # 使用 jieba 分词处理标题\n                    words = jieba.cut(title, HMM=False)\n                    title_search = \"%\".join(words)\n                    # 查询记录\n                    result = await TransferHistory.async_list_by_title(\n                        db, title=title_search, page=page, count=count, status=status_bool\n                    )\n                    total = await TransferHistory.async_count_by_title(\n                        db, title=title_search, status=status_bool\n                    )\n                else:\n                    # 查询所有记录\n                    result = await TransferHistory.async_list_by_page(\n                        db, page=page, count=count, status=status_bool\n                    )\n                    total = await TransferHistory.async_count(db, status=status_bool)\n\n                if not result:\n                    return \"未找到相关整理历史记录\"\n\n                # 转换为字典格式，只保留关键信息\n                simplified_records = []\n                for record in result:\n                    simplified = {\n                        \"id\": record.id,\n                        \"title\": record.title,\n                        \"year\": record.year,\n                        \"type\": media_type_to_agent(record.type),\n                        \"category\": record.category,\n                        \"seasons\": record.seasons,\n                        \"episodes\": record.episodes,\n                        \"src\": record.src,\n                        \"dest\": record.dest,\n                        \"mode\": record.mode,\n                        \"status\": \"成功\" if record.status else \"失败\",\n                        \"date\": record.date,\n                        \"downloader\": record.downloader,\n                        \"download_hash\": record.download_hash\n                    }\n                    # 如果失败，添加错误信息\n                    if not record.status and record.errmsg:\n                        simplified[\"errmsg\"] = record.errmsg\n                    # 添加媒体ID信息（如果有）\n                    if record.tmdbid:\n                        simplified[\"tmdbid\"] = record.tmdbid\n                    if record.imdbid:\n                        simplified[\"imdbid\"] = record.imdbid\n                    if record.doubanid:\n                        simplified[\"doubanid\"] = record.doubanid\n                    simplified_records.append(simplified)\n\n                result_json = json.dumps(simplified_records, ensure_ascii=False, indent=2)\n\n                # 计算总页数\n                total_pages = (total + count - 1) // count if total > 0 else 1\n\n                # 构建分页信息\n                pagination_info = f\"第 {page}/{total_pages} 页，共 {total} 条记录（每页 {count} 条）\"\n\n                return f\"{pagination_info}\\n\\n{result_json}\"\n        except Exception as e:\n            logger.error(f\"查询整理历史记录失败: {e}\", exc_info=True)\n            return f\"查询整理历史记录时发生错误: {str(e)}\"\n"
  },
  {
    "path": "app/agent/tools/impl/query_workflows.py",
    "content": "\"\"\"查询工作流工具\"\"\"\n\nimport json\nfrom typing import Optional, Type\n\nfrom pydantic import BaseModel, Field\n\nfrom app.agent.tools.base import MoviePilotTool\nfrom app.db import AsyncSessionFactory\nfrom app.db.workflow_oper import WorkflowOper\nfrom app.log import logger\n\n\nclass QueryWorkflowsInput(BaseModel):\n    \"\"\"查询工作流工具的输入参数模型\"\"\"\n    explanation: str = Field(..., description=\"Clear explanation of why this tool is being used in the current context\")\n    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')\")\n    name: Optional[str] = Field(None, description=\"Filter workflows by name (partial match, optional)\")\n    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')\")\n\n\nclass QueryWorkflowsTool(MoviePilotTool):\n    name: str = \"query_workflows\"\n    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.\"\n    args_schema: Type[BaseModel] = QueryWorkflowsInput\n\n    def get_tool_message(self, **kwargs) -> Optional[str]:\n        \"\"\"根据查询参数生成友好的提示消息\"\"\"\n        state = kwargs.get(\"state\", \"all\")\n        name = kwargs.get(\"name\")\n        trigger_type = kwargs.get(\"trigger_type\", \"all\")\n        \n        parts = [\"正在查询工作流\"]\n        \n        if state != \"all\":\n            state_map = {\"W\": \"等待\", \"R\": \"运行中\", \"P\": \"暂停\", \"S\": \"成功\", \"F\": \"失败\"}\n            parts.append(f\"状态: {state_map.get(state, state)}\")\n        \n        if trigger_type != \"all\":\n            trigger_map = {\"timer\": \"定时触发\", \"event\": \"事件触发\", \"manual\": \"手动触发\"}\n            parts.append(f\"触发类型: {trigger_map.get(trigger_type, trigger_type)}\")\n        \n        if name:\n            parts.append(f\"名称: {name}\")\n        \n        return \" | \".join(parts) if len(parts) > 1 else parts[0]\n\n    async def run(self, state: Optional[str] = \"all\",\n                  name: Optional[str] = None,\n                  trigger_type: Optional[str] = \"all\", **kwargs) -> str:\n        logger.info(f\"执行工具: {self.name}, 参数: state={state}, name={name}, trigger_type={trigger_type}\")\n\n        try:\n            # 获取数据库会话\n            async with AsyncSessionFactory() as db:\n                workflow_oper = WorkflowOper(db)\n                workflows = await workflow_oper.async_list()\n                \n                # 过滤工作流\n                filtered_workflows = []\n                for wf in workflows:\n                    # 按状态过滤\n                    if state != \"all\" and wf.state != state:\n                        continue\n                    \n                    # 按触发类型过滤\n                    if trigger_type != \"all\":\n                        if trigger_type == \"timer\" and wf.trigger_type not in [\"timer\", None]:\n                            continue\n                        elif trigger_type == \"event\" and wf.trigger_type != \"event\":\n                            continue\n                        elif trigger_type == \"manual\" and wf.trigger_type != \"manual\":\n                            continue\n                    \n                    # 按名称过滤（部分匹配）\n                    if name and wf.name and name.lower() not in wf.name.lower():\n                        continue\n                    \n                    filtered_workflows.append(wf)\n                \n                if not filtered_workflows:\n                    return \"未找到相关工作流\"\n                \n                # 转换为字典格式，只保留关键信息\n                simplified_workflows = []\n                for wf in filtered_workflows:\n                    # 状态说明\n                    state_map = {\n                        \"W\": \"等待\",\n                        \"R\": \"运行中\",\n                        \"P\": \"暂停\",\n                        \"S\": \"成功\",\n                        \"F\": \"失败\"\n                    }\n                    state_desc = state_map.get(wf.state, wf.state)\n                    \n                    # 触发类型说明\n                    trigger_type_map = {\n                        \"timer\": \"定时触发\",\n                        \"event\": \"事件触发\",\n                        \"manual\": \"手动触发\"\n                    }\n                    trigger_type_desc = trigger_type_map.get(wf.trigger_type, wf.trigger_type or \"定时触发\")\n                    \n                    simplified = {\n                        \"id\": wf.id,\n                        \"name\": wf.name,\n                        \"description\": wf.description,\n                        \"trigger_type\": trigger_type_desc,\n                        \"state\": state_desc,\n                        \"run_count\": wf.run_count,\n                        \"timer\": wf.timer,\n                        \"event_type\": wf.event_type,\n                        \"add_time\": wf.add_time,\n                        \"last_time\": wf.last_time,\n                        \"current_action\": wf.current_action\n                    }\n                    # 如果有结果，添加结果信息\n                    if wf.result:\n                        simplified[\"result\"] = wf.result\n                    simplified_workflows.append(simplified)\n                \n                result_json = json.dumps(simplified_workflows, ensure_ascii=False, indent=2)\n                return result_json\n        except Exception as e:\n            logger.error(f\"查询工作流失败: {e}\", exc_info=True)\n            return f\"查询工作流时发生错误: {str(e)}\"\n\n"
  },
  {
    "path": "app/agent/tools/impl/recognize_media.py",
    "content": "\"\"\"识别媒体信息工具\"\"\"\n\nimport json\nfrom typing import Optional, Type\n\nfrom pydantic import BaseModel, Field\n\nfrom app.agent.tools.base import MoviePilotTool\nfrom app.chain.media import MediaChain\nfrom app.core.context import Context\nfrom app.core.metainfo import MetaInfo\nfrom app.log import logger\nfrom app.schemas.types import media_type_to_agent\n\n\nclass RecognizeMediaInput(BaseModel):\n    \"\"\"识别媒体信息工具的输入参数模型\"\"\"\n    explanation: str = Field(..., description=\"Clear explanation of why this tool is being used in the current context\")\n    title: Optional[str] = Field(None, description=\"The title of the torrent/media to recognize (required for torrent recognition)\")\n    subtitle: Optional[str] = Field(None, description=\"The subtitle or description of the torrent (optional, helps improve recognition accuracy)\")\n    path: Optional[str] = Field(None, description=\"The file path to recognize (required for file recognition, mutually exclusive with title)\")\n\n\nclass RecognizeMediaTool(MoviePilotTool):\n    name: str = \"recognize_media\"\n    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.\"\n    args_schema: Type[BaseModel] = RecognizeMediaInput\n\n    def get_tool_message(self, **kwargs) -> Optional[str]:\n        \"\"\"根据识别参数生成友好的提示消息\"\"\"\n        title = kwargs.get(\"title\")\n        subtitle = kwargs.get(\"subtitle\")\n        path = kwargs.get(\"path\")\n        \n        if path:\n            message = f\"正在识别文件媒体信息: {path}\"\n        elif title:\n            message = f\"正在识别种子媒体信息: {title}\"\n            if subtitle:\n                message += f\" ({subtitle})\"\n        else:\n            message = \"正在识别媒体信息\"\n        \n        return message\n\n    async def run(self, title: Optional[str] = None, subtitle: Optional[str] = None,\n                  path: Optional[str] = None, **kwargs) -> str:\n        logger.info(f\"执行工具: {self.name}, 参数: title={title}, subtitle={subtitle}, path={path}\")\n        \n        try:\n            media_chain = MediaChain()\n            context = None\n            \n            # 根据提供的参数选择识别方式\n            if path:\n                # 文件路径识别\n                if not path:\n                    return json.dumps({\n                        \"success\": False,\n                        \"message\": \"文件路径不能为空\"\n                    }, ensure_ascii=False)\n                \n                context = await media_chain.async_recognize_by_path(path)\n                if context:\n                    return self._format_context_result(context, \"文件\")\n                else:\n                    return json.dumps({\n                        \"success\": False,\n                        \"message\": f\"无法识别文件媒体信息: {path}\",\n                        \"path\": path\n                    }, ensure_ascii=False)\n            \n            elif title:\n                # 种子标题识别\n                metainfo = MetaInfo(title, subtitle)\n                mediainfo = await media_chain.async_recognize_by_meta(metainfo)\n                if mediainfo:\n                    context = Context(meta_info=metainfo, media_info=mediainfo)\n                    return self._format_context_result(context, \"种子\")\n                else:\n                    return json.dumps({\n                        \"success\": False,\n                        \"message\": f\"无法识别种子媒体信息: {title}\",\n                        \"title\": title,\n                        \"subtitle\": subtitle\n                    }, ensure_ascii=False)\n            \n            else:\n                return json.dumps({\n                    \"success\": False,\n                    \"message\": \"必须提供 title（标题）或 path（文件路径）参数之一\"\n                }, ensure_ascii=False)\n        \n        except Exception as e:\n            error_message = f\"识别媒体信息失败: {str(e)}\"\n            logger.error(f\"识别媒体信息失败: {e}\", exc_info=True)\n            return json.dumps({\n                \"success\": False,\n                \"message\": error_message\n            }, ensure_ascii=False)\n\n    def _format_context_result(self, context: Context, source_type: str) -> str:\n        \"\"\"格式化识别结果为JSON字符串\"\"\"\n        if not context:\n            return json.dumps({\n                \"success\": False,\n                \"message\": \"识别结果为空\"\n            }, ensure_ascii=False)\n        \n        context_dict = context.to_dict()\n        media_info = context_dict.get(\"media_info\")\n        meta_info = context_dict.get(\"meta_info\")\n        \n        # 构建简化的结果\n        result = {\n            \"success\": True,\n            \"source_type\": source_type,\n            \"media_info\": None,\n            \"meta_info\": None\n        }\n        \n        # 处理媒体信息\n        if media_info:\n            result[\"media_info\"] = {\n                \"title\": media_info.get(\"title\"),\n                \"en_title\": media_info.get(\"en_title\"),\n                \"year\": media_info.get(\"year\"),\n                \"type\": media_type_to_agent(media_info.get(\"type\")),\n                \"season\": media_info.get(\"season\"),\n                \"tmdb_id\": media_info.get(\"tmdb_id\"),\n                \"imdb_id\": media_info.get(\"imdb_id\"),\n                \"douban_id\": media_info.get(\"douban_id\"),\n                \"bangumi_id\": media_info.get(\"bangumi_id\"),\n                \"overview\": media_info.get(\"overview\"),\n                \"vote_average\": media_info.get(\"vote_average\"),\n                \"poster_path\": media_info.get(\"poster_path\"),\n                \"backdrop_path\": media_info.get(\"backdrop_path\"),\n                \"detail_link\": media_info.get(\"detail_link\"),\n                \"title_year\": media_info.get(\"title_year\"),\n                \"source\": media_info.get(\"source\")\n            }\n        \n        # 处理元数据信息\n        if meta_info:\n            result[\"meta_info\"] = {\n                \"name\": meta_info.get(\"name\"),\n                \"title\": meta_info.get(\"title\"),\n                \"year\": meta_info.get(\"year\"),\n                \"type\": media_type_to_agent(meta_info.get(\"type\")),\n                \"begin_season\": meta_info.get(\"begin_season\"),\n                \"end_season\": meta_info.get(\"end_season\"),\n                \"begin_episode\": meta_info.get(\"begin_episode\"),\n                \"end_episode\": meta_info.get(\"end_episode\"),\n                \"total_episode\": meta_info.get(\"total_episode\"),\n                \"part\": meta_info.get(\"part\"),\n                \"season_episode\": meta_info.get(\"season_episode\"),\n                \"episode_list\": meta_info.get(\"episode_list\"),\n                \"tmdbid\": meta_info.get(\"tmdbid\"),\n                \"doubanid\": meta_info.get(\"doubanid\")\n            }\n        \n        return json.dumps(result, ensure_ascii=False, indent=2)\n\n"
  },
  {
    "path": "app/agent/tools/impl/run_scheduler.py",
    "content": "\"\"\"运行定时服务工具\"\"\"\n\nfrom typing import Optional, Type\n\nfrom pydantic import BaseModel, Field\n\nfrom app.agent.tools.base import MoviePilotTool\nfrom app.log import logger\nfrom app.scheduler import Scheduler\n\n\nclass RunSchedulerInput(BaseModel):\n    \"\"\"运行定时服务工具的输入参数模型\"\"\"\n    explanation: str = Field(..., description=\"Clear explanation of why this tool is being used in the current context\")\n    job_id: str = Field(..., description=\"The ID of the scheduled job to run (can be obtained from query_schedulers tool)\")\n\n\nclass RunSchedulerTool(MoviePilotTool):\n    name: str = \"run_scheduler\"\n    description: str = \"Manually trigger a scheduled task to run immediately. This will execute the specified scheduler job by its ID.\"\n    args_schema: Type[BaseModel] = RunSchedulerInput\n\n    def get_tool_message(self, **kwargs) -> Optional[str]:\n        \"\"\"根据运行参数生成友好的提示消息\"\"\"\n        job_id = kwargs.get(\"job_id\", \"\")\n        return f\"正在运行定时服务 (ID: {job_id})\"\n\n    async def run(self, job_id: str, **kwargs) -> str:\n        logger.info(f\"执行工具: {self.name}, 参数: job_id={job_id}\")\n\n        try:\n            scheduler = Scheduler()\n            # 检查定时服务是否存在\n            schedulers = scheduler.list()\n            job_exists = False\n            job_name = None\n            for s in schedulers:\n                if s.id == job_id:\n                    job_exists = True\n                    job_name = s.name\n                    break\n            \n            if not job_exists:\n                return f\"定时服务 ID {job_id} 不存在，请使用 query_schedulers 工具查询可用的定时服务\"\n            \n            # 运行定时服务\n            scheduler.start(job_id)\n            \n            return f\"成功触发定时服务：{job_name} (ID: {job_id})\"\n        except Exception as e:\n            logger.error(f\"运行定时服务失败: {e}\", exc_info=True)\n            return f\"运行定时服务时发生错误: {str(e)}\"\n\n"
  },
  {
    "path": "app/agent/tools/impl/run_workflow.py",
    "content": "\"\"\"执行工作流工具\"\"\"\n\nfrom typing import Optional, Type\n\nfrom pydantic import BaseModel, Field\n\nfrom app.agent.tools.base import MoviePilotTool\nfrom app.chain.workflow import WorkflowChain\nfrom app.db import AsyncSessionFactory\nfrom app.db.workflow_oper import WorkflowOper\nfrom app.log import logger\n\n\nclass RunWorkflowInput(BaseModel):\n    \"\"\"执行工作流工具的输入参数模型\"\"\"\n    explanation: str = Field(..., description=\"Clear explanation of why this tool is being used in the current context\")\n    workflow_id: int = Field(..., description=\"Workflow ID (can be obtained from query_workflows tool)\")\n    from_begin: Optional[bool] = Field(True, description=\"Whether to run workflow from the beginning (default: True, if False will continue from last executed action)\")\n\n\nclass RunWorkflowTool(MoviePilotTool):\n    name: str = \"run_workflow\"\n    description: str = \"Execute a specific workflow manually by workflow ID. Supports running from the beginning or continuing from the last executed action.\"\n    args_schema: Type[BaseModel] = RunWorkflowInput\n\n    def get_tool_message(self, **kwargs) -> Optional[str]:\n        \"\"\"根据工作流参数生成友好的提示消息\"\"\"\n        workflow_id = kwargs.get(\"workflow_id\")\n        from_begin = kwargs.get(\"from_begin\", True)\n        \n        message = f\"正在执行工作流: {workflow_id}\"\n        if not from_begin:\n            message += \" (从上次位置继续)\"\n        else:\n            message += \" (从头开始)\"\n        \n        return message\n\n    async def run(self, workflow_id: int,\n                  from_begin: Optional[bool] = True, **kwargs) -> str:\n        logger.info(f\"执行工具: {self.name}, 参数: workflow_id={workflow_id}, from_begin={from_begin}\")\n\n        try:\n            # 获取数据库会话\n            async with AsyncSessionFactory() as db:\n                workflow_oper = WorkflowOper(db)\n                workflow = await workflow_oper.async_get(workflow_id)\n                \n                if not workflow:\n                    return f\"未找到工作流：{workflow_id}，请使用 query_workflows 工具查询可用的工作流\"\n                \n                # 执行工作流\n                workflow_chain = WorkflowChain()\n                state, errmsg = workflow_chain.process(workflow.id, from_begin=from_begin)\n                \n                if not state:\n                    return f\"执行工作流失败：{workflow.name} (ID: {workflow.id})\\n错误原因：{errmsg}\"\n                else:\n                    return f\"工作流执行成功：{workflow.name} (ID: {workflow.id})\"\n        except Exception as e:\n            logger.error(f\"执行工作流失败: {e}\", exc_info=True)\n            return f\"执行工作流时发生错误: {str(e)}\"\n\n"
  },
  {
    "path": "app/agent/tools/impl/scrape_metadata.py",
    "content": "\"\"\"刮削媒体元数据工具\"\"\"\n\nimport json\nfrom pathlib import Path\nfrom typing import Optional, Type\n\nfrom pydantic import BaseModel, Field\n\nfrom app.agent.tools.base import MoviePilotTool\nfrom app.chain.media import MediaChain\nfrom app.core.config import global_vars\nfrom app.core.metainfo import MetaInfoPath\nfrom app.log import logger\nfrom app.schemas import FileItem\n\n\nclass ScrapeMetadataInput(BaseModel):\n    \"\"\"刮削媒体元数据工具的输入参数模型\"\"\"\n    explanation: str = Field(..., description=\"Clear explanation of why this tool is being used in the current context\")\n    path: str = Field(...,\n                      description=\"Path to the file or directory to scrape metadata for (e.g., '/path/to/file.mkv' or '/path/to/directory')\")\n    storage: Optional[str] = Field(\"local\",\n                                   description=\"Storage type: 'local' for local storage, 'smb', 'alist', etc. for remote storage (default: 'local')\")\n    overwrite: Optional[bool] = Field(False,\n                                      description=\"Whether to overwrite existing metadata files (default: False)\")\n\n\nclass ScrapeMetadataTool(MoviePilotTool):\n    name: str = \"scrape_metadata\"\n    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.\"\n    args_schema: Type[BaseModel] = ScrapeMetadataInput\n\n    def get_tool_message(self, **kwargs) -> Optional[str]:\n        \"\"\"根据刮削参数生成友好的提示消息\"\"\"\n        path = kwargs.get(\"path\", \"\")\n        storage = kwargs.get(\"storage\", \"local\")\n        overwrite = kwargs.get(\"overwrite\", False)\n\n        message = f\"正在刮削媒体元数据: {path}\"\n        if storage != \"local\":\n            message += f\" [存储: {storage}]\"\n        if overwrite:\n            message += \" [覆盖模式]\"\n\n        return message\n\n    async def run(self, path: str, storage: Optional[str] = \"local\",\n                  overwrite: Optional[bool] = False, **kwargs) -> str:\n        logger.info(f\"执行工具: {self.name}, 参数: path={path}, storage={storage}, overwrite={overwrite}\")\n\n        try:\n            # 验证路径\n            if not path:\n                return json.dumps({\n                    \"success\": False,\n                    \"message\": \"刮削路径不能为空\"\n                }, ensure_ascii=False)\n\n            # 创建 FileItem\n            fileitem = FileItem(\n                storage=storage,\n                path=path,\n                type=\"file\" if Path(path).suffix else \"dir\"\n            )\n\n            # 检查本地存储路径是否存在\n            if storage == \"local\":\n                scrape_path = Path(path)\n                if not scrape_path.exists():\n                    return json.dumps({\n                        \"success\": False,\n                        \"message\": f\"刮削路径不存在: {path}\"\n                    }, ensure_ascii=False)\n\n            # 识别媒体信息\n            media_chain = MediaChain()\n            scrape_path = Path(path)\n            meta = MetaInfoPath(scrape_path)\n            mediainfo = await media_chain.async_recognize_by_meta(meta)\n\n            if not mediainfo:\n                return json.dumps({\n                    \"success\": False,\n                    \"message\": f\"刮削失败，无法识别媒体信息: {path}\",\n                    \"path\": path\n                }, ensure_ascii=False)\n\n            # 在线程池中执行同步的刮削操作\n            await global_vars.loop.run_in_executor(\n                None,\n                lambda: media_chain.scrape_metadata(\n                    fileitem=fileitem,\n                    meta=meta,\n                    mediainfo=mediainfo,\n                    overwrite=overwrite\n                )\n            )\n\n            return json.dumps({\n                \"success\": True,\n                \"message\": f\"{path} 刮削完成\",\n                \"path\": path,\n                \"media_info\": {\n                    \"title\": mediainfo.title,\n                    \"year\": mediainfo.year,\n                    \"type\": mediainfo.type.value if mediainfo.type else None,\n                    \"tmdb_id\": mediainfo.tmdb_id,\n                    \"season\": mediainfo.season\n                }\n            }, ensure_ascii=False, indent=2)\n\n        except Exception as e:\n            error_message = f\"刮削媒体元数据失败: {str(e)}\"\n            logger.error(f\"刮削媒体元数据失败: {e}\", exc_info=True)\n            return json.dumps({\n                \"success\": False,\n                \"message\": error_message,\n                \"path\": path\n            }, ensure_ascii=False)\n"
  },
  {
    "path": "app/agent/tools/impl/search_media.py",
    "content": "\"\"\"搜索媒体工具\"\"\"\n\nimport json\nfrom typing import Optional, Type\n\nfrom pydantic import BaseModel, Field\n\nfrom app.agent.tools.base import MoviePilotTool\nfrom app.chain.media import MediaChain\nfrom app.log import logger\nfrom app.schemas.types import MediaType, media_type_to_agent\n\n\nclass SearchMediaInput(BaseModel):\n    \"\"\"搜索媒体工具的输入参数模型\"\"\"\n    explanation: str = Field(..., description=\"Clear explanation of why this tool is being used in the current context\")\n    title: str = Field(..., description=\"The title of the media to search for (e.g., 'The Matrix', 'Breaking Bad')\")\n    year: Optional[str] = Field(None, description=\"Release year of the media (optional, helps narrow down results)\")\n    media_type: Optional[str] = Field(None,\n                                      description=\"Allowed values: movie, tv\")\n    season: Optional[int] = Field(None,\n                                  description=\"Season number for TV shows and anime (optional, only applicable for series)\")\n\n\nclass SearchMediaTool(MoviePilotTool):\n    name: str = \"search_media\"\n    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.\"\n    args_schema: Type[BaseModel] = SearchMediaInput\n\n    def get_tool_message(self, **kwargs) -> Optional[str]:\n        \"\"\"根据搜索参数生成友好的提示消息\"\"\"\n        title = kwargs.get(\"title\", \"\")\n        year = kwargs.get(\"year\")\n        media_type = kwargs.get(\"media_type\")\n        season = kwargs.get(\"season\")\n        \n        message = f\"正在搜索媒体: {title}\"\n        if year:\n            message += f\" ({year})\"\n        if media_type:\n            message += f\" [{media_type}]\"\n        if season:\n            message += f\" 第{season}季\"\n        \n        return message\n\n    async def run(self, title: str, year: Optional[str] = None,\n                  media_type: Optional[str] = None, season: Optional[int] = None, **kwargs) -> str:\n        logger.info(\n            f\"执行工具: {self.name}, 参数: title={title}, year={year}, media_type={media_type}, season={season}\")\n\n        try:\n            media_chain = MediaChain()\n            # 使用 MediaChain.search 方法\n            meta, results = await media_chain.async_search(title=title)\n\n            # 过滤结果\n            if results:\n                media_type_enum = None\n                if media_type:\n                    media_type_enum = MediaType.from_agent(media_type)\n                    if not media_type_enum:\n                        return f\"错误：无效的媒体类型 '{media_type}'，支持的类型：'movie', 'tv'\"\n\n                filtered_results = []\n                for result in results:\n                    if year and result.year != year:\n                        continue\n                    if media_type_enum and result.type != media_type_enum:\n                        continue\n                    if season is not None and result.season != season:\n                        continue\n                    filtered_results.append(result)\n\n                if filtered_results:\n                    # 限制最多30条结果\n                    total_count = len(filtered_results)\n                    limited_results = filtered_results[:30]\n                    # 精简字段，只保留关键信息\n                    simplified_results = []\n                    for r in limited_results:\n                        simplified = {\n                            \"title\": r.title,\n                            \"en_title\": r.en_title,\n                            \"year\": r.year,\n                            \"type\": media_type_to_agent(r.type),\n                            \"season\": r.season,\n                            \"tmdb_id\": r.tmdb_id,\n                            \"imdb_id\": r.imdb_id,\n                            \"douban_id\": r.douban_id,\n                            \"overview\": r.overview[:200] + \"...\" if r.overview and len(r.overview) > 200 else r.overview,\n                            \"vote_average\": r.vote_average,\n                            \"poster_path\": r.poster_path,\n                            \"detail_link\": r.detail_link\n                        }\n                        simplified_results.append(simplified)\n                    result_json = json.dumps(simplified_results, ensure_ascii=False, indent=2)\n                    # 如果结果被裁剪，添加提示信息\n                    if total_count > 30:\n                        return f\"注意：搜索结果共找到 {total_count} 条，为节省上下文空间，仅显示前 30 条结果。\\n\\n{result_json}\"\n                    return result_json\n                else:\n                    return f\"未找到符合条件的媒体资源: {title}\"\n            else:\n                return f\"未找到相关媒体资源: {title}\"\n        except Exception as e:\n            error_message = f\"搜索媒体失败: {str(e)}\"\n            logger.error(f\"搜索媒体失败: {e}\", exc_info=True)\n            return error_message\n"
  },
  {
    "path": "app/agent/tools/impl/search_person.py",
    "content": "\"\"\"搜索人物工具\"\"\"\n\nimport json\nfrom typing import Optional, Type\n\nfrom pydantic import BaseModel, Field\n\nfrom app.agent.tools.base import MoviePilotTool\nfrom app.chain.media import MediaChain\nfrom app.log import logger\n\n\nclass SearchPersonInput(BaseModel):\n    \"\"\"搜索人物工具的输入参数模型\"\"\"\n    explanation: str = Field(..., description=\"Clear explanation of why this tool is being used in the current context\")\n    name: str = Field(..., description=\"The name of the person to search for (e.g., 'Tom Hanks', '周杰伦')\")\n\n\nclass SearchPersonTool(MoviePilotTool):\n    name: str = \"search_person\"\n    description: str = \"Search for person information including actors, directors, etc. Supports searching by name. Returns detailed person information from TMDB, Douban, or Bangumi database.\"\n    args_schema: Type[BaseModel] = SearchPersonInput\n\n    def get_tool_message(self, **kwargs) -> Optional[str]:\n        \"\"\"根据搜索参数生成友好的提示消息\"\"\"\n        name = kwargs.get(\"name\", \"\")\n        return f\"正在搜索人物: {name}\"\n\n    async def run(self, name: str, **kwargs) -> str:\n        logger.info(f\"执行工具: {self.name}, 参数: name={name}\")\n\n        try:\n            media_chain = MediaChain()\n            # 使用 MediaChain.async_search_persons 方法搜索人物\n            persons = await media_chain.async_search_persons(name=name)\n\n            if persons:\n                # 限制最多30条结果\n                total_count = len(persons)\n                limited_persons = persons[:30]\n                # 精简字段，只保留关键信息\n                simplified_results = []\n                for person in limited_persons:\n                    simplified = {\n                        \"name\": person.name,\n                        \"id\": person.id,\n                        \"source\": person.source,\n                        \"profile_path\": person.profile_path,\n                        \"original_name\": person.original_name,\n                        \"known_for_department\": person.known_for_department,\n                        \"popularity\": person.popularity,\n                        \"biography\": person.biography[:200] + \"...\" if person.biography and len(person.biography) > 200 else person.biography,\n                        \"birthday\": person.birthday,\n                        \"deathday\": person.deathday,\n                        \"place_of_birth\": person.place_of_birth,\n                        \"gender\": person.gender,\n                        \"imdb_id\": person.imdb_id,\n                        \"also_known_as\": person.also_known_as[:5] if person.also_known_as else [],  # 限制别名数量\n                    }\n                    # 添加豆瓣特有字段\n                    if person.source == \"douban\":\n                        simplified[\"url\"] = person.url\n                        simplified[\"avatar\"] = person.avatar\n                        simplified[\"latin_name\"] = person.latin_name\n                        simplified[\"roles\"] = person.roles[:5] if person.roles else []  # 限制角色数量\n                    # 添加Bangumi特有字段\n                    if person.source == \"bangumi\":\n                        simplified[\"career\"] = person.career\n                        simplified[\"relation\"] = person.relation\n                    \n                    simplified_results.append(simplified)\n                \n                result_json = json.dumps(simplified_results, ensure_ascii=False, indent=2)\n                # 如果结果被裁剪，添加提示信息\n                if total_count > 30:\n                    return f\"注意：搜索结果共找到 {total_count} 条，为节省上下文空间，仅显示前 30 条结果。\\n\\n{result_json}\"\n                return result_json\n            else:\n                return f\"未找到相关人物信息: {name}\"\n        except Exception as e:\n            error_message = f\"搜索人物失败: {str(e)}\"\n            logger.error(f\"搜索人物失败: {e}\", exc_info=True)\n            return error_message\n"
  },
  {
    "path": "app/agent/tools/impl/search_person_credits.py",
    "content": "\"\"\"搜索演员参演作品工具\"\"\"\n\nimport json\nfrom typing import Optional, Type\n\nfrom pydantic import BaseModel, Field\n\nfrom app.agent.tools.base import MoviePilotTool\nfrom app.chain.douban import DoubanChain\nfrom app.chain.tmdb import TmdbChain\nfrom app.chain.bangumi import BangumiChain\nfrom app.log import logger\n\n\nclass SearchPersonCreditsInput(BaseModel):\n    \"\"\"搜索演员参演作品工具的输入参数模型\"\"\"\n    explanation: str = Field(..., description=\"Clear explanation of why this tool is being used in the current context\")\n    person_id: int = Field(..., description=\"The ID of the person/actor to search for credits (e.g., 31 for Tom Hanks in TMDB)\")\n    source: str = Field(..., description=\"The data source: 'tmdb' for TheMovieDB, 'douban' for Douban, 'bangumi' for Bangumi\")\n    page: Optional[int] = Field(1, description=\"Page number for pagination (default: 1)\")\n\n\nclass SearchPersonCreditsTool(MoviePilotTool):\n    name: str = \"search_person_credits\"\n    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.\"\n    args_schema: Type[BaseModel] = SearchPersonCreditsInput\n\n    def get_tool_message(self, **kwargs) -> Optional[str]:\n        \"\"\"根据搜索参数生成友好的提示消息\"\"\"\n        person_id = kwargs.get(\"person_id\", \"\")\n        source = kwargs.get(\"source\", \"\")\n        return f\"正在搜索人物参演作品: {source} ID {person_id}\"\n\n    async def run(self, person_id: int, source: str, page: Optional[int] = 1, **kwargs) -> str:\n        logger.info(f\"执行工具: {self.name}, 参数: person_id={person_id}, source={source}, page={page}\")\n\n        try:\n            # 根据source选择相应的chain\n            if source.lower() == \"tmdb\":\n                tmdb_chain = TmdbChain()\n                medias = await tmdb_chain.async_person_credits(person_id=person_id, page=page)\n            elif source.lower() == \"douban\":\n                douban_chain = DoubanChain()\n                medias = await douban_chain.async_person_credits(person_id=person_id, page=page)\n            elif source.lower() == \"bangumi\":\n                bangumi_chain = BangumiChain()\n                medias = await bangumi_chain.async_person_credits(person_id=person_id)\n            else:\n                return f\"不支持的数据源: {source}。支持的数据源: tmdb, douban, bangumi\"\n\n            if medias:\n                # 限制最多30条结果\n                total_count = len(medias)\n                limited_medias = medias[:30]\n                # 精简字段，只保留关键信息\n                simplified_results = []\n                for media in limited_medias:\n                    simplified = {\n                        \"title\": media.title,\n                        \"en_title\": media.en_title,\n                        \"year\": media.year,\n                        \"type\": media.type.value if media.type else None,\n                        \"season\": media.season,\n                        \"tmdb_id\": media.tmdb_id,\n                        \"imdb_id\": media.imdb_id,\n                        \"douban_id\": media.douban_id,\n                        \"overview\": media.overview[:200] + \"...\" if media.overview and len(media.overview) > 200 else media.overview,\n                        \"vote_average\": media.vote_average,\n                        \"poster_path\": media.poster_path,\n                        \"backdrop_path\": media.backdrop_path,\n                        \"detail_link\": media.detail_link\n                    }\n                    simplified_results.append(simplified)\n                \n                result_json = json.dumps(simplified_results, ensure_ascii=False, indent=2)\n                # 如果结果被裁剪，添加提示信息\n                if total_count > 30:\n                    return f\"注意：搜索结果共找到 {total_count} 条，为节省上下文空间，仅显示前 30 条结果。\\n\\n{result_json}\"\n                return result_json\n            else:\n                return f\"未找到人物 ID {person_id} ({source}) 的参演作品\"\n        except Exception as e:\n            error_message = f\"搜索演员参演作品失败: {str(e)}\"\n            logger.error(f\"搜索演员参演作品失败: {e}\", exc_info=True)\n            return error_message\n"
  },
  {
    "path": "app/agent/tools/impl/search_subscribe.py",
    "content": "\"\"\"搜索订阅缺失剧集工具\"\"\"\n\nimport json\nfrom typing import Optional, Type, List\n\nfrom pydantic import BaseModel, Field\n\nfrom app.agent.tools.base import MoviePilotTool\nfrom app.chain.subscribe import SubscribeChain\nfrom app.core.config import global_vars\nfrom app.db.subscribe_oper import SubscribeOper\nfrom app.log import logger\nfrom app.schemas.types import media_type_to_agent\n\n\nclass SearchSubscribeInput(BaseModel):\n    \"\"\"搜索订阅缺失剧集工具的输入参数模型\"\"\"\n    explanation: str = Field(..., description=\"Clear explanation of why this tool is being used in the current context\")\n    subscribe_id: int = Field(..., description=\"The ID of the subscription to search for missing episodes (can be obtained from query_subscribes tool)\")\n    manual: Optional[bool] = Field(False, description=\"Whether this is a manual search (default: False)\")\n    filter_groups: Optional[List[str]] = Field(None,\n                                               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)\")\n\n\nclass SearchSubscribeTool(MoviePilotTool):\n    name: str = \"search_subscribe\"\n    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.\"\n    args_schema: Type[BaseModel] = SearchSubscribeInput\n\n    def get_tool_message(self, **kwargs) -> Optional[str]:\n        \"\"\"根据搜索参数生成友好的提示消息\"\"\"\n        subscribe_id = kwargs.get(\"subscribe_id\")\n        manual = kwargs.get(\"manual\", False)\n\n        message = f\"正在搜索订阅 #{subscribe_id} 的缺失剧集\"\n        if manual:\n            message += \"（手动搜索）\"\n\n        return message\n\n    async def run(self, subscribe_id: int, manual: Optional[bool] = False,\n                  filter_groups: Optional[List[str]] = None, **kwargs) -> str:\n        logger.info(\n            f\"执行工具: {self.name}, 参数: subscribe_id={subscribe_id}, manual={manual}, filter_groups={filter_groups}\")\n\n        try:\n            # 先验证订阅是否存在\n            subscribe_oper = SubscribeOper()\n            subscribe = subscribe_oper.get(subscribe_id)\n\n            if not subscribe:\n                return json.dumps({\n                    \"success\": False,\n                    \"message\": f\"订阅不存在: {subscribe_id}\"\n                }, ensure_ascii=False)\n\n            # 获取订阅信息用于返回\n            subscribe_info = {\n                \"id\": subscribe.id,\n                \"name\": subscribe.name,\n                \"year\": subscribe.year,\n                \"type\": media_type_to_agent(subscribe.type),\n                \"season\": subscribe.season,\n                \"state\": subscribe.state,\n                \"total_episode\": subscribe.total_episode,\n                \"lack_episode\": subscribe.lack_episode,\n                \"tmdbid\": subscribe.tmdbid,\n                \"doubanid\": subscribe.doubanid\n            }\n\n            # 检查订阅状态\n            if subscribe.state == \"S\":\n                return json.dumps({\n                    \"success\": False,\n                    \"message\": f\"订阅 #{subscribe_id} ({subscribe.name}) 已暂停，无法搜索\",\n                    \"subscribe\": subscribe_info\n                }, ensure_ascii=False)\n\n            # 如果提供了 filter_groups 参数，先更新订阅的规则组\n            if filter_groups is not None:\n                subscribe_oper.update(subscribe_id, {\"filter_groups\": filter_groups})\n                logger.info(f\"更新订阅 #{subscribe_id} 的规则组为: {filter_groups}\")\n\n            # 调用 SubscribeChain 的 search 方法\n            # search 方法是同步的，需要在异步环境中运行\n            subscribe_chain = SubscribeChain()\n\n            # 在线程池中执行同步的搜索操作\n            # 当 sid 有值时，state 参数会被忽略，直接处理该订阅\n            await global_vars.loop.run_in_executor(\n                None,\n                lambda: subscribe_chain.search(\n                    sid=subscribe_id,\n                    state='R',  # 默认状态，当 sid 有值时此参数会被忽略\n                    manual=manual\n                )\n            )\n\n            # 重新获取订阅信息以获取更新后的状态\n            updated_subscribe = subscribe_oper.get(subscribe_id)\n            if updated_subscribe:\n                subscribe_info.update({\n                    \"state\": updated_subscribe.state,\n                    \"lack_episode\": updated_subscribe.lack_episode,\n                    \"last_update\": updated_subscribe.last_update,\n                    \"filter_groups\": updated_subscribe.filter_groups\n                })\n\n            # 如果提供了规则组，会在返回信息中显示\n            result = {\n                \"success\": True,\n                \"message\": f\"订阅 #{subscribe_id} ({subscribe.name}) 搜索完成\",\n                \"subscribe\": subscribe_info\n            }\n\n            if filter_groups is not None:\n                result[\"message\"] += f\"（已应用规则组: {', '.join(filter_groups)}）\"\n\n            return json.dumps(result, ensure_ascii=False, indent=2)\n\n        except Exception as e:\n            error_message = f\"搜索订阅缺失剧集失败: {str(e)}\"\n            logger.error(f\"搜索订阅缺失剧集失败: {e}\", exc_info=True)\n            return json.dumps({\n                \"success\": False,\n                \"message\": error_message,\n                \"subscribe_id\": subscribe_id\n            }, ensure_ascii=False)\n"
  },
  {
    "path": "app/agent/tools/impl/search_torrents.py",
    "content": "\"\"\"搜索种子工具\"\"\"\n\nimport json\nfrom typing import List, Optional, Type\n\nfrom pydantic import BaseModel, Field\n\nfrom app.agent.tools.base import MoviePilotTool\nfrom app.chain.search import SearchChain\nfrom app.db.systemconfig_oper import SystemConfigOper\nfrom app.helper.sites import SitesHelper\nfrom app.log import logger\nfrom app.schemas.types import MediaType, SystemConfigKey\nfrom ._torrent_search_utils import (\n    SEARCH_RESULT_CACHE_FILE,\n    build_filter_options,\n)\n\n\nclass SearchTorrentsInput(BaseModel):\n    \"\"\"搜索种子工具的输入参数模型\"\"\"\n    explanation: str = Field(..., description=\"Clear explanation of why this tool is being used in the current context\")\n    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.\")\n    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.\")\n    media_type: Optional[str] = Field(None, description=\"Allowed values: movie, tv\")\n    area: Optional[str] = Field(None, description=\"Search scope: 'title' (default) or 'imdbid'\")\n    sites: Optional[List[int]] = Field(None,\n                                       description=\"Array of specific site IDs to search on (optional, if not provided searches all configured sites)\")\n\nclass SearchTorrentsTool(MoviePilotTool):\n    name: str = \"search_torrents\"\n    description: str = (\"Search for torrent files by media ID across configured indexer sites, cache the matched results, \"\n                        \"and return available filter options for follow-up selection. \"\n                        \"Requires tmdb_id or douban_id (can be obtained from search_media tool) for accurate matching.\")\n    args_schema: Type[BaseModel] = SearchTorrentsInput\n\n    def get_tool_message(self, **kwargs) -> Optional[str]:\n        \"\"\"根据搜索参数生成友好的提示消息\"\"\"\n        tmdb_id = kwargs.get(\"tmdb_id\")\n        douban_id = kwargs.get(\"douban_id\")\n        media_type = kwargs.get(\"media_type\")\n\n        if tmdb_id:\n            message = f\"正在搜索种子: TMDB={tmdb_id}\"\n        elif douban_id:\n            message = f\"正在搜索种子: 豆瓣={douban_id}\"\n        else:\n            message = \"正在搜索种子\"\n        if media_type:\n            message += f\" [{media_type}]\"\n        return message\n\n    async def run(self, tmdb_id: Optional[int] = None, douban_id: Optional[str] = None,\n                  media_type: Optional[str] = None, area: Optional[str] = None,\n                  sites: Optional[List[int]] = None, **kwargs) -> str:\n        logger.info(\n            f\"执行工具: {self.name}, 参数: tmdb_id={tmdb_id}, douban_id={douban_id}, media_type={media_type}, area={area}, sites={sites}\")\n\n        if not tmdb_id and not douban_id:\n            return \"参数错误：tmdb_id 和 douban_id 至少需要提供一个，请先使用 search_media 工具获取媒体 ID。\"\n\n        try:\n            search_chain = SearchChain()\n            media_type_enum = None\n            if media_type:\n                media_type_enum = MediaType.from_agent(media_type)\n                if not media_type_enum:\n                    return f\"错误：无效的媒体类型 '{media_type}'，支持的类型：'movie', 'tv'\"\n\n            filtered_torrents = await search_chain.async_search_by_id(\n                tmdbid=tmdb_id,\n                doubanid=douban_id,\n                mtype=media_type_enum,\n                area=area or \"title\",\n                sites=sites,\n                cache_local=False,\n            )\n\n            # 获取站点信息\n            all_indexers = await SitesHelper().async_get_indexers()\n            all_sites = [{\"id\": indexer.get(\"id\"), \"name\": indexer.get(\"name\")} for indexer in (all_indexers or [])]\n\n            if sites:\n                search_site_ids = sites\n            else:\n                configured_sites = SystemConfigOper().get(SystemConfigKey.IndexerSites)\n                search_site_ids = configured_sites if configured_sites else []\n\n            if filtered_torrents:\n                await search_chain.async_save_cache(filtered_torrents, SEARCH_RESULT_CACHE_FILE)\n                result_json = json.dumps({\n                    \"total_count\": len(filtered_torrents),\n                    \"message\": \"搜索完成。请使用 get_search_results 工具获取搜索结果。\",\n                    \"all_sites\": all_sites,\n                    \"search_site_ids\": search_site_ids,\n                    \"filter_options\": build_filter_options(filtered_torrents),\n                }, ensure_ascii=False, indent=2)\n                return result_json\n            else:\n                media_id = f\"TMDB={tmdb_id}\" if tmdb_id else f\"豆瓣={douban_id}\"\n                result_json = json.dumps({\n                    \"message\": f\"未找到相关种子资源: {media_id}\",\n                    \"all_sites\": all_sites,\n                    \"search_site_ids\": search_site_ids,\n                }, ensure_ascii=False, indent=2)\n                return result_json\n        except Exception as e:\n            error_message = f\"搜索种子时发生错误: {str(e)}\"\n            logger.error(f\"搜索种子失败: {e}\", exc_info=True)\n            return error_message\n"
  },
  {
    "path": "app/agent/tools/impl/search_web.py",
    "content": "import asyncio\nimport json\nimport re\nfrom typing import Optional, Type, List, Dict\n\nimport httpx\nfrom ddgs import DDGS\nfrom pydantic import BaseModel, Field\n\nfrom app.agent.tools.base import MoviePilotTool\nfrom app.core.config import settings\nfrom app.log import logger\n\n# 搜索超时时间（秒）\nSEARCH_TIMEOUT = 20\n\n\nclass SearchWebInput(BaseModel):\n    \"\"\"搜索网络内容工具的输入参数模型\"\"\"\n    explanation: str = Field(..., description=\"Clear explanation of why this tool is being used in the current context\")\n    query: str = Field(..., description=\"The search query string to search for on the web\")\n    max_results: Optional[int] = Field(5,\n                                       description=\"Maximum number of search results to return (default: 5, max: 10)\")\n\n\nclass SearchWebTool(MoviePilotTool):\n    name: str = \"search_web\"\n    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.\"\n    args_schema: Type[BaseModel] = SearchWebInput\n\n    def get_tool_message(self, **kwargs) -> Optional[str]:\n        \"\"\"根据搜索参数生成友好的提示消息\"\"\"\n        query = kwargs.get(\"query\", \"\")\n        max_results = kwargs.get(\"max_results\", 5)\n        return f\"正在搜索网络内容: {query} (最多返回 {max_results} 条结果)\"\n\n    async def run(self, query: str, max_results: Optional[int] = 5, **kwargs) -> str:\n        \"\"\"\n        执行网络搜索\n        \"\"\"\n        logger.info(f\"执行工具: {self.name}, 参数: query={query}, max_results={max_results}\")\n\n        try:\n            # 限制最大结果数\n            max_results = min(max(1, max_results or 5), 10)\n            results = []\n\n            # 1. 优先使用 Tavily (如果配置了 API Key)\n            if settings.TAVILY_API_KEY:\n                logger.info(\"使用 Tavily 进行搜索...\")\n                results = await self._search_tavily(query, max_results)\n\n            # 2. 如果没有结果或未配置 Tavily，使用 DuckDuckGo\n            if not results:\n                logger.info(\"使用 DuckDuckGo 进行搜索...\")\n                results = await self._search_duckduckgo(query, max_results)\n\n            if not results:\n                return f\"未找到与 '{query}' 相关的搜索结果\"\n\n            # 格式化并裁剪结果\n            formatted_results = self._format_and_truncate_results(results, max_results)\n            return json.dumps(formatted_results, ensure_ascii=False, indent=2)\n\n        except Exception as e:\n            error_message = f\"搜索网络内容失败: {str(e)}\"\n            logger.error(f\"搜索网络内容失败: {e}\", exc_info=True)\n            return error_message\n\n    @staticmethod\n    async def _search_tavily(query: str, max_results: int) -> List[Dict]:\n        \"\"\"使用 Tavily API 进行搜索\"\"\"\n        try:\n            async with httpx.AsyncClient(timeout=SEARCH_TIMEOUT) as client:\n                response = await client.post(\n                    \"https://api.tavily.com/search\",\n                    json={\n                        \"api_key\": settings.TAVILY_API_KEY,\n                        \"query\": query,\n                        \"search_depth\": \"basic\",\n                        \"max_results\": max_results,\n                        \"include_answer\": False,\n                        \"include_images\": False,\n                        \"include_raw_content\": False,\n                    }\n                )\n                response.raise_for_status()\n                data = response.json()\n\n                results = []\n                for result in data.get(\"results\", []):\n                    results.append({\n                        'title': result.get('title', ''),\n                        'snippet': result.get('content', ''),\n                        'url': result.get('url', ''),\n                        'source': 'Tavily'\n                    })\n                return results\n        except Exception as e:\n            logger.warning(f\"Tavily 搜索失败: {e}\")\n            return []\n\n    @staticmethod\n    def _get_proxy_url(proxy_setting) -> Optional[str]:\n        \"\"\"从代理设置中提取代理URL\"\"\"\n        if not proxy_setting:\n            return None\n        if isinstance(proxy_setting, dict):\n            return proxy_setting.get('http') or proxy_setting.get('https')\n        return proxy_setting\n\n    async def _search_duckduckgo(self, query: str, max_results: int) -> List[Dict]:\n        \"\"\"使用 duckduckgo-search (DDGS) 进行搜索\"\"\"\n        try:\n            def sync_search():\n                results = []\n                ddgs_kwargs = {\n                    'timeout': SEARCH_TIMEOUT\n                }\n                proxy_url = self._get_proxy_url(settings.PROXY)\n                if proxy_url:\n                    ddgs_kwargs['proxy'] = proxy_url\n\n                try:\n                    with DDGS(**ddgs_kwargs) as ddgs:\n                        ddgs_gen = ddgs.text(\n                            query,\n                            max_results=max_results\n                        )\n                        if ddgs_gen:\n                            for result in ddgs_gen:\n                                results.append({\n                                    'title': result.get('title', ''),\n                                    'snippet': result.get('body', ''),\n                                    'url': result.get('href', ''),\n                                    'source': 'DuckDuckGo'\n                                })\n                except Exception as err:\n                    logger.warning(f\"DuckDuckGo search process failed: {err}\")\n                return results\n\n            loop = asyncio.get_running_loop()\n            return await loop.run_in_executor(None, sync_search)\n\n        except Exception as e:\n            logger.warning(f\"DuckDuckGo 搜索失败: {e}\")\n            return []\n\n    @staticmethod\n    def _format_and_truncate_results(results: List[Dict], max_results: int) -> Dict:\n        \"\"\"格式化并裁剪搜索结果\"\"\"\n        formatted = {\n            \"total_results\": len(results),\n            \"results\": []\n        }\n\n        for idx, result in enumerate(results[:max_results], 1):\n            title = result.get(\"title\", \"\")[:200]\n            snippet = result.get(\"snippet\", \"\")\n            url = result.get(\"url\", \"\")\n            source = result.get(\"source\", \"Unknown\")\n\n            # 裁剪摘要\n            max_snippet_length = 500  # 增加到500字符，提供更多上下文\n            if len(snippet) > max_snippet_length:\n                snippet = snippet[:max_snippet_length] + \"...\"\n\n            # 清理文本\n            snippet = re.sub(r'\\s+', ' ', snippet).strip()\n\n            formatted[\"results\"].append({\n                \"rank\": idx,\n                \"title\": title,\n                \"snippet\": snippet,\n                \"url\": url,\n                \"source\": source\n            })\n\n        if len(results) > max_results:\n            formatted[\"note\"] = f\"仅显示前 {max_results} 条结果。\"\n\n        return formatted\n"
  },
  {
    "path": "app/agent/tools/impl/send_message.py",
    "content": "\"\"\"发送消息工具\"\"\"\n\nfrom typing import Optional, Type\n\nfrom pydantic import BaseModel, Field\n\nfrom app.agent.tools.base import MoviePilotTool\nfrom app.log import logger\n\n\nclass SendMessageInput(BaseModel):\n    \"\"\"发送消息工具的输入参数模型\"\"\"\n    explanation: str = Field(..., description=\"Clear explanation of why this tool is being used in the current context\")\n    message: str = Field(..., description=\"The message content to send to the user (should be clear and informative)\")\n    message_type: Optional[str] = Field(\"info\",\n                                        description=\"Type of message: 'info' for general information, 'success' for successful operations, 'warning' for warnings, 'error' for error messages\")\n\n\nclass SendMessageTool(MoviePilotTool):\n    name: str = \"send_message\"\n    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.\"\n    args_schema: Type[BaseModel] = SendMessageInput\n\n    def get_tool_message(self, **kwargs) -> Optional[str]:\n        \"\"\"根据消息参数生成友好的提示消息\"\"\"\n        message = kwargs.get(\"message\", \"\")\n        message_type = kwargs.get(\"message_type\", \"info\")\n        \n        type_map = {\"info\": \"信息\", \"success\": \"成功\", \"warning\": \"警告\", \"error\": \"错误\"}\n        type_desc = type_map.get(message_type, message_type)\n        \n        # 截断过长的消息\n        if len(message) > 50:\n            message = message[:50] + \"...\"\n        \n        return f\"正在发送{type_desc}消息: {message}\"\n\n    async def run(self, message: str, message_type: Optional[str] = None, **kwargs) -> str:\n        logger.info(f\"执行工具: {self.name}, 参数: message={message}, message_type={message_type}\")\n        try:\n            await self.send_tool_message(message, title=message_type)\n            return \"消息已发送\"\n        except Exception as e:\n            logger.error(f\"发送消息失败: {e}\")\n            return f\"发送消息时发生错误: {str(e)}\"\n"
  },
  {
    "path": "app/agent/tools/impl/test_site.py",
    "content": "\"\"\"测试站点连通性工具\"\"\"\n\nfrom typing import Optional, Type\n\nfrom pydantic import BaseModel, Field\n\nfrom app.agent.tools.base import MoviePilotTool\nfrom app.chain.site import SiteChain\nfrom app.db.site_oper import SiteOper\nfrom app.log import logger\n\n\nclass TestSiteInput(BaseModel):\n    \"\"\"测试站点连通性工具的输入参数模型\"\"\"\n    explanation: str = Field(..., description=\"Clear explanation of why this tool is being used in the current context\")\n    site_identifier: int = Field(..., description=\"Site ID to test (can be obtained from query_sites tool)\")\n\n\nclass TestSiteTool(MoviePilotTool):\n    name: str = \"test_site\"\n    description: str = \"Test site connectivity and availability. This will check if a site is accessible and can be logged in. Accepts site ID only.\"\n    args_schema: Type[BaseModel] = TestSiteInput\n\n    def get_tool_message(self, **kwargs) -> Optional[str]:\n        \"\"\"根据测试参数生成友好的提示消息\"\"\"\n        site_identifier = kwargs.get(\"site_identifier\")\n        return f\"正在测试站点连通性: {site_identifier}\"\n\n    async def run(self, site_identifier: int, **kwargs) -> str:\n        logger.info(f\"执行工具: {self.name}, 参数: site_identifier={site_identifier}\")\n\n        try:\n            site_oper = SiteOper()\n            site_chain = SiteChain()\n            site = await site_oper.async_get(site_identifier)\n            \n            if not site:\n                return f\"未找到站点：{site_identifier}，请使用 query_sites 工具查询可用的站点\"\n            \n            # 测试站点连通性\n            status, message = site_chain.test(site.domain)\n            \n            if status:\n                return f\"站点连通性测试成功：{site.name} ({site.domain})\\n{message}\"\n            else:\n                return f\"站点连通性测试失败：{site.name} ({site.domain})\\n{message}\"\n        except Exception as e:\n            logger.error(f\"测试站点连通性失败: {e}\", exc_info=True)\n            return f\"测试站点连通性时发生错误: {str(e)}\"\n\n"
  },
  {
    "path": "app/agent/tools/impl/transfer_file.py",
    "content": "\"\"\"整理文件或目录工具\"\"\"\n\nfrom pathlib import Path\nfrom typing import Optional, Type\n\nfrom pydantic import BaseModel, Field\n\nfrom app.agent.tools.base import MoviePilotTool\nfrom app.chain.transfer import TransferChain\nfrom app.log import logger\nfrom app.schemas import FileItem, MediaType\n\n\nclass TransferFileInput(BaseModel):\n    \"\"\"整理文件或目录工具的输入参数模型\"\"\"\n    explanation: str = Field(..., description=\"Clear explanation of why this tool is being used in the current context\")\n    file_path: str = Field(..., description=\"Path to the file or directory to transfer (e.g., '/path/to/file.mkv' or '/path/to/directory')\")\n    storage: Optional[str] = Field(\"local\", description=\"Storage type of the source file (default: 'local', can be 'smb', 'alist', etc.)\")\n    target_path: Optional[str] = Field(None, description=\"Target path for the transferred file/directory (optional, uses default library path if not specified)\")\n    target_storage: Optional[str] = Field(None, description=\"Target storage type (optional, uses default storage if not specified)\")\n    media_type: Optional[str] = Field(None, description=\"Allowed values: movie, tv\")\n    tmdbid: Optional[int] = Field(None, description=\"TMDB ID for precise media identification (optional but recommended for accuracy)\")\n    doubanid: Optional[str] = Field(None, description=\"Douban ID for media identification (optional)\")\n    season: Optional[int] = Field(None, description=\"Season number for TV shows (optional)\")\n    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)\")\n    background: Optional[bool] = Field(False, description=\"Whether to run transfer in background (default: False, runs synchronously)\")\n\n\nclass TransferFileTool(MoviePilotTool):\n    name: str = \"transfer_file\"\n    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.\"\n    args_schema: Type[BaseModel] = TransferFileInput\n\n    def get_tool_message(self, **kwargs) -> Optional[str]:\n        \"\"\"根据整理参数生成友好的提示消息\"\"\"\n        file_path = kwargs.get(\"file_path\", \"\")\n        media_type = kwargs.get(\"media_type\")\n        transfer_type = kwargs.get(\"transfer_type\")\n        background = kwargs.get(\"background\", False)\n        \n        message = f\"正在整理文件: {file_path}\"\n        if media_type:\n            message += f\" [{media_type}]\"\n        if transfer_type:\n            transfer_map = {\"move\": \"移动\", \"copy\": \"复制\", \"link\": \"硬链接\", \"softlink\": \"软链接\"}\n            message += f\" 模式: {transfer_map.get(transfer_type, transfer_type)}\"\n        if background:\n            message += \" [后台运行]\"\n        \n        return message\n\n    async def run(self, file_path: str, storage: Optional[str] = \"local\",\n                  target_path: Optional[str] = None,\n                  target_storage: Optional[str] = None,\n                  media_type: Optional[str] = None,\n                  tmdbid: Optional[int] = None,\n                  doubanid: Optional[str] = None,\n                  season: Optional[int] = None,\n                  transfer_type: Optional[str] = None,\n                  background: Optional[bool] = False, **kwargs) -> str:\n        logger.info(\n            f\"执行工具: {self.name}, 参数: file_path={file_path}, storage={storage}, target_path={target_path}, \"\n            f\"target_storage={target_storage}, media_type={media_type}, tmdbid={tmdbid}, doubanid={doubanid}, \"\n            f\"season={season}, transfer_type={transfer_type}, background={background}\")\n\n        try:\n            if not file_path:\n                return \"错误：必须提供文件或目录路径\"\n            \n            # 规范化路径\n            if storage == \"local\":\n                # 本地路径处理\n                if not file_path.startswith(\"/\") and not (len(file_path) > 1 and file_path[1] == \":\"):\n                    # 相对路径，尝试转换为绝对路径\n                    file_path = str(Path(file_path).resolve())\n            else:\n                # 远程存储路径，确保以/开头\n                if not file_path.startswith(\"/\"):\n                    file_path = \"/\" + file_path\n            \n            # 创建FileItem\n            fileitem = FileItem(\n                storage=storage or \"local\",\n                path=file_path,\n                type=\"dir\" if file_path.endswith(\"/\") else \"file\"\n            )\n            \n            # 处理目标路径\n            target_path_obj = None\n            if target_path:\n                target_path_obj = Path(target_path)\n            \n            # 处理媒体类型\n            media_type_enum = None\n            if media_type:\n                media_type_enum = MediaType.from_agent(media_type)\n                if not media_type_enum:\n                    return f\"错误：无效的媒体类型 '{media_type}'，支持的类型：'movie', 'tv'\"\n            \n            # 调用整理方法\n            transfer_chain = TransferChain()\n            state, errormsg = transfer_chain.manual_transfer(\n                fileitem=fileitem,\n                target_storage=target_storage,\n                target_path=target_path_obj,\n                tmdbid=tmdbid,\n                doubanid=doubanid,\n                mtype=media_type_enum,\n                season=season,\n                transfer_type=transfer_type,\n                background=background\n            )\n            \n            if not state:\n                # 处理错误信息\n                if isinstance(errormsg, list):\n                    error_text = f\"整理完成，{len(errormsg)} 个文件转移失败\"\n                    if errormsg:\n                        error_text += f\"：\\n\" + \"\\n\".join(str(e) for e in errormsg[:5])  # 只显示前5个错误\n                        if len(errormsg) > 5:\n                            error_text += f\"\\n... 还有 {len(errormsg) - 5} 个错误\"\n                else:\n                    error_text = str(errormsg)\n                return f\"整理失败：{error_text}\"\n            else:\n                if background:\n                    return f\"整理任务已提交到后台运行：{file_path}\"\n                else:\n                    return f\"整理成功：{file_path}\"\n        except Exception as e:\n            logger.error(f\"整理文件失败: {e}\", exc_info=True)\n            return f\"整理文件时发生错误: {str(e)}\"\n\n"
  },
  {
    "path": "app/agent/tools/impl/update_site.py",
    "content": "\"\"\"更新站点工具\"\"\"\n\nimport json\nfrom typing import Optional, Type\n\nfrom pydantic import BaseModel, Field\n\nfrom app.agent.tools.base import MoviePilotTool\nfrom app.core.event import eventmanager\nfrom app.db import AsyncSessionFactory\nfrom app.db.models.site import Site\nfrom app.log import logger\nfrom app.schemas.types import EventType\nfrom app.utils.string import StringUtils\n\n\nclass UpdateSiteInput(BaseModel):\n    \"\"\"更新站点工具的输入参数模型\"\"\"\n    explanation: str = Field(..., description=\"Clear explanation of why this tool is being used in the current context\")\n    site_id: int = Field(..., description=\"The ID of the site to update (can be obtained from query_sites tool)\")\n    name: Optional[str] = Field(None, description=\"Site name (optional)\")\n    url: Optional[str] = Field(None, description=\"Site URL (optional, will be automatically formatted)\")\n    pri: Optional[int] = Field(None, description=\"Site priority (optional, smaller value = higher priority, e.g., pri=1 has higher priority than pri=10)\")\n    rss: Optional[str] = Field(None, description=\"RSS feed URL (optional)\")\n    cookie: Optional[str] = Field(None, description=\"Site cookie (optional)\")\n    ua: Optional[str] = Field(None, description=\"User-Agent string (optional)\")\n    apikey: Optional[str] = Field(None, description=\"API key (optional)\")\n    token: Optional[str] = Field(None, description=\"API token (optional)\")\n    proxy: Optional[int] = Field(None, description=\"Whether to use proxy: 0 for no, 1 for yes (optional)\")\n    filter: Optional[str] = Field(None, description=\"Filter rule as regular expression (optional)\")\n    note: Optional[str] = Field(None, description=\"Site notes/remarks (optional)\")\n    timeout: Optional[int] = Field(None, description=\"Request timeout in seconds (optional, default: 15)\")\n    limit_interval: Optional[int] = Field(None, description=\"Rate limit interval in seconds (optional)\")\n    limit_count: Optional[int] = Field(None, description=\"Rate limit count per interval (optional)\")\n    limit_seconds: Optional[int] = Field(None, description=\"Rate limit seconds between requests (optional)\")\n    is_active: Optional[bool] = Field(None, description=\"Whether site is active: True for enabled, False for disabled (optional)\")\n    downloader: Optional[str] = Field(None, description=\"Downloader name for this site (optional)\")\n\n\nclass UpdateSiteTool(MoviePilotTool):\n    name: str = \"update_site\"\n    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).\"\n    args_schema: Type[BaseModel] = UpdateSiteInput\n\n    def get_tool_message(self, **kwargs) -> Optional[str]:\n        \"\"\"根据更新参数生成友好的提示消息\"\"\"\n        site_id = kwargs.get(\"site_id\")\n        fields_updated = []\n        \n        if kwargs.get(\"name\"):\n            fields_updated.append(\"名称\")\n        if kwargs.get(\"url\"):\n            fields_updated.append(\"URL\")\n        if kwargs.get(\"pri\") is not None:\n            fields_updated.append(\"优先级\")\n        if kwargs.get(\"cookie\"):\n            fields_updated.append(\"Cookie\")\n        if kwargs.get(\"ua\"):\n            fields_updated.append(\"User-Agent\")\n        if kwargs.get(\"proxy\") is not None:\n            fields_updated.append(\"代理设置\")\n        if kwargs.get(\"is_active\") is not None:\n            fields_updated.append(\"启用状态\")\n        if kwargs.get(\"downloader\"):\n            fields_updated.append(\"下载器\")\n        \n        if fields_updated:\n            return f\"正在更新站点 #{site_id}: {', '.join(fields_updated)}\"\n        return f\"正在更新站点 #{site_id}\"\n\n    async def run(self, site_id: int,\n                  name: Optional[str] = None,\n                  url: Optional[str] = None,\n                  pri: Optional[int] = None,\n                  rss: Optional[str] = None,\n                  cookie: Optional[str] = None,\n                  ua: Optional[str] = None,\n                  apikey: Optional[str] = None,\n                  token: Optional[str] = None,\n                  proxy: Optional[int] = None,\n                  filter: Optional[str] = None,\n                  note: Optional[str] = None,\n                  timeout: Optional[int] = None,\n                  limit_interval: Optional[int] = None,\n                  limit_count: Optional[int] = None,\n                  limit_seconds: Optional[int] = None,\n                  is_active: Optional[bool] = None,\n                  downloader: Optional[str] = None,\n                  **kwargs) -> str:\n        logger.info(f\"执行工具: {self.name}, 参数: site_id={site_id}\")\n        \n        try:\n            # 获取数据库会话\n            async with AsyncSessionFactory() as db:\n                # 获取站点\n                site = await Site.async_get(db, site_id)\n                if not site:\n                    return json.dumps({\n                        \"success\": False,\n                        \"message\": f\"站点不存在: {site_id}\"\n                    }, ensure_ascii=False)\n                \n                # 构建更新字典\n                site_dict = {}\n                \n                # 基本信息\n                if name is not None:\n                    site_dict[\"name\"] = name\n                \n                # URL处理（需要校正格式）\n                if url is not None:\n                    _scheme, _netloc = StringUtils.get_url_netloc(url)\n                    site_dict[\"url\"] = f\"{_scheme}://{_netloc}/\"\n                \n                if pri is not None:\n                    site_dict[\"pri\"] = pri\n                if rss is not None:\n                    site_dict[\"rss\"] = rss\n                \n                # 认证信息\n                if cookie is not None:\n                    site_dict[\"cookie\"] = cookie\n                if ua is not None:\n                    site_dict[\"ua\"] = ua\n                if apikey is not None:\n                    site_dict[\"apikey\"] = apikey\n                if token is not None:\n                    site_dict[\"token\"] = token\n                \n                # 配置选项\n                if proxy is not None:\n                    site_dict[\"proxy\"] = proxy\n                if filter is not None:\n                    site_dict[\"filter\"] = filter\n                if note is not None:\n                    site_dict[\"note\"] = note\n                if timeout is not None:\n                    site_dict[\"timeout\"] = timeout\n                \n                # 流控设置\n                if limit_interval is not None:\n                    site_dict[\"limit_interval\"] = limit_interval\n                if limit_count is not None:\n                    site_dict[\"limit_count\"] = limit_count\n                if limit_seconds is not None:\n                    site_dict[\"limit_seconds\"] = limit_seconds\n                \n                # 状态和下载器\n                if is_active is not None:\n                    site_dict[\"is_active\"] = is_active\n                if downloader is not None:\n                    site_dict[\"downloader\"] = downloader\n                \n                # 如果没有要更新的字段\n                if not site_dict:\n                    return json.dumps({\n                        \"success\": False,\n                        \"message\": \"没有提供要更新的字段\"\n                    }, ensure_ascii=False)\n                \n                # 更新站点\n                await site.async_update(db, site_dict)\n                \n                # 重新获取更新后的站点数据\n                updated_site = await Site.async_get(db, site_id)\n                \n                # 发送站点更新事件\n                await eventmanager.async_send_event(EventType.SiteUpdated, {\n                    \"domain\": updated_site.domain if updated_site else site.domain\n                })\n                \n                # 构建返回结果\n                result = {\n                    \"success\": True,\n                    \"message\": f\"站点 #{site_id} 更新成功\",\n                    \"site_id\": site_id,\n                    \"updated_fields\": list(site_dict.keys())\n                }\n                \n                if updated_site:\n                    result[\"site\"] = {\n                        \"id\": updated_site.id,\n                        \"name\": updated_site.name,\n                        \"domain\": updated_site.domain,\n                        \"url\": updated_site.url,\n                        \"pri\": updated_site.pri,\n                        \"is_active\": updated_site.is_active,\n                        \"downloader\": updated_site.downloader,\n                        \"proxy\": updated_site.proxy,\n                        \"timeout\": updated_site.timeout\n                    }\n                \n                return json.dumps(result, ensure_ascii=False, indent=2)\n        \n        except Exception as e:\n            error_message = f\"更新站点失败: {str(e)}\"\n            logger.error(f\"更新站点失败: {e}\", exc_info=True)\n            return json.dumps({\n                \"success\": False,\n                \"message\": error_message,\n                \"site_id\": site_id\n            }, ensure_ascii=False)\n\n"
  },
  {
    "path": "app/agent/tools/impl/update_site_cookie.py",
    "content": "\"\"\"更新站点Cookie和UA工具\"\"\"\n\nfrom typing import Optional, Type\n\nfrom pydantic import BaseModel, Field\n\nfrom app.agent.tools.base import MoviePilotTool\nfrom app.chain.site import SiteChain\nfrom app.db.site_oper import SiteOper\nfrom app.log import logger\n\n\nclass UpdateSiteCookieInput(BaseModel):\n    \"\"\"更新站点Cookie和UA工具的输入参数模型\"\"\"\n    explanation: str = Field(..., description=\"Clear explanation of why this tool is being used in the current context\")\n    site_identifier: int = Field(..., description=\"Site ID to update Cookie and User-Agent for (can be obtained from query_sites tool)\")\n    username: str = Field(..., description=\"Site login username\")\n    password: str = Field(..., description=\"Site login password\")\n    two_step_code: Optional[str] = Field(None, description=\"Two-step verification code or secret key (optional, required for sites with 2FA enabled)\")\n\n\nclass UpdateSiteCookieTool(MoviePilotTool):\n    name: str = \"update_site_cookie\"\n    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.\"\n    args_schema: Type[BaseModel] = UpdateSiteCookieInput\n\n    def get_tool_message(self, **kwargs) -> Optional[str]:\n        \"\"\"根据更新参数生成友好的提示消息\"\"\"\n        site_identifier = kwargs.get(\"site_identifier\")\n        username = kwargs.get(\"username\", \"\")\n        two_step_code = kwargs.get(\"two_step_code\")\n        \n        message = f\"正在更新站点Cookie: {site_identifier} (用户: {username})\"\n        if two_step_code:\n            message += \" [需要两步验证]\"\n        \n        return message\n\n    async def run(self, site_identifier: int, username: str, password: str,\n                  two_step_code: Optional[str] = None, **kwargs) -> str:\n        logger.info(f\"执行工具: {self.name}, 参数: site_identifier={site_identifier}, username={username}\")\n\n        try:\n            site_oper = SiteOper()\n            site_chain = SiteChain()\n            site = await site_oper.async_get(site_identifier)\n            \n            if not site:\n                return f\"未找到站点：{site_identifier}，请使用 query_sites 工具查询可用的站点\"\n            \n            # 更新站点Cookie和UA\n            status, message = site_chain.update_cookie(\n                site_info=site,\n                username=username,\n                password=password,\n                two_step_code=two_step_code\n            )\n            \n            if status:\n                return f\"站点【{site.name}】Cookie和UA更新成功\\n{message}\"\n            else:\n                return f\"站点【{site.name}】Cookie和UA更新失败\\n错误原因：{message}\"\n        except Exception as e:\n            logger.error(f\"更新站点Cookie和UA失败: {e}\", exc_info=True)\n            return f\"更新站点Cookie和UA时发生错误: {str(e)}\"\n\n"
  },
  {
    "path": "app/agent/tools/impl/update_subscribe.py",
    "content": "\"\"\"更新订阅工具\"\"\"\n\nimport json\nfrom typing import Optional, Type, List\n\nfrom pydantic import BaseModel, Field\n\nfrom app.agent.tools.base import MoviePilotTool\nfrom app.core.event import eventmanager\nfrom app.db import AsyncSessionFactory\nfrom app.db.models.subscribe import Subscribe\nfrom app.log import logger\nfrom app.schemas.types import EventType\n\n\nclass UpdateSubscribeInput(BaseModel):\n    \"\"\"更新订阅工具的输入参数模型\"\"\"\n    explanation: str = Field(..., description=\"Clear explanation of why this tool is being used in the current context\")\n    subscribe_id: int = Field(..., description=\"The ID of the subscription to update (can be obtained from query_subscribes tool)\")\n    name: Optional[str] = Field(None, description=\"Subscription name/title (optional)\")\n    year: Optional[str] = Field(None, description=\"Release year (optional)\")\n    season: Optional[int] = Field(None, description=\"Season number for TV shows (optional)\")\n    total_episode: Optional[int] = Field(None, description=\"Total number of episodes (optional)\")\n    lack_episode: Optional[int] = Field(None, description=\"Number of missing episodes (optional)\")\n    start_episode: Optional[int] = Field(None, description=\"Starting episode number (optional)\")\n    quality: Optional[str] = Field(None, description=\"Quality filter as regular expression (optional, e.g., 'BluRay|WEB-DL|HDTV')\")\n    resolution: Optional[str] = Field(None, description=\"Resolution filter as regular expression (optional, e.g., '1080p|720p|2160p')\")\n    effect: Optional[str] = Field(None, description=\"Effect filter as regular expression (optional, e.g., 'HDR|DV|SDR')\")\n    include: Optional[str] = Field(None, description=\"Include filter as regular expression (optional)\")\n    exclude: Optional[str] = Field(None, description=\"Exclude filter as regular expression (optional)\")\n    filter: Optional[str] = Field(None, description=\"Filter rule as regular expression (optional)\")\n    state: Optional[str] = Field(None, description=\"Subscription state: 'R' for enabled, 'P' for pending, 'S' for paused (optional)\")\n    sites: Optional[List[int]] = Field(None, description=\"List of site IDs to search from (optional)\")\n    downloader: Optional[str] = Field(None, description=\"Downloader name (optional)\")\n    save_path: Optional[str] = Field(None, description=\"Save path for downloaded files (optional)\")\n    best_version: Optional[int] = Field(None, description=\"Whether to upgrade to best version: 0 for no, 1 for yes (optional)\")\n    custom_words: Optional[str] = Field(None, description=\"Custom recognition words (optional)\")\n    media_category: Optional[str] = Field(None, description=\"Custom media category (optional)\")\n    episode_group: Optional[str] = Field(None, description=\"Episode group ID (optional)\")\n\n\nclass UpdateSubscribeTool(MoviePilotTool):\n    name: str = \"update_subscribe\"\n    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.\"\n    args_schema: Type[BaseModel] = UpdateSubscribeInput\n\n    def get_tool_message(self, **kwargs) -> Optional[str]:\n        \"\"\"根据更新参数生成友好的提示消息\"\"\"\n        subscribe_id = kwargs.get(\"subscribe_id\")\n        fields_updated = []\n        \n        if kwargs.get(\"name\"):\n            fields_updated.append(\"名称\")\n        if kwargs.get(\"total_episode\") is not None:\n            fields_updated.append(\"总集数\")\n        if kwargs.get(\"lack_episode\") is not None:\n            fields_updated.append(\"缺失集数\")\n        if kwargs.get(\"quality\"):\n            fields_updated.append(\"质量过滤\")\n        if kwargs.get(\"resolution\"):\n            fields_updated.append(\"分辨率过滤\")\n        if kwargs.get(\"state\"):\n            state_map = {\"R\": \"启用\", \"P\": \"禁用\", \"S\": \"暂停\"}\n            fields_updated.append(f\"状态({state_map.get(kwargs.get('state'), kwargs.get('state'))})\")\n        if kwargs.get(\"sites\"):\n            fields_updated.append(\"站点\")\n        if kwargs.get(\"downloader\"):\n            fields_updated.append(\"下载器\")\n        \n        if fields_updated:\n            return f\"正在更新订阅 #{subscribe_id}: {', '.join(fields_updated)}\"\n        return f\"正在更新订阅 #{subscribe_id}\"\n\n    async def run(self, subscribe_id: int,\n                  name: Optional[str] = None,\n                  year: Optional[str] = None,\n                  season: Optional[int] = None,\n                  total_episode: Optional[int] = None,\n                  lack_episode: Optional[int] = None,\n                  start_episode: Optional[int] = None,\n                  quality: Optional[str] = None,\n                  resolution: Optional[str] = None,\n                  effect: Optional[str] = None,\n                  include: Optional[str] = None,\n                  exclude: Optional[str] = None,\n                  filter: Optional[str] = None,\n                  state: Optional[str] = None,\n                  sites: Optional[List[int]] = None,\n                  downloader: Optional[str] = None,\n                  save_path: Optional[str] = None,\n                  best_version: Optional[int] = None,\n                  custom_words: Optional[str] = None,\n                  media_category: Optional[str] = None,\n                  episode_group: Optional[str] = None,\n                  **kwargs) -> str:\n        logger.info(f\"执行工具: {self.name}, 参数: subscribe_id={subscribe_id}\")\n        \n        try:\n            # 获取数据库会话\n            async with AsyncSessionFactory() as db:\n                # 获取订阅\n                subscribe = await Subscribe.async_get(db, subscribe_id)\n                if not subscribe:\n                    return json.dumps({\n                        \"success\": False,\n                        \"message\": f\"订阅不存在: {subscribe_id}\"\n                    }, ensure_ascii=False)\n                \n                # 保存旧数据用于事件\n                old_subscribe_dict = subscribe.to_dict()\n                \n                # 构建更新字典\n                subscribe_dict = {}\n                \n                # 基本信息\n                if name is not None:\n                    subscribe_dict[\"name\"] = name\n                if year is not None:\n                    subscribe_dict[\"year\"] = year\n                if season is not None:\n                    subscribe_dict[\"season\"] = season\n                \n                # 集数相关\n                if total_episode is not None:\n                    subscribe_dict[\"total_episode\"] = total_episode\n                    # 如果总集数增加，缺失集数也要相应增加\n                    if total_episode > (subscribe.total_episode or 0):\n                        old_lack = subscribe.lack_episode or 0\n                        subscribe_dict[\"lack_episode\"] = old_lack + (total_episode - (subscribe.total_episode or 0))\n                    # 标记为手动修改过总集数\n                    subscribe_dict[\"manual_total_episode\"] = 1\n                \n                # 缺失集数处理（只有在没有提供总集数时才单独处理）\n                # 注意：如果 lack_episode 为 0，不更新（避免更新为0）\n                if lack_episode is not None and total_episode is None:\n                    if lack_episode > 0:\n                        subscribe_dict[\"lack_episode\"] = lack_episode\n                    # 如果 lack_episode 为 0，不添加到更新字典中（保持原值或由总集数逻辑处理）\n                \n                if start_episode is not None:\n                    subscribe_dict[\"start_episode\"] = start_episode\n                \n                # 过滤规则\n                if quality is not None:\n                    subscribe_dict[\"quality\"] = quality\n                if resolution is not None:\n                    subscribe_dict[\"resolution\"] = resolution\n                if effect is not None:\n                    subscribe_dict[\"effect\"] = effect\n                if include is not None:\n                    subscribe_dict[\"include\"] = include\n                if exclude is not None:\n                    subscribe_dict[\"exclude\"] = exclude\n                if filter is not None:\n                    subscribe_dict[\"filter\"] = filter\n                \n                # 状态\n                if state is not None:\n                    valid_states = [\"R\", \"P\", \"S\", \"N\"]\n                    if state not in valid_states:\n                        return json.dumps({\n                            \"success\": False,\n                            \"message\": f\"无效的订阅状态: {state}，有效状态: {', '.join(valid_states)}\"\n                        }, ensure_ascii=False)\n                    subscribe_dict[\"state\"] = state\n                \n                # 下载配置\n                if sites is not None:\n                    subscribe_dict[\"sites\"] = sites\n                if downloader is not None:\n                    subscribe_dict[\"downloader\"] = downloader\n                if save_path is not None:\n                    subscribe_dict[\"save_path\"] = save_path\n                if best_version is not None:\n                    subscribe_dict[\"best_version\"] = best_version\n                \n                # 其他配置\n                if custom_words is not None:\n                    subscribe_dict[\"custom_words\"] = custom_words\n                if media_category is not None:\n                    subscribe_dict[\"media_category\"] = media_category\n                if episode_group is not None:\n                    subscribe_dict[\"episode_group\"] = episode_group\n                \n                # 如果没有要更新的字段\n                if not subscribe_dict:\n                    return json.dumps({\n                        \"success\": False,\n                        \"message\": \"没有提供要更新的字段\"\n                    }, ensure_ascii=False)\n                \n                # 更新订阅\n                await subscribe.async_update(db, subscribe_dict)\n                \n                # 重新获取更新后的订阅数据\n                updated_subscribe = await Subscribe.async_get(db, subscribe_id)\n                \n                # 发送订阅调整事件\n                await eventmanager.async_send_event(EventType.SubscribeModified, {\n                    \"subscribe_id\": subscribe_id,\n                    \"old_subscribe_info\": old_subscribe_dict,\n                    \"subscribe_info\": updated_subscribe.to_dict() if updated_subscribe else {},\n                })\n                \n                # 构建返回结果\n                result = {\n                    \"success\": True,\n                    \"message\": f\"订阅 #{subscribe_id} 更新成功\",\n                    \"subscribe_id\": subscribe_id,\n                    \"updated_fields\": list(subscribe_dict.keys())\n                }\n                \n                if updated_subscribe:\n                    result[\"subscribe\"] = {\n                        \"id\": updated_subscribe.id,\n                        \"name\": updated_subscribe.name,\n                        \"year\": updated_subscribe.year,\n                        \"type\": updated_subscribe.type,\n                        \"season\": updated_subscribe.season,\n                        \"state\": updated_subscribe.state,\n                        \"total_episode\": updated_subscribe.total_episode,\n                        \"lack_episode\": updated_subscribe.lack_episode,\n                        \"start_episode\": updated_subscribe.start_episode,\n                        \"quality\": updated_subscribe.quality,\n                        \"resolution\": updated_subscribe.resolution,\n                        \"effect\": updated_subscribe.effect\n                    }\n                \n                return json.dumps(result, ensure_ascii=False, indent=2)\n        \n        except Exception as e:\n            error_message = f\"更新订阅失败: {str(e)}\"\n            logger.error(f\"更新订阅失败: {e}\", exc_info=True)\n            return json.dumps({\n                \"success\": False,\n                \"message\": error_message,\n                \"subscribe_id\": subscribe_id\n            }, ensure_ascii=False)\n\n"
  },
  {
    "path": "app/agent/tools/manager.py",
    "content": "import json\nimport uuid\nfrom typing import Any, Dict, List, Optional\n\nfrom app.agent.tools.factory import MoviePilotToolFactory\nfrom app.log import logger\n\n\nclass ToolDefinition:\n    \"\"\"\n    工具定义\n    \"\"\"\n\n    def __init__(self, name: str, description: str, input_schema: Dict[str, Any]):\n        self.name = name\n        self.description = description\n        self.input_schema = input_schema\n\n\nclass MoviePilotToolsManager:\n    \"\"\"\n    MoviePilot工具管理器（用于HTTP API）\n    \"\"\"\n\n    def __init__(self, user_id: str = \"api_user\", session_id: str = uuid.uuid4()):\n        \"\"\"\n        初始化工具管理器\n        \n        Args:\n            user_id: 用户ID\n            session_id: 会话ID\n        \"\"\"\n        self.user_id = user_id\n        self.session_id = session_id\n        self.tools: List[Any] = []\n        self._load_tools()\n\n    def _load_tools(self):\n        \"\"\"\n        加载所有MoviePilot工具\n        \"\"\"\n        try:\n            # 创建工具实例\n            self.tools = MoviePilotToolFactory.create_tools(\n                session_id=self.session_id,\n                user_id=self.user_id,\n                channel=None,\n                source=\"api\",\n                username=\"API Client\",\n                callback_handler=None,\n            )\n            logger.info(f\"成功加载 {len(self.tools)} 个工具\")\n        except Exception as e:\n            logger.error(f\"加载工具失败: {e}\", exc_info=True)\n            self.tools = []\n\n    def list_tools(self) -> List[ToolDefinition]:\n        \"\"\"\n        列出所有可用的工具\n        \n        Returns:\n            工具定义列表\n        \"\"\"\n        tools_list = []\n        for tool in self.tools:\n            # 获取工具的输入参数模型\n            args_schema = getattr(tool, 'args_schema', None)\n            if args_schema:\n                # 将Pydantic模型转换为JSON Schema\n                input_schema = self._convert_to_json_schema(args_schema)\n            else:\n                # 如果没有args_schema，使用基本信息\n                input_schema = {\n                    \"type\": \"object\",\n                    \"properties\": {},\n                    \"required\": []\n                }\n\n            tools_list.append(ToolDefinition(\n                name=tool.name,\n                description=tool.description or \"\",\n                input_schema=input_schema\n            ))\n\n        return tools_list\n\n    def get_tool(self, tool_name: str) -> Optional[Any]:\n        \"\"\"\n        获取指定工具实例\n        \n        Args:\n            tool_name: 工具名称\n            \n        Returns:\n            工具实例，如果未找到返回None\n        \"\"\"\n        for tool in self.tools:\n            if tool.name == tool_name:\n                return tool\n        return None\n\n    @staticmethod\n    def _resolve_field_schema(field_info: Dict[str, Any]) -> Dict[str, Any]:\n        \"\"\"\n        解析字段schema，兼容 Optional[T] 生成的 anyOf 结构\n        \"\"\"\n        if field_info.get(\"type\"):\n            return field_info\n\n        any_of = field_info.get(\"anyOf\")\n        if not any_of:\n            return field_info\n\n        for type_option in any_of:\n            if type_option.get(\"type\") and type_option[\"type\"] != \"null\":\n                merged = dict(type_option)\n                if \"description\" not in merged and field_info.get(\"description\"):\n                    merged[\"description\"] = field_info[\"description\"]\n                if \"default\" not in merged and \"default\" in field_info:\n                    merged[\"default\"] = field_info[\"default\"]\n                return merged\n\n        return field_info\n\n    @staticmethod\n    def _normalize_scalar_value(field_type: Optional[str], value: Any, key: str) -> Any:\n        \"\"\"\n        根据字段类型规范化单个值\n        \"\"\"\n        if field_type == \"integer\" and isinstance(value, str):\n            try:\n                return int(value)\n            except (ValueError, TypeError):\n                logger.warning(f\"无法将参数 {key}='{value}' 转换为整数，返回 None\")\n                return None\n        if field_type == \"number\" and isinstance(value, str):\n            try:\n                return float(value)\n            except (ValueError, TypeError):\n                logger.warning(f\"无法将参数 {key}='{value}' 转换为浮点数，返回 None\")\n                return None\n        if field_type == \"boolean\":\n            if isinstance(value, str):\n                return value.lower() in (\"true\", \"1\", \"yes\", \"on\")\n            if isinstance(value, (int, float)):\n                return value != 0\n            if isinstance(value, bool):\n                return value\n            return True\n        return value\n\n    @staticmethod\n    def _parse_array_string(value: str, key: str, item_type: str = \"string\") -> list:\n        \"\"\"\n        将逗号分隔的字符串解析为列表，并根据 item_type 转换元素类型\n        \"\"\"\n        trimmed = value.strip()\n        if not trimmed:\n            return []\n        return [\n            MoviePilotToolsManager._normalize_scalar_value(item_type, item.strip(), key)\n            for item in trimmed.split(\",\") if item.strip()\n        ]\n\n    @staticmethod\n    def _normalize_arguments(tool_instance: Any, arguments: Dict[str, Any]) -> Dict[str, Any]:\n        \"\"\"\n        根据工具的参数schema规范化参数类型\n        \n        Args:\n            tool_instance: 工具实例\n            arguments: 原始参数\n            \n        Returns:\n            规范化后的参数\n        \"\"\"\n        # 获取工具的参数schema\n        args_schema = getattr(tool_instance, 'args_schema', None)\n        if not args_schema:\n            return arguments\n\n        # 获取schema中的字段定义\n        try:\n            schema = args_schema.model_json_schema()\n            properties = schema.get(\"properties\", {})\n        except Exception as e:\n            logger.warning(f\"获取工具schema失败: {e}\")\n            return arguments\n\n        # 规范化参数\n        normalized = {}\n        for key, value in arguments.items():\n            if key not in properties:\n                # 参数不在schema中，保持原样\n                normalized[key] = value\n                continue\n\n            field_info = MoviePilotToolsManager._resolve_field_schema(properties[key])\n            field_type = field_info.get(\"type\")\n\n            # 数组类型：将字符串解析为列表\n            if field_type == \"array\" and isinstance(value, str):\n                item_type = field_info.get(\"items\", {}).get(\"type\", \"string\")\n                normalized[key] = MoviePilotToolsManager._parse_array_string(value, key, item_type)\n                continue\n\n            # 根据类型进行转换\n            normalized[key] = MoviePilotToolsManager._normalize_scalar_value(field_type, value, key)\n\n        return normalized\n\n    async def call_tool(self, tool_name: str, arguments: Dict[str, Any]) -> str:\n        \"\"\"\n        调用工具\n        \n        Args:\n            tool_name: 工具名称\n            arguments: 工具参数\n            \n        Returns:\n            工具执行结果（字符串）\n        \"\"\"\n        tool_instance = self.get_tool(tool_name)\n\n        if not tool_instance:\n            error_msg = json.dumps({\n                \"error\": f\"工具 '{tool_name}' 未找到\"\n            }, ensure_ascii=False)\n            return error_msg\n\n        try:\n            # 规范化参数类型\n            normalized_arguments = self._normalize_arguments(tool_instance, arguments)\n\n            # 调用工具的run方法\n            result = await tool_instance.run(**normalized_arguments)\n\n            # 确保返回字符串\n            if isinstance(result, str):\n                formated_result = result\n            elif isinstance(result, int, float):\n                formated_result = str(result)\n            else:\n                try:\n                    formated_result = json.dumps(result, ensure_ascii=False, indent=2)\n                except Exception as e:\n                    logger.warning(f\"结果转换为JSON失败: {e}, 使用字符串表示\")\n                    formated_result = str(result)\n\n            return formated_result\n        except Exception as e:\n            logger.error(f\"调用工具 {tool_name} 时发生错误: {e}\", exc_info=True)\n            error_msg = json.dumps({\n                \"error\": f\"调用工具 '{tool_name}' 时发生错误: {str(e)}\"\n            }, ensure_ascii=False)\n            return error_msg\n\n    @staticmethod\n    def _convert_to_json_schema(args_schema: Any) -> Dict[str, Any]:\n        \"\"\"\n        将Pydantic模型转换为JSON Schema\n        \n        Args:\n            args_schema: Pydantic模型类\n            \n        Returns:\n            JSON Schema字典\n        \"\"\"\n        # 获取Pydantic模型的字段信息\n        schema = args_schema.model_json_schema()\n\n        # 构建JSON Schema\n        properties = {}\n        required = []\n\n        if \"properties\" in schema:\n            for field_name, field_info in schema[\"properties\"].items():\n                resolved_field_info = MoviePilotToolsManager._resolve_field_schema(field_info)\n                # 转换字段类型\n                field_type = resolved_field_info.get(\"type\", \"string\")\n                field_description = resolved_field_info.get(\"description\", \"\")\n\n                # 处理可选字段\n                if field_name not in schema.get(\"required\", []):\n                    # 可选字段\n                    default_value = resolved_field_info.get(\"default\")\n                    properties[field_name] = {\n                        \"type\": field_type,\n                        \"description\": field_description\n                    }\n                    if default_value is not None:\n                        properties[field_name][\"default\"] = default_value\n                else:\n                    properties[field_name] = {\n                        \"type\": field_type,\n                        \"description\": field_description\n                    }\n                    required.append(field_name)\n\n                # 处理枚举类型\n                if \"enum\" in resolved_field_info:\n                    properties[field_name][\"enum\"] = resolved_field_info[\"enum\"]\n\n                # 处理数组类型\n                if field_type == \"array\" and \"items\" in resolved_field_info:\n                    properties[field_name][\"items\"] = resolved_field_info[\"items\"]\n\n        return {\n            \"type\": \"object\",\n            \"properties\": properties,\n            \"required\": required\n        }\n\n\nmoviepilot_tool_manager = MoviePilotToolsManager()\n"
  },
  {
    "path": "app/api/__init__.py",
    "content": ""
  },
  {
    "path": "app/api/apiv1.py",
    "content": "from fastapi import APIRouter\n\nfrom app.api.endpoints import login, user, webhook, message, site, subscribe, \\\n    media, douban, search, plugin, tmdb, history, system, download, dashboard, \\\n    transfer, mediaserver, bangumi, storage, discover, recommend, workflow, torrent, mcp, mfa\n\napi_router = APIRouter()\napi_router.include_router(login.router, prefix=\"/login\", tags=[\"login\"])\napi_router.include_router(user.router, prefix=\"/user\", tags=[\"user\"])\napi_router.include_router(mfa.router, prefix=\"/mfa\", tags=[\"mfa\"])\napi_router.include_router(site.router, prefix=\"/site\", tags=[\"site\"])\napi_router.include_router(message.router, prefix=\"/message\", tags=[\"message\"])\napi_router.include_router(webhook.router, prefix=\"/webhook\", tags=[\"webhook\"])\napi_router.include_router(subscribe.router, prefix=\"/subscribe\", tags=[\"subscribe\"])\napi_router.include_router(media.router, prefix=\"/media\", tags=[\"media\"])\napi_router.include_router(search.router, prefix=\"/search\", tags=[\"search\"])\napi_router.include_router(douban.router, prefix=\"/douban\", tags=[\"douban\"])\napi_router.include_router(tmdb.router, prefix=\"/tmdb\", tags=[\"tmdb\"])\napi_router.include_router(history.router, prefix=\"/history\", tags=[\"history\"])\napi_router.include_router(system.router, prefix=\"/system\", tags=[\"system\"])\napi_router.include_router(plugin.router, prefix=\"/plugin\", tags=[\"plugin\"])\napi_router.include_router(download.router, prefix=\"/download\", tags=[\"download\"])\napi_router.include_router(dashboard.router, prefix=\"/dashboard\", tags=[\"dashboard\"])\napi_router.include_router(storage.router, prefix=\"/storage\", tags=[\"storage\"])\napi_router.include_router(transfer.router, prefix=\"/transfer\", tags=[\"transfer\"])\napi_router.include_router(mediaserver.router, prefix=\"/mediaserver\", tags=[\"mediaserver\"])\napi_router.include_router(bangumi.router, prefix=\"/bangumi\", tags=[\"bangumi\"])\napi_router.include_router(discover.router, prefix=\"/discover\", tags=[\"discover\"])\napi_router.include_router(recommend.router, prefix=\"/recommend\", tags=[\"recommend\"])\napi_router.include_router(workflow.router, prefix=\"/workflow\", tags=[\"workflow\"])\napi_router.include_router(torrent.router, prefix=\"/torrent\", tags=[\"torrent\"])\napi_router.include_router(mcp.router, prefix=\"/mcp\", tags=[\"mcp\"])\n"
  },
  {
    "path": "app/api/endpoints/__init__.py",
    "content": ""
  },
  {
    "path": "app/api/endpoints/bangumi.py",
    "content": "from typing import List, Any, Optional\n\nfrom fastapi import APIRouter, Depends\n\nfrom app import schemas\nfrom app.chain.bangumi import BangumiChain\nfrom app.core.context import MediaInfo\nfrom app.core.security import verify_token\n\nrouter = APIRouter()\n\n\n@router.get(\"/credits/{bangumiid}\", summary=\"查询Bangumi演职员表\", response_model=List[schemas.MediaPerson])\nasync def bangumi_credits(bangumiid: int,\n                          page: Optional[int] = 1,\n                          count: Optional[int] = 20,\n                          _: schemas.TokenPayload = Depends(verify_token)) -> Any:\n    \"\"\"\n    查询Bangumi演职员表\n    \"\"\"\n    persons = await BangumiChain().async_bangumi_credits(bangumiid)\n    if persons:\n        return persons[(page - 1) * count: page * count]\n    return []\n\n\n@router.get(\"/recommend/{bangumiid}\", summary=\"查询Bangumi推荐\", response_model=List[schemas.MediaInfo])\nasync def bangumi_recommend(bangumiid: int,\n                            page: Optional[int] = 1,\n                            count: Optional[int] = 20,\n                            _: schemas.TokenPayload = Depends(verify_token)) -> Any:\n    \"\"\"\n    查询Bangumi推荐\n    \"\"\"\n    medias = await BangumiChain().async_bangumi_recommend(bangumiid)\n    if medias:\n        return [media.to_dict() for media in medias[(page - 1) * count: page * count]]\n    return []\n\n\n@router.get(\"/person/{person_id}\", summary=\"人物详情\", response_model=schemas.MediaPerson)\nasync def bangumi_person(person_id: int,\n                         _: schemas.TokenPayload = Depends(verify_token)) -> Any:\n    \"\"\"\n    根据人物ID查询人物详情\n    \"\"\"\n    return await BangumiChain().async_person_detail(person_id=person_id)\n\n\n@router.get(\"/person/credits/{person_id}\", summary=\"人物参演作品\", response_model=List[schemas.MediaInfo])\nasync def bangumi_person_credits(person_id: int,\n                                 page: Optional[int] = 1,\n                                 count: Optional[int] = 20,\n                                 _: schemas.TokenPayload = Depends(verify_token)) -> Any:\n    \"\"\"\n    根据人物ID查询人物参演作品\n    \"\"\"\n    medias = await BangumiChain().async_person_credits(person_id=person_id)\n    if medias:\n        return [media.to_dict() for media in medias[(page - 1) * count: page * count]]\n    return []\n\n\n@router.get(\"/{bangumiid}\", summary=\"查询Bangumi详情\", response_model=schemas.MediaInfo)\nasync def bangumi_info(bangumiid: int,\n                       _: schemas.TokenPayload = Depends(verify_token)) -> Any:\n    \"\"\"\n    查询Bangumi详情\n    \"\"\"\n    info = await BangumiChain().async_bangumi_info(bangumiid)\n    if info:\n        return MediaInfo(bangumi_info=info).to_dict()\n    else:\n        return schemas.MediaInfo()\n"
  },
  {
    "path": "app/api/endpoints/dashboard.py",
    "content": "from pathlib import Path\nfrom typing import Any, List, Optional, Annotated\n\nfrom fastapi import APIRouter, Depends\nfrom sqlalchemy.orm import Session\n\nfrom app import schemas\nfrom app.chain.dashboard import DashboardChain\nfrom app.chain.storage import StorageChain\nfrom app.core.security import verify_token, verify_apitoken\nfrom app.db import get_db\nfrom app.db.models.transferhistory import TransferHistory\nfrom app.helper.directory import DirectoryHelper\nfrom app.scheduler import Scheduler\nfrom app.utils.system import SystemUtils\n\nrouter = APIRouter()\n\n\n@router.get(\"/statistic\", summary=\"媒体数量统计\", response_model=schemas.Statistic)\ndef statistic(name: Optional[str] = None, _: schemas.TokenPayload = Depends(verify_token)) -> Any:\n    \"\"\"\n    查询媒体数量统计信息\n    \"\"\"\n    media_statistics: Optional[List[schemas.Statistic]] = DashboardChain().media_statistic(name)\n    if media_statistics:\n        # 汇总各媒体库统计信息\n        ret_statistic = schemas.Statistic()\n        has_episode_count = False\n        for media_statistic in media_statistics:\n            ret_statistic.movie_count += media_statistic.movie_count or 0\n            ret_statistic.tv_count += media_statistic.tv_count or 0\n            ret_statistic.user_count += media_statistic.user_count or 0\n            if media_statistic.episode_count is not None:\n                ret_statistic.episode_count += media_statistic.episode_count or 0\n                has_episode_count = True\n        if not has_episode_count:\n            # 所有媒体服务都未提供剧集统计时，返回 None 供前端展示“未获取”。\n            ret_statistic.episode_count = None\n        return ret_statistic\n    else:\n        return schemas.Statistic()\n\n\n@router.get(\"/statistic2\", summary=\"媒体数量统计（API_TOKEN）\", response_model=schemas.Statistic)\ndef statistic2(_: Annotated[str, Depends(verify_apitoken)]) -> Any:\n    \"\"\"\n    查询媒体数量统计信息 API_TOKEN认证（?token=xxx）\n    \"\"\"\n    return statistic()\n\n\n@router.get(\"/storage\", summary=\"本地存储空间\", response_model=schemas.Storage)\ndef storage(_: schemas.TokenPayload = Depends(verify_token)) -> Any:\n    \"\"\"\n    查询本地存储空间信息\n    \"\"\"\n    total, available = 0, 0\n    dirs = DirectoryHelper().get_dirs()\n    if not dirs:\n        return schemas.Storage(total_storage=total, used_storage=total - available)\n    storages = set([d.library_storage for d in dirs if d.library_storage])\n    for _storage in storages:\n        _usage = StorageChain().storage_usage(_storage)\n        if _usage:\n            total += _usage.total\n            available += _usage.available\n    return schemas.Storage(\n        total_storage=total,\n        used_storage=total - available\n    )\n\n\n@router.get(\"/storage2\", summary=\"本地存储空间（API_TOKEN）\", response_model=schemas.Storage)\ndef storage2(_: Annotated[str, Depends(verify_apitoken)]) -> Any:\n    \"\"\"\n    查询本地存储空间信息 API_TOKEN认证（?token=xxx）\n    \"\"\"\n    return storage()\n\n\n@router.get(\"/processes\", summary=\"进程信息\", response_model=List[schemas.ProcessInfo])\ndef processes(_: schemas.TokenPayload = Depends(verify_token)) -> Any:\n    \"\"\"\n    查询进程信息\n    \"\"\"\n    return SystemUtils.processes()\n\n\n@router.get(\"/downloader\", summary=\"下载器信息\", response_model=schemas.DownloaderInfo)\ndef downloader(name: Optional[str] = None, _: schemas.TokenPayload = Depends(verify_token)) -> Any:\n    \"\"\"\n    查询下载器信息\n    \"\"\"\n    # 下载目录空间\n    download_dirs = DirectoryHelper().get_local_download_dirs()\n    _, free_space = SystemUtils.space_usage([Path(d.download_path) for d in download_dirs])\n    # 下载器信息\n    downloader_info = schemas.DownloaderInfo()\n    transfer_infos = DashboardChain().downloader_info(name)\n    if transfer_infos:\n        for transfer_info in transfer_infos:\n            downloader_info.download_speed += transfer_info.download_speed\n            downloader_info.upload_speed += transfer_info.upload_speed\n            downloader_info.download_size += transfer_info.download_size\n            downloader_info.upload_size += transfer_info.upload_size\n        downloader_info.free_space = free_space\n    return downloader_info\n\n\n@router.get(\"/downloader2\", summary=\"下载器信息（API_TOKEN）\", response_model=schemas.DownloaderInfo)\ndef downloader2(_: Annotated[str, Depends(verify_apitoken)]) -> Any:\n    \"\"\"\n    查询下载器信息 API_TOKEN认证（?token=xxx）\n    \"\"\"\n    return downloader()\n\n\n@router.get(\"/schedule\", summary=\"后台服务\", response_model=List[schemas.ScheduleInfo])\nasync def schedule(_: schemas.TokenPayload = Depends(verify_token)) -> Any:\n    \"\"\"\n    查询后台服务信息\n    \"\"\"\n    return Scheduler().list()\n\n\n@router.get(\"/schedule2\", summary=\"后台服务（API_TOKEN）\", response_model=List[schemas.ScheduleInfo])\nasync def schedule2(_: Annotated[str, Depends(verify_apitoken)]) -> Any:\n    \"\"\"\n    查询下载器信息 API_TOKEN认证（?token=xxx）\n    \"\"\"\n    return await schedule()\n\n\n@router.get(\"/transfer\", summary=\"文件整理统计\", response_model=List[int])\nasync def transfer(days: Optional[int] = 7,\n                   db: Session = Depends(get_db),\n                   _: schemas.TokenPayload = Depends(verify_token)) -> Any:\n    \"\"\"\n    查询文件整理统计信息\n    \"\"\"\n    transfer_stat = await TransferHistory.async_statistic(db, days)\n    return [stat[1] for stat in transfer_stat]\n\n\n@router.get(\"/cpu\", summary=\"获取当前CPU使用率\", response_model=float)\ndef cpu(_: schemas.TokenPayload = Depends(verify_token)) -> Any:\n    \"\"\"\n    获取当前CPU使用率\n    \"\"\"\n    return SystemUtils.cpu_usage()\n\n\n@router.get(\"/cpu2\", summary=\"获取当前CPU使用率（API_TOKEN）\", response_model=float)\ndef cpu2(_: Annotated[str, Depends(verify_apitoken)]) -> Any:\n    \"\"\"\n    获取当前CPU使用率 API_TOKEN认证（?token=xxx）\n    \"\"\"\n    return cpu()\n\n\n@router.get(\"/memory\", summary=\"获取当前内存使用量和使用率\", response_model=List[int])\ndef memory(_: schemas.TokenPayload = Depends(verify_token)) -> Any:\n    \"\"\"\n    获取当前内存使用率\n    \"\"\"\n    return SystemUtils.memory_usage()\n\n\n@router.get(\"/memory2\", summary=\"获取当前内存使用量和使用率（API_TOKEN）\", response_model=List[int])\ndef memory2(_: Annotated[str, Depends(verify_apitoken)]) -> Any:\n    \"\"\"\n    获取当前内存使用率 API_TOKEN认证（?token=xxx）\n    \"\"\"\n    return memory()\n\n\n@router.get(\"/network\", summary=\"获取当前网络流量\", response_model=List[int])\ndef network(_: schemas.TokenPayload = Depends(verify_token)) -> Any:\n    \"\"\"\n    获取当前网络流量（上行和下行流量，单位：bytes/s）\n    \"\"\"\n    return SystemUtils.network_usage()\n\n\n@router.get(\"/network2\", summary=\"获取当前网络流量（API_TOKEN）\", response_model=List[int])\ndef network2(_: Annotated[str, Depends(verify_apitoken)]) -> Any:\n    \"\"\"\n    获取当前网络流量 API_TOKEN认证（?token=xxx）\n    \"\"\"\n    return network()\n"
  },
  {
    "path": "app/api/endpoints/discover.py",
    "content": "from typing import Any, List, Optional\n\nfrom fastapi import APIRouter, Depends\n\nfrom app import schemas\nfrom app.chain.bangumi import BangumiChain\nfrom app.chain.douban import DoubanChain\nfrom app.chain.tmdb import TmdbChain\nfrom app.core.event import eventmanager\nfrom app.core.security import verify_token\nfrom app.schemas import DiscoverSourceEventData\nfrom app.schemas.types import ChainEventType, MediaType\n\nrouter = APIRouter()\n\n\n@router.get(\"/source\", summary=\"获取探索数据源\", response_model=List[schemas.DiscoverMediaSource])\ndef source(_: schemas.TokenPayload = Depends(verify_token)) -> Any:\n    \"\"\"\n    获取探索数据源\n    \"\"\"\n    # 广播事件，请示额外的探索数据源支持\n    event_data = DiscoverSourceEventData()\n    event = eventmanager.send_event(ChainEventType.DiscoverSource, event_data)\n    # 使用事件返回的上下文数据\n    if event and event.event_data:\n        event_data: DiscoverSourceEventData = event.event_data\n        if event_data.extra_sources:\n            return event_data.extra_sources\n    return []\n\n\n@router.get(\"/bangumi\", summary=\"探索Bangumi\", response_model=List[schemas.MediaInfo])\nasync def bangumi(type: Optional[int] = 2,\n                  cat: Optional[int] = None,\n                  sort: Optional[str] = 'rank',\n                  year: Optional[str] = None,\n                  page: Optional[int] = 1,\n                  count: Optional[int] = 30,\n                  _: schemas.TokenPayload = Depends(verify_token)) -> Any:\n    \"\"\"\n    探索Bangumi\n    \"\"\"\n    medias = await BangumiChain().async_discover(type=type, cat=cat, sort=sort, year=year,\n                                                 limit=count, offset=(page - 1) * count)\n    if medias:\n        return [media.to_dict() for media in medias]\n    return []\n\n\n@router.get(\"/douban_movies\", summary=\"探索豆瓣电影\", response_model=List[schemas.MediaInfo])\nasync def douban_movies(sort: Optional[str] = \"R\",\n                        tags: Optional[str] = \"\",\n                        page: Optional[int] = 1,\n                        count: Optional[int] = 30,\n                        _: schemas.TokenPayload = Depends(verify_token)) -> Any:\n    \"\"\"\n    浏览豆瓣电影信息\n    \"\"\"\n    movies = await DoubanChain().async_douban_discover(mtype=MediaType.MOVIE,\n                                                       sort=sort, tags=tags, page=page, count=count)\n    return [media.to_dict() for media in movies] if movies else []\n\n\n@router.get(\"/douban_tvs\", summary=\"探索豆瓣剧集\", response_model=List[schemas.MediaInfo])\nasync def douban_tvs(sort: Optional[str] = \"R\",\n                     tags: Optional[str] = \"\",\n                     page: Optional[int] = 1,\n                     count: Optional[int] = 30,\n                     _: schemas.TokenPayload = Depends(verify_token)) -> Any:\n    \"\"\"\n    浏览豆瓣剧集信息\n    \"\"\"\n    tvs = await DoubanChain().async_douban_discover(mtype=MediaType.TV,\n                                                    sort=sort, tags=tags, page=page, count=count)\n    return [media.to_dict() for media in tvs] if tvs else []\n\n\n@router.get(\"/tmdb_movies\", summary=\"探索TMDB电影\", response_model=List[schemas.MediaInfo])\nasync def tmdb_movies(sort_by: Optional[str] = \"popularity.desc\",\n                      with_genres: Optional[str] = \"\",\n                      with_original_language: Optional[str] = \"\",\n                      with_keywords: Optional[str] = \"\",\n                      with_watch_providers: Optional[str] = \"\",\n                      vote_average: Optional[float] = 0.0,\n                      vote_count: Optional[int] = 0,\n                      release_date: Optional[str] = \"\",\n                      page: Optional[int] = 1,\n                      _: schemas.TokenPayload = Depends(verify_token)) -> Any:\n    \"\"\"\n    浏览TMDB电影信息\n    \"\"\"\n    movies = await TmdbChain().async_tmdb_discover(mtype=MediaType.MOVIE,\n                                                   sort_by=sort_by,\n                                                   with_genres=with_genres,\n                                                   with_original_language=with_original_language,\n                                                   with_keywords=with_keywords,\n                                                   with_watch_providers=with_watch_providers,\n                                                   vote_average=vote_average,\n                                                   vote_count=vote_count,\n                                                   release_date=release_date,\n                                                   page=page)\n    return [movie.to_dict() for movie in movies] if movies else []\n\n\n@router.get(\"/tmdb_tvs\", summary=\"探索TMDB剧集\", response_model=List[schemas.MediaInfo])\nasync def tmdb_tvs(sort_by: Optional[str] = \"popularity.desc\",\n                   with_genres: Optional[str] = \"\",\n                   with_original_language: Optional[str] = \"\",\n                   with_keywords: Optional[str] = \"\",\n                   with_watch_providers: Optional[str] = \"\",\n                   vote_average: Optional[float] = 0.0,\n                   vote_count: Optional[int] = 0,\n                   release_date: Optional[str] = \"\",\n                   page: Optional[int] = 1,\n                   _: schemas.TokenPayload = Depends(verify_token)) -> Any:\n    \"\"\"\n    浏览TMDB剧集信息\n    \"\"\"\n    tvs = await TmdbChain().async_tmdb_discover(mtype=MediaType.TV,\n                                                sort_by=sort_by,\n                                                with_genres=with_genres,\n                                                with_original_language=with_original_language,\n                                                with_keywords=with_keywords,\n                                                with_watch_providers=with_watch_providers,\n                                                vote_average=vote_average,\n                                                vote_count=vote_count,\n                                                release_date=release_date,\n                                                page=page)\n    return [tv.to_dict() for tv in tvs] if tvs else []\n"
  },
  {
    "path": "app/api/endpoints/douban.py",
    "content": "from typing import Any, List, Optional\n\nfrom fastapi import APIRouter, Depends\n\nfrom app import schemas\nfrom app.chain.douban import DoubanChain\nfrom app.core.context import MediaInfo\nfrom app.core.security import verify_token\nfrom app.schemas import MediaType\n\nrouter = APIRouter()\n\n\n@router.get(\"/person/{person_id}\", summary=\"人物详情\", response_model=schemas.MediaPerson)\nasync def douban_person(person_id: int,\n                        _: schemas.TokenPayload = Depends(verify_token)) -> Any:\n    \"\"\"\n    根据人物ID查询人物详情\n    \"\"\"\n    return await DoubanChain().async_person_detail(person_id=person_id)\n\n\n@router.get(\"/person/credits/{person_id}\", summary=\"人物参演作品\", response_model=List[schemas.MediaInfo])\nasync def douban_person_credits(person_id: int,\n                                page: Optional[int] = 1,\n                                _: schemas.TokenPayload = Depends(verify_token)) -> Any:\n    \"\"\"\n    根据人物ID查询人物参演作品\n    \"\"\"\n    medias = await DoubanChain().async_person_credits(person_id=person_id, page=page)\n    if medias:\n        return [media.to_dict() for media in medias]\n    return []\n\n\n@router.get(\"/credits/{doubanid}/{type_name}\", summary=\"豆瓣演员阵容\", response_model=List[schemas.MediaPerson])\nasync def douban_credits(doubanid: str,\n                         type_name: str,\n                         _: schemas.TokenPayload = Depends(verify_token)) -> Any:\n    \"\"\"\n    根据豆瓣ID查询演员阵容，type_name: 电影/电视剧\n    \"\"\"\n    mediatype = MediaType(type_name)\n    if mediatype == MediaType.MOVIE:\n        return await DoubanChain().async_movie_credits(doubanid=doubanid)\n    elif mediatype == MediaType.TV:\n        return await DoubanChain().async_tv_credits(doubanid=doubanid)\n    return []\n\n\n@router.get(\"/recommend/{doubanid}/{type_name}\", summary=\"豆瓣推荐电影/电视剧\", response_model=List[schemas.MediaInfo])\nasync def douban_recommend(doubanid: str,\n                           type_name: str,\n                           _: schemas.TokenPayload = Depends(verify_token)) -> Any:\n    \"\"\"\n    根据豆瓣ID查询推荐电影/电视剧，type_name: 电影/电视剧\n    \"\"\"\n    mediatype = MediaType(type_name)\n    if mediatype == MediaType.MOVIE:\n        medias = await DoubanChain().async_movie_recommend(doubanid=doubanid)\n    elif mediatype == MediaType.TV:\n        medias = await DoubanChain().async_tv_recommend(doubanid=doubanid)\n    else:\n        return []\n    if medias:\n        return [media.to_dict() for media in medias]\n    return []\n\n\n@router.get(\"/{doubanid}\", summary=\"查询豆瓣详情\", response_model=schemas.MediaInfo)\nasync def douban_info(doubanid: str,\n                      _: schemas.TokenPayload = Depends(verify_token)) -> Any:\n    \"\"\"\n    根据豆瓣ID查询豆瓣媒体信息\n    \"\"\"\n    doubaninfo = await DoubanChain().async_douban_info(doubanid=doubanid)\n    if doubaninfo:\n        return MediaInfo(douban_info=doubaninfo).to_dict()\n    else:\n        return schemas.MediaInfo()\n"
  },
  {
    "path": "app/api/endpoints/download.py",
    "content": "from typing import Any, List, Annotated, Optional\n\nfrom fastapi import APIRouter, Depends, Body\n\nfrom app import schemas\nfrom app.chain.download import DownloadChain\nfrom app.chain.media import MediaChain\nfrom app.core.config import settings\nfrom app.core.context import MediaInfo, Context, TorrentInfo\nfrom app.core.event import eventmanager\nfrom app.core.metainfo import MetaInfo\nfrom app.core.security import verify_token\nfrom app.db.models.user import User\nfrom app.db.systemconfig_oper import SystemConfigOper\nfrom app.db.user_oper import get_current_active_user\nfrom app.schemas.types import ChainEventType, SystemConfigKey\n\nrouter = APIRouter()\n\n\n@router.get(\"/\", summary=\"正在下载\", response_model=List[schemas.DownloadingTorrent])\ndef current(\n        name: Optional[str] = None,\n        _: schemas.TokenPayload = Depends(verify_token)) -> Any:\n    \"\"\"\n    查询正在下载的任务\n    \"\"\"\n    return DownloadChain().downloading(name)\n\n\n@router.post(\"/\", summary=\"添加下载（含媒体信息）\", response_model=schemas.Response)\ndef download(\n        media_in: schemas.MediaInfo,\n        torrent_in: schemas.TorrentInfo,\n        downloader: Annotated[str | None, Body()] = None,\n        save_path: Annotated[str | None, Body()] = None,\n        current_user: User = Depends(get_current_active_user)) -> Any:\n    \"\"\"\n    添加下载任务（含媒体信息）\n    \"\"\"\n    # 元数据\n    metainfo = MetaInfo(title=torrent_in.title, subtitle=torrent_in.description)\n    # 媒体信息\n    mediainfo = MediaInfo()\n    mediainfo.from_dict(media_in.model_dump())\n    # 种子信息\n    torrentinfo = TorrentInfo()\n    torrentinfo.from_dict(torrent_in.model_dump())\n    # 手动下载始终使用选择的下载器\n    torrentinfo.site_downloader = downloader\n    # 上下文\n    context = Context(\n        meta_info=metainfo,\n        media_info=mediainfo,\n        torrent_info=torrentinfo\n    )\n    did = DownloadChain().download_single(context=context, username=current_user.name,\n                                          save_path=save_path, source=\"Manual\")\n    if not did:\n        return schemas.Response(success=False, message=\"任务添加失败\")\n    return schemas.Response(success=True, data={\n        \"download_id\": did\n    })\n\n\n@router.post(\"/add\", summary=\"添加下载（不含媒体信息）\", response_model=schemas.Response)\ndef add(\n        torrent_in: schemas.TorrentInfo,\n        tmdbid: Annotated[int | None, Body()] = None,\n        doubanid: Annotated[str | None, Body()] = None,\n        downloader: Annotated[str | None, Body()] = None,\n        # 保存路径, 支持<storage>:<path>, 如rclone:/MP, smb:/server/share/Movies等\n        save_path: Annotated[str | None, Body()] = None,\n        current_user: User = Depends(get_current_active_user)) -> Any:\n    \"\"\"\n    添加下载任务（不含媒体信息）\n    \"\"\"\n    # 元数据\n    metainfo = MetaInfo(title=torrent_in.title, subtitle=torrent_in.description)\n    # 媒体信息\n    mediainfo = MediaChain().select_recognize_source(\n                    log_name=torrent_in.title,\n                    log_context=torrent_in.title,\n                    native_fn=lambda: MediaChain().recognize_media(meta=metainfo, tmdbid=tmdbid, doubanid=doubanid),\n                    plugin_fn=lambda: MediaChain().recognize_help(title=torrent_in.title, org_meta=metainfo)\n                )\n    if not mediainfo:\n        return schemas.Response(success=False, message=\"无法识别媒体信息\")\n    # 种子信息\n    torrentinfo = TorrentInfo()\n    torrentinfo.from_dict(torrent_in.model_dump())\n    # 上下文\n    context = Context(\n        meta_info=metainfo,\n        media_info=mediainfo,\n        torrent_info=torrentinfo\n    )\n\n    did = DownloadChain().download_single(context=context, username=current_user.name,\n                                          downloader=downloader, save_path=save_path, source=\"Manual\")\n    if not did:\n        return schemas.Response(success=False, message=\"任务添加失败\")\n    return schemas.Response(success=True, data={\n        \"download_id\": did\n    })\n\n\n@router.get(\"/start/{hashString}\", summary=\"开始任务\", response_model=schemas.Response)\ndef start(\n        hashString: str, name: Optional[str] = None,\n        _: schemas.TokenPayload = Depends(verify_token)) -> Any:\n    \"\"\"\n    开如下载任务\n    \"\"\"\n    ret = DownloadChain().set_downloading(hashString, \"start\", name=name)\n    return schemas.Response(success=True if ret else False)\n\n\n@router.get(\"/stop/{hashString}\", summary=\"暂停任务\", response_model=schemas.Response)\ndef stop(hashString: str, name: Optional[str] = None,\n         _: schemas.TokenPayload = Depends(verify_token)) -> Any:\n    \"\"\"\n    暂停下载任务\n    \"\"\"\n    ret = DownloadChain().set_downloading(hashString, \"stop\", name=name)\n    return schemas.Response(success=True if ret else False)\n\n\n@router.get(\"/clients\", summary=\"查询可用下载器\", response_model=List[dict])\nasync def clients(_: schemas.TokenPayload = Depends(verify_token)) -> Any:\n    \"\"\"\n    查询可用下载器\n    \"\"\"\n    downloaders: List[dict] = SystemConfigOper().get(SystemConfigKey.Downloaders)\n    if downloaders:\n        return [{\"name\": d.get(\"name\"), \"type\": d.get(\"type\")} for d in downloaders if d.get(\"enabled\")]\n    return []\n\n\n@router.delete(\"/{hashString}\", summary=\"删除下载任务\", response_model=schemas.Response)\ndef delete(hashString: str, name: Optional[str] = None,\n           _: schemas.TokenPayload = Depends(verify_token)) -> Any:\n    \"\"\"\n    删除下载任务\n    \"\"\"\n    ret = DownloadChain().remove_downloading(hashString, name=name)\n    return schemas.Response(success=True if ret else False)\n"
  },
  {
    "path": "app/api/endpoints/history.py",
    "content": "from typing import List, Any, Optional\n\nimport jieba\nfrom fastapi import APIRouter, Depends\nfrom sqlalchemy.ext.asyncio import AsyncSession\nfrom sqlalchemy.orm import Session\nfrom pathlib import Path\n\nfrom app import schemas\nfrom app.chain.storage import StorageChain\nfrom app.core.event import eventmanager\nfrom app.core.security import verify_token\nfrom app.db import get_async_db, get_db\nfrom app.db.models import User\nfrom app.db.models.downloadhistory import DownloadHistory, DownloadFiles\nfrom app.db.models.transferhistory import TransferHistory\nfrom app.db.user_oper import get_current_active_superuser_async, get_current_active_superuser\nfrom app.schemas.types import EventType\n\nrouter = APIRouter()\n\n\n@router.get(\"/download\", summary=\"查询下载历史记录\", response_model=List[schemas.DownloadHistory])\nasync def download_history(page: Optional[int] = 1,\n                           count: Optional[int] = 30,\n                           db: AsyncSession = Depends(get_async_db),\n                           _: schemas.TokenPayload = Depends(verify_token)) -> Any:\n    \"\"\"\n    查询下载历史记录\n    \"\"\"\n    return await DownloadHistory.async_list_by_page(db, page, count)\n\n\n@router.delete(\"/download\", summary=\"删除下载历史记录\", response_model=schemas.Response)\nasync def delete_download_history(history_in: schemas.DownloadHistory,\n                                  db: AsyncSession = Depends(get_async_db),\n                                  _: schemas.TokenPayload = Depends(verify_token)) -> Any:\n    \"\"\"\n    删除下载历史记录\n    \"\"\"\n    await DownloadHistory.async_delete(db, history_in.id)\n    return schemas.Response(success=True)\n\n\n@router.get(\"/transfer\", summary=\"查询整理记录\", response_model=schemas.Response)\nasync def transfer_history(title: Optional[str] = None,\n                           page: Optional[int] = 1,\n                           count: Optional[int] = 30,\n                           status: Optional[bool] = None,\n                           db: AsyncSession = Depends(get_async_db),\n                           _: schemas.TokenPayload = Depends(verify_token)) -> Any:\n    \"\"\"\n    查询整理记录\n    \"\"\"\n    if title == \"失败\":\n        title = None\n        status = False\n    elif title == \"成功\":\n        title = None\n        status = True\n\n    if title:\n        words = jieba.cut(title, HMM=False)\n        title = \"%\".join(words)\n        total = await TransferHistory.async_count_by_title(db, title=title, status=status)\n        result = await TransferHistory.async_list_by_title(db, title=title, page=page,\n                                                           count=count, status=status)\n    else:\n        result = await TransferHistory.async_list_by_page(db, page=page, count=count, status=status)\n        total = await TransferHistory.async_count(db, status=status)\n\n    return schemas.Response(success=True,\n                            data={\n                                \"list\": [item.to_dict() for item in result],\n                                \"total\": total,\n                            })\n\n\n@router.delete(\"/transfer\", summary=\"删除整理记录\", response_model=schemas.Response)\ndef delete_transfer_history(history_in: schemas.TransferHistory,\n                            deletesrc: Optional[bool] = False,\n                            deletedest: Optional[bool] = False,\n                            db: Session = Depends(get_db),\n                            _: User = Depends(get_current_active_superuser)) -> Any:\n    \"\"\"\n    删除整理记录\n    \"\"\"\n    history: TransferHistory = TransferHistory.get(db, history_in.id)\n    if not history:\n        return schemas.Response(success=False, message=\"记录不存在\")\n    # 册除媒体库文件\n    if deletedest and history.dest_fileitem:\n        dest_fileitem = schemas.FileItem(**history.dest_fileitem)\n        StorageChain().delete_media_file(dest_fileitem)\n\n    # 删除源文件\n    if deletesrc and history.src_fileitem:\n        src_fileitem = schemas.FileItem(**history.src_fileitem)\n        state = StorageChain().delete_media_file(src_fileitem)\n        if not state:\n            return schemas.Response(success=False, message=f\"{src_fileitem.path} 删除失败\")\n        # 删除下载记录中关联的文件\n        DownloadFiles.delete_by_fullpath(db, Path(src_fileitem.path).as_posix())\n        # 发送事件\n        eventmanager.send_event(\n            EventType.DownloadFileDeleted,\n            {\n                \"src\": history.src,\n                \"hash\": history.download_hash\n            }\n        )\n    # 删除记录\n    TransferHistory.delete(db, history_in.id)\n    return schemas.Response(success=True)\n\n\n@router.get(\"/empty/transfer\", summary=\"清空整理记录\", response_model=schemas.Response)\nasync def empty_transfer_history(db: AsyncSession = Depends(get_async_db),\n                                 _: User = Depends(get_current_active_superuser_async)) -> Any:\n    \"\"\"\n    清空整理记录\n    \"\"\"\n    await TransferHistory.async_truncate(db)\n    return schemas.Response(success=True)\n"
  },
  {
    "path": "app/api/endpoints/login.py",
    "content": "from datetime import timedelta\nfrom typing import Any, List, Annotated\n\nfrom fastapi import APIRouter, Depends, Form, HTTPException\nfrom fastapi.security import OAuth2PasswordRequestForm\n\nfrom app import schemas\nfrom app.chain.user import UserChain\nfrom app.core import security\nfrom app.core.config import settings\nfrom app.db.systemconfig_oper import SystemConfigOper\nfrom app.helper.sites import SitesHelper  # noqa\nfrom app.helper.image import WallpaperHelper\nfrom app.schemas.types import SystemConfigKey\n\nrouter = APIRouter()\n\n\n@router.post(\"/access-token\", summary=\"获取token\", response_model=schemas.Token)\ndef login_access_token(\n        form_data: Annotated[OAuth2PasswordRequestForm, Depends()],\n        otp_password: Annotated[str | None, Form()] = None\n) -> Any:\n    \"\"\"\n    获取认证Token\n    \"\"\"\n    success, user_or_message = UserChain().user_authenticate(username=form_data.username,\n                                                             password=form_data.password,\n                                                             mfa_code=otp_password)\n\n    if not success:\n        # 如果是需要MFA验证，返回特殊标识\n        if user_or_message == \"MFA_REQUIRED\":\n            raise HTTPException(\n                status_code=401,\n                detail=\"需要双重验证，请提供验证码或使用通行密钥\",\n                headers={\"X-MFA-Required\": \"true\"}\n            )\n        raise HTTPException(status_code=401, detail=\"用户名或密码错误\")\n\n    # 用户等级\n    level = SitesHelper().auth_level\n    # 是否显示配置向导\n    show_wizard = not SystemConfigOper().get(SystemConfigKey.SetupWizardState) and not settings.ADVANCED_MODE\n    return schemas.Token(\n        access_token=security.create_access_token(\n            userid=user_or_message.id,\n            username=user_or_message.name,\n            super_user=user_or_message.is_superuser,\n            expires_delta=timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES),\n            level=level\n        ),\n        token_type=\"bearer\",\n        super_user=user_or_message.is_superuser,\n        user_id=user_or_message.id,\n        user_name=user_or_message.name,\n        avatar=user_or_message.avatar,\n        level=level,\n        permissions=user_or_message.permissions or {},\n        wizard=show_wizard\n    )\n\n\n@router.get(\"/wallpaper\", summary=\"登录页面电影海报\", response_model=schemas.Response)\ndef wallpaper() -> Any:\n    \"\"\"\n    获取登录页面电影海报\n    \"\"\"\n    url = WallpaperHelper().get_wallpaper()\n    if url:\n        return schemas.Response(\n            success=True,\n            message=url\n        )\n    return schemas.Response(success=False)\n\n\n@router.get(\"/wallpapers\", summary=\"登录页面电影海报列表\", response_model=List[str])\ndef wallpapers() -> Any:\n    \"\"\"\n    获取登录页面电影海报\n    \"\"\"\n    return WallpaperHelper().get_wallpapers()\n"
  },
  {
    "path": "app/api/endpoints/mcp.py",
    "content": "from typing import List, Any, Dict, Annotated, Union\n\nfrom fastapi import APIRouter, Depends, HTTPException, Request\nfrom fastapi.responses import JSONResponse, Response\n\nfrom app import schemas\nfrom app.agent.tools.manager import moviepilot_tool_manager\nfrom app.core.security import verify_apikey\nfrom app.log import logger\n\n# 导入版本号\ntry:\n    from version import APP_VERSION\nexcept ImportError:\n    APP_VERSION = \"unknown\"\n\nrouter = APIRouter()\n\n# MCP 协议版本\nMCP_PROTOCOL_VERSIONS = [\"2025-11-25\", \"2025-06-18\", \"2024-11-05\"]\nMCP_PROTOCOL_VERSION = MCP_PROTOCOL_VERSIONS[0]  # 默认使用最新版本\nMCP_HIDDEN_TOOLS = {\"execute_command\", \"search_web\"}\n\n\ndef list_exposed_tools():\n    \"\"\"\n    获取 MCP 可见工具列表\n    \"\"\"\n    return [\n        tool for tool in moviepilot_tool_manager.list_tools()\n        if tool.name not in MCP_HIDDEN_TOOLS\n    ]\n\n\ndef create_jsonrpc_response(request_id: Union[str, int, None], result: Any) -> Dict[str, Any]:\n    \"\"\"\n    创建 JSON-RPC 成功响应\n    \"\"\"\n    response = {\n        \"jsonrpc\": \"2.0\",\n        \"id\": request_id,\n        \"result\": result\n    }\n    return response\n\n\ndef create_jsonrpc_error(request_id: Union[str, int, None], code: int, message: str, data: Any = None) -> Dict[\n    str, Any]:\n    \"\"\"\n    创建 JSON-RPC 错误响应\n    \"\"\"\n    error = {\n        \"jsonrpc\": \"2.0\",\n        \"id\": request_id,\n        \"error\": {\n            \"code\": code,\n            \"message\": message\n        }\n    }\n    if data is not None:\n        error[\"error\"][\"data\"] = data\n    return error\n\n\n@router.post(\"\", summary=\"MCP JSON-RPC 端点\", response_model=None)\nasync def mcp_jsonrpc(\n        request: Request,\n        _: Annotated[str, Depends(verify_apikey)] = None\n) -> Union[JSONResponse, Response]:\n    \"\"\"\n    MCP 标准 JSON-RPC 2.0 端点\n    \n    处理所有 MCP 协议消息（初始化、工具列表、工具调用等）\n    \"\"\"\n    try:\n        body = await request.json()\n    except Exception as e:\n        logger.error(f\"解析请求体失败: {e}\")\n        return JSONResponse(\n            status_code=400,\n            content=create_jsonrpc_error(None, -32700, \"Parse error\", str(e))\n        )\n\n    # 验证 JSON-RPC 格式\n    if not isinstance(body, dict) or body.get(\"jsonrpc\") != \"2.0\":\n        return JSONResponse(\n            status_code=400,\n            content=create_jsonrpc_error(body.get(\"id\"), -32600, \"Invalid Request\")\n        )\n\n    method = body.get(\"method\")\n    params = body.get(\"params\", {})\n    request_id = body.get(\"id\")\n\n    # 如果有 id，则为请求；没有 id 则为通知\n    is_notification = request_id is None\n\n    try:\n        # 处理初始化请求\n        if method == \"initialize\":\n            result = await handle_initialize(params)\n            return JSONResponse(content=create_jsonrpc_response(request_id, result))\n\n        # 处理已初始化通知\n        elif method == \"notifications/initialized\":\n            if is_notification:\n                return Response(status_code=204)\n            else:\n                return JSONResponse(\n                    status_code=400,\n                    content={\"error\": \"initialized must be a notification\"}\n                )\n\n        # 处理工具列表请求\n        if method == \"tools/list\":\n            result = await handle_tools_list()\n            return JSONResponse(content=create_jsonrpc_response(request_id, result))\n\n        # 处理工具调用请求\n        elif method == \"tools/call\":\n            result = await handle_tools_call(params)\n            return JSONResponse(content=create_jsonrpc_response(request_id, result))\n\n        # 处理 ping 请求\n        elif method == \"ping\":\n            return JSONResponse(content=create_jsonrpc_response(request_id, {}))\n\n        # 未知方法\n        else:\n            return JSONResponse(\n                content=create_jsonrpc_error(request_id, -32601, f\"Method not found: {method}\")\n            )\n\n    except ValueError as e:\n        logger.warning(f\"MCP 请求参数错误: {e}\")\n        return JSONResponse(\n            status_code=400,\n            content=create_jsonrpc_error(request_id, -32602, \"Invalid params\", str(e))\n        )\n    except Exception as e:\n        logger.error(f\"处理 MCP 请求失败: {e}\", exc_info=True)\n        return JSONResponse(\n            status_code=500,\n            content=create_jsonrpc_error(request_id, -32603, \"Internal error\", str(e))\n        )\n\n\nasync def handle_initialize(params: Dict[str, Any]) -> Dict[str, Any]:\n    \"\"\"\n    处理初始化请求\n    \"\"\"\n    protocol_version = params.get(\"protocolVersion\")\n    client_info = params.get(\"clientInfo\", {})\n\n    logger.info(f\"MCP 初始化请求: 客户端={client_info.get('name')}, 协议版本={protocol_version}\")\n\n    # 版本协商：选择客户端和服务器都支持的版本\n    negotiated_version = MCP_PROTOCOL_VERSION\n    if protocol_version in MCP_PROTOCOL_VERSIONS:\n        # 客户端版本在支持列表中，使用客户端版本\n        negotiated_version = protocol_version\n        logger.info(f\"使用客户端协议版本: {negotiated_version}\")\n    else:\n        # 客户端版本不支持，使用服务器默认版本\n        logger.warning(f\"协议版本不匹配: 客户端={protocol_version}, 使用服务器版本={negotiated_version}\")\n\n    return {\n        \"protocolVersion\": negotiated_version,\n        \"capabilities\": {\n            \"tools\": {\n                \"listChanged\": False  # 暂不支持工具列表变更通知\n            },\n            \"logging\": {}\n        },\n        \"serverInfo\": {\n            \"name\": \"MoviePilot\",\n            \"version\": APP_VERSION,\n            \"description\": \"MoviePilot MCP Server - 电影自动化管理工具\",\n        },\n        \"instructions\": \"MoviePilot MCP 服务器，提供媒体管理、订阅、下载等工具。\"\n    }\n\n\nasync def handle_tools_list() -> Dict[str, Any]:\n    \"\"\"\n    处理工具列表请求\n    \"\"\"\n    tools = list_exposed_tools()\n\n    # 转换为 MCP 工具格式\n    mcp_tools = []\n    for tool in tools:\n        mcp_tool = {\n            \"name\": tool.name,\n            \"description\": tool.description,\n            \"inputSchema\": tool.input_schema\n        }\n        mcp_tools.append(mcp_tool)\n\n    return {\n        \"tools\": mcp_tools\n    }\n\n\nasync def handle_tools_call(params: Dict[str, Any]) -> Dict[str, Any]:\n    \"\"\"\n    处理工具调用请求\n    \"\"\"\n    tool_name = params.get(\"name\")\n    arguments = params.get(\"arguments\", {})\n\n    if not tool_name:\n        raise ValueError(\"Missing tool name\")\n\n    try:\n        if tool_name in MCP_HIDDEN_TOOLS:\n            raise ValueError(f\"工具 '{tool_name}' 未找到\")\n\n        result_text = await moviepilot_tool_manager.call_tool(tool_name, arguments)\n\n        return {\n            \"content\": [\n                {\n                    \"type\": \"text\",\n                    \"text\": result_text\n                }\n            ]\n        }\n    except Exception as e:\n        logger.error(f\"工具调用失败: {tool_name}, 错误: {e}\", exc_info=True)\n        return {\n            \"content\": [\n                {\n                    \"type\": \"text\",\n                    \"text\": f\"错误: {str(e)}\"\n                }\n            ],\n            \"isError\": True\n        }\n\n\n@router.delete(\"\", summary=\"终止 MCP 会话\", response_model=None)\nasync def delete_mcp_session(\n        _: Annotated[str, Depends(verify_apikey)] = None\n) -> Union[JSONResponse, Response]:\n    \"\"\"\n    终止 MCP 会话（无状态模式下仅返回成功）\n    \"\"\"\n    return Response(status_code=204)\n\n\n# ==================== 兼容的 RESTful API 端点 ====================\n\n@router.get(\"/tools\", summary=\"列出所有可用工具\", response_model=List[Dict[str, Any]])\nasync def list_tools(\n        _: Annotated[str, Depends(verify_apikey)]\n) -> Any:\n    \"\"\"\n    获取所有可用的工具列表\n    \n    返回每个工具的名称、描述和参数定义\n    \"\"\"\n    try:\n        # 获取所有工具定义\n        tools = list_exposed_tools()\n\n        # 转换为字典格式\n        tools_list = []\n        for tool in tools:\n            tool_dict = {\n                \"name\": tool.name,\n                \"description\": tool.description,\n                \"inputSchema\": tool.input_schema\n            }\n            tools_list.append(tool_dict)\n\n        return tools_list\n    except Exception as e:\n        logger.error(f\"获取工具列表失败: {e}\", exc_info=True)\n        raise HTTPException(status_code=500, detail=f\"获取工具列表失败: {str(e)}\")\n\n\n@router.post(\"/tools/call\", summary=\"调用工具\", response_model=schemas.ToolCallResponse)\nasync def call_tool(\n        request: schemas.ToolCallRequest,\n        _: Annotated[str, Depends(verify_apikey)] = None\n) -> Any:\n    \"\"\"\n    调用指定的工具\n        \n    Returns:\n        工具执行结果\n    \"\"\"\n    try:\n        if request.tool_name in MCP_HIDDEN_TOOLS:\n            raise ValueError(f\"工具 '{request.tool_name}' 未找到\")\n\n        result_text = await moviepilot_tool_manager.call_tool(request.tool_name, request.arguments)\n\n        return schemas.ToolCallResponse(\n            success=True,\n            result=result_text\n        )\n    except Exception as e:\n        logger.error(f\"调用工具 {request.tool_name} 失败: {e}\", exc_info=True)\n        return schemas.ToolCallResponse(\n            success=False,\n            error=f\"调用工具失败: {str(e)}\"\n        )\n\n\n@router.get(\"/tools/{tool_name}\", summary=\"获取工具详情\", response_model=Dict[str, Any])\nasync def get_tool_info(\n        tool_name: str,\n        _: Annotated[str, Depends(verify_apikey)]\n) -> Any:\n    \"\"\"\n    获取指定工具的详细信息\n        \n    Returns:\n        工具的详细信息，包括名称、描述和参数定义\n    \"\"\"\n    try:\n        # 获取所有工具\n        tools = list_exposed_tools()\n\n        # 查找指定工具\n        for tool in tools:\n            if tool.name == tool_name:\n                return {\n                    \"name\": tool.name,\n                    \"description\": tool.description,\n                    \"inputSchema\": tool.input_schema\n                }\n\n        raise HTTPException(status_code=404, detail=f\"工具 '{tool_name}' 未找到\")\n    except HTTPException:\n        raise\n    except Exception as e:\n        logger.error(f\"获取工具信息失败: {e}\", exc_info=True)\n        raise HTTPException(status_code=500, detail=f\"获取工具信息失败: {str(e)}\")\n\n\n@router.get(\"/tools/{tool_name}/schema\", summary=\"获取工具参数Schema\", response_model=Dict[str, Any])\nasync def get_tool_schema(\n        tool_name: str,\n        _: Annotated[str, Depends(verify_apikey)]\n) -> Any:\n    \"\"\"\n    获取指定工具的参数Schema（JSON Schema格式）\n        \n    Returns:\n        工具的JSON Schema定义\n    \"\"\"\n    try:\n        # 获取所有工具\n        tools = list_exposed_tools()\n\n        # 查找指定工具\n        for tool in tools:\n            if tool.name == tool_name:\n                return tool.input_schema\n\n        raise HTTPException(status_code=404, detail=f\"工具 '{tool_name}' 未找到\")\n    except HTTPException:\n        raise\n    except Exception as e:\n        logger.error(f\"获取工具Schema失败: {e}\", exc_info=True)\n        raise HTTPException(status_code=500, detail=f\"获取工具Schema失败: {str(e)}\")\n"
  },
  {
    "path": "app/api/endpoints/media.py",
    "content": "from pathlib import Path\nfrom typing import List, Any, Union, Annotated, Optional\n\nfrom fastapi import APIRouter, Depends\n\nfrom app import schemas\nfrom app.chain.media import MediaChain\nfrom app.chain.tmdb import TmdbChain\nfrom app.core.config import settings\nfrom app.core.context import Context\nfrom app.core.event import eventmanager\nfrom app.core.metainfo import MetaInfo, MetaInfoPath\nfrom app.core.security import verify_token, verify_apitoken\nfrom app.db.models import User\nfrom app.db.user_oper import get_current_active_user, get_current_active_superuser\nfrom app.schemas import MediaType, MediaRecognizeConvertEventData\nfrom app.schemas.category import CategoryConfig\nfrom app.schemas.types import ChainEventType\n\nrouter = APIRouter()\n\n\n@router.get(\"/recognize\", summary=\"识别媒体信息（种子）\", response_model=schemas.Context)\nasync def recognize(title: str,\n                    subtitle: Optional[str] = None,\n                    _: schemas.TokenPayload = Depends(verify_token)) -> Any:\n    \"\"\"\n    根据标题、副标题识别媒体信息\n    \"\"\"\n    # 识别媒体信息\n    metainfo = MetaInfo(title, subtitle)\n    mediainfo = await MediaChain().async_recognize_by_meta(metainfo)\n    if mediainfo:\n        return Context(meta_info=metainfo, media_info=mediainfo).to_dict()\n    return schemas.Context()\n\n\n@router.get(\"/recognize2\", summary=\"识别种子媒体信息（API_TOKEN）\", response_model=schemas.Context)\nasync def recognize2(_: Annotated[str, Depends(verify_apitoken)],\n                     title: str,\n                     subtitle: Optional[str] = None\n                     ) -> Any:\n    \"\"\"\n    根据标题、副标题识别媒体信息 API_TOKEN认证（?token=xxx）\n    \"\"\"\n    # 识别媒体信息\n    return await recognize(title, subtitle)\n\n\n@router.get(\"/recognize_file\", summary=\"识别媒体信息（文件）\", response_model=schemas.Context)\nasync def recognize_file(path: str,\n                         _: schemas.TokenPayload = Depends(verify_token)) -> Any:\n    \"\"\"\n    根据文件路径识别媒体信息\n    \"\"\"\n    # 识别媒体信息\n    context = await MediaChain().async_recognize_by_path(path)\n    if context:\n        return context.to_dict()\n    return schemas.Context()\n\n\n@router.get(\"/recognize_file2\", summary=\"识别文件媒体信息（API_TOKEN）\", response_model=schemas.Context)\nasync def recognize_file2(path: str,\n                          _: Annotated[str, Depends(verify_apitoken)]) -> Any:\n    \"\"\"\n    根据文件路径识别媒体信息 API_TOKEN认证（?token=xxx）\n    \"\"\"\n    # 识别媒体信息\n    return await recognize_file(path)\n\n\n@router.get(\"/search\", summary=\"搜索媒体/人物信息\", response_model=List[dict])\nasync def search(title: str,\n                 type: Optional[str] = \"media\",\n                 page: int = 1,\n                 count: int = 8,\n                 _: schemas.TokenPayload = Depends(verify_token)) -> Any:\n    \"\"\"\n    模糊搜索媒体/人物信息列表 media：媒体信息，person：人物信息\n    \"\"\"\n\n    def __get_source(obj: Union[schemas.MediaInfo, schemas.MediaPerson, dict]):\n        \"\"\"\n        获取对象属性\n        \"\"\"\n        if isinstance(obj, dict):\n            return obj.get(\"source\")\n        return obj.source\n\n    media_chain = MediaChain()\n    if type == \"media\":\n        _, medias = await media_chain.async_search(title=title)\n        result = [media.to_dict() for media in medias] if medias else []\n    elif type == \"collection\":\n        collections = await media_chain.async_search_collections(name=title)\n        result = [collection.to_dict() for collection in collections] if collections else []\n    else:  # person\n        persons = await media_chain.async_search_persons(name=title)\n        result = [person.model_dump() for person in persons] if persons else []\n\n    if not result:\n        return []\n\n    # 排序和分页\n    setting_order = settings.SEARCH_SOURCE.split(',') if settings.SEARCH_SOURCE else []\n    sort_order = {source: index for index, source in enumerate(setting_order)}\n\n    sorted_result = sorted(result, key=lambda x: sort_order.get(__get_source(x), 4))\n    return sorted_result[(page - 1) * count:page * count]\n\n\n@router.post(\"/scrape/{storage}\", summary=\"刮削媒体信息\", response_model=schemas.Response)\ndef scrape(fileitem: schemas.FileItem,\n           storage: Optional[str] = \"local\",\n           _: schemas.TokenPayload = Depends(verify_token)) -> Any:\n    \"\"\"\n    刮削媒体信息\n    \"\"\"\n    if not fileitem or not fileitem.path:\n        return schemas.Response(success=False, message=\"刮削路径无效\")\n    chain = MediaChain()\n    # 识别媒体信息\n    scrape_path = Path(fileitem.path)\n    meta = MetaInfoPath(scrape_path)\n    mediainfo = chain.recognize_by_meta(meta)\n    if not mediainfo:\n        return schemas.Response(success=False, message=\"刮削失败，无法识别媒体信息\")\n    if storage == \"local\":\n        if not scrape_path.exists():\n            return schemas.Response(success=False, message=\"刮削路径不存在\")\n    # 手动刮削 (暂时使用同步版本，可以后续优化为异步)\n    chain.scrape_metadata(fileitem=fileitem, meta=meta, mediainfo=mediainfo, overwrite=True)\n    return schemas.Response(success=True, message=f\"{fileitem.path} 刮削完成\")\n\n\n@router.get(\"/category/config\", summary=\"获取分类策略配置\", response_model=schemas.Response)\ndef get_category_config(_: User = Depends(get_current_active_user)):\n    \"\"\"\n    获取分类策略配置\n    \"\"\"\n    config = MediaChain().category_config()\n    return schemas.Response(success=True, data=config.model_dump())\n\n\n@router.post(\"/category/config\", summary=\"保存分类策略配置\", response_model=schemas.Response)\ndef save_category_config(config: CategoryConfig, _: User = Depends(get_current_active_superuser)):\n    \"\"\"\n    保存分类策略配置\n    \"\"\"\n    if MediaChain().save_category_config(config):\n        return schemas.Response(success=True, message=\"保存成功\")\n    else:\n        return schemas.Response(success=False, message=\"保存失败\")\n\n\n@router.get(\"/category\", summary=\"查询自动分类配置\", response_model=dict)\nasync def category(_: schemas.TokenPayload = Depends(verify_token)) -> Any:\n    \"\"\"\n    查询自动分类配置\n    \"\"\"\n    return MediaChain().media_category() or {}\n\n\n@router.get(\"/group/seasons/{episode_group}\", summary=\"查询剧集组季信息\", response_model=List[schemas.MediaSeason])\nasync def group_seasons(episode_group: str, _: schemas.TokenPayload = Depends(verify_token)) -> Any:\n    \"\"\"\n    查询剧集组季信息（themoviedb）\n    \"\"\"\n    return await TmdbChain().async_tmdb_group_seasons(group_id=episode_group)\n\n\n@router.get(\"/groups/{tmdbid}\", summary=\"查询媒体剧集组\", response_model=List[dict])\nasync def groups(tmdbid: int, _: schemas.TokenPayload = Depends(verify_token)) -> Any:\n    \"\"\"\n    查询媒体剧集组列表（themoviedb）\n    \"\"\"\n    mediainfo = await MediaChain().async_recognize_media(tmdbid=tmdbid, mtype=MediaType.TV)\n    if not mediainfo:\n        return []\n    return mediainfo.episode_groups\n\n\n@router.get(\"/seasons\", summary=\"查询媒体季信息\", response_model=List[schemas.MediaSeason])\nasync def seasons(mediaid: Optional[str] = None,\n                  title: Optional[str] = None,\n                  year: str = None,\n                  season: int = None,\n                  _: schemas.TokenPayload = Depends(verify_token)) -> Any:\n    \"\"\"\n    查询媒体季信息\n    \"\"\"\n    if mediaid:\n        if mediaid.startswith(\"tmdb:\"):\n            tmdbid = int(mediaid[5:])\n            seasons_info = await TmdbChain().async_tmdb_seasons(tmdbid=tmdbid)\n            if seasons_info:\n                if season is not None:\n                    return [sea for sea in seasons_info if sea.season_number == season]\n                return seasons_info\n    if title:\n        meta = MetaInfo(title)\n        if year:\n            meta.year = year\n        mediainfo = await MediaChain().async_recognize_media(meta, mtype=MediaType.TV)\n        if mediainfo:\n            if settings.RECOGNIZE_SOURCE == \"themoviedb\":\n                seasons_info = await TmdbChain().async_tmdb_seasons(tmdbid=mediainfo.tmdb_id)\n                if seasons_info:\n                    if season is not None:\n                        return [sea for sea in seasons_info if sea.season_number == season]\n                    return seasons_info\n            else:\n                sea = season if season is not None else 1\n                return [schemas.MediaSeason(\n                    season_number=sea,\n                    poster_path=mediainfo.poster_path,\n                    name=f\"第 {sea} 季\",\n                    air_date=mediainfo.release_date,\n                    overview=mediainfo.overview,\n                    vote_average=mediainfo.vote_average,\n                    episode_count=mediainfo.number_of_episodes\n                )]\n    return []\n\n\n@router.get(\"/{mediaid}\", summary=\"查询媒体详情\", response_model=schemas.MediaInfo)\nasync def detail(mediaid: str, type_name: str, title: Optional[str] = None, year: str = None,\n                 _: schemas.TokenPayload = Depends(verify_token)) -> Any:\n    \"\"\"\n    根据媒体ID查询themoviedb或豆瓣媒体信息，type_name: 电影/电视剧\n    \"\"\"\n    mtype = MediaType(type_name)\n    mediainfo = None\n    mediachain = MediaChain()\n    if mediaid.startswith(\"tmdb:\"):\n        mediainfo = await mediachain.async_recognize_media(tmdbid=int(mediaid[5:]), mtype=mtype)\n    elif mediaid.startswith(\"douban:\"):\n        mediainfo = await mediachain.async_recognize_media(doubanid=mediaid[7:], mtype=mtype)\n    elif mediaid.startswith(\"bangumi:\"):\n        mediainfo = await mediachain.async_recognize_media(bangumiid=int(mediaid[8:]), mtype=mtype)\n    else:\n        # 广播事件解析媒体信息\n        event_data = MediaRecognizeConvertEventData(\n            mediaid=mediaid,\n            convert_type=settings.RECOGNIZE_SOURCE\n        )\n        event = await eventmanager.async_send_event(ChainEventType.MediaRecognizeConvert, event_data)\n        # 使用事件返回的上下文数据\n        if event and event.event_data and event.event_data.media_dict:\n            event_data: MediaRecognizeConvertEventData = event.event_data\n            new_id = event_data.media_dict.get(\"id\")\n            if event_data.convert_type == \"themoviedb\":\n                mediainfo = await mediachain.async_recognize_media(tmdbid=new_id, mtype=mtype)\n            elif event_data.convert_type == \"douban\":\n                mediainfo = await mediachain.async_recognize_media(doubanid=new_id, mtype=mtype)\n        elif title:\n            # 使用名称识别兜底\n            meta = MetaInfo(title)\n            if year:\n                meta.year = year\n            if mtype:\n                meta.type = mtype\n            mediainfo = await mediachain.async_recognize_media(meta=meta)\n    # 识别\n    if mediainfo:\n        await mediachain.async_obtain_images(mediainfo)\n        return mediainfo.to_dict()\n\n    return schemas.MediaInfo()\n"
  },
  {
    "path": "app/api/endpoints/mediaserver.py",
    "content": "from typing import Any, List, Dict, Optional\n\nfrom fastapi import APIRouter, Depends\nfrom sqlalchemy.ext.asyncio import AsyncSession\n\nfrom app import schemas\nfrom app.chain.download import DownloadChain\nfrom app.chain.mediaserver import MediaServerChain\nfrom app.core.context import MediaInfo\nfrom app.core.metainfo import MetaInfo\nfrom app.core.security import verify_token\nfrom app.db import get_async_db\nfrom app.db.mediaserver_oper import MediaServerOper\nfrom app.db.models import MediaServerItem\nfrom app.db.systemconfig_oper import SystemConfigOper\nfrom app.helper.mediaserver import MediaServerHelper\nfrom app.schemas import MediaType, NotExistMediaInfo\nfrom app.schemas.types import SystemConfigKey\n\nrouter = APIRouter()\n\n\n@router.get(\"/play/{itemid:path}\", summary=\"在线播放\")\ndef play_item(itemid: str, _: schemas.TokenPayload = Depends(verify_token)) -> schemas.Response:\n    \"\"\"\n    获取媒体服务器播放页面地址\n    \"\"\"\n    if not itemid:\n        return schemas.Response(success=False, message=\"参数错误\")\n    configs = MediaServerHelper().get_configs()\n    if not configs:\n        return schemas.Response(success=False, message=\"未配置媒体服务器\")\n    media_chain = MediaServerChain()\n    for name in configs.keys():\n        item = media_chain.iteminfo(server=name, item_id=itemid)\n        if item:\n            play_url = media_chain.get_play_url(server=name, item_id=itemid)\n            if play_url:\n                return schemas.Response(success=True, data={\n                    \"url\": play_url\n                })\n    return schemas.Response(success=False, message=\"未找到播放地址\")\n\n\n@router.get(\"/exists\", summary=\"查询本地是否存在（数据库）\", response_model=schemas.Response)\nasync def exists_local(title: Optional[str] = None,\n                       year: Optional[str] = None,\n                       mtype: Optional[str] = None,\n                       tmdbid: Optional[int] = None,\n                       season: Optional[int] = None,\n                       db: AsyncSession = Depends(get_async_db),\n                       _: schemas.TokenPayload = Depends(verify_token)) -> Any:\n    \"\"\"\n    判断本地是否存在\n    \"\"\"\n    meta = MetaInfo(title)\n    if season is None:\n        season = meta.begin_season\n    # 返回对象\n    ret_info = {}\n    # 本地数据库是否存在\n    exist: MediaServerItem = await MediaServerOper(db).async_exists(\n        title=meta.name, year=year, mtype=mtype, tmdbid=tmdbid, season=season\n    )\n    if exist:\n        ret_info = {\n            \"id\": exist.item_id\n        }\n    return schemas.Response(success=True if exist else False, data={\n        \"item\": ret_info\n    })\n\n\n@router.post(\"/exists_remote\", summary=\"查询已存在的剧集信息（媒体服务器）\", response_model=Dict[int, list])\ndef exists(media_in: schemas.MediaInfo,\n           _: schemas.TokenPayload = Depends(verify_token)) -> Any:\n    \"\"\"\n    根据媒体信息查询媒体库已存在的剧集信息\n    \"\"\"\n    # 转化为媒体信息对象\n    mediainfo = MediaInfo()\n    mediainfo.from_dict(media_in.model_dump())\n    existsinfo: schemas.ExistMediaInfo = MediaServerChain().media_exists(mediainfo=mediainfo)\n    if not existsinfo:\n        return {}\n    if media_in.season is not None:\n        return {\n            media_in.season: existsinfo.seasons.get(media_in.season) or []\n        }\n    return existsinfo.seasons\n\n\n@router.post(\"/notexists\", summary=\"查询媒体库缺失信息（媒体服务器）\", response_model=List[schemas.NotExistMediaInfo])\ndef not_exists(media_in: schemas.MediaInfo,\n               _: schemas.TokenPayload = Depends(verify_token)) -> Any:\n    \"\"\"\n    根据媒体信息查询缺失电影/剧集\n    \"\"\"\n    # 媒体信息\n    meta = MetaInfo(title=media_in.title)\n    mtype = MediaType(media_in.type) if media_in.type else None\n    if mtype:\n        meta.type = mtype\n    if media_in.season is not None:\n        meta.begin_season = media_in.season\n        meta.type = MediaType.TV\n    if media_in.year:\n        meta.year = media_in.year\n    # 转化为媒体信息对象\n    mediainfo = MediaInfo()\n    mediainfo.from_dict(media_in.model_dump())\n    exist_flag, no_exists = DownloadChain().get_no_exists_info(meta=meta, mediainfo=mediainfo)\n    mediakey = mediainfo.tmdb_id or mediainfo.douban_id\n    if mediainfo.type == MediaType.MOVIE:\n        # 电影已存在时返回空列表，不存在时返回空对像列表\n        return [] if exist_flag else [NotExistMediaInfo()]\n    elif no_exists and no_exists.get(mediakey):\n        # 电视剧返回缺失的剧集\n        return list(no_exists.get(mediakey).values())\n    return []\n\n\n@router.get(\"/latest\", summary=\"最新入库条目\", response_model=List[schemas.MediaServerPlayItem])\ndef latest(server: str, count: Optional[int] = 20,\n           userinfo: schemas.TokenPayload = Depends(verify_token)) -> Any:\n    \"\"\"\n    获取媒体服务器最新入库条目\n    \"\"\"\n    return MediaServerChain().latest(server=server, count=count, username=userinfo.username) or []\n\n\n@router.get(\"/playing\", summary=\"正在播放条目\", response_model=List[schemas.MediaServerPlayItem])\ndef playing(server: str, count: Optional[int] = 12,\n            userinfo: schemas.TokenPayload = Depends(verify_token)) -> Any:\n    \"\"\"\n    获取媒体服务器正在播放条目\n    \"\"\"\n    return MediaServerChain().playing(server=server, count=count, username=userinfo.username) or []\n\n\n@router.get(\"/library\", summary=\"媒体库列表\", response_model=List[schemas.MediaServerLibrary])\ndef library(server: str, hidden: Optional[bool] = False,\n            userinfo: schemas.TokenPayload = Depends(verify_token)) -> Any:\n    \"\"\"\n    获取媒体服务器媒体库列表\n    \"\"\"\n    return MediaServerChain().librarys(server=server, username=userinfo.username, hidden=hidden) or []\n\n\n@router.get(\"/clients\", summary=\"查询可用媒体服务器\", response_model=List[dict])\nasync def clients(_: schemas.TokenPayload = Depends(verify_token)) -> Any:\n    \"\"\"\n    查询可用媒体服务器\n    \"\"\"\n    mediaservers: List[dict] = SystemConfigOper().get(SystemConfigKey.MediaServers)\n    if mediaservers:\n        return [{\"name\": d.get(\"name\"), \"type\": d.get(\"type\")} for d in mediaservers if d.get(\"enabled\")]\n    return []\n"
  },
  {
    "path": "app/api/endpoints/message.py",
    "content": "import json\nfrom typing import Union, Any, List, Optional\n\nfrom fastapi import APIRouter, BackgroundTasks, Depends, Request\nfrom pywebpush import WebPushException, webpush\nfrom sqlalchemy.ext.asyncio import AsyncSession\nfrom starlette.responses import PlainTextResponse\n\nfrom app import schemas\nfrom app.chain.message import MessageChain\nfrom app.core.config import settings, global_vars\nfrom app.core.security import verify_token, verify_apitoken\nfrom app.db import get_async_db\nfrom app.db.models import User\nfrom app.db.models.message import Message\nfrom app.db.user_oper import get_current_active_superuser\nfrom app.helper.service import ServiceConfigHelper\nfrom app.log import logger\nfrom app.modules.wechat.WXBizMsgCrypt3 import WXBizMsgCrypt\nfrom app.schemas.types import MessageChannel\n\nrouter = APIRouter()\n\n\ndef start_message_chain(body: Any, form: Any, args: Any):\n    \"\"\"\n    启动链式任务\n    \"\"\"\n    MessageChain().process(body=body, form=form, args=args)\n\n\n@router.post(\"/\", summary=\"接收用户消息\", response_model=schemas.Response)\nasync def user_message(background_tasks: BackgroundTasks, request: Request,\n                       _: schemas.TokenPayload = Depends(verify_apitoken)):\n    \"\"\"\n    用户消息响应，配置请求中需要添加参数：token=API_TOKEN&source=消息配置名\n    \"\"\"\n    body = await request.body()\n    form = await request.form()\n    args = request.query_params\n    background_tasks.add_task(start_message_chain, body, form, args)\n    return schemas.Response(success=True)\n\n\n@router.post(\"/web\", summary=\"接收WEB消息\", response_model=schemas.Response)\ndef web_message(text: str, current_user: User = Depends(get_current_active_superuser)):\n    \"\"\"\n    WEB消息响应\n    \"\"\"\n    MessageChain().handle_message(\n        channel=MessageChannel.Web,\n        source=current_user.name,\n        userid=current_user.name,\n        username=current_user.name,\n        text=text\n    )\n    return schemas.Response(success=True)\n\n\n@router.get(\"/web\", summary=\"获取WEB消息\", response_model=List[dict])\nasync def get_web_message(_: schemas.TokenPayload = Depends(verify_token),\n                          db: AsyncSession = Depends(get_async_db),\n                          page: Optional[int] = 1,\n                          count: Optional[int] = 20):\n    \"\"\"\n    获取WEB消息列表\n    \"\"\"\n    ret_messages = []\n    messages = await Message.async_list_by_page(db, page=page, count=count)\n    for message in messages:\n        try:\n            ret_messages.append(message.to_dict())\n        except Exception as e:\n            logger.error(f\"获取WEB消息列表失败: {str(e)}\")\n            continue\n    return ret_messages\n\n\ndef wechat_verify(echostr: str, msg_signature: str, timestamp: Union[str, int], nonce: str,\n                  source: Optional[str] = None) -> Any:\n    \"\"\"\n    微信验证响应\n    \"\"\"\n    # 获取服务配置\n    client_configs = ServiceConfigHelper.get_notification_configs()\n    if not client_configs:\n        return \"未找到对应的消息配置\"\n    client_config = next((config for config in client_configs if\n                          config.type == \"wechat\"\n                          and config.enabled\n                          and config.config.get(\"WECHAT_MODE\", \"app\") != \"bot\"\n                          and (not source or config.name == source)), None)\n    if not client_config:\n        return \"未找到对应的消息配置\"\n    try:\n        wxcpt = WXBizMsgCrypt(sToken=client_config.config.get('WECHAT_TOKEN'),\n                              sEncodingAESKey=client_config.config.get('WECHAT_ENCODING_AESKEY'),\n                              sReceiveId=client_config.config.get('WECHAT_CORPID'))\n        ret, sEchoStr = wxcpt.VerifyURL(sMsgSignature=msg_signature,\n                                        sTimeStamp=timestamp,\n                                        sNonce=nonce,\n                                        sEchoStr=echostr)\n        if ret == 0:\n            # 验证URL成功，将sEchoStr返回给企业号\n            return PlainTextResponse(sEchoStr)\n        return \"微信验证失败\"\n    except Exception as err:\n        logger.error(f\"微信请求验证失败: {str(err)}\")\n        return str(err)\n\n\ndef vocechat_verify() -> Any:\n    \"\"\"\n    VoceChat验证响应\n    \"\"\"\n    return {\"status\": \"OK\"}\n\n\n@router.get(\"/\", summary=\"回调请求验证\")\ndef incoming_verify(token: Optional[str] = None, echostr: Optional[str] = None, msg_signature: Optional[str] = None,\n                    timestamp: Union[str, int] = None, nonce: Optional[str] = None, source: Optional[str] = None,\n                    _: schemas.TokenPayload = Depends(verify_apitoken)) -> Any:\n    \"\"\"\n    微信/VoceChat等验证响应\n    \"\"\"\n    logger.info(f\"收到验证请求: token={token}, echostr={echostr}, \"\n                f\"msg_signature={msg_signature}, timestamp={timestamp}, nonce={nonce}\")\n    if echostr and msg_signature and timestamp and nonce:\n        return wechat_verify(echostr, msg_signature, timestamp, nonce, source)\n    return vocechat_verify()\n\n\n@router.post(\"/webpush/subscribe\", summary=\"客户端webpush通知订阅\", response_model=schemas.Response)\nasync def subscribe(subscription: schemas.Subscription, _: schemas.TokenPayload = Depends(verify_token)):\n    \"\"\"\n    客户端webpush通知订阅\n    \"\"\"\n    subinfo = subscription.model_dump()\n    if subinfo not in global_vars.get_subscriptions():\n        global_vars.push_subscription(subinfo)\n    logger.debug(f\"通知订阅成功: {subinfo}\")\n    return schemas.Response(success=True)\n\n\n@router.post(\"/webpush/send\", summary=\"发送webpush通知\", response_model=schemas.Response)\ndef send_notification(payload: schemas.SubscriptionMessage, _: schemas.TokenPayload = Depends(verify_token)):\n    \"\"\"\n    发送webpush通知\n    \"\"\"\n    for sub in global_vars.get_subscriptions():\n        try:\n            webpush(\n                subscription_info=sub,\n                data=json.dumps(payload.model_dump()),\n                vapid_private_key=settings.VAPID.get(\"privateKey\"),\n                vapid_claims={\n                    \"sub\": settings.VAPID.get(\"subject\")\n                },\n            )\n        except WebPushException as err:\n            logger.error(f\"WebPush发送失败: {str(err)}\")\n            continue\n    return schemas.Response(success=True)\n"
  },
  {
    "path": "app/api/endpoints/mfa.py",
    "content": "\"\"\"\nMFA (Multi-Factor Authentication) API 端点\n包含 OTP 和 PassKey 相关功能\n\"\"\"\nfrom datetime import timedelta\nfrom typing import Any, Annotated, Optional\n\nfrom app.helper.sites import SitesHelper\nfrom fastapi import APIRouter, Depends, HTTPException, Body\nfrom sqlalchemy.ext.asyncio import AsyncSession\n\nfrom app import schemas\nfrom app.core import security\nfrom app.core.config import settings\nfrom app.db import get_async_db\nfrom app.db.models.passkey import PassKey\nfrom app.db.models.user import User\nfrom app.db.systemconfig_oper import SystemConfigOper\nfrom app.db.user_oper import get_current_active_user, get_current_active_user_async\nfrom app.helper.passkey import PassKeyHelper\nfrom app.log import logger\nfrom app.schemas.types import SystemConfigKey\nfrom app.utils.otp import OtpUtils\n\nrouter = APIRouter()\n\n# ==================== 辅助函数 ====================\n\ndef _build_credential_list(passkeys: list[PassKey]) -> list[dict[str, Any]]:\n    \"\"\"\n    构建凭证列表\n\n    :param passkeys: PassKey 列表\n    :return: 凭证字典列表\n    \"\"\"\n    return [\n        {\n            'credential_id': pk.credential_id,\n            'transports': pk.transports\n        }\n        for pk in passkeys\n    ] if passkeys else []\n\n\ndef _extract_and_standardize_credential_id(credential: dict) -> str:\n    \"\"\"\n    从凭证中提取并标准化 credential_id\n\n    :param credential: 凭证字典\n    :return: 标准化后的 credential_id\n    :raises ValueError: 如果凭证无效\n    \"\"\"\n    credential_id_raw = credential.get('id') or credential.get('rawId')\n    if not credential_id_raw:\n        raise ValueError(\"无效的凭证\")\n    return PassKeyHelper.standardize_credential_id(credential_id_raw)\n\n\ndef _verify_passkey_and_update(\n    credential: dict,\n    challenge: str,\n    passkey: PassKey\n) -> tuple[bool, int]:\n    \"\"\"\n    验证 PassKey 并更新使用时间和签名计数\n\n    :param credential: 凭证字典\n    :param challenge: 挑战值\n    :param passkey: PassKey 对象\n    :return: (验证是否成功, 新的签名计数)\n    \"\"\"\n    success, new_sign_count = PassKeyHelper.verify_authentication_response(\n        credential=credential,\n        expected_challenge=challenge,\n        credential_public_key=passkey.public_key,\n        credential_current_sign_count=passkey.sign_count\n    )\n\n    if success:\n        passkey.update_last_used(db=None, sign_count=new_sign_count)\n\n    return success, new_sign_count\n\n\nasync def _check_user_has_passkey(db: AsyncSession, user_id: int) -> bool:\n    \"\"\"\n    检查用户是否有 PassKey\n\n    :param db: 数据库会话\n    :param user_id: 用户 ID\n    :return: 是否有 PassKey\n    \"\"\"\n    return bool(await PassKey.async_get_by_user_id(db=db, user_id=user_id))\n\n\n# ==================== 请求模型 ====================\n\nclass OtpVerifyRequest(schemas.BaseModel):\n    \"\"\"OTP验证请求\"\"\"\n    uri: str\n    otpPassword: str\n\nclass OtpDisableRequest(schemas.BaseModel):\n    \"\"\"OTP禁用请求\"\"\"\n    password: str\n\nclass PassKeyDeleteRequest(schemas.BaseModel):\n    \"\"\"PassKey删除请求\"\"\"\n    passkey_id: int\n    password: str\n\n# ==================== 通用 MFA 接口 ====================\n\n@router.get('/status/{username}', summary='判断用户是否开启双重验证(MFA)', response_model=schemas.Response)\nasync def mfa_status(username: str, db: AsyncSession = Depends(get_async_db)) -> Any:\n    \"\"\"\n    检查指定用户是否启用了任何双重验证方式（OTP 或 PassKey）\n    \"\"\"\n    user: User = await User.async_get_by_name(db, username)\n    if not user:\n        return schemas.Response(success=False)\n    \n    # 检查是否启用了OTP\n    has_otp = user.is_otp\n    \n    # 检查是否有PassKey\n    has_passkey = await _check_user_has_passkey(db, user.id)\n    \n    # 只要有任何一种验证方式，就需要双重验证\n    return schemas.Response(success=(has_otp or has_passkey))\n\n\n# ==================== OTP 相关接口 ====================\n\n@router.post('/otp/generate', summary='生成 OTP 验证 URI', response_model=schemas.Response)\ndef otp_generate(\n    current_user: Annotated[User, Depends(get_current_active_user)]\n) -> Any:\n    \"\"\"生成 OTP 密钥及对应的 URI\"\"\"\n    secret, uri = OtpUtils.generate_secret_key(current_user.name)\n    return schemas.Response(success=secret != \"\", data={'secret': secret, 'uri': uri})\n\n\n@router.post('/otp/verify', summary='绑定并验证 OTP', response_model=schemas.Response)\nasync def otp_verify(\n    data: OtpVerifyRequest,\n    db: AsyncSession = Depends(get_async_db),\n    current_user: User = Depends(get_current_active_user_async)\n) -> Any:\n    \"\"\"验证用户输入的 OTP 码，验证通过后正式开启 OTP 验证\"\"\"\n    if not OtpUtils.is_legal(data.uri, data.otpPassword):\n        return schemas.Response(success=False, message=\"验证码错误\")\n    await current_user.async_update_otp_by_name(db, current_user.name, True, OtpUtils.get_secret(data.uri))\n    return schemas.Response(success=True)\n\n\n@router.post('/otp/disable', summary='关闭当前用户的 OTP 验证', response_model=schemas.Response)\nasync def otp_disable(\n    data: OtpDisableRequest,\n    db: AsyncSession = Depends(get_async_db),\n    current_user: User = Depends(get_current_active_user_async)\n) -> Any:\n    \"\"\"关闭当前用户的 OTP 验证功能\"\"\"\n    # 安全检查：如果存在 PassKey，默认不允许关闭 OTP，除非配置允许\n    has_passkey = await _check_user_has_passkey(db, current_user.id)\n    if has_passkey and not settings.PASSKEY_ALLOW_REGISTER_WITHOUT_OTP:\n        return schemas.Response(\n            success=False,\n            message=\"您已注册通行密钥，为了防止域名配置变更导致无法登录，请先删除所有通行密钥再关闭 OTP 验证\"\n        )\n\n    # 验证密码\n    if not security.verify_password(data.password, str(current_user.hashed_password)):\n        return schemas.Response(success=False, message=\"密码错误\")\n    await current_user.async_update_otp_by_name(db, current_user.name, False, \"\")\n    return schemas.Response(success=True)\n\n\n# ==================== PassKey 相关接口 ====================\n\nclass PassKeyRegistrationStart(schemas.BaseModel):\n    \"\"\"PassKey注册开始请求\"\"\"\n    name: str = \"通行密钥\"\n\n\nclass PassKeyRegistrationFinish(schemas.BaseModel):\n    \"\"\"PassKey注册完成请求\"\"\"\n    credential: dict\n    challenge: str\n    name: str = \"通行密钥\"\n\n\nclass PassKeyAuthenticationStart(schemas.BaseModel):\n    \"\"\"PassKey认证开始请求\"\"\"\n    username: Optional[str] = None\n\n\nclass PassKeyAuthenticationFinish(schemas.BaseModel):\n    \"\"\"PassKey认证完成请求\"\"\"\n    credential: dict\n    challenge: str\n\n\n@router.post(\"/passkey/register/start\", summary=\"开始注册 PassKey\", response_model=schemas.Response)\ndef passkey_register_start(\n    current_user: Annotated[User, Depends(get_current_active_user)]\n) -> Any:\n    \"\"\"开始注册 PassKey - 生成注册选项\"\"\"\n    try:\n        # 安全检查：默认需要先启用 OTP，除非配置允许在未启用 OTP 时注册\n        if not current_user.is_otp and not settings.PASSKEY_ALLOW_REGISTER_WITHOUT_OTP:\n            return schemas.Response(\n                success=False,\n                message=\"为了确保在域名配置错误时仍能找回访问权限，请先启用 OTP 验证码再注册通行密钥\"\n            )\n\n        # 获取用户已有的PassKey\n        existing_passkeys = PassKey.get_by_user_id(db=None, user_id=current_user.id)\n        existing_credentials = _build_credential_list(existing_passkeys) if existing_passkeys else None\n\n        # 生成注册选项\n        options_json, challenge = PassKeyHelper.generate_registration_options(\n            user_id=current_user.id,\n            username=current_user.name,\n            display_name=current_user.settings.get('nickname') if current_user.settings else None,\n            existing_credentials=existing_credentials\n        )\n\n        return schemas.Response(\n            success=True,\n            data={\n                'options': options_json,\n                'challenge': challenge\n            }\n        )\n    except Exception as e:\n        logger.error(f\"生成PassKey注册选项失败: {e}\")\n        return schemas.Response(\n            success=False,\n            message=f\"生成注册选项失败: {str(e)}\"\n        )\n\n\n@router.post(\"/passkey/register/finish\", summary=\"完成注册 PassKey\", response_model=schemas.Response)\ndef passkey_register_finish(\n    passkey_req: PassKeyRegistrationFinish,\n    current_user: Annotated[User, Depends(get_current_active_user)]\n) -> Any:\n    \"\"\"完成注册 PassKey - 验证并保存凭证\"\"\"\n    try:\n        # 验证注册响应\n        credential_id, public_key, sign_count, aaguid = PassKeyHelper.verify_registration_response(\n            credential=passkey_req.credential,\n            expected_challenge=passkey_req.challenge\n        )\n\n        # 提取transports\n        transports = None\n        if 'response' in passkey_req.credential and 'transports' in passkey_req.credential['response']:\n            transports = ','.join(passkey_req.credential['response']['transports'])\n\n        # 保存到数据库\n        passkey = PassKey(\n            user_id=current_user.id,\n            credential_id=credential_id,\n            public_key=public_key,\n            sign_count=sign_count,\n            name=passkey_req.name or \"通行密钥\",\n            aaguid=aaguid,\n            transports=transports\n        )\n        passkey.create()\n\n        logger.info(f\"用户 {current_user.name} 成功注册PassKey: {passkey_req.name}\")\n\n        return schemas.Response(\n            success=True,\n            message=\"通行密钥注册成功\"\n        )\n    except Exception as e:\n        logger.error(f\"注册PassKey失败: {e}\")\n        return schemas.Response(\n            success=False,\n            message=f\"注册失败: {str(e)}\"\n        )\n\n\n@router.post(\"/passkey/authenticate/start\", summary=\"开始 PassKey 认证\", response_model=schemas.Response)\ndef passkey_authenticate_start(\n    passkey_req: PassKeyAuthenticationStart = Body(...)\n) -> Any:\n    \"\"\"开始 PassKey 认证 - 生成认证选项\"\"\"\n    try:\n        existing_credentials = None\n        \n        # 如果指定了用户名，只允许该用户的PassKey\n        if passkey_req.username:\n            user = User.get_by_name(db=None, name=passkey_req.username)\n            existing_passkeys = PassKey.get_by_user_id(db=None, user_id=user.id) if user else None\n\n            if not user or not existing_passkeys:\n                return schemas.Response(\n                    success=False,\n                    message=\"认证失败\"\n                )\n\n            existing_credentials = _build_credential_list(existing_passkeys)\n\n        # 生成认证选项\n        options_json, challenge = PassKeyHelper.generate_authentication_options(\n            existing_credentials=existing_credentials\n        )\n\n        return schemas.Response(\n            success=True,\n            data={\n                'options': options_json,\n                'challenge': challenge\n            }\n        )\n    except Exception as e:\n        logger.error(f\"生成PassKey认证选项失败: {e}\")\n        return schemas.Response(\n            success=False,\n            message=\"认证失败\"\n        )\n\n\n@router.post(\"/passkey/authenticate/finish\", summary=\"完成 PassKey 认证\", response_model=schemas.Token)\ndef passkey_authenticate_finish(\n    passkey_req: PassKeyAuthenticationFinish\n) -> Any:\n    \"\"\"完成 PassKey 认证 - 验证凭证并返回 token\"\"\"\n    try:\n        # 提取并标准化凭证ID\n        try:\n            credential_id = _extract_and_standardize_credential_id(passkey_req.credential)\n        except ValueError as e:\n            logger.warning(f\"PassKey认证失败，提供的凭证无效: {e}\")\n            raise HTTPException(status_code=401, detail=\"认证失败\")\n\n        # 查找PassKey并获取用户\n        passkey = PassKey.get_by_credential_id(db=None, credential_id=credential_id)\n        user = User.get_by_id(db=None, user_id=passkey.user_id) if passkey else None\n        if not passkey or not user or not user.is_active:\n            raise HTTPException(status_code=401, detail=\"认证失败\")\n\n        # 验证认证响应并更新\n        success, _ = _verify_passkey_and_update(\n            credential=passkey_req.credential,\n            challenge=passkey_req.challenge,\n            passkey=passkey\n        )\n\n        if not success:\n            raise HTTPException(status_code=401, detail=\"认证失败\")\n\n        logger.info(f\"用户 {user.name} 通过PassKey认证成功\")\n\n        # 生成token\n        level = SitesHelper().auth_level\n        show_wizard = not SystemConfigOper().get(SystemConfigKey.SetupWizardState) and not settings.ADVANCED_MODE\n\n        return schemas.Token(\n            access_token=security.create_access_token(\n                userid=user.id,\n                username=user.name,\n                super_user=user.is_superuser,\n                expires_delta=timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES),\n                level=level\n            ),\n            token_type=\"bearer\",\n            super_user=user.is_superuser,\n            user_id=user.id,\n            user_name=user.name,\n            avatar=user.avatar,\n            level=level,\n            permissions=user.permissions or {},\n            wizard=show_wizard\n        )\n    except HTTPException:\n        raise\n    except Exception as e:\n        logger.error(f\"PassKey认证失败: {e}\")\n        raise HTTPException(status_code=401, detail=\"认证失败\")\n\n\n@router.get(\"/passkey/list\", summary=\"获取当前用户的 PassKey 列表\", response_model=schemas.Response)\ndef passkey_list(\n    current_user: Annotated[User, Depends(get_current_active_user)]\n) -> Any:\n    \"\"\"获取当前用户的所有 PassKey\"\"\"\n    try:\n        passkeys = PassKey.get_by_user_id(db=None, user_id=current_user.id)\n        \n        key_list = [\n            {\n                'id': pk.id,\n                'name': pk.name,\n                'created_at': pk.created_at.isoformat() if pk.created_at else None,\n                'last_used_at': pk.last_used_at.isoformat() if pk.last_used_at else None,\n                'aaguid': pk.aaguid,\n                'transports': pk.transports\n            }\n            for pk in passkeys\n        ] if passkeys else []\n\n        return schemas.Response(\n            success=True,\n            data=key_list\n        )\n    except Exception as e:\n        logger.error(f\"获取PassKey列表失败: {e}\")\n        return schemas.Response(\n            success=False,\n            message=f\"获取列表失败: {str(e)}\"\n        )\n\n\n@router.post(\"/passkey/delete\", summary=\"删除 PassKey\", response_model=schemas.Response)\nasync def passkey_delete(\n    data: PassKeyDeleteRequest,\n    current_user: User = Depends(get_current_active_user_async)\n) -> Any:\n    \"\"\"删除指定的 PassKey\"\"\"\n    try:\n        # 验证密码\n        if not security.verify_password(data.password, str(current_user.hashed_password)):\n            return schemas.Response(success=False, message=\"密码错误\")\n\n        success = PassKey.delete_by_id(db=None, passkey_id=data.passkey_id, user_id=current_user.id)\n        \n        if success:\n            logger.info(f\"用户 {current_user.name} 删除了PassKey: {data.passkey_id}\")\n            return schemas.Response(\n                success=True,\n                message=\"通行密钥已删除\"\n            )\n        else:\n            return schemas.Response(\n                success=False,\n                message=\"通行密钥不存在或无权删除\"\n            )\n    except Exception as e:\n        logger.error(f\"删除PassKey失败: {e}\")\n        return schemas.Response(\n            success=False,\n            message=f\"删除失败: {str(e)}\"\n        )\n\n\n@router.post(\"/passkey/verify\", summary=\"PassKey 二次验证\", response_model=schemas.Response)\ndef passkey_verify_mfa(\n    passkey_req: PassKeyAuthenticationFinish,\n    current_user: Annotated[User, Depends(get_current_active_user)]\n) -> Any:\n    \"\"\"使用 PassKey 进行二次验证（MFA）\"\"\"\n    try:\n        # 提取并标准化凭证ID\n        try:\n            credential_id = _extract_and_standardize_credential_id(passkey_req.credential)\n        except ValueError as e:\n            logger.warning(f\"PassKey二次验证失败，提供的凭证无效: {e}\")\n            return schemas.Response(success=False, message=\"验证失败\")\n\n        # 查找PassKey（必须属于当前用户）\n        passkey = PassKey.get_by_credential_id(db=None, credential_id=credential_id)\n        if not passkey or passkey.user_id != current_user.id:\n            return schemas.Response(\n                success=False,\n                message=\"通行密钥不存在或不属于当前用户\"\n            )\n\n        # 验证认证响应并更新\n        success, _ = _verify_passkey_and_update(\n            credential=passkey_req.credential,\n            challenge=passkey_req.challenge,\n            passkey=passkey\n        )\n\n        if not success:\n            return schemas.Response(\n                success=False,\n                message=\"通行密钥验证失败\"\n            )\n\n        logger.info(f\"用户 {current_user.name} 通过PassKey二次验证成功\")\n\n        return schemas.Response(\n            success=True,\n            message=\"二次验证成功\"\n        )\n    except Exception as e:\n        logger.error(f\"PassKey二次验证失败: {e}\")\n        return schemas.Response(\n            success=False,\n            message=\"验证失败\"\n        )\n"
  },
  {
    "path": "app/api/endpoints/plugin.py",
    "content": "import mimetypes\nimport shutil\nfrom typing import Annotated, Any, List, Optional\n\nimport aiofiles\nfrom anyio import Path as AsyncPath\nfrom fastapi import APIRouter, Depends, Header, HTTPException\nfrom fastapi.concurrency import run_in_threadpool\nfrom starlette import status\nfrom starlette.responses import StreamingResponse\n\nfrom app import schemas\nfrom app.command import Command\nfrom app.core.config import settings\nfrom app.core.plugin import PluginManager\nfrom app.core.security import verify_apikey, verify_token\nfrom app.db.models import User\nfrom app.db.systemconfig_oper import SystemConfigOper\nfrom app.db.user_oper import get_current_active_superuser, get_current_active_superuser_async\nfrom app.factory import app\nfrom app.helper.plugin import PluginHelper\nfrom app.log import logger\nfrom app.scheduler import Scheduler\nfrom app.schemas.types import SystemConfigKey\n\nPROTECTED_ROUTES = {\"/api/v1/openapi.json\", \"/docs\", \"/docs/oauth2-redirect\", \"/redoc\"}\nPLUGIN_PREFIX = f\"{settings.API_V1_STR}/plugin\"\n\nrouter = APIRouter()\n\n\ndef register_plugin_api(plugin_id: Optional[str] = None):\n    \"\"\"\n    动态注册插件 API\n    :param plugin_id: 插件 ID，如果为 None，则注册所有插件\n    \"\"\"\n    _update_plugin_api_routes(plugin_id, action=\"add\")\n\n\ndef remove_plugin_api(plugin_id: str):\n    \"\"\"\n    动态移除单个插件的 API\n    :param plugin_id: 插件 ID\n    \"\"\"\n    _update_plugin_api_routes(plugin_id, action=\"remove\")\n\n\ndef _update_plugin_api_routes(plugin_id: Optional[str], action: str):\n    \"\"\"\n    插件 API 路由注册和移除\n    :param plugin_id: 插件 ID，如果 action 为 \"add\" 且 plugin_id 为 None，则处理所有插件\n                      如果 action 为 \"remove\"，plugin_id 必须是有效的插件 ID\n    :param action: \"add\" 或 \"remove\"，决定是添加还是移除路由\n    \"\"\"\n    if action not in {\"add\", \"remove\"}:\n        raise ValueError(\"Action must be 'add' or 'remove'\")\n\n    is_modified = False\n    existing_paths = {route.path: route for route in app.routes}\n\n    plugin_ids = [plugin_id] if plugin_id else PluginManager().get_running_plugin_ids()\n    for plugin_id in plugin_ids:\n        routes_removed = _remove_routes(plugin_id)\n        if routes_removed:\n            is_modified = True\n\n        if action != \"add\":\n            continue\n        # 获取插件的 API 路由信息\n        plugin_apis = PluginManager().get_plugin_apis(plugin_id)\n        for api in plugin_apis:\n            api_path = f\"{PLUGIN_PREFIX}{api.get('path', '')}\"\n            try:\n                api[\"path\"] = api_path\n                allow_anonymous = api.pop(\"allow_anonymous\", False)\n                auth_mode = api.pop(\"auth\", \"apikey\")\n                dependencies = api.setdefault(\"dependencies\", [])\n                if not allow_anonymous:\n                    if auth_mode == \"bear\" and Depends(verify_token) not in dependencies:\n                        dependencies.append(Depends(verify_token))\n                    elif Depends(verify_apikey) not in dependencies:\n                        dependencies.append(Depends(verify_apikey))\n                app.add_api_route(**api, tags=[\"plugin\"])\n                is_modified = True\n                logger.debug(f\"Added plugin route: {api_path}\")\n            except Exception as e:\n                logger.error(f\"Error adding plugin route {api_path}: {str(e)}\")\n\n    if is_modified:\n        _clean_protected_routes(existing_paths)\n        app.openapi_schema = None\n        app.setup()\n\n\ndef _remove_routes(plugin_id: str) -> bool:\n    \"\"\"\n    移除与单个插件相关的路由\n    :param plugin_id: 插件 ID\n    :return: 是否有路由被移除\n    \"\"\"\n    if not plugin_id:\n        return False\n    prefix = f\"{PLUGIN_PREFIX}/{plugin_id}/\"\n    routes_to_remove = [route for route in app.routes if route.path.startswith(prefix)]\n    removed = False\n    for route in routes_to_remove:\n        try:\n            app.routes.remove(route)\n            removed = True\n            logger.debug(f\"Removed plugin route: {route.path}\")\n        except Exception as e:\n            logger.error(f\"Error removing plugin route {route.path}: {str(e)}\")\n    return removed\n\n\ndef _clean_protected_routes(existing_paths: dict):\n    \"\"\"\n    清理受保护的路由，防止在插件操作中被删除或重复添加\n    :param existing_paths: 当前应用的路由路径映射\n    \"\"\"\n    for protected_route in PROTECTED_ROUTES:\n        try:\n            existing_route = existing_paths.get(protected_route)\n            if existing_route:\n                app.routes.remove(existing_route)\n        except Exception as e:\n            logger.error(f\"Error removing protected route {protected_route}: {str(e)}\")\n\n\ndef register_plugin(plugin_id: str):\n    \"\"\"\n    注册一个插件相关的服务\n    \"\"\"\n    # 注册插件服务\n    Scheduler().update_plugin_job(plugin_id)\n    # 注册菜单命令\n    Command().init_commands(plugin_id)\n    # 注册插件API\n    register_plugin_api(plugin_id)\n\n\n@router.get(\"/\", summary=\"所有插件\", response_model=List[schemas.Plugin])\nasync def all_plugins(_: User = Depends(get_current_active_superuser_async),\n                      state: Optional[str] = \"all\", force: bool = False) -> List[schemas.Plugin]:\n    \"\"\"\n    查询所有插件清单，包括本地插件和在线插件，插件状态：installed, market, all\n    \"\"\"\n    # 本地插件\n    plugin_manager = PluginManager()\n    local_plugins = plugin_manager.get_local_plugins()\n    # 已安装插件\n    installed_plugins = [plugin for plugin in local_plugins if plugin.installed]\n    if state == \"installed\":\n        return installed_plugins\n\n    # 未安装的本地插件\n    not_installed_plugins = [plugin for plugin in local_plugins if not plugin.installed]\n    # 在线插件\n    online_plugins = await plugin_manager.async_get_online_plugins(force)\n    if not online_plugins:\n        # 没有获取在线插件\n        if state == \"market\":\n            # 返回未安装的本地插件\n            return not_installed_plugins\n        return local_plugins\n\n    # 插件市场插件清单\n    market_plugins = []\n    # 已安装插件IDS\n    _installed_ids = [plugin.id for plugin in installed_plugins]\n    # 未安装的线上插件或者有更新的插件\n    for plugin in online_plugins:\n        if plugin.id not in _installed_ids:\n            market_plugins.append(plugin)\n        elif plugin.has_update:\n            market_plugins.append(plugin)\n    # 未安装的本地插件，且不在线上插件中\n    _plugin_ids = [plugin.id for plugin in market_plugins]\n    for plugin in not_installed_plugins:\n        if plugin.id not in _plugin_ids:\n            market_plugins.append(plugin)\n    # 返回插件清单\n    if state == \"market\":\n        # 返回未安装的插件\n        return market_plugins\n\n    # 返回所有插件\n    return installed_plugins + market_plugins\n\n\n@router.get(\"/installed\", summary=\"已安装插件\", response_model=List[str])\nasync def installed(_: User = Depends(get_current_active_superuser_async)) -> Any:\n    \"\"\"\n    查询用户已安装插件清单\n    \"\"\"\n    return SystemConfigOper().get(SystemConfigKey.UserInstalledPlugins) or []\n\n\n@router.get(\"/statistic\", summary=\"插件安装统计\", response_model=dict)\nasync def statistic(_: schemas.TokenPayload = Depends(verify_token)) -> Any:\n    \"\"\"\n    插件安装统计\n    \"\"\"\n    return await PluginHelper().async_get_statistic()\n\n\n@router.get(\"/reload/{plugin_id}\", summary=\"重新加载插件\", response_model=schemas.Response)\ndef reload_plugin(plugin_id: str, _: User = Depends(get_current_active_superuser)) -> Any:\n    \"\"\"\n    重新加载插件\n    \"\"\"\n    # 重新加载插件\n    PluginManager().reload_plugin(plugin_id)\n    # 注册插件服务\n    register_plugin(plugin_id)\n    return schemas.Response(success=True)\n\n\n@router.get(\"/install/{plugin_id}\", summary=\"安装插件\", response_model=schemas.Response)\nasync def install(plugin_id: str,\n                  repo_url: Optional[str] = \"\",\n                  force: Optional[bool] = False,\n                  _: User = Depends(get_current_active_superuser_async)) -> Any:\n    \"\"\"\n    安装插件\n    \"\"\"\n    # 已安装插件\n    install_plugins = SystemConfigOper().get(SystemConfigKey.UserInstalledPlugins) or []\n    # 首先检查插件是否已经存在，并且是否强制安装，否则只进行安装统计\n    plugin_helper = PluginHelper()\n    if not force and plugin_id in PluginManager().get_plugin_ids():\n        await plugin_helper.async_install_reg(pid=plugin_id)\n    else:\n        # 插件不存在或需要强制安装，下载安装并注册插件\n        if repo_url:\n            state, msg = await plugin_helper.async_install(pid=plugin_id, repo_url=repo_url)\n            # 安装失败则直接响应\n            if not state:\n                return schemas.Response(success=False, message=msg)\n        else:\n            # repo_url 为空时，也直接响应\n            return schemas.Response(success=False, message=\"没有传入仓库地址，无法正确安装插件，请检查配置\")\n    # 安装插件\n    if plugin_id not in install_plugins:\n        install_plugins.append(plugin_id)\n        # 保存设置\n        await SystemConfigOper().async_set(SystemConfigKey.UserInstalledPlugins, install_plugins)\n    # 重新加载插件\n    await run_in_threadpool(reload_plugin, plugin_id)\n    return schemas.Response(success=True)\n\n\n@router.get(\"/remotes\", summary=\"获取插件联邦组件列表\", response_model=List[dict])\nasync def remotes(token: str) -> Any:\n    \"\"\"\n    获取插件联邦组件列表\n    \"\"\"\n    if token != \"moviepilot\":\n        raise HTTPException(status_code=403, detail=\"Forbidden\")\n    return PluginManager().get_plugin_remotes()\n\n\n@router.get(\"/form/{plugin_id}\", summary=\"获取插件表单页面\")\ndef plugin_form(plugin_id: str,\n                _: User = Depends(get_current_active_superuser)) -> dict:\n    \"\"\"\n    根据插件ID获取插件配置表单或Vue组件URL\n    \"\"\"\n    plugin_manager = PluginManager()\n    plugin_instance = plugin_manager.running_plugins.get(plugin_id)\n    if not plugin_instance:\n        raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f\"插件 {plugin_id} 不存在或未加载\")\n\n    # 渲染模式\n    render_mode, _ = plugin_instance.get_render_mode()\n    try:\n        conf, model = plugin_instance.get_form()\n        return {\n            \"render_mode\": render_mode,\n            \"conf\": conf,\n            \"model\": plugin_manager.get_plugin_config(plugin_id) or model\n        }\n    except Exception as e:\n        logger.error(f\"插件 {plugin_id} 调用方法 get_form 出错: {str(e)}\")\n    return {}\n\n\n@router.get(\"/page/{plugin_id}\", summary=\"获取插件数据页面\")\ndef plugin_page(plugin_id: str, _: User = Depends(get_current_active_superuser)) -> dict:\n    \"\"\"\n    根据插件ID获取插件数据页面\n    \"\"\"\n    plugin_instance = PluginManager().running_plugins.get(plugin_id)\n    if not plugin_instance:\n        raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f\"插件 {plugin_id} 不存在或未加载\")\n\n    # 渲染模式\n    render_mode, _ = plugin_instance.get_render_mode()\n    try:\n        page = plugin_instance.get_page()\n        return {\n            \"render_mode\": render_mode,\n            \"page\": page or []\n        }\n    except Exception as e:\n        logger.error(f\"插件 {plugin_id} 调用方法 get_page 出错: {str(e)}\")\n    return {}\n\n\n@router.get(\"/dashboard/meta\", summary=\"获取所有插件仪表板元信息\")\ndef plugin_dashboard_meta(_: schemas.TokenPayload = Depends(verify_token)) -> List[dict]:\n    \"\"\"\n    获取所有插件仪表板元信息\n    \"\"\"\n    return PluginManager().get_plugin_dashboard_meta()\n\n\n@router.get(\"/dashboard/{plugin_id}/{key}\", summary=\"获取插件仪表板配置\")\ndef plugin_dashboard_by_key(plugin_id: str, key: str, user_agent: Annotated[str | None, Header()] = None,\n                            _: schemas.TokenPayload = Depends(verify_token)) -> Optional[schemas.PluginDashboard]:\n    \"\"\"\n    根据插件ID获取插件仪表板\n    \"\"\"\n    return PluginManager().get_plugin_dashboard(plugin_id, key, user_agent)\n\n\n@router.get(\"/dashboard/{plugin_id}\", summary=\"获取插件仪表板配置\")\ndef plugin_dashboard(plugin_id: str, user_agent: Annotated[str | None, Header()] = None,\n                     _: schemas.TokenPayload = Depends(verify_token)) -> schemas.PluginDashboard:\n    \"\"\"\n    根据插件ID获取插件仪表板\n    \"\"\"\n    return plugin_dashboard_by_key(plugin_id, \"\", user_agent)\n\n\n@router.get(\"/reset/{plugin_id}\", summary=\"重置插件配置及数据\", response_model=schemas.Response)\ndef reset_plugin(plugin_id: str,\n                 _: User = Depends(get_current_active_superuser)) -> Any:\n    \"\"\"\n    根据插件ID重置插件配置及数据\n    \"\"\"\n    plugin_manager = PluginManager()\n    # 删除配置\n    plugin_manager.delete_plugin_config(plugin_id)\n    # 删除插件所有数据\n    plugin_manager.delete_plugin_data(plugin_id)\n    # 重新加载插件\n    reload_plugin(plugin_id)\n    return schemas.Response(success=True)\n\n\n@router.get(\"/file/{plugin_id}/{filepath:path}\", summary=\"获取插件静态文件\")\nasync def plugin_static_file(plugin_id: str, filepath: str):\n    \"\"\"\n    获取插件静态文件\n    \"\"\"\n    # 基础安全检查\n    if \"..\" in filepath or \"..\" in plugin_id:\n        logger.warning(f\"Static File API: Path traversal attempt detected: {plugin_id}/{filepath}\")\n        raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=\"Forbidden\")\n\n    plugin_base_dir = AsyncPath(settings.ROOT_PATH) / \"app\" / \"plugins\" / plugin_id.lower()\n    plugin_file_path = plugin_base_dir / filepath.lstrip('/')\n\n    try:\n        resolved_base = await plugin_base_dir.resolve()\n        resolved_file = await plugin_file_path.resolve()\n    except Exception:\n        raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=\"Invalid path\")\n\n    if not resolved_file.is_relative_to(resolved_base):\n        logger.warning(f\"Static File API: Path traversal attempt detected: {plugin_id}/{filepath}\")\n        raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=\"Forbidden\")\n\n    if not await plugin_file_path.exists():\n        raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f\"{plugin_file_path} 不存在\")\n    if not await plugin_file_path.is_file():\n        raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f\"{plugin_file_path} 不是文件\")\n\n    # 判断 MIME 类型\n    response_type, _ = mimetypes.guess_type(str(plugin_file_path))\n    suffix = plugin_file_path.suffix.lower()\n    # 强制修正 .mjs 和 .js 的 MIME 类型\n    if suffix in ['.js', '.mjs']:\n        response_type = 'application/javascript'\n    elif suffix == '.css' and not response_type:  # 如果 guess_type 没猜对 css，也修正\n        response_type = 'text/css'\n    elif not response_type:  # 对于其他猜不出的类型\n        response_type = 'application/octet-stream'\n\n    try:\n        # 异步生成器函数，用于流式读取文件\n        async def file_generator():\n            async with aiofiles.open(plugin_file_path, mode='rb') as file:\n                # 8KB 块大小\n                while chunk := await file.read(8192):\n                    yield chunk\n\n        return StreamingResponse(\n            file_generator(),\n            media_type=response_type,\n            headers={\"Content-Disposition\": f\"inline; filename={plugin_file_path.name}\"}\n        )\n    except Exception as e:\n        logger.error(f\"Error creating/sending StreamingResponse for {plugin_file_path}: {e}\", exc_info=True)\n        raise HTTPException(status_code=500, detail=\"Internal Server Error\")\n\n\n@router.get(\"/folders\", summary=\"获取插件文件夹配置\", response_model=dict)\nasync def get_plugin_folders(_: User = Depends(get_current_active_superuser_async)) -> dict:\n    \"\"\"\n    获取插件文件夹分组配置\n    \"\"\"\n    try:\n        result = SystemConfigOper().get(SystemConfigKey.PluginFolders) or {}\n        return result\n    except Exception as e:\n        logger.error(f\"[文件夹API] 获取文件夹配置失败: {str(e)}\")\n        return {}\n\n\n@router.post(\"/folders\", summary=\"保存插件文件夹配置\", response_model=schemas.Response)\nasync def save_plugin_folders(folders: dict, _: User = Depends(get_current_active_superuser_async)) -> Any:\n    \"\"\"\n    保存插件文件夹分组配置\n    \"\"\"\n    try:\n        SystemConfigOper().set(SystemConfigKey.PluginFolders, folders)\n        return schemas.Response(success=True)\n    except Exception as e:\n        logger.error(f\"[文件夹API] 保存文件夹配置失败: {str(e)}\")\n        return schemas.Response(success=False, message=str(e))\n\n\n@router.post(\"/folders/{folder_name}\", summary=\"创建插件文件夹\", response_model=schemas.Response)\nasync def create_plugin_folder(folder_name: str,\n                               _: User = Depends(get_current_active_superuser_async)) -> Any:\n    \"\"\"\n    创建新的插件文件夹\n    \"\"\"\n    folders = SystemConfigOper().get(SystemConfigKey.PluginFolders) or {}\n    if folder_name not in folders:\n        folders[folder_name] = []\n        SystemConfigOper().set(SystemConfigKey.PluginFolders, folders)\n        return schemas.Response(success=True, message=f\"文件夹 '{folder_name}' 创建成功\")\n    else:\n        return schemas.Response(success=False, message=f\"文件夹 '{folder_name}' 已存在\")\n\n\n@router.delete(\"/folders/{folder_name}\", summary=\"删除插件文件夹\", response_model=schemas.Response)\nasync def delete_plugin_folder(folder_name: str,\n                               _: User = Depends(get_current_active_superuser_async)) -> Any:\n    \"\"\"\n    删除插件文件夹\n    \"\"\"\n    folders = SystemConfigOper().get(SystemConfigKey.PluginFolders) or {}\n    if folder_name in folders:\n        del folders[folder_name]\n        await SystemConfigOper().async_set(SystemConfigKey.PluginFolders, folders)\n        return schemas.Response(success=True, message=f\"文件夹 '{folder_name}' 删除成功\")\n    else:\n        return schemas.Response(success=False, message=f\"文件夹 '{folder_name}' 不存在\")\n\n\n@router.put(\"/folders/{folder_name}/plugins\", summary=\"更新文件夹中的插件\", response_model=schemas.Response)\nasync def update_folder_plugins(folder_name: str, plugin_ids: List[str],\n                                _: User = Depends(get_current_active_superuser_async)) -> Any:\n    \"\"\"\n    更新指定文件夹中的插件列表\n    \"\"\"\n    folders = SystemConfigOper().get(SystemConfigKey.PluginFolders) or {}\n    folders[folder_name] = plugin_ids\n    await SystemConfigOper().async_set(SystemConfigKey.PluginFolders, folders)\n    return schemas.Response(success=True, message=f\"文件夹 '{folder_name}' 中的插件已更新\")\n\n\n@router.post(\"/clone/{plugin_id}\", summary=\"创建插件分身\", response_model=schemas.Response)\ndef clone_plugin(plugin_id: str,\n                 clone_data: dict,\n                 _: User = Depends(get_current_active_superuser)) -> Any:\n    \"\"\"\n    创建插件分身\n    \"\"\"\n    try:\n        success, message = PluginManager().clone_plugin(\n            plugin_id=plugin_id,\n            suffix=clone_data.get(\"suffix\", \"\"),\n            name=clone_data.get(\"name\", \"\"),\n            description=clone_data.get(\"description\", \"\"),\n            version=clone_data.get(\"version\", \"\"),\n            icon=clone_data.get(\"icon\", \"\")\n        )\n\n        if success:\n            # 注册插件服务\n            reload_plugin(message)\n            # 将分身插件添加到原插件所在的文件夹中\n            _add_clone_to_plugin_folder(plugin_id, message)\n            return schemas.Response(success=True, message=\"插件分身创建成功\")\n        else:\n            return schemas.Response(success=False, message=message)\n    except Exception as e:\n        logger.error(f\"创建插件分身失败：{str(e)}\")\n        return schemas.Response(success=False, message=f\"创建插件分身失败：{str(e)}\")\n\n\n@router.get(\"/{plugin_id}\", summary=\"获取插件配置\")\nasync def plugin_config(plugin_id: str,\n                        _: User = Depends(get_current_active_superuser_async)) -> dict:\n    \"\"\"\n    根据插件ID获取插件配置信息\n    \"\"\"\n    return PluginManager().get_plugin_config(plugin_id)\n\n\n@router.put(\"/{plugin_id}\", summary=\"更新插件配置\", response_model=schemas.Response)\ndef set_plugin_config(plugin_id: str, conf: dict,\n                      _: User = Depends(get_current_active_superuser)) -> Any:\n    \"\"\"\n    更新插件配置\n    \"\"\"\n    plugin_manager = PluginManager()\n    # 保存配置\n    plugin_manager.save_plugin_config(plugin_id, conf)\n    # 重新生效插件\n    plugin_manager.init_plugin(plugin_id, conf)\n    # 注册插件服务\n    register_plugin(plugin_id)\n    return schemas.Response(success=True)\n\n\n@router.delete(\"/{plugin_id}\", summary=\"卸载插件\", response_model=schemas.Response)\ndef uninstall_plugin(plugin_id: str,\n                     _: User = Depends(get_current_active_superuser)) -> Any:\n    \"\"\"\n    卸载插件\n    \"\"\"\n    config_oper = SystemConfigOper()\n    # 删除已安装信息\n    install_plugins = config_oper.get(SystemConfigKey.UserInstalledPlugins) or []\n    for plugin in install_plugins:\n        if plugin == plugin_id:\n            install_plugins.remove(plugin)\n            break\n    config_oper.set(SystemConfigKey.UserInstalledPlugins, install_plugins)\n    # 移除插件API\n    remove_plugin_api(plugin_id)\n    # 移除插件服务\n    Scheduler().remove_plugin_job(plugin_id)\n    # 判断是否为分身\n    plugin_manager = PluginManager()\n    plugin_class = plugin_manager.plugins.get(plugin_id)\n    if getattr(plugin_class, \"is_clone\", False):\n        # 如果是分身插件，则删除分身数据和配置\n        plugin_manager.delete_plugin_config(plugin_id)\n        plugin_manager.delete_plugin_data(plugin_id)\n        # 删除分身文件\n        plugin_base_dir = settings.ROOT_PATH / \"app\" / \"plugins\" / plugin_id.lower()\n        if plugin_base_dir.exists():\n            try:\n                shutil.rmtree(plugin_base_dir)\n                plugin_manager.plugins.pop(plugin_id, None)\n            except Exception as e:\n                logger.error(f\"删除插件分身目录 {plugin_base_dir} 失败: {str(e)}\")\n    # 从插件文件夹中移除该插件\n    _remove_plugin_from_folders(plugin_id)\n    # 移除插件\n    plugin_manager.remove_plugin(plugin_id)\n    return schemas.Response(success=True)\n\n\ndef _add_clone_to_plugin_folder(original_plugin_id: str, clone_plugin_id: str):\n    \"\"\"\n    将分身插件添加到原插件所在的文件夹中\n    :param original_plugin_id: 原插件ID\n    :param clone_plugin_id: 分身插件ID\n    \"\"\"\n    try:\n        config_oper = SystemConfigOper()\n        # 获取插件文件夹配置\n        folders = config_oper.get(SystemConfigKey.PluginFolders) or {}\n\n        # 查找原插件所在的文件夹\n        target_folder = None\n        for folder_name, folder_data in folders.items():\n            if isinstance(folder_data, dict) and 'plugins' in folder_data:\n                # 新格式：{\"plugins\": [...], \"order\": ..., \"icon\": ...}\n                if original_plugin_id in folder_data['plugins']:\n                    target_folder = folder_name\n                    break\n            elif isinstance(folder_data, list):\n                # 旧格式：直接是插件列表\n                if original_plugin_id in folder_data:\n                    target_folder = folder_name\n                    break\n\n        # 如果找到了原插件所在的文件夹，则将分身插件也添加到该文件夹中\n        if target_folder:\n            folder_data = folders[target_folder]\n            if isinstance(folder_data, dict) and 'plugins' in folder_data:\n                # 新格式\n                if clone_plugin_id not in folder_data['plugins']:\n                    folder_data['plugins'].append(clone_plugin_id)\n                    logger.info(f\"已将分身插件 {clone_plugin_id} 添加到文件夹 '{target_folder}' 中\")\n            elif isinstance(folder_data, list):\n                # 旧格式\n                if clone_plugin_id not in folder_data:\n                    folder_data.append(clone_plugin_id)\n                    logger.info(f\"已将分身插件 {clone_plugin_id} 添加到文件夹 '{target_folder}' 中\")\n\n            # 保存更新后的文件夹配置\n            config_oper.set(SystemConfigKey.PluginFolders, folders)\n        else:\n            logger.info(f\"原插件 {original_plugin_id} 不在任何文件夹中，分身插件 {clone_plugin_id} 将保持独立\")\n\n    except Exception as e:\n        logger.error(f\"处理插件文件夹时出错：{str(e)}\")\n        # 文件夹处理失败不影响插件分身创建的整体流程\n\n\ndef _remove_plugin_from_folders(plugin_id: str):\n    \"\"\"\n    从所有文件夹中移除指定的插件\n    :param plugin_id: 要移除的插件ID\n    \"\"\"\n    try:\n        config_oper = SystemConfigOper()\n        # 获取插件文件夹配置\n        folders = config_oper.get(SystemConfigKey.PluginFolders) or {}\n\n        # 标记是否有修改\n        modified = False\n\n        # 遍历所有文件夹，移除指定插件\n        for folder_name, folder_data in folders.items():\n            if isinstance(folder_data, dict) and 'plugins' in folder_data:\n                # 新格式：{\"plugins\": [...], \"order\": ..., \"icon\": ...}\n                if plugin_id in folder_data['plugins']:\n                    folder_data['plugins'].remove(plugin_id)\n                    logger.info(f\"已从文件夹 '{folder_name}' 中移除插件 {plugin_id}\")\n                    modified = True\n            elif isinstance(folder_data, list):\n                # 旧格式：直接是插件列表\n                if plugin_id in folder_data:\n                    folder_data.remove(plugin_id)\n                    logger.info(f\"已从文件夹 '{folder_name}' 中移除插件 {plugin_id}\")\n                    modified = True\n\n        # 如果有修改，保存更新后的文件夹配置\n        if modified:\n            config_oper.set(SystemConfigKey.PluginFolders, folders)\n        else:\n            logger.debug(f\"插件 {plugin_id} 不在任何文件夹中，无需移除\")\n\n    except Exception as e:\n        logger.error(f\"从文件夹中移除插件时出错：{str(e)}\")\n        # 文件夹处理失败不影响插件卸载的整体流程\n"
  },
  {
    "path": "app/api/endpoints/recommend.py",
    "content": "from typing import Any, List, Optional\n\nfrom fastapi import APIRouter, Depends\n\nfrom app import schemas\nfrom app.chain.recommend import RecommendChain\nfrom app.core.event import eventmanager\nfrom app.core.security import verify_token\nfrom app.schemas import RecommendSourceEventData\nfrom app.schemas.types import ChainEventType\n\nrouter = APIRouter()\n\n\n@router.get(\"/source\", summary=\"获取推荐数据源\", response_model=List[schemas.RecommendMediaSource])\ndef source(_: schemas.TokenPayload = Depends(verify_token)) -> Any:\n    \"\"\"\n    获取推荐数据源\n    \"\"\"\n    # 广播事件，请示额外的推荐数据源支持\n    event_data = RecommendSourceEventData()\n    event = eventmanager.send_event(ChainEventType.RecommendSource, event_data)\n    # 使用事件返回的上下文数据\n    if event and event.event_data:\n        event_data: RecommendSourceEventData = event.event_data\n        if event_data.extra_sources:\n            return event_data.extra_sources\n    return []\n\n\n@router.get(\"/bangumi_calendar\", summary=\"Bangumi每日放送\", response_model=List[schemas.MediaInfo])\nasync def bangumi_calendar(page: Optional[int] = 1,\n                           count: Optional[int] = 30,\n                           _: schemas.TokenPayload = Depends(verify_token)) -> Any:\n    \"\"\"\n    浏览Bangumi每日放送\n    \"\"\"\n    return await RecommendChain().async_bangumi_calendar(page=page, count=count)\n\n\n@router.get(\"/douban_showing\", summary=\"豆瓣正在热映\", response_model=List[schemas.MediaInfo])\nasync def douban_showing(page: Optional[int] = 1,\n                         count: Optional[int] = 30,\n                         _: schemas.TokenPayload = Depends(verify_token)) -> Any:\n    \"\"\"\n    浏览豆瓣正在热映\n    \"\"\"\n    return await RecommendChain().async_douban_movie_showing(page=page, count=count)\n\n\n@router.get(\"/douban_movies\", summary=\"豆瓣电影\", response_model=List[schemas.MediaInfo])\nasync def douban_movies(sort: Optional[str] = \"R\",\n                        tags: Optional[str] = \"\",\n                        page: Optional[int] = 1,\n                        count: Optional[int] = 30,\n                        _: schemas.TokenPayload = Depends(verify_token)) -> Any:\n    \"\"\"\n    浏览豆瓣电影信息\n    \"\"\"\n    return await RecommendChain().async_douban_movies(sort=sort, tags=tags, page=page, count=count)\n\n\n@router.get(\"/douban_tvs\", summary=\"豆瓣剧集\", response_model=List[schemas.MediaInfo])\nasync def douban_tvs(sort: Optional[str] = \"R\",\n                     tags: Optional[str] = \"\",\n                     page: Optional[int] = 1,\n                     count: Optional[int] = 30,\n                     _: schemas.TokenPayload = Depends(verify_token)) -> Any:\n    \"\"\"\n    浏览豆瓣剧集信息\n    \"\"\"\n    return await RecommendChain().async_douban_tvs(sort=sort, tags=tags, page=page, count=count)\n\n\n@router.get(\"/douban_movie_top250\", summary=\"豆瓣电影TOP250\", response_model=List[schemas.MediaInfo])\nasync def douban_movie_top250(page: Optional[int] = 1,\n                              count: Optional[int] = 30,\n                              _: schemas.TokenPayload = Depends(verify_token)) -> Any:\n    \"\"\"\n    浏览豆瓣剧集信息\n    \"\"\"\n    return await RecommendChain().async_douban_movie_top250(page=page, count=count)\n\n\n@router.get(\"/douban_tv_weekly_chinese\", summary=\"豆瓣国产剧集周榜\", response_model=List[schemas.MediaInfo])\nasync def douban_tv_weekly_chinese(page: Optional[int] = 1,\n                                   count: Optional[int] = 30,\n                                   _: schemas.TokenPayload = Depends(verify_token)) -> Any:\n    \"\"\"\n    中国每周剧集口碑榜\n    \"\"\"\n    return await RecommendChain().async_douban_tv_weekly_chinese(page=page, count=count)\n\n\n@router.get(\"/douban_tv_weekly_global\", summary=\"豆瓣全球剧集周榜\", response_model=List[schemas.MediaInfo])\nasync def douban_tv_weekly_global(page: Optional[int] = 1,\n                                  count: Optional[int] = 30,\n                                  _: schemas.TokenPayload = Depends(verify_token)) -> Any:\n    \"\"\"\n    全球每周剧集口碑榜\n    \"\"\"\n    return await RecommendChain().async_douban_tv_weekly_global(page=page, count=count)\n\n\n@router.get(\"/douban_tv_animation\", summary=\"豆瓣动画剧集\", response_model=List[schemas.MediaInfo])\nasync def douban_tv_animation(page: Optional[int] = 1,\n                              count: Optional[int] = 30,\n                              _: schemas.TokenPayload = Depends(verify_token)) -> Any:\n    \"\"\"\n    热门动画剧集\n    \"\"\"\n    return await RecommendChain().async_douban_tv_animation(page=page, count=count)\n\n\n@router.get(\"/douban_movie_hot\", summary=\"豆瓣热门电影\", response_model=List[schemas.MediaInfo])\nasync def douban_movie_hot(page: Optional[int] = 1,\n                           count: Optional[int] = 30,\n                           _: schemas.TokenPayload = Depends(verify_token)) -> Any:\n    \"\"\"\n    热门电影\n    \"\"\"\n    return await RecommendChain().async_douban_movie_hot(page=page, count=count)\n\n\n@router.get(\"/douban_tv_hot\", summary=\"豆瓣热门电视剧\", response_model=List[schemas.MediaInfo])\nasync def douban_tv_hot(page: Optional[int] = 1,\n                        count: Optional[int] = 30,\n                        _: schemas.TokenPayload = Depends(verify_token)) -> Any:\n    \"\"\"\n    热门电视剧\n    \"\"\"\n    return await RecommendChain().async_douban_tv_hot(page=page, count=count)\n\n\n@router.get(\"/tmdb_movies\", summary=\"TMDB电影\", response_model=List[schemas.MediaInfo])\nasync def tmdb_movies(sort_by: Optional[str] = \"popularity.desc\",\n                      with_genres: Optional[str] = \"\",\n                      with_original_language: Optional[str] = \"\",\n                      with_keywords: Optional[str] = \"\",\n                      with_watch_providers: Optional[str] = \"\",\n                      vote_average: Optional[float] = 0.0,\n                      vote_count: Optional[int] = 0,\n                      release_date: Optional[str] = \"\",\n                      page: Optional[int] = 1,\n                      _: schemas.TokenPayload = Depends(verify_token)) -> Any:\n    \"\"\"\n    浏览TMDB电影信息\n    \"\"\"\n    return await RecommendChain().async_tmdb_movies(sort_by=sort_by,\n                                                    with_genres=with_genres,\n                                                    with_original_language=with_original_language,\n                                                    with_keywords=with_keywords,\n                                                    with_watch_providers=with_watch_providers,\n                                                    vote_average=vote_average,\n                                                    vote_count=vote_count,\n                                                    release_date=release_date,\n                                                    page=page)\n\n\n@router.get(\"/tmdb_tvs\", summary=\"TMDB剧集\", response_model=List[schemas.MediaInfo])\nasync def tmdb_tvs(sort_by: Optional[str] = \"popularity.desc\",\n                   with_genres: Optional[str] = \"\",\n                   with_original_language: Optional[str] = \"\",\n                   with_keywords: Optional[str] = \"\",\n                   with_watch_providers: Optional[str] = \"\",\n                   vote_average: Optional[float] = 0.0,\n                   vote_count: Optional[int] = 0,\n                   release_date: Optional[str] = \"\",\n                   page: Optional[int] = 1,\n                   _: schemas.TokenPayload = Depends(verify_token)) -> Any:\n    \"\"\"\n    浏览TMDB剧集信息\n    \"\"\"\n    return await RecommendChain().async_tmdb_tvs(sort_by=sort_by,\n                                                 with_genres=with_genres,\n                                                 with_original_language=with_original_language,\n                                                 with_keywords=with_keywords,\n                                                 with_watch_providers=with_watch_providers,\n                                                 vote_average=vote_average,\n                                                 vote_count=vote_count,\n                                                 release_date=release_date,\n                                                 page=page)\n\n\n@router.get(\"/tmdb_trending\", summary=\"TMDB流行趋势\", response_model=List[schemas.MediaInfo])\nasync def tmdb_trending(page: Optional[int] = 1,\n                        _: schemas.TokenPayload = Depends(verify_token)) -> Any:\n    \"\"\"\n    TMDB流行趋势\n    \"\"\"\n    return await RecommendChain().async_tmdb_trending(page=page)\n"
  },
  {
    "path": "app/api/endpoints/search.py",
    "content": "from typing import List, Any, Optional\n\nfrom fastapi import APIRouter, Depends, Body\n\nfrom app import schemas\nfrom app.chain.media import MediaChain\nfrom app.chain.search import SearchChain\nfrom app.chain.ai_recommend import AIRecommendChain\nfrom app.core.config import settings\nfrom app.core.event import eventmanager\nfrom app.core.metainfo import MetaInfo\nfrom app.core.security import verify_token\nfrom app.log import logger\nfrom app.schemas import MediaRecognizeConvertEventData\nfrom app.schemas.types import MediaType, ChainEventType\n\nrouter = APIRouter()\n\n\n@router.get(\"/last\", summary=\"查询搜索结果\", response_model=List[schemas.Context])\nasync def search_latest(_: schemas.TokenPayload = Depends(verify_token)) -> Any:\n    \"\"\"\n    查询搜索结果\n    \"\"\"\n    torrents = await SearchChain().async_last_search_results() or []\n    return [torrent.to_dict() for torrent in torrents]\n\n\n@router.get(\"/media/{mediaid}\", summary=\"精确搜索资源\", response_model=schemas.Response)\nasync def search_by_id(mediaid: str,\n                       mtype: Optional[str] = None,\n                       area: Optional[str] = \"title\",\n                       title: Optional[str] = None,\n                       year: Optional[str] = None,\n                       season: Optional[str] = None,\n                       sites: Optional[str] = None,\n                       _: schemas.TokenPayload = Depends(verify_token)) -> Any:\n    \"\"\"\n    根据TMDBID/豆瓣ID精确搜索站点资源 tmdb:/douban:/bangumi:\n    \"\"\"\n    # 取消正在运行的AI推荐（会清除数据库缓存）\n    AIRecommendChain().cancel_ai_recommend()\n    \n    if mtype:\n        media_type = MediaType(mtype)\n    else:\n        media_type = None\n    if season:\n        media_season = int(season)\n    else:\n        media_season = None\n    if sites:\n        site_list = [int(site) for site in sites.split(\",\") if site]\n    else:\n        site_list = None\n    torrents = None\n    media_chain = MediaChain()\n    search_chain = SearchChain()\n    # 根据前缀识别媒体ID\n    if mediaid.startswith(\"tmdb:\"):\n        tmdbid = int(mediaid.replace(\"tmdb:\", \"\"))\n        if settings.RECOGNIZE_SOURCE == \"douban\":\n            # 通过TMDBID识别豆瓣ID\n            doubaninfo = await media_chain.async_get_doubaninfo_by_tmdbid(tmdbid=tmdbid, mtype=media_type)\n            if doubaninfo:\n                torrents = await search_chain.async_search_by_id(doubanid=doubaninfo.get(\"id\"),\n                                                                 mtype=media_type, area=area, season=media_season,\n                                                                 sites=site_list, cache_local=True)\n            else:\n                return schemas.Response(success=False, message=\"未识别到豆瓣媒体信息\")\n        else:\n            torrents = await search_chain.async_search_by_id(tmdbid=tmdbid, mtype=media_type, area=area,\n                                                             season=media_season,\n                                                             sites=site_list, cache_local=True)\n    elif mediaid.startswith(\"douban:\"):\n        doubanid = mediaid.replace(\"douban:\", \"\")\n        if settings.RECOGNIZE_SOURCE == \"themoviedb\":\n            # 通过豆瓣ID识别TMDBID\n            tmdbinfo = await media_chain.async_get_tmdbinfo_by_doubanid(doubanid=doubanid, mtype=media_type)\n            if tmdbinfo:\n                if tmdbinfo.get('season') and not media_season:\n                    media_season = tmdbinfo.get('season')\n                torrents = await search_chain.async_search_by_id(tmdbid=tmdbinfo.get(\"id\"),\n                                                                 mtype=media_type, area=area, season=media_season,\n                                                                 sites=site_list, cache_local=True)\n            else:\n                return schemas.Response(success=False, message=\"未识别到TMDB媒体信息\")\n        else:\n            torrents = await search_chain.async_search_by_id(doubanid=doubanid, mtype=media_type, area=area,\n                                                             season=media_season,\n                                                             sites=site_list, cache_local=True)\n    elif mediaid.startswith(\"bangumi:\"):\n        bangumiid = int(mediaid.replace(\"bangumi:\", \"\"))\n        if settings.RECOGNIZE_SOURCE == \"themoviedb\":\n            # 通过BangumiID识别TMDBID\n            tmdbinfo = await media_chain.async_get_tmdbinfo_by_bangumiid(bangumiid=bangumiid)\n            if tmdbinfo:\n                torrents = await search_chain.async_search_by_id(tmdbid=tmdbinfo.get(\"id\"),\n                                                                 mtype=media_type, area=area, season=media_season,\n                                                                 sites=site_list, cache_local=True)\n            else:\n                return schemas.Response(success=False, message=\"未识别到TMDB媒体信息\")\n        else:\n            # 通过BangumiID识别豆瓣ID\n            doubaninfo = await media_chain.async_get_doubaninfo_by_bangumiid(bangumiid=bangumiid)\n            if doubaninfo:\n                torrents = await search_chain.async_search_by_id(doubanid=doubaninfo.get(\"id\"),\n                                                                 mtype=media_type, area=area, season=media_season,\n                                                                 sites=site_list, cache_local=True)\n            else:\n                return schemas.Response(success=False, message=\"未识别到豆瓣媒体信息\")\n    else:\n        # 未知前缀，广播事件解析媒体信息\n        event_data = MediaRecognizeConvertEventData(\n            mediaid=mediaid,\n            convert_type=settings.RECOGNIZE_SOURCE\n        )\n        event = await eventmanager.async_send_event(ChainEventType.MediaRecognizeConvert, event_data)\n        # 使用事件返回的上下文数据\n        if event and event.event_data:\n            event_data: MediaRecognizeConvertEventData = event.event_data\n            if event_data.media_dict:\n                search_id = event_data.media_dict.get(\"id\")\n                if event_data.convert_type == \"themoviedb\":\n                    torrents = await search_chain.async_search_by_id(tmdbid=search_id, mtype=media_type, area=area,\n                                                                     season=media_season, cache_local=True)\n                elif event_data.convert_type == \"douban\":\n                    torrents = await search_chain.async_search_by_id(doubanid=search_id, mtype=media_type, area=area,\n                                                                     season=media_season, cache_local=True)\n        else:\n            if not title:\n                return schemas.Response(success=False, message=\"未知的媒体ID\")\n            # 使用名称识别兜底\n            meta = MetaInfo(title)\n            if year:\n                meta.year = year\n            if media_type:\n                meta.type = media_type\n            if media_season:\n                meta.type = MediaType.TV\n                meta.begin_season = media_season\n            mediainfo = await media_chain.async_recognize_media(meta=meta)\n            if mediainfo:\n                if settings.RECOGNIZE_SOURCE == \"themoviedb\":\n                    torrents = await search_chain.async_search_by_id(tmdbid=mediainfo.tmdb_id, mtype=media_type,\n                                                                     area=area,\n                                                                     season=media_season, cache_local=True)\n                else:\n                    torrents = await search_chain.async_search_by_id(doubanid=mediainfo.douban_id, mtype=media_type,\n                                                                     area=area,\n                                                                     season=media_season, cache_local=True)\n    # 返回搜索结果\n    if not torrents:\n        return schemas.Response(success=False, message=\"未搜索到任何资源\")\n    else:\n        return schemas.Response(success=True, data=[torrent.to_dict() for torrent in torrents])\n\n\n@router.get(\"/title\", summary=\"模糊搜索资源\", response_model=schemas.Response)\nasync def search_by_title(keyword: Optional[str] = None,\n                          page: Optional[int] = 0,\n                          sites: Optional[str] = None,\n                          _: schemas.TokenPayload = Depends(verify_token)) -> Any:\n    \"\"\"\n    根据名称模糊搜索站点资源，支持分页，关键词为空是返回首页资源\n    \"\"\"\n    # 取消正在运行的AI推荐并清除数据库缓存\n    AIRecommendChain().cancel_ai_recommend()\n    \n    torrents = await SearchChain().async_search_by_title(\n        title=keyword, page=page,\n        sites=[int(site) for site in sites.split(\",\") if site] if sites else None,\n        cache_local=True\n    )\n    if not torrents:\n        return schemas.Response(success=False, message=\"未搜索到任何资源\")\n    return schemas.Response(success=True, data=[torrent.to_dict() for torrent in torrents])\n\n\n@router.post(\"/recommend\", summary=\"AI推荐资源\", response_model=schemas.Response)\nasync def recommend_search_results(\n        filtered_indices: Optional[List[int]] = Body(None, embed=True, description=\"筛选后的索引列表\"),\n        check_only: bool = Body(False, embed=True, description=\"仅检查状态，不启动新任务\"),\n        force: bool = Body(False, embed=True, description=\"强制重新推荐，清除旧结果\"),\n        _: schemas.TokenPayload = Depends(verify_token)) -> Any:\n    \"\"\"\n    AI推荐资源 - 轮询接口\n    前端轮询此接口，发送筛选后的索引（如果有筛选）\n    后端根据请求变化自动取消旧任务并启动新任务\n    \n    参数：\n    - filtered_indices: 筛选后的索引列表（可选，为空或不提供时使用所有结果）\n    - check_only: 仅检查状态（首次打开页面时使用，避免触发不必要的重新推理）\n    - force: 强制重新推荐（清除旧结果并重新启动）\n    \n    返回数据结构：\n    {\n        \"success\": bool,\n        \"message\": string,   // 错误信息（仅在错误时存在）\n        \"data\": {\n            \"status\": string,    // 状态: disabled | idle | running | completed | error\n            \"results\": array     // 推荐结果（仅status=completed时存在）\n        }\n    }\n    \"\"\"\n    # 从缓存获取上次搜索结果\n    results = await SearchChain().async_last_search_results() or []\n    if not results:\n        return schemas.Response(success=False, message=\"没有可用的搜索结果\", data={\n            \"status\": \"error\"\n        })\n    \n    recommend_chain = AIRecommendChain()\n    \n    # 如果是强制模式，先取消并清除旧结果，然后直接启动新任务\n    if force:\n        # 检查功能是否启用\n        if not settings.AI_AGENT_ENABLE or not settings.AI_RECOMMEND_ENABLED:\n            return schemas.Response(success=True, data={\n                \"status\": \"disabled\"\n            })\n        logger.info(\"收到新推荐请求，清除旧结果并启动新任务\")\n        recommend_chain.cancel_ai_recommend()\n        recommend_chain.start_recommend_task(filtered_indices, len(results), results)\n        # 直接返回运行中状态\n        return schemas.Response(success=True, data={\n            \"status\": \"running\"\n        })\n    \n    # 如果是仅检查模式，不传递 filtered_indices（避免触发请求变化检测）\n    if check_only:\n        # 返回当前运行状态，不做任何任务启动或取消操作\n        current_status = recommend_chain.get_current_status_only()\n        # 如果有错误，将错误信息放到message中\n        if current_status.get(\"status\") == \"error\":\n            error_msg = current_status.pop(\"error\", \"未知错误\")\n            return schemas.Response(success=False, message=error_msg, data=current_status)\n        return schemas.Response(success=True, data=current_status)\n    \n    # 获取当前状态（会检测请求是否变化）\n    status_data = recommend_chain.get_status(filtered_indices, len(results))\n    \n    # 如果功能未启用，直接返回禁用状态\n    if status_data.get(\"status\") == \"disabled\":\n        return schemas.Response(success=True, data=status_data)\n    \n    # 如果是空闲状态，启动新任务\n    if status_data[\"status\"] == \"idle\":\n        recommend_chain.start_recommend_task(filtered_indices, len(results), results)\n        # 立即返回运行中状态\n        return schemas.Response(success=True, data={\n            \"status\": \"running\"\n        })\n    \n    # 如果有错误，将错误信息放到message中\n    if status_data.get(\"status\") == \"error\":\n        error_msg = status_data.pop(\"error\", \"未知错误\")\n        return schemas.Response(success=False, message=error_msg, data=status_data)\n    \n    # 返回当前状态\n    return schemas.Response(success=True, data=status_data)\n"
  },
  {
    "path": "app/api/endpoints/site.py",
    "content": "from typing import List, Any, Dict, Optional\n\nfrom fastapi import APIRouter, Depends, HTTPException\nfrom sqlalchemy.ext.asyncio import AsyncSession\nfrom sqlalchemy.orm import Session\nfrom starlette.background import BackgroundTasks\n\nfrom app import schemas\nfrom app.api.endpoints.plugin import register_plugin_api\nfrom app.chain.site import SiteChain\nfrom app.chain.torrents import TorrentsChain\nfrom app.command import Command\nfrom app.core.event import eventmanager\nfrom app.core.plugin import PluginManager\nfrom app.core.security import verify_token\nfrom app.db import get_db, get_async_db\nfrom app.db.models import User\nfrom app.db.models.site import Site\nfrom app.db.models.siteicon import SiteIcon\nfrom app.db.models.sitestatistic import SiteStatistic\nfrom app.db.models.siteuserdata import SiteUserData\nfrom app.db.site_oper import SiteOper\nfrom app.db.systemconfig_oper import SystemConfigOper\nfrom app.db.user_oper import get_current_active_superuser, get_current_active_superuser_async\nfrom app.helper.sites import SitesHelper  # noqa\nfrom app.scheduler import Scheduler\nfrom app.schemas.types import SystemConfigKey, EventType\nfrom app.utils.string import StringUtils\n\nrouter = APIRouter()\n\n\n@router.get(\"/\", summary=\"所有站点\", response_model=List[schemas.Site])\nasync def read_sites(db: AsyncSession = Depends(get_async_db),\n                     _: User = Depends(get_current_active_superuser)) -> List[dict]:\n    \"\"\"\n    获取站点列表\n    \"\"\"\n    return await Site.async_list_order_by_pri(db)\n\n\n@router.post(\"/\", summary=\"新增站点\", response_model=schemas.Response)\nasync def add_site(\n        *,\n        db: AsyncSession = Depends(get_async_db),\n        site_in: schemas.Site,\n        _: User = Depends(get_current_active_superuser)\n) -> Any:\n    \"\"\"\n    新增站点\n    \"\"\"\n    if not site_in.url:\n        return schemas.Response(success=False, message=\"站点地址不能为空\")\n    if SitesHelper().auth_level < 2:\n        return schemas.Response(success=False, message=\"用户未通过认证，无法使用站点功能！\")\n    domain = StringUtils.get_url_domain(site_in.url)\n    site_info = await SitesHelper().async_get_indexer(domain)\n    if not site_info:\n        return schemas.Response(success=False, message=\"该站点不支持，请检查站点域名是否正确\")\n    if await Site.async_get_by_domain(db, domain):\n        return schemas.Response(success=False, message=f\"{domain} 站点己存在\")\n    # 保存站点信息\n    site_in.domain = domain\n    # 校正地址格式\n    _scheme, _netloc = StringUtils.get_url_netloc(site_in.url)\n    site_in.url = f\"{_scheme}://{_netloc}/\"\n    site_in.name = site_info.get(\"name\")\n    site_in.id = None\n    site_in.public = 1 if site_info.get(\"public\") else 0\n    site = Site(**site_in.model_dump())\n    site.create(db)\n    # 通知站点更新\n    await eventmanager.async_send_event(EventType.SiteUpdated, {\n        \"domain\": domain\n    })\n    return schemas.Response(success=True)\n\n\n@router.put(\"/\", summary=\"更新站点\", response_model=schemas.Response)\nasync def update_site(\n        *,\n        db: AsyncSession = Depends(get_async_db),\n        site_in: schemas.Site,\n        _: User = Depends(get_current_active_superuser)\n) -> Any:\n    \"\"\"\n    更新站点信息\n    \"\"\"\n    site = await Site.async_get(db, site_in.id)\n    if not site:\n        return schemas.Response(success=False, message=\"站点不存在\")\n    # 校正地址格式\n    _scheme, _netloc = StringUtils.get_url_netloc(site_in.url)\n    site_in.url = f\"{_scheme}://{_netloc}/\"\n    site_in.domain = StringUtils.get_url_domain(site_in.url)\n    await site.async_update(db, site_in.model_dump())\n    # 通知站点更新\n    await eventmanager.async_send_event(EventType.SiteUpdated, {\n        \"site_id\": site_in.id,\n        \"domain\": site_in.domain,\n        \"name\": site_in.name,\n        \"site_url\": site_in.url\n    })\n    return schemas.Response(success=True)\n\n\n@router.get(\"/cookiecloud\", summary=\"CookieCloud同步\", response_model=schemas.Response)\nasync def cookie_cloud_sync(background_tasks: BackgroundTasks,\n                            _: User = Depends(get_current_active_superuser_async)) -> Any:\n    \"\"\"\n    运行CookieCloud同步站点信息\n    \"\"\"\n    background_tasks.add_task(Scheduler().start, job_id=\"cookiecloud\")\n    return schemas.Response(success=True, message=\"CookieCloud同步任务已启动！\")\n\n\n@router.get(\"/reset\", summary=\"重置站点\", response_model=schemas.Response)\ndef reset(db: AsyncSession = Depends(get_db),\n          _: User = Depends(get_current_active_superuser)) -> Any:\n    \"\"\"\n    清空所有站点数据并重新同步CookieCloud站点信息\n    \"\"\"\n    Site.reset(db)\n    SystemConfigOper().set(SystemConfigKey.IndexerSites, [])\n    SystemConfigOper().set(SystemConfigKey.RssSites, [])\n    # 启动定时服务\n    Scheduler().start(\"cookiecloud\", manual=True)\n    # 插件站点删除\n    eventmanager.send_event(EventType.SiteDeleted,\n                            {\n                                \"site_id\": \"*\"\n                            })\n    return schemas.Response(success=True, message=\"站点已重置！\")\n\n\n@router.post(\"/priorities\", summary=\"批量更新站点优先级\", response_model=schemas.Response)\nasync def update_sites_priority(\n        priorities: List[dict],\n        db: AsyncSession = Depends(get_async_db),\n        _: User = Depends(get_current_active_superuser_async)) -> Any:\n    \"\"\"\n    批量更新站点优先级\n    \"\"\"\n    for priority in priorities:\n        site = await Site.async_get(db, priority.get(\"id\"))\n        if site:\n            await site.async_update(db, {\"pri\": priority.get(\"pri\")})\n    return schemas.Response(success=True)\n\n\n@router.get(\"/cookie/{site_id}\", summary=\"更新站点Cookie&UA\", response_model=schemas.Response)\ndef update_cookie(\n        site_id: int,\n        username: str,\n        password: str,\n        code: Optional[str] = None,\n        db: Session = Depends(get_db),\n        _: User = Depends(get_current_active_superuser)) -> Any:\n    \"\"\"\n    使用用户密码更新站点Cookie\n    \"\"\"\n    # 查询站点\n    site_info = Site.get(db, site_id)\n    if not site_info:\n        raise HTTPException(\n            status_code=404,\n            detail=f\"站点 {site_id} 不存在！\",\n        )\n    # 更新Cookie\n    state, message = SiteChain().update_cookie(site_info=site_info,\n                                               username=username,\n                                               password=password,\n                                               two_step_code=code)\n    return schemas.Response(success=state, message=message)\n\n\n@router.post(\"/userdata/{site_id}\", summary=\"更新站点用户数据\", response_model=schemas.Response)\ndef refresh_userdata(\n        site_id: int,\n        db: Session = Depends(get_db),\n        _: User = Depends(get_current_active_superuser)) -> Any:\n    \"\"\"\n    刷新站点用户数据\n    \"\"\"\n    site = Site.get(db, site_id)\n    if not site:\n        raise HTTPException(\n            status_code=404,\n            detail=f\"站点 {site_id} 不存在\",\n        )\n    indexer = SitesHelper().get_indexer(site.domain)\n    if not indexer:\n        return schemas.Response(success=False, message=\"站点不支持索引或未通过用户认证！\")\n    user_data = SiteChain().refresh_userdata(site=indexer) or {}\n    return schemas.Response(success=True, data=user_data)\n\n\n@router.get(\"/userdata/latest\", summary=\"查询所有站点最新用户数据\", response_model=List[schemas.SiteUserData])\nasync def read_userdata_latest(\n        db: AsyncSession = Depends(get_async_db),\n        _: User = Depends(get_current_active_superuser_async)) -> Any:\n    \"\"\"\n    查询所有站点最新用户数据\n    \"\"\"\n    user_datas = await SiteUserData.async_get_latest(db)\n    if not user_datas:\n        return []\n    return [user_data.to_dict() for user_data in user_datas]\n\n\n@router.get(\"/userdata/{site_id}\", summary=\"查询某站点用户数据\", response_model=schemas.Response)\nasync def read_userdata(\n        site_id: int,\n        workdate: Optional[str] = None,\n        db: AsyncSession = Depends(get_async_db),\n        _: User = Depends(get_current_active_superuser_async)) -> Any:\n    \"\"\"\n    查询站点用户数据\n    \"\"\"\n    site = await Site.async_get(db, site_id)\n    if not site:\n        raise HTTPException(\n            status_code=404,\n            detail=f\"站点 {site_id} 不存在\",\n        )\n    user_datas = await SiteUserData.async_get_by_domain(db, domain=site.domain, workdate=workdate)\n    if not user_datas:\n        return schemas.Response(success=False, data=[])\n    return schemas.Response(success=True, data=[data.to_dict() for data in user_datas])\n\n\n@router.get(\"/test/{site_id}\", summary=\"连接测试\", response_model=schemas.Response)\ndef test_site(site_id: int,\n              db: Session = Depends(get_db),\n              _: schemas.TokenPayload = Depends(verify_token)) -> Any:\n    \"\"\"\n    测试站点是否可用\n    \"\"\"\n    site = Site.get(db, site_id)\n    if not site:\n        raise HTTPException(\n            status_code=404,\n            detail=f\"站点 {site_id} 不存在\",\n        )\n    status, message = SiteChain().test(site.domain)\n    return schemas.Response(success=status, message=message)\n\n\n@router.get(\"/icon/{site_id}\", summary=\"站点图标\", response_model=schemas.Response)\nasync def site_icon(site_id: int,\n                    db: AsyncSession = Depends(get_async_db),\n                    _: schemas.TokenPayload = Depends(verify_token)) -> Any:\n    \"\"\"\n    获取站点图标：base64或者url\n    \"\"\"\n    site = await Site.async_get(db, site_id)\n    if not site:\n        raise HTTPException(\n            status_code=404,\n            detail=f\"站点 {site_id} 不存在\",\n        )\n    icon = await SiteIcon.async_get_by_domain(db, site.domain)\n    if not icon:\n        return schemas.Response(success=False, message=\"站点图标不存在！\")\n    return schemas.Response(success=True, data={\n        \"icon\": icon.base64 if icon.base64 else icon.url\n    })\n\n\n@router.get(\"/category/{site_id}\", summary=\"站点分类\", response_model=List[schemas.SiteCategory])\nasync def site_category(site_id: int,\n                        db: AsyncSession = Depends(get_async_db),\n                        _: schemas.TokenPayload = Depends(verify_token)) -> Any:\n    \"\"\"\n    获取站点分类\n    \"\"\"\n    site = await Site.async_get(db, site_id)\n    if not site:\n        raise HTTPException(\n            status_code=404,\n            detail=f\"站点 {site_id} 不存在\",\n        )\n    indexer = await SitesHelper().async_get_indexer(site.domain)\n    if not indexer:\n        raise HTTPException(\n            status_code=404,\n            detail=f\"站点 {site.domain} 不支持\",\n        )\n    category: Dict[str, List[dict]] = indexer.get('category') or []\n    if not category:\n        return []\n    result = []\n    for cats in category.values():\n        for cat in cats:\n            if cat not in result:\n                result.append(cat)\n    return result\n\n\n@router.get(\"/resource/{site_id}\", summary=\"站点资源\", response_model=List[schemas.TorrentInfo])\nasync def site_resource(site_id: int,\n                        keyword: Optional[str] = None,\n                        cat: Optional[str] = None,\n                        page: Optional[int] = 0,\n                        db: AsyncSession = Depends(get_async_db),\n                        _: User = Depends(get_current_active_superuser_async)) -> Any:\n    \"\"\"\n    浏览站点资源\n    \"\"\"\n    site = await Site.async_get(db, site_id)\n    if not site:\n        raise HTTPException(\n            status_code=404,\n            detail=f\"站点 {site_id} 不存在\",\n        )\n    torrents = await TorrentsChain().async_browse(domain=site.domain, keyword=keyword, cat=cat, page=page)\n    if not torrents:\n        return []\n    return [torrent.to_dict() for torrent in torrents]\n\n\n@router.get(\"/domain/{site_url}\", summary=\"站点详情\", response_model=schemas.Site)\nasync def read_site_by_domain(\n        site_url: str,\n        db: AsyncSession = Depends(get_async_db),\n        _: schemas.TokenPayload = Depends(verify_token)\n) -> Any:\n    \"\"\"\n    通过域名获取站点信息\n    \"\"\"\n    domain = StringUtils.get_url_domain(site_url)\n    site = await Site.async_get_by_domain(db, domain)\n    if not site:\n        raise HTTPException(\n            status_code=404,\n            detail=f\"站点 {domain} 不存在\",\n        )\n    return site\n\n\n@router.get(\"/statistic/{site_url}\", summary=\"特定站点统计信息\", response_model=schemas.SiteStatistic)\nasync def read_statistic_by_domain(\n        site_url: str,\n        db: AsyncSession = Depends(get_async_db),\n        _: schemas.TokenPayload = Depends(verify_token)\n) -> Any:\n    \"\"\"\n    通过域名获取站点统计信息\n    \"\"\"\n    domain = StringUtils.get_url_domain(site_url)\n    sitestatistic = await SiteStatistic.async_get_by_domain(db, domain)\n    if sitestatistic:\n        return sitestatistic\n    return schemas.SiteStatistic(domain=domain)\n\n\n@router.get(\"/statistic\", summary=\"所有站点统计信息\", response_model=List[schemas.SiteStatistic])\nasync def read_statistics(\n        db: AsyncSession = Depends(get_async_db),\n        _: schemas.TokenPayload = Depends(verify_token)\n) -> Any:\n    \"\"\"\n    获取所有站点统计信息\n    \"\"\"\n    return await SiteStatistic.async_list(db)\n\n\n@router.get(\"/rss\", summary=\"所有订阅站点\", response_model=List[schemas.Site])\nasync def read_rss_sites(db: AsyncSession = Depends(get_async_db),\n                         _: schemas.TokenPayload = Depends(verify_token)) -> List[dict]:\n    \"\"\"\n    获取站点列表\n    \"\"\"\n    # 选中的rss站点\n    selected_sites = SystemConfigOper().get(SystemConfigKey.RssSites) or []\n\n    # 所有站点\n    all_site = await Site.async_list_order_by_pri(db)\n    if not selected_sites:\n        return all_site\n\n    # 选中的rss站点\n    rss_sites = [site for site in all_site if site and site.id in selected_sites]\n    return rss_sites\n\n\n@router.get(\"/auth\", summary=\"查询认证站点\", response_model=dict)\nasync def read_auth_sites(_: schemas.TokenPayload = Depends(verify_token)) -> dict:\n    \"\"\"\n    获取可认证站点列表\n    \"\"\"\n    return SitesHelper().get_authsites()\n\n\n@router.post(\"/auth\", summary=\"用户站点认证\", response_model=schemas.Response)\ndef auth_site(\n        auth_info: schemas.SiteAuth,\n        _: User = Depends(get_current_active_superuser)\n) -> Any:\n    \"\"\"\n    用户站点认证\n    \"\"\"\n    if not auth_info or not auth_info.site or not auth_info.params:\n        return schemas.Response(success=False, message=\"请输入认证站点和认证参数\")\n    status, msg = SitesHelper().check_user(auth_info.site, auth_info.params)\n    SystemConfigOper().set(SystemConfigKey.UserSiteAuthParams, auth_info.model_dump())\n    # 认证成功后，重新初始化插件\n    PluginManager().init_config()\n    Scheduler().init_plugin_jobs()\n    Command().init_commands()\n    register_plugin_api()\n    return schemas.Response(success=status, message=msg)\n\n\n@router.get(\"/mapping\", summary=\"获取站点域名到名称的映射\", response_model=schemas.Response)\nasync def site_mapping(_: User = Depends(get_current_active_superuser_async)):\n    \"\"\"\n    获取站点域名到名称的映射关系\n    \"\"\"\n    try:\n        sites = await SiteOper().async_list()\n        mapping = {}\n        for site in sites:\n            mapping[site.domain] = site.name\n        return schemas.Response(success=True, data=mapping)\n    except Exception as e:\n        return schemas.Response(success=False, message=f\"获取映射失败：{str(e)}\")\n\n\n@router.get(\"/supporting\", summary=\"获取支持的站点列表\", response_model=dict)\nasync def support_sites(_: User = Depends(get_current_active_superuser_async)):\n    \"\"\"\n    获取支持的站点列表\n    \"\"\"\n    return SitesHelper().get_indexsites()\n\n\n@router.get(\"/{site_id}\", summary=\"站点详情\", response_model=schemas.Site)\nasync def read_site(\n        site_id: int,\n        db: AsyncSession = Depends(get_async_db),\n        _: User = Depends(get_current_active_superuser_async)\n) -> Any:\n    \"\"\"\n    通过ID获取站点信息\n    \"\"\"\n    site = await Site.async_get(db, site_id)\n    if not site:\n        raise HTTPException(\n            status_code=404,\n            detail=f\"站点 {site_id} 不存在\",\n        )\n    return site\n\n\n@router.delete(\"/{site_id}\", summary=\"删除站点\", response_model=schemas.Response)\nasync def delete_site(\n        site_id: int,\n        db: AsyncSession = Depends(get_async_db),\n        _: User = Depends(get_current_active_superuser_async)\n) -> Any:\n    \"\"\"\n    删除站点\n    \"\"\"\n    await Site.async_delete(db, site_id)\n    # 插件站点删除\n    await eventmanager.async_send_event(EventType.SiteDeleted,\n                                        {\n                                            \"site_id\": site_id\n                                        })\n    return schemas.Response(success=True)\n"
  },
  {
    "path": "app/api/endpoints/storage.py",
    "content": "import math\nfrom pathlib import Path\nfrom typing import Any, List, Optional\n\nfrom fastapi import APIRouter, Depends, HTTPException\nfrom starlette.responses import FileResponse, Response\n\nfrom app import schemas\nfrom app.chain.storage import StorageChain\nfrom app.chain.transfer import TransferChain\nfrom app.core.config import settings\nfrom app.core.metainfo import MetaInfoPath\nfrom app.core.security import verify_token\nfrom app.db.models import User\nfrom app.db.user_oper import get_current_active_superuser, get_current_active_superuser_async\nfrom app.helper.progress import ProgressHelper\nfrom app.schemas.types import ProgressKey\nfrom app.utils.string import StringUtils\n\nrouter = APIRouter()\n\n\n@router.get(\"/qrcode/{name}\", summary=\"生成二维码内容\", response_model=schemas.Response)\ndef qrcode(name: str, _: schemas.TokenPayload = Depends(verify_token)) -> Any:\n    \"\"\"\n    生成二维码\n    \"\"\"\n    qrcode_data, errmsg = StorageChain().generate_qrcode(name)\n    if qrcode_data:\n        return schemas.Response(success=True, data=qrcode_data, message=errmsg)\n    return schemas.Response(success=False, message=errmsg)\n\n\n@router.get(\"/auth_url/{name}\", summary=\"获取 OAuth2 授权 URL\", response_model=schemas.Response)\ndef auth_url(name: str, _: schemas.TokenPayload = Depends(verify_token)) -> Any:\n    \"\"\"\n    获取 OAuth2 授权 URL\n    \"\"\"\n    auth_data, errmsg = StorageChain().generate_auth_url(name)\n    if auth_data:\n        return schemas.Response(success=True, data=auth_data)\n    return schemas.Response(success=False, message=errmsg)\n\n\n@router.get(\"/check/{name}\", summary=\"二维码登录确认\", response_model=schemas.Response)\ndef check(name: str, ck: Optional[str] = None, t: Optional[str] = None,\n          _: schemas.TokenPayload = Depends(verify_token)) -> Any:\n    \"\"\"\n    二维码登录确认\n    \"\"\"\n    if ck or t:\n        data, errmsg = StorageChain().check_login(name, ck=ck, t=t)\n    else:\n        data, errmsg = StorageChain().check_login(name)\n    if data:\n        return schemas.Response(success=True, data=data)\n    return schemas.Response(success=False, message=errmsg)\n\n\n@router.post(\"/save/{name}\", summary=\"保存存储配置\", response_model=schemas.Response)\ndef save(name: str,\n         conf: dict,\n         _: User = Depends(get_current_active_superuser)) -> Any:\n    \"\"\"\n    保存存储配置\n    \"\"\"\n    StorageChain().save_config(name, conf)\n    return schemas.Response(success=True)\n\n\n@router.get(\"/reset/{name}\", summary=\"重置存储配置\", response_model=schemas.Response)\ndef reset(name: str,\n          _: User = Depends(get_current_active_superuser)) -> Any:\n    \"\"\"\n    重置存储配置\n    \"\"\"\n    StorageChain().reset_config(name)\n    return schemas.Response(success=True)\n\n\n@router.post(\"/list\", summary=\"所有目录和文件\", response_model=List[schemas.FileItem])\ndef list_files(fileitem: schemas.FileItem,\n               sort: Optional[str] = 'updated_at',\n               _: User = Depends(get_current_active_superuser)) -> Any:\n    \"\"\"\n    查询当前目录下所有目录和文件\n    :param fileitem: 文件项\n    :param sort: 排序方式，name:按名称排序，time:按修改时间排序\n    :param _: token\n    :return: 所有目录和文件\n    \"\"\"\n    file_list = StorageChain().list_files(fileitem)\n    if file_list:\n        if sort == \"name\":\n            file_list.sort(key=lambda x: StringUtils.natural_sort_key(x.name or \"\"))\n        else:\n            file_list.sort(key=lambda x: x.modify_time or -math.inf, reverse=True)\n    return file_list\n\n\n@router.post(\"/mkdir\", summary=\"创建目录\", response_model=schemas.Response)\ndef mkdir(fileitem: schemas.FileItem,\n          name: str,\n          _: User = Depends(get_current_active_superuser)) -> Any:\n    \"\"\"\n    创建目录\n    :param fileitem: 文件项\n    :param name: 目录名称\n    :param _: token\n    \"\"\"\n    if not name:\n        return schemas.Response(success=False)\n    result = StorageChain().create_folder(fileitem, name)\n    if result:\n        return schemas.Response(success=True)\n    return schemas.Response(success=False)\n\n\n@router.post(\"/delete\", summary=\"删除文件或目录\", response_model=schemas.Response)\ndef delete(fileitem: schemas.FileItem,\n           _: User = Depends(get_current_active_superuser)) -> Any:\n    \"\"\"\n    删除文件或目录\n    :param fileitem: 文件项\n    :param _: token\n    \"\"\"\n    result = StorageChain().delete_file(fileitem)\n    if result:\n        return schemas.Response(success=True)\n    return schemas.Response(success=False)\n\n\n@router.post(\"/download\", summary=\"下载文件\")\ndef download(fileitem: schemas.FileItem,\n             _: User = Depends(get_current_active_superuser)) -> Any:\n    \"\"\"\n    下载文件或目录\n    :param fileitem: 文件项\n    :param _: token\n    \"\"\"\n    # 临时目录\n    tmp_file = StorageChain().download_file(fileitem)\n    if tmp_file:\n        return FileResponse(path=tmp_file)\n    return schemas.Response(success=False)\n\n\n@router.post(\"/image\", summary=\"预览图片\")\ndef image(fileitem: schemas.FileItem,\n          _: User = Depends(get_current_active_superuser)) -> Any:\n    \"\"\"\n    下载文件或目录\n    :param fileitem: 文件项\n    :param _: token\n    \"\"\"\n    # 临时目录\n    tmp_file = StorageChain().download_file(fileitem)\n    if not tmp_file:\n        raise HTTPException(status_code=500, detail=\"图片读取出错\")\n    return Response(content=tmp_file.read_bytes(), media_type=\"image/jpeg\")\n\n\n@router.post(\"/rename\", summary=\"重命名文件或目录\", response_model=schemas.Response)\ndef rename(fileitem: schemas.FileItem,\n           new_name: str,\n           recursive: Optional[bool] = False,\n           _: User = Depends(get_current_active_superuser)) -> Any:\n    \"\"\"\n    重命名文件或目录\n    :param fileitem: 文件项\n    :param new_name: 新名称\n    :param recursive: 是否递归修改\n    :param _: token\n    \"\"\"\n    if not new_name:\n        return schemas.Response(success=False, message=\"新名称为空\")\n\n    # 重命名目录内文件\n    if recursive:\n        transferchain = TransferChain()\n        media_exts = settings.RMT_MEDIAEXT + settings.RMT_SUBEXT + settings.RMT_AUDIOEXT\n        # 递归修改目录内文件（智能识别命名）\n        sub_files: List[schemas.FileItem] = StorageChain().list_files(fileitem)\n        if sub_files:\n            # 开始进度\n            progress = ProgressHelper(ProgressKey.BatchRename)\n            progress.start()\n            total = len(sub_files)\n            handled = 0\n            for sub_file in sub_files:\n                handled += 1\n                progress.update(value=handled / total * 100,\n                                text=f\"正在处理 {sub_file.name} ...\")\n                if sub_file.type == \"dir\":\n                    continue\n                if not sub_file.extension:\n                    continue\n                if f\".{sub_file.extension.lower()}\" not in media_exts:\n                    continue\n                sub_path = Path(f\"{fileitem.path}{sub_file.name}\")\n                meta = MetaInfoPath(sub_path)\n                mediainfo = transferchain.recognize_media(meta)\n                if not mediainfo:\n                    progress.end()\n                    return schemas.Response(success=False, message=f\"{sub_path.name} 未识别到媒体信息\")\n                new_path = transferchain.recommend_name(meta=meta, mediainfo=mediainfo)\n                if not new_path:\n                    progress.end()\n                    return schemas.Response(success=False, message=f\"{sub_path.name} 未识别到新名称\")\n                ret: schemas.Response = rename(fileitem=sub_file,\n                                               new_name=Path(new_path).name,\n                                               recursive=False)\n                if not ret.success:\n                    progress.end()\n                    return schemas.Response(success=False, message=f\"{sub_path.name} 重命名失败！\")\n            progress.end()\n    # 重命名自己\n    result = StorageChain().rename_file(fileitem, new_name)\n    if result:\n        return schemas.Response(success=True)\n    return schemas.Response(success=False)\n\n\n@router.get(\"/usage/{name}\", summary=\"存储空间信息\", response_model=schemas.StorageUsage)\ndef usage(name: str, _: User = Depends(get_current_active_superuser)) -> Any:\n    \"\"\"\n    查询存储空间\n    \"\"\"\n    ret = StorageChain().storage_usage(name)\n    if ret:\n        return ret\n    return schemas.StorageUsage()\n\n\n@router.get(\"/transtype/{name}\", summary=\"支持的整理方式获取\", response_model=schemas.StorageTransType)\nasync def transtype(name: str, _: User = Depends(get_current_active_superuser_async)) -> Any:\n    \"\"\"\n    查询支持的整理方式\n    \"\"\"\n    ret = StorageChain().support_transtype(name)\n    if ret:\n        return schemas.StorageTransType(transtype=ret)\n    return schemas.StorageTransType()\n"
  },
  {
    "path": "app/api/endpoints/subscribe.py",
    "content": "from typing import List, Any, Annotated, Optional\n\nimport cn2an\nfrom fastapi import APIRouter, Request, BackgroundTasks, Depends, HTTPException, Header\nfrom sqlalchemy.ext.asyncio import AsyncSession\nfrom sqlalchemy.orm import Session\n\nfrom app import schemas\nfrom app.chain.subscribe import SubscribeChain\nfrom app.core.config import settings\nfrom app.core.context import MediaInfo\nfrom app.core.event import eventmanager\nfrom app.core.metainfo import MetaInfo\nfrom app.core.security import verify_token, verify_apitoken\nfrom app.db import get_async_db, get_db\nfrom app.db.models.subscribe import Subscribe\nfrom app.db.models.subscribehistory import SubscribeHistory\nfrom app.db.models.user import User\nfrom app.db.systemconfig_oper import SystemConfigOper\nfrom app.db.user_oper import get_current_active_user_async\nfrom app.helper.subscribe import SubscribeHelper\nfrom app.scheduler import Scheduler\nfrom app.schemas.types import MediaType, EventType, SystemConfigKey\n\nrouter = APIRouter()\n\n\ndef start_subscribe_add(title: str, year: str,\n                        mtype: MediaType, tmdbid: int, season: int, username: str):\n    \"\"\"\n    启动订阅任务\n    \"\"\"\n    SubscribeChain().add(title=title, year=year,\n                         mtype=mtype, tmdbid=tmdbid, season=season, username=username)\n\n\n@router.get(\"/\", summary=\"查询所有订阅\", response_model=List[schemas.Subscribe])\nasync def read_subscribes(\n        db: AsyncSession = Depends(get_async_db),\n        _: schemas.TokenPayload = Depends(verify_token)) -> Any:\n    \"\"\"\n    查询所有订阅\n    \"\"\"\n    return await Subscribe.async_list(db)\n\n\n@router.get(\"/list\", summary=\"查询所有订阅（API_TOKEN）\", response_model=List[schemas.Subscribe])\nasync def list_subscribes(_: Annotated[str, Depends(verify_apitoken)]) -> Any:\n    \"\"\"\n    查询所有订阅 API_TOKEN认证（?token=xxx）\n    \"\"\"\n    return await read_subscribes()\n\n\n@router.post(\"/\", summary=\"新增订阅\", response_model=schemas.Response)\nasync def create_subscribe(\n        *,\n        subscribe_in: schemas.Subscribe,\n        current_user: User = Depends(get_current_active_user_async),\n) -> schemas.Response:\n    \"\"\"\n    新增订阅\n    \"\"\"\n    # 类型转换\n    if subscribe_in.type:\n        mtype = MediaType(subscribe_in.type)\n    else:\n        mtype = None\n    # 豆瓣标理\n    if subscribe_in.doubanid or subscribe_in.bangumiid:\n        meta = MetaInfo(subscribe_in.name)\n        subscribe_in.name = meta.name\n        subscribe_in.season = meta.begin_season\n    # 标题转换\n    if subscribe_in.name:\n        title = subscribe_in.name\n    else:\n        title = None\n    # 订阅用户\n    subscribe_in.username = current_user.name\n    # 转化为字典\n    subscribe_dict = subscribe_in.model_dump()\n    if subscribe_in.id:\n        subscribe_dict.pop(\"id\", None)\n    sid, message = await SubscribeChain().async_add(mtype=mtype,\n                                                    title=title,\n                                                    exist_ok=True,\n                                                    **subscribe_dict)\n    return schemas.Response(\n        success=bool(sid), message=message, data={\"id\": sid}\n    )\n\n\n@router.put(\"/\", summary=\"更新订阅\", response_model=schemas.Response)\nasync def update_subscribe(\n        *,\n        subscribe_in: schemas.Subscribe,\n        db: AsyncSession = Depends(get_async_db),\n        _: schemas.TokenPayload = Depends(verify_token)\n) -> Any:\n    \"\"\"\n    更新订阅信息\n    \"\"\"\n    subscribe = await Subscribe.async_get(db, subscribe_in.id)\n    if not subscribe:\n        return schemas.Response(success=False, message=\"订阅不存在\")\n    # 避免更新缺失集数\n    old_subscribe_dict = subscribe.to_dict()\n    subscribe_dict = subscribe_in.model_dump()\n    if not subscribe_in.lack_episode:\n        # 没有缺失集数时，缺失集数清空，避免更新为0\n        subscribe_dict.pop(\"lack_episode\")\n    elif subscribe_in.total_episode:\n        # 总集数增加时，缺失集数也要增加\n        if subscribe_in.total_episode > (subscribe.total_episode or 0):\n            subscribe_dict[\"lack_episode\"] = (subscribe.lack_episode\n                                              + (subscribe_in.total_episode\n                                                 - (subscribe.total_episode or 0)))\n    # 是否手动修改过总集数\n    if subscribe_in.total_episode != subscribe.total_episode:\n        subscribe_dict[\"manual_total_episode\"] = 1\n    # 更新到数据库\n    await subscribe.async_update(db, subscribe_dict)\n    # 重新获取更新后的订阅数据\n    updated_subscribe = await Subscribe.async_get(db, subscribe_in.id)\n    # 发送订阅调整事件\n    await eventmanager.async_send_event(EventType.SubscribeModified, {\n        \"subscribe_id\": subscribe_in.id,\n        \"old_subscribe_info\": old_subscribe_dict,\n        \"subscribe_info\": updated_subscribe.to_dict() if updated_subscribe else {},\n    })\n    return schemas.Response(success=True)\n\n\n@router.put(\"/status/{subid}\", summary=\"更新订阅状态\", response_model=schemas.Response)\nasync def update_subscribe_status(\n        subid: int,\n        state: str,\n        db: AsyncSession = Depends(get_async_db),\n        _: schemas.TokenPayload = Depends(verify_token)) -> Any:\n    \"\"\"\n    更新订阅状态\n    \"\"\"\n    subscribe = await Subscribe.async_get(db, subid)\n    if not subscribe:\n        return schemas.Response(success=False, message=\"订阅不存在\")\n    valid_states = [\"R\", \"P\", \"S\"]\n    if state not in valid_states:\n        return schemas.Response(success=False, message=\"无效的订阅状态\")\n    old_subscribe_dict = subscribe.to_dict()\n    await subscribe.async_update(db, {\n        \"state\": state\n    })\n    # 重新获取更新后的订阅数据\n    updated_subscribe = await Subscribe.async_get(db, subid)\n    # 发送订阅调整事件\n    await eventmanager.async_send_event(EventType.SubscribeModified, {\n        \"subscribe_id\": subid,\n        \"old_subscribe_info\": old_subscribe_dict,\n        \"subscribe_info\": updated_subscribe.to_dict() if updated_subscribe else {},\n    })\n    return schemas.Response(success=True)\n\n\n@router.get(\"/media/{mediaid}\", summary=\"查询订阅\", response_model=schemas.Subscribe)\nasync def subscribe_mediaid(\n        mediaid: str,\n        season: Optional[int] = None,\n        title: Optional[str] = None,\n        db: AsyncSession = Depends(get_async_db),\n        _: schemas.TokenPayload = Depends(verify_token)) -> Any:\n    \"\"\"\n    根据 TMDBID/豆瓣ID/BangumiId 查询订阅 tmdb:/douban:\n    \"\"\"\n    title_check = False\n    if mediaid.startswith(\"tmdb:\"):\n        tmdbid = mediaid[5:]\n        if not tmdbid or not str(tmdbid).isdigit():\n            return Subscribe()\n        result = await Subscribe.async_exists(db, tmdbid=int(tmdbid), season=season)\n    elif mediaid.startswith(\"douban:\"):\n        doubanid = mediaid[7:]\n        if not doubanid:\n            return Subscribe()\n        result = await Subscribe.async_get_by_doubanid(db, doubanid)\n        if not result and title:\n            title_check = True\n    elif mediaid.startswith(\"bangumi:\"):\n        bangumiid = mediaid[8:]\n        if not bangumiid or not str(bangumiid).isdigit():\n            return Subscribe()\n        result = await Subscribe.async_get_by_bangumiid(db, int(bangumiid))\n        if not result and title:\n            title_check = True\n    else:\n        result = await Subscribe.async_get_by_mediaid(db, mediaid)\n        if not result and title:\n            title_check = True\n    # 使用名称检查订阅\n    if title_check and title:\n        meta = MetaInfo(title)\n        if season is not None:\n            meta.begin_season = season\n        result = await Subscribe.async_get_by_title(db, title=meta.name, season=meta.begin_season)\n\n    return result if result else Subscribe()\n\n\n@router.get(\"/refresh\", summary=\"刷新订阅\", response_model=schemas.Response)\ndef refresh_subscribes(\n        _: schemas.TokenPayload = Depends(verify_token)) -> Any:\n    \"\"\"\n    刷新所有订阅\n    \"\"\"\n    Scheduler().start(\"subscribe_refresh\")\n    return schemas.Response(success=True)\n\n\n@router.get(\"/reset/{subid}\", summary=\"重置订阅\", response_model=schemas.Response)\nasync def reset_subscribes(\n        subid: int,\n        db: AsyncSession = Depends(get_async_db),\n        _: schemas.TokenPayload = Depends(verify_token)) -> Any:\n    \"\"\"\n    重置订阅\n    \"\"\"\n    subscribe = await Subscribe.async_get(db, subid)\n    if subscribe:\n        # 在更新之前获取旧数据\n        old_subscribe_dict = subscribe.to_dict()\n        # 更新订阅\n        await subscribe.async_update(db, {\n            \"note\": [],\n            \"lack_episode\": subscribe.total_episode,\n            \"state\": \"R\"\n        })\n        # 重新获取更新后的订阅数据\n        updated_subscribe = await Subscribe.async_get(db, subid)\n        # 发送订阅调整事件\n        await eventmanager.async_send_event(EventType.SubscribeModified, {\n            \"subscribe_id\": subid,\n            \"old_subscribe_info\": old_subscribe_dict,\n            \"subscribe_info\": updated_subscribe.to_dict() if updated_subscribe else {},\n        })\n        return schemas.Response(success=True)\n    return schemas.Response(success=False, message=\"订阅不存在\")\n\n\n@router.get(\"/check\", summary=\"刷新订阅 TMDB 信息\", response_model=schemas.Response)\ndef check_subscribes(\n        _: schemas.TokenPayload = Depends(verify_token)) -> Any:\n    \"\"\"\n    刷新订阅 TMDB 信息\n    \"\"\"\n    Scheduler().start(\"subscribe_tmdb\")\n    return schemas.Response(success=True)\n\n\n@router.get(\"/search\", summary=\"搜索所有订阅\", response_model=schemas.Response)\nasync def search_subscribes(\n        background_tasks: BackgroundTasks,\n        _: schemas.TokenPayload = Depends(verify_token)) -> Any:\n    \"\"\"\n    搜索所有订阅\n    \"\"\"\n    background_tasks.add_task(\n        Scheduler().start,\n        job_id=\"subscribe_search\",\n        **{\n            \"sid\": None,\n            \"state\": 'R',\n            \"manual\": True\n        }\n    )\n    return schemas.Response(success=True)\n\n\n@router.get(\"/search/{subscribe_id}\", summary=\"搜索订阅\", response_model=schemas.Response)\nasync def search_subscribe(\n        subscribe_id: int,\n        background_tasks: BackgroundTasks,\n        _: schemas.TokenPayload = Depends(verify_token)) -> Any:\n    \"\"\"\n    根据订阅编号搜索订阅\n    \"\"\"\n    background_tasks.add_task(\n        Scheduler().start,\n        job_id=\"subscribe_search\",\n        **{\n            \"sid\": subscribe_id,\n            \"state\": None,\n            \"manual\": True\n        }\n    )\n    return schemas.Response(success=True)\n\n\n@router.delete(\"/media/{mediaid}\", summary=\"删除订阅\", response_model=schemas.Response)\nasync def delete_subscribe_by_mediaid(\n        mediaid: str,\n        season: Optional[int] = None,\n        db: AsyncSession = Depends(get_async_db),\n        _: schemas.TokenPayload = Depends(verify_token)\n) -> Any:\n    \"\"\"\n    根据TMDBID或豆瓣ID删除订阅 tmdb:/douban:\n    \"\"\"\n    delete_subscribes = []\n    if mediaid.startswith(\"tmdb:\"):\n        tmdbid = mediaid[5:]\n        if not tmdbid or not str(tmdbid).isdigit():\n            return schemas.Response(success=False)\n        subscribes = await Subscribe.async_get_by_tmdbid(db, int(tmdbid), season)\n        delete_subscribes.extend(subscribes)\n    elif mediaid.startswith(\"douban:\"):\n        doubanid = mediaid[7:]\n        if not doubanid:\n            return schemas.Response(success=False)\n        subscribe = await Subscribe.async_get_by_doubanid(db, doubanid)\n        if subscribe:\n            delete_subscribes.append(subscribe)\n    else:\n        subscribe = await Subscribe.async_get_by_mediaid(db, mediaid)\n        if subscribe:\n            delete_subscribes.append(subscribe)\n    for subscribe in delete_subscribes:\n        # 在删除之前获取订阅信息\n        subscribe_info = subscribe.to_dict()\n        subscribe_id = subscribe.id\n        await Subscribe.async_delete(db, subscribe_id)\n        # 发送事件\n        await eventmanager.async_send_event(EventType.SubscribeDeleted, {\n            \"subscribe_id\": subscribe_id,\n            \"subscribe_info\": subscribe_info\n        })\n    return schemas.Response(success=True)\n\n\n@router.post(\"/seerr\", summary=\"OverSeerr/JellySeerr通知订阅\", response_model=schemas.Response)\nasync def seerr_subscribe(request: Request, background_tasks: BackgroundTasks,\n                          authorization: Annotated[str | None, Header()] = None) -> Any:\n    \"\"\"\n    Jellyseerr/Overseerr网络勾子通知订阅\n    \"\"\"\n    if not authorization or authorization != settings.API_TOKEN:\n        raise HTTPException(\n            status_code=400,\n            detail=\"授权失败\",\n        )\n    req_json = await request.json()\n    if not req_json:\n        raise HTTPException(\n            status_code=500,\n            detail=\"报文内容为空\",\n        )\n    notification_type = req_json.get(\"notification_type\")\n    if notification_type not in [\"MEDIA_APPROVED\", \"MEDIA_AUTO_APPROVED\"]:\n        return schemas.Response(success=False, message=\"不支持的通知类型\")\n    subject = req_json.get(\"subject\")\n    media_type = MediaType.MOVIE if req_json.get(\"media\", {}).get(\"media_type\") == \"movie\" else MediaType.TV\n    tmdbId = req_json.get(\"media\", {}).get(\"tmdbId\")\n    if not media_type or not tmdbId or not subject:\n        return schemas.Response(success=False, message=\"请求参数不正确\")\n    user_name = req_json.get(\"request\", {}).get(\"requestedBy_username\")\n    # 添加订阅\n    if media_type == MediaType.MOVIE:\n        background_tasks.add_task(start_subscribe_add,\n                                  mtype=media_type,\n                                  tmdbid=tmdbId,\n                                  title=subject,\n                                  year=\"\",\n                                  season=0,\n                                  username=user_name)\n    else:\n        seasons = []\n        for extra in req_json.get(\"extra\", []):\n            if extra.get(\"name\") == \"Requested Seasons\":\n                seasons = [int(str(sea).strip()) for sea in extra.get(\"value\").split(\", \") if str(sea).isdigit()]\n                break\n        for season in seasons:\n            background_tasks.add_task(start_subscribe_add,\n                                      mtype=media_type,\n                                      tmdbid=tmdbId,\n                                      title=subject,\n                                      year=\"\",\n                                      season=season,\n                                      username=user_name)\n\n    return schemas.Response(success=True)\n\n\n@router.get(\"/history/{mtype}\", summary=\"查询订阅历史\", response_model=List[schemas.Subscribe])\nasync def subscribe_history(\n        mtype: str,\n        page: Optional[int] = 1,\n        count: Optional[int] = 30,\n        db: AsyncSession = Depends(get_async_db),\n        _: schemas.TokenPayload = Depends(verify_token)) -> Any:\n    \"\"\"\n    查询电影/电视剧订阅历史\n    \"\"\"\n    return await SubscribeHistory.async_list_by_type(db, mtype=mtype, page=page, count=count)\n\n\n@router.delete(\"/history/{history_id}\", summary=\"删除订阅历史\", response_model=schemas.Response)\nasync def delete_subscribe(\n        history_id: int,\n        db: AsyncSession = Depends(get_async_db),\n        _: schemas.TokenPayload = Depends(verify_token)\n) -> Any:\n    \"\"\"\n    删除订阅历史\n    \"\"\"\n    await SubscribeHistory.async_delete(db, history_id)\n    return schemas.Response(success=True)\n\n\n@router.get(\"/popular\", summary=\"热门订阅（基于用户共享数据）\", response_model=List[schemas.MediaInfo])\nasync def popular_subscribes(\n        stype: str,\n        page: Optional[int] = 1,\n        count: Optional[int] = 30,\n        min_sub: Optional[int] = None,\n        genre_id: Optional[int] = None,\n        min_rating: Optional[float] = None,\n        max_rating: Optional[float] = None,\n        sort_type: Optional[str] = None,\n        _: schemas.TokenPayload = Depends(verify_token)) -> Any:\n    \"\"\"\n    查询热门订阅\n    \"\"\"\n    subscribes = await SubscribeHelper().async_get_statistic(\n        stype=stype,\n        page=page,\n        count=count,\n        genre_id=genre_id,\n        min_rating=min_rating,\n        max_rating=max_rating,\n        sort_type=sort_type\n    )\n    if subscribes:\n        ret_medias = []\n        for sub in subscribes:\n            # 订阅人数\n            count = sub.get(\"count\")\n            if min_sub and count < min_sub:\n                continue\n            media = MediaInfo()\n            media.type = MediaType(sub.get(\"type\"))\n            media.tmdb_id = sub.get(\"tmdbid\")\n            # 处理标题\n            title = sub.get(\"name\")\n            season = sub.get(\"season\")\n            if season and int(season) > 1 and media.tmdb_id:\n                # 小写数据转大写\n                season_str = cn2an.an2cn(season, \"low\")\n                title = f\"{title} 第{season_str}季\"\n            media.title = title\n            media.year = sub.get(\"year\")\n            media.douban_id = sub.get(\"doubanid\")\n            media.bangumi_id = sub.get(\"bangumiid\")\n            media.tvdb_id = sub.get(\"tvdbid\")\n            media.imdb_id = sub.get(\"imdbid\")\n            media.season = sub.get(\"season\")\n            media.overview = sub.get(\"description\")\n            media.vote_average = sub.get(\"vote\")\n            media.poster_path = sub.get(\"poster\")\n            media.backdrop_path = sub.get(\"backdrop\")\n            media.popularity = count\n            ret_medias.append(media)\n        return [media.to_dict() for media in ret_medias]\n    return []\n\n\n@router.get(\"/user/{username}\", summary=\"用户订阅\", response_model=List[schemas.Subscribe])\nasync def user_subscribes(\n        username: str,\n        db: AsyncSession = Depends(get_async_db),\n        _: schemas.TokenPayload = Depends(verify_token)) -> Any:\n    \"\"\"\n    查询用户订阅\n    \"\"\"\n    return await Subscribe.async_list_by_username(db, username)\n\n\n@router.get(\"/files/{subscribe_id}\", summary=\"订阅相关文件信息\", response_model=schemas.SubscrbieInfo)\ndef subscribe_files(\n        subscribe_id: int,\n        db: Session = Depends(get_db),\n        _: schemas.TokenPayload = Depends(verify_token)) -> Any:\n    \"\"\"\n    订阅相关文件信息\n    \"\"\"\n    subscribe = Subscribe.get(db, subscribe_id)\n    if subscribe:\n        return SubscribeChain().subscribe_files_info(subscribe)\n    return schemas.SubscrbieInfo()\n\n\n@router.post(\"/share\", summary=\"分享订阅\", response_model=schemas.Response)\nasync def subscribe_share(\n        sub: schemas.SubscribeShare,\n        _: schemas.TokenPayload = Depends(verify_token)) -> Any:\n    \"\"\"\n    分享订阅\n    \"\"\"\n    state, errmsg = await SubscribeHelper().async_sub_share(subscribe_id=sub.subscribe_id,\n                                                            share_title=sub.share_title,\n                                                            share_comment=sub.share_comment,\n                                                            share_user=sub.share_user)\n    return schemas.Response(success=state, message=errmsg)\n\n\n@router.delete(\"/share/{share_id}\", summary=\"删除分享\", response_model=schemas.Response)\nasync def subscribe_share_delete(\n        share_id: int,\n        _: schemas.TokenPayload = Depends(verify_token)) -> Any:\n    \"\"\"\n    删除分享\n    \"\"\"\n    state, errmsg = await SubscribeHelper().async_share_delete(share_id=share_id)\n    return schemas.Response(success=state, message=errmsg)\n\n\n@router.post(\"/fork\", summary=\"复用订阅\", response_model=schemas.Response)\nasync def subscribe_fork(\n        sub: schemas.SubscribeShare,\n        current_user: User = Depends(get_current_active_user_async)) -> Any:\n    \"\"\"\n    复用订阅\n    \"\"\"\n    sub_dict = sub.model_dump()\n    sub_dict.pop(\"id\")\n    for key in list(sub_dict.keys()):\n        if not hasattr(schemas.Subscribe(), key):\n            sub_dict.pop(key)\n    result = await create_subscribe(subscribe_in=schemas.Subscribe(**sub_dict),\n                                    current_user=current_user)\n    if result.success:\n        await SubscribeHelper().async_sub_fork(share_id=sub.id)\n    return result\n\n\n@router.get(\"/follow\", summary=\"查询已Follow的订阅分享人\", response_model=List[str])\nasync def followed_subscribers(_: schemas.TokenPayload = Depends(verify_token)) -> Any:\n    \"\"\"\n    查询已Follow的订阅分享人\n    \"\"\"\n    return SystemConfigOper().get(SystemConfigKey.FollowSubscribers) or []\n\n\n@router.post(\"/follow\", summary=\"Follow订阅分享人\", response_model=schemas.Response)\nasync def follow_subscriber(\n        share_uid: Optional[str] = None,\n        _: schemas.TokenPayload = Depends(verify_token)) -> Any:\n    \"\"\"\n    Follow订阅分享人\n    \"\"\"\n    subscribers = SystemConfigOper().get(SystemConfigKey.FollowSubscribers) or []\n    if share_uid and share_uid not in subscribers:\n        subscribers.append(share_uid)\n        await SystemConfigOper().async_set(SystemConfigKey.FollowSubscribers, subscribers)\n    return schemas.Response(success=True)\n\n\n@router.delete(\"/follow\", summary=\"取消Follow订阅分享人\", response_model=schemas.Response)\nasync def unfollow_subscriber(\n        share_uid: Optional[str] = None,\n        _: schemas.TokenPayload = Depends(verify_token)) -> Any:\n    \"\"\"\n    取消Follow订阅分享人\n    \"\"\"\n    subscribers = SystemConfigOper().get(SystemConfigKey.FollowSubscribers) or []\n    if share_uid and share_uid in subscribers:\n        subscribers.remove(share_uid)\n        await SystemConfigOper().async_set(SystemConfigKey.FollowSubscribers, subscribers)\n    return schemas.Response(success=True)\n\n\n@router.get(\"/shares\", summary=\"查询分享的订阅\", response_model=List[schemas.SubscribeShare])\nasync def popular_subscribes(\n        name: Optional[str] = None,\n        page: Optional[int] = 1,\n        count: Optional[int] = 30,\n        genre_id: Optional[int] = None,\n        min_rating: Optional[float] = None,\n        max_rating: Optional[float] = None,\n        sort_type: Optional[str] = None,\n        _: schemas.TokenPayload = Depends(verify_token)) -> Any:\n    \"\"\"\n    查询分享的订阅\n    \"\"\"\n    return await SubscribeHelper().async_get_shares(\n        name=name,\n        page=page,\n        count=count,\n        genre_id=genre_id,\n        min_rating=min_rating,\n        max_rating=max_rating,\n        sort_type=sort_type\n    )\n\n\n@router.get(\"/share/statistics\", summary=\"查询订阅分享统计\", response_model=List[schemas.SubscribeShareStatistics])\nasync def subscribe_share_statistics(_: schemas.TokenPayload = Depends(verify_token)) -> Any:\n    \"\"\"\n    查询订阅分享统计\n    返回每个分享人分享的媒体数量以及总的复用人次\n    \"\"\"\n    return await SubscribeHelper().async_get_share_statistics()\n\n\n@router.get(\"/{subscribe_id}\", summary=\"订阅详情\", response_model=schemas.Subscribe)\nasync def read_subscribe(\n        subscribe_id: int,\n        db: AsyncSession = Depends(get_async_db),\n        _: schemas.TokenPayload = Depends(verify_token)) -> Any:\n    \"\"\"\n    根据订阅编号查询订阅信息\n    \"\"\"\n    if not subscribe_id:\n        return Subscribe()\n    return await Subscribe.async_get(db, subscribe_id)\n\n\n@router.delete(\"/{subscribe_id}\", summary=\"删除订阅\", response_model=schemas.Response)\nasync def delete_subscribe(\n        subscribe_id: int,\n        db: AsyncSession = Depends(get_async_db),\n        _: schemas.TokenPayload = Depends(verify_token)\n) -> Any:\n    \"\"\"\n    删除订阅信息\n    \"\"\"\n    subscribe = await Subscribe.async_get(db, subscribe_id)\n    if subscribe:\n        # 在删除之前获取订阅信息\n        subscribe_info = subscribe.to_dict()\n        await Subscribe.async_delete(db, subscribe_id)\n        # 发送事件\n        await eventmanager.async_send_event(EventType.SubscribeDeleted, {\n            \"subscribe_id\": subscribe_id,\n            \"subscribe_info\": subscribe_info\n        })\n        # 统计订阅\n        SubscribeHelper().sub_done_async({\n            \"tmdbid\": subscribe.tmdbid,\n            \"doubanid\": subscribe.doubanid\n        })\n    return schemas.Response(success=True)\n"
  },
  {
    "path": "app/api/endpoints/system.py",
    "content": "import asyncio\nimport json\nimport re\nfrom collections import deque\nfrom datetime import datetime\nfrom typing import Optional, Union, Annotated\n\nimport aiofiles\nimport pillow_avif  # noqa 用于自动注册AVIF支持\nfrom anyio import Path as AsyncPath\nfrom app.helper.sites import SitesHelper  # noqa  # noqa\nfrom fastapi import APIRouter, Body, Depends, HTTPException, Header, Request, Response\nfrom fastapi.responses import StreamingResponse\n\nfrom app import schemas\nfrom app.chain.mediaserver import MediaServerChain\nfrom app.chain.search import SearchChain\nfrom app.chain.system import SystemChain\nfrom app.core.config import global_vars, settings\nfrom app.core.event import eventmanager\nfrom app.core.metainfo import MetaInfo\nfrom app.core.module import ModuleManager\nfrom app.core.security import verify_apitoken, verify_resource_token, verify_token\nfrom app.db.models import User\nfrom app.db.systemconfig_oper import SystemConfigOper\nfrom app.db.user_oper import get_current_active_superuser, get_current_active_superuser_async, \\\n    get_current_active_user_async\nfrom app.helper.llm import LLMHelper\nfrom app.helper.mediaserver import MediaServerHelper\nfrom app.helper.message import MessageHelper\nfrom app.helper.progress import ProgressHelper\nfrom app.helper.rule import RuleHelper\nfrom app.helper.subscribe import SubscribeHelper\nfrom app.helper.system import SystemHelper\nfrom app.helper.image import ImageHelper\nfrom app.log import logger\nfrom app.scheduler import Scheduler\nfrom app.schemas import ConfigChangeEventData\nfrom app.schemas.types import SystemConfigKey, EventType\nfrom app.utils.crypto import HashUtils\nfrom app.utils.http import RequestUtils, AsyncRequestUtils\nfrom app.utils.security import SecurityUtils\nfrom app.utils.url import UrlUtils\nfrom version import APP_VERSION\n\nrouter = APIRouter()\n\n\nasync def fetch_image(\n        url: str,\n        proxy: Optional[bool] = None,\n        use_cache: bool = False,\n        if_none_match: Optional[str] = None,\n        cookies: Optional[str | dict] = None,\n        allowed_domains: Optional[set[str]] = None) -> Optional[Response]:\n    \"\"\"\n    处理图片缓存逻辑，支持HTTP缓存和磁盘缓存\n    \"\"\"\n    if not url:\n        return None\n\n    if allowed_domains is None:\n        allowed_domains = set(settings.SECURITY_IMAGE_DOMAINS)\n\n    # 验证URL安全性\n    if not SecurityUtils.is_safe_url(url, allowed_domains):\n        logger.warn(f\"Blocked unsafe image URL: {url}\")\n        return None\n\n    content = await ImageHelper().async_fetch_image(\n        url=url,\n        proxy=proxy,\n        use_cache=use_cache,\n        cookies=cookies,\n    )\n    if content:\n        # 检查 If-None-Match\n        etag = HashUtils.md5(content)\n        headers = RequestUtils.generate_cache_headers(etag, max_age=86400 * 7)\n        if if_none_match == etag:\n            return Response(status_code=304, headers=headers)\n        # 返回缓存图片\n        return Response(\n            content=content,\n            media_type=UrlUtils.get_mime_type(url, \"image/jpeg\"),\n            headers=headers\n        )\n\n\n@router.get(\"/img/{proxy}\", summary=\"图片代理\")\nasync def proxy_img(\n        imgurl: str,\n        proxy: bool = False,\n        cache: bool = False,\n        use_cookies: bool = False,\n        if_none_match: Annotated[str | None, Header()] = None,\n        _: schemas.TokenPayload = Depends(verify_resource_token)\n) -> Response:\n    \"\"\"\n    图片代理，可选是否使用代理服务器，支持 HTTP 缓存\n    \"\"\"\n    # 媒体服务器添加图片代理支持\n    hosts = [config.config.get(\"host\") for config in MediaServerHelper().get_configs().values() if\n             config and config.config and config.config.get(\"host\")]\n    allowed_domains = set(settings.SECURITY_IMAGE_DOMAINS) | set(hosts)\n    cookies = (\n        MediaServerChain().get_image_cookies(server=None, image_url=imgurl)\n        if use_cookies\n        else None\n    )\n    return await fetch_image(url=imgurl, proxy=proxy, use_cache=cache, cookies=cookies,\n                             if_none_match=if_none_match, allowed_domains=allowed_domains)\n\n\n@router.get(\"/cache/image\", summary=\"图片缓存\")\nasync def cache_img(\n        url: str,\n        if_none_match: Annotated[str | None, Header()] = None,\n        _: schemas.TokenPayload = Depends(verify_resource_token)\n) -> Response:\n    \"\"\"\n    本地缓存图片文件，支持 HTTP 缓存，如果启用全局图片缓存，则使用磁盘缓存\n    \"\"\"\n    # 如果没有启用全局图片缓存，则不使用磁盘缓存\n    return await fetch_image(url=url, use_cache=settings.GLOBAL_IMAGE_CACHE,\n                             if_none_match=if_none_match)\n\n\n@router.get(\"/global\", summary=\"查询非敏感系统设置\", response_model=schemas.Response)\ndef get_global_setting(token: str):\n    \"\"\"\n    查询非敏感系统设置（默认鉴权）\n    仅包含登录前UI初始化必需的字段\n    \"\"\"\n    if token != \"moviepilot\":\n        raise HTTPException(status_code=403, detail=\"Forbidden\")\n\n    # 白名单模式，仅包含登录前UI初始化必需的字段\n    info = settings.model_dump(\n        include={\n            \"TMDB_IMAGE_DOMAIN\",\n            \"GLOBAL_IMAGE_CACHE\",\n            \"ADVANCED_MODE\",\n        }\n    )\n    # 追加版本信息（用于版本检查）\n    info.update({\n        \"FRONTEND_VERSION\": SystemChain.get_frontend_version(),\n        \"BACKEND_VERSION\": APP_VERSION\n    })\n    return schemas.Response(success=True,\n                            data=info)\n\n\n@router.get(\"/global/user\", summary=\"查询用户相关系统设置\", response_model=schemas.Response)\nasync def get_user_global_setting(_: User = Depends(get_current_active_user_async)):\n    \"\"\"\n    查询用户相关系统设置（登录后获取）\n    包含业务功能相关的配置和用户权限信息\n    \"\"\"\n    # 业务功能相关的配置字段\n    info = settings.model_dump(\n        include={\n            \"RECOGNIZE_SOURCE\",\n            \"SEARCH_SOURCE\",\n            \"AI_RECOMMEND_ENABLED\",\n            \"PASSKEY_ALLOW_REGISTER_WITHOUT_OTP\"\n        }\n    )\n    # 智能助手总开关未开启，智能推荐状态强制返回False\n    if not settings.AI_AGENT_ENABLE:\n        info[\"AI_RECOMMEND_ENABLED\"] = False\n\n    # 追加用户唯一ID和订阅分享管理权限\n    share_admin = SubscribeHelper().is_admin_user()\n    info.update({\n        \"USER_UNIQUE_ID\": SubscribeHelper().get_user_uuid(),\n        \"SUBSCRIBE_SHARE_MANAGE\": share_admin,\n        \"WORKFLOW_SHARE_MANAGE\": share_admin,\n    })\n    return schemas.Response(success=True,\n                            data=info)\n\n\n@router.get(\"/env\", summary=\"查询系统配置\", response_model=schemas.Response)\nasync def get_env_setting(_: User = Depends(get_current_active_user_async)):\n    \"\"\"\n    查询系统环境变量，包括当前版本号（仅管理员）\n    \"\"\"\n    info = settings.model_dump(\n        exclude={\"SECRET_KEY\", \"RESOURCE_SECRET_KEY\"}\n    )\n    info.update({\n        \"VERSION\": APP_VERSION,\n        \"AUTH_VERSION\": SitesHelper().auth_version,\n        \"INDEXER_VERSION\": SitesHelper().indexer_version,\n        \"FRONTEND_VERSION\": SystemChain().get_frontend_version()\n    })\n    return schemas.Response(success=True,\n                            data=info)\n\n\n@router.post(\"/env\", summary=\"更新系统配置\", response_model=schemas.Response)\nasync def set_env_setting(env: dict,\n                          _: User = Depends(get_current_active_superuser_async)):\n    \"\"\"\n    更新系统环境变量（仅管理员）\n    \"\"\"\n    result = settings.update_settings(env=env)\n    # 统计成功和失败的结果\n    success_updates = {k: v for k, v in result.items() if v[0]}\n    failed_updates = {k: v for k, v in result.items() if v[0] is False}\n\n    if failed_updates:\n        return schemas.Response(\n            success=False,\n            message=f\"{', '.join([v[1] for v in failed_updates.values()])}\",\n            data={\n                \"success_updates\": success_updates,\n                \"failed_updates\": failed_updates\n            }\n        )\n\n    if success_updates:\n        # 发送配置变更事件\n        await eventmanager.async_send_event(etype=EventType.ConfigChanged, data=ConfigChangeEventData(\n            key=success_updates.keys(),\n            change_type=\"update\"\n        ))\n\n    return schemas.Response(\n        success=True,\n        message=\"所有配置项更新成功\",\n        data={\n            \"success_updates\": success_updates\n        }\n    )\n\n\n@router.get(\"/progress/{process_type}\", summary=\"实时进度\")\nasync def get_progress(request: Request, process_type: str, _: schemas.TokenPayload = Depends(verify_resource_token)):\n    \"\"\"\n    实时获取处理进度，返回格式为SSE\n    \"\"\"\n    progress = ProgressHelper(process_type)\n\n    async def event_generator():\n        try:\n            while not global_vars.is_system_stopped:\n                if await request.is_disconnected():\n                    break\n                detail = progress.get()\n                yield f\"data: {json.dumps(detail)}\\n\\n\"\n                await asyncio.sleep(0.5)\n        except asyncio.CancelledError:\n            return\n\n    return StreamingResponse(event_generator(), media_type=\"text/event-stream\")\n\n\n@router.get(\"/setting/{key}\", summary=\"查询系统设置\", response_model=schemas.Response)\nasync def get_setting(key: str,\n                      _: User = Depends(get_current_active_user_async)):\n    \"\"\"\n    查询系统设置（仅管理员）\n    \"\"\"\n    if hasattr(settings, key):\n        value = getattr(settings, key)\n    else:\n        value = SystemConfigOper().get(key)\n    return schemas.Response(success=True, data={\n        \"value\": value\n    })\n\n\n@router.post(\"/setting/{key}\", summary=\"更新系统设置\", response_model=schemas.Response)\nasync def set_setting(\n        key: str,\n        value: Annotated[Union[list, dict, bool, int, str] | None, Body()] = None,\n        _: User = Depends(get_current_active_superuser_async),\n):\n    \"\"\"\n    更新系统设置（仅管理员）\n    \"\"\"\n    if hasattr(settings, key):\n        success, message = settings.update_setting(key=key, value=value)\n        if success:\n            # 发送配置变更事件\n            await eventmanager.async_send_event(etype=EventType.ConfigChanged, data=ConfigChangeEventData(\n                key=key,\n                value=value,\n                change_type=\"update\"\n            ))\n        elif success is None:\n            success = True\n        return schemas.Response(success=success, message=message)\n    elif key in {item.value for item in SystemConfigKey}:\n        if isinstance(value, list):\n            value = list(filter(None, value))\n            value = value if value else None\n        success = await SystemConfigOper().async_set(key, value)\n        if success:\n            # 发送配置变更事件\n            await eventmanager.async_send_event(etype=EventType.ConfigChanged, data=ConfigChangeEventData(\n                key=key,\n                value=value,\n                change_type=\"update\"\n            ))\n        return schemas.Response(success=True)\n    else:\n        return schemas.Response(success=False, message=f\"配置项 '{key}' 不存在\")\n\n\n@router.get(\"/llm-models\", summary=\"获取LLM模型列表\", response_model=schemas.Response)\nasync def get_llm_models(provider: str, api_key: str, base_url: Optional[str] = None, _: User = Depends(get_current_active_user_async)):\n    \"\"\"\n    获取LLM模型列表\n    \"\"\"\n    try:\n        models = LLMHelper().get_models(provider, api_key, base_url)\n        return schemas.Response(success=True, data=models)\n    except Exception as e:\n        return schemas.Response(success=False, message=str(e))\n\n\n@router.get(\"/message\", summary=\"实时消息\")\nasync def get_message(request: Request, role: Optional[str] = \"system\",\n                      _: schemas.TokenPayload = Depends(verify_resource_token)):\n    \"\"\"\n    实时获取系统消息，返回格式为SSE\n    \"\"\"\n    message = MessageHelper()\n\n    async def event_generator():\n        try:\n            while not global_vars.is_system_stopped:\n                if await request.is_disconnected():\n                    break\n                detail = message.get(role)\n                yield f\"data: {detail or ''}\\n\\n\"\n                await asyncio.sleep(3)\n        except asyncio.CancelledError:\n            return\n\n    return StreamingResponse(event_generator(), media_type=\"text/event-stream\")\n\n\n@router.get(\"/logging\", summary=\"实时日志\")\nasync def get_logging(request: Request, length: Optional[int] = 50, logfile: Optional[str] = \"moviepilot.log\",\n                      _: schemas.TokenPayload = Depends(verify_resource_token)):\n    \"\"\"\n    实时获取系统日志\n    length = -1 时, 返回text/plain\n    否则 返回格式SSE\n    \"\"\"\n    base_path = AsyncPath(settings.LOG_PATH)\n    log_path = base_path / logfile\n\n    if not await SecurityUtils.async_is_safe_path(base_path=base_path, user_path=log_path, allowed_suffixes={\".log\"}):\n        raise HTTPException(status_code=404, detail=\"Not Found\")\n\n    if not await log_path.exists() or not await log_path.is_file():\n        raise HTTPException(status_code=404, detail=\"Not Found\")\n\n    async def log_generator():\n        try:\n            # 使用固定大小的双向队列来限制内存使用\n            lines_queue = deque(maxlen=max(length, 50))\n            # 获取文件大小\n            file_stat = await log_path.stat()\n            file_size = file_stat.st_size\n\n            # 读取历史日志\n            async with aiofiles.open(log_path, mode=\"r\", encoding=\"utf-8\", errors=\"ignore\") as f:\n                # 优化大文件读取策略\n                if file_size > 100 * 1024:\n                    # 只读取最后100KB的内容\n                    bytes_to_read = min(file_size, 100 * 1024)\n                    position = file_size - bytes_to_read\n                    await f.seek(position)\n                    content = await f.read()\n                    # 找到第一个完整的行\n                    first_newline = content.find('\\n')\n                    if first_newline != -1:\n                        content = content[first_newline + 1:]\n                else:\n                    # 小文件直接读取全部内容\n                    content = await f.read()\n\n                # 按行分割并添加到队列，只保留非空行\n                lines = [line.strip() for line in content.splitlines() if line.strip()]\n                # 只取最后N行\n                for line in lines[-max(length, 50):]:\n                    lines_queue.append(line)\n\n            # 输出历史日志\n            for line in lines_queue:\n                yield f\"data: {line}\\n\\n\"\n\n            # 实时监听新日志\n            async with aiofiles.open(log_path, mode=\"r\", encoding=\"utf-8\", errors=\"ignore\") as f:\n                # 移动文件指针到文件末尾，继续监听新增内容\n                await f.seek(0, 2)\n                # 记录初始文件大小\n                initial_stat = await log_path.stat()\n                initial_size = initial_stat.st_size\n                # 实时监听新日志，使用更短的轮询间隔\n                while not global_vars.is_system_stopped:\n                    if await request.is_disconnected():\n                        break\n                    # 检查文件是否有新内容\n                    current_stat = await log_path.stat()\n                    current_size = current_stat.st_size\n                    if current_size > initial_size:\n                        # 文件有新内容，读取新行\n                        line = await f.readline()\n                        if line:\n                            line = line.strip()\n                            if line:\n                                yield f\"data: {line}\\n\\n\"\n                        initial_size = current_size\n                    else:\n                        # 没有新内容，短暂等待\n                        await asyncio.sleep(0.5)\n        except asyncio.CancelledError:\n            return\n        except Exception as err:\n            logger.error(f\"日志读取异常: {err}\")\n            yield f\"data: 日志读取异常: {err}\\n\\n\"\n\n    # 根据length参数返回不同的响应\n    if length == -1:\n        # 返回全部日志作为文本响应\n        if not await log_path.exists():\n            return Response(content=\"日志文件不存在！\", media_type=\"text/plain\")\n        try:\n            # 使用 aiofiles 异步读取文件\n            async with aiofiles.open(log_path, mode=\"r\", encoding=\"utf-8\", errors=\"ignore\") as file:\n                text = await file.read()\n            # 倒序输出\n            text = \"\\n\".join(text.split(\"\\n\")[::-1])\n            return Response(content=text, media_type=\"text/plain\")\n        except Exception as e:\n            return Response(content=f\"读取日志文件失败: {e}\", media_type=\"text/plain\")\n    else:\n        # 返回SSE流响应\n        return StreamingResponse(log_generator(), media_type=\"text/event-stream\")\n\n\n@router.get(\"/versions\", summary=\"查询Github所有Release版本\", response_model=schemas.Response)\nasync def latest_version(_: schemas.TokenPayload = Depends(verify_token)):\n    \"\"\"\n    查询Github所有Release版本\n    \"\"\"\n    version_res = await AsyncRequestUtils(proxies=settings.PROXY, headers=settings.GITHUB_HEADERS).get_res(\n        f\"https://api.github.com/repos/jxxghp/MoviePilot/releases\")\n    if version_res:\n        ver_json = version_res.json()\n        if ver_json:\n            return schemas.Response(success=True, data=ver_json)\n    return schemas.Response(success=False)\n\n\n@router.get(\"/ruletest\", summary=\"过滤规则测试\", response_model=schemas.Response)\ndef ruletest(title: str,\n             rulegroup_name: str,\n             subtitle: Optional[str] = None,\n             _: schemas.TokenPayload = Depends(verify_token)):\n    \"\"\"\n    过滤规则测试，规则类型 1-订阅，2-洗版，3-搜索\n    \"\"\"\n    torrent = schemas.TorrentInfo(\n        title=title,\n        description=subtitle,\n    )\n    # 查询规则组详情\n    rulegroup = RuleHelper().get_rule_group(rulegroup_name)\n    if not rulegroup:\n        return schemas.Response(success=False, message=f\"过滤规则组 {rulegroup_name} 不存在！\")\n\n    # 根据标题查询媒体信息\n    media_info = SearchChain().recognize_media(MetaInfo(title=title, subtitle=subtitle))\n    if not media_info:\n        return schemas.Response(success=False, message=\"未识别到媒体信息！\")\n\n    # 过滤\n    result = SearchChain().filter_torrents(rule_groups=[rulegroup.name],\n                                           torrent_list=[torrent], mediainfo=media_info)\n    if not result:\n        return schemas.Response(success=False, message=\"不符合过滤规则！\")\n    return schemas.Response(success=True, data={\n        \"priority\": 100 - result[0].pri_order + 1\n    })\n\n\n@router.get(\"/nettest\", summary=\"测试网络连通性\")\nasync def nettest(\n        url: str,\n        proxy: bool,\n        include: Optional[str] = None,\n        _: schemas.TokenPayload = Depends(verify_token),\n):\n    \"\"\"\n    测试网络连通性\n    \"\"\"\n    # 记录开始的毫秒数\n    start_time = datetime.now()\n    headers = None\n    # 当前使用的加速代理\n    proxy_name = \"\"\n    if \"github\" in url:\n        # 这是github的连通性测试\n        headers = settings.GITHUB_HEADERS\n    if \"{GITHUB_PROXY}\" in url:\n        url = url.replace(\n            \"{GITHUB_PROXY}\", UrlUtils.standardize_base_url(settings.GITHUB_PROXY or \"\")\n        )\n        if settings.GITHUB_PROXY:\n            proxy_name = \"Github加速代理\"\n    if \"{PIP_PROXY}\" in url:\n        url = url.replace(\n            \"{PIP_PROXY}\",\n            UrlUtils.standardize_base_url(\n                settings.PIP_PROXY or \"https://pypi.org/simple/\"\n            ),\n        )\n        if settings.PIP_PROXY:\n            proxy_name = \"PIP加速代理\"\n    url = url.replace(\"{TMDBAPIKEY}\", settings.TMDB_API_KEY)\n    result = await AsyncRequestUtils(\n        proxies=settings.PROXY if proxy else None,\n        headers=headers,\n        timeout=10,\n        ua=settings.NORMAL_USER_AGENT,\n    ).get_res(url)\n    # 计时结束的毫秒数\n    end_time = datetime.now()\n    time = round((end_time - start_time).total_seconds() * 1000)\n    # 计算相关秒数\n    if result is None:\n        return schemas.Response(\n            success=False, message=f\"{proxy_name}无法连接\", data={\"time\": time}\n        )\n    elif result.status_code == 200:\n        if include and not re.search(r\"%s\" % include, result.text, re.IGNORECASE):\n            # 通常是被加速代理跳转到其它页面了\n            logger.error(f\"{url} 的响应内容不匹配包含规则 {include}\")\n            if proxy_name:\n                message = f\"{proxy_name}已失效，请检查配置\"\n            else:\n                message = f\"无效响应，不匹配 {include}\"\n            return schemas.Response(\n                success=False,\n                message=message,\n                data={\"time\": time},\n            )\n        return schemas.Response(success=True, data={\"time\": time})\n    else:\n        if proxy_name:\n            # 加速代理失败\n            message = f\"{proxy_name}已失效，错误码：{result.status_code}\"\n        else:\n            message = f\"错误码：{result.status_code}\"\n            if \"github\" in url:\n                # 非加速代理访问github\n                if result.status_code == 401:\n                    message = \"Github Token已失效，请检查配置\"\n                elif result.status_code in {403, 429}:\n                    message = \"触发限流，请配置Github Token\"\n        return schemas.Response(success=False, message=message, data={\"time\": time})\n\n\n@router.get(\"/modulelist\", summary=\"查询已加载的模块ID列表\", response_model=schemas.Response)\ndef modulelist(_: schemas.TokenPayload = Depends(verify_token)):\n    \"\"\"\n    查询已加载的模块ID列表\n    \"\"\"\n    modules = [{\n        \"id\": k,\n        \"name\": v.get_name(),\n    } for k, v in ModuleManager().get_modules().items()]\n    return schemas.Response(success=True, data={\n        \"modules\": modules\n    })\n\n\n@router.get(\"/moduletest/{moduleid}\", summary=\"模块可用性测试\", response_model=schemas.Response)\ndef moduletest(moduleid: str, _: schemas.TokenPayload = Depends(verify_token)):\n    \"\"\"\n    模块可用性测试接口\n    \"\"\"\n    state, errmsg = ModuleManager().test(moduleid)\n    return schemas.Response(success=state, message=errmsg)\n\n\n@router.get(\"/restart\", summary=\"重启系统\", response_model=schemas.Response)\ndef restart_system(_: User = Depends(get_current_active_superuser)):\n    \"\"\"\n    重启系统（仅管理员）\n    \"\"\"\n    if not SystemHelper.can_restart():\n        return schemas.Response(success=False, message=\"当前运行环境不支持重启操作！\")\n    # 标识停止事件\n    global_vars.stop_system()\n    # 执行重启\n    ret, msg = SystemHelper.restart()\n    return schemas.Response(success=ret, message=msg)\n\n\n@router.get(\"/runscheduler\", summary=\"运行服务\", response_model=schemas.Response)\ndef run_scheduler(jobid: str,\n                  _: User = Depends(get_current_active_superuser)):\n    \"\"\"\n    执行命令（仅管理员）\n    \"\"\"\n    if not jobid:\n        return schemas.Response(success=False, message=\"命令不能为空！\")\n    if jobid in {\"recommend_refresh\", \"cookiecloud\"}:\n        Scheduler().start(jobid, manual=True)\n    else:\n        Scheduler().start(jobid)\n    return schemas.Response(success=True)\n\n\n@router.get(\"/runscheduler2\", summary=\"运行服务（API_TOKEN）\", response_model=schemas.Response)\ndef run_scheduler2(jobid: str,\n                   _: Annotated[str, Depends(verify_apitoken)]):\n    \"\"\"\n    执行命令（API_TOKEN认证）\n    \"\"\"\n    if not jobid:\n        return schemas.Response(success=False, message=\"命令不能为空！\")\n\n    if jobid in {\"recommend_refresh\", \"cookiecloud\"}:\n        Scheduler().start(jobid, manual=True)\n    else:\n        Scheduler().start(jobid)\n    return schemas.Response(success=True)\n"
  },
  {
    "path": "app/api/endpoints/tmdb.py",
    "content": "from typing import List, Any, Optional\n\nfrom fastapi import APIRouter, Depends\n\nfrom app import schemas\nfrom app.chain.tmdb import TmdbChain\nfrom app.core.security import verify_token\nfrom app.schemas.types import MediaType\n\nrouter = APIRouter()\n\n\n@router.get(\"/seasons/{tmdbid}\", summary=\"TMDB所有季\", response_model=List[schemas.TmdbSeason])\nasync def tmdb_seasons(tmdbid: int, _: schemas.TokenPayload = Depends(verify_token)) -> Any:\n    \"\"\"\n    根据TMDBID查询themoviedb所有季信息\n    \"\"\"\n    seasons_info = await TmdbChain().async_tmdb_seasons(tmdbid=tmdbid)\n    if seasons_info:\n        return seasons_info\n    return []\n\n\n@router.get(\"/similar/{tmdbid}/{type_name}\", summary=\"类似电影/电视剧\", response_model=List[schemas.MediaInfo])\nasync def tmdb_similar(tmdbid: int,\n                       type_name: str,\n                       _: schemas.TokenPayload = Depends(verify_token)) -> Any:\n    \"\"\"\n    根据TMDBID查询类似电影/电视剧，type_name: 电影/电视剧\n    \"\"\"\n    mediatype = MediaType(type_name)\n    if mediatype == MediaType.MOVIE:\n        medias = await TmdbChain().async_movie_similar(tmdbid=tmdbid)\n    elif mediatype == MediaType.TV:\n        medias = await TmdbChain().async_tv_similar(tmdbid=tmdbid)\n    else:\n        return []\n    if medias:\n        return [media.to_dict() for media in medias]\n    return []\n\n\n@router.get(\"/recommend/{tmdbid}/{type_name}\", summary=\"推荐电影/电视剧\", response_model=List[schemas.MediaInfo])\nasync def tmdb_recommend(tmdbid: int,\n                         type_name: str,\n                         _: schemas.TokenPayload = Depends(verify_token)) -> Any:\n    \"\"\"\n    根据TMDBID查询推荐电影/电视剧，type_name: 电影/电视剧\n    \"\"\"\n    mediatype = MediaType(type_name)\n    if mediatype == MediaType.MOVIE:\n        medias = await TmdbChain().async_movie_recommend(tmdbid=tmdbid)\n    elif mediatype == MediaType.TV:\n        medias = await TmdbChain().async_tv_recommend(tmdbid=tmdbid)\n    else:\n        return []\n    if medias:\n        return [media.to_dict() for media in medias]\n    return []\n\n\n@router.get(\"/collection/{collection_id}\", summary=\"系列合集详情\", response_model=List[schemas.MediaInfo])\nasync def tmdb_collection(collection_id: int,\n                          page: Optional[int] = 1,\n                          count: Optional[int] = 20,\n                          _: schemas.TokenPayload = Depends(verify_token)) -> Any:\n    \"\"\"\n    根据合集ID查询合集详情\n    \"\"\"\n    medias = await TmdbChain().async_tmdb_collection(collection_id=collection_id)\n    if medias:\n        return [media.to_dict() for media in medias][(page - 1) * count:page * count]\n    return []\n\n\n@router.get(\"/credits/{tmdbid}/{type_name}\", summary=\"演员阵容\", response_model=List[schemas.MediaPerson])\nasync def tmdb_credits(tmdbid: int,\n                       type_name: str,\n                       page: Optional[int] = 1,\n                       _: schemas.TokenPayload = Depends(verify_token)) -> Any:\n    \"\"\"\n    根据TMDBID查询演员阵容，type_name: 电影/电视剧\n    \"\"\"\n    mediatype = MediaType(type_name)\n    if mediatype == MediaType.MOVIE:\n        persons = await TmdbChain().async_movie_credits(tmdbid=tmdbid, page=page)\n    elif mediatype == MediaType.TV:\n        persons = await TmdbChain().async_tv_credits(tmdbid=tmdbid, page=page)\n    else:\n        return []\n    return persons or []\n\n\n@router.get(\"/person/{person_id}\", summary=\"人物详情\", response_model=schemas.MediaPerson)\nasync def tmdb_person(person_id: int,\n                      _: schemas.TokenPayload = Depends(verify_token)) -> Any:\n    \"\"\"\n    根据人物ID查询人物详情\n    \"\"\"\n    return await TmdbChain().async_person_detail(person_id=person_id)\n\n\n@router.get(\"/person/credits/{person_id}\", summary=\"人物参演作品\", response_model=List[schemas.MediaInfo])\nasync def tmdb_person_credits(person_id: int,\n                              page: Optional[int] = 1,\n                              _: schemas.TokenPayload = Depends(verify_token)) -> Any:\n    \"\"\"\n    根据人物ID查询人物参演作品\n    \"\"\"\n    medias = await TmdbChain().async_person_credits(person_id=person_id, page=page)\n    if medias:\n        return [media.to_dict() for media in medias]\n    return []\n\n\n@router.get(\"/{tmdbid}/{season}\", summary=\"TMDB季所有集\", response_model=List[schemas.TmdbEpisode])\nasync def tmdb_season_episodes(tmdbid: int, season: int, episode_group: Optional[str] = None,\n                               _: schemas.TokenPayload = Depends(verify_token)) -> Any:\n    \"\"\"\n    根据TMDBID查询某季的所有信信息\n    \"\"\"\n    return await TmdbChain().async_tmdb_episodes(tmdbid=tmdbid, season=season, episode_group=episode_group)\n"
  },
  {
    "path": "app/api/endpoints/torrent.py",
    "content": "from typing import Optional\n\nfrom fastapi import APIRouter, Depends\n\nfrom app import schemas\nfrom app.chain.media import MediaChain\nfrom app.chain.torrents import TorrentsChain\nfrom app.core.config import settings\nfrom app.core.context import MediaInfo\nfrom app.core.metainfo import MetaInfo\nfrom app.db.models import User\nfrom app.db.user_oper import get_current_active_superuser, get_current_active_superuser_async\nfrom app.utils.crypto import HashUtils\n\nrouter = APIRouter()\n\n\n@router.get(\"/cache\", summary=\"获取种子缓存\", response_model=schemas.Response)\nasync def torrents_cache(_: User = Depends(get_current_active_superuser_async)):\n    \"\"\"\n    获取当前种子缓存数据\n    \"\"\"\n    torrents_chain = TorrentsChain()\n\n    # 获取spider和rss两种缓存\n    if settings.SUBSCRIBE_MODE == \"rss\":\n        cache_info = await torrents_chain.async_get_torrents(\"rss\")\n    else:\n        cache_info = await torrents_chain.async_get_torrents(\"spider\")\n\n    # 统计信息\n    torrent_count = sum(len(torrents) for torrents in cache_info.values())\n\n    # 转换为前端需要的格式\n    torrent_data = []\n    for domain, contexts in cache_info.items():\n        for context in contexts:\n            torrent_hash = HashUtils.md5(f\"{context.torrent_info.title}{context.torrent_info.description}\")\n            torrent_data.append({\n                \"hash\": torrent_hash,\n                \"domain\": domain,\n                \"title\": context.torrent_info.title,\n                \"description\": context.torrent_info.description,\n                \"size\": context.torrent_info.size,\n                \"pubdate\": context.torrent_info.pubdate,\n                \"site_name\": context.torrent_info.site_name,\n                \"media_name\": context.media_info.title if context.media_info else \"\",\n                \"media_year\": context.media_info.year if context.media_info else \"\",\n                \"media_type\": context.media_info.type if context.media_info else \"\",\n                \"season_episode\": context.meta_info.season_episode if context.meta_info else \"\",\n                \"resource_term\": context.meta_info.resource_term if context.meta_info else \"\",\n                \"enclosure\": context.torrent_info.enclosure,\n                \"page_url\": context.torrent_info.page_url,\n                \"poster_path\": context.media_info.get_poster_image() if context.media_info else \"\",\n                \"backdrop_path\": context.media_info.get_backdrop_image() if context.media_info else \"\"\n            })\n\n    return schemas.Response(success=True, data={\n        \"count\": torrent_count,\n        \"sites\": len(cache_info),\n        \"data\": torrent_data\n    })\n\n\n@router.delete(\"/cache/{domain}/{torrent_hash}\", summary=\"删除指定种子缓存\", response_model=schemas.Response)\nasync def delete_cache(domain: str, torrent_hash: str, _: User = Depends(get_current_active_superuser_async)):\n    \"\"\"\n    删除指定的种子缓存\n    :param domain: 站点域名\n    :param torrent_hash: 种子hash（使用title+description的md5）\n    :param _: 当前用户，必须是超级用户\n    \"\"\"\n\n    torrents_chain = TorrentsChain()\n\n    try:\n        # 获取当前缓存\n        cache_data = await torrents_chain.async_get_torrents()\n\n        if domain not in cache_data:\n            return schemas.Response(success=False, message=f\"站点 {domain} 缓存不存在\")\n\n        # 查找并删除指定种子\n        original_count = len(cache_data[domain])\n        cache_data[domain] = [\n            context for context in cache_data[domain]\n            if HashUtils.md5(f\"{context.torrent_info.title}{context.torrent_info.description}\") != torrent_hash\n        ]\n\n        if len(cache_data[domain]) == original_count:\n            return schemas.Response(success=False, message=\"未找到指定的种子\")\n\n        # 保存更新后的缓存\n        await torrents_chain.async_save_cache(cache_data, torrents_chain.cache_file)\n\n        return schemas.Response(success=True, message=\"种子删除成功\")\n    except Exception as e:\n        return schemas.Response(success=False, message=f\"删除失败：{str(e)}\")\n\n\n@router.delete(\"/cache\", summary=\"清理种子缓存\", response_model=schemas.Response)\nasync def clear_cache(_: User = Depends(get_current_active_superuser_async)):\n    \"\"\"\n    清理所有种子缓存\n    \"\"\"\n    torrents_chain = TorrentsChain()\n\n    try:\n        await torrents_chain.async_clear_torrents()\n        return schemas.Response(success=True, message=\"种子缓存清理完成\")\n    except Exception as e:\n        return schemas.Response(success=False, message=f\"清理失败：{str(e)}\")\n\n\n@router.post(\"/cache/refresh\", summary=\"刷新种子缓存\", response_model=schemas.Response)\ndef refresh_cache(_: User = Depends(get_current_active_superuser)):\n    \"\"\"\n    刷新种子缓存\n    \"\"\"\n    from app.chain.torrents import TorrentsChain\n\n    torrents_chain = TorrentsChain()\n\n    try:\n        result = torrents_chain.refresh()\n\n        # 统计刷新结果\n        total_count = sum(len(torrents) for torrents in result.values())\n        sites_count = len(result)\n\n        return schemas.Response(success=True, message=f\"缓存刷新完成，共刷新 {sites_count} 个站点，{total_count} 个种子\")\n    except Exception as e:\n        return schemas.Response(success=False, message=f\"刷新失败：{str(e)}\")\n\n\n@router.post(\"/cache/reidentify/{domain}/{torrent_hash}\", summary=\"重新识别种子\", response_model=schemas.Response)\nasync def reidentify_cache(domain: str, torrent_hash: str,\n                           tmdbid: Optional[int] = None, doubanid: Optional[str] = None,\n                           _: User = Depends(get_current_active_superuser_async)):\n    \"\"\"\n    重新识别指定的种子\n    :param domain: 站点域名\n    :param torrent_hash: 种子hash（使用title+description的md5）\n    :param tmdbid: 手动指定的TMDB ID\n    :param doubanid: 手动指定的豆瓣ID\n    :param _: 当前用户，必须是超级用户\n    \"\"\"\n\n    torrents_chain = TorrentsChain()\n    media_chain = MediaChain()\n\n    try:\n        # 获取当前缓存\n        cache_data = await torrents_chain.async_get_torrents()\n\n        if domain not in cache_data:\n            return schemas.Response(success=False, message=f\"站点 {domain} 缓存不存在\")\n\n        # 查找指定种子\n        target_context = None\n        for context in cache_data[domain]:\n            if HashUtils.md5(f\"{context.torrent_info.title}{context.torrent_info.description}\") == torrent_hash:\n                target_context = context\n                break\n\n        if not target_context:\n            return schemas.Response(success=False, message=\"未找到指定的种子\")\n\n        # 重新识别\n        meta = MetaInfo(title=target_context.torrent_info.title, subtitle=target_context.torrent_info.description)\n        if tmdbid or doubanid:\n            # 手动指定媒体信息\n            mediainfo = await media_chain.async_recognize_media(meta=meta, tmdbid=tmdbid, doubanid=doubanid)\n        else:\n            # 自动重新识别\n            mediainfo = await media_chain.async_recognize_by_meta(meta)\n\n        if not mediainfo:\n            # 创建空的媒体信息\n            mediainfo = MediaInfo()\n        else:\n            # 清理多余数据\n            mediainfo.clear()\n\n        # 更新上下文中的媒体信息\n        target_context.media_info = mediainfo\n\n        # 保存更新后的缓存\n        await torrents_chain.async_save_cache(cache_data, TorrentsChain().cache_file)\n\n        return schemas.Response(success=True, message=\"重新识别完成\", data={\n            \"media_name\": mediainfo.title if mediainfo else \"\",\n            \"media_year\": mediainfo.year if mediainfo else \"\",\n            \"media_type\": mediainfo.type.value if mediainfo and mediainfo.type else \"\"\n        })\n    except Exception as e:\n        return schemas.Response(success=False, message=f\"重新识别失败：{str(e)}\")\n"
  },
  {
    "path": "app/api/endpoints/transfer.py",
    "content": "from pathlib import Path\nfrom typing import Any, List, Annotated, Optional\n\nfrom fastapi import APIRouter, Depends\nfrom sqlalchemy.orm import Session\n\nfrom app import schemas\nfrom app.chain.media import MediaChain\nfrom app.chain.storage import StorageChain\nfrom app.chain.transfer import TransferChain\nfrom app.core.config import settings, global_vars\nfrom app.core.metainfo import MetaInfoPath\nfrom app.core.security import verify_token, verify_apitoken\nfrom app.db import get_db\nfrom app.db.models import User\nfrom app.db.models.transferhistory import TransferHistory\nfrom app.db.user_oper import get_current_active_superuser\nfrom app.helper.directory import DirectoryHelper\nfrom app.schemas import MediaType, FileItem, ManualTransferItem\n\nrouter = APIRouter()\n\n\n@router.get(\"/name\", summary=\"查询整理后的名称\", response_model=schemas.Response)\ndef query_name(path: str, filetype: str,\n               _: schemas.TokenPayload = Depends(verify_token)) -> Any:\n    \"\"\"\n    查询整理后的名称\n    :param path: 文件路径\n    :param filetype: 文件类型\n    :param _: Token校验\n    \"\"\"\n    meta = MetaInfoPath(Path(path))\n    mediainfo = MediaChain().recognize_media(meta)\n    if not mediainfo:\n        return schemas.Response(success=False, message=\"未识别到媒体信息\")\n    new_path = TransferChain().recommend_name(meta=meta, mediainfo=mediainfo)\n    if not new_path:\n        return schemas.Response(success=False, message=\"未识别到新名称\")\n    if filetype == \"dir\":\n        media_path = DirectoryHelper.get_media_root_path(\n            rename_format=settings.RENAME_FORMAT(mediainfo.type),\n            rename_path=Path(new_path),\n        )\n        if media_path:\n            new_name = media_path.name\n        else:\n            # fallback\n            parents = Path(new_path).parents\n            if len(parents) > 2:\n                new_name = parents[1].name\n            else:\n                new_name = parents[0].name\n    else:\n        new_name = Path(new_path).name\n    return schemas.Response(success=True, data={\n        \"name\": new_name\n    })\n\n\n@router.get(\"/queue\", summary=\"查询整理队列\", response_model=List[schemas.TransferJob])\nasync def query_queue(_: schemas.TokenPayload = Depends(verify_token)) -> Any:\n    \"\"\"\n    查询整理队列\n    :param _: Token校验\n    \"\"\"\n    return TransferChain().get_queue_tasks()\n\n\n@router.delete(\"/queue\", summary=\"从整理队列中删除任务\", response_model=schemas.Response)\nasync def remove_queue(fileitem: schemas.FileItem, _: schemas.TokenPayload = Depends(verify_token)) -> Any:\n    \"\"\"\n    查询整理队列\n    :param fileitem: 文件项\n    :param _: Token校验\n    \"\"\"\n    TransferChain().remove_from_queue(fileitem)\n    # 取消整理\n    global_vars.stop_transfer(fileitem.path)\n    return schemas.Response(success=True)\n\n\n@router.post(\"/manual\", summary=\"手动转移\", response_model=schemas.Response)\ndef manual_transfer(transer_item: ManualTransferItem,\n                    background: Optional[bool] = False,\n                    db: Session = Depends(get_db),\n                    _: User = Depends(get_current_active_superuser)) -> Any:\n    \"\"\"\n    手动转移，文件或历史记录，支持自定义剧集识别格式\n    :param transer_item: 手工整理项\n    :param background: 后台运行\n    :param db: 数据库\n    :param _: Token校验\n    \"\"\"\n    force = False\n    downloader = None\n    download_hash = None\n    target_path = Path(transer_item.target_path) if transer_item.target_path else None\n    if transer_item.logid:\n        # 查询历史记录\n        history: TransferHistory = TransferHistory.get(db, transer_item.logid)\n        if not history:\n            return schemas.Response(success=False, message=f\"整理记录不存在，ID：{transer_item.logid}\")\n        # 强制转移\n        force = True\n        downloader = history.downloader\n        download_hash = history.download_hash\n        if history.status and (\"move\" in history.mode):\n            # 重新整理成功的转移，则使用成功的 dest 做 in_path\n            src_fileitem = FileItem(**history.dest_fileitem)\n        else:\n            # 源路径\n            src_fileitem = FileItem(**history.src_fileitem)\n            # 目的路径\n            if history.dest_fileitem:\n                # 删除旧的已整理文件\n                dest_fileitem = FileItem(**history.dest_fileitem)\n                state = StorageChain().delete_media_file(dest_fileitem)\n                if not state:\n                    return schemas.Response(success=False, message=f\"{dest_fileitem.path} 删除失败\")\n\n        # 从历史数据获取信息\n        if transer_item.from_history:\n            transer_item.type_name = history.type if history.type else transer_item.type_name\n            transer_item.tmdbid = int(history.tmdbid) if history.tmdbid else transer_item.tmdbid\n            transer_item.doubanid = str(history.doubanid) if history.doubanid else transer_item.doubanid\n            transer_item.season = int(str(history.seasons).replace(\"S\", \"\")) if history.seasons else transer_item.season\n            transer_item.episode_group = history.episode_group or transer_item.episode_group\n            if history.episodes:\n                if \"-\" in str(history.episodes):\n                    # E01-E03多集合并\n                    episode_start, episode_end = str(history.episodes).split(\"-\")\n                    episode_list: list[int] = []\n                    for i in range(int(episode_start.replace(\"E\", \"\")), int(episode_end.replace(\"E\", \"\")) + 1):\n                        episode_list.append(i)\n                    transer_item.episode_detail = \",\".join(str(e) for e in episode_list)\n                else:\n                    # E01单集\n                    transer_item.episode_detail = str(history.episodes).replace(\"E\", \"\")\n\n    elif transer_item.fileitem:\n        src_fileitem = transer_item.fileitem\n    else:\n        return schemas.Response(success=False, message=f\"缺少参数\")\n\n    # 类型（“自动/auto/none”按未指定处理）\n    mtype = None\n    type_name = str(transer_item.type_name).strip() if transer_item.type_name else \"\"\n    if type_name and type_name.lower() not in {\"自动\", \"auto\", \"none\"}:\n        try:\n            mtype = MediaType(type_name)\n        except ValueError:\n            return schemas.Response(success=False, message=f\"不支持的媒体类型：{type_name}\")\n    # 自定义格式\n    epformat = None\n    if transer_item.episode_offset or transer_item.episode_part \\\n            or transer_item.episode_detail or transer_item.episode_format:\n        epformat = schemas.EpisodeFormat(\n            format=transer_item.episode_format,\n            detail=transer_item.episode_detail,\n            part=transer_item.episode_part,\n            offset=transer_item.episode_offset,\n        )\n    # 开始转移\n    state, errormsg = TransferChain().manual_transfer(\n        fileitem=src_fileitem,\n        target_storage=transer_item.target_storage,\n        target_path=target_path,\n        tmdbid=transer_item.tmdbid,\n        doubanid=transer_item.doubanid,\n        mtype=mtype,\n        season=transer_item.season,\n        episode_group=transer_item.episode_group,\n        transfer_type=transer_item.transfer_type,\n        epformat=epformat,\n        min_filesize=transer_item.min_filesize,\n        scrape=transer_item.scrape,\n        library_type_folder=transer_item.library_type_folder,\n        library_category_folder=transer_item.library_category_folder,\n        force=force,\n        background=background,\n        downloader=downloader,\n        download_hash=download_hash\n    )\n    # 失败\n    if not state:\n        if isinstance(errormsg, list):\n            errormsg = f\"整理完成，{len(errormsg)} 个文件转移失败！\"\n        return schemas.Response(success=False, message=errormsg)\n    # 成功\n    return schemas.Response(success=True)\n\n\n@router.get(\"/now\", summary=\"立即执行下载器文件整理\", response_model=schemas.Response)\ndef now(_: Annotated[str, Depends(verify_apitoken)]) -> Any:\n    \"\"\"\n    立即执行下载器文件整理 API_TOKEN认证（?token=xxx）\n    \"\"\"\n    TransferChain().process()\n    return schemas.Response(success=True)\n"
  },
  {
    "path": "app/api/endpoints/user.py",
    "content": "import base64\nimport re\nfrom typing import Annotated, Any, List, Union\n\nfrom fastapi import APIRouter, Body, Depends, HTTPException, UploadFile, File\nfrom sqlalchemy.ext.asyncio import AsyncSession\n\nfrom app import schemas\nfrom app.core.security import get_password_hash\nfrom app.db import get_async_db\nfrom app.db.models.user import User\nfrom app.db.user_oper import get_current_active_superuser_async, \\\n    get_current_active_user_async, get_current_active_user\nfrom app.db.userconfig_oper import UserConfigOper\nfrom app.utils.otp import OtpUtils\n\nrouter = APIRouter()\n\n\n@router.get(\"/\", summary=\"所有用户\", response_model=List[schemas.User])\nasync def list_users(\n        db: AsyncSession = Depends(get_async_db),\n        current_user: User = Depends(get_current_active_superuser_async),\n) -> Any:\n    \"\"\"\n    查询用户列表\n    \"\"\"\n    return await current_user.async_list(db)\n\n\n@router.post(\"/\", summary=\"新增用户\", response_model=schemas.Response)\nasync def create_user(\n        *,\n        db: AsyncSession = Depends(get_async_db),\n        user_in: schemas.UserCreate,\n        current_user: User = Depends(get_current_active_superuser_async),\n) -> Any:\n    \"\"\"\n    新增用户\n    \"\"\"\n    user = await current_user.async_get_by_name(db, name=user_in.name)\n    if user:\n        return schemas.Response(success=False, message=\"用户已存在\")\n    user_info = user_in.model_dump()\n    if user_info.get(\"password\"):\n        user_info[\"hashed_password\"] = get_password_hash(user_info[\"password\"])\n        user_info.pop(\"password\")\n    user = await User(**user_info).async_create(db)\n    return schemas.Response(success=True if user else False)\n\n\n@router.put(\"/\", summary=\"更新用户\", response_model=schemas.Response)\nasync def update_user(\n        *,\n        db: AsyncSession = Depends(get_async_db),\n        user_in: schemas.UserUpdate,\n        current_user: User = Depends(get_current_active_superuser_async),\n) -> Any:\n    \"\"\"\n    更新用户\n    \"\"\"\n    user_info = user_in.model_dump()\n    if user_info.get(\"password\"):\n        # 正则表达式匹配密码包含字母、数字、特殊字符中的至少两项\n        pattern = r'^(?![a-zA-Z]+$)(?!\\d+$)(?![^\\da-zA-Z\\s]+$).{6,50}$'\n        if not re.match(pattern, user_info.get(\"password\")):\n            return schemas.Response(success=False,\n                                    message=\"密码需要同时包含字母、数字、特殊字符中的至少两项，且长度大于6位\")\n        user_info[\"hashed_password\"] = get_password_hash(user_info[\"password\"])\n        user_info.pop(\"password\")\n    user = await current_user.async_get_by_id(db, user_id=user_info[\"id\"])\n    user_name = user_info.get(\"name\")\n    if not user_name:\n        return schemas.Response(success=False, message=\"用户名不能为空\")\n    # 新用户名去重\n    users = await current_user.async_list(db)\n    for u in users:\n        if u.name == user_name and u.id != user_info[\"id\"]:\n            return schemas.Response(success=False, message=\"用户名已被使用\")\n    if not user:\n        return schemas.Response(success=False, message=\"用户不存在\")\n    await user.async_update(db, user_info)\n    return schemas.Response(success=True)\n\n\n@router.get(\"/current\", summary=\"当前登录用户信息\", response_model=schemas.User)\nasync def read_current_user(\n        current_user: User = Depends(get_current_active_user_async)\n) -> Any:\n    \"\"\"\n    当前登录用户信息\n    \"\"\"\n    return current_user\n\n\n@router.post(\"/avatar/{user_id}\", summary=\"上传用户头像\", response_model=schemas.Response)\nasync def upload_avatar(user_id: int, db: AsyncSession = Depends(get_async_db), file: UploadFile = File(...),\n                        _: User = Depends(get_current_active_user_async)):\n    \"\"\"\n    上传用户头像\n    \"\"\"\n    # 将文件转换为Base64\n    file_base64 = base64.b64encode(file.file.read())\n    # 更新到用户表\n    user = await User.async_get(db, user_id)\n    if not user:\n        return schemas.Response(success=False, message=\"用户不存在\")\n    await user.async_update(db, {\n        \"avatar\": f\"data:image/ico;base64,{file_base64}\"\n    })\n    return schemas.Response(success=True, message=file.filename)\n\n\n@router.get(\"/config/{key}\", summary=\"查询用户配置\", response_model=schemas.Response)\ndef get_config(key: str,\n               current_user: User = Depends(get_current_active_user)):\n    \"\"\"\n    查询用户配置\n    \"\"\"\n    value = UserConfigOper().get(username=current_user.name, key=key)\n    return schemas.Response(success=True, data={\n        \"value\": value\n    })\n\n\n@router.post(\"/config/{key}\", summary=\"更新用户配置\", response_model=schemas.Response)\ndef set_config(\n        key: str,\n        value: Annotated[Union[list, dict, bool, int, str] | None, Body()] = None,\n        current_user: User = Depends(get_current_active_user),\n):\n    \"\"\"\n    更新用户配置\n    \"\"\"\n    UserConfigOper().set(username=current_user.name, key=key, value=value)\n    return schemas.Response(success=True)\n\n\n@router.delete(\"/id/{user_id}\", summary=\"删除用户\", response_model=schemas.Response)\nasync def delete_user_by_id(\n        *,\n        db: AsyncSession = Depends(get_async_db),\n        user_id: int,\n        current_user: User = Depends(get_current_active_superuser_async),\n) -> Any:\n    \"\"\"\n    通过唯一ID删除用户\n    \"\"\"\n    user = await current_user.async_get_by_id(db, user_id=user_id)\n    if not user:\n        return schemas.Response(success=False, message=\"用户不存在\")\n    await current_user.async_delete(db, user_id)\n    return schemas.Response(success=True)\n\n\n@router.delete(\"/name/{user_name}\", summary=\"删除用户\", response_model=schemas.Response)\nasync def delete_user_by_name(\n        *,\n        db: AsyncSession = Depends(get_async_db),\n        user_name: str,\n        current_user: User = Depends(get_current_active_superuser_async),\n) -> Any:\n    \"\"\"\n    通过用户名删除用户\n    \"\"\"\n    user = await current_user.async_get_by_name(db, name=user_name)\n    if not user:\n        return schemas.Response(success=False, message=\"用户不存在\")\n    await current_user.async_delete(db, user.id)\n    return schemas.Response(success=True)\n\n\n@router.get(\"/{username}\", summary=\"用户详情\", response_model=schemas.User)\nasync def read_user_by_name(\n        username: str,\n        current_user: User = Depends(get_current_active_user_async),\n        db: AsyncSession = Depends(get_async_db),\n) -> Any:\n    \"\"\"\n    查询用户详情\n    \"\"\"\n    user = await current_user.async_get_by_name(db, name=username)\n    if not user:\n        raise HTTPException(\n            status_code=404,\n            detail=\"用户不存在\",\n        )\n    if user == current_user:\n        return user\n    if not current_user.is_superuser:\n        raise HTTPException(\n            status_code=400,\n            detail=\"用户权限不足\"\n        )\n    return user\n"
  },
  {
    "path": "app/api/endpoints/webhook.py",
    "content": "from typing import Any, Annotated\n\nfrom fastapi import APIRouter, BackgroundTasks, Request, Depends\n\nfrom app import schemas\nfrom app.chain.webhook import WebhookChain\nfrom app.core.security import verify_apitoken\n\nrouter = APIRouter()\n\n\ndef start_webhook_chain(body: Any, form: Any, args: Any):\n    \"\"\"\n    启动链式任务\n    \"\"\"\n    WebhookChain().message(body=body, form=form, args=args)\n\n\n@router.post(\"/\", summary=\"Webhook消息响应\", response_model=schemas.Response)\nasync def webhook_message(background_tasks: BackgroundTasks,\n                          request: Request,\n                          _: Annotated[str, Depends(verify_apitoken)]\n                          ) -> Any:\n    \"\"\"\n    Webhook响应，配置请求中需要添加参数：token=API_TOKEN&source=媒体服务器名\n    \"\"\"\n    body = await request.body()\n    form = await request.form()\n    args = request.query_params\n    background_tasks.add_task(start_webhook_chain, body, form, args)\n    return schemas.Response(success=True)\n\n\n@router.get(\"/\", summary=\"Webhook消息响应\", response_model=schemas.Response)\nasync def webhook_message(background_tasks: BackgroundTasks,\n                          request: Request, _: Annotated[str, Depends(verify_apitoken)]) -> Any:\n    \"\"\"\n    Webhook响应，配置请求中需要添加参数：token=API_TOKEN&source=媒体服务器名\n    \"\"\"\n    args = request.query_params\n    background_tasks.add_task(start_webhook_chain, None, None, args)\n    return schemas.Response(success=True)\n"
  },
  {
    "path": "app/api/endpoints/workflow.py",
    "content": "import json\nfrom datetime import datetime\nfrom typing import List, Any, Optional\n\nfrom fastapi import APIRouter, Depends\nfrom sqlalchemy.ext.asyncio import AsyncSession\nfrom sqlalchemy.orm import Session\n\nfrom app import schemas\nfrom app.chain.workflow import WorkflowChain\nfrom app.core.config import global_vars\nfrom app.core.plugin import PluginManager\nfrom app.core.security import verify_token\nfrom app.workflow import WorkFlowManager\nfrom app.db import get_async_db, get_db\nfrom app.db.models import Workflow\nfrom app.db.systemconfig_oper import SystemConfigOper\nfrom app.db.workflow_oper import WorkflowOper\nfrom app.helper.workflow import WorkflowHelper\nfrom app.scheduler import Scheduler\nfrom app.schemas.types import EventType, EVENT_TYPE_NAMES\n\nrouter = APIRouter()\n\n\n@router.get(\"/\", summary=\"所有工作流\", response_model=List[schemas.Workflow])\nasync def list_workflows(db: AsyncSession = Depends(get_async_db),\n                         _: schemas.TokenPayload = Depends(verify_token)) -> Any:\n    \"\"\"\n    获取工作流列表\n    \"\"\"\n    return await WorkflowOper(db).async_list()\n\n\n@router.post(\"/\", summary=\"创建工作流\", response_model=schemas.Response)\nasync def create_workflow(workflow: schemas.Workflow,\n                          db: AsyncSession = Depends(get_async_db),\n                          _: schemas.TokenPayload = Depends(verify_token)) -> Any:\n    \"\"\"\n    创建工作流\n    \"\"\"\n    if workflow.name and await WorkflowOper(db).async_get_by_name(workflow.name):\n        return schemas.Response(success=False, message=\"已存在相同名称的工作流\")\n    if not workflow.add_time:\n        workflow.add_time = datetime.strftime(datetime.now(), \"%Y-%m-%d %H:%M:%S\")\n    if not workflow.state:\n        workflow.state = \"P\"\n    if not workflow.trigger_type:\n        workflow.trigger_type = \"timer\"\n    workflow_obj = Workflow(**workflow.model_dump())\n    await workflow_obj.async_create(db)\n    return schemas.Response(success=True, message=\"创建工作流成功\")\n\n\n@router.get(\"/plugin/actions\", summary=\"查询插件动作\", response_model=List[dict])\ndef list_plugin_actions(plugin_id: str = None, _: schemas.TokenPayload = Depends(verify_token)) -> Any:\n    \"\"\"\n    获取所有动作\n    \"\"\"\n    return PluginManager().get_plugin_actions(plugin_id)\n\n\n@router.get(\"/actions\", summary=\"所有动作\", response_model=List[dict])\nasync def list_actions(_: schemas.TokenPayload = Depends(verify_token)) -> Any:\n    \"\"\"\n    获取所有动作\n    \"\"\"\n    return WorkFlowManager().list_actions()\n\n\n@router.get(\"/event_types\", summary=\"获取所有事件类型\", response_model=List[dict])\nasync def get_event_types(_: schemas.TokenPayload = Depends(verify_token)) -> Any:\n    \"\"\"\n    获取所有事件类型\n    \"\"\"\n    return [{\n        \"title\": EVENT_TYPE_NAMES.get(event_type, event_type.name),\n        \"value\": event_type.value\n    } for event_type in EventType]\n\n\n@router.post(\"/share\", summary=\"分享工作流\", response_model=schemas.Response)\nasync def workflow_share(\n        workflow: schemas.WorkflowShare,\n        _: schemas.TokenPayload = Depends(verify_token)) -> Any:\n    \"\"\"\n    分享工作流\n    \"\"\"\n    if not workflow.id or not workflow.share_title or not workflow.share_user:\n        return schemas.Response(success=False, message=\"请填写工作流ID、分享标题和分享人\")\n\n    state, errmsg = await WorkflowHelper().async_workflow_share(workflow_id=workflow.id,\n                                                                share_title=workflow.share_title or \"\",\n                                                                share_comment=workflow.share_comment or \"\",\n                                                                share_user=workflow.share_user or \"\")\n    return schemas.Response(success=state, message=errmsg)\n\n\n@router.delete(\"/share/{share_id}\", summary=\"删除分享\", response_model=schemas.Response)\nasync def workflow_share_delete(\n        share_id: int,\n        _: schemas.TokenPayload = Depends(verify_token)) -> Any:\n    \"\"\"\n    删除分享\n    \"\"\"\n    state, errmsg = await WorkflowHelper().async_share_delete(share_id=share_id)\n    return schemas.Response(success=state, message=errmsg)\n\n\n@router.post(\"/fork\", summary=\"复用工作流\", response_model=schemas.Response)\nasync def workflow_fork(\n        workflow: schemas.WorkflowShare,\n        db: AsyncSession = Depends(get_async_db),\n        _: schemas.User = Depends(verify_token)) -> Any:\n    \"\"\"\n    复用工作流\n    \"\"\"\n    if not workflow.name:\n        return schemas.Response(success=False, message=\"工作流名称不能为空\")\n\n    # 解析JSON数据，添加错误处理\n    try:\n        actions = json.loads(workflow.actions or \"[]\")\n    except json.JSONDecodeError:\n        return schemas.Response(success=False, message=\"actions字段JSON格式错误\")\n\n    try:\n        flows = json.loads(workflow.flows or \"[]\")\n    except json.JSONDecodeError:\n        return schemas.Response(success=False, message=\"flows字段JSON格式错误\")\n\n    try:\n        context = json.loads(workflow.context or \"{}\")\n    except json.JSONDecodeError:\n        return schemas.Response(success=False, message=\"context字段JSON格式错误\")\n\n    # 创建工作流\n    workflow_dict = {\n        \"name\": workflow.name,\n        \"description\": workflow.description,\n        \"timer\": workflow.timer,\n        \"trigger_type\": workflow.trigger_type or \"timer\",\n        \"event_type\": workflow.event_type,\n        \"event_conditions\": json.loads(workflow.event_conditions or \"{}\") if workflow.event_conditions else {},\n        \"actions\": actions,\n        \"flows\": flows,\n        \"context\": context,\n        \"state\": \"P\"  # 默认暂停状态\n    }\n\n    # 检查名称是否重复\n    workflow_oper = WorkflowOper(db)\n    if await workflow_oper.async_get_by_name(workflow_dict[\"name\"]):\n        return schemas.Response(success=False, message=\"已存在相同名称的工作流\")\n\n    # 创建新工作流\n    workflow = await Workflow(**workflow_dict).async_create(db)\n\n    # 更新复用次数\n    if workflow:\n        await WorkflowHelper().async_workflow_fork(share_id=workflow.id)\n\n    return schemas.Response(success=True, message=\"复用成功\")\n\n\n@router.get(\"/shares\", summary=\"查询分享的工作流\", response_model=List[schemas.WorkflowShare])\nasync def workflow_shares(\n        name: Optional[str] = None,\n        page: Optional[int] = 1,\n        count: Optional[int] = 30,\n        _: schemas.TokenPayload = Depends(verify_token)) -> Any:\n    \"\"\"\n    查询分享的工作流\n    \"\"\"\n    return await WorkflowHelper().async_get_shares(name=name, page=page, count=count)\n\n\n@router.post(\"/{workflow_id}/run\", summary=\"执行工作流\", response_model=schemas.Response)\ndef run_workflow(workflow_id: int,\n                 from_begin: Optional[bool] = True,\n                 _: schemas.TokenPayload = Depends(verify_token)) -> Any:\n    \"\"\"\n    执行工作流\n    \"\"\"\n    state, errmsg = WorkflowChain().process(workflow_id, from_begin=from_begin)\n    if not state:\n        return schemas.Response(success=False, message=errmsg)\n    return schemas.Response(success=True)\n\n\n@router.post(\"/{workflow_id}/start\", summary=\"启用工作流\", response_model=schemas.Response)\ndef start_workflow(workflow_id: int,\n                   db: Session = Depends(get_db),\n                   _: schemas.TokenPayload = Depends(verify_token)) -> Any:\n    \"\"\"\n    启用工作流\n    \"\"\"\n    workflow = WorkflowOper(db).get(workflow_id)\n    if not workflow:\n        return schemas.Response(success=False, message=\"工作流不存在\")\n    if not workflow.trigger_type or workflow.trigger_type == \"timer\":\n        # 添加定时任务\n        Scheduler().update_workflow_job(workflow)\n    else:\n        # 事件触发：添加到事件触发器\n        WorkFlowManager().load_workflow_events(workflow_id)\n    # 更新状态\n    workflow.update_state(db, workflow_id, \"W\")\n    return schemas.Response(success=True)\n\n\n@router.post(\"/{workflow_id}/pause\", summary=\"停用工作流\", response_model=schemas.Response)\ndef pause_workflow(workflow_id: int,\n                   db: Session = Depends(get_db),\n                   _: schemas.TokenPayload = Depends(verify_token)) -> Any:\n    \"\"\"\n    停用工作流\n    \"\"\"\n    workflow = WorkflowOper(db).get(workflow_id)\n    if not workflow:\n        return schemas.Response(success=False, message=\"工作流不存在\")\n    # 根据触发类型进行不同处理\n    if workflow.trigger_type == \"timer\":\n        # 定时触发：移除定时任务\n        Scheduler().remove_workflow_job(workflow)\n    elif workflow.trigger_type == \"event\":\n        # 事件触发：从事件触发器中移除\n        WorkFlowManager().remove_workflow_event(workflow_id, workflow.event_type)\n    # 停止工作流\n    global_vars.stop_workflow(workflow_id)\n    # 更新状态\n    workflow.update_state(db, workflow_id, \"P\")\n    return schemas.Response(success=True)\n\n\n@router.post(\"/{workflow_id}/reset\", summary=\"重置工作流\", response_model=schemas.Response)\nasync def reset_workflow(workflow_id: int,\n                         db: AsyncSession = Depends(get_async_db),\n                         _: schemas.TokenPayload = Depends(verify_token)) -> Any:\n    \"\"\"\n    重置工作流\n    \"\"\"\n    workflow = await WorkflowOper(db).async_get(workflow_id)\n    if not workflow:\n        return schemas.Response(success=False, message=\"工作流不存在\")\n    # 停止工作流\n    global_vars.stop_workflow(workflow_id)\n    # 重置工作流\n    await Workflow.async_reset(db, workflow_id, reset_count=True)\n    # 删除缓存\n    SystemConfigOper().delete(f\"WorkflowCache-{workflow_id}\")\n    return schemas.Response(success=True)\n\n\n@router.get(\"/{workflow_id}\", summary=\"工作流详情\", response_model=schemas.Workflow)\nasync def get_workflow(workflow_id: int,\n                       db: AsyncSession = Depends(get_async_db),\n                       _: schemas.TokenPayload = Depends(verify_token)) -> Any:\n    \"\"\"\n    获取工作流详情\n    \"\"\"\n    return await WorkflowOper(db).async_get(workflow_id)\n\n\n@router.put(\"/{workflow_id}\", summary=\"更新工作流\", response_model=schemas.Response)\ndef update_workflow(workflow: schemas.Workflow,\n                    db: Session = Depends(get_db),\n                    _: schemas.TokenPayload = Depends(verify_token)) -> Any:\n    \"\"\"\n    更新工作流\n    \"\"\"\n    if not workflow.id:\n        return schemas.Response(success=False, message=\"工作流ID不能为空\")\n    workflow_oper = WorkflowOper(db)\n    wf = workflow_oper.get(workflow.id)\n    if not wf:\n        return schemas.Response(success=False, message=\"工作流不存在\")\n    if not wf.trigger_type:\n        workflow.trigger_type = \"timer\"\n    wf.update(db, workflow.model_dump())\n    # 更新后的工作流对象\n    updated_workflow = workflow_oper.get(workflow.id)\n    # 更新定时任务\n    Scheduler().update_workflow_job(updated_workflow)\n    # 更新事件注册\n    WorkFlowManager().update_workflow_event(updated_workflow)\n    return schemas.Response(success=True, message=\"更新成功\")\n\n\n@router.delete(\"/{workflow_id}\", summary=\"删除工作流\", response_model=schemas.Response)\ndef delete_workflow(workflow_id: int,\n                    db: Session = Depends(get_db),\n                    _: schemas.TokenPayload = Depends(verify_token)) -> Any:\n    \"\"\"\n    删除工作流\n    \"\"\"\n    workflow = WorkflowOper(db).get(workflow_id)\n    if not workflow:\n        return schemas.Response(success=False, message=\"工作流不存在\")\n    if not workflow.trigger_type or workflow.trigger_type == \"timer\":\n        # 定时触发：删除定时任务\n        Scheduler().remove_workflow_job(workflow)\n    else:\n        # 事件触发：从事件触发器中移除\n        WorkFlowManager().remove_workflow_event(workflow_id, workflow.event_type)\n    # 删除工作流\n    Workflow.delete(db, workflow_id)\n    # 删除缓存\n    SystemConfigOper().delete(f\"WorkflowCache-{workflow_id}\")\n    return schemas.Response(success=True, message=\"删除成功\")\n"
  },
  {
    "path": "app/api/servarr.py",
    "content": "from typing import Any, List, Annotated\n\nfrom fastapi import APIRouter, HTTPException, Depends\nfrom sqlalchemy.ext.asyncio import AsyncSession\nfrom sqlalchemy.orm import Session\n\nfrom app import schemas\nfrom app.chain.media import MediaChain\nfrom app.chain.subscribe import SubscribeChain\nfrom app.chain.tvdb import TvdbChain\nfrom app.core.metainfo import MetaInfo\nfrom app.core.security import verify_apikey\nfrom app.db import get_db, get_async_db\nfrom app.db.models.subscribe import Subscribe\nfrom app.schemas import RadarrMovie, SonarrSeries\nfrom app.schemas.types import MediaType\nfrom version import APP_VERSION\n\narr_router = APIRouter(tags=['servarr'])\n\n\n@arr_router.get(\"/system/status\", summary=\"系统状态\")\nasync def arr_system_status(_: Annotated[str, Depends(verify_apikey)]) -> Any:\n    \"\"\"\n    模拟Radarr、Sonarr系统状态\n    \"\"\"\n    return {\n        \"appName\": \"MoviePilot\",\n        \"instanceName\": \"moviepilot\",\n        \"version\": APP_VERSION,\n        \"buildTime\": \"\",\n        \"isDebug\": False,\n        \"isProduction\": True,\n        \"isAdmin\": True,\n        \"isUserInteractive\": True,\n        \"startupPath\": \"/app\",\n        \"appData\": \"/config\",\n        \"osName\": \"debian\",\n        \"osVersion\": \"\",\n        \"isNetCore\": True,\n        \"isLinux\": True,\n        \"isOsx\": False,\n        \"isWindows\": False,\n        \"isDocker\": True,\n        \"mode\": \"console\",\n        \"branch\": \"main\",\n        \"databaseType\": \"sqLite\",\n        \"databaseVersion\": {\n            \"major\": 0,\n            \"minor\": 0,\n            \"build\": 0,\n            \"revision\": 0,\n            \"majorRevision\": 0,\n            \"minorRevision\": 0\n        },\n        \"authentication\": \"none\",\n        \"migrationVersion\": 0,\n        \"urlBase\": \"\",\n        \"runtimeVersion\": {\n            \"major\": 0,\n            \"minor\": 0,\n            \"build\": 0,\n            \"revision\": 0,\n            \"majorRevision\": 0,\n            \"minorRevision\": 0\n        },\n        \"runtimeName\": \"\",\n        \"startTime\": \"\",\n        \"packageVersion\": \"\",\n        \"packageAuthor\": \"jxxghp\",\n        \"packageUpdateMechanism\": \"builtIn\",\n        \"packageUpdateMechanismMessage\": \"\"\n    }\n\n\n@arr_router.get(\"/qualityProfile\", summary=\"质量配置\")\nasync def arr_qualityProfile(_: Annotated[str, Depends(verify_apikey)]) -> Any:\n    \"\"\"\n    模拟Radarr、Sonarr质量配置\n    \"\"\"\n    return [\n        {\n            \"id\": 1,\n            \"name\": \"默认\",\n            \"upgradeAllowed\": True,\n            \"cutoff\": 0,\n            \"items\": [\n                {\n                    \"id\": 0,\n                    \"name\": \"默认\",\n                    \"quality\": {\n                        \"id\": 0,\n                        \"name\": \"默认\",\n                        \"source\": \"0\",\n                        \"resolution\": 0\n                    },\n                    \"items\": [\n                        \"string\"\n                    ],\n                    \"allowed\": True\n                }\n            ],\n            \"minFormatScore\": 0,\n            \"cutoffFormatScore\": 0,\n            \"formatItems\": [\n                {\n                    \"id\": 0,\n                    \"format\": 0,\n                    \"name\": \"默认\",\n                    \"score\": 0\n                }\n            ]\n        }\n    ]\n\n\n@arr_router.get(\"/rootfolder\", summary=\"根目录\")\nasync def arr_rootfolder(_: Annotated[str, Depends(verify_apikey)]) -> Any:\n    \"\"\"\n    模拟Radarr、Sonarr根目录\n    \"\"\"\n    return [\n        {\n            \"id\": 1,\n            \"path\": \"/\",\n            \"accessible\": True,\n            \"freeSpace\": 0,\n            \"unmappedFolders\": []\n        }\n    ]\n\n\n@arr_router.get(\"/tag\", summary=\"标签\")\nasync def arr_tag(_: Annotated[str, Depends(verify_apikey)]) -> Any:\n    \"\"\"\n    模拟Radarr、Sonarr标签\n    \"\"\"\n    return [\n        {\n            \"id\": 1,\n            \"label\": \"默认\"\n        }\n    ]\n\n\n@arr_router.get(\"/languageprofile\", summary=\"语言\")\nasync def arr_languageprofile(_: Annotated[str, Depends(verify_apikey)]) -> Any:\n    \"\"\"\n    模拟Radarr、Sonarr语言\n    \"\"\"\n    return [{\n        \"id\": 1,\n        \"name\": \"默认\",\n        \"upgradeAllowed\": True,\n        \"cutoff\": {\n            \"id\": 1,\n            \"name\": \"默认\"\n        },\n        \"languages\": [\n            {\n                \"id\": 1,\n                \"language\": {\n                    \"id\": 1,\n                    \"name\": \"默认\"\n                },\n                \"allowed\": True\n            }\n        ]\n    }]\n\n\n@arr_router.get(\"/movie\", summary=\"所有订阅电影\", response_model=List[schemas.RadarrMovie])\nasync def arr_movies(_: Annotated[str, Depends(verify_apikey)], db: AsyncSession = Depends(get_async_db)) -> Any:\n    \"\"\"\n    查询Rardar电影\n    \"\"\"\n    \"\"\"\n    [\n      {\n        \"id\": 0,\n        \"title\": \"string\",\n        \"originalTitle\": \"string\",\n        \"originalLanguage\": {\n          \"id\": 0,\n          \"name\": \"string\"\n        },\n        \"secondaryYear\": 0,\n        \"secondaryYearSourceId\": 0,\n        \"sortTitle\": \"string\",\n        \"sizeOnDisk\": 0,\n        \"status\": \"tba\",\n        \"overview\": \"string\",\n        \"inCinemas\": \"2023-06-13T09:23:41.494Z\",\n        \"physicalRelease\": \"2023-06-13T09:23:41.494Z\",\n        \"digitalRelease\": \"2023-06-13T09:23:41.494Z\",\n        \"physicalReleaseNote\": \"string\",\n        \"images\": [\n          {\n            \"coverType\": \"unknown\",\n            \"url\": \"string\",\n            \"remoteUrl\": \"string\"\n          }\n        ],\n        \"website\": \"string\",\n        \"remotePoster\": \"string\",\n        \"year\": 0,\n        \"hasFile\": true,\n        \"youTubeTrailerId\": \"string\",\n        \"studio\": \"string\",\n        \"path\": \"string\",\n        \"qualityProfileId\": 0,\n        \"monitored\": true,\n        \"minimumAvailability\": \"tba\",\n        \"isAvailable\": true,\n        \"folderName\": \"string\",\n        \"runtime\": 0,\n        \"cleanTitle\": \"string\",\n        \"imdbId\": \"string\",\n        \"tmdbId\": 0,\n        \"titleSlug\": \"string\",\n        \"rootFolderPath\": \"string\",\n        \"folder\": \"string\",\n        \"certification\": \"string\",\n        \"genres\": [\n          \"string\"\n        ],\n        \"tags\": [\n          0\n        ],\n        \"added\": \"2023-06-13T09:23:41.494Z\",\n        \"addOptions\": {\n          \"ignoreEpisodesWithFiles\": true,\n          \"ignoreEpisodesWithoutFiles\": true,\n          \"monitor\": \"movieOnly\",\n          \"searchForMovie\": true,\n          \"addMethod\": \"manual\"\n        },\n        \"popularity\": 0\n      }\n    ]\n    \"\"\"\n    # 查询所有电影订阅\n    result = []\n    subscribes = await Subscribe.async_list(db)\n    for subscribe in subscribes:\n        if subscribe.type != MediaType.MOVIE.value:\n            continue\n        result.append(RadarrMovie(\n            id=subscribe.id,\n            title=subscribe.name,\n            year=subscribe.year,\n            isAvailable=True,\n            monitored=True,\n            tmdbId=subscribe.tmdbid,\n            imdbId=subscribe.imdbid,\n            profileId=1,\n            qualityProfileId=1,\n            hasFile=False\n        ))\n    return result\n\n\n@arr_router.get(\"/movie/lookup\", summary=\"查询电影\", response_model=List[schemas.RadarrMovie])\ndef arr_movie_lookup(term: str, _: Annotated[str, Depends(verify_apikey)], db: Session = Depends(get_db)) -> Any:\n    \"\"\"\n    查询Rardar电影 term: `tmdb:${id}`\n    存在和不存在均不能返回错误\n    \"\"\"\n    tmdbid = term.replace(\"tmdb:\", \"\")\n    # 查询媒体信息\n    mediainfo = MediaChain().recognize_media(mtype=MediaType.MOVIE, tmdbid=int(tmdbid))\n    if not mediainfo:\n        return [RadarrMovie()]\n    # 查询是否已存在\n    exists = MediaChain().media_exists(mediainfo=mediainfo)\n    if not exists:\n        # 文件不存在\n        hasfile = False\n    else:\n        # 文件存在\n        hasfile = True\n    # 查询是否已订阅\n    subscribes = Subscribe.get_by_tmdbid(db, int(tmdbid))\n    if subscribes:\n        # 订阅ID\n        subid = subscribes[0].id\n        # 已订阅\n        monitored = True\n    else:\n        subid = None\n        monitored = False\n\n    return [RadarrMovie(\n        id=subid,\n        title=mediainfo.title,\n        year=mediainfo.year,\n        isAvailable=True,\n        monitored=monitored,\n        tmdbId=mediainfo.tmdb_id,\n        imdbId=mediainfo.imdb_id,\n        titleSlug=mediainfo.original_title,\n        folderName=mediainfo.title_year,\n        profileId=1,\n        qualityProfileId=1,\n        hasFile=hasfile\n    )]\n\n\n@arr_router.get(\"/movie/{mid}\", summary=\"电影订阅详情\", response_model=schemas.RadarrMovie)\nasync def arr_movie(mid: int, _: Annotated[str, Depends(verify_apikey)],\n                    db: AsyncSession = Depends(get_async_db)) -> Any:\n    \"\"\"\n    查询Rardar电影订阅\n    \"\"\"\n    subscribe = await Subscribe.async_get(db, mid)\n    if subscribe:\n        return RadarrMovie(\n            id=subscribe.id,\n            title=subscribe.name,\n            year=subscribe.year,\n            isAvailable=True,\n            monitored=True,\n            tmdbId=subscribe.tmdbid,\n            imdbId=subscribe.imdbid,\n            profileId=1,\n            qualityProfileId=1,\n            hasFile=False\n        )\n    else:\n        raise HTTPException(\n            status_code=404,\n            detail=\"未找到该电影！\"\n        )\n\n\n@arr_router.post(\"/movie\", summary=\"新增电影订阅\")\nasync def arr_add_movie(_: Annotated[str, Depends(verify_apikey)],\n                        movie: RadarrMovie,\n                        db: AsyncSession = Depends(get_async_db)\n                        ) -> Any:\n    \"\"\"\n    新增Rardar电影订阅\n    \"\"\"\n    # 检查订阅是否已存在\n    subscribe = await Subscribe.async_get_by_tmdbid(db, movie.tmdbId)\n    if subscribe:\n        return {\n            \"id\": subscribe.id\n        }\n    # 添加订阅\n    sid, message = await SubscribeChain().async_add(title=movie.title,\n                                                    year=movie.year,\n                                                    mtype=MediaType.MOVIE,\n                                                    tmdbid=movie.tmdbId,\n                                                    username=\"Seerr\")\n    if sid:\n        return {\n            \"id\": sid\n        }\n    else:\n        raise HTTPException(\n            status_code=500,\n            detail=f\"添加订阅失败：{message}\"\n        )\n\n\n@arr_router.delete(\"/movie/{mid}\", summary=\"删除电影订阅\", response_model=schemas.Response)\nasync def arr_remove_movie(mid: int, _: Annotated[str, Depends(verify_apikey)],\n                           db: AsyncSession = Depends(get_async_db)) -> Any:\n    \"\"\"\n    删除Rardar电影订阅\n    \"\"\"\n    subscribe = await Subscribe.async_get(db, mid)\n    if subscribe:\n        await subscribe.async_delete(db, mid)\n        return schemas.Response(success=True)\n    else:\n        raise HTTPException(\n            status_code=404,\n            detail=\"未找到该电影！\"\n        )\n\n\n@arr_router.get(\"/series\", summary=\"所有剧集\", response_model=List[schemas.SonarrSeries])\nasync def arr_series(_: Annotated[str, Depends(verify_apikey)], db: AsyncSession = Depends(get_async_db)) -> Any:\n    \"\"\"\n    查询Sonarr剧集\n    \"\"\"\n    \"\"\"\n    [\n      {\n        \"id\": 0,\n        \"title\": \"string\",\n        \"sortTitle\": \"string\",\n        \"status\": \"continuing\",\n        \"ended\": true,\n        \"profileName\": \"string\",\n        \"overview\": \"string\",\n        \"nextAiring\": \"2023-06-13T09:08:17.624Z\",\n        \"previousAiring\": \"2023-06-13T09:08:17.624Z\",\n        \"network\": \"string\",\n        \"airTime\": \"string\",\n        \"images\": [\n          {\n            \"coverType\": \"unknown\",\n            \"url\": \"string\",\n            \"remoteUrl\": \"string\"\n          }\n        ],\n        \"originalLanguage\": {\n          \"id\": 0,\n          \"name\": \"string\"\n        },\n        \"remotePoster\": \"string\",\n        \"seasons\": [\n          {\n            \"seasonNumber\": 0,\n            \"monitored\": true,\n            \"statistics\": {\n              \"nextAiring\": \"2023-06-13T09:08:17.624Z\",\n              \"previousAiring\": \"2023-06-13T09:08:17.624Z\",\n              \"episodeFileCount\": 0,\n              \"episodeCount\": 0,\n              \"totalEpisodeCount\": 0,\n              \"sizeOnDisk\": 0,\n              \"releaseGroups\": [\n                \"string\"\n              ],\n              \"percentOfEpisodes\": 0\n            },\n            \"images\": [\n              {\n                \"coverType\": \"unknown\",\n                \"url\": \"string\",\n                \"remoteUrl\": \"string\"\n              }\n            ]\n          }\n        ],\n        \"year\": 0,\n        \"path\": \"string\",\n        \"qualityProfileId\": 0,\n        \"seasonFolder\": true,\n        \"monitored\": true,\n        \"useSceneNumbering\": true,\n        \"runtime\": 0,\n        \"tvdbId\": 0,\n        \"tvRageId\": 0,\n        \"tvMazeId\": 0,\n        \"firstAired\": \"2023-06-13T09:08:17.624Z\",\n        \"seriesType\": \"standard\",\n        \"cleanTitle\": \"string\",\n        \"imdbId\": \"string\",\n        \"titleSlug\": \"string\",\n        \"rootFolderPath\": \"string\",\n        \"folder\": \"string\",\n        \"certification\": \"string\",\n        \"genres\": [\n          \"string\"\n        ],\n        \"tags\": [\n          0\n        ],\n        \"added\": \"2023-06-13T09:08:17.624Z\",\n        \"addOptions\": {\n          \"ignoreEpisodesWithFiles\": true,\n          \"ignoreEpisodesWithoutFiles\": true,\n          \"monitor\": \"unknown\",\n          \"searchForMissingEpisodes\": true,\n          \"searchForCutoffUnmetEpisodes\": true\n        },\n        \"ratings\": {\n          \"votes\": 0,\n          \"value\": 0\n        },\n        \"statistics\": {\n          \"seasonCount\": 0,\n          \"episodeFileCount\": 0,\n          \"episodeCount\": 0,\n          \"totalEpisodeCount\": 0,\n          \"sizeOnDisk\": 0,\n          \"releaseGroups\": [\n            \"string\"\n          ],\n          \"percentOfEpisodes\": 0\n        },\n        \"episodesChanged\": true\n      }\n    ]\n    \"\"\"\n    # 查询所有电视剧订阅\n    result = []\n    subscribes = await Subscribe.async_list(db)\n    for subscribe in subscribes:\n        if subscribe.type != MediaType.TV.value:\n            continue\n        result.append(SonarrSeries(\n            id=subscribe.id,\n            title=subscribe.name,\n            seasonCount=1,\n            seasons=[{\n                \"seasonNumber\": subscribe.season,\n                \"monitored\": True,\n            }],\n            remotePoster=subscribe.poster,\n            year=subscribe.year,\n            tmdbId=subscribe.tmdbid,\n            tvdbId=subscribe.tvdbid,\n            imdbId=subscribe.imdbid,\n            profileId=1,\n            languageProfileId=1,\n            qualityProfileId=1,\n            isAvailable=True,\n            monitored=True,\n            hasFile=False\n        ))\n    return result\n\n\n@arr_router.get(\"/series/lookup\", summary=\"查询剧集\")\ndef arr_series_lookup(term: str, _: Annotated[str, Depends(verify_apikey)], db: Session = Depends(get_db)) -> Any:\n    \"\"\"\n    查询Sonarr剧集 term: `tvdb:${id}` title\n    \"\"\"\n    # 季信息\n    seas: List[int] = []\n    # tvdbid 列表\n    tvdbids: List[int] = []\n    # 获取TVDBID\n    if not term.startswith(\"tvdb:\"):\n        title = term.replace(\"+\", \" \")\n        tvdbids = TvdbChain().get_tvdbid_by_name(title=title)\n    else:\n        tvdbid = int(term.replace(\"tvdb:\", \"\"))\n        tvdbids.append(tvdbid)\n\n    sonarr_series_list = []\n    for tvdbid in tvdbids:\n        # 查询TVDB信息\n        tvdbinfo = MediaChain().tvdb_info(tvdbid=tvdbid)\n        if not tvdbinfo:\n            continue\n\n        # 季信息(只取默认季类型，排除特别季)\n        sea_num = len([season for season in tvdbinfo.get('seasons') if\n                       season['type']['id'] == tvdbinfo.get('defaultSeasonType') and season['number'] > 0])\n        if sea_num:\n            seas = list(range(1, int(sea_num) + 1))\n\n        # 根据TVDB查询媒体信息\n        mediainfo = MediaChain().recognize_media(meta=MetaInfo(tvdbinfo.get('name')),\n                                                 mtype=MediaType.TV)\n        if not mediainfo:\n            continue\n        # 查询是否存在\n        exists = MediaChain().media_exists(mediainfo)\n        if exists:\n            hasfile = True\n        else:\n            hasfile = False\n\n        # 查询订阅信息\n        seasons: List[dict] = []\n        subscribes = Subscribe.get_by_tmdbid(db, mediainfo.tmdb_id)\n        if subscribes:\n            # 已监控\n            monitored = True\n            # 已监控季\n            sub_seas = [sub.season for sub in subscribes]\n            for sea in seas:\n                if sea in sub_seas:\n                    seasons.append({\n                        \"seasonNumber\": sea,\n                        \"monitored\": True,\n                    })\n                else:\n                    seasons.append({\n                        \"seasonNumber\": sea,\n                        \"monitored\": False,\n                    })\n            subid = subscribes[-1].id\n        else:\n            subid = None\n            monitored = False\n            for sea in seas:\n                seasons.append({\n                    \"seasonNumber\": sea,\n                    \"monitored\": False,\n                })\n        sonarr_series = SonarrSeries(\n            id=subid,\n            title=mediainfo.title,\n            seasonCount=len(seasons),\n            seasons=seasons,\n            remotePoster=mediainfo.get_poster_image(),\n            year=mediainfo.year,\n            tmdbId=mediainfo.tmdb_id,\n            tvdbId=tvdbid,\n            imdbId=mediainfo.imdb_id,\n            profileId=1,\n            languageProfileId=1,\n            monitored=monitored,\n            hasFile=hasfile,\n        )\n        sonarr_series_list.append(sonarr_series)\n\n    return sonarr_series_list if sonarr_series_list else [SonarrSeries()]\n\n\n@arr_router.get(\"/series/{tid}\", summary=\"剧集详情\")\nasync def arr_serie(tid: int, _: Annotated[str, Depends(verify_apikey)],\n                    db: AsyncSession = Depends(get_async_db)) -> Any:\n    \"\"\"\n    查询Sonarr剧集\n    \"\"\"\n    subscribe = await Subscribe.async_get(db, tid)\n    if subscribe:\n        return SonarrSeries(\n            id=subscribe.id,\n            title=subscribe.name,\n            seasonCount=1,\n            seasons=[{\n                \"seasonNumber\": subscribe.season,\n                \"monitored\": True,\n            }],\n            year=subscribe.year,\n            remotePoster=subscribe.poster,\n            tmdbId=subscribe.tmdbid,\n            tvdbId=subscribe.tvdbid,\n            imdbId=subscribe.imdbid,\n            profileId=1,\n            languageProfileId=1,\n            qualityProfileId=1,\n            isAvailable=True,\n            monitored=True,\n            hasFile=False\n        )\n    else:\n        raise HTTPException(\n            status_code=404,\n            detail=\"未找到该电视剧！\"\n        )\n\n\n@arr_router.post(\"/series\", summary=\"新增剧集订阅\")\nasync def arr_add_series(tv: schemas.SonarrSeries,\n                         _: Annotated[str, Depends(verify_apikey)],\n                         db: AsyncSession = Depends(get_async_db)) -> Any:\n    \"\"\"\n    新增Sonarr剧集订阅\n    \"\"\"\n    # 检查订阅是否存在\n    left_seasons = []\n    for season in tv.seasons:\n        subscribe = await Subscribe.async_get_by_tmdbid(db, tmdbid=tv.tmdbId,\n                                                        season=season.get(\"seasonNumber\"))\n        if subscribe:\n            continue\n        left_seasons.append(season)\n    # 全部已存在订阅\n    if not left_seasons:\n        return {\n            \"id\": 1\n        }\n    # 剩下的添加订阅\n    sid = 0\n    message = \"\"\n    for season in left_seasons:\n        if not season.get(\"monitored\"):\n            continue\n        sid, message = await SubscribeChain().async_add(title=tv.title,\n                                                        year=tv.year,\n                                                        season=season.get(\"seasonNumber\"),\n                                                        tmdbid=tv.tmdbId,\n                                                        mtype=MediaType.TV,\n                                                        username=\"Seerr\")\n\n    if sid:\n        return {\n            \"id\": sid\n        }\n    else:\n        raise HTTPException(\n            status_code=500,\n            detail=f\"添加订阅失败：{message}\"\n        )\n\n\n@arr_router.put(\"/series\", summary=\"更新剧集订阅\")\nasync def arr_update_series(tv: schemas.SonarrSeries, _: Annotated[str, Depends(verify_apikey)]) -> Any:\n    \"\"\"\n    更新Sonarr剧集订阅\n    \"\"\"\n    return await arr_add_series(tv)\n\n\n@arr_router.delete(\"/series/{tid}\", summary=\"删除剧集订阅\")\nasync def arr_remove_series(tid: int, _: Annotated[str, Depends(verify_apikey)],\n                            db: AsyncSession = Depends(get_async_db)) -> Any:\n    \"\"\"\n    删除Sonarr剧集订阅\n    \"\"\"\n    subscribe = await Subscribe.async_get(db, tid)\n    if subscribe:\n        await subscribe.async_delete(db, tid)\n        return schemas.Response(success=True)\n    else:\n        raise HTTPException(\n            status_code=404,\n            detail=\"未找到该电视剧！\"\n        )\n"
  },
  {
    "path": "app/api/servcookie.py",
    "content": "import gzip\nimport json\nfrom typing import Annotated, Callable, Any, Dict, Optional\n\nimport aiofiles\nfrom anyio import Path as AsyncPath\nfrom fastapi import APIRouter, Body, Depends, HTTPException, Path, Request, Response\nfrom fastapi.responses import PlainTextResponse\nfrom fastapi.routing import APIRoute\n\nfrom app import schemas\nfrom app.core.config import settings\nfrom app.log import logger\nfrom app.utils.crypto import CryptoJsUtils, HashUtils\n\n\nclass GzipRequest(Request):\n\n    async def body(self) -> bytes:\n        if not hasattr(self, \"_body\"):\n            body = await super().body()\n            if \"gzip\" in self.headers.getlist(\"Content-Encoding\"):\n                body = gzip.decompress(body)\n            self._body = body  # noqa\n        return self._body\n\n\nclass GzipRoute(APIRoute):\n\n    def get_route_handler(self) -> Callable:\n        original_route_handler = super().get_route_handler()\n\n        async def custom_route_handler(request: Request) -> Response:\n            request = GzipRequest(request.scope, request.receive)\n            return await original_route_handler(request)\n\n        return custom_route_handler\n\n\nasync def verify_server_enabled():\n    \"\"\"\n    校验CookieCloud服务路由是否打开\n    \"\"\"\n    if not settings.COOKIECLOUD_ENABLE_LOCAL:\n        raise HTTPException(status_code=400, detail=\"本地CookieCloud服务器未启用\")\n    return True\n\n\ncookie_router = APIRouter(route_class=GzipRoute,\n                          tags=[\"servcookie\"],\n                          dependencies=[Depends(verify_server_enabled)])\n\n\n@cookie_router.get(\"/\", response_class=PlainTextResponse)\nasync def get_root():\n    return \"Hello MoviePilot! COOKIECLOUD API ROOT = /cookiecloud\"\n\n\n@cookie_router.post(\"/\", response_class=PlainTextResponse)\nasync def post_root():\n    return \"Hello MoviePilot! COOKIECLOUD API ROOT = /cookiecloud\"\n\n\n@cookie_router.post(\"/update\")\nasync def update_cookie(req: schemas.CookieData):\n    \"\"\"\n    上传Cookie数据\n    \"\"\"\n    file_path = AsyncPath(settings.COOKIE_PATH) / f\"{req.uuid}.json\"\n    content = json.dumps({\"encrypted\": req.encrypted})\n    async with aiofiles.open(file_path, encoding=\"utf-8\", mode=\"w\") as file:\n        await file.write(content)\n    async with aiofiles.open(file_path, encoding=\"utf-8\", mode=\"r\") as file:\n        read_content = await file.read()\n    if read_content == content:\n        return {\"action\": \"done\"}\n    else:\n        return {\"action\": \"error\"}\n\n\nasync def load_encrypt_data(uuid: str) -> Dict[str, Any]:\n    \"\"\"\n    加载本地加密原始数据\n    \"\"\"\n    file_path = AsyncPath(settings.COOKIE_PATH) / f\"{uuid}.json\"\n\n    # 检查文件是否存在\n    if not file_path.exists():\n        raise HTTPException(status_code=404, detail=\"Item not found\")\n\n    # 读取文件\n    async with aiofiles.open(file_path, encoding=\"utf-8\", mode=\"r\") as file:\n        read_content = await file.read()\n    data = json.loads(read_content.encode(\"utf-8\"))\n    return data\n\n\ndef get_decrypted_cookie_data(uuid: str, password: str,\n                              encrypted: str) -> Optional[Dict[str, Any]]:\n    \"\"\"\n    加载本地加密数据并解密为Cookie\n    \"\"\"\n    combined_string = f\"{uuid}-{password}\"\n    aes_key = HashUtils.md5(combined_string)[:16].encode(\"utf-8\")\n\n    if encrypted:\n        try:\n            decrypted_data = CryptoJsUtils.decrypt(encrypted, aes_key).decode(\"utf-8\")\n            decrypted_data = json.loads(decrypted_data)\n            if \"cookie_data\" in decrypted_data:\n                return decrypted_data\n        except Exception as e:\n            logger.error(f\"解密Cookie数据失败：{str(e)}\")\n            return None\n    else:\n        return None\n\n\n@cookie_router.get(\"/get/{uuid}\")\nasync def get_cookie(\n        uuid: Annotated[str, Path(min_length=5, pattern=\"^[a-zA-Z0-9]+$\")]):\n    \"\"\"\n    GET 下载加密数据\n    \"\"\"\n    return await load_encrypt_data(uuid)\n\n\n@cookie_router.post(\"/get/{uuid}\")\nasync def post_cookie(\n        uuid: Annotated[str, Path(min_length=5, pattern=\"^[a-zA-Z0-9]+$\")],\n        request: Optional[schemas.CookiePassword] = Body(None)):\n    \"\"\"\n    POST 下载加密数据\n    \"\"\"\n    data = await load_encrypt_data(uuid)\n    if request is not None:\n        return get_decrypted_cookie_data(uuid, request.password, data[\"encrypted\"])\n    else:\n        return data\n"
  },
  {
    "path": "app/chain/__init__.py",
    "content": "import copy\nimport inspect\nimport pickle\nimport traceback\nfrom abc import ABCMeta\nfrom collections.abc import Callable\nfrom datetime import datetime\nfrom pathlib import Path\nfrom typing import Optional, Any, Tuple, List, Set, Union, Dict\n\nfrom fastapi.concurrency import run_in_threadpool\nfrom qbittorrentapi import TorrentFilesList\nfrom transmission_rpc import File\n\nfrom app.core.cache import FileCache, AsyncFileCache, fresh, async_fresh\nfrom app.core.config import settings\nfrom app.core.context import Context, MediaInfo, TorrentInfo\nfrom app.core.event import EventManager\nfrom app.core.meta import MetaBase\nfrom app.core.module import ModuleManager\nfrom app.core.plugin import PluginManager\nfrom app.db.message_oper import MessageOper\nfrom app.db.user_oper import UserOper\nfrom app.helper.message import MessageHelper, MessageQueueManager, MessageTemplateHelper\nfrom app.helper.service import ServiceConfigHelper\nfrom app.log import logger\nfrom app.schemas import TransferInfo, TransferTorrent, ExistMediaInfo, DownloadingTorrent, CommingMessage, Notification, \\\n    WebhookEventInfo, TmdbEpisode, MediaPerson, FileItem, TransferDirectoryConf\nfrom app.schemas.category import CategoryConfig\nfrom app.schemas.types import TorrentStatus, MediaType, MediaImageType, EventType, MessageChannel\nfrom app.utils.object import ObjectUtils\n\n\nclass ChainBase(metaclass=ABCMeta):\n    \"\"\"\n    处理链基类\n    \"\"\"\n\n    def __init__(self):\n        \"\"\"\n        公共初始化\n        \"\"\"\n        self.modulemanager = ModuleManager()\n        self.eventmanager = EventManager()\n        self.messageoper = MessageOper()\n        self.messagehelper = MessageHelper()\n        self.messagequeue = MessageQueueManager(\n            send_callback=self.run_module\n        )\n        self.pluginmanager = PluginManager()\n        self.filecache = FileCache()\n        self.async_filecache = AsyncFileCache()\n\n    def load_cache(self, filename: str) -> Any:\n        \"\"\"\n        加载缓存\n        \"\"\"\n        content = self.filecache.get(filename)\n        if not content:\n            return None\n        try:\n            return pickle.loads(content)\n        except Exception as err:\n            logger.error(f\"加载缓存 {filename} 出错：{str(err)}\")\n            return None\n\n    async def async_load_cache(self, filename: str) -> Any:\n        \"\"\"\n        异步加载缓存\n        \"\"\"\n        content = await self.async_filecache.get(filename)\n        if not content:\n            return None\n        try:\n            return pickle.loads(content)\n        except Exception as err:\n            logger.error(f\"异步加载缓存 {filename} 出错：{str(err)}\")\n            return None\n\n    async def async_save_cache(self, cache: Any, filename: str) -> None:\n        \"\"\"\n        异步保存缓存\n        \"\"\"\n        try:\n            await self.async_filecache.set(filename, pickle.dumps(cache))\n        except Exception as err:\n            logger.error(f\"异步保存缓存 {filename} 出错：{str(err)}\")\n            return\n\n    def save_cache(self, cache: Any, filename: str) -> None:\n        \"\"\"\n        保存缓存\n        \"\"\"\n        try:\n            self.filecache.set(filename, pickle.dumps(cache))\n        except Exception as err:\n            logger.error(f\"保存缓存 {filename} 出错：{str(err)}\")\n            return\n\n    def remove_cache(self, filename: str) -> None:\n        \"\"\"\n        删除缓存，同时删除Redis和本地缓存\n        \"\"\"\n        self.filecache.delete(filename)\n\n    async def async_remove_cache(self, filename: str) -> None:\n        \"\"\"\n        异步删除缓存，同时删除Redis和本地缓存\n        \"\"\"\n        await self.async_filecache.delete(filename)\n\n    @staticmethod\n    def __is_valid_empty(ret):\n        \"\"\"\n        判断结果是否为空\n        \"\"\"\n        if isinstance(ret, tuple):\n            return all(value is None for value in ret)\n        else:\n            return ret is None\n\n    def __handle_plugin_error(self, err: Exception, plugin_id: str, plugin_name: str, method: str, **kwargs):\n        \"\"\"\n        处理插件模块执行错误\n        \"\"\"\n        if kwargs.get(\"raise_exception\"):\n            raise\n        logger.error(\n            f\"运行插件 {plugin_id} 模块 {method} 出错：{str(err)}\\n{traceback.format_exc()}\")\n        self.messagehelper.put(title=f\"{plugin_name} 发生了错误\",\n                               message=str(err),\n                               role=\"plugin\")\n        self.eventmanager.send_event(\n            EventType.SystemError,\n            {\n                \"type\": \"plugin\",\n                \"plugin_id\": plugin_id,\n                \"plugin_name\": plugin_name,\n                \"plugin_method\": method,\n                \"error\": str(err),\n                \"traceback\": traceback.format_exc()\n            }\n        )\n\n    def __handle_system_error(self, err: Exception, module_id: str, module_name: str, method: str, **kwargs):\n        \"\"\"\n        处理系统模块执行错误\n        \"\"\"\n        if kwargs.get(\"raise_exception\"):\n            raise\n        logger.error(\n            f\"运行模块 {module_id}.{method} 出错：{str(err)}\\n{traceback.format_exc()}\")\n        self.messagehelper.put(title=f\"{module_name}发生了错误\",\n                               message=str(err),\n                               role=\"system\")\n        self.eventmanager.send_event(\n            EventType.SystemError,\n            {\n                \"type\": \"module\",\n                \"module_id\": module_id,\n                \"module_name\": module_name,\n                \"module_method\": method,\n                \"error\": str(err),\n                \"traceback\": traceback.format_exc()\n            }\n        )\n\n    def __execute_plugin_modules(self, method: str, result: Any, *args, **kwargs) -> Any:\n        \"\"\"\n        执行插件模块\n        \"\"\"\n        for plugin, module_dict in self.pluginmanager.get_plugin_modules().items():\n            plugin_id, plugin_name = plugin\n            if method in module_dict:\n                func = module_dict[method]\n                if func:\n                    try:\n                        logger.info(f\"请求插件 {plugin_name} 执行：{method} ...\")\n                        if self.__is_valid_empty(result):\n                            # 返回None，第一次执行或者需继续执行下一模块\n                            result = func(*args, **kwargs)\n                        elif isinstance(result, list):\n                            # 返回为列表，有多个模块运行结果时进行合并\n                            temp = func(*args, **kwargs)\n                            if isinstance(temp, list):\n                                result.extend(temp)\n                        else:\n                            break\n                    except Exception as err:\n                        self.__handle_plugin_error(err, plugin_id, plugin_name, method, **kwargs)\n        return result\n\n    async def __async_execute_plugin_modules(self, method: str, result: Any, *args, **kwargs) -> Any:\n        \"\"\"\n        异步执行插件模块\n        \"\"\"\n        for plugin, module_dict in self.pluginmanager.get_plugin_modules().items():\n            plugin_id, plugin_name = plugin\n            if method in module_dict:\n                func = module_dict[method]\n                if func:\n                    try:\n                        logger.info(f\"请求插件 {plugin_name} 执行：{method} ...\")\n                        if self.__is_valid_empty(result):\n                            # 返回None，第一次执行或者需继续执行下一模块\n                            if inspect.iscoroutinefunction(func):\n                                result = await func(*args, **kwargs)\n                            else:\n                                # 插件同步函数在异步环境中运行，避免阻塞\n                                result = await run_in_threadpool(func, *args, **kwargs)\n                        elif isinstance(result, list):\n                            # 返回为列表，有多个模块运行结果时进行合并\n                            if inspect.iscoroutinefunction(func):\n                                temp = await func(*args, **kwargs)\n                            else:\n                                # 插件同步函数在异步环境中运行，避免阻塞\n                                temp = await run_in_threadpool(func, *args, **kwargs)\n                            if isinstance(temp, list):\n                                result.extend(temp)\n                        else:\n                            break\n                    except Exception as err:\n                        self.__handle_plugin_error(err, plugin_id, plugin_name, method, **kwargs)\n        return result\n\n    def __execute_system_modules(self, method: str, result: Any, *args, **kwargs) -> Any:\n        \"\"\"\n        执行系统模块\n        \"\"\"\n        logger.debug(f\"请求系统模块执行：{method} ...\")\n        for module in sorted(self.modulemanager.get_running_modules(method), key=lambda x: x.get_priority()):\n            module_id = module.__class__.__name__\n            try:\n                module_name = module.get_name()\n            except Exception as err:\n                logger.debug(f\"获取模块名称出错：{str(err)}\")\n                module_name = module_id\n            try:\n                func = getattr(module, method)\n                if self.__is_valid_empty(result):\n                    # 返回None，第一次执行或者需继续执行下一模块\n                    result = func(*args, **kwargs)\n                elif ObjectUtils.check_signature(func, result):\n                    # 返回结果与方法签名一致，将结果传入\n                    result = func(result)\n                elif isinstance(result, list):\n                    # 返回为列表，有多个模块运行结果时进行合并\n                    temp = func(*args, **kwargs)\n                    if isinstance(temp, list):\n                        result.extend(temp)\n                else:\n                    # 中止继续执行\n                    break\n            except Exception as err:\n                logger.error(traceback.format_exc())\n                self.__handle_system_error(err, module_id, module_name, method, **kwargs)\n        return result\n\n    async def __async_execute_system_modules(self, method: str, result: Any, *args, **kwargs) -> Any:\n        \"\"\"\n        异步执行系统模块\n        \"\"\"\n        logger.debug(f\"请求系统模块执行：{method} ...\")\n        for module in sorted(self.modulemanager.get_running_modules(method), key=lambda x: x.get_priority()):\n            module_id = module.__class__.__name__\n            try:\n                module_name = module.get_name()\n            except Exception as err:\n                logger.debug(f\"获取模块名称出错：{str(err)}\")\n                module_name = module_id\n            try:\n                func = getattr(module, method)\n                if self.__is_valid_empty(result):\n                    # 返回None，第一次执行或者需继续执行下一模块\n                    if inspect.iscoroutinefunction(func):\n                        result = await func(*args, **kwargs)\n                    else:\n                        result = func(*args, **kwargs)\n                elif ObjectUtils.check_signature(func, result):\n                    # 返回结果与方法签名一致，将结果传入\n                    if inspect.iscoroutinefunction(func):\n                        result = await func(result)\n                    else:\n                        result = func(result)\n                elif isinstance(result, list):\n                    # 返回为列表，有多个模块运行结果时进行合并\n                    if inspect.iscoroutinefunction(func):\n                        temp = await func(*args, **kwargs)\n                    else:\n                        temp = func(*args, **kwargs)\n                    if isinstance(temp, list):\n                        result.extend(temp)\n                else:\n                    # 中止继续执行\n                    break\n            except Exception as err:\n                logger.error(traceback.format_exc())\n                self.__handle_system_error(err, module_id, module_name, method, **kwargs)\n        return result\n\n    def run_module(self, method: str, *args, **kwargs) -> Any:\n        \"\"\"\n        运行包含该方法的所有模块，然后返回结果\n        当kwargs包含命名参数raise_exception时，如模块方法抛出异常且raise_exception为True，则同步抛出异常\n        \"\"\"\n        result = None\n\n        # 执行插件模块\n        result = self.__execute_plugin_modules(method, result, *args, **kwargs)\n\n        if not self.__is_valid_empty(result) and not isinstance(result, list):\n            # 插件模块返回结果不为空且不是列表，直接返回\n            return result\n\n        # 执行系统模块\n        return self.__execute_system_modules(method, result, *args, **kwargs)\n\n    async def async_run_module(self, method: str, *args, **kwargs) -> Any:\n        \"\"\"\n        异步运行包含该方法的所有模块，然后返回结果\n        当kwargs包含命名参数raise_exception时，如模块方法抛出异常且raise_exception为True，则同步抛出异常\n        支持异步和同步方法的混合调用\n        \"\"\"\n        result = None\n\n        # 执行插件模块\n        result = await self.__async_execute_plugin_modules(method, result, *args, **kwargs)\n\n        if not self.__is_valid_empty(result) and not isinstance(result, list):\n            # 插件模块返回结果不为空且不是列表，直接返回\n            return result\n\n        # 执行系统模块\n        return await self.__async_execute_system_modules(method, result, *args, **kwargs)\n\n    def recognize_media(self, meta: MetaBase = None,\n                        mtype: Optional[MediaType] = None,\n                        tmdbid: Optional[int] = None,\n                        doubanid: Optional[str] = None,\n                        bangumiid: Optional[int] = None,\n                        episode_group: Optional[str] = None,\n                        cache: bool = True) -> Optional[MediaInfo]:\n        \"\"\"\n        识别媒体信息，不含Fanart图片\n        :param meta:     识别的元数据\n        :param mtype:    识别的媒体类型，与tmdbid配套\n        :param tmdbid:   tmdbid\n        :param doubanid: 豆瓣ID\n        :param bangumiid: BangumiID\n        :param episode_group: 剧集组\n        :param cache:    是否使用缓存\n        :return: 识别的媒体信息，包括剧集信息\n        \"\"\"\n        # 识别用名中含指定信息情形\n        if not mtype and meta and meta.type in [MediaType.TV, MediaType.MOVIE]:\n            mtype = meta.type\n        if not tmdbid and hasattr(meta, \"tmdbid\"):\n            tmdbid = meta.tmdbid\n        if not doubanid and hasattr(meta, \"doubanid\"):\n            doubanid = meta.doubanid\n        # 有tmdbid时不使用其它ID\n        if tmdbid:\n            doubanid = None\n            bangumiid = None\n        with fresh(not cache):\n            return self.run_module(\"recognize_media\", meta=meta, mtype=mtype,\n                                tmdbid=tmdbid, doubanid=doubanid, bangumiid=bangumiid,\n                                episode_group=episode_group, cache=cache)\n\n    async def async_recognize_media(self, meta: MetaBase = None,\n                                    mtype: Optional[MediaType] = None,\n                                    tmdbid: Optional[int] = None,\n                                    doubanid: Optional[str] = None,\n                                    bangumiid: Optional[int] = None,\n                                    episode_group: Optional[str] = None,\n                                    cache: bool = True) -> Optional[MediaInfo]:\n        \"\"\"\n        识别媒体信息，不含Fanart图片（异步版本）\n        :param meta:     识别的元数据\n        :param mtype:    识别的媒体类型，与tmdbid配套\n        :param tmdbid:   tmdbid\n        :param doubanid: 豆瓣ID\n        :param bangumiid: BangumiID\n        :param episode_group: 剧集组\n        :param cache:    是否使用缓存\n        :return: 识别的媒体信息，包括剧集信息\n        \"\"\"\n        # 识别用名中含指定信息情形\n        if not mtype and meta and meta.type in [MediaType.TV, MediaType.MOVIE]:\n            mtype = meta.type\n        if not tmdbid and hasattr(meta, \"tmdbid\"):\n            tmdbid = meta.tmdbid\n        if not doubanid and hasattr(meta, \"doubanid\"):\n            doubanid = meta.doubanid\n        # 有tmdbid时不使用其它ID\n        if tmdbid:\n            doubanid = None\n            bangumiid = None\n        async with async_fresh(not cache):\n            return await self.async_run_module(\"async_recognize_media\", meta=meta, mtype=mtype,\n                                            tmdbid=tmdbid, doubanid=doubanid, bangumiid=bangumiid,\n                                            episode_group=episode_group, cache=cache)\n\n    def match_doubaninfo(self, name: str, imdbid: Optional[str] = None,\n                         mtype: Optional[MediaType] = None, year: Optional[str] = None, season: Optional[int] = None,\n                         raise_exception: bool = False) -> Optional[dict]:\n        \"\"\"\n        搜索和匹配豆瓣信息\n        :param name: 标题\n        :param imdbid: imdbid\n        :param mtype: 类型\n        :param year: 年份\n        :param season: 季\n        :param raise_exception: 触发速率限制时是否抛出异常\n        \"\"\"\n        return self.run_module(\"match_doubaninfo\", name=name, imdbid=imdbid,\n                               mtype=mtype, year=year, season=season, raise_exception=raise_exception)\n\n    async def async_match_doubaninfo(self, name: str, imdbid: Optional[str] = None,\n                                     mtype: Optional[MediaType] = None, year: Optional[str] = None,\n                                     season: Optional[int] = None,\n                                     raise_exception: bool = False) -> Optional[dict]:\n        \"\"\"\n        搜索和匹配豆瓣信息（异步版本）\n        :param name: 标题\n        :param imdbid: imdbid\n        :param mtype: 类型\n        :param year: 年份\n        :param season: 季\n        :param raise_exception: 触发速率限制时是否抛出异常\n        \"\"\"\n        return await self.async_run_module(\"async_match_doubaninfo\", name=name, imdbid=imdbid,\n                                           mtype=mtype, year=year, season=season, raise_exception=raise_exception)\n\n    def match_tmdbinfo(self, name: str, mtype: Optional[MediaType] = None,\n                       year: Optional[str] = None, season: Optional[int] = None) -> Optional[dict]:\n        \"\"\"\n        搜索和匹配TMDB信息\n        :param name: 标题\n        :param mtype: 类型\n        :param year: 年份\n        :param season: 季\n        \"\"\"\n        return self.run_module(\"match_tmdbinfo\", name=name,\n                               mtype=mtype, year=year, season=season)\n\n    async def async_match_tmdbinfo(self, name: str, mtype: Optional[MediaType] = None,\n                                   year: Optional[str] = None, season: Optional[int] = None) -> Optional[dict]:\n        \"\"\"\n        搜索和匹配TMDB信息（异步版本）\n        :param name: 标题\n        :param mtype: 类型\n        :param year: 年份\n        :param season: 季\n        \"\"\"\n        return await self.async_run_module(\"async_match_tmdbinfo\", name=name,\n                                           mtype=mtype, year=year, season=season)\n\n    def obtain_images(self, mediainfo: MediaInfo) -> Optional[MediaInfo]:\n        \"\"\"\n        补充抓取媒体信息图片\n        :param mediainfo:  识别的媒体信息\n        :return: 更新后的媒体信息\n        \"\"\"\n        return self.run_module(\"obtain_images\", mediainfo=mediainfo)\n\n    async def async_obtain_images(self, mediainfo: MediaInfo) -> Optional[MediaInfo]:\n        \"\"\"\n        补充抓取媒体信息图片（异步版本）\n        :param mediainfo:  识别的媒体信息\n        :return: 更新后的媒体信息\n        \"\"\"\n        return await self.async_run_module(\"async_obtain_images\", mediainfo=mediainfo)\n\n    def obtain_specific_image(self, mediaid: Union[str, int], mtype: MediaType,\n                              image_type: MediaImageType, image_prefix: Optional[str] = None,\n                              season: Optional[int] = None, episode: Optional[int] = None) -> Optional[str]:\n        \"\"\"\n        获取指定媒体信息图片，返回图片地址\n        :param mediaid:     媒体ID\n        :param mtype:       媒体类型\n        :param image_type:  图片类型\n        :param image_prefix: 图片前缀\n        :param season:      季\n        :param episode:     集\n        \"\"\"\n        return self.run_module(\"obtain_specific_image\", mediaid=mediaid, mtype=mtype,\n                               image_prefix=image_prefix, image_type=image_type,\n                               season=season, episode=episode)\n\n    def douban_info(self, doubanid: str, mtype: Optional[MediaType] = None,\n                    raise_exception: bool = False) -> Optional[dict]:\n        \"\"\"\n        获取豆瓣信息\n        :param doubanid: 豆瓣ID\n        :param mtype: 媒体类型\n        :return: 豆瓣信息\n        :param raise_exception: 触发速率限制时是否抛出异常\n        \"\"\"\n        return self.run_module(\"douban_info\", doubanid=doubanid, mtype=mtype, raise_exception=raise_exception)\n\n    async def async_douban_info(self, doubanid: str, mtype: Optional[MediaType] = None,\n                                raise_exception: bool = False) -> Optional[dict]:\n        \"\"\"\n        获取豆瓣信息（异步版本）\n        :param doubanid: 豆瓣ID\n        :param mtype: 媒体类型\n        :return: 豆瓣信息\n        :param raise_exception: 触发速率限制时是否抛出异常\n        \"\"\"\n        return await self.async_run_module(\"async_douban_info\", doubanid=doubanid, mtype=mtype,\n                                           raise_exception=raise_exception)\n\n    def tvdb_info(self, tvdbid: int) -> Optional[dict]:\n        \"\"\"\n        获取TVDB信息\n        :param tvdbid: int\n        :return: TVDB信息\n        \"\"\"\n        return self.run_module(\"tvdb_info\", tvdbid=tvdbid)\n\n    def tmdb_info(self, tmdbid: int, mtype: MediaType, season: Optional[int] = None) -> Optional[dict]:\n        \"\"\"\n        获取TMDB信息\n        :param tmdbid: int\n        :param mtype:  媒体类型\n        :param season: 季\n        :return: TVDB信息\n        \"\"\"\n        return self.run_module(\"tmdb_info\", tmdbid=tmdbid, mtype=mtype, season=season)\n\n    async def async_tmdb_info(self, tmdbid: int, mtype: MediaType, season: Optional[int] = None) -> Optional[dict]:\n        \"\"\"\n        获取TMDB信息（异步版本）\n        :param tmdbid: int\n        :param mtype:  媒体类型\n        :param season: 季\n        :return: TVDB信息\n        \"\"\"\n        return await self.async_run_module(\"async_tmdb_info\", tmdbid=tmdbid, mtype=mtype, season=season)\n\n    def bangumi_info(self, bangumiid: int) -> Optional[dict]:\n        \"\"\"\n        获取Bangumi信息\n        :param bangumiid: int\n        :return: Bangumi信息\n        \"\"\"\n        return self.run_module(\"bangumi_info\", bangumiid=bangumiid)\n\n    async def async_bangumi_info(self, bangumiid: int) -> Optional[dict]:\n        \"\"\"\n        获取Bangumi信息（异步版本）\n        :param bangumiid: int\n        :return: Bangumi信息\n        \"\"\"\n        return await self.async_run_module(\"async_bangumi_info\", bangumiid=bangumiid)\n\n    def message_parser(self, source: str, body: Any, form: Any,\n                       args: Any) -> Optional[CommingMessage]:\n        \"\"\"\n        解析消息内容，返回字典，注意以下约定值：\n        userid: 用户ID\n        username: 用户名\n        text: 内容\n        :param source: 消息来源（渠道配置名称）\n        :param body: 请求体\n        :param form: 表单\n        :param args: 参数\n        :return: 消息渠道、消息内容\n        \"\"\"\n        return self.run_module(\"message_parser\", source=source, body=body, form=form, args=args)\n\n    def webhook_parser(self, body: Any, form: Any, args: Any) -> Optional[WebhookEventInfo]:\n        \"\"\"\n        解析Webhook报文体\n        :param body:  请求体\n        :param form:  请求表单\n        :param args:  请求参数\n        :return: 字典，解析为消息时需要包含：title、text、image\n        \"\"\"\n        return self.run_module(\"webhook_parser\", body=body, form=form, args=args)\n\n    def search_medias(self, meta: MetaBase) -> Optional[List[MediaInfo]]:\n        \"\"\"\n        搜索媒体信息\n        :param meta:  识别的元数据\n        :reutrn: 媒体信息列表\n        \"\"\"\n        return self.run_module(\"search_medias\", meta=meta)\n\n    async def async_search_medias(self, meta: MetaBase) -> Optional[List[MediaInfo]]:\n        \"\"\"\n        搜索媒体信息（异步版本）\n        :param meta:  识别的元数据\n        :reutrn: 媒体信息列表\n        \"\"\"\n        return await self.async_run_module(\"async_search_medias\", meta=meta)\n\n    def search_persons(self, name: str) -> Optional[List[MediaPerson]]:\n        \"\"\"\n        搜索人物信息\n        :param name:  人物名称\n        \"\"\"\n        return self.run_module(\"search_persons\", name=name)\n\n    async def async_search_persons(self, name: str) -> Optional[List[MediaPerson]]:\n        \"\"\"\n        搜索人物信息（异步版本）\n        :param name:  人物名称\n        \"\"\"\n        return await self.async_run_module(\"async_search_persons\", name=name)\n\n    def search_collections(self, name: str) -> Optional[List[MediaInfo]]:\n        \"\"\"\n        搜索集合信息\n        :param name:  集合名称\n        \"\"\"\n        return self.run_module(\"search_collections\", name=name)\n\n    async def async_search_collections(self, name: str) -> Optional[List[MediaInfo]]:\n        \"\"\"\n        搜索集合信息（异步版本）\n        :param name:  集合名称\n        \"\"\"\n        return await self.async_run_module(\"async_search_collections\", name=name)\n\n    def search_torrents(self, site: dict,\n                        keyword: str,\n                        mtype: Optional[MediaType] = None,\n                        page: Optional[int] = 0) -> List[TorrentInfo]:\n        \"\"\"\n        搜索一个站点的种子资源\n        :param site:  站点\n        :param keyword:  搜索关键词\n        :param mtype:  媒体类型\n        :param page:  页码\n        :reutrn: 资源列表\n        \"\"\"\n        return self.run_module(\"search_torrents\", site=site, keyword=keyword,\n                               mtype=mtype, page=page)\n\n    async def async_search_torrents(self, site: dict,\n                                    keyword: str,\n                                    mtype: Optional[MediaType] = None,\n                                    page: Optional[int] = 0) -> List[TorrentInfo]:\n        \"\"\"\n        异步搜索一个站点的种子资源\n        :param site:  站点\n        :param keyword:  搜索关键词\n        :param mtype:  媒体类型\n        :param page:  页码\n        :reutrn: 资源列表\n        \"\"\"\n        return await self.async_run_module(\"async_search_torrents\", site=site, keyword=keyword,\n                                           mtype=mtype, page=page)\n\n    def refresh_torrents(self, site: dict, keyword: Optional[str] = None,\n                         cat: Optional[str] = None, page: Optional[int] = 0) -> List[TorrentInfo]:\n        \"\"\"\n        获取站点最新一页的种子，多个站点需要多线程处理\n        :param site:  站点\n        :param keyword:  标题\n        :param cat:  分类\n        :param page:  页码\n        :reutrn: 种子资源列表\n        \"\"\"\n        return self.run_module(\"refresh_torrents\", site=site, keyword=keyword, cat=cat, page=page)\n\n    async def async_refresh_torrents(self, site: dict, keyword: Optional[str] = None,\n                                     cat: Optional[str] = None, page: Optional[int] = 0) -> List[TorrentInfo]:\n        \"\"\"\n        异步获取站点最新一页的种子，多个站点需要多线程处理\n        :param site:  站点\n        :param keyword:  标题\n        :param cat:  分类\n        :param page:  页码\n        :reutrn: 种子资源列表\n        \"\"\"\n        return await self.async_run_module(\"async_refresh_torrents\",\n                                           site=site, keyword=keyword, cat=cat, page=page)\n\n    def filter_torrents(self, rule_groups: List[str],\n                        torrent_list: List[TorrentInfo],\n                        mediainfo: MediaInfo = None) -> List[TorrentInfo]:\n        \"\"\"\n        过滤种子资源\n        :param rule_groups:  过滤规则组名称列表\n        :param torrent_list:  资源列表\n        :param mediainfo:  识别的媒体信息\n        :return: 过滤后的资源列表，添加资源优先级\n        \"\"\"\n        return self.run_module(\"filter_torrents\", rule_groups=rule_groups,\n                               torrent_list=torrent_list, mediainfo=mediainfo)\n\n    def download(self, content: Union[Path, str, bytes], download_dir: Path, cookie: str,\n                 episodes: Set[int] = None, category: Optional[str] = None, label: Optional[str] = None,\n                 downloader: Optional[str] = None\n                 ) -> Optional[Tuple[Optional[str], Optional[str], Optional[str], str]]:\n        \"\"\"\n        根据种子文件，选择并添加下载任务\n        :param content:  种子文件地址或者磁力链接或者种子内容\n        :param download_dir:  下载目录\n        :param cookie:  cookie\n        :param episodes:  需要下载的集数\n        :param category:  种子分类\n        :param label:  标签\n        :param downloader:  下载器\n        :return: 下载器名称、种子Hash、种子文件布局、错误原因\n        \"\"\"\n        return self.run_module(\"download\", content=content, download_dir=download_dir,\n                               cookie=cookie, episodes=episodes, category=category, label=label,\n                               downloader=downloader)\n\n    def download_added(self, context: Context, download_dir: Path, torrent_content: Union[str, bytes] = None) -> None:\n        \"\"\"\n        添加下载任务成功后，从站点下载字幕，保存到下载目录\n        :param context:  上下文，包括识别信息、媒体信息、种子信息\n        :param download_dir:  下载目录\n        :param torrent_content:  种子内容，如果有则直接使用该内容，否则从context中获取种子文件路径\n        :return: None，该方法可被多个模块同时处理\n        \"\"\"\n        return self.run_module(\"download_added\", context=context,\n                               torrent_content=torrent_content,\n                               download_dir=download_dir)\n\n    def list_torrents(self, status: TorrentStatus = None,\n                      hashs: Union[list, str] = None,\n                      downloader: Optional[str] = None\n                      ) -> Optional[List[Union[TransferTorrent, DownloadingTorrent]]]:\n        \"\"\"\n        获取下载器种子列表\n        :param status:  种子状态\n        :param hashs:  种子Hash\n        :param downloader:  下载器\n        :return: 下载器中符合状态的种子列表\n        \"\"\"\n        return self.run_module(\"list_torrents\", status=status, hashs=hashs, downloader=downloader)\n\n    def transfer(self, fileitem: FileItem, meta: MetaBase, mediainfo: MediaInfo,\n                 target_directory: TransferDirectoryConf = None,\n                 target_storage: Optional[str] = None, target_path: Path = None,\n                 transfer_type: Optional[str] = None, scrape: bool = None,\n                 library_type_folder: bool = None, library_category_folder: bool = None,\n                 episodes_info: List[TmdbEpisode] = None,\n                 source_oper: Callable = None, target_oper: Callable = None) -> Optional[TransferInfo]:\n        \"\"\"\n        文件转移\n        :param fileitem:  文件信息\n        :param meta: 预识别的元数据\n        :param mediainfo:  识别的媒体信息\n        :param target_directory:  目标目录配置\n        :param target_storage:  目标存储\n        :param target_path:  目标路径\n        :param transfer_type:  转移模式\n        :param scrape: 是否刮削元数据\n        :param library_type_folder: 是否按类型创建目录\n        :param library_category_folder: 是否按类别创建目录\n        :param episodes_info: 当前季的全部集信息\n        :param source_oper:  源存储操作类\n        :param target_oper:  目标存储操作类\n        :return: {path, target_path, message}\n        \"\"\"\n        return self.run_module(\"transfer\",\n                               fileitem=fileitem, meta=meta, mediainfo=mediainfo,\n                               target_directory=target_directory,\n                               target_path=target_path, target_storage=target_storage,\n                               transfer_type=transfer_type, scrape=scrape,\n                               library_type_folder=library_type_folder,\n                               library_category_folder=library_category_folder,\n                               episodes_info=episodes_info,\n                               source_oper=source_oper, target_oper=target_oper)\n\n    def transfer_completed(self, hashs: str, downloader: Optional[str] = None) -> None:\n        \"\"\"\n        下载器转移完成后的处理\n        :param hashs:  种子Hash\n        :param downloader:  下载器\n        \"\"\"\n        return self.run_module(\"transfer_completed\", hashs=hashs, downloader=downloader)\n\n    def remove_torrents(self, hashs: Union[str, list], delete_file: bool = True,\n                        downloader: Optional[str] = None) -> bool:\n        \"\"\"\n        删除下载器种子\n        :param hashs:  种子Hash\n        :param delete_file: 是否删除文件\n        :param downloader:  下载器\n        :return: bool\n        \"\"\"\n        return self.run_module(\"remove_torrents\", hashs=hashs, delete_file=delete_file, downloader=downloader)\n\n    def start_torrents(self, hashs: Union[list, str], downloader: Optional[str] = None) -> bool:\n        \"\"\"\n        开始下载\n        :param hashs:  种子Hash\n        :param downloader:  下载器\n        :return: bool\n        \"\"\"\n        return self.run_module(\"start_torrents\", hashs=hashs, downloader=downloader)\n\n    def stop_torrents(self, hashs: Union[list, str], downloader: Optional[str] = None) -> bool:\n        \"\"\"\n        停止下载\n        :param hashs:  种子Hash\n        :param downloader:  下载器\n        :return: bool\n        \"\"\"\n        return self.run_module(\"stop_torrents\", hashs=hashs, downloader=downloader)\n\n    def torrent_files(self, tid: str,\n                      downloader: Optional[str] = None) -> Optional[Union[TorrentFilesList, List[File]]]:\n        \"\"\"\n        获取种子文件\n        :param tid:  种子Hash\n        :param downloader:  下载器\n        :return: 种子文件\n        \"\"\"\n        return self.run_module(\"torrent_files\", tid=tid, downloader=downloader)\n\n    def media_exists(self, mediainfo: MediaInfo, itemid: Optional[str] = None,\n                     server: Optional[str] = None) -> Optional[ExistMediaInfo]:\n        \"\"\"\n        判断媒体文件是否存在\n        :param mediainfo:  识别的媒体信息\n        :param itemid:  媒体服务器ItemID\n        :param server:  媒体服务器\n        :return: 如不存在返回None，存在时返回信息，包括每季已存在所有集{type: movie/tv, seasons: {season: [episodes]}}\n        \"\"\"\n        return self.run_module(\"media_exists\", mediainfo=mediainfo, itemid=itemid, server=server)\n\n    def media_files(self, mediainfo: MediaInfo) -> Optional[List[FileItem]]:\n        \"\"\"\n        获取媒体文件清单\n        :param mediainfo:  识别的媒体信息\n        :return: 媒体文件列表\n        \"\"\"\n        return self.run_module(\"media_files\", mediainfo=mediainfo)\n\n    def post_message(self,\n                     message: Optional[Notification] = None,\n                     meta: Optional[MetaBase] = None,\n                     mediainfo: Optional[MediaInfo] = None,\n                     torrentinfo: Optional[TorrentInfo] = None,\n                     transferinfo: Optional[TransferInfo] = None,\n                     **kwargs) -> None:\n        \"\"\"\n        发送消息\n        :param message:  Notification实例\n        :param meta:  元数据\n        :param mediainfo:  媒体信息\n        :param torrentinfo:  种子信息\n        :param transferinfo:  文件整理信息\n        :param kwargs:  其他参数(覆盖业务对象属性值)\n        :return: 成功或失败\n        \"\"\"\n        # 添加格式化的时间参数\n        kwargs.setdefault('current_time', datetime.now().strftime('%Y-%m-%d %H:%M:%S'))\n        # 渲染消息\n        message = MessageTemplateHelper.render(message=message, meta=meta, mediainfo=mediainfo,\n                                               torrentinfo=torrentinfo, transferinfo=transferinfo, **kwargs)\n        # 检查消息是否有效\n        if not message:\n            logger.warning(\"消息为空，跳过发送\")\n            return\n        # 保存消息\n        self.messagehelper.put(message, role=\"user\", title=message.title)\n        self.messageoper.add(**message.model_dump())\n        # 发送消息按设置隔离\n        if not message.userid and message.mtype:\n            # 消息隔离设置\n            notify_action = ServiceConfigHelper.get_notification_switch(message.mtype)\n            if notify_action:\n                # 'admin' 'user,admin' 'user' 'all'\n                actions = notify_action.split(\",\")\n                # 是否已发送管理员标志\n                admin_sended = False\n                send_orignal = False\n                useroper = UserOper()\n                for action in actions:\n                    send_message = copy.deepcopy(message)\n                    if action == \"admin\" and not admin_sended:\n                        # 仅发送管理员\n                        logger.info(f\"{send_message.mtype} 的消息已设置发送给管理员\")\n                        # 读取管理员消息IDS\n                        send_message.targets = useroper.get_settings(settings.SUPERUSER)\n                        admin_sended = True\n                    elif action == \"user\" and send_message.username:\n                        # 发送对应用户\n                        logger.info(f\"{send_message.mtype} 的消息已设置发送给用户 {send_message.username}\")\n                        # 读取用户消息IDS\n                        send_message.targets = useroper.get_settings(send_message.username)\n                        if send_message.targets is None:\n                            # 没有找到用户\n                            if not admin_sended:\n                                # 回滚发送管理员\n                                logger.info(f\"用户 {send_message.username} 不存在，消息将发送给管理员\")\n                                # 读取管理员消息IDS\n                                send_message.targets = useroper.get_settings(settings.SUPERUSER)\n                                admin_sended = True\n                            else:\n                                # 管理员发过了，此消息不发了\n                                logger.info(f\"用户 {send_message.username} 不存在，消息无法发送到对应用户\")\n                                continue\n                        elif send_message.username == settings.SUPERUSER:\n                            # 管理员同名已发送\n                            admin_sended = True\n                    else:\n                        # 按原消息发送全体\n                        if not admin_sended:\n                            send_orignal = True\n                        break\n                    # 按设定发送\n                    self.eventmanager.send_event(etype=EventType.NoticeMessage,\n                                                 data={**send_message.model_dump(), \"type\": send_message.mtype})\n                    self.messagequeue.send_message(\"post_message\", message=send_message, **kwargs)\n                if not send_orignal:\n                    return\n        # 发送消息事件\n        self.eventmanager.send_event(etype=EventType.NoticeMessage, data={**message.model_dump(), \"type\": message.mtype})\n        # 按原消息发送\n        self.messagequeue.send_message(\"post_message\", message=message,\n                                       immediately=True if message.userid else False, **kwargs)\n\n    async def async_post_message(self,\n                                 message: Optional[Notification] = None,\n                                 meta: Optional[MetaBase] = None,\n                                 mediainfo: Optional[MediaInfo] = None,\n                                 torrentinfo: Optional[TorrentInfo] = None,\n                                 transferinfo: Optional[TransferInfo] = None,\n                                 **kwargs) -> None:\n        \"\"\"\n        异步发送消息\n        :param message:  Notification实例\n        :param meta:  元数据\n        :param mediainfo:  媒体信息\n        :param torrentinfo:  种子信息\n        :param transferinfo:  文件整理信息\n        :param kwargs:  其他参数(覆盖业务对象属性值)\n        :return: 成功或失败\n        \"\"\"\n        # 添加格式化的时间参数\n        kwargs.setdefault('current_time', datetime.now().strftime('%Y-%m-%d %H:%M:%S'))\n        # 渲染消息\n        message = MessageTemplateHelper.render(message=message, meta=meta, mediainfo=mediainfo,\n                                               torrentinfo=torrentinfo, transferinfo=transferinfo, **kwargs)\n        # 检查消息是否有效\n        if not message:\n            logger.warning(\"消息为空，跳过发送\")\n            return\n        # 保存消息\n        self.messagehelper.put(message, role=\"user\", title=message.title)\n        await self.messageoper.async_add(**message.model_dump())\n        # 发送消息按设置隔离\n        if not message.userid and message.mtype:\n            # 消息隔离设置\n            notify_action = ServiceConfigHelper.get_notification_switch(message.mtype)\n            if notify_action:\n                # 'admin' 'user,admin' 'user' 'all'\n                actions = notify_action.split(\",\")\n                # 是否已发送管理员标志\n                admin_sended = False\n                send_orignal = False\n                useroper = UserOper()\n                for action in actions:\n                    send_message = copy.deepcopy(message)\n                    if action == \"admin\" and not admin_sended:\n                        # 仅发送管理员\n                        logger.info(f\"{send_message.mtype} 的消息已设置发送给管理员\")\n                        # 读取管理员消息IDS\n                        send_message.targets = useroper.get_settings(settings.SUPERUSER)\n                        admin_sended = True\n                    elif action == \"user\" and send_message.username:\n                        # 发送对应用户\n                        logger.info(f\"{send_message.mtype} 的消息已设置发送给用户 {send_message.username}\")\n                        # 读取用户消息IDS\n                        send_message.targets = useroper.get_settings(send_message.username)\n                        if send_message.targets is None:\n                            # 没有找到用户\n                            if not admin_sended:\n                                # 回滚发送管理员\n                                logger.info(f\"用户 {send_message.username} 不存在，消息将发送给管理员\")\n                                # 读取管理员消息IDS\n                                send_message.targets = useroper.get_settings(settings.SUPERUSER)\n                                admin_sended = True\n                            else:\n                                # 管理员发过了，此消息不发了\n                                logger.info(f\"用户 {send_message.username} 不存在，消息无法发送到对应用户\")\n                                continue\n                        elif send_message.username == settings.SUPERUSER:\n                            # 管理员同名已发送\n                            admin_sended = True\n                    else:\n                        # 按原消息发送全体\n                        if not admin_sended:\n                            send_orignal = True\n                        break\n                    # 按设定发送\n                    await self.eventmanager.async_send_event(etype=EventType.NoticeMessage,\n                                                             data={**send_message.model_dump(), \"type\": send_message.mtype})\n                    await self.messagequeue.async_send_message(\"post_message\", message=send_message, **kwargs)\n                if not send_orignal:\n                    return\n        # 发送消息事件\n        await self.eventmanager.async_send_event(etype=EventType.NoticeMessage,\n                                                 data={**message.model_dump(), \"type\": message.mtype})\n        # 按原消息发送\n        await self.messagequeue.async_send_message(\"post_message\", message=message,\n                                                   immediately=True if message.userid else False, **kwargs)\n\n    def post_medias_message(self, message: Notification, medias: List[MediaInfo]) -> None:\n        \"\"\"\n        发送媒体信息选择列表\n        :param message:  消息体\n        :param medias:  媒体列表\n        :return: 成功或失败\n        \"\"\"\n        note_list = [media.to_dict() for media in medias]\n        self.messagehelper.put(message, role=\"user\", note=note_list, title=message.title)\n        self.messageoper.add(**message.model_dump(), note=note_list)\n        return self.messagequeue.send_message(\"post_medias_message\", message=message, medias=medias,\n                                              immediately=True if message.userid else False)\n\n    def post_torrents_message(self, message: Notification, torrents: List[Context]) -> None:\n        \"\"\"\n        发送种子信息选择列表\n        :param message:  消息体\n        :param torrents:  种子列表\n        :return: 成功或失败\n        \"\"\"\n        note_list = [torrent.torrent_info.to_dict() for torrent in torrents]\n        self.messagehelper.put(message, role=\"user\", note=note_list, title=message.title)\n        self.messageoper.add(**message.model_dump(), note=note_list)\n        return self.messagequeue.send_message(\"post_torrents_message\", message=message, torrents=torrents,\n                                              immediately=True if message.userid else False)\n\n    def delete_message(self, channel: MessageChannel, source: str,\n                       message_id: Union[str, int], chat_id: Optional[Union[str, int]] = None) -> bool:\n        \"\"\"\n        删除消息\n        :param channel: 消息渠道\n        :param source: 消息源（指定特定的消息模块）\n        :param message_id: 消息ID\n        :param chat_id: 聊天ID（如群组ID）\n        :return: 删除是否成功\n        \"\"\"\n        return self.run_module(\"delete_message\", channel=channel, source=source,\n                               message_id=message_id, chat_id=chat_id)\n\n    def metadata_img(self, mediainfo: MediaInfo,\n                     season: Optional[int] = None, episode: Optional[int] = None) -> Optional[dict]:\n        \"\"\"\n        获取图片名称和url\n        :param mediainfo: 媒体信息\n        :param season: 季号\n        :param episode: 集号\n        \"\"\"\n        return self.run_module(\"metadata_img\", mediainfo=mediainfo, season=season, episode=episode)\n\n    def media_category(self) -> Optional[Dict[str, list]]:\n        \"\"\"\n        获取媒体分类\n        :return: 获取二级分类配置字典项，需包括电影、电视剧\n        \"\"\"\n        return self.run_module(\"media_category\")\n\n    def category_config(self) -> CategoryConfig:\n        \"\"\"\n        获取分类策略配置\n        \"\"\"\n        return self.run_module(\"load_category_config\")\n\n    def save_category_config(self, config: CategoryConfig) -> bool:\n        \"\"\"\n        保存分类策略配置\n        \"\"\"\n        return self.run_module(\"save_category_config\", config=config)\n\n    def register_commands(self, commands: Dict[str, dict]) -> None:\n        \"\"\"\n        注册菜单命令\n        \"\"\"\n        self.run_module(\"register_commands\", commands=commands)\n\n    def scheduler_job(self) -> None:\n        \"\"\"\n        定时任务，每10分钟调用一次，模块实现该接口以实现定时服务\n        \"\"\"\n        self.run_module(\"scheduler_job\")\n\n    def clear_cache(self) -> None:\n        \"\"\"\n        清理缓存，模块实现该接口响应清理缓存事件\n        \"\"\"\n        self.run_module(\"clear_cache\")\n"
  },
  {
    "path": "app/chain/ai_recommend.py",
    "content": "import re\nfrom typing import List, Optional, Dict, Any\nimport asyncio\nimport hashlib\nimport json\n\nfrom app.chain import ChainBase\nfrom app.core.config import settings\nfrom app.log import logger\nfrom app.utils.common import log_execution_time\nfrom app.utils.singleton import Singleton\nfrom app.utils.string import StringUtils\n\n\nclass AIRecommendChain(ChainBase, metaclass=Singleton):\n    \"\"\"\n    AI推荐处理链，单例运行\n    用于基于搜索结果的AI智能推荐\n    \"\"\"\n\n    # 缓存文件名\n    __ai_indices_cache_file = \"__ai_recommend_indices__\"\n\n    # AI推荐状态\n    _ai_recommend_running = False\n    _ai_recommend_task: Optional[asyncio.Task] = None\n    _current_request_hash: Optional[str] = None  # 当前请求的哈希值\n    _ai_recommend_result: Optional[List[int]] = None  # AI推荐索引缓存（索引列表）\n    _ai_recommend_error: Optional[str] = None  # AI推荐错误信息\n\n    @staticmethod\n    def _calculate_request_hash(\n        filtered_indices: Optional[List[int]], search_results_count: int\n    ) -> str:\n        \"\"\"\n        计算请求的哈希值，用于判断请求是否变化\n        \"\"\"\n        request_data = {\n            \"filtered_indices\": filtered_indices or [],\n            \"search_results_count\": search_results_count,\n        }\n        return hashlib.md5(\n            json.dumps(request_data, sort_keys=True).encode()\n        ).hexdigest()\n\n    @property\n    def is_enabled(self) -> bool:\n        \"\"\"\n        检查AI推荐功能是否已启用。\n        \"\"\"\n        return settings.AI_AGENT_ENABLE and settings.AI_RECOMMEND_ENABLED\n\n    def _build_status(self) -> Dict[str, Any]:\n        \"\"\"\n        构建AI推荐状态字典\n        :return: 状态字典\n        \"\"\"\n        if not self.is_enabled:\n            return {\"status\": \"disabled\"}\n\n        if self._ai_recommend_running:\n            return {\"status\": \"running\"}\n\n        # 尝试从数据库加载缓存\n        if self._ai_recommend_result is None:\n            cached_indices = self.load_cache(self.__ai_indices_cache_file)\n            if cached_indices is not None:\n                self._ai_recommend_result = cached_indices\n\n        # 只要有结果，始终返回completed状态和数据\n        if self._ai_recommend_result is not None:\n            return {\"status\": \"completed\", \"results\": self._ai_recommend_result}\n\n        if self._ai_recommend_error is not None:\n            return {\"status\": \"error\", \"error\": self._ai_recommend_error}\n\n        return {\"status\": \"idle\"}\n\n    def get_current_status_only(self) -> Dict[str, Any]:\n        \"\"\"\n        获取当前状态（不校验hash，用于check_only模式）\n        \"\"\"\n        return self._build_status()\n\n    def get_status(\n        self, filtered_indices: Optional[List[int]], search_results_count: int\n    ) -> Dict[str, Any]:\n        \"\"\"\n        获取AI推荐状态并检查请求是否变化（用于首次请求或force模式）\n        如果请求变化（筛选条件变化），返回idle状态\n        \"\"\"\n        # 计算当前请求的hash\n        request_hash = self._calculate_request_hash(\n            filtered_indices, search_results_count\n        )\n\n        # 检查请求是否变化\n        is_same_request = request_hash == self._current_request_hash\n\n        # 如果请求变化了（筛选条件改变），返回idle状态\n        if not is_same_request:\n            return {\"status\": \"idle\"} if self.is_enabled else {\"status\": \"disabled\"}\n\n        # 请求未变化，返回当前实际状态\n        return self._build_status()\n\n    @log_execution_time(logger=logger)\n    async def async_ai_recommend(self, items: List[str], preference: str = None) -> str:\n        \"\"\"\n        AI推荐\n        :param items: 候选资源列表(JSON字符串格式)\n        :param preference: 用户偏好(可选)\n        :return: AI返回的推荐结果\n        \"\"\"\n        # 设置运行状态\n        self._ai_recommend_running = True\n        try:\n            # 导入LLMHelper\n            from app.helper.llm import LLMHelper\n\n            # 获取LLM实例\n            llm = LLMHelper.get_llm()\n\n            # 构建提示词\n            user_preference = (\n                preference\n                or settings.AI_RECOMMEND_USER_PREFERENCE\n                or \"Prefer high-quality resources with more seeders\"\n            )\n\n            # 添加指令\n            instruction = \"\"\"\nTask: Select the best matching items from the list based on user preferences.\n\nEach item contains:\n- index: Item number\n- title: Full torrent title\n- size: File size\n- seeders: Number of seeders\n\nOutput Format: Return ONLY a JSON array of \"index\" numbers (e.g., [0, 3, 1]). Do NOT include any explanations or other text.\n\"\"\"\n            message = (\n                f\"User Preference: {user_preference}\\n{instruction}\\nCandidate Resources:\\n\"\n                + \"\\n\".join(items)\n            )\n\n            # 调用LLM\n            response = await llm.ainvoke(message)\n            return response.content\n\n        except ValueError as e:\n            logger.error(f\"AI推荐配置错误: {e}\")\n            raise\n        except Exception as e:\n            raise\n        finally:\n            # 清除运行状态\n            self._ai_recommend_running = False\n            self._ai_recommend_task = None\n\n    def is_ai_recommend_running(self) -> bool:\n        \"\"\"\n        检查AI推荐是否正在运行\n        \"\"\"\n        return self._ai_recommend_running\n\n    def cancel_ai_recommend(self):\n        \"\"\"\n        取消正在运行的AI推荐任务\n        \"\"\"\n        if self._ai_recommend_task and not self._ai_recommend_task.done():\n            self._ai_recommend_task.cancel()\n        self._ai_recommend_running = False\n        self._ai_recommend_task = None\n        self._current_request_hash = None\n        self._ai_recommend_result = None\n        self._ai_recommend_error = None\n        self.remove_cache(self.__ai_indices_cache_file)\n\n    def start_recommend_task(\n        self,\n        filtered_indices: Optional[List[int]],\n        search_results_count: int,\n        results: List[Any],\n    ) -> None:\n        \"\"\"\n        启动AI推荐任务\n        :param filtered_indices: 筛选后的索引列表\n        :param search_results_count: 搜索结果总数\n        :param results: 搜索结果列表\n        \"\"\"\n        # 防护检查：确保AI推荐功能已启用\n        if not self.is_enabled:\n            logger.warning(\"AI推荐功能未启用，跳过任务执行\")\n            return\n\n        # 计算新请求的哈希值\n        new_request_hash = self._calculate_request_hash(\n            filtered_indices, search_results_count\n        )\n\n        # 如果请求变化了，取消旧任务\n        if new_request_hash != self._current_request_hash:\n            self.cancel_ai_recommend()\n\n            # 更新请求哈希值\n            self._current_request_hash = new_request_hash\n\n            # 重置状态\n            self._ai_recommend_result = None\n            self._ai_recommend_error = None\n\n            # 启动新任务\n            async def run_recommend():\n                # 获取当前任务对象，用于在finally中比对\n                current_task = asyncio.current_task()\n                try:\n                    self._ai_recommend_running = True\n\n                    # 准备数据\n                    items = []\n                    valid_indices = []\n                    max_items = settings.AI_RECOMMEND_MAX_ITEMS or 50\n\n                    # 如果提供了筛选索引，先筛选结果；否则使用所有结果\n                    if filtered_indices is not None and len(filtered_indices) > 0:\n                        results_to_process = [\n                            results[i]\n                            for i in filtered_indices\n                            if 0 <= i < len(results)\n                        ]\n                    else:\n                        results_to_process = results\n\n                    for i, torrent in enumerate(results_to_process):\n                        if len(items) >= max_items:\n                            break\n\n                        if not torrent.torrent_info:\n                            continue\n\n                        valid_indices.append(i)\n\n                        item_info = {\n                            \"index\": i,\n                            \"title\": torrent.torrent_info.title or \"未知\",\n                            \"size\": (\n                                StringUtils.format_size(torrent.torrent_info.size)\n                                if torrent.torrent_info.size\n                                else \"0 B\"\n                            ),\n                            \"seeders\": torrent.torrent_info.seeders or 0,\n                        }\n\n                        items.append(json.dumps(item_info, ensure_ascii=False))\n\n                    if not items:\n                        self._ai_recommend_error = \"没有可用于AI推荐的资源\"\n                        return\n\n                    # 调用AI推荐\n                    ai_response = await self.async_ai_recommend(items)\n\n                    # 解析AI返回的索引\n                    try:\n                        # 使用正则提取JSON数组（非贪婪模式，避免匹配多个数组）\n                        json_match = re.search(r'\\[.*?\\]', ai_response, re.DOTALL)\n                        if not json_match:\n                            raise ValueError(ai_response)\n                            \n                        ai_indices = json.loads(json_match.group())\n                        if not isinstance(ai_indices, list):\n                            raise ValueError(f\"AI返回格式错误: {ai_response}\")\n\n                        # 映射回原始索引\n                        if filtered_indices:\n                            original_indices = [\n                                filtered_indices[valid_indices[i]]\n                                for i in ai_indices\n                                if i < len(valid_indices)\n                                and 0 <= filtered_indices[valid_indices[i]] < len(results)\n                            ]\n                        else:\n                            original_indices = [\n                                valid_indices[i]\n                                for i in ai_indices\n                                if i < len(valid_indices)\n                                and 0 <= valid_indices[i] < len(results)\n                            ]\n\n                        # 只返回索引列表，不返回完整数据\n                        self._ai_recommend_result = original_indices\n\n                        # 保存到数据库\n                        self.save_cache(original_indices, self.__ai_indices_cache_file)\n                        logger.info(f\"AI推荐完成: {len(original_indices)}项\")\n\n                    except Exception as e:\n                        logger.error(\n                            f\"解析AI返回结果失败: {e}, 原始响应: {ai_response}\"\n                        )\n                        self._ai_recommend_error = str(e)\n\n                except asyncio.CancelledError:\n                    logger.info(\"AI推荐任务被取消\")\n                except Exception as e:\n                    logger.error(f\"AI推荐任务失败: {e}\")\n                    self._ai_recommend_error = str(e)\n                finally:\n                    # 只有当 self._ai_recommend_task 仍然是当前任务时，才清理状态\n                    # 如果任务被取消并启动了新任务，self._ai_recommend_task 已经指向新任务，不应重置\n                    if self._ai_recommend_task == current_task:\n                        self._ai_recommend_running = False\n                        self._ai_recommend_task = None\n\n            # 创建并启动任务\n            self._ai_recommend_task = asyncio.create_task(run_recommend())\n"
  },
  {
    "path": "app/chain/bangumi.py",
    "content": "from typing import Optional, List\n\nfrom app import schemas\nfrom app.chain import ChainBase\nfrom app.core.context import MediaInfo\n\n\nclass BangumiChain(ChainBase):\n    \"\"\"\n    Bangumi处理链\n    \"\"\"\n\n    def calendar(self) -> Optional[List[MediaInfo]]:\n        \"\"\"\n        获取Bangumi每日放送\n        \"\"\"\n        return self.run_module(\"bangumi_calendar\")\n\n    def discover(self, **kwargs) -> Optional[List[MediaInfo]]:\n        \"\"\"\n        发现Bangumi番剧\n        \"\"\"\n        return self.run_module(\"bangumi_discover\", **kwargs)\n\n    def bangumi_info(self, bangumiid: int) -> Optional[dict]:\n        \"\"\"\n        获取Bangumi信息\n        :param bangumiid: BangumiID\n        :return: Bangumi信息\n        \"\"\"\n        return self.run_module(\"bangumi_info\", bangumiid=bangumiid)\n\n    def bangumi_credits(self, bangumiid: int) -> List[schemas.MediaPerson]:\n        \"\"\"\n        根据BangumiID查询电影演职员表\n        :param bangumiid:  BangumiID\n        \"\"\"\n        return self.run_module(\"bangumi_credits\", bangumiid=bangumiid)\n\n    def bangumi_recommend(self, bangumiid: int) -> Optional[List[MediaInfo]]:\n        \"\"\"\n        根据BangumiID查询推荐电影\n        :param bangumiid:  BangumiID\n        \"\"\"\n        return self.run_module(\"bangumi_recommend\", bangumiid=bangumiid)\n\n    def person_detail(self, person_id: int) -> Optional[schemas.MediaPerson]:\n        \"\"\"\n        根据人物ID查询Bangumi人物详情\n        :param person_id:  人物ID\n        \"\"\"\n        return self.run_module(\"bangumi_person_detail\", person_id=person_id)\n\n    def person_credits(self, person_id: int) -> Optional[List[MediaInfo]]:\n        \"\"\"\n        根据人物ID查询人物参演作品\n        :param person_id:  人物ID\n        \"\"\"\n        return self.run_module(\"bangumi_person_credits\", person_id=person_id)\n\n    async def async_calendar(self) -> Optional[List[MediaInfo]]:\n        \"\"\"\n        获取Bangumi每日放送（异步版本）\n        \"\"\"\n        return await self.async_run_module(\"async_bangumi_calendar\")\n\n    async def async_discover(self, **kwargs) -> Optional[List[MediaInfo]]:\n        \"\"\"\n        发现Bangumi番剧（异步版本）\n        \"\"\"\n        return await self.async_run_module(\"async_bangumi_discover\", **kwargs)\n\n    async def async_bangumi_info(self, bangumiid: int) -> Optional[dict]:\n        \"\"\"\n        获取Bangumi信息（异步版本）\n        :param bangumiid: BangumiID\n        :return: Bangumi信息\n        \"\"\"\n        return await self.async_run_module(\"async_bangumi_info\", bangumiid=bangumiid)\n\n    async def async_bangumi_credits(self, bangumiid: int) -> List[schemas.MediaPerson]:\n        \"\"\"\n        根据BangumiID查询电影演职员表（异步版本）\n        :param bangumiid:  BangumiID\n        \"\"\"\n        return await self.async_run_module(\"async_bangumi_credits\", bangumiid=bangumiid)\n\n    async def async_bangumi_recommend(self, bangumiid: int) -> Optional[List[MediaInfo]]:\n        \"\"\"\n        根据BangumiID查询推荐电影（异步版本）\n        :param bangumiid:  BangumiID\n        \"\"\"\n        return await self.async_run_module(\"async_bangumi_recommend\", bangumiid=bangumiid)\n\n    async def async_person_detail(self, person_id: int) -> Optional[schemas.MediaPerson]:\n        \"\"\"\n        根据人物ID查询Bangumi人物详情（异步版本）\n        :param person_id:  人物ID\n        \"\"\"\n        return await self.async_run_module(\"async_bangumi_person_detail\", person_id=person_id)\n\n    async def async_person_credits(self, person_id: int) -> Optional[List[MediaInfo]]:\n        \"\"\"\n        根据人物ID查询人物参演作品（异步版本）\n        :param person_id:  人物ID\n        \"\"\"\n        return await self.async_run_module(\"async_bangumi_person_credits\", person_id=person_id)\n"
  },
  {
    "path": "app/chain/dashboard.py",
    "content": "from typing import Optional, List\n\nfrom app import schemas\nfrom app.chain import ChainBase\n\n\nclass DashboardChain(ChainBase):\n    \"\"\"\n    各类仪表板统计处理链\n    \"\"\"\n    def media_statistic(self, server: Optional[str] = None) -> Optional[List[schemas.Statistic]]:\n        \"\"\"\n        媒体数量统计\n        \"\"\"\n        return self.run_module(\"media_statistic\", server=server)\n\n    def downloader_info(self, downloader: Optional[str] = None) -> Optional[List[schemas.DownloaderInfo]]:\n        \"\"\"\n        下载器信息\n        \"\"\"\n        return self.run_module(\"downloader_info\", downloader=downloader)\n"
  },
  {
    "path": "app/chain/douban.py",
    "content": "from typing import Optional, List\n\nfrom app import schemas\nfrom app.chain import ChainBase\nfrom app.core.context import MediaInfo\nfrom app.schemas import MediaType\n\n\nclass DoubanChain(ChainBase):\n    \"\"\"\n    豆瓣处理链\n    \"\"\"\n\n    def person_detail(self, person_id: int) -> Optional[schemas.MediaPerson]:\n        \"\"\"\n        根据人物ID查询豆瓣人物详情\n        :param person_id:  人物ID\n        \"\"\"\n        return self.run_module(\"douban_person_detail\", person_id=person_id)\n\n    def person_credits(self, person_id: int, page: Optional[int] = 1) -> List[MediaInfo]:\n        \"\"\"\n        根据人物ID查询人物参演作品\n        :param person_id:  人物ID\n        :param page:  页码\n        \"\"\"\n        return self.run_module(\"douban_person_credits\", person_id=person_id, page=page)\n\n    def movie_top250(self, page: Optional[int] = 1, count: Optional[int] = 30) -> Optional[List[MediaInfo]]:\n        \"\"\"\n        获取豆瓣电影TOP250\n        :param page:  页码\n        :param count:  每页数量\n        \"\"\"\n        return self.run_module(\"movie_top250\", page=page, count=count)\n\n    def movie_showing(self, page: Optional[int] = 1, count: Optional[int] = 30) -> Optional[List[MediaInfo]]:\n        \"\"\"\n        获取正在上映的电影\n        \"\"\"\n        return self.run_module(\"movie_showing\", page=page, count=count)\n\n    def tv_weekly_chinese(self, page: Optional[int] = 1, count: Optional[int] = 30) -> Optional[List[MediaInfo]]:\n        \"\"\"\n        获取本周中国剧集榜\n        \"\"\"\n        return self.run_module(\"tv_weekly_chinese\", page=page, count=count)\n\n    def tv_weekly_global(self, page: Optional[int] = 1, count: Optional[int] = 30) -> Optional[List[MediaInfo]]:\n        \"\"\"\n        获取本周全球剧集榜\n        \"\"\"\n        return self.run_module(\"tv_weekly_global\", page=page, count=count)\n\n    def douban_discover(self, mtype: MediaType, sort: str, tags: str,\n                        page: Optional[int] = 0, count: Optional[int] = 30) -> Optional[List[MediaInfo]]:\n        \"\"\"\n        发现豆瓣电影、剧集\n        :param mtype:  媒体类型\n        :param sort:  排序方式\n        :param tags:  标签\n        :param page:  页码\n        :param count:  数量\n        :return: 媒体信息列表\n        \"\"\"\n        return self.run_module(\"douban_discover\", mtype=mtype, sort=sort, tags=tags,\n                               page=page, count=count)\n\n    def tv_animation(self, page: Optional[int] = 1, count: Optional[int] = 30) -> Optional[List[MediaInfo]]:\n        \"\"\"\n        获取动画剧集\n        \"\"\"\n        return self.run_module(\"tv_animation\", page=page, count=count)\n\n    def movie_hot(self, page: Optional[int] = 1, count: Optional[int] = 30) -> Optional[List[MediaInfo]]:\n        \"\"\"\n        获取热门电影\n        \"\"\"\n        return self.run_module(\"movie_hot\", page=page, count=count)\n\n    def tv_hot(self, page: Optional[int] = 1, count: Optional[int] = 30) -> Optional[List[MediaInfo]]:\n        \"\"\"\n        获取热门剧集\n        \"\"\"\n        return self.run_module(\"tv_hot\", page=page, count=count)\n\n    def movie_credits(self, doubanid: str) -> Optional[List[schemas.MediaPerson]]:\n        \"\"\"\n        根据TMDBID查询电影演职人员\n        :param doubanid:  豆瓣ID\n        \"\"\"\n        return self.run_module(\"douban_movie_credits\", doubanid=doubanid)\n\n    def tv_credits(self, doubanid: str) -> Optional[List[schemas.MediaPerson]]:\n        \"\"\"\n        根据TMDBID查询电视剧演职人员\n        :param doubanid:  豆瓣ID\n        \"\"\"\n        return self.run_module(\"douban_tv_credits\", doubanid=doubanid)\n\n    def movie_recommend(self, doubanid: str) -> List[MediaInfo]:\n        \"\"\"\n        根据豆瓣ID查询推荐电影\n        :param doubanid:  豆瓣ID\n        \"\"\"\n        return self.run_module(\"douban_movie_recommend\", doubanid=doubanid)\n\n    def tv_recommend(self, doubanid: str) -> List[MediaInfo]:\n        \"\"\"\n        根据豆瓣ID查询推荐电视剧\n        :param doubanid:  豆瓣ID\n        \"\"\"\n        return self.run_module(\"douban_tv_recommend\", doubanid=doubanid)\n\n    async def async_person_detail(self, person_id: int) -> Optional[schemas.MediaPerson]:\n        \"\"\"\n        根据人物ID查询豆瓣人物详情（异步版本）\n        :param person_id:  人物ID\n        \"\"\"\n        return await self.async_run_module(\"async_douban_person_detail\", person_id=person_id)\n\n    async def async_person_credits(self, person_id: int, page: Optional[int] = 1) -> List[MediaInfo]:\n        \"\"\"\n        根据人物ID查询人物参演作品（异步版本）\n        :param person_id:  人物ID\n        :param page:  页码\n        \"\"\"\n        return await self.async_run_module(\"async_douban_person_credits\", person_id=person_id, page=page)\n\n    async def async_movie_top250(self, page: Optional[int] = 1,\n                                 count: Optional[int] = 30) -> Optional[List[MediaInfo]]:\n        \"\"\"\n        获取豆瓣电影TOP250（异步版本）\n        :param page:  页码\n        :param count:  每页数量\n        \"\"\"\n        return await self.async_run_module(\"async_movie_top250\", page=page, count=count)\n\n    async def async_movie_showing(self, page: Optional[int] = 1,\n                                  count: Optional[int] = 30) -> Optional[List[MediaInfo]]:\n        \"\"\"\n        获取正在上映的电影（异步版本）\n        \"\"\"\n        return await self.async_run_module(\"async_movie_showing\", page=page, count=count)\n\n    async def async_tv_weekly_chinese(self, page: Optional[int] = 1,\n                                      count: Optional[int] = 30) -> Optional[List[MediaInfo]]:\n        \"\"\"\n        获取本周中国剧集榜（异步版本）\n        \"\"\"\n        return await self.async_run_module(\"async_tv_weekly_chinese\", page=page, count=count)\n\n    async def async_tv_weekly_global(self, page: Optional[int] = 1,\n                                     count: Optional[int] = 30) -> Optional[List[MediaInfo]]:\n        \"\"\"\n        获取本周全球剧集榜（异步版本）\n        \"\"\"\n        return await self.async_run_module(\"async_tv_weekly_global\", page=page, count=count)\n\n    async def async_douban_discover(self, mtype: MediaType, sort: str, tags: str,\n                                    page: Optional[int] = 0, count: Optional[int] = 30) -> Optional[List[MediaInfo]]:\n        \"\"\"\n        发现豆瓣电影、剧集（异步版本）\n        :param mtype:  媒体类型\n        :param sort:  排序方式\n        :param tags:  标签\n        :param page:  页码\n        :param count:  数量\n        :return: 媒体信息列表\n        \"\"\"\n        return await self.async_run_module(\"async_douban_discover\", mtype=mtype, sort=sort, tags=tags,\n                                           page=page, count=count)\n\n    async def async_tv_animation(self, page: Optional[int] = 1,\n                                 count: Optional[int] = 30) -> Optional[List[MediaInfo]]:\n        \"\"\"\n        获取动画剧集（异步版本）\n        \"\"\"\n        return await self.async_run_module(\"async_tv_animation\", page=page, count=count)\n\n    async def async_movie_hot(self, page: Optional[int] = 1,\n                              count: Optional[int] = 30) -> Optional[List[MediaInfo]]:\n        \"\"\"\n        获取热门电影（异步版本）\n        \"\"\"\n        return await self.async_run_module(\"async_movie_hot\", page=page, count=count)\n\n    async def async_tv_hot(self, page: Optional[int] = 1,\n                           count: Optional[int] = 30) -> Optional[List[MediaInfo]]:\n        \"\"\"\n        获取热门剧集（异步版本）\n        \"\"\"\n        return await self.async_run_module(\"async_tv_hot\", page=page, count=count)\n\n    async def async_movie_credits(self, doubanid: str) -> Optional[List[schemas.MediaPerson]]:\n        \"\"\"\n        根据TMDBID查询电影演职人员（异步版本）\n        :param doubanid:  豆瓣ID\n        \"\"\"\n        return await self.async_run_module(\"async_douban_movie_credits\", doubanid=doubanid)\n\n    async def async_tv_credits(self, doubanid: str) -> Optional[List[schemas.MediaPerson]]:\n        \"\"\"\n        根据TMDBID查询电视剧演职人员（异步版本）\n        :param doubanid:  豆瓣ID\n        \"\"\"\n        return await self.async_run_module(\"async_douban_tv_credits\", doubanid=doubanid)\n\n    async def async_movie_recommend(self, doubanid: str) -> List[MediaInfo]:\n        \"\"\"\n        根据豆瓣ID查询推荐电影（异步版本）\n        :param doubanid:  豆瓣ID\n        \"\"\"\n        return await self.async_run_module(\"async_douban_movie_recommend\", doubanid=doubanid)\n\n    async def async_tv_recommend(self, doubanid: str) -> List[MediaInfo]:\n        \"\"\"\n        根据豆瓣ID查询推荐电视剧（异步版本）\n        :param doubanid:  豆瓣ID\n        \"\"\"\n        return await self.async_run_module(\"async_douban_tv_recommend\", doubanid=doubanid)\n"
  },
  {
    "path": "app/chain/download.py",
    "content": "import base64\nimport copy\nimport json\nimport re\nimport time\nfrom pathlib import Path\nfrom typing import List, Optional, Tuple, Set, Dict, Union\n\nfrom app import schemas\nfrom app.chain import ChainBase\nfrom app.core.cache import FileCache\nfrom app.core.config import settings, global_vars\nfrom app.core.context import MediaInfo, TorrentInfo, Context\nfrom app.core.event import eventmanager, Event\nfrom app.core.meta import MetaBase\nfrom app.core.metainfo import MetaInfo\nfrom app.db.downloadhistory_oper import DownloadHistoryOper\nfrom app.db.mediaserver_oper import MediaServerOper\nfrom app.helper.directory import DirectoryHelper\nfrom app.helper.torrent import TorrentHelper\nfrom app.log import logger\nfrom app.schemas import ExistMediaInfo, FileURI, NotExistMediaInfo, DownloadingTorrent, Notification, ResourceSelectionEventData, \\\n    ResourceDownloadEventData\nfrom app.schemas.types import MediaType, TorrentStatus, EventType, MessageChannel, NotificationType, ContentType, \\\n    ChainEventType\nfrom app.utils.http import RequestUtils\nfrom app.utils.string import StringUtils\n\n\nclass DownloadChain(ChainBase):\n    \"\"\"\n    下载处理链\n    \"\"\"\n\n    def download_torrent(self, torrent: TorrentInfo,\n                         channel: MessageChannel = None,\n                         source: Optional[str] = None,\n                         userid: Union[str, int] = None\n                         ) -> Tuple[Optional[Union[str, bytes]], str, list]:\n        \"\"\"\n        下载种子文件，如果是磁力链，会返回磁力链接本身\n        :return: 种子内容，种子目录名，种子文件清单\n        \"\"\"\n\n        def __get_redict_url(url: str, ua: Optional[str] = None, cookie: Optional[str] = None) -> Optional[str]:\n            \"\"\"\n            获取下载链接， url格式：[base64]url\n            \"\"\"\n            # 获取[]中的内容\n            m = re.search(r\"\\[(.*)](.*)\", url)\n            if m:\n                # 参数\n                base64_str = m.group(1)\n                # URL\n                url = m.group(2)\n                if not base64_str:\n                    return url\n                # 解码参数\n                req_str = base64.b64decode(base64_str.encode('utf-8')).decode('utf-8')\n                req_params: Dict[str, dict] = json.loads(req_str)\n                # 是否使用cookie\n                if not req_params.get('cookie'):\n                    cookie = None\n                # 代理\n                proxy = req_params.get('proxy')\n                # 请求头\n                if req_params.get('header'):\n                    headers = req_params.get('header')\n                else:\n                    headers = None\n                if req_params.get('method') == 'get':\n                    # GET请求\n                    res = RequestUtils(\n                        ua=ua,\n                        cookies=cookie,\n                        headers=headers,\n                        proxies=settings.PROXY if proxy else None\n                    ).get_res(url, params=req_params.get('params'))\n                else:\n                    # POST请求\n                    res = RequestUtils(\n                        ua=ua,\n                        cookies=cookie,\n                        headers=headers,\n                        proxies=settings.PROXY if proxy else None\n                    ).post_res(url, params=req_params.get('params'))\n                if not res:\n                    return None\n                if not req_params.get('result'):\n                    return res.text\n                else:\n                    data = res.json()\n                    for key in str(req_params.get('result')).split(\".\"):\n                        data = data.get(key)\n                        if not data:\n                            return None\n                    logger.info(f\"获取到下载地址：{data}\")\n                    return data\n            return None\n\n        # 获取下载链接\n        if not torrent.enclosure:\n            return None, \"\", []\n        if torrent.enclosure.startswith(\"magnet:\"):\n            return torrent.enclosure, \"\", []\n        # Cookie\n        site_cookie = torrent.site_cookie\n        if torrent.enclosure.startswith(\"[\"):\n            # 需要解码获取下载地址\n            torrent_url = __get_redict_url(url=torrent.enclosure,\n                                           ua=torrent.site_ua,\n                                           cookie=site_cookie)\n            # 涉及解析地址的不使用Cookie下载种子，否则MT会出错\n            site_cookie = None\n        else:\n            torrent_url = torrent.enclosure\n        if not torrent_url:\n            logger.error(f\"{torrent.title} 无法获取下载地址：{torrent.enclosure}！\")\n            return None, \"\", []\n        # 下载种子文件\n        _, content, download_folder, files, error_msg = TorrentHelper().download_torrent(\n            url=torrent_url,\n            cookie=site_cookie,\n            ua=torrent.site_ua or settings.USER_AGENT,\n            proxy=torrent.site_proxy)\n\n        if isinstance(content, str):\n            # 磁力链\n            return content, \"\", []\n\n        if not content:\n            logger.error(f\"下载种子文件失败：{torrent.title} - {torrent_url}\")\n            self.post_message(Notification(\n                channel=channel,\n                source=source if channel else None,\n                mtype=NotificationType.Manual,\n                title=f\"{torrent.title} 种子下载失败！\",\n                text=f\"错误信息：{error_msg}\\n站点：{torrent.site_name}\",\n                userid=userid))\n            return None, \"\", []\n\n        # 返回 种子文件路径，种子目录名，种子文件清单\n        return content, download_folder, files\n\n    def download_single(self, context: Context,\n                        torrent_file: Path = None,\n                        torrent_content: Optional[Union[str, bytes]] = None,\n                        episodes: Set[int] = None,\n                        channel: MessageChannel = None,\n                        source: Optional[str] = None,\n                        downloader: Optional[str] = None,\n                        save_path: Optional[str] = None,\n                        userid: Union[str, int] = None,\n                        username: Optional[str] = None,\n                        label: Optional[str] = None,\n                        return_detail: bool = False) -> Union[Optional[str], Tuple[Optional[str], Optional[str]]]:\n        \"\"\"\n        下载及发送通知\n        :param context: 资源上下文\n        :param torrent_file: 种子文件路径\n        :param torrent_content: 种子内容（磁力链或种子文件内容）\n        :param episodes: 需要下载的集数\n        :param channel: 通知渠道\n        :param source: 来源（消息通知、Subscribe、Manual等）\n        :param downloader: 下载器\n        :param save_path: 保存路径, 支持<storage>:<path>, 如rclone:/MP, smb:/server/share/Movies等\n        :param userid: 用户ID\n        :param username: 调用下载的用户名/插件名\n        :param label: 自定义标签\n        :param return_detail: 是否返回详细结果；False 时返回下载任务 hash 或 None，True 时返回 (hash, error_msg)\n        :return: return_detail=False 时返回下载任务 hash 或 None；return_detail=True 时返回 (hash, error_msg)\n        \"\"\"\n        _torrent = context.torrent_info\n        _media = context.media_info\n        _meta = context.meta_info\n        _site_downloader = _torrent.site_downloader\n\n        # 发送资源下载事件，允许外部拦截下载\n        event_data = ResourceDownloadEventData(\n            context=context,\n            episodes=episodes or context.meta_info.episode_list,\n            channel=channel,\n            origin=source,\n            downloader=downloader,\n            options={\n                \"save_path\": save_path,\n                \"userid\": userid,\n                \"username\": username,\n                \"media_category\": _media.category\n            }\n        )\n        # 触发资源下载事件\n        event = eventmanager.send_event(ChainEventType.ResourceDownload, event_data)\n        if event and event.event_data:\n            event_data: ResourceDownloadEventData = event.event_data\n            # 如果事件被取消，跳过资源下载\n            if event_data.cancel:\n                logger.debug(\n                    f\"Resource download canceled by event: {event_data.source},\"\n                    f\"Reason: {event_data.reason}\")\n                return (None, \"下载被事件取消\") if return_detail else None\n            # 如果事件修改了下载路径，使用新路径\n            if event_data.options and event_data.options.get(\"save_path\"):\n                save_path = event_data.options.get(\"save_path\")\n\n        # 补充完整的media数据\n        if not _media.genre_ids:\n            new_media = self.recognize_media(mtype=_media.type, tmdbid=_media.tmdb_id,\n                                             doubanid=_media.douban_id, bangumiid=_media.bangumi_id,\n                                             episode_group=_media.episode_group)\n            if new_media:\n                _media = new_media\n\n        # 实际下载的集数\n        download_episodes = StringUtils.format_ep(list(episodes)) if episodes else None\n        _folder_name = \"\"\n        if not torrent_file and not torrent_content:\n            # 下载种子文件，得到的可能是文件也可能是磁力链\n            torrent_content, _folder_name, _file_list = self.download_torrent(_torrent,\n                                                                              channel=channel,\n                                                                              source=source,\n                                                                              userid=userid)\n        elif torrent_file:\n            if torrent_file.exists():\n                torrent_content = torrent_file.read_bytes()\n            else:\n                # 缓存处理器\n                cache_backend = FileCache()\n                # 读取缓存的种子文件\n                torrent_content = cache_backend.get(torrent_file.as_posix(), region=\"torrents\")\n\n        if not torrent_content:\n            return (None, \"下载种子内容为空\") if return_detail else None\n\n        # 获取种子文件的文件夹名和文件清单\n        _folder_name, _file_list = TorrentHelper().get_fileinfo_from_torrent_content(torrent_content)\n\n        storage = 'local'\n        # 下载目录\n        if save_path:\n            download_dir = Path(save_path)\n        else:\n            # 根据媒体信息查询下载目录配置\n            dir_info = DirectoryHelper().get_dir(_media, include_unsorted=True)\n            storage = dir_info.storage if dir_info else storage\n            # 拼装子目录\n            if dir_info:\n                # 一级目录\n                if not dir_info.media_type and dir_info.download_type_folder:\n                    # 一级自动分类\n                    download_dir = Path(dir_info.download_path) / _media.type.value\n                else:\n                    # 一级不分类\n                    download_dir = Path(dir_info.download_path)\n\n                # 二级目录\n                if not dir_info.media_category and dir_info.download_category_folder and _media and _media.category:\n                    # 二级自动分类\n                    download_dir = download_dir / _media.category\n            else:\n                # 未找到下载目录，且没有自定义下载目录\n                logger.error(f\"未找到下载目录：{_media.type.value} {_media.title_year}\")\n                self.messagehelper.put(f\"{_media.type.value} {_media.title_year} 未找到下载目录！\",\n                                       title=\"下载失败\", role=\"system\")\n                return (None, \"未找到下载目录\") if return_detail else None\n            fileURI = FileURI(storage=storage, path=download_dir.as_posix())\n            download_dir = Path(fileURI.uri)\n\n        # 添加下载\n        result: Optional[tuple] = self.download(content=torrent_content,\n                                                cookie=_torrent.site_cookie,\n                                                episodes=episodes,\n                                                download_dir=download_dir,\n                                                category=_media.category,\n                                                label=label,\n                                                downloader=downloader or _site_downloader)\n        if result:\n            _downloader, _hash, _layout, error_msg = result\n        else:\n            _downloader, _hash, _layout, error_msg = None, None, None, \"未找到下载器\"\n\n        if _hash:\n            # `不创建子文件夹` 或 `不存在子文件夹`\n            if _layout == \"NoSubfolder\" or not _folder_name:\n                # 下载路径记录至文件\n                download_path = download_dir / _file_list[0] if _file_list else download_dir\n            # 原始布局\n            elif _folder_name:\n                download_path = download_dir / _folder_name\n            # 创建子文件夹\n            else:\n                download_path = download_dir / Path(_file_list[0]).stem if _file_list else download_dir\n            # 文件保存路径\n            _save_path = download_dir if _layout == \"NoSubfolder\" or not _folder_name else download_path\n\n            # 登记下载记录\n            downloadhis = DownloadHistoryOper()\n            downloadhis.add(\n                path=download_path.as_posix(),\n                type=_media.type.value,\n                title=_media.title,\n                year=_media.year,\n                tmdbid=_media.tmdb_id,\n                imdbid=_media.imdb_id,\n                tvdbid=_media.tvdb_id,\n                doubanid=_media.douban_id,\n                seasons=_meta.season,\n                episodes=download_episodes or _meta.episode,\n                image=_media.get_backdrop_image(),\n                downloader=_downloader,\n                download_hash=_hash,\n                torrent_name=_torrent.title,\n                torrent_description=_torrent.description,\n                torrent_site=_torrent.site_name,\n                userid=userid,\n                username=username,\n                channel=channel.value if channel else None,\n                date=time.strftime(\"%Y-%m-%d %H:%M:%S\", time.localtime()),\n                media_category=_media.category,\n                episode_group=_media.episode_group,\n                note={\"source\": source}\n            )\n\n            # 登记下载文件\n            files_to_add = []\n            for file in _file_list:\n                if episodes:\n                    # 识别文件集\n                    file_meta = MetaInfo(Path(file).stem)\n                    if not file_meta.begin_episode \\\n                            or file_meta.begin_episode not in episodes:\n                        continue\n                # 只处理音视频、字幕格式\n                media_exts = settings.RMT_MEDIAEXT + settings.RMT_SUBEXT + settings.RMT_AUDIOEXT\n                if not Path(file).suffix \\\n                        or Path(file).suffix.lower() not in media_exts:\n                    continue\n                files_to_add.append({\n                    \"download_hash\": _hash,\n                    \"downloader\": _downloader,\n                    \"fullpath\": (_save_path / file).as_posix(),\n                    \"savepath\": _save_path.as_posix(),\n                    \"filepath\": file,\n                    \"torrentname\": _meta.org_string,\n                })\n            if files_to_add:\n                downloadhis.add_files(files_to_add)\n\n            # 下载成功发送消息\n            self.post_message(\n                Notification(\n                    channel=channel,\n                    source=source if channel else None,\n                    mtype=NotificationType.Download,\n                    ctype=ContentType.DownloadAdded,\n                    image=_media.get_message_image(),\n                    link=settings.MP_DOMAIN('/#/downloading'),\n                    userid=userid,\n                    username=username\n                ),\n                meta=_meta,\n                mediainfo=_media,\n                torrentinfo=_torrent,\n                download_episodes=download_episodes,\n                username=username,\n            )\n            # 下载成功后处理\n            self.download_added(context=context, download_dir=download_dir, torrent_content=torrent_content)\n            # 广播事件\n            self.eventmanager.send_event(EventType.DownloadAdded, {\n                \"hash\": _hash,\n                \"context\": context,\n                \"username\": username,\n                \"downloader\": _downloader,\n                \"episodes\": episodes or _meta.episode_list,\n                \"source\": source\n            })\n        else:\n            # 下载失败\n            logger.error(f\"{_media.title_year} 添加下载任务失败：\"\n                         f\"{_torrent.title} - {_torrent.enclosure}，{error_msg}\")\n            # 只发送给对应渠道和用户\n            self.post_message(Notification(\n                channel=channel,\n                source=source if channel else None,\n                mtype=NotificationType.Manual,\n                title=\"添加下载任务失败：%s %s\"\n                      % (_media.title_year, _meta.season_episode),\n                text=f\"站点：{_torrent.site_name}\\n\"\n                     f\"种子名称：{_meta.org_string}\\n\"\n                     f\"错误信息：{error_msg}\",\n                image=_media.get_message_image(),\n                userid=userid))\n        if return_detail:\n            return _hash, error_msg\n        return _hash\n\n    def batch_download(self,\n                       contexts: List[Context],\n                       no_exists: Dict[Union[int, str], Dict[int, NotExistMediaInfo]] = None,\n                       save_path: Optional[str] = None,\n                       channel: MessageChannel = None,\n                       source: Optional[str] = None,\n                       userid: Optional[str] = None,\n                       username: Optional[str] = None,\n                       downloader: Optional[str] = None\n                       ) -> Tuple[List[Context], Dict[Union[int, str], Dict[int, NotExistMediaInfo]]]:\n        \"\"\"\n        根据缺失数据，自动种子列表中组合择优下载\n        :param contexts:  资源上下文列表\n        :param no_exists:  缺失的剧集信息\n        :param save_path:  保存路径, 支持<storage>:<path>, 如rclone:/MP, smb:/server/share/Movies等\n        :param channel:  通知渠道\n        :param source:  来源（消息通知、订阅、手工下载等）\n        :param userid:  用户ID\n        :param username: 调用下载的用户名/插件名\n        :param downloader: 下载器\n        :return: 已经下载的资源列表、剩余未下载到的剧集 no_exists[tmdb_id/douban_id] = {season: NotExistMediaInfo}\n        \"\"\"\n        # 已下载的项目\n        downloaded_list: List[Context] = []\n\n        def __update_seasons(_mid: Union[int, str], _need: list, _current: list) -> list:\n            \"\"\"\n            更新need_tvs季数，返回剩余季数\n            :param _mid: TMDBID\n            :param _need: 需要下载的季数\n            :param _current: 已经下载的季数\n            \"\"\"\n            # 剩余季数\n            need = list(set(_need).difference(set(_current)))\n            # 清除已下载的季信息\n            seas = copy.deepcopy(no_exists.get(_mid))\n            if seas:\n                for _sea in list(seas):\n                    if _sea not in need:\n                        no_exists[_mid].pop(_sea)\n                    if not no_exists.get(_mid) and no_exists.get(_mid) is not None:\n                        no_exists.pop(_mid)\n                        break\n            return need\n\n        def __update_episodes(_mid: Union[int, str], _sea: int, _need: list, _current: set) -> list:\n            \"\"\"\n            更新need_tvs集数，返回剩余集数\n            :param _mid: TMDBID\n            :param _sea: 季数\n            :param _need: 需要下载的集数\n            :param _current: 已经下载的集数\n            \"\"\"\n            # 剩余集数\n            need = list(set(_need).difference(set(_current)))\n            if need:\n                not_exist = no_exists[_mid][_sea]\n                no_exists[_mid][_sea] = NotExistMediaInfo(\n                    season=not_exist.season,\n                    episodes=need,\n                    total_episode=not_exist.total_episode,\n                    start_episode=not_exist.start_episode\n                )\n            else:\n                no_exists[_mid].pop(_sea)\n                if not no_exists.get(_mid) and no_exists.get(_mid) is not None:\n                    no_exists.pop(_mid)\n            return need\n\n        def __get_season_episodes(_mid: Union[int, str], season: int) -> int:\n            \"\"\"\n            获取需要的季的集数\n            \"\"\"\n            if not no_exists.get(_mid):\n                return 9999\n            no_exist = no_exists.get(_mid)\n            if not no_exist.get(season):\n                return 9999\n            return no_exist[season].total_episode\n\n        # 发送资源选择事件，允许外部修改上下文数据\n        logger.debug(f\"Initial contexts: {len(contexts)} items, Downloader: {downloader}\")\n        event_data = ResourceSelectionEventData(\n            contexts=contexts,\n            downloader=downloader,\n            origin=source\n        )\n        event = eventmanager.send_event(ChainEventType.ResourceSelection, event_data)\n        # 如果事件修改了上下文数据，使用更新后的数据\n        if event and event.event_data:\n            event_data: ResourceSelectionEventData = event.event_data\n            if event_data.updated and event_data.updated_contexts is not None:\n                logger.debug(f\"Contexts updated by event: \"\n                             f\"{len(event_data.updated_contexts)} items (source: {event_data.source})\")\n                contexts = event_data.updated_contexts\n\n        # 分组排序\n        contexts = TorrentHelper().sort_group_torrents(contexts)\n\n        # 如果是电影，直接下载\n        for context in contexts:\n            if global_vars.is_system_stopped:\n                break\n            if context.media_info.type == MediaType.MOVIE:\n                logger.info(f\"开始下载电影 {context.torrent_info.title} ...\")\n                if self.download_single(context, save_path=save_path, channel=channel,\n                                        source=source, userid=userid, username=username,\n                                        downloader=downloader):\n                    # 下载成功\n                    logger.info(f\"{context.torrent_info.title} 添加下载成功\")\n                    downloaded_list.append(context)\n\n        # 电视剧整季匹配\n        if no_exists:\n            logger.info(f\"开始匹配电视剧整季：{no_exists}\")\n            # 先把整季缺失的拿出来，看是否刚好有所有季都满足的种子 {tmdbid: [seasons]}\n            need_seasons: Dict[int, list] = {}\n            for need_mid, need_tv in no_exists.items():\n                for tv in need_tv.values():\n                    if not tv:\n                        continue\n                    # 季列表为空的，代表全季缺失\n                    if not tv.episodes:\n                        if not need_seasons.get(need_mid):\n                            need_seasons[need_mid] = []\n                        need_seasons[need_mid].append(tv.season or 1)\n            logger.info(f\"缺失整季：{need_seasons}\")\n            # 查找整季包含的种子，只处理整季没集的种子或者是集数超过季的种子\n            for need_mid, need_season in need_seasons.items():\n                # 循环种子\n                for context in contexts:\n                    if global_vars.is_system_stopped:\n                        break\n                    # 媒体信息\n                    media = context.media_info\n                    # 识别元数据\n                    meta = context.meta_info\n                    # 种子信息\n                    torrent = context.torrent_info\n                    # 排除电视剧\n                    if media.type != MediaType.TV:\n                        continue\n                    # 种子的季清单\n                    torrent_season = meta.season_list\n                    # 没有季的默认为第1季\n                    if not torrent_season:\n                        torrent_season = [1]\n                    # 种子有集的不要\n                    if meta.episode_list:\n                        continue\n                    # 匹配TMDBID\n                    if need_mid == media.tmdb_id or need_mid == media.douban_id:\n                        # 不重复添加\n                        if context in downloaded_list:\n                            continue\n                        # 种子季是需要季或者子集\n                        if set(torrent_season).issubset(set(need_season)):\n                            if len(torrent_season) == 1:\n                                # 只有一季的可能是命名错误，需要打开种子鉴别，只有实际集数大于等于总集数才下载\n                                logger.info(f\"开始下载种子 {torrent.title} ...\")\n                                content, _, torrent_files = self.download_torrent(torrent)\n                                if not content:\n                                    logger.warn(f\"{torrent.title} 种子下载失败！\")\n                                    continue\n                                if isinstance(content, str):\n                                    logger.warn(f\"{meta.org_string} 下载地址是磁力链，无法确定种子文件集数\")\n                                    continue\n                                torrent_episodes = TorrentHelper().get_torrent_episodes(torrent_files)\n                                logger.info(f\"{meta.org_string} 解析种子文件集数为 {torrent_episodes}\")\n                                if not torrent_episodes:\n                                    continue\n                                # 更新集数范围\n                                begin_ep = min(torrent_episodes)\n                                end_ep = max(torrent_episodes)\n                                meta.set_episodes(begin=begin_ep, end=end_ep)\n                                # 需要总集数\n                                need_total = __get_season_episodes(need_mid, torrent_season[0])\n                                if len(torrent_episodes) < need_total:\n                                    logger.info(\n                                        f\"{meta.org_string} 解析文件集数发现不是完整合集，先放弃这个种子\")\n                                    continue\n                                else:\n                                    # 下载\n                                    logger.info(f\"开始下载 {torrent.title} ...\")\n                                    download_id = self.download_single(\n                                        context=context,\n                                        torrent_content=content,\n                                        save_path=save_path,\n                                        channel=channel,\n                                        source=source,\n                                        userid=userid,\n                                        username=username,\n                                        downloader=downloader\n                                    )\n                            else:\n                                # 下载\n                                logger.info(f\"开始下载 {torrent.title} ...\")\n                                download_id = self.download_single(context, save_path=save_path,\n                                                                   channel=channel, source=source,\n                                                                   userid=userid, username=username,\n                                                                   downloader=downloader)\n\n                            if download_id:\n                                # 下载成功\n                                logger.info(f\"{torrent.title} 添加下载成功\")\n                                downloaded_list.append(context)\n                                # 更新仍需季集\n                                need_season = __update_seasons(_mid=need_mid,\n                                                               _need=need_season,\n                                                               _current=torrent_season)\n                                logger.info(f\"{need_mid} 剩余需要季：{need_season}\")\n                                if not need_season:\n                                    # 全部下载完成\n                                    break\n        # 电视剧季内的集匹配\n        if no_exists:\n            logger.info(f\"开始电视剧完整集匹配：{no_exists}\")\n            # TMDBID列表\n            need_tv_list = list(no_exists)\n            for need_mid in need_tv_list:\n                # dict[season, [NotExistMediaInfo]]\n                need_tv = no_exists.get(need_mid)\n                if not need_tv:\n                    continue\n                need_tv_copy = copy.deepcopy(no_exists.get(need_mid))\n                # 循环每一季\n                for sea, tv in need_tv_copy.items():\n                    # 当前需要季\n                    need_season = sea\n                    # 当前需要集\n                    need_episodes = tv.episodes\n                    # TMDB总集数\n                    total_episode = tv.total_episode\n                    # 需要开始集\n                    start_episode = tv.start_episode or 1\n                    # 缺失整季的转化为缺失集进行比较\n                    if not need_episodes:\n                        need_episodes = list(range(start_episode, total_episode + 1))\n                    # 循环种子\n                    for context in contexts:\n                        if global_vars.is_system_stopped:\n                            break\n                        # 媒体信息\n                        media = context.media_info\n                        # 识别元数据\n                        meta = context.meta_info\n                        # 非剧集不处理\n                        if media.type != MediaType.TV:\n                            continue\n                        # 匹配TMDB\n                        if media.tmdb_id == need_mid or media.douban_id == need_mid:\n                            # 不重复添加\n                            if context in downloaded_list:\n                                continue\n                            # 种子季\n                            torrent_season = meta.season_list\n                            # 只处理单季含集的种子\n                            if len(torrent_season) != 1 or torrent_season[0] != need_season:\n                                continue\n                            # 种子集列表\n                            torrent_episodes = set(meta.episode_list)\n                            # 整季的不处理\n                            if not torrent_episodes:\n                                continue\n                            # 为需要集的子集则下载\n                            if torrent_episodes.issubset(set(need_episodes)):\n                                # 下载\n                                logger.info(f\"开始下载 {meta.title} ...\")\n                                download_id = self.download_single(context, save_path=save_path,\n                                                                   channel=channel, source=source,\n                                                                   userid=userid, username=username,\n                                                                   downloader=downloader)\n                                if download_id:\n                                    # 下载成功\n                                    logger.info(f\"{meta.title} 添加下载成功\")\n                                    downloaded_list.append(context)\n                                    # 更新仍需集数\n                                    need_episodes = __update_episodes(_mid=need_mid,\n                                                                      _need=need_episodes,\n                                                                      _sea=need_season,\n                                                                      _current=torrent_episodes)\n                                    logger.info(f\"季 {need_season} 剩余需要集：{need_episodes}\")\n\n        # 仍然缺失的剧集，从整季中选择需要的集数文件下载，仅支持QB和TR\n        if no_exists:\n            logger.info(f\"开始电视剧多集拆包匹配：{no_exists}\")\n            # TMDBID列表\n            no_exists_list = list(no_exists)\n            for need_mid in no_exists_list:\n                # dict[season, [NotExistMediaInfo]]\n                need_tv = no_exists.get(need_mid)\n                if not need_tv:\n                    continue\n                # 需要季列表\n                need_tv_list = list(need_tv)\n                # 循环需要季\n                for sea in need_tv_list:\n                    # NotExistMediaInfo\n                    tv = need_tv.get(sea)\n                    # 当前需要季\n                    need_season = sea\n                    # 当前需要集\n                    need_episodes = tv.episodes\n                    # 没有集的不处理\n                    if not need_episodes:\n                        continue\n                    # 循环种子\n                    for context in contexts:\n                        if global_vars.is_system_stopped:\n                            break\n                        # 媒体信息\n                        media = context.media_info\n                        # 识别元数据\n                        meta = context.meta_info\n                        # 种子信息\n                        torrent = context.torrent_info\n                        # 非剧集不处理\n                        if media.type != MediaType.TV:\n                            continue\n                        # 不重复添加\n                        if context in downloaded_list:\n                            continue\n                        # 没有需要集后退出\n                        if not need_episodes:\n                            break\n                        # 选中一个单季整季的或单季包括需要的所有集的\n                        if (media.tmdb_id == need_mid or media.douban_id == need_mid) \\\n                                and (not meta.episode_list\n                                     or set(meta.episode_list).intersection(set(need_episodes))) \\\n                                and len(meta.season_list) == 1 \\\n                                and meta.season_list[0] == need_season:\n                            # 检查种子看是否有需要的集\n                            logger.info(f\"开始下载种子 {torrent.title} ...\")\n                            content, _, torrent_files = self.download_torrent(torrent)\n                            if not content:\n                                logger.info(f\"{torrent.title} 种子下载失败！\")\n                                continue\n                            if isinstance(content, str):\n                                logger.warn(f\"{meta.org_string} 下载地址是磁力链，无法解析种子文件集数\")\n                                continue\n                            # 种子全部集\n                            torrent_episodes = TorrentHelper().get_torrent_episodes(torrent_files)\n                            logger.info(f\"{torrent.site_name} - {meta.org_string} 解析种子文件集数：{torrent_episodes}\")\n                            # 选中的集\n                            selected_episodes = set(torrent_episodes).intersection(set(need_episodes))\n                            if not selected_episodes:\n                                logger.info(f\"{torrent.site_name} - {torrent.title} 没有需要的集，跳过...\")\n                                continue\n                            logger.info(f\"{torrent.site_name} - {torrent.title} 选中集数：{selected_episodes}\")\n                            # 添加下载\n                            logger.info(f\"开始下载 {torrent.title} ...\")\n                            download_id = self.download_single(\n                                context=context,\n                                torrent_content=content,\n                                episodes=selected_episodes,\n                                save_path=save_path,\n                                channel=channel,\n                                source=source,\n                                userid=userid,\n                                username=username,\n                                downloader=downloader\n                            )\n                            if not download_id:\n                                continue\n                            # 下载成功\n                            logger.info(f\"{torrent.title} 添加下载成功\")\n                            downloaded_list.append(context)\n                            # 更新种子集数范围\n                            begin_ep = min(torrent_episodes)\n                            end_ep = max(torrent_episodes)\n                            meta.set_episodes(begin=begin_ep, end=end_ep)\n                            # 更新仍需集数\n                            need_episodes = __update_episodes(_mid=need_mid,\n                                                              _need=need_episodes,\n                                                              _sea=need_season,\n                                                              _current=selected_episodes)\n                            logger.info(f\"季 {need_season} 剩余需要集：{need_episodes}\")\n\n        # 返回下载的资源，剩下没下完的\n        logger.info(f\"成功下载种子数：{len(downloaded_list)}，剩余未下载的剧集：{no_exists}\")\n        return downloaded_list, no_exists\n\n    def get_no_exists_info(self, meta: MetaBase,\n                           mediainfo: MediaInfo,\n                           no_exists: Dict[int, Dict[int, NotExistMediaInfo]] = None,\n                           totals: Dict[int, int] = None\n                           ) -> Tuple[bool, Dict[Union[int, str], Dict[int, NotExistMediaInfo]]]:\n        \"\"\"\n        检查媒体库，查询是否存在，对于剧集同时返回不存在的季集信息\n        :param meta: 元数据\n        :param mediainfo: 已识别的媒体信息\n        :param no_exists: 在调用该方法前已经存储的不存在的季集信息，有传入时该函数搜索的内容将会叠加后输出\n        :param totals: 电视剧每季的总集数\n        :return: 当前媒体是否缺失，各标题总的季集和缺失的季集\n        \"\"\"\n\n        def __append_no_exists(_season: int, _episodes: list, _total: int, _start: int):\n            \"\"\"\n            添加不存在的季集信息\n            {tmdbid: [\n                \"season\": int,\n                \"episodes\": list,\n                \"total_episode\": int,\n                \"start_episode\": int\n            ]}\n            \"\"\"\n            mediakey = mediainfo.tmdb_id or mediainfo.douban_id\n            if not no_exists.get(mediakey):\n                no_exists[mediakey] = {\n                    _season: NotExistMediaInfo(\n                        season=_season,\n                        episodes=_episodes,\n                        total_episode=_total,\n                        start_episode=_start\n                    )\n                }\n            else:\n                no_exists[mediakey][_season] = NotExistMediaInfo(\n                    season=_season,\n                    episodes=_episodes,\n                    total_episode=_total,\n                    start_episode=_start\n                )\n\n        if not no_exists:\n            no_exists = {}\n\n        if not totals:\n            totals = {}\n\n        mediaserver = MediaServerOper()\n        if mediainfo.type == MediaType.MOVIE:\n            # 电影\n            itemid = mediaserver.get_item_id(mtype=mediainfo.type.value,\n                                             title=mediainfo.title,\n                                             tmdbid=mediainfo.tmdb_id)\n            exists_movies: Optional[ExistMediaInfo] = self.media_exists(mediainfo=mediainfo, itemid=itemid)\n            if exists_movies:\n                logger.info(f\"媒体库中已存在电影：{mediainfo.title_year}\")\n                return True, {}\n            return False, {}\n        else:\n            if not mediainfo.seasons:\n                # 补充媒体信息\n                mediainfo: MediaInfo = self.recognize_media(mtype=mediainfo.type,\n                                                            tmdbid=mediainfo.tmdb_id,\n                                                            doubanid=mediainfo.douban_id,\n                                                            episode_group=mediainfo.episode_group)\n                if not mediainfo:\n                    logger.error(f\"媒体信息识别失败！\")\n                    return False, {}\n                if not mediainfo.seasons:\n                    logger.error(f\"媒体信息中没有季集信息：{mediainfo.title_year}\")\n                    return False, {}\n            # 电视剧\n            itemid = mediaserver.get_item_id(mtype=mediainfo.type.value,\n                                             title=mediainfo.title,\n                                             tmdbid=mediainfo.tmdb_id,\n                                             season=mediainfo.season)\n            # 媒体库已存在的剧集\n            exists_tvs: Optional[ExistMediaInfo] = self.media_exists(mediainfo=mediainfo, itemid=itemid)\n            if not exists_tvs:\n                # 所有季集均缺失\n                for season, episodes in mediainfo.seasons.items():\n                    if not episodes:\n                        continue\n                    # 全季不存在\n                    if meta.sea \\\n                            and season not in meta.season_list:\n                        continue\n                    # 总集数\n                    total_ep = totals.get(season) or len(episodes)\n                    __append_no_exists(_season=season, _episodes=[],\n                                       _total=total_ep, _start=min(episodes))\n                return False, no_exists\n            else:\n                # 存在一些，检查每季缺失的季集\n                for season, episodes in mediainfo.seasons.items():\n                    if meta.sea \\\n                            and season not in meta.season_list:\n                        continue\n                    if not episodes:\n                        continue\n                    # 该季总集数\n                    season_total = totals.get(season) or len(episodes)\n                    # 该季已存在的集\n                    exist_episodes = exists_tvs.seasons.get(season)\n                    if exist_episodes:\n                        # 已存在取差集\n                        if totals.get(season):\n                            # 按总集数计算缺失集（开始集为TMDB中的最小集）\n                            lack_episodes = list(set(range(min(episodes),\n                                                           season_total + min(episodes))\n                                                     ).difference(set(exist_episodes)))\n                        else:\n                            # 按TMDB集数计算缺失集\n                            lack_episodes = list(set(episodes).difference(set(exist_episodes)))\n                        if not lack_episodes:\n                            # 全部集存在\n                            continue\n                        # 添加不存在的季集信息\n                        __append_no_exists(_season=season, _episodes=lack_episodes,\n                                           _total=season_total, _start=min(lack_episodes))\n                    else:\n                        # 全季不存在\n                        __append_no_exists(_season=season, _episodes=[],\n                                           _total=season_total, _start=min(episodes))\n            # 存在不完整的剧集\n            if no_exists:\n                logger.debug(f\"媒体库中已存在部分剧集，缺失：{no_exists}\")\n                return False, no_exists\n            # 全部存在\n            return True, no_exists\n\n    def remote_downloading(self, channel: MessageChannel, userid: Union[str, int] = None, source: Optional[str] = None):\n        \"\"\"\n        查询正在下载的任务，并发送消息\n        \"\"\"\n        torrents = self.list_torrents(status=TorrentStatus.DOWNLOADING)\n        if not torrents:\n            self.post_message(Notification(\n                channel=channel,\n                source=source,\n                mtype=NotificationType.Download,\n                title=\"没有正在下载的任务！\",\n                userid=userid,\n                link=settings.MP_DOMAIN('#/downloading')\n            ))\n            return\n        # 发送消息\n        title = f\"共 {len(torrents)} 个任务正在下载：\"\n        messages = []\n        index = 1\n        for torrent in torrents:\n            messages.append(f\"{index}. {torrent.title} \"\n                            f\"{StringUtils.str_filesize(torrent.size)} \"\n                            f\"{round(torrent.progress, 1)}%\")\n            index += 1\n        self.post_message(Notification(\n            channel=channel,\n            source=source,\n            mtype=NotificationType.Download,\n            title=title,\n            text=\"\\n\".join(messages),\n            userid=userid,\n            link=settings.MP_DOMAIN('#/downloading')\n        ))\n\n    def downloading(self, name: Optional[str] = None) -> List[DownloadingTorrent]:\n        \"\"\"\n        查询正在下载的任务\n        \"\"\"\n        torrents = self.list_torrents(downloader=name, status=TorrentStatus.DOWNLOADING)\n        if not torrents:\n            return []\n        ret_torrents = []\n        for torrent in torrents:\n            history = DownloadHistoryOper().get_by_hash(torrent.hash)\n            if history:\n                # 媒体信息\n                torrent.media = {\n                    \"tmdbid\": history.tmdbid,\n                    \"type\": history.type,\n                    \"title\": history.title,\n                    \"season\": history.seasons,\n                    \"episode\": history.episodes,\n                    \"image\": history.image,\n                }\n                # 下载用户\n                torrent.userid = history.userid\n                torrent.username = history.username\n            ret_torrents.append(torrent)\n        return ret_torrents\n\n    def set_downloading(self, hash_str, oper: str, name: Optional[str] = None) -> bool:\n        \"\"\"\n        控制下载任务 start/stop\n        \"\"\"\n        if oper == \"start\":\n            return self.start_torrents(hashs=[hash_str], downloader=name)\n        elif oper == \"stop\":\n            return self.stop_torrents(hashs=[hash_str], downloader=name)\n        return False\n\n    def remove_downloading(self, hash_str: str, name: Optional[str] = None) -> bool:\n        \"\"\"\n        删除下载任务\n        \"\"\"\n        return self.remove_torrents(hashs=[hash_str], downloader=name)\n\n    @eventmanager.register(EventType.DownloadFileDeleted)\n    def download_file_deleted(self, event: Event):\n        \"\"\"\n        下载文件删除时，同步删除下载任务\n        \"\"\"\n        if not event:\n            return\n        hash_str = event.event_data.get(\"hash\")\n        if not hash_str:\n            return\n        logger.warn(f\"检测到下载源文件被删除，删除下载任务（不含文件）：{hash_str}\")\n        # 先查询种子\n        torrents: List[schemas.TransferTorrent] = self.list_torrents(hashs=[hash_str])\n        if torrents:\n            self.remove_torrents(hashs=[hash_str], delete_file=False)\n            # 发出下载任务删除事件，如需处理辅种，可监听该事件\n            self.eventmanager.send_event(EventType.DownloadDeleted, {\n                \"hash\": hash_str,\n                    \"torrents\": [torrent.model_dump() for torrent in torrents]\n            })\n        else:\n            logger.info(f\"没有在下载器中查询到 {hash_str} 对应的下载任务\")\n"
  },
  {
    "path": "app/chain/media.py",
    "content": "import os\nfrom pathlib import Path\nfrom tempfile import NamedTemporaryFile\nfrom threading import Lock\nfrom typing import Optional, List, Tuple, Union\n\nfrom app import schemas\nfrom app.chain import ChainBase\nfrom app.chain.storage import StorageChain\nfrom app.core.config import settings\nfrom app.core.context import Context, MediaInfo\nfrom app.core.event import eventmanager, Event\nfrom app.core.meta import MetaBase\nfrom app.core.metainfo import MetaInfo, MetaInfoPath\nfrom app.db.systemconfig_oper import SystemConfigOper\nfrom app.log import logger\nfrom app.schemas import FileItem\nfrom app.schemas.types import ChainEventType, EventType, MediaType, \\\n    ScrapingTarget, ScrapingMetadata, ScrapingPolicy, SystemConfigKey\nfrom app.utils.mixins import ConfigReloadMixin\nfrom app.utils.singleton import Singleton\nfrom app.utils.http import RequestUtils\nfrom app.utils.string import StringUtils\n\nrecognize_lock = Lock()\nscraping_lock = Lock()\n\ncurrent_umask = os.umask(0)\nos.umask(current_umask)\n\n\nclass ScrapingOption:\n    \"\"\"刮削选项\"\"\"\n    type: ScrapingTarget = ScrapingTarget.TV\n    metadata: ScrapingMetadata = ScrapingMetadata.NFO\n    policy: ScrapingPolicy = ScrapingPolicy.MISSINGONLY\n\n    def __init__(\n        self,\n        type: Union[str, ScrapingTarget],\n        metadata: Union[str, ScrapingMetadata],\n        value: Union[ScrapingPolicy, bool, str],\n    ):\n        if isinstance(type, ScrapingTarget):\n            self.type = type\n        elif isinstance(type, str):\n            self.type = ScrapingTarget(type)\n        if isinstance(metadata, ScrapingMetadata):\n            self.metadata = metadata\n        elif isinstance(metadata, str):\n            self.metadata = ScrapingMetadata(metadata)\n        if isinstance(value, bool):\n            # 兼容旧的布尔值格式\n            self.policy = ScrapingPolicy.MISSINGONLY if value else ScrapingPolicy.SKIP\n        elif isinstance(value, ScrapingPolicy):\n            self.policy = value\n        elif isinstance(value, str):\n            self.policy = ScrapingPolicy(value)\n        else:\n            logger.error(f\"无效的刮削选项：type={type}, metadata={metadata}, value={value}\")\n\n    @property\n    def is_skip(self) -> bool:\n        \"\"\"是否跳过\"\"\"\n        return self.policy == ScrapingPolicy.SKIP\n\n    @property\n    def is_overwrite(self) -> bool:\n        \"\"\"是否覆盖模式\"\"\"\n        return self.policy == ScrapingPolicy.OVERWRITE\n\n\nclass ScrapingConfig:\n    \"\"\"媒体刮削配置\"\"\"\n\n    _policies: dict[tuple[str], ScrapingOption] = {}\n\n    def __init__(self, config_dict: dict[str, str] = None):\n        \"\"\"\n        初始化配置对象\n        :param config_dict: 用户配置字典（扁平化格式），为 None 时使用默认配置\n        \"\"\"\n        # 合并用户配置和默认配置\n        if config_dict is None:\n            config_dict = {}\n\n        # 以默认配置为基础，用用户配置覆盖\n        _config = self.get_default_config()\n        for key, value in config_dict.items():\n            _config[key] = value\n\n        for key, value in _config.items():\n            if \"_\" in key:\n                items = key.split('_', 1)\n                self._policies[tuple(items)] = ScrapingOption(*items, value)\n\n    def option(self, item: Union[str, ScrapingTarget], metadata: Union[str, ScrapingMetadata]) -> ScrapingOption:\n\n        if isinstance(item, ScrapingTarget):\n            item = item.name.lower()\n        if isinstance(metadata, ScrapingMetadata):\n            metadata = metadata.name.lower()\n\n        return self._policies.get((item, metadata), ScrapingOption(item, metadata, ScrapingPolicy.SKIP))\n\n    @classmethod\n    def from_system_config(cls) -> 'ScrapingConfig':\n        \"\"\"\n        从系统配置加载\n\n        :return: MediaScrapingConfig 实例\n        \"\"\"\n        user_config = SystemConfigOper().get(SystemConfigKey.ScrapingSwitchs) or {}\n        return cls(user_config)\n\n    @staticmethod\n    def get_default_config() -> dict[str, str]:\n        \"\"\"获取默认配置字典\"\"\"\n        config_items = [\n            f\"{mt}_{md}\"\n            for mt, mds in [\n                ('movie', ['nfo', 'poster', 'backdrop', 'logo', 'disc', 'banner', 'thumb']),\n                ('tv', ['nfo', 'poster', 'backdrop', 'logo', 'banner', 'thumb']),\n                ('season', ['nfo', 'poster', 'banner', 'thumb']),\n                ('episode', ['nfo', 'thumb'])\n            ]\n            for md in mds\n        ]\n        return {item: ScrapingPolicy.MISSINGONLY for item in config_items}\n\n\nclass MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):\n    \"\"\"\n    媒体信息处理链，单例运行\n    \"\"\"\n    CONFIG_WATCH = {SystemConfigKey.ScrapingSwitchs.value}\n\n    IMAGE_METADATA_MAP = {\n        'poster': ScrapingMetadata.POSTER,\n        'backdrop': ScrapingMetadata.BACKDROP,\n        'fanart': ScrapingMetadata.BACKDROP,\n        'background': ScrapingMetadata.BACKDROP,\n        'logo': ScrapingMetadata.LOGO,\n        'disc': ScrapingMetadata.DISC,\n        'cdart': ScrapingMetadata.DISC,\n        'banner': ScrapingMetadata.BANNER,\n        'thumb': ScrapingMetadata.THUMB,\n    }\n\n    scraping_policies = ScrapingConfig.from_system_config()\n    storagechain = StorageChain()\n\n    def on_config_changed(self):\n        self.scraping_policies = ScrapingConfig.from_system_config()\n\n    def _should_scrape(self, scraping_option: ScrapingOption, file_exists: bool, global_overwrite: bool = False) -> bool:\n        \"\"\"\n        判断是否应该执行刮削操作\n\n        :param scraping_option: 刮削选项对象\n        :param file_exists: 文件是否已存在\n        :param global_overwrite: 全局覆盖标志\n        :return bool: 是否应该刮削\n        \"\"\"\n        if scraping_option.is_skip:\n            logger.info(f\"{scraping_option.type.value} {scraping_option.metadata.value} 刮削策略 {scraping_option.policy.value}\")\n            return False\n\n        if not file_exists:\n            # 文件不存在\n            return True\n\n        # 文件存在的情况\n        if scraping_option.is_overwrite or global_overwrite:\n            logger.info(\n                f\"{scraping_option.type.value} {scraping_option.metadata.value} 文件存在，\"\n                f\"{'配置为覆盖' if scraping_option.is_overwrite else '配置为全局覆盖'}\"\n                )\n            return True\n        else:\n            logger.info(f\"{scraping_option.type.value} {scraping_option.metadata.value} 文件已存在，跳过\")\n            return False\n\n    def _save_file(self, fileitem: schemas.FileItem, path: Path, content: Union[bytes, str]):\n        \"\"\"\n        保存或上传文件\n\n        :param fileitem: 关联的媒体文件项\n        :param path: 元数据文件路径\n        :param content: 文件内容\n        \"\"\"\n        if not fileitem or not content or not path:\n            return\n        # 使用tempfile创建临时文件\n        with NamedTemporaryFile(delete=True, delete_on_close=False, suffix=path.suffix) as tmp_file:\n            tmp_file_path = Path(tmp_file.name)\n            # 写入内容\n            if isinstance(content, bytes):\n                tmp_file.write(content)\n            else:\n                tmp_file.write(content.encode('utf-8'))\n            tmp_file.flush()\n            tmp_file.close()  # 关闭文件句柄\n\n            # 刮削文件只需要读写权限\n            tmp_file_path.chmod(0o666 & ~current_umask)\n\n            # 上传文件\n            item = self.storagechain.upload_file(fileitem=fileitem, path=tmp_file_path, new_name=path.name)\n            if item:\n                logger.info(f\"已保存文件：{item.path}\")\n            else:\n                logger.warn(f\"文件保存失败：{path}\")\n\n    def _download_and_save_image(self, fileitem: schemas.FileItem, path: Path, url: str):\n        \"\"\"\n        流式下载图片并保存到文件\n\n        :param storagechain: StorageChain实例\n        :param fileitem: 关联的媒体文件项\n        :param path: 图片文件路径\n        :param url: 图片下载URL\n        \"\"\"\n        if not fileitem or not url or not path:\n            return\n        try:\n            logger.info(f\"正在下载图片：{url} ...\")\n            request_utils = RequestUtils(proxies=settings.PROXY, ua=settings.NORMAL_USER_AGENT)\n            with request_utils.get_stream(url=url) as r:\n                if r and r.status_code == 200:\n                    # 使用tempfile创建临时文件，自动删除\n                    with NamedTemporaryFile(delete=True, delete_on_close=False, suffix=path.suffix) as tmp_file:\n                        tmp_file_path = Path(tmp_file.name)\n                        # 流式写入文件\n                        for chunk in r.iter_content(chunk_size=8192):\n                            if chunk:\n                                tmp_file.write(chunk)\n                        tmp_file.flush()\n                        tmp_file.close()  # 关闭文件句柄\n\n                        # 刮削的图片只需要读写权限\n                        tmp_file_path.chmod(0o666 & ~current_umask)\n\n                        # 上传文件\n                        item = self.storagechain.upload_file(fileitem=fileitem, path=tmp_file_path,\n                                                        new_name=path.name)\n                        if item:\n                            logger.info(f\"已保存图片：{item.path}\")\n                        else:\n                            logger.warn(f\"图片保存失败：{path}\")\n                else:\n                    logger.info(f\"{url} 图片下载失败\")\n        except Exception as err:\n            logger.error(f\"{url} 图片下载失败：{str(err)}！\")\n\n    def _get_target_fileitem_and_path(self, current_fileitem: schemas.FileItem,\n                                        item_type: ScrapingTarget, metadata_type: ScrapingMetadata,\n                                        filename_hint: Optional[str] = None,\n                                        parent_fileitem: Optional[schemas.FileItem] = None\n                                        ) -> Tuple[schemas.FileItem, Optional[Path]]:\n        \"\"\"\n        根据当前上下文、刮削项类型和元数据类型生成目标 FileItem 和 Path\n        处理 NFO 和图片文件的命名约定及存储位置\n        \"\"\"\n        # 默认保存的目录是当前文件项的目录\n        target_dir_item = current_fileitem\n        target_dir_path = Path(current_fileitem.path)\n        final_filename = filename_hint # 如果提供了 filename_hint，优先使用\n\n        # 针对 NFO 文件的特殊命名和存储逻辑\n        if metadata_type == ScrapingMetadata.NFO:\n            if item_type == ScrapingTarget.MOVIE:\n                if current_fileitem.type == \"file\":\n                    # 电影文件NFO: 放在电影文件同级目录，名称与电影文件主体一致，后缀.nfo\n                    final_filename = f\"{target_dir_path.stem}.nfo\"\n                    target_dir_item = parent_fileitem or self.storagechain.get_parent_item(current_fileitem)\n                    if not target_dir_item:\n                        logger.error(f\"无法获取文件 {current_fileitem.path} 的父目录项。\")\n                        return current_fileitem, None # 返回一个表示失败的FileItem和None\n                    target_dir_path = Path(target_dir_item.path)\n                else: # current_fileitem.type == \"dir\"\n                    # 电影目录NFO (例如蓝光原盘): 放在电影目录内，名称与目录名主体一致，后缀.nfo\n                    final_filename = f\"{target_dir_path.name}.nfo\"\n                    # target_dir_item 保持为 current_fileitem\n                    # target_dir_path 保持为 Path(current_fileitem.path)\n            elif item_type == ScrapingTarget.TV:\n                # 电视剧根目录NFO: 放在剧集根目录内，命名为 tvshow.nfo\n                final_filename = \"tvshow.nfo\"\n            elif item_type == ScrapingTarget.SEASON:\n                # 电视剧季目录NFO: 放在季目录内，命名为 season.nfo\n                final_filename = \"season.nfo\"\n            elif item_type == ScrapingTarget.EPISODE:\n                # 电视剧集文件NFO: 放在集文件同级目录，名称与集文件主体一致，后缀.nfo\n                final_filename = f\"{target_dir_path.stem}.nfo\"\n                target_dir_item = parent_fileitem or self.storagechain.get_parent_item(current_fileitem)\n                if not target_dir_item:\n                    logger.error(f\"无法获取文件 {current_fileitem.path} 的父目录项。\")\n                    return current_fileitem, None# 返回一个表示失败的FileItem和None\n                target_dir_path = Path(target_dir_item.path)\n        # 图片通常是放在当前目录 (current_fileitem) 下\n        # 如果是 EPISODE 类型的图片（如thumb），通常也是放在文件同级目录，调整 target_dir_item 和 target_dir_path\n        elif metadata_type in [ScrapingMetadata.THUMB] and item_type == ScrapingTarget.EPISODE:\n            target_dir_item = parent_fileitem or self.storagechain.get_parent_item(current_fileitem)\n            if not target_dir_item:\n                logger.error(f\"无法获取文件 {current_fileitem.path} 的父目录项。\")\n                return current_fileitem, None # 返回一个表示失败的FileItem和None\n            target_dir_path = Path(target_dir_item.path)\n        # TODO: 考虑其他图片类型是否也需要保存到父目录\n\n        # 确保最终有文件名\n        if not final_filename:\n            logger.error(f\"无法为 {item_type.value} - {metadata_type.value} 确定文件名。filename_hint: {filename_hint}\")\n            # 返回一个表示失败的FileItem和None\n            return current_fileitem, None\n\n        target_full_path = target_dir_path / final_filename\n        return target_dir_item, target_full_path\n\n    def metadata_nfo(self, meta: MetaBase, mediainfo: MediaInfo,\n                     season: Optional[int] = None, episode: Optional[int] = None) -> Optional[str]:\n        \"\"\"\n        获取NFO文件内容文本\n\n        :param meta: 元数据\n        :param mediainfo: 媒体信息\n        :param season: 季号\n        :param episode: 集号\n        \"\"\"\n        return self.run_module(\"metadata_nfo\", meta=meta, mediainfo=mediainfo, season=season, episode=episode)\n\n    def select_recognize_source(self, log_name: str, log_context: str,\n                                 native_fn, plugin_fn) -> Optional[MediaInfo]:\n        \"\"\"\n        选择识别模式，插件优先或原生优先\n\n        :param log_name: 用于日志“标题：...”处的名称（如 file_path.name 或 title）\n        :param log_context: 用于日志“未识别到...的媒体信息”处的上下文（如 path 或 title）\n        :param native_fn: 原生识别函数\n        :param plugin_fn: 插件识别函数\n        \"\"\"\n        mediainfo = None\n        plugin_available = eventmanager.check(ChainEventType.NameRecognize)\n        if settings.RECOGNIZE_PLUGIN_FIRST and plugin_available:\n            # 插件优先\n            logger.info(f\"插件优先模式已开启。请求辅助识别，标题：{log_name} ...\")\n            mediainfo = plugin_fn()\n            if not mediainfo:\n                logger.info(f'辅助识别未识别到 {log_context} 的媒体信息，尝试使用原生识别')\n                mediainfo = native_fn()\n        else:\n            # 原生优先\n            logger.info(f\"插件优先模式未开启。尝试原生识别，标题：{log_name} ...\")\n            mediainfo = native_fn()\n            if not mediainfo and plugin_available:\n                logger.info(f'原生识别未识别到 {log_context} 的媒体信息，尝试使用辅助识别')\n                mediainfo = plugin_fn()\n        return mediainfo\n\n    def recognize_by_meta(self, metainfo: MetaBase, episode_group: Optional[str] = None) -> Optional[MediaInfo]:\n        \"\"\"\n        根据主副标题识别媒体信息\n        \"\"\"\n        title = metainfo.title\n         # 按 config 中设置的识别顺序识别\n        mediainfo = self.select_recognize_source(\n                        log_name=title,\n                        log_context=title,\n                        native_fn=lambda: self.recognize_media(meta=metainfo, episode_group=episode_group),\n                        plugin_fn=lambda: self.recognize_help(title=title, org_meta=metainfo)\n                    )\n        if not mediainfo:\n            logger.warn(f'{title} 未识别到媒体信息')\n            return None\n        # 识别成功\n        logger.info(f'{title} 识别到媒体信息：{mediainfo.type.value} {mediainfo.title_year}')\n        # 更新媒体图片\n        self.obtain_images(mediainfo=mediainfo)\n        # 返回上下文\n        return mediainfo\n\n    def recognize_help(self, title: str, org_meta: MetaBase) -> Optional[MediaInfo]:\n        \"\"\"\n        请求辅助识别，返回媒体信息\n\n        :param title: 标题\n        :param org_meta: 原始元数据\n        \"\"\"\n        # 发送请求事件，等待结果\n        result: Event = eventmanager.send_event(\n            ChainEventType.NameRecognize,\n            {\n                'title': title,\n            }\n        )\n        if not result:\n            return None\n        # 获取返回事件数据\n        event_data = result.event_data or {}\n        logger.info(f'获取到辅助识别结果：{event_data}')\n        # 处理数据格式\n        title, year, season_number, episode_number = None, None, None, None\n        if event_data.get(\"name\"):\n            title = str(event_data[\"name\"]).split(\"/\")[0].strip().replace(\".\", \" \")\n        if event_data.get(\"year\"):\n            year = str(event_data[\"year\"]).split(\"/\")[0].strip()\n        if event_data.get(\"season\") and str(event_data[\"season\"]).isdigit():\n            season_number = int(event_data[\"season\"])\n        if event_data.get(\"episode\") and str(event_data[\"episode\"]).isdigit():\n            episode_number = int(event_data[\"episode\"])\n        if not title:\n            return None\n        if title == 'Unknown':\n            return None\n        if not str(year).isdigit():\n            year = None\n        # 结果赋值\n        if title == org_meta.name and year == org_meta.year:\n            logger.info(f'辅助识别与原始识别结果一致，无需重新识别媒体信息')\n            return None\n        logger.info(f'辅助识别结果与原始识别结果不一致，重新匹配媒体信息 ...')\n        org_meta.name = title\n        org_meta.year = year\n        org_meta.begin_season = season_number\n        org_meta.begin_episode = episode_number\n        if org_meta.begin_season is not None or org_meta.begin_episode is not None:\n            org_meta.type = MediaType.TV\n        # 重新识别\n        return self.recognize_media(meta=org_meta)\n\n    def recognize_by_path(self, path: str, episode_group: Optional[str] = None) -> Optional[Context]:\n        \"\"\"\n        根据文件路径识别媒体信息\n        \"\"\"\n        logger.info(f'开始识别媒体信息，文件：{path} ...')\n        file_path = Path(path)\n        # 元数据\n        file_meta = MetaInfoPath(file_path)\n         # 按 config 中设置的识别顺序识别\n        mediainfo = self.select_recognize_source(\n                        log_name=file_path.name,\n                        log_context=path,\n                        native_fn=lambda: self.recognize_media(meta=file_meta, episode_group=episode_group),\n                        plugin_fn=lambda: self.recognize_help(title=path, org_meta=file_meta)\n                    )\n        if not mediainfo:\n            logger.warn(f'{path} 未识别到媒体信息')\n            return Context(meta_info=file_meta)\n        logger.info(f'{path} 识别到媒体信息：{mediainfo.type.value} {mediainfo.title_year}')\n        # 更新媒体图片\n        self.obtain_images(mediainfo=mediainfo)\n        # 返回上下文\n        return Context(meta_info=file_meta, media_info=mediainfo)\n\n    def search(self, title: str) -> Tuple[Optional[MetaBase], List[MediaInfo]]:\n        \"\"\"\n        搜索媒体/人物信息\n\n        :param title: 搜索内容\n        :return: 识别元数据，媒体信息列表\n        \"\"\"\n        # 提取要素\n        mtype, key_word, season_num, episode_num, year, content = StringUtils.get_keyword(title)\n        # 识别\n        meta = MetaInfo(content)\n        if not meta.name:\n            meta.cn_name = content\n        # 合并信息\n        if mtype:\n            meta.type = mtype\n        if season_num:\n            meta.begin_season = season_num\n        if episode_num:\n            meta.begin_episode = episode_num\n        if year:\n            meta.year = year\n        # 开始搜索\n        logger.info(f\"开始搜索媒体信息：{meta.name}\")\n        medias: Optional[List[MediaInfo]] = self.search_medias(meta=meta)\n        if not medias:\n            logger.warn(f\"{meta.name} 没有找到对应的媒体信息！\")\n            return meta, []\n        logger.info(f\"{content} 搜索到 {len(medias)} 条相关媒体信息\")\n        # 识别的元数据，媒体信息列表\n        return meta, medias\n\n    def get_tmdbinfo_by_doubanid(self, doubanid: str, mtype: MediaType = None) -> Optional[dict]:\n        \"\"\"\n        根据豆瓣ID获取TMDB信息\n        \"\"\"\n        tmdbinfo = None\n        doubaninfo = self.douban_info(doubanid=doubanid, mtype=mtype)\n        if doubaninfo:\n            # 优先使用原标题匹配\n            if doubaninfo.get(\"original_title\"):\n                meta = MetaInfo(title=doubaninfo.get(\"title\"))\n                meta_org = MetaInfo(title=doubaninfo.get(\"original_title\"))\n            else:\n                meta_org = meta = MetaInfo(title=doubaninfo.get(\"title\"))\n            # 年份\n            if doubaninfo.get(\"year\"):\n                meta.year = doubaninfo.get(\"year\")\n            # 处理类型\n            if isinstance(doubaninfo.get('media_type'), MediaType):\n                meta.type = doubaninfo.get('media_type')\n            else:\n                meta.type = MediaType.MOVIE if doubaninfo.get(\"type\") == \"movie\" else MediaType.TV\n            # 匹配TMDB信息\n            meta_names = list(dict.fromkeys([k for k in [meta_org.name,\n                                                         meta.cn_name,\n                                                         meta.en_name] if k]))\n            tmdbinfo = self._match_tmdb_with_names(\n                meta_names=meta_names,\n                year=meta.year,\n                mtype=mtype or meta.type,\n                season=meta.begin_season\n            )\n            if tmdbinfo:\n                # 合季季后返回\n                tmdbinfo['season'] = meta.begin_season\n        return tmdbinfo\n\n    def get_tmdbinfo_by_bangumiid(self, bangumiid: int) -> Optional[dict]:\n        \"\"\"\n        根据BangumiID获取TMDB信息\n        \"\"\"\n        bangumiinfo = self.bangumi_info(bangumiid=bangumiid)\n        if bangumiinfo:\n            # 优先使用原标题匹配\n            if bangumiinfo.get(\"name_cn\"):\n                meta = MetaInfo(title=bangumiinfo.get(\"name\"))\n                meta_cn = MetaInfo(title=bangumiinfo.get(\"name_cn\"))\n            else:\n                meta_cn = meta = MetaInfo(title=bangumiinfo.get(\"name\"))\n            # 年份\n            year = self._extract_year_from_bangumi(bangumiinfo)\n            # 识别TMDB媒体信息\n            meta_names = list(dict.fromkeys([k for k in [meta_cn.name,\n                                                         meta.name] if k]))\n            tmdbinfo = self._match_tmdb_with_names(\n                meta_names=meta_names,\n                year=year,\n                mtype=MediaType.TV,\n                season=meta.begin_season\n            )\n            return tmdbinfo\n        return None\n\n    def get_doubaninfo_by_tmdbid(self, tmdbid: int,\n                                 mtype: MediaType = None, season: Optional[int] = None) -> Optional[dict]:\n        \"\"\"\n        根据TMDBID获取豆瓣信息\n        \"\"\"\n        tmdbinfo = self.tmdb_info(tmdbid=tmdbid, mtype=mtype)\n        if tmdbinfo:\n            # 名称\n            name = tmdbinfo.get(\"title\") or tmdbinfo.get(\"name\")\n            # 年份\n            year = self._extract_year_from_tmdb(tmdbinfo, season)\n            # IMDBID\n            imdbid = tmdbinfo.get(\"external_ids\", {}).get(\"imdb_id\")\n            return self.match_doubaninfo(\n                name=name,\n                year=year,\n                mtype=mtype,\n                imdbid=imdbid\n            )\n        return None\n\n    def get_doubaninfo_by_bangumiid(self, bangumiid: int) -> Optional[dict]:\n        \"\"\"\n        根据BangumiID获取豆瓣信息\n        \"\"\"\n        bangumiinfo = self.bangumi_info(bangumiid=bangumiid)\n        if bangumiinfo:\n            # 优先使用中文标题匹配\n            if bangumiinfo.get(\"name_cn\"):\n                meta = MetaInfo(title=bangumiinfo.get(\"name_cn\"))\n            else:\n                meta = MetaInfo(title=bangumiinfo.get(\"name\"))\n            # 年份\n            year = self._extract_year_from_bangumi(bangumiinfo)\n            # 使用名称识别豆瓣媒体信息\n            return self.match_doubaninfo(\n                name=meta.name,\n                year=year,\n                mtype=MediaType.TV,\n                season=meta.begin_season\n            )\n        return None\n\n    @eventmanager.register(EventType.MetadataScrape)\n    def scrape_metadata_event(self, event: Event):\n        \"\"\"\n        监控手动刮削事件\n        \"\"\"\n        if not event:\n            return\n        event_data = event.event_data or {}\n        # 媒体根目录\n        fileitem: FileItem = event_data.get(\"fileitem\")\n        # 媒体文件列表\n        file_list: List[str] = event_data.get(\"file_list\", [])\n        # 媒体元数据\n        meta: MetaBase = event_data.get(\"meta\")\n        # 媒体信息\n        mediainfo: MediaInfo = event_data.get(\"mediainfo\")\n        # 是否覆盖\n        overwrite = event_data.get(\"overwrite\", False)\n        # 检查媒体根目录\n        if not fileitem:\n            return\n\n        # 刮削锁\n        with scraping_lock:\n            # 检查文件项是否存在\n            if not self.storagechain.get_item(fileitem):\n                logger.warn(f\"文件项不存在：{fileitem.path}\")\n                return\n            # 检查是否为目录\n            if fileitem.type == \"file\":\n                # 单个文件刮削\n                self.scrape_metadata(fileitem=fileitem,\n                                     mediainfo=mediainfo,\n                                     init_folder=False,\n                                     parent=self.storagechain.get_parent_item(fileitem),\n                                     overwrite=overwrite)\n            else:\n                if file_list:\n                    # 如果是BDMV原盘目录，只对根目录进行刮削，不处理子目录\n                    if self.storagechain.is_bluray_folder(fileitem):\n                        logger.info(f\"检测到BDMV原盘目录，只对根目录进行刮削：{fileitem.path}\")\n                        self.scrape_metadata(fileitem=fileitem,\n                                             mediainfo=mediainfo,\n                                             init_folder=True,\n                                             recursive=False,\n                                             overwrite=overwrite)\n                    else:\n                        # 1. 收集fileitem和file_list中每个文件之间所有子目录\n                        all_dirs = set()\n                        root_path = Path(fileitem.path)\n\n                        logger.debug(f\"开始收集目录，根目录：{root_path}\")\n                        # 收集根目录\n                        all_dirs.add(root_path)\n\n                        # 收集所有目录（包括所有层级）\n                        for sub_file in file_list:\n                            sub_path = Path(sub_file)\n                            # 收集从根目录到文件的所有父目录\n                            current_path = sub_path.parent\n                            while current_path != root_path and current_path.is_relative_to(root_path):\n                                all_dirs.add(current_path)\n                                current_path = current_path.parent\n\n                        logger.debug(f\"共收集到 {len(all_dirs)} 个目录\")\n\n                        # 2. 初始化一遍子目录，但不处理文件\n                        for sub_dir in all_dirs:\n                            sub_dir_item = self.storagechain.get_file_item(storage=fileitem.storage, path=sub_dir)\n                            if sub_dir_item:\n                                logger.info(f\"为目录生成海报和nfo：{sub_dir}\")\n                                # 初始化目录元数据，但不处理文件\n                                self.scrape_metadata(fileitem=sub_dir_item,\n                                                     mediainfo=mediainfo,\n                                                     init_folder=True,\n                                                     recursive=False,\n                                                     overwrite=overwrite)\n                            else:\n                                logger.warn(f\"无法获取目录项：{sub_dir}\")\n\n                        # 3. 刮削每个文件\n                        logger.info(f\"开始刮削 {len(file_list)} 个文件\")\n                        for sub_file_path in file_list:\n                            sub_file_item = self.storagechain.get_file_item(storage=fileitem.storage,\n                                                                       path=Path(sub_file_path))\n                            if sub_file_item:\n                                self.scrape_metadata(fileitem=sub_file_item,\n                                                     mediainfo=mediainfo,\n                                                     init_folder=False,\n                                                     overwrite=overwrite)\n                            else:\n                                logger.warn(f\"无法获取文件项：{sub_file_path}\")\n                else:\n                    # 执行全量刮削\n                    logger.info(f\"开始刮削目录 {fileitem.path} ...\")\n                    self.scrape_metadata(fileitem=fileitem, meta=meta, init_folder=True,\n                                         mediainfo=mediainfo, overwrite=overwrite)\n\n    def _scrape_nfo_generic(self, current_fileitem: schemas.FileItem,\n                             meta: MetaBase, mediainfo: MediaInfo,\n                             item_type: ScrapingTarget,\n                             parent_fileitem: Optional[schemas.FileItem] = None,\n                             overwrite: bool = False,\n                             season_number: Optional[int] = None,\n                             episode_number: Optional[int] = None):\n        \"\"\"\n        NFO 刮削\n        \"\"\"\n        # 获取刮削选项\n        nfo_option = self.scraping_policies.option(item_type, ScrapingMetadata.NFO)\n\n        # 检查刮削开关\n        if nfo_option.is_skip:\n            logger.info(f\"{item_type.value} {ScrapingMetadata.NFO.value} 刮削策略 {nfo_option.policy.value}\")\n            return\n\n        # 获取目标 FileItem (`base_item`) 和 Path (`nfo_path`)\n        base_item, nfo_path = self._get_target_fileitem_and_path(\n            current_fileitem=current_fileitem,\n            item_type=item_type,\n            metadata_type=ScrapingMetadata.NFO,\n            parent_fileitem=parent_fileitem\n        )\n\n        if not nfo_path: # _get_target_fileitem_and_path 内部错误处理返回None\n            return\n\n        # 文件存在检查\n        file_exists = self.storagechain.get_file_item(storage=base_item.storage, path=nfo_path)\n\n        # 刮削决策\n        if self._should_scrape(nfo_option, bool(file_exists), overwrite):\n            # 生成 NFO 内容\n            nfo_content = self.metadata_nfo(meta=meta, mediainfo=mediainfo,\n                                            season=season_number, episode=episode_number)\n            if nfo_content:\n                self._save_file(fileitem=base_item, path=nfo_path, content=nfo_content)\n            else:\n                logger.warn(f\"{nfo_path.name} NFO 文件生成失败！\")\n\n    def _scrape_images_generic(self, current_fileitem: schemas.FileItem,\n                               mediainfo: MediaInfo,\n                               item_type: ScrapingTarget,\n                               parent_fileitem: Optional[schemas.FileItem] = None,\n                               overwrite: bool = False,\n                               season_number: Optional[int] = None):\n        \"\"\"\n        图片刮削\n        \"\"\"\n        # 获取图片 URL\n        if item_type == ScrapingTarget.SEASON and season_number is not None:\n            image_dict = self.metadata_img(mediainfo=mediainfo, season=season_number)\n        else:\n            image_dict = self.metadata_img(mediainfo=mediainfo)\n\n        if not image_dict:\n            logger.info(f\"未获取到 {item_type.value} 的图片信息，跳过图片刮削。\")\n            return\n\n        # 遍历图片 image_name 和 image_url\n        for image_name, image_url in image_dict.items():\n            metadata_type = None\n            # 对每个 image_name 查找匹配的 ScrapingMetadata\n            for keyword, meta_type in self.IMAGE_METADATA_MAP.items():\n                if keyword in image_name.lower():\n                    metadata_type = meta_type\n                    break\n\n            if metadata_type:\n                # 获取对应的 ScrapingOption\n                option = self.scraping_policies.option(item_type, metadata_type)\n\n                if option.is_skip:\n                    logger.info(f\"{item_type.value} {option.metadata.value} 刮削策略 {option.policy.value}\")\n                    continue\n\n                # 判断是否匹配当前刮削的季号\n                if item_type == ScrapingTarget.TV and image_name.lower().startswith(\"season\"):\n                    logger.info(f\"当前为电视剧根目录刮削，跳过季图片：{image_name}\")\n                    continue\n                if item_type == ScrapingTarget.SEASON and season_number is not None and image_name.lower().startswith(\"season\"):\n                    # 检查是否只下载当前刮削季的图片\n                    image_season_str = \"00\" if \"specials\" in image_name.lower() else image_name[6:8]\n\n                    if image_season_str is not None and image_season_str != str(season_number).rjust(2, '0'):\n                        logger.info(f\"当前刮削季为：{season_number}，跳过非本季图片：{image_name}\")\n                        continue\n\n                # 获取目标 FileItem (`base_item`) 和 Path (`image_path`)\n                base_item, image_path = self._get_target_fileitem_and_path(\n                    current_fileitem=current_fileitem,\n                    item_type=item_type,\n                    metadata_type=metadata_type,\n                    filename_hint=image_name,\n                    parent_fileitem=parent_fileitem\n                )\n\n                if not image_path:\n                    continue\n\n                # 文件存在检查\n                file_exists = self.storagechain.get_file_item(storage=base_item.storage, path=image_path)\n\n                # 刮削决策\n                if self._should_scrape(option, bool(file_exists), overwrite):\n                    self._download_and_save_image(fileitem=base_item, path=image_path, url=image_url)\n            else:\n                logger.debug(f\"未找到图片类型 {image_name} 对应的 ScrapingMetadata，跳过。\")\n\n    def scrape_metadata(self, fileitem: schemas.FileItem,\n                        meta: MetaBase = None, mediainfo: MediaInfo = None,\n                        init_folder: bool = True, parent: schemas.FileItem = None,\n                        overwrite: bool = False, recursive: bool = True):\n        \"\"\"\n        手动刮削媒体信息\n\n        :param fileitem: 刮削目录或文件\n        :param meta: 元数据\n        :param mediainfo: 媒体信息\n        :param init_folder: 是否刮削根目录\n        :param parent: 上级目录\n        :param overwrite: 是否覆盖已有文件\n        :param recursive: 是否递归处理目录内文件\n        \"\"\"\n        if not fileitem:\n            return\n\n        # 当前文件路径\n        filepath = Path(fileitem.path)\n        if fileitem.type == \"file\" \\\n                and (not filepath.suffix or filepath.suffix.lower() not in settings.RMT_MEDIAEXT):\n            return\n\n        # 准备元数据和媒体信息\n        if not meta:\n            meta = MetaInfoPath(filepath)\n        if not mediainfo:\n            mediainfo = self.recognize_by_meta(meta)\n        if not mediainfo:\n            logger.warn(f\"{filepath} 无法识别文件媒体信息！\")\n            return\n\n        logger.info(f\"开始刮削：{filepath} ...\")\n\n        # 根据媒体类型分发处理逻辑\n        if mediainfo.type == MediaType.MOVIE:\n            self._handle_movie_scraping(\n                fileitem=fileitem,\n                meta=meta,\n                mediainfo=mediainfo,\n                init_folder=init_folder,\n                parent=parent,\n                overwrite=overwrite,\n                recursive=recursive\n            )\n        else:\n            self._handle_tv_scraping(\n                fileitem=fileitem,\n                meta=meta,\n                mediainfo=mediainfo,\n                init_folder=init_folder,\n                parent=parent,\n                overwrite=overwrite,\n                recursive=recursive\n            )\n\n        logger.info(f\"{filepath.name} 刮削完成\")\n\n    def _handle_movie_scraping(self, fileitem: schemas.FileItem,\n                               meta: MetaBase, mediainfo: MediaInfo,\n                               init_folder: bool, parent: schemas.FileItem,\n                               overwrite: bool, recursive: bool):\n        \"\"\"\n        处理电影刮削\n        \"\"\"\n        if fileitem.type == \"file\":\n            # 电影文件：仅处理 NFO\n            self._scrape_nfo_generic(\n                current_fileitem=fileitem,\n                meta=meta,\n                mediainfo=mediainfo,\n                item_type=ScrapingTarget.MOVIE,\n                parent_fileitem=parent,\n                overwrite=overwrite\n            )\n        else:\n            # 电影目录：递归处理文件并初始化目录\n            self._handle_movie_directory(\n                fileitem=fileitem,\n                meta=meta,\n                mediainfo=mediainfo,\n                init_folder=init_folder,\n                parent=parent,\n                overwrite=overwrite,\n                recursive=recursive\n            )\n\n    def _handle_movie_directory(self, fileitem: schemas.FileItem,\n                                meta: MetaBase, mediainfo: MediaInfo,\n                                init_folder: bool, parent: schemas.FileItem,\n                                overwrite: bool, recursive: bool):\n        \"\"\"\n        处理电影目录刮削\n        \"\"\"\n        files = self.storagechain.list_files(fileitem=fileitem) or []\n        is_bluray_folder = self.storagechain.contains_bluray_subdirectories(files)\n\n        # 递归处理文件（非蓝光原盘）\n        if recursive and not is_bluray_folder:\n            for file in files:\n                if file.type == \"dir\":\n                    continue\n                self.scrape_metadata(fileitem=file,\n                                     mediainfo=mediainfo,\n                                     init_folder=False,\n                                     parent=fileitem,\n                                     overwrite=overwrite)\n\n        # 初始化目录元数据\n        if init_folder:\n            if is_bluray_folder:\n                # 蓝光原盘目录：仅处理 NFO\n                self._scrape_nfo_generic(\n                    current_fileitem=fileitem,\n                    meta=meta,\n                    mediainfo=mediainfo,\n                    item_type=ScrapingTarget.MOVIE,\n                    overwrite=overwrite\n                )\n            # 电影目录：处理图片\n            self._scrape_images_generic(\n                current_fileitem=fileitem,\n                mediainfo=mediainfo,\n                item_type=ScrapingTarget.MOVIE,\n                overwrite=overwrite\n            )\n\n    def _handle_tv_scraping(self, fileitem: schemas.FileItem,\n                            meta: MetaBase, mediainfo: MediaInfo,\n                            init_folder: bool, parent: schemas.FileItem,\n                            overwrite: bool, recursive: bool):\n        \"\"\"\n        处理电视剧刮削\n        \"\"\"\n        filepath = Path(fileitem.path)\n\n        if fileitem.type == \"file\":\n            # 电视剧集文件：重新识别季集信息并刮削\n            self._handle_tv_episode_file(\n                fileitem=fileitem,\n                filepath=filepath,\n                mediainfo=mediainfo,\n                parent=parent,\n                overwrite=overwrite\n            )\n        else:\n            # 电视剧目录：递归处理并初始化目录\n            self._handle_tv_directory(\n                fileitem=fileitem,\n                filepath=filepath,\n                meta=meta,\n                mediainfo=mediainfo,\n                init_folder=init_folder,\n                parent=parent,\n                overwrite=overwrite,\n                recursive=recursive\n            )\n\n    def _handle_tv_episode_file(self, fileitem: schemas.FileItem,\n                                filepath: Path,\n                                mediainfo: MediaInfo,\n                                parent: schemas.FileItem,\n                                overwrite: bool):\n        \"\"\"\n        处理电视剧集文件刮削\n        \"\"\"\n        # 重新识别季集信息\n        file_meta = MetaInfoPath(filepath)\n        if not file_meta.begin_episode:\n            logger.warn(f\"{filepath.name} 无法识别文件集数！\")\n            return\n\n        file_mediainfo = self.recognize_media(meta=file_meta, tmdbid=mediainfo.tmdb_id,\n                                              episode_group=mediainfo.episode_group)\n        if not file_mediainfo:\n            logger.warn(f\"{filepath.name} 无法识别文件媒体信息！\")\n            return\n\n        # 处理 NFO\n        self._scrape_nfo_generic(\n            current_fileitem=fileitem,\n            meta=file_meta,\n            mediainfo=file_mediainfo,\n            item_type=ScrapingTarget.EPISODE,\n            parent_fileitem=parent,\n            overwrite=overwrite,\n            season_number=file_meta.begin_season,\n            episode_number=file_meta.begin_episode\n        )\n\n        # 处理图片\n        self._scrape_images_generic(\n            current_fileitem=fileitem,\n            mediainfo=file_mediainfo,\n            item_type=ScrapingTarget.EPISODE,\n            parent_fileitem=parent,\n            overwrite=overwrite,\n            season_number=file_meta.begin_season\n        )\n\n    def _handle_tv_directory(self, fileitem: schemas.FileItem,\n                             filepath: Path,\n                             meta: MetaBase, mediainfo: MediaInfo,\n                             init_folder: bool, parent: schemas.FileItem,\n                             overwrite: bool, recursive: bool):\n        \"\"\"\n        处理电视剧目录刮削\n        \"\"\"\n        # 递归处理子目录和文件\n        if recursive:\n            files = self.storagechain.list_files(fileitem=fileitem) or []\n            for file in files:\n                if (\n                    file.type == \"dir\"\n                    and file.name not in settings.RENAME_FORMAT_S0_NAMES\n                    and MetaInfo(file.name).begin_season is None\n                ):\n                    # 电视剧不处理非季子目录\n                    continue\n                self.scrape_metadata(fileitem=file,\n                                     mediainfo=mediainfo,\n                                     parent=fileitem if file.type == \"file\" else None,\n                                     init_folder=True if file.type == \"dir\" else False,\n                                     overwrite=overwrite)\n\n        # 初始化目录元数据\n        if init_folder:\n            self._initialize_tv_directory_metadata(\n                fileitem=fileitem,\n                filepath=filepath,\n                meta=meta,\n                mediainfo=mediainfo,\n                parent=parent,\n                overwrite=overwrite\n            )\n\n    def _initialize_tv_directory_metadata(self, fileitem: schemas.FileItem,\n                                          filepath: Path,\n                                          meta: MetaBase, mediainfo: MediaInfo,\n                                          parent: schemas.FileItem,\n                                          overwrite: bool):\n        \"\"\"\n        初始化电视剧目录元数据（识别季号并刮削）\n        \"\"\"\n        # 识别文件夹名称\n        season_meta = MetaInfo(filepath.name)\n\n        # 特殊季目录处理（Specials/SPs）\n        if filepath.name in settings.RENAME_FORMAT_S0_NAMES:\n            season_meta.begin_season = 0\n        elif season_meta.name and season_meta.begin_season is not None:\n            # 排除辅助词重新识别，避免误判根目录 (issue https://github.com/jxxghp/MoviePilot/issues/5501)\n            season_meta_no_custom = MetaInfo(filepath.name, custom_words=[\"#\"])\n            if season_meta_no_custom.begin_season is None:\n                # 季号由辅助词指定，按剧集根目录处理 (issue https://github.com/jxxghp/MoviePilot/issues/5373)\n                season_meta.begin_season = None\n\n        # 根据季号判断目录类型并刮削\n        if season_meta.begin_season is not None:\n            # 季目录：处理季 NFO 和图片\n            self._scrape_nfo_generic(\n                current_fileitem=fileitem,\n                meta=meta,\n                mediainfo=mediainfo,\n                item_type=ScrapingTarget.SEASON,\n                overwrite=overwrite,\n                season_number=season_meta.begin_season\n            )\n            self._scrape_images_generic(\n                current_fileitem=fileitem,\n                mediainfo=mediainfo,\n                item_type=ScrapingTarget.SEASON,\n                parent_fileitem=parent,\n                overwrite=overwrite,\n                season_number=season_meta.begin_season\n            )\n        elif season_meta.name:\n            # 剧集根目录：处理电视剧 NFO 和图片\n            self._scrape_nfo_generic(\n                current_fileitem=fileitem,\n                meta=meta,\n                mediainfo=mediainfo,\n                item_type=ScrapingTarget.TV,\n                overwrite=overwrite\n            )\n            self._scrape_images_generic(\n                current_fileitem=fileitem,\n                mediainfo=mediainfo,\n                item_type=ScrapingTarget.TV,\n                overwrite=overwrite\n            )\n        else:\n            logger.warn(\"无法识别元数据，跳过\")\n\n    async def async_select_recognize_source(self, log_name: str, log_context: str,\n                                             native_fn, plugin_fn) -> Optional[MediaInfo]:\n        \"\"\"\n        选择识别模式，插件优先或原生优先（异步版本）\n\n        :param log_name: 用于日志“标题：...”处的名称（如 file_path.name 或 title）\n        :param log_context: 用于日志“未识别到...的媒体信息”处的上下文（如 path 或 title）\n        :param native_fn: 原生识别函数\n        :param plugin_fn: 插件识别函数\n        \"\"\"\n        mediainfo = None\n        plugin_available = eventmanager.check(ChainEventType.NameRecognize)\n        if settings.RECOGNIZE_PLUGIN_FIRST and plugin_available:\n            # 插件优先\n            logger.info(f\"插件优先模式已开启。请求辅助识别，标题：{log_name} ...\")\n            mediainfo = await plugin_fn()\n            if not mediainfo:\n                logger.info(f'辅助识别未识别到 {log_context} 的媒体信息，尝试使用原生识别')\n                mediainfo = await native_fn()\n        else:\n            # 原生优先\n            logger.info(f\"插件优先模式未开启。尝试原生识别，标题：{log_name} ...\")\n            mediainfo = await native_fn()\n            if not mediainfo and plugin_available:\n                logger.info(f'原生识别未识别到 {log_context} 的媒体信息，尝试使用辅助识别')\n                mediainfo = await plugin_fn()\n        return mediainfo\n\n    async def async_recognize_by_meta(self, metainfo: MetaBase,\n                                      episode_group: Optional[str] = None) -> Optional[MediaInfo]:\n        \"\"\"\n        根据主副标题识别媒体信息（异步版本）\n        \"\"\"\n        title = metainfo.title\n        # 定义识别函数\n        async def native_recognize():\n            return await self.async_recognize_media(meta=metainfo, episode_group=episode_group)\n        async def plugin_recognize():\n            return await self.async_recognize_help(title=title, org_meta=metainfo)\n        # 按 config 中设置的识别顺序识别\n        mediainfo = await self.async_select_recognize_source(\n                              log_name=title,\n                              log_context=title,\n                              native_fn=native_recognize,\n                              plugin_fn=plugin_recognize\n                          )\n        if not mediainfo:\n            logger.warn(f'{title} 未识别到媒体信息')\n            return None\n        # 识别成功\n        logger.info(f'{title} 识别到媒体信息：{mediainfo.type.value} {mediainfo.title_year}')\n        # 更新媒体图片\n        await self.async_obtain_images(mediainfo=mediainfo)\n        # 返回上下文\n        return mediainfo\n\n    async def async_recognize_help(self, title: str, org_meta: MetaBase) -> Optional[MediaInfo]:\n        \"\"\"\n        请求辅助识别，返回媒体信息（异步版本）\n\n        :param title: 标题\n        :param org_meta: 原始元数据\n        \"\"\"\n        # 发送请求事件，等待结果\n        result: Event = await eventmanager.async_send_event(\n            ChainEventType.NameRecognize,\n            {\n                'title': title,\n            }\n        )\n        if not result:\n            return None\n        # 获取返回事件数据\n        event_data = result.event_data or {}\n        logger.info(f'获取到辅助识别结果：{event_data}')\n        # 处理数据格式\n        title, year, season_number, episode_number = None, None, None, None\n        if event_data.get(\"name\"):\n            title = str(event_data[\"name\"]).split(\"/\")[0].strip().replace(\".\", \" \")\n        if event_data.get(\"year\"):\n            year = str(event_data[\"year\"]).split(\"/\")[0].strip()\n        if event_data.get(\"season\") and str(event_data[\"season\"]).isdigit():\n            season_number = int(event_data[\"season\"])\n        if event_data.get(\"episode\") and str(event_data[\"episode\"]).isdigit():\n            episode_number = int(event_data[\"episode\"])\n        if not title:\n            return None\n        if title == 'Unknown':\n            return None\n        if not str(year).isdigit():\n            year = None\n        # 结果赋值\n        if title == org_meta.name and year == org_meta.year:\n            logger.info(f'辅助识别与原始识别结果一致，无需重新识别媒体信息')\n            return None\n        logger.info(f'辅助识别结果与原始识别结果不一致，重新匹配媒体信息 ...')\n        org_meta.name = title\n        org_meta.year = year\n        org_meta.begin_season = season_number\n        org_meta.begin_episode = episode_number\n        if org_meta.begin_season or org_meta.begin_episode:\n            org_meta.type = MediaType.TV\n        # 重新识别\n        return await self.async_recognize_media(meta=org_meta)\n\n    async def async_recognize_by_path(self, path: str, episode_group: Optional[str] = None) -> Optional[Context]:\n        \"\"\"\n        根据文件路径识别媒体信息（异步版本）\n        \"\"\"\n        logger.info(f'开始识别媒体信息，文件：{path} ...')\n        file_path = Path(path)\n        # 元数据\n        file_meta = MetaInfoPath(file_path)\n        # 定义识别函数\n        async def native_recognize():\n            return await self.async_recognize_media(meta=file_meta, episode_group=episode_group)\n        async def plugin_recognize():\n            return await self.async_recognize_help(title=path, org_meta=file_meta)\n        # 按 config 中设置的识别顺序识别\n        mediainfo = await self.async_select_recognize_source(\n                              log_name=file_path.name,\n                              log_context=path,\n                              native_fn=native_recognize,\n                              plugin_fn=plugin_recognize\n                          )\n        if not mediainfo:\n            logger.warn(f'{path} 未识别到媒体信息')\n            return Context(meta_info=file_meta)\n        logger.info(f'{path} 识别到媒体信息：{mediainfo.type.value} {mediainfo.title_year}')\n        # 更新媒体图片\n        await self.async_obtain_images(mediainfo=mediainfo)\n        # 返回上下文\n        return Context(meta_info=file_meta, media_info=mediainfo)\n\n    async def async_search(self, title: str) -> Tuple[Optional[MetaBase], List[MediaInfo]]:\n        \"\"\"\n        搜索媒体/人物信息（异步版本）\n\n        :param title: 搜索内容\n        :return: 识别元数据，媒体信息列表\n        \"\"\"\n        # 提取要素\n        mtype, key_word, season_num, episode_num, year, content = StringUtils.get_keyword(title)\n        # 识别\n        meta = MetaInfo(content)\n        if not meta.name:\n            meta.cn_name = content\n        # 合并信息\n        if mtype:\n            meta.type = mtype\n        if season_num:\n            meta.begin_season = season_num\n        if episode_num:\n            meta.begin_episode = episode_num\n        if year:\n            meta.year = year\n        # 开始搜索\n        logger.info(f\"开始搜索媒体信息：{meta.name}\")\n        medias: Optional[List[MediaInfo]] = await self.async_search_medias(meta=meta)\n        if not medias:\n            logger.warn(f\"{meta.name} 没有找到对应的媒体信息！\")\n            return meta, []\n        logger.info(f\"{content} 搜索到 {len(medias)} 条相关媒体信息\")\n        # 识别的元数据，媒体信息列表\n        return meta, medias\n\n    @staticmethod\n    def _extract_year_from_bangumi(bangumiinfo: dict) -> Optional[str]:\n        \"\"\"\n        从Bangumi信息中提取年份\n        \"\"\"\n        release_date = bangumiinfo.get(\"date\") or bangumiinfo.get(\"air_date\")\n        if release_date:\n            return release_date[:4]\n        return None\n\n    @staticmethod\n    def _extract_year_from_tmdb(tmdbinfo: dict, season: Optional[int] = None) -> Optional[str]:\n        \"\"\"\n        从TMDB信息中提取年份\n        \"\"\"\n        year = None\n        if tmdbinfo.get('release_date'):\n            year = tmdbinfo['release_date'][:4]\n        elif tmdbinfo.get('seasons') and season is not None:\n            for seainfo in tmdbinfo['seasons']:\n                season_number = seainfo.get(\"season_number\")\n                if season_number is None:\n                    continue\n                air_date = seainfo.get(\"air_date\")\n                if air_date and season_number == season:\n                    year = air_date[:4]\n                    break\n        return year\n\n    def _match_tmdb_with_names(self, meta_names: list, year: Optional[str],\n                               mtype: MediaType, season: Optional[int] = None) -> Optional[dict]:\n        \"\"\"\n        使用名称列表匹配TMDB信息\n        \"\"\"\n        for name in meta_names:\n            tmdbinfo = self.match_tmdbinfo(\n                name=name,\n                year=year,\n                mtype=mtype,\n                season=season\n            )\n            if tmdbinfo:\n                return tmdbinfo\n        return None\n\n    async def _async_match_tmdb_with_names(self, meta_names: list, year: Optional[str],\n                                           mtype: MediaType, season: Optional[int] = None) -> Optional[dict]:\n        \"\"\"\n        使用名称列表匹配TMDB信息（异步版本）\n        \"\"\"\n        for name in meta_names:\n            tmdbinfo = await self.async_match_tmdbinfo(\n                name=name,\n                year=year,\n                mtype=mtype,\n                season=season\n            )\n            if tmdbinfo:\n                return tmdbinfo\n        return None\n\n    async def async_get_tmdbinfo_by_doubanid(self, doubanid: str, mtype: MediaType = None) -> Optional[dict]:\n        \"\"\"\n        根据豆瓣ID获取TMDB信息（异步版本）\n        \"\"\"\n        tmdbinfo = None\n        doubaninfo = await self.async_douban_info(doubanid=doubanid, mtype=mtype)\n        if doubaninfo:\n            # 优先使用原标题匹配\n            if doubaninfo.get(\"original_title\"):\n                meta = MetaInfo(title=doubaninfo.get(\"title\"))\n                meta_org = MetaInfo(title=doubaninfo.get(\"original_title\"))\n            else:\n                meta_org = meta = MetaInfo(title=doubaninfo.get(\"title\"))\n            # 年份\n            if doubaninfo.get(\"year\"):\n                meta.year = doubaninfo.get(\"year\")\n            # 处理类型\n            if isinstance(doubaninfo.get('media_type'), MediaType):\n                meta.type = doubaninfo.get('media_type')\n            else:\n                meta.type = MediaType.MOVIE if doubaninfo.get(\"type\") == \"movie\" else MediaType.TV\n            # 匹配TMDB信息\n            meta_names = list(dict.fromkeys([k for k in [meta_org.name,\n                                                         meta.cn_name,\n                                                         meta.en_name] if k]))\n            tmdbinfo = await self._async_match_tmdb_with_names(\n                meta_names=meta_names,\n                year=meta.year,\n                mtype=mtype or meta.type,\n                season=meta.begin_season\n            )\n            if tmdbinfo:\n                # 合季季后返回\n                tmdbinfo['season'] = meta.begin_season\n        return tmdbinfo\n\n    async def async_get_tmdbinfo_by_bangumiid(self, bangumiid: int) -> Optional[dict]:\n        \"\"\"\n        根据BangumiID获取TMDB信息（异步版本）\n        \"\"\"\n        bangumiinfo = await self.async_bangumi_info(bangumiid=bangumiid)\n        if bangumiinfo:\n            # 优先使用原标题匹配\n            if bangumiinfo.get(\"name_cn\"):\n                meta = MetaInfo(title=bangumiinfo.get(\"name\"))\n                meta_cn = MetaInfo(title=bangumiinfo.get(\"name_cn\"))\n            else:\n                meta_cn = meta = MetaInfo(title=bangumiinfo.get(\"name\"))\n            # 年份\n            year = self._extract_year_from_bangumi(bangumiinfo)\n            # 识别TMDB媒体信息\n            meta_names = list(dict.fromkeys([k for k in [meta_cn.name,\n                                                         meta.name] if k]))\n            tmdbinfo = await self._async_match_tmdb_with_names(\n                meta_names=meta_names,\n                year=year,\n                mtype=MediaType.TV,\n                season=meta.begin_season\n            )\n            return tmdbinfo\n        return None\n\n    async def async_get_doubaninfo_by_tmdbid(self, tmdbid: int, mtype: MediaType = None,\n                                             season: Optional[int] = None) -> Optional[dict]:\n        \"\"\"\n        根据TMDBID获取豆瓣信息（异步版本）\n        \"\"\"\n        tmdbinfo = await self.async_tmdb_info(tmdbid=tmdbid, mtype=mtype)\n        if tmdbinfo:\n            # 名称\n            name = tmdbinfo.get(\"title\") or tmdbinfo.get(\"name\")\n            # 年份\n            year = self._extract_year_from_tmdb(tmdbinfo, season)\n            # IMDBID\n            imdbid = tmdbinfo.get(\"external_ids\", {}).get(\"imdb_id\")\n            return await self.async_match_doubaninfo(\n                name=name,\n                year=year,\n                mtype=mtype,\n                imdbid=imdbid\n            )\n        return None\n\n    async def async_get_doubaninfo_by_bangumiid(self, bangumiid: int) -> Optional[dict]:\n        \"\"\"\n        根据BangumiID获取豆瓣信息（异步版本）\n        \"\"\"\n        bangumiinfo = await self.async_bangumi_info(bangumiid=bangumiid)\n        if bangumiinfo:\n            # 优先使用中文标题匹配\n            if bangumiinfo.get(\"name_cn\"):\n                meta = MetaInfo(title=bangumiinfo.get(\"name_cn\"))\n            else:\n                meta = MetaInfo(title=bangumiinfo.get(\"name\"))\n            # 年份\n            year = self._extract_year_from_bangumi(bangumiinfo)\n            # 使用名称识别豆瓣媒体信息\n            return await self.async_match_doubaninfo(\n                name=meta.name,\n                year=year,\n                mtype=MediaType.TV,\n                season=meta.begin_season\n            )\n        return None\n"
  },
  {
    "path": "app/chain/mediaserver.py",
    "content": "import threading\nfrom typing import List, Union, Optional, Generator, Any\n\nfrom app.chain import ChainBase\nfrom app.core.config import global_vars\nfrom app.db.mediaserver_oper import MediaServerOper\nfrom app.helper.service import ServiceConfigHelper\nfrom app.log import logger\nfrom app.schemas import MediaServerLibrary, MediaServerItem, MediaServerSeasonInfo, MediaServerPlayItem\n\nlock = threading.Lock()\n\n\nclass MediaServerChain(ChainBase):\n    \"\"\"\n    媒体服务器处理链\n    \"\"\"\n\n    def librarys(self, server: str, username: Optional[str] = None,\n                 hidden: bool = False) -> List[MediaServerLibrary]:\n        \"\"\"\n        获取媒体服务器所有媒体库\n        \"\"\"\n        return self.run_module(\"mediaserver_librarys\", server=server, username=username, hidden=hidden)\n\n    def items(self, server: str, library_id: Union[str, int],\n              start_index: Optional[int] = 0, limit: Optional[int] = -1) -> Generator[Any, None, None]:\n        \"\"\"\n        获取媒体服务器项目列表，支持分页和不分页逻辑，默认不分页获取所有数据\n\n        :param server: 媒体服务器名称\n        :param library_id: 媒体库ID，用于标识要获取的媒体库\n        :param start_index: 起始索引，用于分页获取数据。默认为 0，即从第一个项目开始获取\n        :param limit: 每次请求的最大项目数，用于分页。如果为 None 或 -1，则表示一次性获取所有数据，默认为 -1\n\n        :return: 返回一个生成器对象，用于逐步获取媒体服务器中的项目\n\n        说明：\n        - 特别注意的是，这里使用yield from返回迭代器，避免同时使用return与yield导致Python生成器解析异常\n        - 如果 `limit` 为 None 或 -1 时，表示一次性获取所有数据，分页处理将不再生效\n        - 在这种情况下，内存消耗可能会较大，特别是在数据量非常大的场景下\n        - 如果未来评估结果显示，不分页场景下的内存消耗远大于分页处理时的网络请求开销，可以考虑在此方法中实现自分页的处理\n        - 即通过 `while` 循环在上层进行分页控制，逐步获取所有数据，避免内存爆炸，当前该逻辑由具体实例来实现不分页的处理\n        - Plex 实际上已默认支持内部分页处理，Jellyfin 与 Emby 获取数据时存在内部过滤场景，如排除合集等，分页数据可能是错误的\n        if limit is not None and limit != -1:\n            yield from self.run_module(\"mediaserver_items\", server=server, library_id=library_id,\n                                   start_index=start_index, limit=limit)\n        else:\n            # 自分页逻辑，通过循环逐步获取所有数据\n            page_size = 10\n            while True:\n                data_generator = self.run_module(\"mediaserver_items\", server=server, library_id=library_id,\n                                                 start_index=start_index, limit=page_size)\n                if not data_generator:\n                    break\n                count = 0\n                for item in data_generator:\n                    if item:\n                        count += 1\n                        yield item\n                if count < page_size:\n                    break\n                start_index += page_size\n        \"\"\"\n        yield from self.run_module(\"mediaserver_items\", server=server, library_id=library_id,\n                                   start_index=start_index, limit=limit)\n\n    def iteminfo(self, server: str, item_id: Union[str, int]) -> MediaServerItem:\n        \"\"\"\n        获取媒体服务器项目信息\n        \"\"\"\n        return self.run_module(\"mediaserver_iteminfo\", server=server, item_id=item_id)\n\n    def episodes(self, server: str, item_id: Union[str, int]) -> List[MediaServerSeasonInfo]:\n        \"\"\"\n        获取媒体服务器剧集信息\n        \"\"\"\n        return self.run_module(\"mediaserver_tv_episodes\", server=server, item_id=item_id)\n\n    def playing(self, server: str, count: Optional[int] = 20,\n                username: Optional[str] = None) -> List[MediaServerPlayItem]:\n        \"\"\"\n        获取媒体服务器正在播放信息\n        \"\"\"\n        return self.run_module(\"mediaserver_playing\", count=count, server=server, username=username)\n\n    def latest(self, server: str, count: Optional[int] = 20,\n               username: Optional[str] = None) -> List[MediaServerPlayItem]:\n        \"\"\"\n        获取媒体服务器最新入库条目\n        \"\"\"\n        return self.run_module(\"mediaserver_latest\", count=count, server=server, username=username)\n\n    def get_latest_wallpapers(self, server: Optional[str] = None, count: Optional[int] = 10,\n                              remote: bool = True, username: Optional[str] = None) -> List[str]:\n        \"\"\"\n        获取最新最新入库条目海报作为壁纸，缓存1小时\n        \"\"\"\n        return self.run_module(\"mediaserver_latest_images\", server=server, count=count,\n                               remote=remote, username=username)\n\n    def get_latest_wallpaper(self, server: Optional[str] = None,\n                             remote: bool = True, username: Optional[str] = None) -> Optional[str]:\n        \"\"\"\n        获取最新最新入库条目海报作为壁纸，缓存1小时\n        \"\"\"\n        wallpapers = self.get_latest_wallpapers(server=server, count=1, remote=remote, username=username)\n        return wallpapers[0] if wallpapers else None\n\n    def get_play_url(self, server: str, item_id: Union[str, int]) -> Optional[str]:\n        \"\"\"\n        获取播放地址\n        \"\"\"\n        return self.run_module(\"mediaserver_play_url\", server=server, item_id=item_id)\n\n    def get_image_cookies(\n        self, server: Optional[str], image_url: str\n    ) -> Optional[str | dict]:\n        \"\"\"\n        获取图片的Cookies\n        \"\"\"\n        return self.run_module(\n            \"mediaserver_image_cookies\", server=server, image_url=image_url\n        )\n\n    def sync(self):\n        \"\"\"\n        同步媒体库所有数据到本地数据库\n        \"\"\"\n        # 设置的媒体服务器\n        mediaservers = ServiceConfigHelper.get_mediaserver_configs()\n        if not mediaservers:\n            return\n        with lock:\n            # 汇总统计\n            total_count = 0\n            # 清空登记薄\n            dboper = MediaServerOper()\n            dboper.empty()\n            # 遍历媒体服务器\n            for mediaserver in mediaservers:\n                if not mediaserver:\n                    continue\n                logger.info(f\"正在准备同步媒体服务器 {mediaserver.name} 的数据\")\n                if not mediaserver.enabled:\n                    logger.info(f\"媒体服务器 {mediaserver.name} 未启用，跳过\")\n                    continue\n                server_name = mediaserver.name\n                sync_libraries = mediaserver.sync_libraries or []\n                logger.info(f\"开始同步媒体服务器 {server_name} 的数据 ...\")\n                libraries = self.librarys(server_name)\n                if not libraries:\n                    logger.info(f\"没有获取到媒体服务器 {server_name} 的媒体库，跳过\")\n                    continue\n                for library in libraries:\n                    if sync_libraries \\\n                            and \"all\" not in sync_libraries \\\n                            and str(library.id) not in sync_libraries:\n                        logger.info(f\"{library.name} 未在 {server_name} 同步媒体库列表中，跳过\")\n                        continue\n                    logger.info(f\"正在同步 {server_name} 媒体库 {library.name} ...\")\n                    library_count = 0\n                    for item in self.items(server=server_name, library_id=library.id):\n                        if global_vars.is_system_stopped:\n                            return\n                        if not item or not item.item_id:\n                            continue\n                        logger.debug(f\"正在同步 {item.title} ...\")\n                        # 计数\n                        library_count += 1\n                        seasoninfo = {}\n                        # 类型\n                        item_type = \"电视剧\" if item.item_type in [\"Series\", \"show\"] else \"电影\"\n                        if item_type == \"电视剧\":\n                            # 查询剧集信息\n                            espisodes_info = self.episodes(server_name, item.item_id) or []\n                            for episode in espisodes_info:\n                                seasoninfo[episode.season] = episode.episodes\n                        # 插入数据\n                        item_dict = item.model_dump()\n                        item_dict[\"seasoninfo\"] = seasoninfo\n                        item_dict[\"item_type\"] = item_type\n                        dboper.add(**item_dict)\n                    logger.info(f\"{server_name} 媒体库 {library.name} 同步完成，共同步数量：{library_count}\")\n                    # 总数累加\n                    total_count += library_count\n                logger.info(f\"媒体服务器 {server_name} 数据同步完成，总同步数量：{total_count}\")\n"
  },
  {
    "path": "app/chain/message.py",
    "content": "import asyncio\nimport re\nimport time\nfrom datetime import datetime, timedelta\nfrom typing import Any, Optional, Dict, Union, List\n\nfrom app.agent import agent_manager\nfrom app.chain import ChainBase\nfrom app.chain.download import DownloadChain\nfrom app.chain.media import MediaChain\nfrom app.chain.search import SearchChain\nfrom app.chain.subscribe import SubscribeChain\nfrom app.core.config import settings, global_vars\nfrom app.core.context import MediaInfo, Context\nfrom app.core.meta import MetaBase\nfrom app.db.user_oper import UserOper\nfrom app.helper.torrent import TorrentHelper\nfrom app.log import logger\nfrom app.schemas import Notification, NotExistMediaInfo, CommingMessage\nfrom app.schemas.message import ChannelCapabilityManager\nfrom app.schemas.types import EventType, MessageChannel, MediaType\nfrom app.utils.string import StringUtils\n\n# 当前页面\n_current_page: int = 0\n# 当前元数据\n_current_meta: Optional[MetaBase] = None\n# 当前媒体信息\n_current_media: Optional[MediaInfo] = None\n\n\nclass MessageChain(ChainBase):\n    \"\"\"\n    外来消息处理链\n    \"\"\"\n    # 缓存的用户数据 {userid: {type: str, items: list}}\n    _cache_file = \"__user_messages__\"\n    # 每页数据量\n    _page_size: int = 8\n    # 用户会话信息 {userid: (session_id, last_time)}\n    _user_sessions: Dict[Union[str, int], tuple] = {}\n    # 会话超时时间（分钟）\n    _session_timeout_minutes: int = 30\n\n    @staticmethod\n    def __get_noexits_info(\n            _meta: MetaBase,\n            _mediainfo: MediaInfo) -> Dict[Union[int, str], Dict[int, NotExistMediaInfo]]:\n        \"\"\"\n        获取缺失的媒体信息\n        \"\"\"\n        if _mediainfo.type == MediaType.TV:\n            if not _mediainfo.seasons:\n                # 补充媒体信息\n                _mediainfo = MediaChain().recognize_media(mtype=_mediainfo.type,\n                                                          tmdbid=_mediainfo.tmdb_id,\n                                                          doubanid=_mediainfo.douban_id,\n                                                          cache=False)\n                if not _mediainfo:\n                    logger.warn(f\"{_mediainfo.tmdb_id or _mediainfo.douban_id} 媒体信息识别失败！\")\n                    return {}\n                if not _mediainfo.seasons:\n                    logger.warn(f\"媒体信息中没有季集信息，\"\n                                f\"标题：{_mediainfo.title}，\"\n                                f\"tmdbid：{_mediainfo.tmdb_id}，doubanid：{_mediainfo.douban_id}\")\n                    return {}\n            # KEY\n            _mediakey = _mediainfo.tmdb_id or _mediainfo.douban_id\n            _no_exists = {\n                _mediakey: {}\n            }\n            if _meta.begin_season:\n                # 指定季\n                episodes = _mediainfo.seasons.get(_meta.begin_season)\n                if not episodes:\n                    return {}\n                _no_exists[_mediakey][_meta.begin_season] = NotExistMediaInfo(\n                    season=_meta.begin_season,\n                    episodes=[],\n                    total_episode=len(episodes),\n                    start_episode=episodes[0]\n                )\n            else:\n                # 所有季\n                for sea, eps in _mediainfo.seasons.items():\n                    if not eps:\n                        continue\n                    _no_exists[_mediakey][sea] = NotExistMediaInfo(\n                        season=sea,\n                        episodes=[],\n                        total_episode=len(eps),\n                        start_episode=eps[0]\n                    )\n        else:\n            _no_exists = {}\n\n        return _no_exists\n\n    def process(self, body: Any, form: Any, args: Any) -> None:\n        \"\"\"\n        调用模块识别消息内容\n        \"\"\"\n        # 消息来源\n        source = args.get(\"source\")\n        # 获取消息内容\n        info = self.message_parser(source=source, body=body, form=form, args=args)\n        if not info:\n            return\n        # 更新消息来源\n        source = info.source\n        # 渠道\n        channel = info.channel\n        # 用户ID\n        userid = info.userid\n        # 用户名（当渠道未提供公开用户名时，回退为 userid 的字符串，避免后续类型校验异常）\n        username = str(info.username) if info.username not in (None, \"\") else str(userid)\n        if userid is None or userid == '':\n            logger.debug(f'未识别到用户ID：{body}{form}{args}')\n            return\n        # 消息内容\n        text = str(info.text).strip() if info.text else None\n        if not text:\n            logger.debug(f'未识别到消息内容：：{body}{form}{args}')\n            return\n\n        # 获取原消息ID信息\n        original_message_id = info.message_id\n        original_chat_id = info.chat_id\n\n        # 处理消息\n        self.handle_message(channel=channel, source=source, userid=userid, username=username, text=text,\n                            original_message_id=original_message_id, original_chat_id=original_chat_id)\n\n    def handle_message(self, channel: MessageChannel, source: str,\n                       userid: Union[str, int], username: str, text: str,\n                       original_message_id: Optional[Union[str, int]] = None,\n                       original_chat_id: Optional[str] = None) -> None:\n        \"\"\"\n        识别消息内容，执行操作\n        \"\"\"\n        # 申明全局变量\n        global _current_page, _current_meta, _current_media\n        # 处理消息\n        logger.info(f'收到用户消息内容，用户：{userid}，内容：{text}')\n        # 加载缓存\n        user_cache: Dict[str, dict] = self.load_cache(self._cache_file) or {}\n        try:\n            # 保存消息\n            if not text.startswith('CALLBACK:'):\n                self.messagehelper.put(\n                    CommingMessage(\n                        userid=userid,\n                        username=username,\n                        channel=channel,\n                        source=source,\n                        text=text\n                    ), role=\"user\")\n                self.messageoper.add(\n                    channel=channel,\n                    source=source,\n                    userid=username or userid,\n                    text=text,\n                    action=0\n                )\n            # 处理消息\n            if text.startswith('CALLBACK:'):\n                # 处理按钮回调（适配支持回调的渠），优先级最高\n                if ChannelCapabilityManager.supports_callbacks(channel):\n                    self._handle_callback(text=text, channel=channel, source=source,\n                                          userid=userid, username=username,\n                                          original_message_id=original_message_id, original_chat_id=original_chat_id)\n                else:\n                    logger.warning(f\"渠道 {channel.value} 不支持回调，但收到了回调消息：{text}\")\n            elif text.startswith('/') and not text.lower().startswith('/ai'):\n                # 执行特定命令命令（但不是/ai）\n                self.eventmanager.send_event(\n                    EventType.CommandExcute,\n                    {\n                        \"cmd\": text,\n                        \"user\": userid,\n                        \"channel\": channel,\n                        \"source\": source\n                    }\n                )\n            elif text.lower().startswith('/ai'):\n                # 用户指定AI智能体消息响应\n                self._handle_ai_message(text=text, channel=channel, source=source,\n                                        userid=userid, username=username)\n            elif settings.AI_AGENT_ENABLE and settings.AI_AGENT_GLOBAL:\n                # 普通消息，全局智能体响应\n                self._handle_ai_message(text=text, channel=channel, source=source,\n                                        userid=userid, username=username)\n            else:\n                # 非智能体普通消息响应\n                if text.isdigit():\n                    # 用户选择了具体的条目\n                    # 缓存\n                    cache_data: dict = user_cache.get(userid)\n                    if not cache_data:\n                        # 发送消息\n                        self.post_message(Notification(channel=channel, source=source, title=\"输入有误！\", userid=userid))\n                        return\n                    cache_data = cache_data.copy()\n                    # 选择项目\n                    if not cache_data.get('items') \\\n                            or len(cache_data.get('items')) < int(text):\n                        # 发送消息\n                        self.post_message(Notification(channel=channel, source=source, title=\"输入有误！\", userid=userid))\n                        return\n                    try:\n                        # 选择的序号\n                        _choice = int(text) + _current_page * self._page_size - 1\n                        # 缓存类型\n                        cache_type: str = cache_data.get('type')\n                        # 缓存列表\n                        cache_list: list = cache_data.get('items').copy()\n                        # 选择\n                        try:\n                            if cache_type in [\"Search\", \"ReSearch\"]:\n                                # 当前媒体信息\n                                mediainfo: MediaInfo = cache_list[_choice]\n                                _current_media = mediainfo\n                                # 查询缺失的媒体信息\n                                exist_flag, no_exists = DownloadChain().get_no_exists_info(meta=_current_meta,\n                                                                                           mediainfo=_current_media)\n                                if exist_flag and cache_type == \"Search\":\n                                    # 媒体库中已存在\n                                    self.post_message(\n                                        Notification(channel=channel,\n                                                     source=source,\n                                                     title=f\"【{_current_media.title_year}\"\n                                                           f\"{_current_meta.sea} 媒体库中已存在，如需重新下载请发送：搜索 名称 或 下载 名称】\",\n                                                     userid=userid))\n                                    return\n                                elif exist_flag:\n                                    # 没有缺失，但要全量重新搜索和下载\n                                    no_exists = self.__get_noexits_info(_current_meta, _current_media)\n                                # 发送缺失的媒体信息\n                                messages = []\n                                if no_exists and cache_type == \"Search\":\n                                    # 发送缺失消息\n                                    mediakey = mediainfo.tmdb_id or mediainfo.douban_id\n                                    messages = [\n                                        f\"第 {sea} 季缺失 {StringUtils.str_series(no_exist.episodes) if no_exist.episodes else no_exist.total_episode} 集\"\n                                        for sea, no_exist in no_exists.get(mediakey).items()]\n                                elif no_exists:\n                                    # 发送总集数的消息\n                                    mediakey = mediainfo.tmdb_id or mediainfo.douban_id\n                                    messages = [\n                                        f\"第 {sea} 季总 {no_exist.total_episode} 集\"\n                                        for sea, no_exist in no_exists.get(mediakey).items()]\n                                if messages:\n                                    self.post_message(Notification(channel=channel,\n                                                                   source=source,\n                                                                   title=f\"{mediainfo.title_year}：\\n\" + \"\\n\".join(messages),\n                                                                   userid=userid))\n                                # 搜索种子，过滤掉不需要的剧集，以便选择\n                                logger.info(f\"开始搜索 {mediainfo.title_year} ...\")\n                                self.post_message(\n                                    Notification(channel=channel,\n                                                 source=source,\n                                                 title=f\"开始搜索 {mediainfo.type.value} {mediainfo.title_year} ...\",\n                                                 userid=userid))\n                                # 开始搜索\n                                contexts = SearchChain().process(mediainfo=mediainfo,\n                                                                 no_exists=no_exists)\n                                if not contexts:\n                                    # 没有数据\n                                    self.post_message(Notification(\n                                        channel=channel,\n                                        source=source,\n                                        title=f\"{mediainfo.title}\"\n                                              f\"{_current_meta.sea} 未搜索到需要的资源！\",\n                                        userid=userid))\n                                    return\n                                # 搜索结果排序\n                                contexts = TorrentHelper().sort_torrents(contexts)\n                                try:\n                                    # 判断是否设置自动下载\n                                    auto_download_user = settings.AUTO_DOWNLOAD_USER\n                                    # 匹配到自动下载用户\n                                    if auto_download_user \\\n                                            and (auto_download_user == \"all\"\n                                                 or any(userid == user for user in auto_download_user.split(\",\"))):\n                                        logger.info(f\"用户 {userid} 在自动下载用户中，开始自动择优下载 ...\")\n                                        # 自动选择下载\n                                        self.__auto_download(channel=channel,\n                                                             source=source,\n                                                             cache_list=contexts,\n                                                             userid=userid,\n                                                             username=username,\n                                                             no_exists=no_exists)\n                                    else:\n                                        # 更新缓存\n                                        user_cache[userid] = {\n                                            \"type\": \"Torrent\",\n                                            \"items\": contexts\n                                        }\n                                        _current_page = 0\n                                        # 保存缓存\n                                        self.save_cache(user_cache, self._cache_file)\n                                        # 删除原消息\n                                        if (original_message_id and original_chat_id and\n                                                ChannelCapabilityManager.supports_deletion(channel)):\n                                            self.delete_message(\n                                                channel=channel,\n                                                source=source,\n                                                message_id=original_message_id,\n                                                chat_id=original_chat_id\n                                            )\n                                        # 发送种子数据\n                                        logger.info(f\"搜索到 {len(contexts)} 条数据，开始发送选择消息 ...\")\n                                        self.__post_torrents_message(channel=channel,\n                                                                     source=source,\n                                                                     title=mediainfo.title,\n                                                                     items=contexts[:self._page_size],\n                                                                     userid=userid,\n                                                                     total=len(contexts))\n                                finally:\n                                    contexts.clear()\n                                    del contexts\n                            elif cache_type in [\"Subscribe\", \"ReSubscribe\"]:\n                                # 订阅或洗版媒体\n                                mediainfo: MediaInfo = cache_list[_choice]\n                                # 洗版标识\n                                best_version = False\n                                # 查询缺失的媒体信息\n                                if cache_type == \"Subscribe\":\n                                    exist_flag, _ = DownloadChain().get_no_exists_info(meta=_current_meta,\n                                                                                       mediainfo=mediainfo)\n                                    if exist_flag:\n                                        self.post_message(Notification(\n                                            channel=channel,\n                                            source=source,\n                                            title=f\"【{mediainfo.title_year}\"\n                                                  f\"{_current_meta.sea} 媒体库中已存在，如需洗版请发送：洗版 XXX】\",\n                                            userid=userid))\n                                        return\n                                else:\n                                    best_version = True\n                                # 转换用户名\n                                mp_name = UserOper().get_name(\n                                    **{f\"{channel.name.lower()}_userid\": userid}) if channel else None\n                                # 添加订阅，状态为N\n                                SubscribeChain().add(title=mediainfo.title,\n                                                     year=mediainfo.year,\n                                                     mtype=mediainfo.type,\n                                                     tmdbid=mediainfo.tmdb_id,\n                                                     season=_current_meta.begin_season,\n                                                     channel=channel,\n                                                     source=source,\n                                                     userid=userid,\n                                                     username=mp_name or username,\n                                                     best_version=best_version)\n                            elif cache_type == \"Torrent\":\n                                if int(text) == 0:\n                                    # 自动选择下载，强制下载模式\n                                    self.__auto_download(channel=channel,\n                                                         source=source,\n                                                         cache_list=cache_list,\n                                                         userid=userid,\n                                                         username=username)\n                                else:\n                                    # 下载种子\n                                    context: Context = cache_list[_choice]\n                                    # 下载\n                                    DownloadChain().download_single(context, channel=channel, source=source,\n                                                                    userid=userid, username=username)\n                        finally:\n                            cache_list.clear()\n                            del cache_list\n                    finally:\n                        cache_data.clear()\n                        del cache_data\n                elif text.lower() == \"p\":\n                    # 上一页\n                    cache_data: dict = user_cache.get(userid)\n                    if not cache_data:\n                        # 没有缓存\n                        self.post_message(Notification(\n                            channel=channel, source=source, title=\"输入有误！\", userid=userid))\n                        return\n                    cache_data = cache_data.copy()\n                    try:\n                        if _current_page == 0:\n                            # 第一页\n                            self.post_message(Notification(\n                                channel=channel, source=source, title=\"已经是第一页了！\", userid=userid))\n                            return\n                        # 减一页\n                        _current_page -= 1\n                        cache_type: str = cache_data.get('type')\n                        # 产生副本，避免修改原值\n                        cache_list: list = cache_data.get('items').copy()\n                        try:\n                            if _current_page == 0:\n                                start = 0\n                                end = self._page_size\n                            else:\n                                start = _current_page * self._page_size\n                                end = start + self._page_size\n                            if cache_type == \"Torrent\":\n                                # 发送种子数据\n                                self.__post_torrents_message(channel=channel,\n                                                             source=source,\n                                                             title=_current_media.title,\n                                                             items=cache_list[start:end],\n                                                             userid=userid,\n                                                             total=len(cache_list),\n                                                             original_message_id=original_message_id,\n                                                             original_chat_id=original_chat_id)\n                            else:\n                                # 发送媒体数据\n                                self.__post_medias_message(channel=channel,\n                                                           source=source,\n                                                           title=_current_meta.name,\n                                                           items=cache_list[start:end],\n                                                           userid=userid,\n                                                           total=len(cache_list),\n                                                           original_message_id=original_message_id,\n                                                           original_chat_id=original_chat_id)\n                        finally:\n                            cache_list.clear()\n                            del cache_list\n                    finally:\n                        cache_data.clear()\n                        del cache_data\n                elif text.lower() == \"n\":\n                    # 下一页\n                    cache_data: dict = user_cache.get(userid)\n                    if not cache_data:\n                        # 没有缓存\n                        self.post_message(Notification(\n                            channel=channel, source=source, title=\"输入有误！\", userid=userid))\n                        return\n                    cache_data = cache_data.copy()\n                    try:\n                        cache_type: str = cache_data.get('type')\n                        # 产生副本，避免修改原值\n                        cache_list: list = cache_data.get('items').copy()\n                        total = len(cache_list)\n                        # 加一页\n                        cache_list = cache_list[(_current_page + 1) * self._page_size:(_current_page + 2) * self._page_size]\n                        if not cache_list:\n                            # 没有数据\n                            self.post_message(Notification(\n                                channel=channel, source=source, title=\"已经是最后一页了！\", userid=userid))\n                            return\n                        else:\n                            try:\n                                # 加一页\n                                _current_page += 1\n                                if cache_type == \"Torrent\":\n                                    # 发送种子数据\n                                    self.__post_torrents_message(channel=channel,\n                                                                 source=source,\n                                                                 title=_current_media.title,\n                                                                 items=cache_list,\n                                                                 userid=userid,\n                                                                 total=total,\n                                                                 original_message_id=original_message_id,\n                                                                 original_chat_id=original_chat_id)\n                                else:\n                                    # 发送媒体数据\n                                    self.__post_medias_message(channel=channel,\n                                                               source=source,\n                                                               title=_current_meta.name,\n                                                               items=cache_list,\n                                                               userid=userid,\n                                                               total=total,\n                                                               original_message_id=original_message_id,\n                                                               original_chat_id=original_chat_id)\n                            finally:\n                                cache_list.clear()\n                                del cache_list\n                    finally:\n                        cache_data.clear()\n                        del cache_data\n                else:\n                    # 搜索或订阅\n                    if text.startswith(\"订阅\"):\n                        # 订阅\n                        content = re.sub(r\"订阅[:：\\s]*\", \"\", text)\n                        action = \"Subscribe\"\n                    elif text.startswith(\"洗版\"):\n                        # 洗版\n                        content = re.sub(r\"洗版[:：\\s]*\", \"\", text)\n                        action = \"ReSubscribe\"\n                    elif text.startswith(\"搜索\") or text.startswith(\"下载\"):\n                        # 重新搜索/下载\n                        content = re.sub(r\"(搜索|下载)[:：\\s]*\", \"\", text)\n                        action = \"ReSearch\"\n                    elif StringUtils.is_link(text):\n                        # 链接\n                        content = text\n                        action = \"Link\"\n                    elif not StringUtils.is_media_title_like(text):\n                        # 聊天\n                        content = text\n                        action = \"Chat\"\n                    else:\n                        # 搜索\n                        content = text\n                        action = \"Search\"\n\n                    if action in [\"Search\", \"ReSearch\", \"Subscribe\", \"ReSubscribe\"]:\n                        # 搜索\n                        meta, medias = MediaChain().search(content)\n                        # 识别\n                        if not meta.name:\n                            self.post_message(Notification(\n                                channel=channel, source=source, title=\"无法识别输入内容！\", userid=userid))\n                            return\n                        # 开始搜索\n                        if not medias:\n                            self.post_message(Notification(\n                                channel=channel, source=source, title=f\"{meta.name} 没有找到对应的媒体信息！\",\n                                userid=userid))\n                            return\n                        logger.info(f\"搜索到 {len(medias)} 条相关媒体信息\")\n                        try:\n                            # 记录当前状态\n                            _current_meta = meta\n                            # 保存缓存\n                            user_cache[userid] = {\n                                'type': action,\n                                'items': medias\n                            }\n                            self.save_cache(user_cache, self._cache_file)\n                            _current_page = 0\n                            _current_media = None\n                            # 发送媒体列表\n                            self.__post_medias_message(channel=channel,\n                                                       source=source,\n                                                       title=meta.name,\n                                                       items=medias[:self._page_size],\n                                                       userid=userid, total=len(medias))\n                        finally:\n                            medias.clear()\n                            del medias\n                    else:\n                        # 广播事件\n                        self.eventmanager.send_event(\n                            EventType.UserMessage,\n                            {\n                                \"text\": content,\n                                \"userid\": userid,\n                                \"channel\": channel,\n                                \"source\": source\n                            }\n                        )\n        finally:\n            user_cache.clear()\n            del user_cache\n\n    def _handle_callback(self, text: str, channel: MessageChannel, source: str,\n                         userid: Union[str, int], username: str,\n                         original_message_id: Optional[Union[str, int]] = None,\n                         original_chat_id: Optional[str] = None) -> None:\n        \"\"\"\n        处理按钮回调\n        \"\"\"\n\n        global _current_media\n\n        # 提取回调数据\n        callback_data = text[9:]  # 去掉 \"CALLBACK:\" 前缀\n        logger.info(f\"处理按钮回调：{callback_data}\")\n\n        # 插件消息的事件回调 [PLUGIN]插件ID|内容\n        if callback_data.startswith('[PLUGIN]'):\n            # 提取插件ID和内容\n            plugin_id, content = callback_data.split(\"|\", 1)\n            # 广播给插件处理\n            self.eventmanager.send_event(\n                EventType.MessageAction,\n                {\n                    \"plugin_id\": plugin_id.replace(\"[PLUGIN]\", \"\"),\n                    \"text\": content,\n                    \"userid\": userid,\n                    \"channel\": channel,\n                    \"source\": source,\n                    \"original_message_id\": original_message_id,\n                    \"original_chat_id\": original_chat_id\n                }\n            )\n            return\n\n        # 解析系统回调数据\n        try:\n            page_text = callback_data.split(\"_\", 1)[1]\n            self.handle_message(channel=channel, source=source, userid=userid, username=username,\n                                text=page_text,\n                                original_message_id=original_message_id, original_chat_id=original_chat_id)\n        except IndexError:\n            logger.error(f\"回调数据格式错误：{callback_data}\")\n            self.post_message(Notification(\n                channel=channel,\n                source=source,\n                userid=userid,\n                username=username,\n                title=\"回调数据格式错误，请检查！\"\n            ))\n\n    def __auto_download(self, channel: MessageChannel, source: str, cache_list: list[Context],\n                        userid: Union[str, int], username: str,\n                        no_exists: Optional[Dict[Union[int, str], Dict[int, NotExistMediaInfo]]] = None):\n        \"\"\"\n        自动择优下载\n        \"\"\"\n        downloadchain = DownloadChain()\n        if no_exists is None:\n            # 查询缺失的媒体信息\n            exist_flag, no_exists = downloadchain.get_no_exists_info(\n                meta=_current_meta,\n                mediainfo=_current_media\n            )\n            if exist_flag:\n                # 媒体库中已存在，查询全量\n                no_exists = self.__get_noexits_info(_current_meta, _current_media)\n\n        # 批量下载\n        downloads, lefts = downloadchain.batch_download(contexts=cache_list,\n                                                        no_exists=no_exists,\n                                                        channel=channel,\n                                                        source=source,\n                                                        userid=userid,\n                                                        username=username)\n        if downloads and not lefts:\n            # 全部下载完成\n            logger.info(f'{_current_media.title_year} 下载完成')\n        else:\n            # 未完成下载\n            logger.info(f'{_current_media.title_year} 未下载未完整，添加订阅 ...')\n            if downloads and _current_media.type == MediaType.TV:\n                # 获取已下载剧集\n                downloaded = [download.meta_info.begin_episode for download in downloads\n                              if download.meta_info.begin_episode]\n                note = downloaded\n            else:\n                note = None\n            # 转换用户名\n            mp_name = UserOper().get_name(**{f\"{channel.name.lower()}_userid\": userid}) if channel else None\n            # 添加订阅，状态为R\n            SubscribeChain().add(title=_current_media.title,\n                                 year=_current_media.year,\n                                 mtype=_current_media.type,\n                                 tmdbid=_current_media.tmdb_id,\n                                 season=_current_meta.begin_season,\n                                 channel=channel,\n                                 source=source,\n                                 userid=userid,\n                                 username=mp_name or username,\n                                 state=\"R\",\n                                 note=note)\n\n    def __post_medias_message(self, channel: MessageChannel, source: str,\n                              title: str, items: list, userid: str, total: int,\n                              original_message_id: Optional[Union[str, int]] = None,\n                              original_chat_id: Optional[str] = None):\n        \"\"\"\n        发送媒体列表消息\n        \"\"\"\n        # 检查渠道是否支持按钮\n        supports_buttons = ChannelCapabilityManager.supports_buttons(channel)\n\n        if supports_buttons:\n            # 支持按钮的渠道\n            if total > self._page_size:\n                title = f\"【{title}】共找到{total}条相关信息，请选择操作\"\n            else:\n                title = f\"【{title}】共找到{total}条相关信息，请选择操作\"\n\n            buttons = self._create_media_buttons(channel=channel, items=items, total=total)\n        else:\n            # 不支持按钮的渠道，使用文本提示\n            if total > self._page_size:\n                title = f\"【{title}】共找到{total}条相关信息，请回复对应数字选择（p: 上一页 n: 下一页）\"\n            else:\n                title = f\"【{title}】共找到{total}条相关信息，请回复对应数字选择\"\n            buttons = None\n\n        notification = Notification(\n            channel=channel,\n            source=source,\n            title=title,\n            userid=userid,\n            buttons=buttons,\n            original_message_id=original_message_id,\n            original_chat_id=original_chat_id\n        )\n\n        self.post_medias_message(notification, medias=items)\n\n    def _create_media_buttons(self, channel: MessageChannel, items: list, total: int) -> List[List[Dict]]:\n        \"\"\"\n        创建媒体选择按钮\n        \"\"\"\n        global _current_page\n\n        buttons = []\n        max_text_length = ChannelCapabilityManager.get_max_button_text_length(channel)\n        max_per_row = ChannelCapabilityManager.get_max_buttons_per_row(channel)\n\n        # 为每个媒体项创建选择按钮\n        current_row = []\n        for i in range(len(items)):\n            media = items[i]\n\n            if max_per_row == 1:\n                # 每行一个按钮，使用完整文本\n                button_text = f\"{i + 1}. {media.title_year}\"\n                if len(button_text) > max_text_length:\n                    button_text = button_text[:max_text_length - 3] + \"...\"\n\n                buttons.append([{\n                    \"text\": button_text,\n                    \"callback_data\": f\"select_{i + 1}\"\n                }])\n            else:\n                # 多按钮一行的情况，使用简化文本\n                button_text = f\"{i + 1}\"\n\n                current_row.append({\n                    \"text\": button_text,\n                    \"callback_data\": f\"select_{i + 1}\"\n                })\n\n                # 如果当前行已满或者是最后一个按钮，添加到按钮列表\n                if len(current_row) == max_per_row or i == len(items) - 1:\n                    buttons.append(current_row)\n                    current_row = []\n\n        # 添加翻页按钮\n        if total > self._page_size:\n            page_buttons = []\n            if _current_page > 0:\n                page_buttons.append({\"text\": \"⬅️ 上一页\", \"callback_data\": \"page_p\"})\n            if (_current_page + 1) * self._page_size < total:\n                page_buttons.append({\"text\": \"下一页 ➡️\", \"callback_data\": \"page_n\"})\n            if page_buttons:\n                buttons.append(page_buttons)\n\n        return buttons\n\n    def __post_torrents_message(self, channel: MessageChannel, source: str,\n                                title: str, items: list, userid: str, total: int,\n                                original_message_id: Optional[Union[str, int]] = None,\n                                original_chat_id: Optional[str] = None):\n        \"\"\"\n        发送种子列表消息\n        \"\"\"\n        # 检查渠道是否支持按钮\n        supports_buttons = ChannelCapabilityManager.supports_buttons(channel)\n\n        if supports_buttons:\n            # 支持按钮的渠道\n            if total > self._page_size:\n                title = f\"【{title}】共找到{total}条相关资源，请选择下载\"\n            else:\n                title = f\"【{title}】共找到{total}条相关资源，请选择下载\"\n\n            buttons = self._create_torrent_buttons(channel=channel, items=items, total=total)\n        else:\n            # 不支持按钮的渠道，使用文本提示\n            if total > self._page_size:\n                title = f\"【{title}】共找到{total}条相关资源，请回复对应数字下载（0: 自动选择 p: 上一页 n: 下一页）\"\n            else:\n                title = f\"【{title}】共找到{total}条相关资源，请回复对应数字下载（0: 自动选择）\"\n            buttons = None\n\n        notification = Notification(\n            channel=channel,\n            source=source,\n            title=title,\n            userid=userid,\n            link=settings.MP_DOMAIN('#/resource'),\n            buttons=buttons,\n            original_message_id=original_message_id,\n            original_chat_id=original_chat_id\n        )\n\n        self.post_torrents_message(notification, torrents=items)\n\n    def _create_torrent_buttons(self, channel: MessageChannel, items: list, total: int) -> List[List[Dict]]:\n        \"\"\"\n        创建种子下载按钮\n        \"\"\"\n\n        global _current_page\n\n        buttons = []\n        max_text_length = ChannelCapabilityManager.get_max_button_text_length(channel)\n        max_per_row = ChannelCapabilityManager.get_max_buttons_per_row(channel)\n\n        # 自动选择按钮\n        buttons.append([{\"text\": \"🤖 自动选择下载\", \"callback_data\": \"download_0\"}])\n\n        # 为每个种子项创建下载按钮\n        current_row = []\n        for i in range(len(items)):\n            context = items[i]\n            torrent = context.torrent_info\n\n            if max_per_row == 1:\n                # 每行一个按钮，使用完整文本\n                button_text = f\"{i + 1}. {torrent.site_name} - {torrent.seeders}↑\"\n                if len(button_text) > max_text_length:\n                    button_text = button_text[:max_text_length - 3] + \"...\"\n\n                buttons.append([{\n                    \"text\": button_text,\n                    \"callback_data\": f\"download_{i + 1}\"\n                }])\n            else:\n                # 多按钮一行的情况，使用简化文本\n                button_text = f\"{i + 1}\"\n\n                current_row.append({\n                    \"text\": button_text,\n                    \"callback_data\": f\"download_{i + 1}\"\n                })\n\n                # 如果当前行已满或者是最后一个按钮，添加到按钮列表\n                if len(current_row) == max_per_row or i == len(items) - 1:\n                    buttons.append(current_row)\n                    current_row = []\n\n        # 添加翻页按钮\n        if total > self._page_size:\n            page_buttons = []\n            if _current_page > 0:\n                page_buttons.append({\"text\": \"⬅️ 上一页\", \"callback_data\": \"page_p\"})\n            if (_current_page + 1) * self._page_size < total:\n                page_buttons.append({\"text\": \"下一页 ➡️\", \"callback_data\": \"page_n\"})\n            if page_buttons:\n                buttons.append(page_buttons)\n\n        return buttons\n\n    def _get_or_create_session_id(self, userid: Union[str, int]) -> str:\n        \"\"\"\n        获取或创建会话ID\n        如果用户上次会话在15分钟内，则复用相同的会话ID；否则创建新的会话ID\n        \"\"\"\n        current_time = datetime.now()\n\n        # 检查用户是否有已存在的会话\n        if userid in self._user_sessions:\n            session_id, last_time = self._user_sessions[userid]\n\n            # 计算时间差\n            time_diff = current_time - last_time\n\n            # 如果时间差小于等于xx分钟，复用会话ID\n            if time_diff <= timedelta(minutes=self._session_timeout_minutes):\n                # 更新最后使用时间\n                self._user_sessions[userid] = (session_id, current_time)\n                logger.info(\n                    f\"复用会话ID: {session_id}, 用户: {userid}, 距离上次会话: {time_diff.total_seconds() / 60:.1f}分钟\")\n                return session_id\n\n        # 创建新的会话ID\n        new_session_id = f\"user_{userid}_{int(time.time())}\"\n        self._user_sessions[userid] = (new_session_id, current_time)\n        logger.info(f\"创建新会话ID: {new_session_id}, 用户: {userid}\")\n        return new_session_id\n\n    def clear_user_session(self, userid: Union[str, int]) -> bool:\n        \"\"\"\n        清除指定用户的会话信息\n        返回是否成功清除\n        \"\"\"\n        if userid in self._user_sessions:\n            session_id, _ = self._user_sessions.pop(userid)\n            logger.info(f\"已清除用户 {userid} 的会话: {session_id}\")\n            return True\n        return False\n\n    def remote_clear_session(self, channel: MessageChannel, userid: Union[str, int], source: Optional[str] = None):\n        \"\"\"\n        清除用户会话（远程命令接口）\n        \"\"\"\n        # 获取并清除会话信息\n        session_id = None\n        if userid in self._user_sessions:\n            session_id, _ = self._user_sessions.pop(userid)\n            logger.info(f\"已清除用户 {userid} 的会话: {session_id}\")\n\n        # 如果有会话ID，同时清除智能体的会话记忆\n        if session_id:\n            try:\n                asyncio.run_coroutine_threadsafe(\n                    agent_manager.clear_session(\n                        session_id=session_id,\n                        user_id=str(userid)\n                    ),\n                    global_vars.loop\n                )\n            except Exception as e:\n                logger.warning(f\"清除智能体会话记忆失败: {e}\")\n\n            self.post_message(Notification(\n                channel=channel,\n                source=source,\n                title=\"智能体会话已清除，下次将创建新的会话\",\n                userid=userid\n            ))\n        else:\n            self.post_message(Notification(\n                channel=channel,\n                source=source,\n                title=\"您当前没有活跃的智能体会话\",\n                userid=userid\n            ))\n\n    def _handle_ai_message(self, text: str, channel: MessageChannel, source: str,\n                           userid: Union[str, int], username: str) -> None:\n        \"\"\"\n        处理AI智能体消息\n        \"\"\"\n        try:\n            # 检查AI智能体是否启用\n            if not settings.AI_AGENT_ENABLE:\n                self.post_message(Notification(\n                    channel=channel,\n                    source=source,\n                    userid=userid,\n                    username=username,\n                    title=\"MoviePilot智能助手未启用，请在系统设置中启用\"\n                ))\n                return\n\n            # 提取用户消息\n            if text.lower().startswith(\"/ai\"):\n                user_message = text[3:].strip()  # 移除 \"/ai\" 前缀（大小写不敏感）\n            else:\n                user_message = text.strip()  # 按原消息处理\n            if not user_message:\n                self.post_message(Notification(\n                    channel=channel,\n                    source=source,\n                    userid=userid,\n                    username=username,\n                    title=\"请输入您的问题或需求\"\n                ))\n                return\n\n            # 生成或复用会话ID\n            session_id = self._get_or_create_session_id(userid)\n\n            # 在事件循环中处理\n            asyncio.run_coroutine_threadsafe(\n                agent_manager.process_message(\n                    session_id=session_id,\n                    user_id=str(userid),\n                    message=user_message,\n                    channel=channel.value if channel else None,\n                    source=source,\n                    username=username\n                ),\n                global_vars.loop\n            )\n\n        except Exception as e:\n            logger.error(f\"处理AI智能体消息失败: {e}\")\n            self.messagehelper.put(f\"AI智能体处理失败: {str(e)}\", role=\"system\", title=\"MoviePilot助手\")\n"
  },
  {
    "path": "app/chain/recommend.py",
    "content": "from typing import List, Optional\n\nimport pillow_avif  # noqa 用于自动注册AVIF支持\n\nfrom app.chain import ChainBase\nfrom app.chain.bangumi import BangumiChain\nfrom app.chain.douban import DoubanChain\nfrom app.chain.tmdb import TmdbChain\nfrom app.core.cache import cached, fresh\nfrom app.core.config import settings, global_vars\nfrom app.helper.image import ImageHelper\nfrom app.log import logger\nfrom app.schemas import MediaType\nfrom app.utils.common import log_execution_time\nfrom app.utils.singleton import Singleton\n\n\nclass RecommendChain(ChainBase, metaclass=Singleton):\n    \"\"\"\n    推荐处理链，单例运行\n    \"\"\"\n\n    # 推荐缓存时间\n    recommend_ttl = 24 * 3600\n    # 推荐缓存页数\n    cache_max_pages = 5\n    # 推荐缓存区域\n    recommend_cache_region = \"recommend\"\n\n    def refresh_recommend(self, manual: bool = False):\n        \"\"\"\n        刷新推荐\n\n        :param manual: 手动触发\n        \"\"\"\n        logger.debug(\"Starting to refresh Recommend data.\")\n\n        # 推荐来源方法\n        recommend_methods = [\n            self.tmdb_movies,\n            self.tmdb_tvs,\n            self.tmdb_trending,\n            self.bangumi_calendar,\n            self.douban_movie_showing,\n            self.douban_movies,\n            self.douban_tvs,\n            self.douban_movie_top250,\n            self.douban_tv_weekly_chinese,\n            self.douban_tv_weekly_global,\n            self.douban_tv_animation,\n            self.douban_movie_hot,\n            self.douban_tv_hot,\n        ]\n\n        # 缓存并刷新所有推荐数据\n        recommends = []\n        # 记录哪些方法已完成\n        methods_finished = set()\n        # 这里避免区间内连续调用相同来源，因此遍历方案为每页遍历所有推荐来源，再进行页数遍历\n        for page in range(1, self.cache_max_pages + 1):\n            for method in recommend_methods:\n                if global_vars.is_system_stopped:\n                    return\n                if method in methods_finished:\n                    continue\n                logger.debug(f\"Fetch {method.__name__} data for page {page}.\")\n                # 手动触发的刷新，总是需要获取最新数据\n                with fresh(manual):\n                    data = method(page=page)\n                if not data:\n                    logger.debug(\"All recommendation methods have finished fetching data. Ending pagination early.\")\n                    methods_finished.add(method)\n                    continue\n                recommends.extend(data)\n            # 如果所有方法都已经完成，提前结束循环\n            if len(methods_finished) == len(recommend_methods):\n                break\n\n        # 缓存收集到的海报\n        self.__cache_posters(recommends)\n        logger.debug(\"Recommend data refresh completed.\")\n\n    def __cache_posters(self, datas: List[dict]):\n        \"\"\"\n        提取 poster_path 并缓存图片\n        :param datas: 数据列表\n        \"\"\"\n        if not settings.GLOBAL_IMAGE_CACHE:\n            return\n\n        for data in datas:\n            if global_vars.is_system_stopped:\n                return\n            poster_path = data.get(\"poster_path\")\n            if poster_path:\n                poster_url = poster_path.replace(\"original\", \"w500\")\n                self.__fetch_and_save_image(poster_url)\n\n    @staticmethod\n    def __fetch_and_save_image(url: str):\n        \"\"\"\n        请求并保存图片\n        :param url: 图片路径\n        \"\"\"\n        ImageHelper().fetch_image(url=url)\n\n    @log_execution_time(logger=logger)\n    @cached(ttl=recommend_ttl, region=recommend_cache_region)\n    def tmdb_movies(self, sort_by: Optional[str] = \"popularity.desc\",\n                    with_genres: Optional[str] = \"\",\n                    with_original_language: Optional[str] = \"\",\n                    with_keywords: Optional[str] = \"\",\n                    with_watch_providers: Optional[str] = \"\",\n                    vote_average: Optional[float] = 0.0,\n                    vote_count: Optional[int] = 0,\n                    release_date: Optional[str] = \"\",\n                    page: Optional[int] = 1) -> List[dict]:\n        \"\"\"\n        TMDB热门电影\n        \"\"\"\n        movies = TmdbChain().tmdb_discover(mtype=MediaType.MOVIE,\n                                           sort_by=sort_by,\n                                           with_genres=with_genres,\n                                           with_original_language=with_original_language,\n                                           with_keywords=with_keywords,\n                                           with_watch_providers=with_watch_providers,\n                                           vote_average=vote_average,\n                                           vote_count=vote_count,\n                                           release_date=release_date,\n                                           page=page)\n        return [movie.to_dict() for movie in movies] if movies else []\n\n    @log_execution_time(logger=logger)\n    @cached(ttl=recommend_ttl, region=recommend_cache_region)\n    def tmdb_tvs(self, sort_by: Optional[str] = \"popularity.desc\",\n                 with_genres: Optional[str] = \"\",\n                 with_original_language: Optional[str] = \"zh|en|ja|ko\",\n                 with_keywords: Optional[str] = \"\",\n                 with_watch_providers: Optional[str] = \"\",\n                 vote_average: Optional[float] = 0.0,\n                 vote_count: Optional[int] = 0,\n                 release_date: Optional[str] = \"\",\n                 page: Optional[int] = 1) -> List[dict]:\n        \"\"\"\n        TMDB热门电视剧\n        \"\"\"\n        tvs = TmdbChain().tmdb_discover(mtype=MediaType.TV,\n                                        sort_by=sort_by,\n                                        with_genres=with_genres,\n                                        with_original_language=with_original_language,\n                                        with_keywords=with_keywords,\n                                        with_watch_providers=with_watch_providers,\n                                        vote_average=vote_average,\n                                        vote_count=vote_count,\n                                        release_date=release_date,\n                                        page=page)\n        return [tv.to_dict() for tv in tvs] if tvs else []\n\n    @log_execution_time(logger=logger)\n    @cached(ttl=recommend_ttl, region=recommend_cache_region)\n    def tmdb_trending(self, page: Optional[int] = 1) -> List[dict]:\n        \"\"\"\n        TMDB流行趋势\n        \"\"\"\n        infos = TmdbChain().tmdb_trending(page=page)\n        return [info.to_dict() for info in infos] if infos else []\n\n    @log_execution_time(logger=logger)\n    @cached(ttl=recommend_ttl, region=recommend_cache_region)\n    def bangumi_calendar(self, page: Optional[int] = 1, count: Optional[int] = 30) -> List[dict]:\n        \"\"\"\n        Bangumi每日放送\n        \"\"\"\n        medias = BangumiChain().calendar()\n        return [media.to_dict() for media in medias[(page - 1) * count: page * count]] if medias else []\n\n    @log_execution_time(logger=logger)\n    @cached(ttl=recommend_ttl, region=recommend_cache_region)\n    def douban_movie_showing(self, page: Optional[int] = 1, count: Optional[int] = 30) -> List[dict]:\n        \"\"\"\n        豆瓣正在热映\n        \"\"\"\n        movies = DoubanChain().movie_showing(page=page, count=count)\n        return [media.to_dict() for media in movies] if movies else []\n\n    @log_execution_time(logger=logger)\n    @cached(ttl=recommend_ttl, region=recommend_cache_region)\n    def douban_movies(self, sort: Optional[str] = \"R\", tags: Optional[str] = \"\",\n                      page: Optional[int] = 1, count: Optional[int] = 30) -> List[dict]:\n        \"\"\"\n        豆瓣最新电影\n        \"\"\"\n        movies = DoubanChain().douban_discover(mtype=MediaType.MOVIE,\n                                               sort=sort, tags=tags, page=page, count=count)\n        return [media.to_dict() for media in movies] if movies else []\n\n    @log_execution_time(logger=logger)\n    @cached(ttl=recommend_ttl, region=recommend_cache_region)\n    def douban_tvs(self, sort: Optional[str] = \"R\", tags: Optional[str] = \"\",\n                   page: Optional[int] = 1, count: Optional[int] = 30) -> List[dict]:\n        \"\"\"\n        豆瓣最新电视剧\n        \"\"\"\n        tvs = DoubanChain().douban_discover(mtype=MediaType.TV,\n                                            sort=sort, tags=tags, page=page, count=count)\n        return [media.to_dict() for media in tvs] if tvs else []\n\n    @log_execution_time(logger=logger)\n    @cached(ttl=recommend_ttl, region=recommend_cache_region)\n    def douban_movie_top250(self, page: Optional[int] = 1, count: Optional[int] = 30) -> List[dict]:\n        \"\"\"\n        豆瓣电影TOP250\n        \"\"\"\n        movies = DoubanChain().movie_top250(page=page, count=count)\n        return [media.to_dict() for media in movies] if movies else []\n\n    @log_execution_time(logger=logger)\n    @cached(ttl=recommend_ttl, region=recommend_cache_region)\n    def douban_tv_weekly_chinese(self, page: Optional[int] = 1, count: Optional[int] = 30) -> List[dict]:\n        \"\"\"\n        豆瓣国产剧集榜\n        \"\"\"\n        tvs = DoubanChain().tv_weekly_chinese(page=page, count=count)\n        return [media.to_dict() for media in tvs] if tvs else []\n\n    @log_execution_time(logger=logger)\n    @cached(ttl=recommend_ttl, region=recommend_cache_region)\n    def douban_tv_weekly_global(self, page: Optional[int] = 1, count: Optional[int] = 30) -> List[dict]:\n        \"\"\"\n        豆瓣全球剧集榜\n        \"\"\"\n        tvs = DoubanChain().tv_weekly_global(page=page, count=count)\n        return [media.to_dict() for media in tvs] if tvs else []\n\n    @log_execution_time(logger=logger)\n    @cached(ttl=recommend_ttl, region=recommend_cache_region)\n    def douban_tv_animation(self, page: Optional[int] = 1, count: Optional[int] = 30) -> List[dict]:\n        \"\"\"\n        豆瓣热门动漫\n        \"\"\"\n        tvs = DoubanChain().tv_animation(page=page, count=count)\n        return [media.to_dict() for media in tvs] if tvs else []\n\n    @log_execution_time(logger=logger)\n    @cached(ttl=recommend_ttl, region=recommend_cache_region)\n    def douban_movie_hot(self, page: Optional[int] = 1, count: Optional[int] = 30) -> List[dict]:\n        \"\"\"\n        豆瓣热门电影\n        \"\"\"\n        movies = DoubanChain().movie_hot(page=page, count=count)\n        return [media.to_dict() for media in movies] if movies else []\n\n    @log_execution_time(logger=logger)\n    @cached(ttl=recommend_ttl, region=recommend_cache_region)\n    def douban_tv_hot(self, page: Optional[int] = 1, count: Optional[int] = 30) -> List[dict]:\n        \"\"\"\n        豆瓣热门电视剧\n        \"\"\"\n        tvs = DoubanChain().tv_hot(page=page, count=count)\n        return [media.to_dict() for media in tvs] if tvs else []\n\n    @log_execution_time(logger=logger)\n    @cached(ttl=recommend_ttl, region=recommend_cache_region)\n    async def async_tmdb_movies(self, sort_by: Optional[str] = \"popularity.desc\",\n                                with_genres: Optional[str] = \"\",\n                                with_original_language: Optional[str] = \"\",\n                                with_keywords: Optional[str] = \"\",\n                                with_watch_providers: Optional[str] = \"\",\n                                vote_average: Optional[float] = 0.0,\n                                vote_count: Optional[int] = 0,\n                                release_date: Optional[str] = \"\",\n                                page: Optional[int] = 1) -> List[dict]:\n        \"\"\"\n        异步TMDB热门电影\n        \"\"\"\n        movies = await TmdbChain().async_run_module(\"async_tmdb_discover\", mtype=MediaType.MOVIE,\n                                                    sort_by=sort_by,\n                                                    with_genres=with_genres,\n                                                    with_original_language=with_original_language,\n                                                    with_keywords=with_keywords,\n                                                    with_watch_providers=with_watch_providers,\n                                                    vote_average=vote_average,\n                                                    vote_count=vote_count,\n                                                    release_date=release_date,\n                                                    page=page)\n        return [movie.to_dict() for movie in movies] if movies else []\n\n    @log_execution_time(logger=logger)\n    @cached(ttl=recommend_ttl, region=recommend_cache_region)\n    async def async_tmdb_tvs(self, sort_by: Optional[str] = \"popularity.desc\",\n                             with_genres: Optional[str] = \"\",\n                             with_original_language: Optional[str] = \"zh|en|ja|ko\",\n                             with_keywords: Optional[str] = \"\",\n                             with_watch_providers: Optional[str] = \"\",\n                             vote_average: Optional[float] = 0.0,\n                             vote_count: Optional[int] = 0,\n                             release_date: Optional[str] = \"\",\n                             page: Optional[int] = 1) -> List[dict]:\n        \"\"\"\n        异步TMDB热门电视剧\n        \"\"\"\n        tvs = await TmdbChain().async_run_module(\"async_tmdb_discover\", mtype=MediaType.TV,\n                                                 sort_by=sort_by,\n                                                 with_genres=with_genres,\n                                                 with_original_language=with_original_language,\n                                                 with_keywords=with_keywords,\n                                                 with_watch_providers=with_watch_providers,\n                                                 vote_average=vote_average,\n                                                 vote_count=vote_count,\n                                                 release_date=release_date,\n                                                 page=page)\n        return [tv.to_dict() for tv in tvs] if tvs else []\n\n    @log_execution_time(logger=logger)\n    @cached(ttl=recommend_ttl, region=recommend_cache_region)\n    async def async_tmdb_trending(self, page: Optional[int] = 1) -> List[dict]:\n        \"\"\"\n        异步TMDB流行趋势\n        \"\"\"\n        infos = await TmdbChain().async_run_module(\"async_tmdb_trending\", page=page)\n        return [info.to_dict() for info in infos] if infos else []\n\n    @log_execution_time(logger=logger)\n    @cached(ttl=recommend_ttl, region=recommend_cache_region)\n    async def async_bangumi_calendar(self, page: Optional[int] = 1, count: Optional[int] = 30) -> List[dict]:\n        \"\"\"\n        异步Bangumi每日放送\n        \"\"\"\n        medias = await BangumiChain().async_run_module(\"async_bangumi_calendar\")\n        return [media.to_dict() for media in medias[(page - 1) * count: page * count]] if medias else []\n\n    @log_execution_time(logger=logger)\n    @cached(ttl=recommend_ttl, region=recommend_cache_region)\n    async def async_douban_movie_showing(self, page: Optional[int] = 1, count: Optional[int] = 30) -> List[dict]:\n        \"\"\"\n        异步豆瓣正在热映\n        \"\"\"\n        movies = await DoubanChain().async_run_module(\"async_movie_showing\", page=page, count=count)\n        return [media.to_dict() for media in movies] if movies else []\n\n    @log_execution_time(logger=logger)\n    @cached(ttl=recommend_ttl, region=recommend_cache_region)\n    async def async_douban_movies(self, sort: Optional[str] = \"R\", tags: Optional[str] = \"\",\n                                  page: Optional[int] = 1, count: Optional[int] = 30) -> List[dict]:\n        \"\"\"\n        异步豆瓣最新电影\n        \"\"\"\n        movies = await DoubanChain().async_run_module(\"async_douban_discover\", mtype=MediaType.MOVIE,\n                                                      sort=sort, tags=tags, page=page, count=count)\n        return [media.to_dict() for media in movies] if movies else []\n\n    @log_execution_time(logger=logger)\n    @cached(ttl=recommend_ttl, region=recommend_cache_region)\n    async def async_douban_tvs(self, sort: Optional[str] = \"R\", tags: Optional[str] = \"\",\n                               page: Optional[int] = 1, count: Optional[int] = 30) -> List[dict]:\n        \"\"\"\n        异步豆瓣最新电视剧\n        \"\"\"\n        tvs = await DoubanChain().async_run_module(\"async_douban_discover\", mtype=MediaType.TV,\n                                                   sort=sort, tags=tags, page=page, count=count)\n        return [media.to_dict() for media in tvs] if tvs else []\n\n    @log_execution_time(logger=logger)\n    @cached(ttl=recommend_ttl, region=recommend_cache_region)\n    async def async_douban_movie_top250(self, page: Optional[int] = 1, count: Optional[int] = 30) -> List[dict]:\n        \"\"\"\n        异步豆瓣电影TOP250\n        \"\"\"\n        movies = await DoubanChain().async_run_module(\"async_movie_top250\", page=page, count=count)\n        return [media.to_dict() for media in movies] if movies else []\n\n    @log_execution_time(logger=logger)\n    @cached(ttl=recommend_ttl, region=recommend_cache_region)\n    async def async_douban_tv_weekly_chinese(self, page: Optional[int] = 1, count: Optional[int] = 30) -> List[dict]:\n        \"\"\"\n        异步豆瓣国产剧集榜\n        \"\"\"\n        tvs = await DoubanChain().async_run_module(\"async_tv_weekly_chinese\", page=page, count=count)\n        return [media.to_dict() for media in tvs] if tvs else []\n\n    @log_execution_time(logger=logger)\n    @cached(ttl=recommend_ttl, region=recommend_cache_region)\n    async def async_douban_tv_weekly_global(self, page: Optional[int] = 1, count: Optional[int] = 30) -> List[dict]:\n        \"\"\"\n        异步豆瓣全球剧集榜\n        \"\"\"\n        tvs = await DoubanChain().async_run_module(\"async_tv_weekly_global\", page=page, count=count)\n        return [media.to_dict() for media in tvs] if tvs else []\n\n    @log_execution_time(logger=logger)\n    @cached(ttl=recommend_ttl, region=recommend_cache_region)\n    async def async_douban_tv_animation(self, page: Optional[int] = 1, count: Optional[int] = 30) -> List[dict]:\n        \"\"\"\n        异步豆瓣热门动漫\n        \"\"\"\n        tvs = await DoubanChain().async_run_module(\"async_tv_animation\", page=page, count=count)\n        return [media.to_dict() for media in tvs] if tvs else []\n\n    @log_execution_time(logger=logger)\n    @cached(ttl=recommend_ttl, region=recommend_cache_region)\n    async def async_douban_movie_hot(self, page: Optional[int] = 1, count: Optional[int] = 30) -> List[dict]:\n        \"\"\"\n        异步豆瓣热门电影\n        \"\"\"\n        movies = await DoubanChain().async_run_module(\"async_movie_hot\", page=page, count=count)\n        return [media.to_dict() for media in movies] if movies else []\n\n    @log_execution_time(logger=logger)\n    @cached(ttl=recommend_ttl, region=recommend_cache_region)\n    async def async_douban_tv_hot(self, page: Optional[int] = 1, count: Optional[int] = 30) -> List[dict]:\n        \"\"\"\n        异步豆瓣热门电视剧\n        \"\"\"\n        tvs = await DoubanChain().async_run_module(\"async_tv_hot\", page=page, count=count)\n        return [media.to_dict() for media in tvs] if tvs else []\n"
  },
  {
    "path": "app/chain/search.py",
    "content": "import asyncio\nimport random\nimport time\nfrom concurrent.futures import ThreadPoolExecutor, as_completed\nfrom datetime import datetime\nfrom typing import Dict, Tuple\nfrom typing import List, Optional\n\nfrom app.helper.sites import SitesHelper  # noqa\nfrom fastapi.concurrency import run_in_threadpool\n\nfrom app.chain import ChainBase\nfrom app.core.config import global_vars, settings\nfrom app.core.context import Context\nfrom app.core.context import MediaInfo, TorrentInfo\nfrom app.core.event import eventmanager, Event\nfrom app.core.metainfo import MetaInfo\nfrom app.db.systemconfig_oper import SystemConfigOper\nfrom app.helper.progress import ProgressHelper\nfrom app.helper.torrent import TorrentHelper\nfrom app.log import logger\nfrom app.schemas import NotExistMediaInfo\nfrom app.schemas.types import MediaType, ProgressKey, SystemConfigKey, EventType\n\n\nclass SearchChain(ChainBase):\n    \"\"\"\n    站点资源搜索处理链\n    \"\"\"\n\n    __result_temp_file = \"__search_result__\"\n    __ai_result_temp_file = \"__ai_search_result__\"\n\n    def search_by_id(self, tmdbid: Optional[int] = None, doubanid: Optional[str] = None,\n                     mtype: MediaType = None, area: Optional[str] = \"title\", season: Optional[int] = None,\n                     sites: List[int] = None, cache_local: bool = False) -> List[Context]:\n        \"\"\"\n        根据TMDBID/豆瓣ID搜索资源，精确匹配，不过滤本地存在的资源\n        :param tmdbid: TMDB ID\n        :param doubanid: 豆瓣 ID\n        :param mtype: 媒体，电影 or 电视剧\n        :param area: 搜索范围，title or imdbid\n        :param season: 季数\n        :param sites: 站点ID列表\n        :param cache_local: 是否缓存到本地\n        \"\"\"\n        mediainfo = self.recognize_media(tmdbid=tmdbid, doubanid=doubanid, mtype=mtype)\n        if not mediainfo:\n            logger.error(f'{tmdbid} 媒体信息识别失败！')\n            return []\n        no_exists = None\n        if season is not None:\n            no_exists = {\n                tmdbid or doubanid: {\n                    season: NotExistMediaInfo(episodes=[])\n                }\n            }\n        results = self.process(mediainfo=mediainfo, sites=sites, area=area, no_exists=no_exists)\n        # 保存到本地文件\n        if cache_local:\n            self.save_cache(results, self.__result_temp_file)\n        return results\n\n    def search_by_title(self, title: str, page: Optional[int] = 0,\n                        sites: List[int] = None, cache_local: Optional[bool] = False) -> List[Context]:\n        \"\"\"\n        根据标题搜索资源，不识别不过滤，直接返回站点内容\n        :param title: 标题，为空时返回所有站点首页内容\n        :param page: 页码\n        :param sites: 站点ID列表\n        :param cache_local: 是否缓存到本地\n        \"\"\"\n        if title:\n            logger.info(f'开始搜索资源，关键词：{title} ...')\n        else:\n            logger.info(f'开始浏览资源，站点：{sites} ...')\n        # 搜索\n        torrents = self.__search_all_sites(keyword=title, sites=sites, page=page) or []\n        if not torrents:\n            logger.warn(f'{title} 未搜索到资源')\n            return []\n        # 组装上下文\n        contexts = [Context(meta_info=MetaInfo(title=torrent.title, subtitle=torrent.description),\n                            torrent_info=torrent) for torrent in torrents]\n        # 保存到本地文件\n        if cache_local:\n            self.save_cache(contexts, self.__result_temp_file)\n        return contexts\n\n    def last_search_results(self) -> Optional[List[Context]]:\n        \"\"\"\n        获取上次搜索结果\n        \"\"\"\n        return self.load_cache(self.__result_temp_file)\n\n    async def async_last_search_results(self) -> Optional[List[Context]]:\n        \"\"\"\n        异步获取上次搜索结果\n        \"\"\"\n        return await self.async_load_cache(self.__result_temp_file)\n\n    async def async_last_ai_results(self) -> Optional[List[Context]]:\n        \"\"\"\n        异步获取上次AI推荐结果\n        \"\"\"\n        return await self.async_load_cache(self.__ai_result_temp_file)\n\n    async def async_save_ai_results(self, results: List[Context]):\n        \"\"\"\n        异步保存AI推荐结果\n        \"\"\"\n        await self.async_save_cache(results, self.__ai_result_temp_file)\n\n    async def async_search_by_id(self, tmdbid: Optional[int] = None, doubanid: Optional[str] = None,\n                                 mtype: MediaType = None, area: Optional[str] = \"title\", season: Optional[int] = None,\n                                 sites: List[int] = None, cache_local: bool = False) -> List[Context]:\n        \"\"\"\n        根据TMDBID/豆瓣ID异步搜索资源，精确匹配，不过滤本地存在的资源\n        :param tmdbid: TMDB ID\n        :param doubanid: 豆瓣 ID\n        :param mtype: 媒体，电影 or 电视剧\n        :param area: 搜索范围，title or imdbid\n        :param season: 季数\n        :param sites: 站点ID列表\n        :param cache_local: 是否缓存到本地\n        \"\"\"\n        mediainfo = await self.async_recognize_media(tmdbid=tmdbid, doubanid=doubanid, mtype=mtype)\n        if not mediainfo:\n            logger.error(f'{tmdbid} 媒体信息识别失败！')\n            return []\n        no_exists = None\n        if season is not None:\n            no_exists = {\n                tmdbid or doubanid: {\n                    season: NotExistMediaInfo(episodes=[])\n                }\n            }\n        results = await self.async_process(mediainfo=mediainfo, sites=sites, area=area, no_exists=no_exists)\n        # 保存到本地文件\n        if cache_local:\n            await self.async_save_cache(results, self.__result_temp_file)\n        return results\n\n    async def async_search_by_title(self, title: str, page: Optional[int] = 0,\n                                    sites: List[int] = None, cache_local: Optional[bool] = False) -> List[Context]:\n        \"\"\"\n        根据标题异步搜索资源，不识别不过滤，直接返回站点内容\n        :param title: 标题，为空时返回所有站点首页内容\n        :param page: 页码\n        :param sites: 站点ID列表\n        :param cache_local: 是否缓存到本地\n        \"\"\"\n        if title:\n            logger.info(f'开始搜索资源，关键词：{title} ...')\n        else:\n            logger.info(f'开始浏览资源，站点：{sites} ...')\n        # 搜索\n        torrents = await self.__async_search_all_sites(keyword=title, sites=sites, page=page) or []\n        if not torrents:\n            logger.warn(f'{title} 未搜索到资源')\n            return []\n        # 组装上下文\n        contexts = [Context(meta_info=MetaInfo(title=torrent.title, subtitle=torrent.description),\n                            torrent_info=torrent) for torrent in torrents]\n        # 保存到本地文件\n        if cache_local:\n            await self.async_save_cache(contexts, self.__result_temp_file)\n        return contexts\n\n    @staticmethod\n    def __prepare_params(mediainfo: MediaInfo,\n                         keyword: Optional[str] = None,\n                         no_exists: Dict[int, Dict[int, NotExistMediaInfo]] = None\n                         ) -> Tuple[Dict[int, List[int]], List[str]]:\n        \"\"\"\n        准备搜索参数\n        \"\"\"\n        # 缺失的季集\n        mediakey = mediainfo.tmdb_id or mediainfo.douban_id\n        if no_exists and no_exists.get(mediakey):\n            # 过滤剧集\n            season_episodes = {sea: info.episodes\n                               for sea, info in no_exists[mediakey].items()}\n        elif mediainfo.season is not None:\n            # 豆瓣只搜索当前季\n            season_episodes = {mediainfo.season: []}\n        else:\n            season_episodes = None\n\n        # 搜索关键词\n        if keyword:\n            keywords = [keyword]\n        else:\n            # 去重去空，但要保持顺序\n            keywords = list(dict.fromkeys([k for k in [mediainfo.title,\n                                                       mediainfo.original_title,\n                                                       mediainfo.en_title,\n                                                       mediainfo.hk_title,\n                                                       mediainfo.tw_title,\n                                                       mediainfo.sg_title] if k]))\n            # 限制搜索关键词数量\n            if settings.MAX_SEARCH_NAME_LIMIT:\n                keywords = keywords[:settings.MAX_SEARCH_NAME_LIMIT]\n\n        return season_episodes, keywords\n\n    def __parse_result(self, torrents: List[TorrentInfo],\n                       mediainfo: MediaInfo,\n                       keyword: Optional[str] = None,\n                       rule_groups: List[str] = None,\n                       season_episodes: Dict[int, List[int]] = None,\n                       custom_words: List[str] = None,\n                       filter_params: Dict[str, str] = None) -> List[Context]:\n        \"\"\"\n        处理搜索结果\n        \"\"\"\n\n        def __do_filter(torrent_list: List[TorrentInfo]) -> List[TorrentInfo]:\n            \"\"\"\n            执行优先级过滤\n            \"\"\"\n            return self.filter_torrents(rule_groups=rule_groups,\n                                        torrent_list=torrent_list,\n                                        mediainfo=mediainfo) or []\n\n        if not torrents:\n            logger.warn(f'{keyword or mediainfo.title} 未搜索到资源')\n            return []\n\n        # 开始新进度\n        progress = ProgressHelper(ProgressKey.Search)\n        progress.start()\n\n        # 开始过滤\n        progress.update(value=0, text=f'开始过滤，总 {len(torrents)} 个资源，请稍候...')\n        # 匹配订阅附加参数\n        if filter_params:\n            logger.info(f'开始附加参数过滤，附加参数：{filter_params} ...')\n            torrents = [torrent for torrent in torrents if TorrentHelper().filter_torrent(torrent, filter_params)]\n        # 开始过滤规则过滤\n        if rule_groups is None:\n            # 取搜索过滤规则\n            rule_groups: List[str] = SystemConfigOper().get(SystemConfigKey.SearchFilterRuleGroups)\n        if rule_groups:\n            logger.info(f'开始过滤规则/剧集过滤，使用规则组：{rule_groups} ...')\n            torrents = __do_filter(torrents)\n            if not torrents:\n                logger.warn(f'{keyword or mediainfo.title} 没有符合过滤规则的资源')\n                return []\n            logger.info(f\"过滤规则/剧集过滤完成，剩余 {len(torrents)} 个资源\")\n\n        # 过滤完成\n        progress.update(value=50, text=f'过滤完成，剩余 {len(torrents)} 个资源')\n\n        # 总数\n        _total = len(torrents)\n        # 已处理数\n        _count = 0\n\n        # 开始匹配\n        _match_torrents = []\n        torrenthelper = TorrentHelper()\n        try:\n            # 英文标题应该在别名/原标题中，不需要再匹配\n            logger.info(f\"开始匹配结果 标题：{mediainfo.title}，原标题：{mediainfo.original_title}，别名：{mediainfo.names}\")\n            progress.update(value=51, text=f'开始匹配，总 {_total} 个资源 ...')\n            for torrent in torrents:\n                if global_vars.is_system_stopped:\n                    break\n                _count += 1\n                progress.update(value=(_count / _total) * 96,\n                                text=f'正在匹配 {torrent.site_name}，已完成 {_count} / {_total} ...')\n                if not torrent.title:\n                    continue\n\n                # 识别元数据\n                torrent_meta = MetaInfo(title=torrent.title, subtitle=torrent.description,\n                                        custom_words=custom_words)\n                if torrent.title != torrent_meta.org_string:\n                    logger.info(f\"种子名称应用识别词后发生改变：{torrent.title} => {torrent_meta.org_string}\")\n                # 季集数过滤\n                if season_episodes \\\n                        and not TorrentHelper.match_season_episodes(torrent=torrent,\n                                                                    meta=torrent_meta,\n                                                                    season_episodes=season_episodes):\n                    continue\n                # 比对IMDBID\n                if torrent.imdbid \\\n                        and mediainfo.imdb_id \\\n                        and torrent.imdbid == mediainfo.imdb_id:\n                    logger.info(f'{mediainfo.title} 通过IMDBID匹配到资源：{torrent.site_name} - {torrent.title}')\n                    _match_torrents.append((torrent, torrent_meta))\n                    continue\n\n                # 比对种子\n                if torrenthelper.match_torrent(mediainfo=mediainfo,\n                                               torrent_meta=torrent_meta,\n                                               torrent=torrent):\n                    # 匹配成功\n                    _match_torrents.append((torrent, torrent_meta))\n                    continue\n            # 匹配完成\n            logger.info(f\"匹配完成，共匹配到 {len(_match_torrents)} 个资源\")\n            progress.update(value=97,\n                            text=f'匹配完成，共匹配到 {len(_match_torrents)} 个资源')\n\n            # 去掉mediainfo中多余的数据\n            mediainfo.clear()\n            # 组装上下文\n            contexts = [Context(torrent_info=t[0],\n                                media_info=mediainfo,\n                                meta_info=t[1]) for t in _match_torrents]\n        finally:\n            torrents.clear()\n            del torrents\n            _match_torrents.clear()\n            del _match_torrents\n\n        # 排序\n        progress.update(value=99,\n                        text=f'正在对 {len(contexts)} 个资源进行排序，请稍候...')\n        contexts = torrenthelper.sort_torrents(contexts)\n\n        # 结束进度\n        logger.info(f'搜索完成，共 {len(contexts)} 个资源')\n        progress.update(value=100,\n                        text=f'搜索完成，共 {len(contexts)} 个资源')\n        progress.end()\n\n        # 去重后返回\n        return self.__remove_duplicate(contexts)\n\n    @staticmethod\n    def __remove_duplicate(_torrents: List[Context]) -> List[Context]:\n        \"\"\"\n        去除重复的种子\n        :param _torrents: 种子列表\n        :return: 去重后的种子列表\n        \"\"\"\n        return list({f\"{t.torrent_info.site_name}_{t.torrent_info.title}_{t.torrent_info.description}\": t\n                     for t in _torrents}.values())\n\n    def process(self, mediainfo: MediaInfo,\n                keyword: Optional[str] = None,\n                no_exists: Dict[int, Dict[int, NotExistMediaInfo]] = None,\n                sites: List[int] = None,\n                rule_groups: List[str] = None,\n                area: Optional[str] = \"title\",\n                custom_words: List[str] = None,\n                filter_params: Dict[str, str] = None) -> List[Context]:\n        \"\"\"\n        根据媒体信息搜索种子资源，精确匹配，应用过滤规则，同时根据no_exists过滤本地已存在的资源\n        :param mediainfo: 媒体信息\n        :param keyword: 搜索关键词\n        :param no_exists: 缺失的媒体信息\n        :param sites: 站点ID列表，为空时搜索所有站点\n        :param rule_groups: 过滤规则组名称列表\n        :param area: 搜索范围，title or imdbid\n        :param custom_words: 自定义识别词列表\n        :param filter_params: 过滤参数\n        \"\"\"\n\n        # 豆瓣标题处理\n        if not mediainfo.tmdb_id:\n            meta = MetaInfo(title=mediainfo.title)\n            mediainfo.title = meta.name\n            mediainfo.season = meta.begin_season\n        logger.info(f'开始搜索资源，关键词：{keyword or mediainfo.title} ...')\n\n        # 补充媒体信息\n        if not mediainfo.names:\n            mediainfo: MediaInfo = self.recognize_media(mtype=mediainfo.type,\n                                                        tmdbid=mediainfo.tmdb_id,\n                                                        doubanid=mediainfo.douban_id)\n            if not mediainfo:\n                logger.error(f'媒体信息识别失败！')\n                return []\n\n        # 准备搜索参数\n        season_episodes, keywords = self.__prepare_params(\n            mediainfo=mediainfo,\n            keyword=keyword,\n            no_exists=no_exists\n        )\n\n        # 站点搜索结果\n        torrents: List[TorrentInfo] = []\n        # 站点搜索次数\n        search_count = 0\n\n        # 多关键字执行搜索\n        for search_word in keywords:\n            # 强制休眠 1-10 秒\n            if search_count > 0:\n                logger.info(f\"已搜索 {search_count} 次，强制休眠 1-10 秒 ...\")\n                time.sleep(random.randint(1, 10))\n\n            # 搜索站点\n            results = self.__search_all_sites(\n                mediainfo=mediainfo,\n                keyword=search_word,\n                sites=sites,\n                area=area\n            ) or []\n            # 合并结果\n\n            search_count += 1\n            torrents.extend(results)\n\n            # 有结果则停止\n            if not settings.SEARCH_MULTIPLE_NAME and torrents:\n                logger.info(f\"共搜索到 {len(torrents)} 个资源，停止搜索\")\n                break\n\n        # 处理结果\n        return self.__parse_result(\n            torrents=torrents,\n            mediainfo=mediainfo,\n            keyword=keyword,\n            rule_groups=rule_groups,\n            season_episodes=season_episodes,\n            custom_words=custom_words,\n            filter_params=filter_params\n        )\n\n    async def async_process(self, mediainfo: MediaInfo,\n                            keyword: Optional[str] = None,\n                            no_exists: Dict[int, Dict[int, NotExistMediaInfo]] = None,\n                            sites: List[int] = None,\n                            rule_groups: List[str] = None,\n                            area: Optional[str] = \"title\",\n                            custom_words: List[str] = None,\n                            filter_params: Dict[str, str] = None) -> List[Context]:\n        \"\"\"\n        根据媒体信息异步搜索种子资源，精确匹配，应用过滤规则，同时根据no_exists过滤本地已存在的资源\n        :param mediainfo: 媒体信息\n        :param keyword: 搜索关键词\n        :param no_exists: 缺失的媒体信息\n        :param sites: 站点ID列表，为空时搜索所有站点\n        :param rule_groups: 过滤规则组名称列表\n        :param area: 搜索范围，title or imdbid\n        :param custom_words: 自定义识别词列表\n        :param filter_params: 过滤参数\n        \"\"\"\n\n        # 豆瓣标题处理\n        if not mediainfo.tmdb_id:\n            meta = MetaInfo(title=mediainfo.title)\n            mediainfo.title = meta.name\n            mediainfo.season = meta.begin_season\n        logger.info(f'开始搜索资源，关键词：{keyword or mediainfo.title} ...')\n\n        # 补充媒体信息\n        if not mediainfo.names:\n            mediainfo: MediaInfo = await self.async_recognize_media(mtype=mediainfo.type,\n                                                                    tmdbid=mediainfo.tmdb_id,\n                                                                    doubanid=mediainfo.douban_id)\n            if not mediainfo:\n                logger.error(f'媒体信息识别失败！')\n                return []\n\n        # 准备搜索参数\n        season_episodes, keywords = self.__prepare_params(\n            mediainfo=mediainfo,\n            keyword=keyword,\n            no_exists=no_exists\n        )\n\n        # 站点搜索结果\n        torrents: List[TorrentInfo] = []\n        # 站点搜索次数\n        search_count = 0\n\n        # 多关键字执行搜索\n        for search_word in keywords:\n            # 强制休眠 1-10 秒\n            if search_count > 0:\n                logger.info(f\"已搜索 {search_count} 次，强制休眠 1-10 秒 ...\")\n                await asyncio.sleep(random.randint(1, 10))\n            # 搜索站点\n            torrents.extend(\n                await self.__async_search_all_sites(\n                    mediainfo=mediainfo,\n                    keyword=search_word,\n                    sites=sites,\n                    area=area\n                ) or []\n            )\n            search_count += 1\n            # 有结果则停止\n            if torrents:\n                logger.info(f\"共搜索到 {len(torrents)} 个资源，停止搜索\")\n                break\n\n        # 处理结果\n        return await run_in_threadpool(self.__parse_result,\n                                       torrents=torrents,\n                                       mediainfo=mediainfo,\n                                       keyword=keyword,\n                                       rule_groups=rule_groups,\n                                       season_episodes=season_episodes,\n                                       custom_words=custom_words,\n                                       filter_params=filter_params\n                                       )\n\n    def __search_all_sites(self, keyword: str,\n                           mediainfo: Optional[MediaInfo] = None,\n                           sites: List[int] = None,\n                           page: Optional[int] = 0,\n                           area: Optional[str] = \"title\") -> Optional[List[TorrentInfo]]:\n        \"\"\"\n        多线程搜索多个站点\n        :param mediainfo:  识别的媒体信息\n        :param keyword:  搜索关键词\n        :param sites:  指定站点ID列表，如有则只搜索指定站点，否则搜索所有站点\n        :param page:  搜索页码\n        :param area:  搜索区域 title or imdbid\n        :reutrn: 资源列表\n        \"\"\"\n        # 未开启的站点不搜索\n        indexer_sites = []\n\n        # 配置的索引站点\n        if not sites:\n            sites = SystemConfigOper().get(SystemConfigKey.IndexerSites) or []\n\n        for indexer in SitesHelper().get_indexers():\n            # 检查站点索引开关\n            if not sites or indexer.get(\"id\") in sites:\n                indexer_sites.append(indexer)\n        if not indexer_sites:\n            logger.warn('未开启任何有效站点，无法搜索资源')\n            return []\n\n        # 开始进度\n        progress = ProgressHelper(ProgressKey.Search)\n        progress.start()\n        # 开始计时\n        start_time = datetime.now()\n        # 总数\n        total_num = len(indexer_sites)\n        # 完成数\n        finish_count = 0\n        # 更新进度\n        progress.update(value=0,\n                        text=f\"开始搜索，共 {total_num} 个站点 ...\")\n        # 结果集\n        results = []\n        # 多线程\n        with ThreadPoolExecutor(max_workers=len(indexer_sites)) as executor:\n            all_task = []\n            for site in indexer_sites:\n                if area == \"imdbid\":\n                    # 搜索IMDBID\n                    task = executor.submit(self.search_torrents, site=site,\n                                           keyword=mediainfo.imdb_id if mediainfo else None,\n                                           mtype=mediainfo.type if mediainfo else None,\n                                           page=page)\n                else:\n                    # 搜索标题\n                    task = executor.submit(self.search_torrents, site=site,\n                                           keyword=keyword,\n                                           mtype=mediainfo.type if mediainfo else None,\n                                           page=page)\n                all_task.append(task)\n            for future in as_completed(all_task):\n                if global_vars.is_system_stopped:\n                    break\n                finish_count += 1\n                result = future.result()\n                if result:\n                    results.extend(result)\n                logger.info(f\"站点搜索进度：{finish_count} / {total_num}\")\n                progress.update(value=finish_count / total_num * 100,\n                                text=f\"正在搜索{keyword or ''}，已完成 {finish_count} / {total_num} 个站点 ...\")\n        # 计算耗时\n        end_time = datetime.now()\n        # 更新进度\n        progress.update(value=100,\n                        text=f\"站点搜索完成，有效资源数：{len(results)}，总耗时 {(end_time - start_time).seconds} 秒\")\n        logger.info(f\"站点搜索完成，有效资源数：{len(results)}，总耗时 {(end_time - start_time).seconds} 秒\")\n        # 结束进度\n        progress.end()\n\n        # 返回\n        return results\n\n    async def __async_search_all_sites(self, keyword: str,\n                                       mediainfo: Optional[MediaInfo] = None,\n                                       sites: List[int] = None,\n                                       page: Optional[int] = 0,\n                                       area: Optional[str] = \"title\") -> Optional[List[TorrentInfo]]:\n        \"\"\"\n        异步搜索多个站点\n        :param mediainfo:  识别的媒体信息\n        :param keyword:  搜索关键词\n        :param sites:  指定站点ID列表，如有则只搜索指定站点，否则搜索所有站点\n        :param page:  搜索页码\n        :param area:  搜索区域 title or imdbid\n        :reutrn: 资源列表\n        \"\"\"\n        # 未开启的站点不搜索\n        indexer_sites = []\n\n        # 配置的索引站点\n        if not sites:\n            sites = SystemConfigOper().get(SystemConfigKey.IndexerSites) or []\n\n        for indexer in await SitesHelper().async_get_indexers():\n            # 检查站点索引开关\n            if not sites or indexer.get(\"id\") in sites:\n                indexer_sites.append(indexer)\n        if not indexer_sites:\n            logger.warn('未开启任何有效站点，无法搜索资源')\n            return []\n\n        # 开始进度\n        progress = ProgressHelper(ProgressKey.Search)\n        progress.start()\n        # 开始计时\n        start_time = datetime.now()\n        # 总数\n        total_num = len(indexer_sites)\n        # 完成数\n        finish_count = 0\n        # 更新进度\n        progress.update(value=0,\n                        text=f\"开始搜索，共 {total_num} 个站点 ...\")\n        # 结果集\n        results = []\n\n        # 创建异步任务列表\n        tasks = []\n        for site in indexer_sites:\n            if area == \"imdbid\":\n                # 搜索IMDBID\n                task = self.async_search_torrents(site=site,\n                                                  keyword=mediainfo.imdb_id if mediainfo else None,\n                                                  mtype=mediainfo.type if mediainfo else None,\n                                                  page=page)\n            else:\n                # 搜索标题\n                task = self.async_search_torrents(site=site,\n                                                  keyword=keyword,\n                                                  mtype=mediainfo.type if mediainfo else None,\n                                                  page=page)\n            tasks.append(task)\n\n        # 使用asyncio.as_completed来处理并发任务\n        for future in asyncio.as_completed(tasks):\n            if global_vars.is_system_stopped:\n                break\n            finish_count += 1\n            result = await future\n            if result:\n                results.extend(result)\n            logger.info(f\"站点搜索进度：{finish_count} / {total_num}\")\n            progress.update(value=finish_count / total_num * 100,\n                            text=f\"正在搜索{keyword or ''}，已完成 {finish_count} / {total_num} 个站点 ...\")\n\n        # 计算耗时\n        end_time = datetime.now()\n        # 更新进度\n        progress.update(value=100,\n                        text=f\"站点搜索完成，有效资源数：{len(results)}，总耗时 {(end_time - start_time).seconds} 秒\")\n        logger.info(f\"站点搜索完成，有效资源数：{len(results)}，总耗时 {(end_time - start_time).seconds} 秒\")\n        # 结束进度\n        progress.end()\n\n        # 返回\n        return results\n\n    @eventmanager.register(EventType.SiteDeleted)\n    def remove_site(self, event: Event):\n        \"\"\"\n        从搜索站点中移除与已删除站点相关的设置\n        \"\"\"\n        if not event:\n            return\n        event_data = event.event_data or {}\n        site_id = event_data.get(\"site_id\")\n        if not site_id:\n            return\n        if site_id == \"*\":\n            # 清空搜索站点\n            SystemConfigOper().set(SystemConfigKey.IndexerSites, [])\n            return\n        # 从选中的rss站点中移除\n        selected_sites = SystemConfigOper().get(SystemConfigKey.IndexerSites) or []\n        if site_id in selected_sites:\n            selected_sites.remove(site_id)\n            SystemConfigOper().set(SystemConfigKey.IndexerSites, selected_sites)\n"
  },
  {
    "path": "app/chain/site.py",
    "content": "import base64\nimport re\nfrom datetime import datetime\nfrom typing import Optional, Tuple, Union, Dict\nfrom urllib.parse import urljoin\n\nfrom lxml import etree\n\nfrom app.chain import ChainBase\nfrom app.core.config import global_vars, settings\nfrom app.core.event import Event, eventmanager\nfrom app.db.models.site import Site\nfrom app.db.site_oper import SiteOper\nfrom app.db.systemconfig_oper import SystemConfigOper\nfrom app.helper.browser import PlaywrightHelper\nfrom app.helper.cloudflare import under_challenge\nfrom app.helper.cookie import CookieHelper\nfrom app.helper.cookiecloud import CookieCloudHelper\nfrom app.helper.rss import RssHelper\nfrom app.helper.sites import SitesHelper  # noqa\nfrom app.log import logger\nfrom app.schemas import MessageChannel, Notification, SiteUserData\nfrom app.schemas.types import EventType, NotificationType\nfrom app.utils.http import RequestUtils\nfrom app.utils.site import SiteUtils\nfrom app.utils.string import StringUtils\n\n\nclass SiteChain(ChainBase):\n    \"\"\"\n    站点管理处理链\n    \"\"\"\n\n    def __init__(self):\n        super().__init__()\n\n        # 特殊站点登录验证\n        self.special_site_test = {\n            \"zhuque.in\": self.__zhuque_test,\n            \"m-team.io\": self.__mteam_test,\n            \"m-team.cc\": self.__mteam_test,\n            \"ptlsp.com\": self.__indexphp_test,\n            \"1ptba.com\": self.__indexphp_test,\n            \"star-space.net\": self.__indexphp_test,\n            \"yemapt.org\": self.__yema_test,\n            \"hddolby.com\": self.__hddolby_test,\n            \"rousi.pro\": self.__rousi_test,\n        }\n\n    def refresh_userdata(self, site: dict = None) -> Optional[SiteUserData]:\n        \"\"\"\n        刷新站点的用户数据\n        :param site:  站点\n        :return: 用户数据\n        \"\"\"\n        userdata: SiteUserData = self.run_module(\"refresh_userdata\", site=site)\n        if userdata:\n            SiteOper().update_userdata(domain=StringUtils.get_url_domain(site.get(\"domain\")),\n                                       name=site.get(\"name\"),\n                                       payload=userdata.model_dump())\n            # 发送事件\n            eventmanager.send_event(EventType.SiteRefreshed, {\n                \"site_id\": site.get(\"id\")\n            })\n            # 发送站点消息\n            if userdata.message_unread:\n                if userdata.message_unread_contents and len(userdata.message_unread_contents) > 0:\n                    for head, date, content in userdata.message_unread_contents:\n                        msg_title = f\"【站点 {site.get('name')} 消息】\"\n                        msg_text = f\"时间：{date}\\n标题：{head}\\n内容：\\n{content}\"\n                        self.post_message(Notification(\n                            mtype=NotificationType.SiteMessage,\n                            title=msg_title, text=msg_text, link=site.get(\"url\")\n                        ))\n                else:\n                    self.post_message(Notification(\n                        mtype=NotificationType.SiteMessage,\n                        title=f\"站点 {site.get('name')} 收到 \"\n                              f\"{userdata.message_unread} 条新消息，请登陆查看\",\n                        link=site.get(\"url\")\n                    ))\n            # 低分享率警告\n            if userdata.ratio and float(userdata.ratio) < 1 and not bool(\n                    re.search(r\"(贵宾|VIP?)\", userdata.user_level or \"\", re.IGNORECASE)):\n                self.post_message(Notification(\n                    mtype=NotificationType.SiteMessage,\n                    title=f\"【站点分享率低预警】\",\n                    text=f\"站点 {site.get('name')} 分享率 {userdata.ratio}，请注意！\"\n                ))\n        return userdata\n\n    def refresh_userdatas(self) -> Optional[Dict[str, SiteUserData]]:\n        \"\"\"\n        刷新所有站点的用户数据\n        \"\"\"\n        any_site_updated = False\n        result = {}\n        for site in SitesHelper().get_indexers():\n            if global_vars.is_system_stopped:\n                return None\n            if site.get(\"is_active\"):\n                userdata = self.refresh_userdata(site)\n                if userdata:\n                    any_site_updated = True\n                    result[site.get(\"name\")] = userdata\n        if any_site_updated:\n            eventmanager.send_event(EventType.SiteRefreshed, {\n                \"site_id\": \"*\"\n            })\n\n        return result\n\n    def is_special_site(self, domain: str) -> bool:\n        \"\"\"\n        判断是否特殊站点\n        \"\"\"\n        return domain in self.special_site_test\n\n    @staticmethod\n    def __zhuque_test(site: Site) -> Tuple[bool, str]:\n        \"\"\"\n        判断站点是否已经登陆：zhuique\n        \"\"\"\n        # 获取token\n        token = None\n        user_agent = site.ua or settings.USER_AGENT\n        res = RequestUtils(\n            ua=user_agent,\n            cookies=site.cookie,\n            proxies=settings.PROXY if site.proxy else None,\n            timeout=site.timeout or 15\n        ).get_res(url=site.url)\n        if res is None:\n            return False, \"无法打开网站！\"\n        if res.status_code == 200:\n            csrf_token = re.search(r'<meta name=\"x-csrf-token\" content=\"(.+?)\">', res.text)\n            if csrf_token:\n                token = csrf_token.group(1)\n        else:\n            return False, f\"错误：{res.status_code} {res.reason}\"\n        if not token:\n            return False, \"无法获取Token\"\n        # 调用查询用户信息接口\n        user_res = RequestUtils(\n            headers={\n                'X-CSRF-TOKEN': token,\n                \"Content-Type\": \"application/json; charset=utf-8\",\n                \"User-Agent\": f\"{user_agent}\"\n            },\n            cookies=site.cookie,\n            proxies=settings.PROXY if site.proxy else None,\n            timeout=site.timeout or 15\n        ).get_res(url=f\"{site.url}api/user/getInfo\")\n        if user_res is None:\n            return False, \"无法打开网站！\"\n        if user_res.status_code == 200:\n            user_info = user_res.json()\n            if user_info and user_info.get(\"data\"):\n                return True, \"连接成功\"\n            return False, \"Cookie已失效\"\n        else:\n            return False, f\"错误：{user_res.status_code} {user_res.reason}\"\n\n    @staticmethod\n    def __mteam_test(site: Site) -> Tuple[bool, str]:\n        \"\"\"\n        判断站点是否已经登陆：m-team\n        \"\"\"\n        user_agent = site.ua or settings.USER_AGENT\n        domain = StringUtils.get_url_domain(site.url)\n        url = f\"https://api.{domain}/api/member/profile\"\n        headers = {\n            \"User-Agent\": user_agent,\n            \"Accept\": \"application/json, text/plain, */*\",\n            \"x-api-key\": site.apikey,\n        }\n        res = RequestUtils(\n            headers=headers,\n            proxies=settings.PROXY if site.proxy else None,\n            timeout=site.timeout or 15\n        ).post_res(url=url)\n        if res is None:\n            return False, \"无法打开网站！\"\n        if res.status_code == 200:\n            user_info = res.json() or {}\n            if user_info.get(\"data\"):\n                return True, \"连接成功\"\n            return False, user_info.get(\"message\", \"鉴权已过期或无效\")\n        else:\n            return False, f\"错误：{res.status_code} {res.reason}\"\n\n    @staticmethod\n    def __yema_test(site: Site) -> Tuple[bool, str]:\n        \"\"\"\n        判断站点是否已经登陆：yemapt\n        \"\"\"\n        user_agent = site.ua or settings.USER_AGENT\n        url = f\"{site.url}api/consumer/fetchSelfDetail\"\n        headers = {\n            \"User-Agent\": user_agent,\n            \"Content-Type\": \"application/json\",\n            \"Accept\": \"application/json, text/plain, */*\",\n        }\n        res = RequestUtils(\n            headers=headers,\n            cookies=site.cookie,\n            proxies=settings.PROXY if site.proxy else None,\n            timeout=site.timeout or 15\n        ).get_res(url=url)\n        if res is None:\n            return False, \"无法打开网站！\"\n        if res.status_code == 200:\n            user_info = res.json()\n            if user_info and user_info.get(\"success\"):\n                return True, \"连接成功\"\n            return False, \"Cookie已过期\"\n        else:\n            return False, f\"错误：{res.status_code} {res.reason}\"\n\n    def __indexphp_test(self, site: Site) -> Tuple[bool, str]:\n        \"\"\"\n        判断站点是否已经登陆：ptlsp/1ptba\n        \"\"\"\n        site.url = f\"{site.url}index.php\"\n        return self.__test(site)\n\n    @staticmethod\n    def __hddolby_test(site: Site) -> Tuple[bool, str]:\n        \"\"\"\n        判断站点是否已经登陆：hddolby\n        \"\"\"\n        url = f\"{site.url}api/v1/user/data\"\n        headers = {\n            \"Content-Type\": \"application/json\",\n            \"Accept\": \"application/json, text/plain, */*\",\n            \"x-api-key\": site.apikey,\n        }\n        res = RequestUtils(\n            headers=headers,\n            proxies=settings.PROXY if site.proxy else None,\n            timeout=site.timeout or 15\n        ).get_res(url=url)\n        if res is None:\n            return False, \"无法打开网站！\"\n        if res.status_code == 200:\n            user_info = res.json()\n            if user_info and user_info.get(\"status\") == 0:\n                return True, \"连接成功\"\n            return False, \"APIKEY已过期\"\n        else:\n            return False, f\"错误：{res.status_code} {res.reason}\"\n\n    @staticmethod\n    def __rousi_test(site: Site) -> Tuple[bool, str]:\n        \"\"\"\n        判断站点是否已经登陆：rousi\n        \"\"\"\n        url = f\"https://{StringUtils.get_url_domain(site.url)}/api/v1/profile\"\n        headers = {\n            \"Content-Type\": \"application/json\",\n            \"Accept\": \"application/json\",\n            \"Authorization\": f\"Bearer {site.apikey}\",\n        }\n        res = RequestUtils(\n            headers=headers,\n            proxies=settings.PROXY if site.proxy else None,\n            timeout=site.timeout or 15\n        ).get_res(url=url)\n        if res is None:\n            return False, \"无法打开网站！\"\n        if res.status_code == 200:\n            user_info = res.json()\n            if user_info and user_info.get(\"code\") == 0:\n                return True, \"连接成功\"\n            return False, \"APIKEY已过期\"\n        else:\n            return False, f\"错误：{res.status_code} {res.reason}\"\n\n    @staticmethod\n    def __parse_favicon(url: str, cookie: str, ua: str) -> Tuple[str, Optional[str]]:\n        \"\"\"\n        解析站点favicon,返回base64 fav图标\n        :param url: 站点地址\n        :param cookie: Cookie\n        :param ua: User-Agent\n        :return:\n        \"\"\"\n        favicon_url = urljoin(url, \"favicon.ico\")\n        res = RequestUtils(cookies=cookie, timeout=30, ua=ua).get_res(url=url)\n        if res:\n            html_text = res.text\n        else:\n            logger.error(f\"获取站点页面失败：{url}\")\n            return favicon_url, None\n        html = etree.HTML(html_text)\n        try:\n            if StringUtils.is_valid_html_element(html):\n                fav_link = html.xpath('//head/link[contains(@rel, \"icon\")]/@href')\n                if fav_link:\n                    favicon_url = urljoin(url, fav_link[0])\n\n            res = RequestUtils(cookies=cookie, timeout=15, ua=ua).get_res(url=favicon_url)\n            if res:\n                return favicon_url, base64.b64encode(res.content).decode()\n            else:\n                logger.error(f\"获取站点图标失败：{favicon_url}\")\n        finally:\n            if html is not None:\n                del html\n        return favicon_url, None\n\n    def sync_cookies(self, manual=False) -> Tuple[bool, str]:\n        \"\"\"\n        通过CookieCloud同步站点Cookie\n        \"\"\"\n\n        def __indexer_domain(inx: dict, sub_domain: str) -> str:\n            \"\"\"\n            根据主域名获取索引器地址\n            \"\"\"\n            if StringUtils.get_url_domain(inx.get(\"domain\")) == sub_domain:\n                return inx.get(\"domain\")\n            for ext_d in inx.get(\"ext_domains\", []):\n                if StringUtils.get_url_domain(ext_d) == sub_domain:\n                    return ext_d\n            return sub_domain\n\n        logger.info(\"开始同步CookieCloud站点 ...\")\n        cookies, msg = CookieCloudHelper().download()\n        if not cookies:\n            logger.error(f\"CookieCloud同步失败：{msg}\")\n            if manual:\n                self.messagehelper.put(msg, title=\"CookieCloud同步失败\", role=\"system\")\n            return False, msg\n        # 保存Cookie或新增站点\n        _update_count = 0\n        _add_count = 0\n        _fail_count = 0\n        siteshelper = SitesHelper()\n        siteoper = SiteOper()\n        rsshelper = RssHelper()\n        for domain, cookie in cookies.items():\n            # 检查系统是否停止\n            if global_vars.is_system_stopped:\n                logger.info(\"系统正在停止，中断CookieCloud同步\")\n                return False, \"系统正在停止，同步被中断\"\n                \n            # 索引器信息\n            indexer = siteshelper.get_indexer(domain)\n            # 数据库的站点信息\n            site_info = siteoper.get_by_domain(domain)\n            if site_info and site_info.is_active:\n                # 站点已存在，检查站点连通性\n                status, msg = self.test(domain)\n                # 更新站点Cookie\n                if status:\n                    logger.info(f\"站点【{site_info.name}】连通性正常，不同步CookieCloud数据\")\n                    # 更新站点rss地址\n                    if not site_info.public and not site_info.rss:\n                        # 自动生成rss地址\n                        rss_url, errmsg = rsshelper.get_rss_link(\n                            url=site_info.url,\n                            cookie=cookie,\n                            ua=site_info.ua or settings.USER_AGENT,\n                            proxy=True if site_info.proxy else False,\n                            timeout=site_info.timeout or 15\n                        )\n                        if rss_url:\n                            logger.info(f\"更新站点 {domain} RSS地址 ...\")\n                            siteoper.update_rss(domain=domain, rss=rss_url)\n                        else:\n                            logger.warn(errmsg)\n                    continue\n                # 更新站点Cookie\n                logger.info(f\"更新站点 {domain} Cookie ...\")\n                siteoper.update_cookie(domain=domain, cookies=cookie)\n                _update_count += 1\n            elif indexer:\n                if settings.COOKIECLOUD_BLACKLIST and any(\n                        StringUtils.get_url_domain(domain) == StringUtils.get_url_domain(black_domain) for black_domain\n                        in str(settings.COOKIECLOUD_BLACKLIST).split(\",\")):\n                    logger.warn(f\"站点 {domain} 已在黑名单中，不添加站点\")\n                    continue\n                # 新增站点\n                domain_url = __indexer_domain(inx=indexer, sub_domain=domain)\n                proxy = False\n                res = RequestUtils(cookies=cookie,\n                                   ua=settings.USER_AGENT\n                                   ).get_res(url=domain_url)\n                if res and res.status_code in [200, 500, 403]:\n                    content = res.text\n                    if not indexer.get(\"public\") and not SiteUtils.is_logged_in(content):\n                        _fail_count += 1\n                        if under_challenge(content):\n                            logger.warn(f\"站点 {indexer.get('name')} 被Cloudflare防护，无法登录，无法添加站点\")\n                            continue\n                        logger.warn(\n                            f\"站点 {indexer.get('name')} 登录失败，没有该站点账号或Cookie已失效，无法添加站点\")\n                        continue\n                elif res is not None:\n                    _fail_count += 1\n                    logger.warn(f\"站点 {indexer.get('name')} 连接状态码：{res.status_code}，无法添加站点\")\n                    continue\n                else:\n                    if not settings.PROXY_HOST:\n                        _fail_count += 1\n                        logger.warn(f\"站点 {indexer.get('name')} 连接失败，无法添加站点\")\n                        continue\n                    else:\n                        # 如果配置了代理，尝试通过代理重试\n                        logger.info(f\"站点 {indexer.get('name')} 初次连接失败，尝试通过代理重试...\")\n                        proxy = True\n                        res = RequestUtils(cookies=cookie,\n                                           ua=settings.USER_AGENT,\n                                           proxies=settings.PROXY\n                                           ).get_res(url=domain_url)\n                        if res and res.status_code in [200, 500, 403]:\n                            if not indexer.get(\"public\") and not SiteUtils.is_logged_in(res.text):\n                                logger.warn(f\"站点 {indexer.get('name')} 登录失败，即使通过代理，无法添加站点\")\n                                _fail_count += 1\n                                continue\n                            logger.info(f\"站点 {indexer.get('name')} 通过代理连接成功\")\n                        else:\n                            logger.warn(f\"站点 {indexer.get('name')} 通过代理连接失败，无法添加站点\")\n                            _fail_count += 1\n                            continue\n\n                # 获取rss地址\n                rss_url = None\n                if not indexer.get(\"public\") and domain_url:\n                    # 自动生成rss地址\n                    rss_url, errmsg = rsshelper.get_rss_link(url=domain_url,\n                                                             cookie=cookie,\n                                                             ua=settings.USER_AGENT,\n                                                             proxy=proxy)\n                    if errmsg:\n                        logger.warn(errmsg)\n                # 插入数据库\n                logger.info(f\"新增站点 {indexer.get('name')} ...\")\n                siteoper.add(name=indexer.get(\"name\"),\n                             url=domain_url,\n                             domain=domain,\n                             cookie=cookie,\n                             rss=rss_url,\n                             proxy=1 if proxy else 0,\n                             public=1 if indexer.get(\"public\") else 0)\n                _add_count += 1\n\n            # 通知站点更新\n            if indexer:\n                eventmanager.send_event(EventType.SiteUpdated, {\n                    \"domain\": domain,\n                })\n        # 处理完成\n        ret_msg = f\"更新了{_update_count}个站点，新增了{_add_count}个站点\"\n        if _fail_count > 0:\n            ret_msg += f\"，{_fail_count}个站点添加失败，下次同步时将重试，也可以手动添加\"\n        if manual:\n            self.messagehelper.put(ret_msg, title=\"CookieCloud同步成功\", role=\"system\")\n        logger.info(f\"CookieCloud同步成功：{ret_msg}\")\n        return True, ret_msg\n\n    @eventmanager.register(EventType.SiteUpdated)\n    def cache_site_icon(self, event: Event):\n        \"\"\"\n        缓存站点图标\n        \"\"\"\n        if not event:\n            return\n        event_data = event.event_data or {}\n        # 主域名\n        domain = event_data.get(\"domain\")\n        if not domain:\n            return\n        if str(domain).startswith(\"http\"):\n            domain = StringUtils.get_url_domain(domain)\n        # 站点信息\n        siteoper = SiteOper()\n        siteshelper = SitesHelper()\n        siteinfo = siteoper.get_by_domain(domain)\n        if not siteinfo:\n            logger.warn(f\"未维护站点 {domain} 信息！\")\n            return\n        # Cookie\n        cookie = siteinfo.cookie\n        # 索引器\n        indexer = siteshelper.get_indexer(domain)\n        if not indexer:\n            logger.warn(f\"站点 {domain} 索引器不存在！\")\n            return\n        # 查询站点图标\n        logger.info(f\"开始缓存站点 {indexer.get('name')} 图标 ...\")\n        icon_url, icon_base64 = self.__parse_favicon(url=indexer.get(\"domain\"),\n                                                     cookie=cookie,\n                                                     ua=settings.USER_AGENT)\n        if icon_url:\n            siteoper.update_icon(name=indexer.get(\"name\"),\n                                 domain=domain,\n                                 icon_url=icon_url,\n                                 icon_base64=icon_base64)\n            logger.info(f\"缓存站点 {indexer.get('name')} 图标成功\")\n        else:\n            logger.warn(f\"缓存站点 {indexer.get('name')} 图标失败\")\n\n    @eventmanager.register(EventType.SiteUpdated)\n    def clear_site_data(self, event: Event):\n        \"\"\"\n        清理站点数据\n        \"\"\"\n        if not event:\n            return\n        event_data = event.event_data or {}\n        # 主域名\n        domain = event_data.get(\"domain\")\n        if not domain:\n            return\n        # 获取主域名中间那段\n        domain_host = StringUtils.get_url_host(domain)\n        # 查询以\"site.domain_host\"开头的配置项，并清除\n        systemconfig = SystemConfigOper()\n        site_keys = systemconfig.all().keys()\n        for key in site_keys:\n            if key.startswith(f\"site.{domain_host}\"):\n                logger.info(f\"清理站点配置：{key}\")\n                systemconfig.delete(key)\n\n    @eventmanager.register(EventType.SiteUpdated)\n    def cache_site_userdata(self, event: Event):\n        \"\"\"\n        缓存站点用户数据\n        \"\"\"\n        if not event:\n            return\n        event_data = event.event_data or {}\n        # 主域名\n        domain = event_data.get(\"domain\")\n        if not domain:\n            return\n        if str(domain).startswith(\"http\"):\n            domain = StringUtils.get_url_domain(domain)\n        indexer = SitesHelper().get_indexer(domain)\n        if not indexer:\n            return\n        # 刷新站点用户数据\n        self.refresh_userdata(site=indexer) or {}\n\n    def test(self, url: str) -> Tuple[bool, str]:\n        \"\"\"\n        测试站点是否可用\n        :param url: 站点域名\n        :return: (是否可用, 错误信息)\n        \"\"\"\n        # 检查域名是否可用\n        domain = StringUtils.get_url_domain(url)\n        siteoper = SiteOper()\n        site_info = siteoper.get_by_domain(domain)\n        if not site_info:\n            return False, f\"站点【{url}】不存在\"\n\n        # 模拟登录\n        try:\n            # 开始记时\n            start_time = datetime.now()\n            # 特殊站点测试\n            if self.special_site_test.get(domain):\n                state, message = self.special_site_test[domain](site_info)\n            else:\n                # 通用站点测试\n                state, message = self.__test(site_info)\n            # 统计\n            seconds = (datetime.now() - start_time).seconds\n            if state:\n                siteoper.success(domain=domain, seconds=seconds)\n            else:\n                siteoper.fail(domain)\n            return state, message\n        except Exception as e:\n            return False, f\"{str(e)}！\"\n\n    @staticmethod\n    def __test(site_info: Site) -> Tuple[bool, str]:\n        \"\"\"\n        通用站点测试\n        \"\"\"\n        site_url = site_info.url\n        site_cookie = site_info.cookie\n        ua = site_info.ua or settings.USER_AGENT\n        render = site_info.render\n        public = site_info.public\n        proxies = settings.PROXY if site_info.proxy else None\n        proxy_server = settings.PROXY_SERVER if site_info.proxy else None\n        timeout = site_info.timeout or 60\n\n        # 访问链接\n        if render:\n            page_source = PlaywrightHelper().get_page_source(url=site_url,\n                                                             cookies=site_cookie,\n                                                             ua=ua,\n                                                             proxies=proxy_server,\n                                                             timeout=timeout)\n            if not public and not SiteUtils.is_logged_in(page_source):\n                if under_challenge(page_source):\n                    return False, f\"无法通过Cloudflare！\"\n                return False, f\"仿真登录失败，Cookie已失效！\"\n        else:\n            res = RequestUtils(cookies=site_cookie,\n                               ua=ua,\n                               proxies=proxies\n                               ).get_res(url=site_url)\n            # 判断登录状态\n            if res and res.status_code in [200, 500, 403]:\n                content = res.text\n                if not public and not SiteUtils.is_logged_in(content):\n                    if under_challenge(content):\n                        msg = \"站点被Cloudflare防护，请打开站点浏览器仿真\"\n                    elif res.status_code == 200:\n                        msg = \"Cookie已失效\"\n                    else:\n                        msg = f\"错误：{res.status_code} {res.reason}\"\n                    return False, f\"{msg}！\"\n                elif public and res.status_code != 200:\n                    return False, f\"错误：{res.status_code} {res.reason}！\"\n            elif res is not None:\n                return False, f\"错误：{res.status_code} {res.reason}！\"\n            else:\n                return False, f\"无法打开网站！\"\n        return True, \"连接成功\"\n\n    def remote_list(self, channel: MessageChannel,\n                    userid: Union[str, int] = None, source: Optional[str] = None):\n        \"\"\"\n        查询所有站点，发送消息\n        \"\"\"\n        site_list = SiteOper().list()\n        if not site_list:\n            self.post_message(Notification(\n                channel=channel,\n                title=\"没有维护任何站点信息！\",\n                userid=userid,\n                link=settings.MP_DOMAIN('#/site')))\n        title = f\"共有 {len(site_list)} 个站点，回复对应指令操作：\" \\\n                f\"\\n- 禁用站点：/site_disable [id]\" \\\n                f\"\\n- 启用站点：/site_enable [id]\" \\\n                f\"\\n- 更新站点Cookie：/site_cookie [id] [username] [password] [2fa_code/secret]\"\n        messages = []\n        for site in site_list:\n            if site.render:\n                render_str = \"🧭\"\n            else:\n                render_str = \"\"\n            if site.is_active:\n                messages.append(f\"{site.id}. {site.name} {render_str}\")\n            else:\n                messages.append(f\"{site.id}. {site.name} ⚠️\")\n        # 发送列表\n        self.post_message(Notification(\n            channel=channel,\n            source=source,\n            title=title, text=\"\\n\".join(messages), userid=userid,\n            link=settings.MP_DOMAIN('#/site'))\n        )\n\n    def remote_disable(self, arg_str: str, channel: MessageChannel,\n                       userid: Union[str, int] = None, source: Optional[str] = None):\n        \"\"\"\n        禁用站点\n        \"\"\"\n        if not arg_str:\n            return\n        arg_str = str(arg_str).strip()\n        if not arg_str.isdigit():\n            return\n        site_id = int(arg_str)\n        siteoper = SiteOper()\n        site = siteoper.get(site_id)\n        if not site:\n            self.post_message(Notification(\n                channel=channel,\n                title=f\"站点编号 {site_id} 不存在！\",\n                userid=userid))\n            return\n        # 禁用站点\n        siteoper.update(site_id, {\n            \"is_active\": False\n        })\n        # 重新发送消息\n        self.remote_list(channel=channel, userid=userid, source=source)\n\n    def remote_enable(self, arg_str: str, channel: MessageChannel,\n                      userid: Union[str, int] = None, source: Optional[str] = None):\n        \"\"\"\n        启用站点\n        \"\"\"\n        if not arg_str:\n            return\n        arg_strs = str(arg_str).split()\n        siteoper = SiteOper()\n        for arg_str in arg_strs:\n            arg_str = arg_str.strip()\n            if not arg_str.isdigit():\n                continue\n            site_id = int(arg_str)\n            site = siteoper.get(site_id)\n            if not site:\n                self.post_message(Notification(\n                    channel=channel,\n                    title=f\"站点编号 {site_id} 不存在！\", userid=userid))\n                return\n            # 禁用站点\n            siteoper.update(site_id, {\n                \"is_active\": True\n            })\n        # 重新发送消息\n        self.remote_list(channel=channel, userid=userid, source=source)\n\n    @staticmethod\n    def update_cookie(site_info: Site,\n                      username: str, password: str, two_step_code: Optional[str] = None) -> Tuple[bool, str]:\n        \"\"\"\n        根据用户名密码更新站点Cookie\n        :param site_info: 站点信息\n        :param username: 用户名\n        :param password: 密码\n        :param two_step_code: 二步验证码或密钥\n        :return: (是否成功, 错误信息)\n        \"\"\"\n        # 更新站点Cookie\n        result = CookieHelper().get_site_cookie_ua(\n            url=site_info.url,\n            username=username,\n            password=password,\n            two_step_code=two_step_code,\n            proxies=settings.PROXY_SERVER if site_info.proxy else None,\n            timeout=site_info.timeout or 60\n        )\n        if result:\n            cookie, ua, msg = result\n            if not cookie:\n                return False, msg\n            SiteOper().update(site_info.id, {\n                \"cookie\": cookie,\n                \"ua\": ua\n            })\n            return True, msg\n        return False, \"未知错误\"\n\n    def remote_cookie(self, arg_str: str, channel: MessageChannel,\n                      userid: Union[str, int] = None, source: Optional[str] = None):\n        \"\"\"\n        使用用户名密码更新站点Cookie\n        \"\"\"\n        err_title = \"请输入正确的命令格式：/site_cookie [id] [username] [password] [2fa_code/secret]，\" \\\n                    \"[id]为站点编号，[uername]为站点用户名，[password]为站点密码，[2fa_code/secret]为站点二步验证码或密钥\"\n        if not arg_str:\n            self.post_message(Notification(\n                channel=channel,\n                source=source,\n                title=err_title, userid=userid))\n            return\n        arg_str = str(arg_str).strip()\n        args = arg_str.split()\n        # 二步验证码\n        two_step_code = None\n        if len(args) == 4:\n            two_step_code = args[3]\n        elif len(args) != 3:\n            self.post_message(Notification(\n                channel=channel,\n                source=source,\n                title=err_title, userid=userid))\n            return\n        site_id = args[0]\n        if not site_id.isdigit():\n            self.post_message(Notification(\n                channel=channel,\n                source=source,\n                title=err_title, userid=userid))\n            return\n        # 站点ID\n        site_id = int(site_id)\n        # 站点信息\n        site_info = SiteOper().get(site_id)\n        if not site_info:\n            self.post_message(Notification(\n                channel=channel,\n                source=source,\n                title=f\"站点编号 {site_id} 不存在！\", userid=userid))\n            return\n        self.post_message(Notification(\n            channel=channel,\n            source=source,\n            title=f\"开始更新【{site_info.name}】Cookie&UA ...\", userid=userid))\n        # 用户名\n        username = args[1]\n        # 密码\n        password = args[2]\n        # 更新Cookie\n        status, msg = self.update_cookie(site_info=site_info,\n                                         username=username,\n                                         password=password,\n                                         two_step_code=two_step_code)\n        if not status:\n            logger.error(msg)\n            self.post_message(Notification(\n                channel=channel,\n                source=source,\n                title=f\"【{site_info.name}】 Cookie&UA更新失败！\",\n                text=f\"错误原因：{msg}\",\n                userid=userid))\n        else:\n            self.post_message(Notification(\n                channel=channel,\n                source=source,\n                title=f\"【{site_info.name}】 Cookie&UA更新成功\",\n                userid=userid))\n\n    def remote_refresh_userdatas(self, channel: MessageChannel,\n                                 userid: Union[str, int] = None, source: Optional[str] = None):\n        \"\"\"\n        刷新所有站点用户数据\n        \"\"\"\n        logger.info(\"收到命令，开始刷新站点数据 ...\")\n        self.post_message(Notification(\n            channel=channel,\n            source=source,\n            title=\"开始刷新站点数据 ...\",\n            userid=userid\n        ))\n        # 刷新站点数据\n        site_datas = self.refresh_userdatas()\n        if site_datas:\n            # 发送消息\n            messages = {}\n            # 总上传\n            incUploads = 0\n            # 总下载\n            incDownloads = 0\n            # 今天日期\n            today_date = datetime.now().strftime(\"%Y-%m-%d\")\n\n            for rand, site in enumerate(site_datas.keys()):\n                upload = int(site_datas[site].upload or 0)\n                download = int(site_datas[site].download or 0)\n                updated_date = site_datas[site].updated_day\n                if updated_date and updated_date != today_date:\n                    updated_date = f\"（{updated_date}）\"\n                else:\n                    updated_date = \"\"\n\n                if upload > 0 or download > 0:\n                    incUploads += upload\n                    incDownloads += download\n                    messages[upload + (rand / 1000)] = (\n                            f\"【{site}】{updated_date}\\n\"\n                            + f\"上传量：{StringUtils.str_filesize(upload)}\\n\"\n                            + f\"下载量：{StringUtils.str_filesize(download)}\\n\"\n                            + \"————————————\"\n                    )\n            if incDownloads or incUploads:\n                sorted_messages = [messages[key] for key in sorted(messages.keys(), reverse=True)]\n                sorted_messages.insert(0, f\"【汇总】\\n\"\n                                          f\"总上传：{StringUtils.str_filesize(incUploads)}\\n\"\n                                          f\"总下载：{StringUtils.str_filesize(incDownloads)}\\n\"\n                                          f\"————————————\")\n                self.post_message(Notification(\n                    channel=channel,\n                    source=source,\n                    title=\"【站点数据统计】\",\n                    text=\"\\n\".join(sorted_messages),\n                    userid=userid\n                ))\n        else:\n            self.post_message(Notification(\n                channel=channel,\n                source=source,\n                title=\"没有刷新到任何站点数据！\",\n                userid=userid\n            ))\n"
  },
  {
    "path": "app/chain/storage.py",
    "content": "from pathlib import Path\nfrom typing import Optional, Tuple, List, Dict\n\nfrom app import schemas\nfrom app.chain import ChainBase\nfrom app.core.config import settings\nfrom app.helper.directory import DirectoryHelper\nfrom app.log import logger\n\n\nclass StorageChain(ChainBase):\n    \"\"\"\n    存储处理链\n    \"\"\"\n\n    def save_config(self, storage: str, conf: dict) -> None:\n        \"\"\"\n        保存存储配置\n        \"\"\"\n        self.run_module(\"save_config\", storage=storage, conf=conf)\n\n    def reset_config(self, storage: str) -> None:\n        \"\"\"\n        重置存储配置\n        \"\"\"\n        self.run_module(\"reset_config\", storage=storage)\n\n    def generate_qrcode(self, storage: str) -> Optional[Tuple[dict, str]]:\n        \"\"\"\n        生成二维码\n        \"\"\"\n        return self.run_module(\"generate_qrcode\", storage=storage)\n\n    def generate_auth_url(self, storage: str) -> Optional[Tuple[dict, str]]:\n        \"\"\"\n        生成 OAuth2 授权 URL\n        \"\"\"\n        return self.run_module(\"generate_auth_url\", storage=storage)\n\n    def check_login(self, storage: str, **kwargs) -> Optional[Tuple[dict, str]]:\n        \"\"\"\n        登录确认\n        \"\"\"\n        return self.run_module(\"check_login\", storage=storage, **kwargs)\n\n    def list_files(self, fileitem: schemas.FileItem, recursion: bool = False) -> Optional[List[schemas.FileItem]]:\n        \"\"\"\n        查询当前目录下所有目录和文件\n        \"\"\"\n        return self.run_module(\"list_files\", fileitem=fileitem, recursion=recursion)\n\n    def any_files(self, fileitem: schemas.FileItem, extensions: list = None) -> Optional[bool]:\n        \"\"\"\n        查询当前目录下是否存在指定扩展名任意文件\n        \"\"\"\n        return self.run_module(\"any_files\", fileitem=fileitem, extensions=extensions)\n\n    def create_folder(self, fileitem: schemas.FileItem, name: str) -> Optional[schemas.FileItem]:\n        \"\"\"\n        创建目录\n        \"\"\"\n        return self.run_module(\"create_folder\", fileitem=fileitem, name=name)\n\n    def download_file(self, fileitem: schemas.FileItem, path: Path = None) -> Optional[Path]:\n        \"\"\"\n        下载文件\n        :param fileitem: 文件项\n        :param path: 本地保存路径\n        \"\"\"\n        return self.run_module(\"download_file\", fileitem=fileitem, path=path)\n\n    def upload_file(self, fileitem: schemas.FileItem, path: Path,\n                    new_name: Optional[str] = None) -> Optional[schemas.FileItem]:\n        \"\"\"\n        上传文件\n        :param fileitem: 保存目录项\n        :param path: 本地文件路径\n        :param new_name: 新文件名\n        \"\"\"\n        return self.run_module(\"upload_file\", fileitem=fileitem, path=path, new_name=new_name)\n\n    def delete_file(self, fileitem: schemas.FileItem) -> Optional[bool]:\n        \"\"\"\n        删除文件或目录\n        \"\"\"\n        return self.run_module(\"delete_file\", fileitem=fileitem)\n\n    def rename_file(self, fileitem: schemas.FileItem, name: str) -> Optional[bool]:\n        \"\"\"\n        重命名文件或目录\n        \"\"\"\n        return self.run_module(\"rename_file\", fileitem=fileitem, name=name)\n\n    def exists(self, fileitem: schemas.FileItem) -> Optional[bool]:\n        \"\"\"\n        判断文件或目录是否存在\n        \"\"\"\n        return True if self.get_item(fileitem) else False\n\n    def get_item(self, fileitem: schemas.FileItem) -> Optional[schemas.FileItem]:\n        \"\"\"\n        查询目录或文件\n        \"\"\"\n        return self.get_file_item(storage=fileitem.storage, path=Path(fileitem.path))\n\n    def get_file_item(self, storage: str, path: Path) -> Optional[schemas.FileItem]:\n        \"\"\"\n        根据路径获取文件项\n        \"\"\"\n        return self.run_module(\"get_file_item\", storage=storage, path=path)\n\n    def get_parent_item(self, fileitem: schemas.FileItem) -> Optional[schemas.FileItem]:\n        \"\"\"\n        获取上级目录项\n        \"\"\"\n        return self.run_module(\"get_parent_item\", fileitem=fileitem)\n\n    def snapshot_storage(self, storage: str, path: Path,\n                         last_snapshot_time: float = None, max_depth: int = 5) -> Optional[Dict[str, Dict]]:\n        \"\"\"\n        快照存储\n        :param storage: 存储类型\n        :param path: 路径\n        :param last_snapshot_time: 上次快照时间，用于增量快照\n        :param max_depth: 最大递归深度，避免过深遍历\n        \"\"\"\n        return self.run_module(\"snapshot_storage\", storage=storage, path=path,\n                               last_snapshot_time=last_snapshot_time, max_depth=max_depth)\n\n    def storage_usage(self, storage: str) -> Optional[schemas.StorageUsage]:\n        \"\"\"\n        存储使用情况\n        \"\"\"\n        return self.run_module(\"storage_usage\", storage=storage)\n\n    def support_transtype(self, storage: str) -> Optional[dict]:\n        \"\"\"\n        获取支持的整理方式\n        \"\"\"\n        return self.run_module(\"support_transtype\", storage=storage)\n\n    def is_bluray_folder(self, fileitem: Optional[schemas.FileItem]) -> bool:\n        \"\"\"\n        检查是否蓝光目录\n        \"\"\"\n        if not fileitem or fileitem.type != \"dir\":\n            return False\n        if self.get_file_item(storage=fileitem.storage, path=Path(fileitem.path) / \"BDMV\"):\n            return True\n        if self.get_file_item(storage=fileitem.storage, path=Path(fileitem.path) / \"CERTIFICATE\"):\n            return True\n        return False\n\n    @staticmethod\n    def contains_bluray_subdirectories(fileitems: Optional[List[schemas.FileItem]]) -> bool:\n        \"\"\"\n        判断是否包含蓝光必备的文件夹\n        \"\"\"\n        required_files = {\"BDMV\", \"CERTIFICATE\"}\n        return any(\n            item.type == \"dir\" and item.name in required_files\n            for item in fileitems or []\n        )\n\n    def delete_media_file(self, fileitem: schemas.FileItem, delete_self: bool = True) -> bool:\n        \"\"\"\n        删除媒体文件，以及不含媒体文件的目录\n        \"\"\"\n        media_exts = settings.RMT_MEDIAEXT + settings.DOWNLOAD_TMPEXT + settings.RMT_SUBEXT + settings.RMT_AUDIOEXT\n        fileitem_path = Path(fileitem.path) if fileitem.path else Path(\"\")\n        if len(fileitem_path.parts) <= 2:\n            logger.warn(f\"【{fileitem.storage}】{fileitem.path} 根目录或一级目录不允许删除\")\n            return False\n        if fileitem.type == \"dir\":\n            # 本身是目录\n            if self.is_bluray_folder(fileitem):\n                logger.warn(f\"正在删除蓝光原盘目录：【{fileitem.storage}】{fileitem.path}\")\n                if not self.delete_file(fileitem):\n                    logger.warn(f\"【{fileitem.storage}】{fileitem.path} 删除失败\")\n                    return False\n\n        elif delete_self:\n            # 本身是文件，需要删除文件\n            logger.warn(f\"正在删除文件【{fileitem.storage}】{fileitem.path}\")\n            if not self.delete_file(fileitem):\n                logger.warn(f\"【{fileitem.storage}】{fileitem.path} 删除失败\")\n                return False\n\n        # 检查和删除上级空目录\n        dir_item = fileitem if fileitem.type == \"dir\" else self.get_parent_item(fileitem)\n        if not dir_item:\n            logger.warn(f\"【{fileitem.storage}】{fileitem.path} 上级目录不存在\")\n            return True\n\n        # 查找操作文件项匹配的配置目录(资源目录、媒体库目录)\n        associated_dir = max(\n            (\n                Path(p)\n                for d in DirectoryHelper().get_dirs()\n                for p in (d.download_path, d.library_path)\n                if p and fileitem_path.is_relative_to(p)\n            ),\n            key=lambda path: len(path.parts),\n            default=None,\n        )\n\n        while dir_item and len(Path(dir_item.path).parts) > 2:\n            # 目录是资源目录、媒体库目录的上级，则不处理\n            if associated_dir and associated_dir.is_relative_to(Path(dir_item.path)):\n                logger.debug(f\"【{dir_item.storage}】{dir_item.path} 位于资源或媒体库目录结构中，不删除\")\n                break\n\n            elif not associated_dir and self.list_files(dir_item, recursion=False):\n                logger.debug(f\"【{dir_item.storage}】{dir_item.path} 不是空目录，不删除\")\n                break\n\n            if self.any_files(dir_item, extensions=media_exts) is not False:\n                logger.debug(f\"【{dir_item.storage}】{dir_item.path} 存在媒体文件，不删除\")\n                break\n\n            # 删除空目录并继续处理父目录\n            logger.warn(f\"【{dir_item.storage}】{dir_item.path} 不存在其它媒体文件，正在删除空目录\")\n            if not self.delete_file(dir_item):\n                logger.warn(f\"【{dir_item.storage}】{dir_item.path} 删除失败\")\n                return False\n            dir_item = self.get_parent_item(dir_item)\n\n        return True\n"
  },
  {
    "path": "app/chain/subscribe.py",
    "content": "import copy\nimport json\nimport random\nimport threading\nimport time\nfrom datetime import datetime\nfrom typing import Dict, List, Optional, Union, Tuple\n\nfrom app import schemas\nfrom app.chain import ChainBase\nfrom app.chain.download import DownloadChain\nfrom app.chain.media import MediaChain\nfrom app.chain.search import SearchChain\nfrom app.chain.tmdb import TmdbChain\nfrom app.chain.torrents import TorrentsChain\nfrom app.core.config import settings, global_vars\nfrom app.core.context import TorrentInfo, Context, MediaInfo\nfrom app.core.event import eventmanager, Event\nfrom app.core.meta import MetaBase\nfrom app.core.meta.words import WordsMatcher\nfrom app.core.metainfo import MetaInfo\nfrom app.db.downloadhistory_oper import DownloadHistoryOper\nfrom app.db.models.subscribe import Subscribe\nfrom app.db.site_oper import SiteOper\nfrom app.db.subscribe_oper import SubscribeOper\nfrom app.db.systemconfig_oper import SystemConfigOper\nfrom app.helper.subscribe import SubscribeHelper\nfrom app.helper.torrent import TorrentHelper\nfrom app.log import logger\nfrom app.schemas import MediaRecognizeConvertEventData\nfrom app.schemas.types import MediaType, SystemConfigKey, MessageChannel, NotificationType, EventType, ChainEventType, \\\n    ContentType\n\n\nclass SubscribeChain(ChainBase):\n    \"\"\"\n    订阅管理处理链\n    \"\"\"\n\n    _rlock = threading.RLock()\n    # 避免莫名原因导致长时间持有锁\n    _LOCK_TIMOUT = 3600 * 2\n\n    @staticmethod\n    def __get_event_media(_mediaid: str, _meta: MetaBase) -> Optional[MediaInfo]:\n        \"\"\"\n        广播事件解析媒体信息\n        \"\"\"\n        event_data = MediaRecognizeConvertEventData(\n            mediaid=_mediaid,\n            convert_type=settings.RECOGNIZE_SOURCE\n        )\n        event = eventmanager.send_event(ChainEventType.MediaRecognizeConvert, event_data)\n        # 使用事件返回的上下文数据\n        if event and event.event_data:\n            event_data: MediaRecognizeConvertEventData = event.event_data\n            if event_data.media_dict:\n                mediachain = MediaChain()\n                new_id = event_data.media_dict.get(\"id\")\n                if event_data.convert_type == \"themoviedb\":\n                    return mediachain.recognize_media(meta=_meta, tmdbid=new_id)\n                elif event_data.convert_type == \"douban\":\n                    return mediachain.recognize_media(meta=_meta, doubanid=new_id)\n        return None\n\n    @staticmethod\n    async def __async_get_event_meida(_mediaid: str, _meta: MetaBase) -> Optional[MediaInfo]:\n        \"\"\"\n        广播事件解析媒体信息\n        \"\"\"\n        event_data = MediaRecognizeConvertEventData(\n            mediaid=_mediaid,\n            convert_type=settings.RECOGNIZE_SOURCE\n        )\n        event = await eventmanager.async_send_event(ChainEventType.MediaRecognizeConvert, event_data)\n        # 使用事件返回的上下文数据\n        if event and event.event_data:\n            event_data: MediaRecognizeConvertEventData = event.event_data\n            if event_data.media_dict:\n                mediachain = MediaChain()\n                new_id = event_data.media_dict.get(\"id\")\n                if event_data.convert_type == \"themoviedb\":\n                    return await mediachain.async_recognize_media(meta=_meta, tmdbid=new_id)\n                elif event_data.convert_type == \"douban\":\n                    return await mediachain.async_recognize_media(meta=_meta, doubanid=new_id)\n        return None\n\n    def __get_default_kwargs(self, mtype: MediaType, **kwargs) -> dict:\n        \"\"\"\n        获取订阅默认配置\n        :param mtype: 媒体类型\n        :param key: 配置键\n        :return: 配置值\n        \"\"\"\n        return {\n            'quality': self.__get_default_subscribe_config(mtype, \"quality\") if not kwargs.get(\n                \"quality\") else kwargs.get(\"quality\"),\n            'resolution': self.__get_default_subscribe_config(mtype, \"resolution\") if not kwargs.get(\n                \"resolution\") else kwargs.get(\"resolution\"),\n            'effect': self.__get_default_subscribe_config(mtype, \"effect\") if not kwargs.get(\n                \"effect\") else kwargs.get(\"effect\"),\n            'include': self.__get_default_subscribe_config(mtype, \"include\") if not kwargs.get(\n                \"include\") else kwargs.get(\"include\"),\n            'exclude': self.__get_default_subscribe_config(mtype, \"exclude\") if not kwargs.get(\n                \"exclude\") else kwargs.get(\"exclude\"),\n            'best_version': self.__get_default_subscribe_config(mtype, \"best_version\") if not kwargs.get(\n                \"best_version\") else kwargs.get(\"best_version\"),\n            'search_imdbid': self.__get_default_subscribe_config(mtype, \"search_imdbid\") if not kwargs.get(\n                \"search_imdbid\") else kwargs.get(\"search_imdbid\"),\n            'sites': self.__get_default_subscribe_config(mtype, \"sites\") or None if not kwargs.get(\n                \"sites\") else kwargs.get(\"sites\"),\n            'downloader': self.__get_default_subscribe_config(mtype, \"downloader\") if not kwargs.get(\n                \"downloader\") else kwargs.get(\"downloader\"),\n            'save_path': self.__get_default_subscribe_config(mtype, \"save_path\") if not kwargs.get(\n                \"save_path\") else kwargs.get(\"save_path\"),\n            'filter_groups': self.__get_default_subscribe_config(mtype, \"filter_groups\") if not kwargs.get(\n                \"filter_groups\") else kwargs.get(\"filter_groups\")\n        }\n\n    def add(self, title: str, year: str,\n            mtype: MediaType = None,\n            tmdbid: Optional[int] = None,\n            doubanid: Optional[str] = None,\n            bangumiid: Optional[int] = None,\n            mediaid: Optional[str] = None,\n            episode_group: Optional[str] = None,\n            season: Optional[int] = None,\n            channel: MessageChannel = None,\n            source: Optional[str] = None,\n            userid: Optional[str] = None,\n            username: Optional[str] = None,\n            message: Optional[bool] = True,\n            exist_ok: Optional[bool] = False,\n            **kwargs) -> Tuple[Optional[int], str]:\n        \"\"\"\n        识别媒体信息并添加订阅\n        \"\"\"\n\n        logger.info(f'开始添加订阅，标题：{title} ...')\n\n        mediainfo = None\n        metainfo = MetaInfo(title)\n        if year:\n            metainfo.year = year\n        if mtype:\n            metainfo.type = mtype\n        if season is not None:\n            metainfo.type = MediaType.TV\n            metainfo.begin_season = season\n        # 识别媒体信息\n        if settings.RECOGNIZE_SOURCE == \"themoviedb\":\n            # TMDB识别模式\n            if not tmdbid:\n                if doubanid:\n                    # 将豆瓣信息转换为TMDB信息\n                    tmdbinfo = MediaChain().get_tmdbinfo_by_doubanid(doubanid=doubanid, mtype=mtype)\n                    if tmdbinfo:\n                        mediainfo = MediaInfo(tmdb_info=tmdbinfo)\n                elif mediaid:\n                    # 未知前缀，广播事件解析媒体信息\n                    mediainfo = self.__get_event_media(mediaid, metainfo)\n            else:\n                # 使用TMDBID识别\n                mediainfo = self.recognize_media(meta=metainfo, mtype=mtype, tmdbid=tmdbid,\n                                                 episode_group=episode_group, cache=False)\n        else:\n            if doubanid:\n                # 豆瓣识别模式，不使用缓存\n                mediainfo = self.recognize_media(meta=metainfo, mtype=mtype, doubanid=doubanid, cache=False)\n            elif mediaid:\n                # 未知前缀，广播事件解析媒体信息\n                mediainfo = self.__get_event_media(mediaid, metainfo)\n            if mediainfo:\n                # 豆瓣标题处理\n                meta = MetaInfo(mediainfo.title)\n                mediainfo.title = meta.name\n                if season is None:\n                    season = meta.begin_season\n\n        # 使用名称识别兜底\n        if not mediainfo:\n            mediainfo = self.recognize_media(meta=metainfo, episode_group=episode_group)\n\n        # 识别失败\n        if not mediainfo:\n            logger.warn(f'未识别到媒体信息，标题：{title}，tmdbid：{tmdbid}，doubanid：{doubanid}')\n            return None, \"未识别到媒体信息\"\n\n        # 总集数\n        if mediainfo.type == MediaType.TV:\n            if season is None:\n                season = 1\n            # 总集数\n            if not kwargs.get('total_episode'):\n                if not mediainfo.seasons or episode_group:\n                    # 补充媒体信息\n                    mediainfo = self.recognize_media(mtype=mediainfo.type,\n                                                     tmdbid=mediainfo.tmdb_id,\n                                                     doubanid=mediainfo.douban_id,\n                                                     bangumiid=mediainfo.bangumi_id,\n                                                     episode_group=episode_group,\n                                                     cache=False)\n                    if not mediainfo:\n                        logger.error(f\"媒体信息识别失败！\")\n                        return None, \"媒体信息识别失败\"\n                    if not mediainfo.seasons:\n                        logger.error(f\"媒体信息中没有季集信息，标题：{title}，tmdbid：{tmdbid}，doubanid：{doubanid}\")\n                        return None, \"媒体信息中没有季集信息\"\n                total_episode = len(mediainfo.seasons.get(season) or [])\n                if not total_episode:\n                    logger.error(f'未获取到总集数，标题：{title}，tmdbid：{tmdbid}, doubanid：{doubanid}')\n                    return None, f\"未获取到第 {season} 季的总集数\"\n                kwargs.update({\n                    'total_episode': total_episode\n                })\n            # 缺失集\n            if not kwargs.get('lack_episode'):\n                kwargs.update({\n                    'lack_episode': kwargs.get('total_episode')\n                })\n        else:\n            # 避免season为0的问题\n            season = None\n\n        # 更新媒体图片\n        self.obtain_images(mediainfo=mediainfo)\n        # 合并信息\n        if doubanid:\n            mediainfo.douban_id = doubanid\n        if bangumiid:\n            mediainfo.bangumi_id = bangumiid\n\n        # 添加订阅\n        kwargs.update(self.__get_default_kwargs(mediainfo.type, **kwargs))\n\n        # 操作数据库\n        sid, err_msg = SubscribeOper().add(mediainfo=mediainfo, season=season, username=username, **kwargs)\n        if not sid:\n            logger.error(f'{mediainfo.title_year} {err_msg}')\n            if not exist_ok and message:\n                # 失败发回原用户\n                self.post_message(schemas.Notification(channel=channel,\n                                                       source=source,\n                                                       mtype=NotificationType.Subscribe,\n                                                       title=f\"{mediainfo.title_year} {metainfo.season} \"\n                                                             f\"添加订阅失败！\",\n                                                       text=f\"{err_msg}\",\n                                                       image=mediainfo.get_message_image(),\n                                                       userid=userid))\n            return None, err_msg\n        elif message:\n            if mediainfo.type == MediaType.TV:\n                link = settings.MP_DOMAIN('#/subscribe/tv?tab=mysub')\n            else:\n                link = settings.MP_DOMAIN('#/subscribe/movie?tab=mysub')\n            # 订阅成功按规则发送消息\n            self.post_message(\n                schemas.Notification(\n                    channel=channel,\n                    source=source,\n                    mtype=NotificationType.Subscribe,\n                    ctype=ContentType.SubscribeAdded,\n                    image=mediainfo.get_message_image(),\n                    link=link,\n                    userid=userid,\n                    username=username\n                ),\n                meta=metainfo,\n                mediainfo=mediainfo,\n                username=username\n            )\n        # 发送事件\n        eventmanager.send_event(EventType.SubscribeAdded, {\n            \"subscribe_id\": sid,\n            \"username\": username,\n            \"mediainfo\": mediainfo.to_dict(),\n        })\n        # 统计订阅\n        SubscribeHelper().sub_reg_async({\n            \"name\": title,\n            \"year\": year,\n            \"type\": metainfo.type.value,\n            \"tmdbid\": mediainfo.tmdb_id,\n            \"imdbid\": mediainfo.imdb_id,\n            \"tvdbid\": mediainfo.tvdb_id,\n            \"doubanid\": mediainfo.douban_id,\n            \"bangumiid\": mediainfo.bangumi_id,\n            \"season\": metainfo.begin_season,\n            \"poster\": mediainfo.get_poster_image(),\n            \"backdrop\": mediainfo.get_backdrop_image(),\n            \"vote\": mediainfo.vote_average,\n            \"description\": mediainfo.overview\n        })\n        # 返回结果\n        return sid, err_msg\n\n    async def async_add(self, title: str, year: str,\n                        mtype: MediaType = None,\n                        tmdbid: Optional[int] = None,\n                        doubanid: Optional[str] = None,\n                        bangumiid: Optional[int] = None,\n                        mediaid: Optional[str] = None,\n                        episode_group: Optional[str] = None,\n                        season: Optional[int] = None,\n                        channel: MessageChannel = None,\n                        source: Optional[str] = None,\n                        userid: Optional[str] = None,\n                        username: Optional[str] = None,\n                        message: Optional[bool] = True,\n                        exist_ok: Optional[bool] = False,\n                        **kwargs) -> Tuple[Optional[int], str]:\n        \"\"\"\n        异步识别媒体信息并添加订阅\n        \"\"\"\n\n        logger.info(f'开始添加订阅，标题：{title} ...')\n\n        mediainfo = None\n        metainfo = MetaInfo(title)\n        if year:\n            metainfo.year = year\n        if mtype:\n            metainfo.type = mtype\n        if season is not None:\n            metainfo.type = MediaType.TV\n            metainfo.begin_season = season\n        # 识别媒体信息\n        if settings.RECOGNIZE_SOURCE == \"themoviedb\":\n            # TMDB识别模式\n            if not tmdbid:\n                if doubanid:\n                    # 将豆瓣信息转换为TMDB信息\n                    tmdbinfo = await MediaChain().async_get_tmdbinfo_by_doubanid(doubanid=doubanid, mtype=mtype)\n                    if tmdbinfo:\n                        mediainfo = MediaInfo(tmdb_info=tmdbinfo)\n                elif mediaid:\n                    # 未知前缀，广播事件解析媒体信息\n                    mediainfo = await self.__async_get_event_meida(mediaid, metainfo)\n            else:\n                # 使用TMDBID识别\n                mediainfo = await self.async_recognize_media(meta=metainfo, mtype=mtype, tmdbid=tmdbid,\n                                                             episode_group=episode_group, cache=False)\n        else:\n            if doubanid:\n                # 豆瓣识别模式，不使用缓存\n                mediainfo = await self.async_recognize_media(meta=metainfo, mtype=mtype, doubanid=doubanid, cache=False)\n            elif mediaid:\n                # 未知前缀，广播事件解析媒体信息\n                mediainfo = await self.__async_get_event_meida(mediaid, metainfo)\n            if mediainfo:\n                # 豆瓣标题处理\n                meta = MetaInfo(mediainfo.title)\n                mediainfo.title = meta.name\n                if season is None:\n                    season = meta.begin_season\n\n        # 使用名称识别兜底\n        if not mediainfo:\n            mediainfo = await self.async_recognize_media(meta=metainfo, episode_group=episode_group)\n\n        # 识别失败\n        if not mediainfo:\n            logger.warn(f'未识别到媒体信息，标题：{title}，tmdbid：{tmdbid}，doubanid：{doubanid}')\n            return None, \"未识别到媒体信息\"\n\n        # 总集数\n        if mediainfo.type == MediaType.TV:\n            if season is None:\n                season = 1\n            # 总集数\n            if not kwargs.get('total_episode'):\n                if not mediainfo.seasons or episode_group:\n                    # 补充媒体信息\n                    mediainfo = await self.async_recognize_media(mtype=mediainfo.type,\n                                                                 tmdbid=mediainfo.tmdb_id,\n                                                                 doubanid=mediainfo.douban_id,\n                                                                 bangumiid=mediainfo.bangumi_id,\n                                                                 episode_group=episode_group,\n                                                                 cache=False)\n                    if not mediainfo:\n                        logger.error(f\"媒体信息识别失败！\")\n                        return None, \"媒体信息识别失败\"\n                    if not mediainfo.seasons:\n                        logger.error(f\"媒体信息中没有季集信息，标题：{title}，tmdbid：{tmdbid}，doubanid：{doubanid}\")\n                        return None, \"媒体信息中没有季集信息\"\n                total_episode = len(mediainfo.seasons.get(season) or [])\n                if not total_episode:\n                    logger.error(f'未获取到总集数，标题：{title}，tmdbid：{tmdbid}, doubanid：{doubanid}')\n                    return None, f\"未获取到第 {season} 季的总集数\"\n                kwargs.update({\n                    'total_episode': total_episode\n                })\n            # 缺失集\n            if not kwargs.get('lack_episode'):\n                kwargs.update({\n                    'lack_episode': kwargs.get('total_episode')\n                })\n        else:\n            # 避免season为0的问题\n            season = None\n\n        # 更新媒体图片\n        await self.async_obtain_images(mediainfo=mediainfo)\n        # 合并信息\n        if doubanid:\n            mediainfo.douban_id = doubanid\n        if bangumiid:\n            mediainfo.bangumi_id = bangumiid\n\n        # 列新默认参数\n        kwargs.update(self.__get_default_kwargs(mediainfo.type, **kwargs))\n\n        # 操作数据库\n        sid, err_msg = await SubscribeOper().async_add(mediainfo=mediainfo, season=season, username=username, **kwargs)\n        if not sid:\n            logger.error(f'{mediainfo.title_year} {err_msg}')\n            if not exist_ok and message:\n                # 失败发回原用户\n                await self.async_post_message(schemas.Notification(channel=channel,\n                                                                   source=source,\n                                                                   mtype=NotificationType.Subscribe,\n                                                                   title=f\"{mediainfo.title_year} {metainfo.season} \"\n                                                                         f\"添加订阅失败！\",\n                                                                   text=f\"{err_msg}\",\n                                                                   image=mediainfo.get_message_image(),\n                                                                   userid=userid))\n            return None, err_msg\n        elif message:\n            if mediainfo.type == MediaType.TV:\n                link = settings.MP_DOMAIN('#/subscribe/tv?tab=mysub')\n            else:\n                link = settings.MP_DOMAIN('#/subscribe/movie?tab=mysub')\n            # 订阅成功按规则发送消息\n            await self.async_post_message(\n                schemas.Notification(\n                    channel=channel,\n                    source=source,\n                    mtype=NotificationType.Subscribe,\n                    ctype=ContentType.SubscribeAdded,\n                    image=mediainfo.get_message_image(),\n                    link=link,\n                    userid=userid,\n                    username=username\n                ),\n                meta=metainfo,\n                mediainfo=mediainfo,\n                username=username\n            )\n        # 发送事件\n        await eventmanager.async_send_event(EventType.SubscribeAdded, {\n            \"subscribe_id\": sid,\n            \"username\": username,\n            \"mediainfo\": mediainfo.to_dict(),\n        })\n        # 统计订阅\n        await SubscribeHelper().async_sub_reg({\n            \"name\": title,\n            \"year\": year,\n            \"type\": metainfo.type.value,\n            \"tmdbid\": mediainfo.tmdb_id,\n            \"imdbid\": mediainfo.imdb_id,\n            \"tvdbid\": mediainfo.tvdb_id,\n            \"doubanid\": mediainfo.douban_id,\n            \"bangumiid\": mediainfo.bangumi_id,\n            \"season\": metainfo.begin_season,\n            \"poster\": mediainfo.get_poster_image(),\n            \"backdrop\": mediainfo.get_backdrop_image(),\n            \"vote\": mediainfo.vote_average,\n            \"description\": mediainfo.overview\n        })\n        # 返回结果\n        return sid, err_msg\n\n    @staticmethod\n    def exists(mediainfo: MediaInfo, meta: MetaBase = None):\n        \"\"\"\n        判断订阅是否已存在\n        \"\"\"\n        if SubscribeOper().exists(tmdbid=mediainfo.tmdb_id,\n                                  doubanid=mediainfo.douban_id,\n                                  season=meta.begin_season if meta else None):\n            return True\n        return False\n\n    def search(self, sid: Optional[int] = None, state: Optional[str] = 'N', manual: Optional[bool] = False):\n        \"\"\"\n        订阅搜索\n        :param sid: 订阅ID，有值时只处理该订阅\n        :param state: 订阅状态 N:新建, R:订阅中, P:待定, S:暂停\n        :param manual: 是否手动搜索\n        :return: 更新订阅状态为R或删除订阅\n        \"\"\"\n        lock_acquired = False\n        try:\n            if lock_acquired := self._rlock.acquire(\n                    blocking=True, timeout=self._LOCK_TIMOUT\n            ):\n                logger.debug(f\"search lock acquired at {datetime.now()}\")\n            else:\n                logger.warn(\"search上锁超时\")\n\n            subscribeoper = SubscribeOper()\n            if sid:\n                subscribe = subscribeoper.get(sid)\n                subscribes = [subscribe] if subscribe else []\n            else:\n                subscribes = subscribeoper.list(self.get_states_for_search(state))\n\n            try:\n                # 遍历订阅\n                for subscribe in subscribes:\n                    if global_vars.is_system_stopped:\n                        break\n                    mediakey = subscribe.tmdbid or subscribe.doubanid\n                    custom_word_list = subscribe.custom_words.split(\"\\n\") if subscribe.custom_words else None\n                    # 校验当前时间减订阅创建时间是否大于1分钟，否则跳过先，留出编辑订阅的时间\n                    if subscribe.date:\n                        now = datetime.now()\n                        subscribe_time = datetime.strptime(subscribe.date, '%Y-%m-%d %H:%M:%S')\n                        if (now - subscribe_time).total_seconds() < 60:\n                            logger.debug(f\"订阅标题：{subscribe.name} 新增小于1分钟，暂不搜索...\")\n                            continue\n                    # 随机休眠1-5分钟\n                    if not sid and state in ['R', 'P']:\n                        sleep_time = random.randint(60, 300)\n                        logger.info(f'订阅搜索随机休眠 {sleep_time} 秒 ...')\n                        time.sleep(sleep_time)\n                    try:\n                        logger.info(f'开始搜索订阅，标题：{subscribe.name} ...')\n                        # 生成元数据\n                        meta = MetaInfo(subscribe.name)\n                        meta.year = subscribe.year\n                        meta.begin_season = subscribe.season if subscribe.season is not None else None\n                        try:\n                            meta.type = MediaType(subscribe.type)\n                        except ValueError:\n                            logger.error(f'订阅 {subscribe.name} 类型错误：{subscribe.type}')\n                            continue\n                        # 识别媒体信息\n                        mediainfo: MediaInfo = self.recognize_media(meta=meta, mtype=meta.type,\n                                                                    tmdbid=subscribe.tmdbid,\n                                                                    doubanid=subscribe.doubanid,\n                                                                    episode_group=subscribe.episode_group,\n                                                                    cache=False)\n                        if not mediainfo:\n                            logger.warn(\n                                f'未识别到媒体信息，标题：{subscribe.name}，tmdbid：{subscribe.tmdbid}，doubanid：{subscribe.doubanid}')\n                            continue\n\n                        # 如果媒体已存在或已下载完毕，跳过当前订阅处理\n                        exist_flag, no_exists = self.check_and_handle_existing_media(subscribe=subscribe,\n                                                                                     meta=meta,\n                                                                                     mediainfo=mediainfo,\n                                                                                     mediakey=mediakey)\n                        if exist_flag:\n                            continue\n\n                        # 站点范围\n                        sites = self.get_sub_sites(subscribe)\n\n                        # 优先级过滤规则\n                        if subscribe.best_version:\n                            rule_groups = subscribe.filter_groups \\\n                                          or SystemConfigOper().get(SystemConfigKey.BestVersionFilterRuleGroups) or []\n                        else:\n                            rule_groups = subscribe.filter_groups \\\n                                          or SystemConfigOper().get(SystemConfigKey.SubscribeFilterRuleGroups) or []\n\n                        # 搜索，同时电视剧会过滤掉不需要的剧集\n                        contexts = SearchChain().process(mediainfo=mediainfo,\n                                                         keyword=subscribe.keyword,\n                                                         no_exists=no_exists,\n                                                         sites=sites,\n                                                         rule_groups=rule_groups,\n                                                         area=\"imdbid\" if subscribe.search_imdbid else \"title\",\n                                                         custom_words=custom_word_list,\n                                                         filter_params=self.get_params(subscribe))\n                        if not contexts:\n                            logger.warn(f'订阅 {subscribe.keyword or subscribe.name} 未搜索到资源')\n                            self.finish_subscribe_or_not(subscribe=subscribe, meta=meta,\n                                                         mediainfo=mediainfo, lefts=no_exists)\n                            continue\n\n                        # 过滤搜索结果\n                        matched_contexts = []\n                        try:\n                            for context in contexts:\n                                if global_vars.is_system_stopped:\n                                    break\n                                torrent_meta = context.meta_info\n                                torrent_info = context.torrent_info\n                                torrent_mediainfo = context.media_info\n\n                                # 洗版\n                                if subscribe.best_version:\n                                    # 洗版时，非整季不要\n                                    if torrent_mediainfo.type == MediaType.TV:\n                                        if torrent_meta.episode_list:\n                                            logger.info(f'{subscribe.name} 正在洗版，{torrent_info.title} 不是整季')\n                                            continue\n                                    # 洗版时，优先级小于等于已下载优先级的不要\n                                    if subscribe.current_priority \\\n                                            and torrent_info.pri_order <= subscribe.current_priority:\n                                        logger.info(\n                                            f'{subscribe.name} 正在洗版，{torrent_info.title} 优先级低于或等于已下载优先级')\n                                        continue\n                                # 更新订阅自定义属性\n                                if subscribe.media_category:\n                                    torrent_mediainfo.category = subscribe.media_category\n                                if subscribe.episode_group:\n                                    torrent_mediainfo.episode_group = subscribe.episode_group\n                                matched_contexts.append(context)\n                        finally:\n                            contexts.clear()\n                            del contexts\n\n                        if not matched_contexts:\n                            logger.warn(f'订阅 {subscribe.name} 没有符合过滤条件的资源')\n                            self.finish_subscribe_or_not(subscribe=subscribe, meta=meta,\n                                                         mediainfo=mediainfo, lefts=no_exists)\n                            continue\n\n                        # 自动下载\n                        downloads, lefts = DownloadChain().batch_download(\n                            contexts=matched_contexts,\n                            no_exists=no_exists,\n                            username=subscribe.username,\n                            save_path=subscribe.save_path,\n                            downloader=subscribe.downloader,\n                            source=self.get_subscribe_source_keyword(subscribe)\n                        )\n\n                        # 同步外部修改，更新订阅信息\n                        subscribe = subscribeoper.get(subscribe.id)\n\n                        # 判断是否应完成订阅\n                        if subscribe:\n                            self.finish_subscribe_or_not(subscribe=subscribe, meta=meta, mediainfo=mediainfo,\n                                                         downloads=downloads, lefts=lefts)\n                    finally:\n                        # 如果状态为N则更新为R\n                        if subscribe and subscribe.state == 'N':\n                            subscribeoper.update(subscribe.id, {'state': 'R'})\n\n                # 手动触发时发送系统消息\n                if manual:\n                    if subscribes:\n                        if sid:\n                            self.messagehelper.put(f'{subscribes[0].name} 搜索完成！', title=\"订阅搜索\", role=\"system\")\n                        else:\n                            self.messagehelper.put('所有订阅搜索完成！', title=\"订阅搜索\", role=\"system\")\n                    else:\n                        self.messagehelper.put('没有找到订阅！', title=\"订阅搜索\", role=\"system\")\n\n            finally:\n                subscribes.clear()\n                del subscribes\n        finally:\n            if lock_acquired:\n                self._rlock.release()\n                logger.debug(f\"search Lock released at {datetime.now()}\")\n\n    def update_subscribe_priority(self, subscribe: Subscribe, meta: MetaBase,\n                                  mediainfo: MediaInfo, downloads: Optional[List[Context]]):\n        \"\"\"\n        更新订阅已下载资源的优先级\n        \"\"\"\n        if not downloads:\n            return\n        if not subscribe.best_version:\n            return\n        # 当前下载资源的优先级\n        priority = max([item.torrent_info.pri_order for item in downloads])\n        # 订阅存在待定策略，不管是否已完成，均需更新订阅信息\n        SubscribeOper().update(subscribe.id, {\n            \"current_priority\": priority,\n            \"last_update\": datetime.now().strftime('%Y-%m-%d %H:%M:%S')\n        })\n        if priority == 100:\n            # 洗版完成\n            self.__finish_subscribe(subscribe=subscribe, meta=meta, mediainfo=mediainfo)\n        else:\n            # 正在洗版，更新资源优先级\n            logger.info(f'{mediainfo.title_year} 正在洗版，更新资源优先级为 {priority}')\n\n    def finish_subscribe_or_not(self, subscribe: Subscribe, meta: MetaBase, mediainfo: MediaInfo,\n                                downloads: List[Context] = None,\n                                lefts: Dict[Union[int | str], Dict[int, schemas.NotExistMediaInfo]] = None,\n                                force: Optional[bool] = False):\n        \"\"\"\n        判断是否应完成订阅\n        \"\"\"\n        mediakey = subscribe.tmdbid or subscribe.doubanid\n        # 是否有剩余集\n        no_lefts = not lefts or not lefts.get(mediakey)\n        # 是否完成订阅\n        if not subscribe.best_version:\n            # 订阅存在待定策略，不管是否已完成，均需更新订阅信息\n            # 更新订阅已下载信息\n            self.__update_subscribe_note(subscribe=subscribe, downloads=downloads)\n            # 更新订阅剩余集数和时间\n            self.__update_lack_episodes(lefts=lefts, subscribe=subscribe, mediainfo=mediainfo,\n                                        update_date=bool(downloads))\n            # 判断是否需要完成订阅\n            if ((no_lefts and meta.type == MediaType.TV)\n                    or (downloads and meta.type == MediaType.MOVIE)\n                    or force):\n                self.__finish_subscribe(subscribe=subscribe, meta=meta, mediainfo=mediainfo)\n            else:\n                # 未下载到内容且不完整\n                logger.info(f'{mediainfo.title_year} 未下载完整，继续订阅 ...')\n        elif downloads:\n            # 洗版下载到了内容，更新资源优先级\n            self.update_subscribe_priority(subscribe=subscribe, meta=meta,\n                                           mediainfo=mediainfo, downloads=downloads)\n        elif subscribe.current_priority == 100:\n            # 洗版完成\n            self.__finish_subscribe(subscribe=subscribe, meta=meta, mediainfo=mediainfo)\n        else:\n            # 洗版，未下载到内容\n            logger.info(f'{mediainfo.title_year} 继续洗版 ...')\n\n    def refresh(self):\n        \"\"\"\n        订阅刷新\n        \"\"\"\n        # 触发刷新站点资源，从缓存中匹配订阅\n        sites = self.get_subscribed_sites()\n        if sites is None:\n            return\n        self.match(\n            TorrentsChain().refresh(sites=sites)\n        )\n\n    @staticmethod\n    def get_sub_sites(subscribe: Subscribe) -> List[int]:\n        \"\"\"\n        获取订阅中涉及的站点清单\n        :param subscribe: 订阅信息对象\n        :return: 涉及的站点清单\n        \"\"\"\n        # 从系统配置获取默认订阅站点\n        default_sites = SystemConfigOper().get(SystemConfigKey.RssSites) or []\n        # 如果订阅未指定站点，直接返回默认站点\n        if not subscribe.sites:\n            return default_sites\n        # 如果默认订阅站点未设置，直接返回订阅指定站点\n        if not default_sites:\n            return subscribe.sites or []\n        # 尝试解析订阅中的站点数据\n        user_sites = subscribe.sites\n        # 计算 user_sites 和 default_sites 的交集\n        intersection_sites = [site for site in user_sites if site in default_sites]\n        # 如果交集为空，返回默认站点\n        return intersection_sites if intersection_sites else default_sites\n\n    def get_subscribed_sites(self) -> Optional[List[int]]:\n        \"\"\"\n        获取订阅中涉及的所有站点清单（节约资源）\n        :return: 返回[]代表所有站点命中，返回None代表没有订阅\n        \"\"\"\n        ret_sites = []\n        subscribes = SubscribeOper().list()\n        if not subscribes:\n            # 没有订阅\n            return None\n        # 刷新订阅选中的Rss站点\n        for subscribe in subscribes:\n            # 刷新选中的站点\n            if subscribe.state in self.get_states_for_search('R'):\n                ret_sites.extend(self.get_sub_sites(subscribe))\n        # 去重\n        if ret_sites:\n            ret_sites = list(set(ret_sites))\n\n        return ret_sites\n\n    def match(self, torrents: Dict[str, List[Context]]):\n        \"\"\"\n        从缓存中匹配订阅，并自动下载\n        \"\"\"\n        if not torrents:\n            logger.warn('没有缓存资源，无法匹配订阅')\n            return\n\n        lock_acquired = False\n        try:\n            if lock_acquired := self._rlock.acquire(\n                    blocking=True, timeout=self._LOCK_TIMOUT\n            ):\n                logger.debug(f\"match lock acquired at {datetime.now()}\")\n            else:\n                logger.warn(\"match上锁超时\")\n\n            # 预识别所有未识别的种子\n            processed_torrents: Dict[str, List[Context]] = {}\n            for domain, contexts in torrents.items():\n                if global_vars.is_system_stopped:\n                    break\n                processed_torrents[domain] = []\n                for context in contexts:\n                    if global_vars.is_system_stopped:\n                        break\n                    # 如果种子未识别且失败次数未超过3次，尝试识别\n                    if (not context.media_info or (not context.media_info.tmdb_id\n                                                   and not context.media_info.douban_id)) and context.media_recognize_fail_count < 3:\n                        logger.debug(\n                            f'尝试重新识别种子：{context.torrent_info.title}，当前失败次数：{context.media_recognize_fail_count}/3')\n                        re_mediainfo = self.recognize_media(meta=context.meta_info)\n                        if re_mediainfo:\n                            # 清理多余信息\n                            re_mediainfo.clear()\n                            # 更新种子缓存\n                            context.media_info = re_mediainfo\n                            # 重置失败次数\n                            context.media_recognize_fail_count = 0\n                            logger.debug(f'种子 {context.torrent_info.title} 重新识别成功')\n                        else:\n                            # 识别失败，增加失败次数\n                            context.media_recognize_fail_count += 1\n                            logger.debug(\n                                f'种子 {context.torrent_info.title} 媒体识别失败，失败次数：{context.media_recognize_fail_count}/3')\n                    elif context.media_recognize_fail_count >= 3:\n                        logger.debug(f'种子 {context.torrent_info.title} 已达到最大识别失败次数(3次)，跳过识别')\n                    # 添加已预处理\n                    processed_torrents[domain].append(context)\n\n            # 所有订阅\n            subscribes = SubscribeOper().list(self.get_states_for_search('R'))\n            try:\n                for subscribe in subscribes:\n                    if global_vars.is_system_stopped:\n                        break\n                    logger.info(f'开始匹配订阅，标题：{subscribe.name} ...')\n                    mediakey = subscribe.tmdbid or subscribe.doubanid\n                    # 生成元数据\n                    meta = MetaInfo(subscribe.name)\n                    meta.year = subscribe.year\n                    meta.begin_season = subscribe.season or None\n                    try:\n                        meta.type = MediaType(subscribe.type)\n                    except ValueError:\n                        logger.error(f'订阅 {subscribe.name} 类型错误：{subscribe.type}')\n                        continue\n                    # 订阅的站点域名列表\n                    domains = []\n                    if subscribe.sites:\n                        domains = SiteOper().get_domains_by_ids(subscribe.sites)\n                    # 识别媒体信息\n                    mediainfo: MediaInfo = self.recognize_media(meta=meta, mtype=meta.type,\n                                                                tmdbid=subscribe.tmdbid,\n                                                                doubanid=subscribe.doubanid,\n                                                                episode_group=subscribe.episode_group,\n                                                                cache=False)\n                    if not mediainfo:\n                        logger.warn(\n                            f'未识别到媒体信息，标题：{subscribe.name}，tmdbid：{subscribe.tmdbid}，doubanid：{subscribe.doubanid}')\n                        continue\n\n                    # 如果媒体已存在或已下载完毕，跳过当前订阅处理\n                    exist_flag, no_exists = self.check_and_handle_existing_media(subscribe=subscribe, meta=meta,\n                                                                                 mediainfo=mediainfo,\n                                                                                 mediakey=mediakey)\n                    if exist_flag:\n                        continue\n\n                    # 清理多余信息\n                    mediainfo.clear()\n\n                    # 订阅识别词\n                    if subscribe.custom_words:\n                        custom_words_list = subscribe.custom_words.split(\"\\n\")\n                    else:\n                        custom_words_list = None\n\n                    # 遍历预识别后的种子\n                    _match_context = []\n                    torrenthelper = TorrentHelper()\n                    systemconfig = SystemConfigOper()\n                    wordsmatcher = WordsMatcher()\n                    for domain, contexts in processed_torrents.items():\n                        if global_vars.is_system_stopped:\n                            break\n                        if domains and domain not in domains:\n                            continue\n                        logger.debug(f'开始匹配站点：{domain}，共缓存了 {len(contexts)} 个种子...')\n                        for context in contexts:\n                            if global_vars.is_system_stopped:\n                                break\n                            # 提取信息\n                            _context = copy.copy(context)\n                            torrent_meta = _context.meta_info\n                            torrent_mediainfo = _context.media_info\n                            torrent_info = _context.torrent_info\n\n                            # 不在订阅站点范围的不处理\n                            sub_sites = self.get_sub_sites(subscribe)\n                            if sub_sites and torrent_info.site not in sub_sites:\n                                logger.debug(f\"{torrent_info.site_name} - {torrent_info.title} 不符合订阅站点要求\")\n                                continue\n\n                            # 有自定义识别词时，需要判断是否需要重新识别\n                            if custom_words_list:\n                                # 使用org_string，应用一次后理论上不能再次应用\n                                _, apply_words = wordsmatcher.prepare(torrent_meta.org_string,\n                                                                      custom_words=custom_words_list)\n                                if apply_words:\n                                    logger.info(\n                                        f'{torrent_info.site_name} - {torrent_info.title} 因订阅存在自定义识别词，重新识别元数据...')\n                                    # 重新识别元数据\n                                    torrent_meta = MetaInfo(title=torrent_info.title, subtitle=torrent_info.description,\n                                                            custom_words=custom_words_list)\n                                    # 更新元数据缓存\n                                    _context.meta_info = torrent_meta\n                                    # 重新识别媒体信息\n                                    torrent_mediainfo = self.recognize_media(meta=torrent_meta,\n                                                                             episode_group=subscribe.episode_group)\n                                    if torrent_mediainfo:\n                                        # 清理多余信息\n                                        torrent_mediainfo.clear()\n                                        # 更新种子缓存\n                                        _context.media_info = torrent_mediainfo\n\n                            # 如果仍然没有识别到媒体信息，尝试标题匹配\n                            if not torrent_mediainfo or (\n                                    not torrent_mediainfo.tmdb_id and not torrent_mediainfo.douban_id):\n                                logger.debug(\n                                    f'{torrent_info.site_name} - {torrent_info.title} 重新识别失败，尝试通过标题匹配...')\n                                if torrenthelper.match_torrent(mediainfo=mediainfo,\n                                                               torrent_meta=torrent_meta,\n                                                               torrent=torrent_info):\n                                    # 匹配成功\n                                    logger.info(\n                                        f'{mediainfo.title_year} 通过标题匹配到可选资源：{torrent_info.site_name} - {torrent_info.title}')\n                                    torrent_mediainfo = mediainfo\n                                    # 更新种子缓存\n                                    _context.media_info = mediainfo\n                                else:\n                                    continue\n\n                            # 直接比对媒体信息\n                            if torrent_mediainfo and (torrent_mediainfo.tmdb_id or torrent_mediainfo.douban_id):\n                                if torrent_mediainfo.type != mediainfo.type:\n                                    continue\n                                if torrent_mediainfo.tmdb_id \\\n                                        and torrent_mediainfo.tmdb_id != mediainfo.tmdb_id:\n                                    continue\n                                if torrent_mediainfo.douban_id \\\n                                        and torrent_mediainfo.douban_id != mediainfo.douban_id:\n                                    continue\n                                logger.info(\n                                    f'{mediainfo.title_year} 通过媒体ID匹配到可选资源：{torrent_info.site_name} - {torrent_info.title}')\n                            else:\n                                continue\n\n                            # 如果是电视剧\n                            if torrent_mediainfo.type == MediaType.TV:\n                                # 有多季的不要\n                                if len(torrent_meta.season_list) > 1:\n                                    logger.debug(f'{torrent_info.title} 有多季，不处理')\n                                    continue\n                                # 比对季\n                                if torrent_meta.begin_season:\n                                    if meta.begin_season != torrent_meta.begin_season:\n                                        logger.debug(f'{torrent_info.title} 季不匹配')\n                                        continue\n                                elif meta.begin_season != 1:\n                                    logger.debug(f'{torrent_info.title} 季不匹配')\n                                    continue\n                                # 非洗版\n                                if not subscribe.best_version:\n                                    # 不是缺失的剧集不要\n                                    if no_exists and no_exists.get(mediakey):\n                                        # 缺失集\n                                        no_exists_info = no_exists.get(mediakey).get(subscribe.season)\n                                        if no_exists_info:\n                                            # 是否有交集\n                                            if no_exists_info.episodes and \\\n                                                    torrent_meta.episode_list and \\\n                                                    not set(no_exists_info.episodes).intersection(\n                                                        set(torrent_meta.episode_list)\n                                                    ):\n                                                logger.debug(\n                                                    f'{torrent_info.title} 对应剧集 {torrent_meta.episode_list} 未包含缺失的剧集'\n                                                )\n                                                continue\n                                else:\n                                    # 洗版时，非整季不要\n                                    if meta.type == MediaType.TV:\n                                        if torrent_meta.episode_list:\n                                            logger.debug(f'{subscribe.name} 正在洗版，{torrent_info.title} 不是整季')\n                                            continue\n\n                            # 匹配订阅附加参数\n                            if not torrenthelper.filter_torrent(torrent_info=torrent_info,\n                                                                filter_params=self.get_params(subscribe)):\n                                continue\n\n                            # 优先级过滤规则\n                            if subscribe.best_version:\n                                rule_groups = subscribe.filter_groups \\\n                                              or systemconfig.get(SystemConfigKey.BestVersionFilterRuleGroups)\n                            else:\n                                rule_groups = subscribe.filter_groups \\\n                                              or systemconfig.get(SystemConfigKey.SubscribeFilterRuleGroups)\n                            result: List[TorrentInfo] = self.filter_torrents(\n                                rule_groups=rule_groups,\n                                torrent_list=[torrent_info],\n                                mediainfo=torrent_mediainfo)\n                            if result is not None and not result:\n                                # 不符合过滤规则\n                                logger.debug(f\"{torrent_info.title} 不匹配过滤规则\")\n                                continue\n\n                            # 洗版时，优先级小于已下载优先级的不要\n                            if subscribe.best_version:\n                                if subscribe.current_priority \\\n                                        and torrent_info.pri_order <= subscribe.current_priority:\n                                    logger.info(\n                                        f'{subscribe.name} 正在洗版，{torrent_info.title} 优先级低于或等于已下载优先级')\n                                    continue\n\n                            # 匹配成功\n                            logger.info(f'{mediainfo.title_year} 匹配成功：{torrent_info.title}')\n                            # 自定义属性\n                            if subscribe.media_category:\n                                torrent_mediainfo.category = subscribe.media_category\n                            if subscribe.episode_group:\n                                torrent_mediainfo.episode_group = subscribe.episode_group\n                            _match_context.append(_context)\n\n                    if not _match_context:\n                        # 未匹配到资源\n                        logger.info(f'{mediainfo.title_year} 未匹配到符合条件的资源')\n                        self.finish_subscribe_or_not(subscribe=subscribe, meta=meta,\n                                                     mediainfo=mediainfo, lefts=no_exists)\n                        continue\n\n                    # 开始批量择优下载\n                    logger.info(f'{mediainfo.title_year} 匹配完成，共匹配到{len(_match_context)}个资源')\n                    downloads, lefts = DownloadChain().batch_download(contexts=_match_context,\n                                                                      no_exists=no_exists,\n                                                                      username=subscribe.username,\n                                                                      save_path=subscribe.save_path,\n                                                                      downloader=subscribe.downloader,\n                                                                      source=self.get_subscribe_source_keyword(\n                                                                          subscribe)\n                                                                      )\n\n                    # 同步外部修改，更新订阅信息\n                    subscribe = SubscribeOper().get(subscribe.id)\n\n                    # 判断是否要完成订阅\n                    if subscribe:\n                        self.finish_subscribe_or_not(subscribe=subscribe, meta=meta, mediainfo=mediainfo,\n                                                     downloads=downloads, lefts=lefts)\n            finally:\n                processed_torrents.clear()\n                del processed_torrents\n                subscribes.clear()\n                del subscribes\n        finally:\n            if lock_acquired:\n                self._rlock.release()\n                logger.debug(f\"match Lock released at {datetime.now()}\")\n\n    def check(self):\n        \"\"\"\n        定时检查订阅，更新订阅信息\n        \"\"\"\n        # 查询所有订阅\n        subscribeoper = SubscribeOper()\n        # 遍历订阅\n        for subscribe in subscribeoper.list():\n            if global_vars.is_system_stopped:\n                break\n            logger.info(f'开始更新订阅元数据：{subscribe.name} ...')\n            # 生成元数据\n            meta = MetaInfo(subscribe.name)\n            meta.year = subscribe.year\n            meta.begin_season = subscribe.season or None\n            try:\n                meta.type = MediaType(subscribe.type)\n            except ValueError:\n                logger.error(f'订阅 {subscribe.name} 类型错误：{subscribe.type}')\n                continue\n            # 识别媒体信息\n            mediainfo: MediaInfo = self.recognize_media(meta=meta, mtype=meta.type,\n                                                        tmdbid=subscribe.tmdbid,\n                                                        doubanid=subscribe.doubanid,\n                                                        episode_group=subscribe.episode_group,\n                                                        cache=False)\n            if not mediainfo:\n                logger.warn(\n                    f'未识别到媒体信息，标题：{subscribe.name}，tmdbid：{subscribe.tmdbid}，doubanid：{subscribe.doubanid}')\n                continue\n            # 对于电视剧，获取当前季的总集数\n            episodes = mediainfo.seasons.get(subscribe.season) or []\n            if not subscribe.manual_total_episode and len(episodes):\n                total_episode = len(episodes)\n                lack_episode = subscribe.lack_episode + (total_episode - subscribe.total_episode)\n                logger.info(\n                    f'订阅 {subscribe.name} 总集数变化，更新总集数为{total_episode}，缺失集数为{lack_episode} ...')\n            else:\n                total_episode = subscribe.total_episode\n                lack_episode = subscribe.lack_episode\n            # 更新TMDB信息\n            subscribeoper.update(subscribe.id, {\n                \"name\": mediainfo.title,\n                \"year\": mediainfo.year,\n                \"vote\": mediainfo.vote_average,\n                \"poster\": mediainfo.get_poster_image(),\n                \"backdrop\": mediainfo.get_backdrop_image(),\n                \"description\": mediainfo.overview,\n                \"imdbid\": mediainfo.imdb_id,\n                \"tvdbid\": mediainfo.tvdb_id,\n                \"total_episode\": total_episode,\n                \"lack_episode\": lack_episode\n            })\n            logger.info(f'{subscribe.name} 订阅元数据更新完成')\n\n    def get_subscribe_by_source(self, source: str) -> Optional[Subscribe]:\n        \"\"\"\n        从来源获取订阅\n        \"\"\"\n        source_keyword = self.parse_subscribe_source_keyword(source)\n        if not source_keyword:\n            return None\n        # 只保留需要的字段动态获取订阅\n        valid_fields = {k: v for k, v in source_keyword.items()\n                        if k in [\"type\", \"season\", \"tmdbid\", \"doubanid\", \"bangumiid\"]}\n        # 暂时不考虑订阅历史, 若有必要再添加\n        return SubscribeOper().get_by(**valid_fields)\n\n    @staticmethod\n    def follow():\n        \"\"\"\n        刷新follow的用户分享，并自动添加订阅\n        \"\"\"\n        follow_users: List[str] = SystemConfigOper().get(SystemConfigKey.FollowSubscribers)\n        if not follow_users:\n            return\n        logger.info(f'开始刷新follow用户分享订阅 ...')\n        success_count = 0\n        subscribeoper = SubscribeOper()\n        for share_sub in SubscribeHelper().get_shares():\n            if global_vars.is_system_stopped:\n                break\n            uid = share_sub.get(\"share_uid\")\n            if uid and uid in follow_users:\n                # 订阅已存在则跳过\n                if subscribeoper.exists(tmdbid=share_sub.get(\"tmdbid\"),\n                                        doubanid=share_sub.get(\"doubanid\"),\n                                        season=share_sub.get(\"season\")):\n                    continue\n                # 已经订阅过跳过\n                if subscribeoper.exist_history(tmdbid=share_sub.get(\"tmdbid\"),\n                                               doubanid=share_sub.get(\"doubanid\"),\n                                               season=share_sub.get(\"season\")):\n                    continue\n                # 去除无效属性\n                for key in list(share_sub.keys()):\n                    if not hasattr(schemas.Subscribe(), key):\n                        share_sub.pop(key)\n                # 类型转换\n                subscribe_in = schemas.Subscribe(**share_sub)\n                mtype = MediaType(subscribe_in.type)\n                # 豆瓣标题处理\n                if subscribe_in.doubanid or subscribe_in.bangumiid:\n                    meta = MetaInfo(subscribe_in.name)\n                    subscribe_in.name = meta.name\n                    subscribe_in.season = meta.begin_season\n                # 标题转换\n                if subscribe_in.name:\n                    title = subscribe_in.name\n                else:\n                    title = None\n                sid, message = SubscribeChain().add(mtype=mtype,\n                                                    title=title,\n                                                    year=subscribe_in.year,\n                                                    tmdbid=subscribe_in.tmdbid,\n                                                    season=subscribe_in.season,\n                                                    doubanid=subscribe_in.doubanid,\n                                                    bangumiid=subscribe_in.bangumiid,\n                                                    username=\"订阅分享\",\n                                                    best_version=subscribe_in.best_version,\n                                                    save_path=subscribe_in.save_path,\n                                                    search_imdbid=subscribe_in.search_imdbid,\n                                                    custom_words=subscribe_in.custom_words,\n                                                    media_category=subscribe_in.media_category,\n                                                    filter_groups=subscribe_in.filter_groups,\n                                                    exist_ok=True)\n                if sid:\n                    success_count += 1\n                    logger.info(f'follow用户分享订阅 {title} 添加成功')\n                else:\n                    logger.error(f'follow用户分享订阅 {title} 添加失败：{message}')\n        logger.info(f'follow用户分享订阅刷新完成，共添加 {success_count} 个订阅')\n\n    async def cache_calendar(self):\n        \"\"\"\n        预缓存订阅日历，实际上就是查询一遍所有订阅的媒体信息\n        前端请示是异常的，所以需要使用异步缓存方法\n        \"\"\"\n        logger.info(f'开始预缓存订阅日历 ...')\n        for subscribe in await SubscribeOper().async_list():\n            if global_vars.is_system_stopped:\n                break\n            try:\n                mtype = MediaType(subscribe.type)\n            except ValueError:\n                logger.error(f'订阅 {subscribe.name} 类型错误：{subscribe.type}')\n                continue\n            # 识别媒体信息\n            if mtype == MediaType.MOVIE:\n                mediainfo: MediaInfo = await self.async_recognize_media(mtype=mtype,\n                                                                        tmdbid=subscribe.tmdbid,\n                                                                        doubanid=subscribe.doubanid,\n                                                                        bangumiid=subscribe.bangumiid,\n                                                                        episode_group=subscribe.episode_group,\n                                                                        cache=False)\n                if not mediainfo:\n                    logger.warn(\n                        f'未识别到媒体信息，标题：{subscribe.name}，tmdbid：{subscribe.tmdbid}，doubanid：{subscribe.doubanid}')\n                    continue\n            else:\n                episodes = await TmdbChain().async_tmdb_episodes(tmdbid=subscribe.tmdbid,\n                                                                 season=subscribe.season,\n                                                                 episode_group=subscribe.episode_group)\n                if not episodes:\n                    logger.warn(\n                        f'未识别到季集信息，标题：{subscribe.name}，tmdbid：{subscribe.tmdbid}，豆瓣ID：{subscribe.doubanid}，季：{subscribe.season}')\n                    continue\n        logger.info(f'订阅日历预缓存完成')\n\n    @staticmethod\n    def __update_subscribe_note(subscribe: Subscribe, downloads: Optional[List[Context]]):\n        \"\"\"\n        更新已下载信息到note字段\n        \"\"\"\n        # 查询现有Note\n        if not downloads:\n            return\n        note = []\n        if subscribe.note:\n            note = subscribe.note or []\n        for context in downloads:\n            meta = context.meta_info\n            mediainfo = context.media_info\n            if subscribe.tmdbid and mediainfo.tmdb_id \\\n                    and mediainfo.tmdb_id != subscribe.tmdbid:\n                continue\n            if subscribe.doubanid and mediainfo.douban_id \\\n                    and mediainfo.douban_id != subscribe.doubanid:\n                continue\n            items = []\n            if mediainfo.type == MediaType.TV:\n                # 电视剧有集数，使用 episode_list\n                items = meta.episode_list\n            elif mediainfo.type == MediaType.MOVIE:\n                # 电影只有一个条目，设置为 [1]\n                items = [1]\n            if not items:\n                continue\n            # 合并已下载的集数或电影项（去重）\n            note = list(set(note).union(set(items)))\n        # 更新订阅\n        if note:\n            SubscribeOper().update(subscribe.id, {\n                \"note\": note\n            })\n\n    @staticmethod\n    def __get_downloaded(subscribe: Subscribe) -> List[int]:\n        \"\"\"\n        获取已下载过的集数或电影\n        \"\"\"\n        if subscribe.best_version:\n            return []\n        note = subscribe.note or []\n        if not note:\n            return []\n        # 针对 TV 类型，返回已下载的集数\n        if subscribe.type == MediaType.TV.value:\n            logger.info(f'订阅 {subscribe.name} 第{subscribe.season}季 已下载集数：{note}')\n            return note\n        # 针对 Movie 类型，直接返回已下载的电影\n        if subscribe.type == MediaType.MOVIE.value:\n            logger.info(f'订阅 {subscribe.name} 已下载内容：{note}')\n            return note\n        return []\n\n    @staticmethod\n    def __update_lack_episodes(lefts: Dict[Union[int, str], Dict[int, schemas.NotExistMediaInfo]],\n                               subscribe: Subscribe,\n                               mediainfo: MediaInfo,\n                               update_date: Optional[bool] = False):\n        \"\"\"\n        更新订阅剩余集数及时间\n        \"\"\"\n        update_data = {}\n        if update_date:\n            update_data[\"last_update\"] = datetime.now().strftime('%Y-%m-%d %H:%M:%S')\n        if subscribe.type == MediaType.TV.value:\n            if not lefts:\n                # 如果 lefts 为空，表示没有缺失集数，直接设置 lack_episode 为 0\n                lack_episode = 0\n                logger.info(f'{mediainfo.title_year} 没有缺失集数，直接更新为 0 ...')\n            else:\n                mediakey = subscribe.tmdbid or subscribe.doubanid\n                left_seasons = lefts.get(mediakey)\n                lack_episode = 0\n                if left_seasons:\n                    for season_info in left_seasons.values():\n                        season = season_info.season\n                        if season == subscribe.season:\n                            left_episodes = season_info.episodes\n                            if not left_episodes:\n                                lack_episode = season_info.total_episode\n                            else:\n                                lack_episode = len(left_episodes)\n                            logger.info(f\"{mediainfo.title_year} 季 {season} 更新缺失集数为{lack_episode} ...\")\n                            break\n            update_data[\"lack_episode\"] = lack_episode\n        # 更新数据库\n        if update_data:\n            SubscribeOper().update(subscribe.id, update_data)\n\n    def __finish_subscribe(self, subscribe: Subscribe, mediainfo: MediaInfo, meta: MetaBase):\n        \"\"\"\n        完成订阅\n        \"\"\"\n        # 如果订阅状态为待定（P），说明订阅信息尚未完全更新，无法完成订阅\n        if subscribe.state == \"P\":\n            return\n        # 完成订阅\n        msgstr = \"订阅\" if not subscribe.best_version else \"洗版\"\n        logger.info(f'{mediainfo.title_year} 完成{msgstr}')\n        # 新增订阅历史\n        subscribeoper = SubscribeOper()\n        subscribeoper.add_history(**subscribe.to_dict())\n        # 删除订阅\n        subscribeoper.delete(subscribe.id)\n        # 发送通知\n        if mediainfo.type == MediaType.TV:\n            link = settings.MP_DOMAIN('#/subscribe/tv?tab=mysub')\n        else:\n            link = settings.MP_DOMAIN('#/subscribe/movie?tab=mysub')\n        # 完成订阅按规则发送消息\n        self.post_message(\n            schemas.Notification(\n                mtype=NotificationType.Subscribe,\n                ctype=ContentType.SubscribeComplete,\n                image=mediainfo.get_message_image(),\n                link=link,\n                username=subscribe.username\n            ),\n            meta=meta,\n            mediainfo=mediainfo,\n            msgstr=msgstr,\n            username=subscribe.username\n        )\n        # 发送事件\n        eventmanager.send_event(EventType.SubscribeComplete, {\n            \"subscribe_id\": subscribe.id,\n            \"subscribe_info\": subscribe.to_dict(),\n            \"mediainfo\": mediainfo.to_dict(),\n        })\n        # 统计订阅\n        SubscribeHelper().sub_done_async({\n            \"tmdbid\": mediainfo.tmdb_id,\n            \"doubanid\": mediainfo.douban_id\n        })\n\n    def remote_list(self, channel: MessageChannel,\n                    userid: Union[str, int] = None, source: Optional[str] = None):\n        \"\"\"\n        查询订阅并发送消息\n        \"\"\"\n        subscribes = SubscribeOper().list()\n        if not subscribes:\n            self.post_message(schemas.Notification(channel=channel,\n                                                   source=source,\n                                                   title='没有任何订阅！', userid=userid))\n            return\n        title = f\"共有 {len(subscribes)} 个订阅，回复对应指令操作： \" \\\n                f\"\\n- 删除订阅：/subscribe_delete [id]\" \\\n                f\"\\n- 搜索订阅：/subscribe_search [id]\" \\\n                f\"\\n- 刷新订阅：/subscribe_refresh\"\n        messages = []\n        for subscribe in subscribes:\n            if subscribe.type == MediaType.MOVIE.value:\n                messages.append(f\"{subscribe.id}. {subscribe.name}（{subscribe.year}）\")\n            else:\n                messages.append(f\"{subscribe.id}. {subscribe.name}（{subscribe.year}）\"\n                                f\"第{subscribe.season}季 \"\n                                f\"[{subscribe.total_episode - (subscribe.lack_episode or subscribe.total_episode)}\"\n                                f\"/{subscribe.total_episode}]\")\n        # 发送列表\n        self.post_message(schemas.Notification(channel=channel, source=source,\n                                               title=title, text='\\n'.join(messages), userid=userid))\n\n    def remote_delete(self, arg_str: str, channel: MessageChannel,\n                      userid: Union[str, int] = None, source: Optional[str] = None):\n        \"\"\"\n        删除订阅\n        \"\"\"\n        if not arg_str:\n            self.post_message(schemas.Notification(channel=channel, source=source,\n                                                   title=\"请输入正确的命令格式：/subscribe_delete [id]，\"\n                                                         \"[id]为订阅编号\", userid=userid))\n            return\n        arg_strs = str(arg_str).split()\n        subscribeoper = SubscribeOper()\n        subscribehelper = SubscribeHelper()\n        for arg_str in arg_strs:\n            arg_str = arg_str.strip()\n            if not arg_str.isdigit():\n                continue\n            subscribe_id = int(arg_str)\n            subscribe = subscribeoper.get(subscribe_id)\n            if not subscribe:\n                self.post_message(schemas.Notification(channel=channel, source=source,\n                                                       title=f\"订阅编号 {subscribe_id} 不存在！\", userid=userid))\n                return\n            # 删除订阅\n            subscribeoper.delete(subscribe_id)\n            # 统计订阅\n            subscribehelper.sub_done_async({\n                \"tmdbid\": subscribe.tmdbid,\n                \"doubanid\": subscribe.doubanid\n            })\n        # 重新发送消息\n        self.remote_list(channel=channel, userid=userid, source=source)\n\n    @staticmethod\n    def __get_subscribe_no_exits(subscribe_name: str,\n                                 no_exists: Dict[Union[int, str], Dict[int, schemas.NotExistMediaInfo]],\n                                 mediakey: Union[str, int],\n                                 begin_season: int,\n                                 total_episode: Optional[int],\n                                 start_episode: Optional[int],\n                                 downloaded_episodes: List[int] = None\n                                 ) -> Tuple[bool, Dict[Union[int, str], Dict[int, schemas.NotExistMediaInfo]]]:\n        \"\"\"\n        根据订阅开始集数和总集数，结合TMDB信息计算当前订阅的缺失集数\n        :param subscribe_name: 订阅名称\n        :param no_exists: 缺失季集列表\n        :param mediakey: TMDB ID或豆瓣ID\n        :param begin_season: 开始季\n        :param total_episode: 订阅设定总集数\n        :param start_episode: 订阅设定开始集数\n        :param downloaded_episodes: 已下载集数\n        \"\"\"\n        # 使用订阅的总集数和开始集数替换no_exists\n        if not no_exists or not no_exists.get(mediakey):\n            return False, no_exists\n        no_exists_item = no_exists.get(mediakey)\n        if total_episode or start_episode:\n            logger.info(f'订阅 {subscribe_name} 设定的开始集数：{start_episode}、总集数：{total_episode}')\n            # 该季原缺失信息\n            no_exist_season = no_exists_item.get(begin_season)\n            if no_exist_season:\n                # 原集列表\n                episode_list = no_exist_season.episodes\n                # 原总集数\n                total = no_exist_season.total_episode\n                # 原开始集数\n                start = no_exist_season.start_episode\n\n                # 更新剧集列表、开始集数、总集数\n                if not episode_list:\n                    # 整季缺失\n                    episodes = []\n                    start_episode = start_episode or start\n                    total_episode = total_episode or total\n                else:\n                    # 部分缺失\n                    if not start_episode \\\n                            and not total_episode:\n                        # 无需调整\n                        return False, no_exists\n                    if not start_episode:\n                        # 没有自定义开始集\n                        start_episode = start\n                    if not total_episode:\n                        # 没有自定义总集数\n                        total_episode = total\n                    # 新的集列表\n                    new_episodes = list(range(max(start_episode, start), total_episode + 1))\n                    # 与原集列表取交集\n                    episodes = list(set(episode_list).intersection(set(new_episodes)))\n                    # 交集为空时，说明订阅的剧集均已入库\n                    if not episodes:\n                        return True, {}\n                # 更新集合\n                no_exists[mediakey][begin_season] = schemas.NotExistMediaInfo(\n                    season=begin_season,\n                    episodes=episodes,\n                    total_episode=total_episode,\n                    start_episode=start_episode\n                )\n        # 根据订阅已下载集数更新缺失集数\n        if downloaded_episodes:\n            logger.info(f'订阅 {subscribe_name} 已下载集数：{downloaded_episodes}')\n            # 该季原缺失信息\n            no_exist_season = no_exists_item.get(begin_season)\n            if no_exist_season:\n                # 原集列表\n                episode_list = no_exist_season.episodes\n                # 原总集数\n                total = no_exist_season.total_episode\n                # 原开始集数\n                start = no_exist_season.start_episode\n                # 整季缺失\n                if not episode_list:\n                    episode_list = list(range(start, total + 1))\n                # 更新剧集列表\n                episodes = list(set(episode_list).difference(set(downloaded_episodes)))\n                # 如果存在已下载剧集，则差集为空时，说明所有均已存在\n                if not episodes:\n                    return True, {}\n                # 更新集合\n                no_exists[mediakey][begin_season] = schemas.NotExistMediaInfo(\n                    season=begin_season,\n                    episodes=episodes,\n                    total_episode=total,\n                    start_episode=start,\n                )\n            else:\n                # 开始集数\n                start = start_episode or 1\n                # 更新剧集列表\n                episodes = list(set(range(start, total_episode + 1)).difference(set(downloaded_episodes)))\n                # 如果存在已下载剧集，则差集为空时，说明所有均已存在\n                if not episodes:\n                    return True, {}\n                no_exists[mediakey][begin_season] = schemas.NotExistMediaInfo(\n                    season=begin_season,\n                    episodes=episodes,\n                    total_episode=total_episode,\n                    start_episode=start,\n                )\n        logger.info(f'订阅 {subscribe_name} 缺失剧集数更新为：{no_exists}')\n        return False, no_exists\n\n    @eventmanager.register(EventType.SiteDeleted)\n    def remove_site(self, event: Event):\n        \"\"\"\n        从订阅中移除与站点相关的设置\n        \"\"\"\n        if not event:\n            return\n        event_data = event.event_data or {}\n        site_id = event_data.get(\"site_id\")\n        if not site_id:\n            return\n        subscribeoper = SubscribeOper()\n        if site_id == \"*\":\n            # 站点被重置\n            SystemConfigOper().set(SystemConfigKey.RssSites, [])\n            for subscribe in subscribeoper.list():\n                if not subscribe.sites:\n                    continue\n                subscribeoper.update(subscribe.id, {\n                    \"sites\": []\n                })\n            return\n        # 从选中的rss站点中移除\n        selected_sites = SystemConfigOper().get(SystemConfigKey.RssSites) or []\n        if site_id in selected_sites:\n            selected_sites.remove(site_id)\n            SystemConfigOper().set(SystemConfigKey.RssSites, selected_sites)\n        # 查询所有订阅\n        for subscribe in subscribeoper.list():\n            if not subscribe.sites:\n                continue\n            sites = subscribe.sites or []\n            if site_id not in sites:\n                continue\n            sites.remove(site_id)\n            subscribeoper.update(subscribe.id, {\n                \"sites\": sites\n            })\n\n    @staticmethod\n    def __get_default_subscribe_config(mtype: MediaType, default_config_key: str) -> Optional[str]:\n        \"\"\"\n        获取默认订阅配置\n        \"\"\"\n        default_subscribe_key = None\n        if mtype == MediaType.TV:\n            default_subscribe_key = SystemConfigKey.DefaultTvSubscribeConfig.value\n        if mtype == MediaType.MOVIE:\n            default_subscribe_key = SystemConfigKey.DefaultMovieSubscribeConfig.value\n\n        # 默认订阅规则\n        if hasattr(settings, default_subscribe_key):\n            value = getattr(settings, default_subscribe_key)\n        else:\n            value = SystemConfigOper().get(default_subscribe_key)\n\n        if not value:\n            return None\n        return value.get(default_config_key) or None\n\n    @staticmethod\n    def get_params(subscribe: Subscribe):\n        \"\"\"\n        获取订阅默认参数\n        \"\"\"\n        # 默认过滤规则\n        default_rule = SystemConfigOper().get(SystemConfigKey.SubscribeDefaultParams) or {}\n        return {\n            key: value for key, value in {\n                \"include\": subscribe.include or default_rule.get(\"include\"),\n                \"exclude\": subscribe.exclude or default_rule.get(\"exclude\"),\n                \"quality\": subscribe.quality or default_rule.get(\"quality\"),\n                \"resolution\": subscribe.resolution or default_rule.get(\"resolution\"),\n                \"effect\": subscribe.effect or default_rule.get(\"effect\"),\n                \"tv_size\": default_rule.get(\"tv_size\"),\n                \"movie_size\": default_rule.get(\"movie_size\"),\n                \"min_seeders\": default_rule.get(\"min_seeders\"),\n                \"min_seeders_time\": default_rule.get(\"min_seeders_time\"),\n            }.items() if value is not None}\n\n    def subscribe_files_info(self, subscribe: Subscribe) -> Optional[schemas.SubscrbieInfo]:\n        \"\"\"\n        订阅相关的下载和文件信息\n        \"\"\"\n        if not subscribe:\n            return None\n\n        # 返回订阅数据\n        subscribe_info = schemas.SubscrbieInfo()\n\n        # 所有集的数据\n        episodes: Dict[int, schemas.SubscribeEpisodeInfo] = {}\n        if subscribe.tmdbid and subscribe.type == MediaType.TV.value:\n            # 查询TMDB中的集信息\n            tmdb_episodes = TmdbChain().tmdb_episodes(\n                tmdbid=subscribe.tmdbid,\n                season=subscribe.season,\n                episode_group=subscribe.episode_group\n            )\n            if tmdb_episodes:\n                for episode in tmdb_episodes:\n                    info = schemas.SubscribeEpisodeInfo()\n                    info.title = episode.name\n                    info.description = episode.overview\n                    info.backdrop = settings.TMDB_IMAGE_URL(episode.still_path, \"w500\")\n                    episodes[episode.episode_number] = info\n        elif subscribe.type == MediaType.TV.value:\n            # 根据开始结束集计算集信息\n            for i in range(subscribe.start_episode or 1, subscribe.total_episode + 1):\n                info = schemas.SubscribeEpisodeInfo()\n                info.title = f'第 {i} 集'\n                episodes[i] = info\n        else:\n            # 电影\n            info = schemas.SubscribeEpisodeInfo()\n            info.title = subscribe.name\n            episodes[0] = info\n\n        # 所有下载记录\n        downloadhis = DownloadHistoryOper()\n        download_his = downloadhis.get_by_mediaid(tmdbid=subscribe.tmdbid, doubanid=subscribe.doubanid)\n        if download_his:\n            for his in download_his:\n                # 查询下载文件\n                files = downloadhis.get_files_by_hash(his.download_hash, state=1)\n                if files:\n                    for file in files:\n                        # 识别文件名\n                        file_meta = MetaInfo(file.filepath)\n                        # 下载文件信息\n                        file_info = schemas.SubscribeDownloadFileInfo(\n                            torrent_title=his.torrent_name,\n                            site_name=his.torrent_site,\n                            downloader=file.downloader,\n                            hash=his.download_hash,\n                            file_path=file.fullpath,\n                        )\n                        if subscribe.type == MediaType.TV.value:\n                            season_number = file_meta.begin_season\n                            if season_number and season_number != subscribe.season:\n                                continue\n                            episode_number = file_meta.begin_episode\n                            if episode_number and episodes.get(episode_number):\n                                episodes[episode_number].download.append(file_info)\n                        else:\n                            episodes[0].download.append(file_info)\n\n        # 生成元数据\n        meta = MetaInfo(subscribe.name)\n        meta.year = subscribe.year\n        meta.begin_season = subscribe.season or None\n        try:\n            meta.type = MediaType(subscribe.type)\n        except ValueError:\n            logger.error(f'订阅 {subscribe.name} 类型错误：{subscribe.type}')\n            return subscribe_info\n        # 识别媒体信息\n        mediainfo: MediaInfo = self.recognize_media(meta=meta, mtype=meta.type,\n                                                    tmdbid=subscribe.tmdbid,\n                                                    doubanid=subscribe.doubanid,\n                                                    episode_group=subscribe.episode_group,\n                                                    cache=False)\n        if not mediainfo:\n            logger.warn(\n                f'未识别到媒体信息，标题：{subscribe.name}，tmdbid：{subscribe.tmdbid}，doubanid：{subscribe.doubanid}')\n            return subscribe_info\n\n        # 所有媒体库文件记录\n        library_fileitems = self.media_files(mediainfo)\n        if library_fileitems:\n            for fileitem in library_fileitems:\n                # 识别文件名\n                file_meta = MetaInfo(fileitem.path)\n                # 媒体库文件信息\n                file_info = schemas.SubscribeLibraryFileInfo(\n                    storage=fileitem.storage,\n                    file_path=fileitem.path,\n                )\n                if subscribe.type == MediaType.TV.value:\n                    season_number = file_meta.begin_season\n                    if season_number and season_number != subscribe.season:\n                        continue\n                    episode_number = file_meta.begin_episode\n                    if episode_number and episodes.get(episode_number):\n                        episodes[episode_number].library.append(file_info)\n                else:\n                    episodes[0].library.append(file_info)\n\n        # 更新订阅信息\n        subscribe_info.subscribe = Subscribe(**subscribe.to_dict())\n        subscribe_info.episodes = episodes\n        return subscribe_info\n\n    def check_and_handle_existing_media(self, subscribe: Subscribe, meta: MetaBase,\n                                        mediainfo: MediaInfo, mediakey: Union[str, int]):\n        \"\"\"\n        检查媒体是否已经存在，并根据情况执行相应的操作\n        1. 查询缺失的媒体信息\n        2. 判断是否已经下载完毕\n        3. 根据媒体类型（电视剧或电影）执行不同的处理\n\n        :param subscribe: 订阅信息对象\n        :param meta: 媒体元数据\n        :param mediainfo: 媒体信息\n        :param mediakey: 媒体标识符\n        :return:\n            - exist_flag (bool): 布尔值，表示媒体是否已经完全下载或已存在\n            - no_exists (dict): 缺失的媒体信息，包含缺失的集数或其他相关信息\n        \"\"\"\n        # 非洗版\n        if not subscribe.best_version:\n            # 每季总集数\n            totals = {}\n            if subscribe.season and subscribe.total_episode:\n                totals = {\n                    subscribe.season: subscribe.total_episode\n                }\n            # 查询媒体库缺失的媒体信息\n            exist_flag, no_exists = DownloadChain().get_no_exists_info(\n                meta=meta,\n                mediainfo=mediainfo,\n                totals=totals\n            )\n        else:\n            # 洗版，如果已经满足了优先级，则认为已经洗版完成\n            if subscribe.current_priority == 100:\n                exist_flag = True\n                no_exists = {}\n            else:\n                exist_flag = False\n                if meta.type == MediaType.TV:\n                    # 对于电视剧，构造缺失的媒体信息\n                    no_exists = {\n                        mediakey: {\n                            subscribe.season: schemas.NotExistMediaInfo(\n                                season=subscribe.season,\n                                episodes=[],\n                                total_episode=subscribe.total_episode,\n                                start_episode=subscribe.start_episode or 1)\n                        }\n                    }\n                else:\n                    no_exists = {}\n\n        # 如果媒体已存在，执行订阅完成操作\n        if exist_flag:\n            if not subscribe.best_version:\n                logger.info(f'{mediainfo.title_year} 媒体库中已存在')\n            self.finish_subscribe_or_not(subscribe=subscribe, meta=meta, mediainfo=mediainfo, force=True)\n            return True, no_exists\n\n        # 获取已下载的集数或电影\n        downloaded = self.__get_downloaded(subscribe)\n        if meta.type == MediaType.TV:\n            # 对于电视剧类型，整合缺失集数并剔除已下载的集数\n            exist_flag, no_exists = self.__get_subscribe_no_exits(\n                subscribe_name=f'{subscribe.name} {meta.season}',\n                no_exists=no_exists,\n                mediakey=mediakey,\n                begin_season=meta.begin_season,\n                total_episode=subscribe.total_episode,\n                start_episode=subscribe.start_episode,\n                downloaded_episodes=downloaded\n            )\n        elif meta.type == MediaType.MOVIE:\n            # 对于电影类型，直接根据是否已下载判断\n            exist_flag = bool(downloaded)\n\n        # 如果已下载完毕，执行订阅完成操作\n        if exist_flag:\n            logger.info(f'{mediainfo.title_year} 已全部下载')\n            self.finish_subscribe_or_not(subscribe=subscribe, meta=meta, mediainfo=mediainfo, force=True)\n            return True, no_exists\n\n        # 返回结果，表示媒体未完全下载或存在\n        return False, no_exists\n\n    @staticmethod\n    def get_states_for_search(state: str) -> str:\n        \"\"\"\n        根据给定的状态返回实际需要搜索的状态列表，支持多个状态用逗号分隔\n        :param state: 订阅状态\n            N: New（新建，未处理）\n            R: Resolved（订阅中）\n            P: Pending（待定，信息待进一步更新，允许搜索，不允许完成）\n            S: Suspended（暂停，订阅不参与任何动作，暂时停止处理）\n        :return: 需要查询的状态列表（多个状态用逗号分隔）\n        \"\"\"\n        # 如果状态是 R 或 P，则视为一起搜索，返回 R,P 作为查询条件\n        if state in [\"R\", \"P\"]:\n            return \"R,P\"\n        return state\n\n    @staticmethod\n    def get_subscribe_source_keyword(subscribe: Subscribe) -> str:\n        \"\"\"\n        构造用于订阅来源的关键字字符串\n\n        :param subscribe: Subscribe 对象\n        :return str: 格式化的订阅来源关键字字符串，格式为 \"Subscribe|{...}\"\n        \"\"\"\n        source_keyword = {\n            'id': subscribe.id,\n            'name': subscribe.name,\n            'year': subscribe.year,\n            'type': subscribe.type,\n            'season': subscribe.season,\n            'tmdbid': subscribe.tmdbid,\n            'imdbid': subscribe.imdbid,\n            'tvdbid': subscribe.tvdbid,\n            'doubanid': subscribe.doubanid,\n            'bangumiid': subscribe.bangumiid\n        }\n        return f\"Subscribe|{json.dumps(source_keyword, ensure_ascii=False)}\"\n\n    @staticmethod\n    def parse_subscribe_source_keyword(source_keyword_str: str) -> Optional[dict]:\n        \"\"\"\n        解析订阅来源关键字字符串\n\n        :param source_keyword_str: 订阅来源关键字字符串，格式为 \"Subscribe|{...}\"\n        :return Dict: 如果解析失败则返回None\n        \"\"\"\n        if not source_keyword_str or not source_keyword_str.startswith(\"Subscribe|\"):\n            return None\n\n        try:\n            # 分割字符串获取JSON部分\n            json_part = source_keyword_str.split(\"|\", 1)[1]\n            # 解析JSON字符串\n            source_keyword = json.loads(json_part)\n            return source_keyword\n        except (IndexError, json.JSONDecodeError, TypeError) as e:\n            logger.error(f\"解析订阅来源关键字失败: {e}\")\n            return None\n"
  },
  {
    "path": "app/chain/system.py",
    "content": "import json\nimport re\nimport shutil\nfrom pathlib import Path\nfrom typing import Union, Optional\n\nfrom app.chain import ChainBase\nfrom app.core.config import settings\nfrom app.core.plugin import PluginManager\nfrom app.helper.system import SystemHelper\nfrom app.log import logger\nfrom app.schemas import Notification, MessageChannel\nfrom app.utils.http import RequestUtils\nfrom app.utils.system import SystemUtils\nfrom version import FRONTEND_VERSION, APP_VERSION\n\n\nclass SystemChain(ChainBase):\n    \"\"\"\n    系统级处理链\n    \"\"\"\n\n    _restart_file = \"__system_restart__\"\n\n    def remote_clear_cache(self, channel: MessageChannel, userid: Union[int, str], source: Optional[str] = None):\n        \"\"\"\n        清理系统缓存\n        \"\"\"\n        self.clear_cache()\n        self.post_message(Notification(channel=channel, source=source,\n                                       title=f\"缓存清理完成！\", userid=userid))\n\n    def restart(self, channel: MessageChannel, userid: Union[int, str], source: Optional[str] = None):\n        \"\"\"\n        重启系统\n        \"\"\"\n        from app.core.config import global_vars\n\n        if channel and userid:\n            self.post_message(Notification(channel=channel, source=source,\n                                           title=\"系统正在重启，请耐心等候！\", userid=userid))\n            # 保存重启信息\n            self.save_cache({\n                \"channel\": channel.value,\n                \"userid\": userid\n            }, self._restart_file)\n        # 主动备份一次插件\n        self.backup_plugins()\n        # 设置停止标志，通知所有模块准备停止\n        global_vars.stop_system()\n        # 重启\n        SystemHelper.restart()\n\n    @staticmethod\n    def backup_plugins():\n        \"\"\"\n        备份插件到用户配置目录（仅docker环境）\n        \"\"\"\n\n        # 非docker环境不处理\n        if not SystemUtils.is_docker():\n            return\n\n        try:\n            # 使用绝对路径确保准确性\n            plugins_dir = settings.ROOT_PATH / \"app\" / \"plugins\"\n            backup_dir = settings.CONFIG_PATH / \"plugins_backup\"\n\n            if not plugins_dir.exists():\n                logger.info(\"插件目录不存在，跳过备份\")\n                return\n\n            # 确保备份目录存在\n            backup_dir.mkdir(parents=True, exist_ok=True)\n\n            # 需要排除的文件和目录\n            exclude_items = {\"__init__.py\", \"__pycache__\", \".DS_Store\"}\n\n            # 遍历插件目录，备份除排除项外的所有内容\n            for item in plugins_dir.iterdir():\n                if item.name in exclude_items:\n                    continue\n\n                target_path = backup_dir / item.name\n\n                # 如果是目录\n                if item.is_dir():\n                    if target_path.exists():\n                        continue\n                    shutil.copytree(item, target_path)\n                    logger.info(f\"已备份插件目录: {item.name}\")\n                # 如果是文件\n                elif item.is_file():\n                    if target_path.exists():\n                        continue\n                    shutil.copy2(item, target_path)\n                    logger.info(f\"已备份插件文件: {item.name}\")\n\n            logger.info(f\"插件备份完成，备份位置: {backup_dir}\")\n\n        except Exception as e:\n            logger.error(f\"插件备份失败: {str(e)}\")\n\n    @staticmethod\n    def restore_plugins():\n        \"\"\"\n        从备份恢复插件到app/plugins目录，恢复完成后删除备份（仅docker环境）\n        \"\"\"\n\n        # 非docker环境不处理\n        if not SystemUtils.is_docker():\n            return\n\n        # 使用绝对路径确保准确性\n        plugins_dir = settings.ROOT_PATH / \"app\" / \"plugins\"\n        backup_dir = settings.CONFIG_PATH / \"plugins_backup\"\n\n        if not backup_dir.exists():\n            logger.info(\"插件备份目录不存在，跳过恢复\")\n            return\n\n        # 系统被重置才恢复插件\n        if SystemHelper().is_system_reset():\n\n            # 确保插件目录存在\n            plugins_dir.mkdir(parents=True, exist_ok=True)\n\n            # 遍历备份目录，恢复所有内容\n            restored_count = 0\n            for item in backup_dir.iterdir():\n                target_path = plugins_dir / item.name\n                try:\n                    # 如果是目录，且目录内有内容\n                    if item.is_dir() and any(item.iterdir()):\n                        if target_path.exists():\n                            shutil.rmtree(target_path)\n                        shutil.copytree(item, target_path)\n                        logger.info(f\"已恢复插件目录: {item.name}\")\n                        restored_count += 1\n                    # 如果是文件\n                    elif item.is_file():\n                        shutil.copy2(item, target_path)\n                        logger.info(f\"已恢复插件文件: {item.name}\")\n                        restored_count += 1\n                except Exception as e:\n                    logger.error(f\"恢复插件 {item.name} 时发生错误: {str(e)}\")\n                    continue\n\n            logger.info(f\"插件恢复完成，共恢复 {restored_count} 个项目\")\n\n            # 安装缺少的依赖\n            PluginManager.install_plugin_missing_dependencies()\n\n        # 删除备份目录\n        try:\n            shutil.rmtree(backup_dir)\n            logger.info(f\"已删除插件备份目录: {backup_dir}\")\n        except Exception as e:\n            logger.warning(f\"删除备份目录失败: {str(e)}\")\n\n    def __get_version_message(self) -> str:\n        \"\"\"\n        获取版本信息文本\n        \"\"\"\n        server_release_version = self.__get_server_release_version()\n        front_release_version = self.__get_front_release_version()\n        server_local_version = self.get_server_local_version()\n        front_local_version = self.get_frontend_version()\n        if server_release_version == server_local_version:\n            title = f\"当前后端版本：{server_local_version}，已是最新版本\\n\"\n        else:\n            title = f\"当前后端版本：{server_local_version}，远程版本：{server_release_version}\\n\"\n        if front_release_version == front_local_version:\n            title += f\"当前前端版本：{front_local_version}，已是最新版本\"\n        else:\n            title += f\"当前前端版本：{front_local_version}，远程版本：{front_release_version}\"\n        return title\n\n    def version(self, channel: MessageChannel, userid: Union[int, str], source: Optional[str] = None):\n        \"\"\"\n        查看当前版本、远程版本\n        \"\"\"\n        self.post_message(Notification(channel=channel, source=source,\n                                       title=self.__get_version_message(),\n                                       userid=userid))\n\n    def restart_finish(self):\n        \"\"\"\n        如通过交互命令重启，\n        重启完发送msg\n        \"\"\"\n        # 重启消息\n        restart_channel = self.load_cache(self._restart_file)\n        if restart_channel:\n            # 发送重启完成msg\n            if not isinstance(restart_channel, dict):\n                restart_channel = json.loads(restart_channel)\n            channel = next(\n                (channel for channel in MessageChannel.__members__.values() if\n                 channel.value == restart_channel.get('channel')), None)\n            userid = restart_channel.get('userid')\n\n            # 版本号\n            title = self.__get_version_message()\n            self.post_message(Notification(channel=channel,\n                                           title=f\"系统已重启完成！\\n{title}\",\n                                           userid=userid))\n            self.remove_cache(self._restart_file)\n\n    @staticmethod\n    def __get_server_release_version():\n        \"\"\"\n        获取后端V2最新版本\n        \"\"\"\n        try:\n            # 获取所有发布的版本列表\n            response = RequestUtils(\n                proxies=settings.PROXY,\n                headers=settings.GITHUB_HEADERS\n            ).get_res(\"https://api.github.com/repos/jxxghp/MoviePilot/releases\")\n            if response:\n                releases = [release['tag_name'] for release in response.json()]\n                v2_releases = [tag for tag in releases if re.match(r\"^v2\\.\", tag)]\n                if not v2_releases:\n                    logger.warn(\"获取v2后端最新版本版本出错！\")\n                else:\n                    # 找到最新的v2版本\n                    latest_v2 = sorted(v2_releases, key=lambda s: list(map(int, re.findall(r'\\d+', s))))[-1]\n                    logger.info(f\"获取到后端最新版本：{latest_v2}\")\n                    return latest_v2\n            else:\n                logger.error(\"无法获取后端版本信息，请检查网络连接或GitHub API请求。\")\n        except Exception as err:\n            logger.error(f\"获取后端最新版本失败：{str(err)}\")\n        return None\n\n    @staticmethod\n    def __get_front_release_version():\n        \"\"\"\n        获取前端V2最新版本\n        \"\"\"\n        try:\n            # 获取所有发布的版本列表\n            response = RequestUtils(\n                proxies=settings.PROXY,\n                headers=settings.GITHUB_HEADERS\n            ).get_res(\"https://api.github.com/repos/jxxghp/MoviePilot-Frontend/releases\")\n            if response:\n                releases = [release['tag_name'] for release in response.json()]\n                v2_releases = [tag for tag in releases if re.match(r\"^v2\\.\", tag)]\n                if not v2_releases:\n                    logger.warn(\"获取v2前端最新版本版本出错！\")\n                else:\n                    # 找到最新的v2版本\n                    latest_v2 = sorted(v2_releases, key=lambda s: list(map(int, re.findall(r'\\d+', s))))[-1]\n                    logger.info(f\"获取到前端最新版本：{latest_v2}\")\n                    return latest_v2\n            else:\n                logger.error(\"无法获取前端版本信息，请检查网络连接或GitHub API请求。\")\n        except Exception as err:\n            logger.error(f\"获取前端最新版本失败：{str(err)}\")\n        return None\n\n    @staticmethod\n    def get_server_local_version():\n        \"\"\"\n        查看当前版本\n        \"\"\"\n        return APP_VERSION\n\n    @staticmethod\n    def get_frontend_version():\n        \"\"\"\n        获取前端版本\n        \"\"\"\n        if SystemUtils.is_frozen() and SystemUtils.is_windows():\n            version_file = settings.CONFIG_PATH.parent / \"nginx\" / \"html\" / \"version.txt\"\n        else:\n            version_file = Path(settings.FRONTEND_PATH) / \"version.txt\"\n        if version_file.exists():\n            try:\n                with open(version_file, 'r') as f:\n                    version = str(f.read()).strip()\n                return version\n            except Exception as err:\n                logger.debug(f\"加载版本文件 {version_file} 出错：{str(err)}\")\n        return FRONTEND_VERSION\n"
  },
  {
    "path": "app/chain/tmdb.py",
    "content": "import random\nfrom typing import Optional, List\n\nfrom app import schemas\nfrom app.chain import ChainBase\nfrom app.core.context import MediaInfo\nfrom app.schemas import MediaType\n\n\nclass TmdbChain(ChainBase):\n    \"\"\"\n    TheMovieDB处理链，单例运行\n    \"\"\"\n\n    def tmdb_discover(self, mtype: MediaType,\n                      sort_by: str,\n                      with_genres: str,\n                      with_original_language: str,\n                      with_keywords: str,\n                      with_watch_providers: str,\n                      vote_average: float,\n                      vote_count: int,\n                      release_date: str,\n                      page: Optional[int] = 1) -> Optional[List[MediaInfo]]:\n        \"\"\"\n        :param mtype:  媒体类型\n        :param sort_by:  排序方式\n        :param with_genres:  类型\n        :param with_original_language:  语言\n        :param with_keywords:  关键字\n        :param with_watch_providers:  提供商\n        :param vote_average:  评分\n        :param vote_count:  评分人数\n        :param release_date:  上映日期\n        :param page:  页码\n        :return: 媒体信息列表\n        \"\"\"\n        return self.run_module(\"tmdb_discover\", mtype=mtype,\n                               sort_by=sort_by,\n                               with_genres=with_genres,\n                               with_original_language=with_original_language,\n                               with_keywords=with_keywords,\n                               with_watch_providers=with_watch_providers,\n                               vote_average=vote_average,\n                               vote_count=vote_count,\n                               release_date=release_date,\n                               page=page)\n\n    def tmdb_trending(self, page: Optional[int] = 1) -> Optional[List[MediaInfo]]:\n        \"\"\"\n        TMDB流行趋势\n        :param page: 第几页\n        :return: TMDB信息列表\n        \"\"\"\n        return self.run_module(\"tmdb_trending\", page=page)\n\n    def tmdb_collection(self, collection_id: int) -> Optional[List[MediaInfo]]:\n        \"\"\"\n        根据合集ID查询集合\n        :param collection_id:  合集ID\n        \"\"\"\n        return self.run_module(\"tmdb_collection\", collection_id=collection_id)\n\n    def tmdb_seasons(self, tmdbid: int) -> List[schemas.TmdbSeason]:\n        \"\"\"\n        根据TMDBID查询themoviedb所有季信息\n        :param tmdbid:  TMDBID\n        \"\"\"\n        return self.run_module(\"tmdb_seasons\", tmdbid=tmdbid)\n\n    def tmdb_group_seasons(self, group_id: str) -> List[schemas.TmdbSeason]:\n        \"\"\"\n        根据剧集组ID查询themoviedb所有季集信息\n        :param group_id: 剧集组ID\n        \"\"\"\n        return self.run_module(\"tmdb_group_seasons\", group_id=group_id)\n\n    def tmdb_episodes(self, tmdbid: int, season: int, episode_group: Optional[str] = None) -> List[schemas.TmdbEpisode]:\n        \"\"\"\n        根据TMDBID查询某季的所有信信息\n        :param tmdbid:  TMDBID\n        :param season:  季\n        :param episode_group:  剧集组\n        \"\"\"\n        return self.run_module(\"tmdb_episodes\", tmdbid=tmdbid, season=season, episode_group=episode_group)\n\n    def movie_similar(self, tmdbid: int) -> Optional[List[MediaInfo]]:\n        \"\"\"\n        根据TMDBID查询类似电影\n        :param tmdbid:  TMDBID\n        \"\"\"\n        return self.run_module(\"tmdb_movie_similar\", tmdbid=tmdbid)\n\n    def tv_similar(self, tmdbid: int) -> Optional[List[MediaInfo]]:\n        \"\"\"\n        根据TMDBID查询类似电视剧\n        :param tmdbid:  TMDBID\n        \"\"\"\n        return self.run_module(\"tmdb_tv_similar\", tmdbid=tmdbid)\n\n    def movie_recommend(self, tmdbid: int) -> Optional[List[MediaInfo]]:\n        \"\"\"\n        根据TMDBID查询推荐电影\n        :param tmdbid:  TMDBID\n        \"\"\"\n        return self.run_module(\"tmdb_movie_recommend\", tmdbid=tmdbid)\n\n    def tv_recommend(self, tmdbid: int) -> Optional[List[MediaInfo]]:\n        \"\"\"\n        根据TMDBID查询推荐电视剧\n        :param tmdbid:  TMDBID\n        \"\"\"\n        return self.run_module(\"tmdb_tv_recommend\", tmdbid=tmdbid)\n\n    def movie_credits(self, tmdbid: int, page: Optional[int] = 1) -> Optional[List[schemas.MediaPerson]]:\n        \"\"\"\n        根据TMDBID查询电影演职人员\n        :param tmdbid:  TMDBID\n        :param page:  页码\n        \"\"\"\n        return self.run_module(\"tmdb_movie_credits\", tmdbid=tmdbid, page=page)\n\n    def tv_credits(self, tmdbid: int, page: Optional[int] = 1) -> Optional[List[schemas.MediaPerson]]:\n        \"\"\"\n        根据TMDBID查询电视剧演职人员\n        :param tmdbid:  TMDBID\n        :param page:  页码\n        \"\"\"\n        return self.run_module(\"tmdb_tv_credits\", tmdbid=tmdbid, page=page)\n\n    def person_detail(self, person_id: int) -> Optional[schemas.MediaPerson]:\n        \"\"\"\n        根据TMDBID查询演职员详情\n        :param person_id:  人物ID\n        \"\"\"\n        return self.run_module(\"tmdb_person_detail\", person_id=person_id)\n\n    def person_credits(self, person_id: int, page: Optional[int] = 1) -> Optional[List[MediaInfo]]:\n        \"\"\"\n        根据人物ID查询人物参演作品\n        :param person_id:  人物ID\n        :param page:  页码\n        \"\"\"\n        return self.run_module(\"tmdb_person_credits\", person_id=person_id, page=page)\n\n    def get_random_wallpager(self) -> Optional[str]:\n        \"\"\"\n        获取随机壁纸，缓存1个小时\n        \"\"\"\n        infos = self.tmdb_trending()\n        if infos:\n            # 随机一个电影\n            while True:\n                info = random.choice(infos)\n                if info and info.backdrop_path:\n                    return info.backdrop_path\n        return None\n\n    def get_trending_wallpapers(self, num: Optional[int] = 10) -> List[str]:\n        \"\"\"\n        获取所有流行壁纸\n        \"\"\"\n        infos = self.tmdb_trending()\n        if infos:\n            return [info.backdrop_path for info in infos if info and info.backdrop_path][:num]\n        return []\n\n    async def async_tmdb_discover(self, mtype: MediaType,\n                                  sort_by: str,\n                                  with_genres: str,\n                                  with_original_language: str,\n                                  with_keywords: str,\n                                  with_watch_providers: str,\n                                  vote_average: float,\n                                  vote_count: int,\n                                  release_date: str,\n                                  page: Optional[int] = 1) -> Optional[List[MediaInfo]]:\n        \"\"\"\n        发现TMDB电影、剧集（异步版本）\n        :param mtype:  媒体类型\n        :param sort_by:  排序方式\n        :param with_genres:  类型\n        :param with_original_language:  语言\n        :param with_keywords:  关键字\n        :param with_watch_providers:  提供商\n        :param vote_average:  评分\n        :param vote_count:  评分人数\n        :param release_date:  上映日期\n        :param page:  页码\n        :return: 媒体信息列表\n        \"\"\"\n        return await self.async_run_module(\"async_tmdb_discover\", mtype=mtype,\n                                           sort_by=sort_by,\n                                           with_genres=with_genres,\n                                           with_original_language=with_original_language,\n                                           with_keywords=with_keywords,\n                                           with_watch_providers=with_watch_providers,\n                                           vote_average=vote_average,\n                                           vote_count=vote_count,\n                                           release_date=release_date,\n                                           page=page)\n\n    async def async_tmdb_trending(self, page: Optional[int] = 1) -> Optional[List[MediaInfo]]:\n        \"\"\"\n        TMDB流行趋势（异步版本）\n        :param page: 第几页\n        :return: TMDB信息列表\n        \"\"\"\n        return await self.async_run_module(\"async_tmdb_trending\", page=page)\n\n    async def async_tmdb_collection(self, collection_id: int) -> Optional[List[MediaInfo]]:\n        \"\"\"\n        根据合集ID查询集合（异步版本）\n        :param collection_id:  合集ID\n        \"\"\"\n        return await self.async_run_module(\"async_tmdb_collection\", collection_id=collection_id)\n\n    async def async_tmdb_seasons(self, tmdbid: int) -> List[schemas.TmdbSeason]:\n        \"\"\"\n        根据TMDBID查询themoviedb所有季信息（异步版本）\n        :param tmdbid:  TMDBID\n        \"\"\"\n        return await self.async_run_module(\"async_tmdb_seasons\", tmdbid=tmdbid)\n\n    async def async_tmdb_group_seasons(self, group_id: str) -> List[schemas.TmdbSeason]:\n        \"\"\"\n        根据剧集组ID查询themoviedb所有季集信息（异步版本）\n        :param group_id: 剧集组ID\n        \"\"\"\n        return await self.async_run_module(\"async_tmdb_group_seasons\", group_id=group_id)\n\n    async def async_tmdb_episodes(self, tmdbid: int, season: int,\n                                  episode_group: Optional[str] = None) -> List[schemas.TmdbEpisode]:\n        \"\"\"\n        根据TMDBID查询某季的所有信信息（异步版本）\n        :param tmdbid:  TMDBID\n        :param season:  季\n        :param episode_group:  剧集组\n        \"\"\"\n        return await self.async_run_module(\"async_tmdb_episodes\", tmdbid=tmdbid, season=season,\n                                           episode_group=episode_group)\n\n    async def async_movie_similar(self, tmdbid: int) -> Optional[List[MediaInfo]]:\n        \"\"\"\n        根据TMDBID查询类似电影（异步版本）\n        :param tmdbid:  TMDBID\n        \"\"\"\n        return await self.async_run_module(\"async_tmdb_movie_similar\", tmdbid=tmdbid)\n\n    async def async_tv_similar(self, tmdbid: int) -> Optional[List[MediaInfo]]:\n        \"\"\"\n        根据TMDBID查询类似电视剧（异步版本）\n        :param tmdbid:  TMDBID\n        \"\"\"\n        return await self.async_run_module(\"async_tmdb_tv_similar\", tmdbid=tmdbid)\n\n    async def async_movie_recommend(self, tmdbid: int) -> Optional[List[MediaInfo]]:\n        \"\"\"\n        根据TMDBID查询推荐电影（异步版本）\n        :param tmdbid:  TMDBID\n        \"\"\"\n        return await self.async_run_module(\"async_tmdb_movie_recommend\", tmdbid=tmdbid)\n\n    async def async_tv_recommend(self, tmdbid: int) -> Optional[List[MediaInfo]]:\n        \"\"\"\n        根据TMDBID查询推荐电视剧（异步版本）\n        :param tmdbid:  TMDBID\n        \"\"\"\n        return await self.async_run_module(\"async_tmdb_tv_recommend\", tmdbid=tmdbid)\n\n    async def async_movie_credits(self, tmdbid: int, page: Optional[int] = 1) -> Optional[List[schemas.MediaPerson]]:\n        \"\"\"\n        根据TMDBID查询电影演职人员（异步版本）\n        :param tmdbid:  TMDBID\n        :param page:  页码\n        \"\"\"\n        return await self.async_run_module(\"async_tmdb_movie_credits\", tmdbid=tmdbid, page=page)\n\n    async def async_tv_credits(self, tmdbid: int, page: Optional[int] = 1) -> Optional[List[schemas.MediaPerson]]:\n        \"\"\"\n        根据TMDBID查询电视剧演职人员（异步版本）\n        :param tmdbid:  TMDBID\n        :param page:  页码\n        \"\"\"\n        return await self.async_run_module(\"async_tmdb_tv_credits\", tmdbid=tmdbid, page=page)\n\n    async def async_person_detail(self, person_id: int) -> Optional[schemas.MediaPerson]:\n        \"\"\"\n        根据TMDBID查询演职员详情（异步版本）\n        :param person_id:  人物ID\n        \"\"\"\n        return await self.async_run_module(\"async_tmdb_person_detail\", person_id=person_id)\n\n    async def async_person_credits(self, person_id: int, page: Optional[int] = 1) -> Optional[List[MediaInfo]]:\n        \"\"\"\n        根据人物ID查询人物参演作品（异步版本）\n        :param person_id:  人物ID\n        :param page:  页码\n        \"\"\"\n        return await self.async_run_module(\"async_tmdb_person_credits\", person_id=person_id, page=page)\n\n    async def async_get_random_wallpager(self) -> Optional[str]:\n        \"\"\"\n        获取随机壁纸（异步版本），缓存1个小时\n        \"\"\"\n        infos = await self.async_tmdb_trending()\n        if infos:\n            # 随机一个电影\n            while True:\n                info = random.choice(infos)\n                if info and info.backdrop_path:\n                    return info.backdrop_path\n        return None\n\n    async def async_get_trending_wallpapers(self, num: Optional[int] = 10) -> List[str]:\n        \"\"\"\n        获取所有流行壁纸（异步版本）\n        \"\"\"\n        infos = await self.async_tmdb_trending()\n        if infos:\n            return [info.backdrop_path for info in infos if info and info.backdrop_path][:num]\n        return []\n"
  },
  {
    "path": "app/chain/torrents.py",
    "content": "import re\nimport traceback\nfrom typing import Dict, List, Union, Optional\n\nfrom app.chain import ChainBase\nfrom app.chain.media import MediaChain\nfrom app.core.config import settings, global_vars\nfrom app.core.context import TorrentInfo, Context, MediaInfo\nfrom app.core.metainfo import MetaInfo\nfrom app.db.site_oper import SiteOper\nfrom app.db.systemconfig_oper import SystemConfigOper\nfrom app.helper.rss import RssHelper\nfrom app.helper.sites import SitesHelper  # noqa\nfrom app.helper.torrent import TorrentHelper\nfrom app.log import logger\nfrom app.schemas import Notification\nfrom app.schemas.types import SystemConfigKey, MessageChannel, NotificationType, MediaType\nfrom app.utils.string import StringUtils\n\n\nclass TorrentsChain(ChainBase):\n    \"\"\"\n    站点首页或RSS种子处理链，服务于订阅、刷流等\n    \"\"\"\n\n    _spider_file = \"__torrents_cache__\"\n    _rss_file = \"__rss_cache__\"\n\n    @property\n    def cache_file(self) -> str:\n        \"\"\"\n        返回缓存文件列表\n        \"\"\"\n        if settings.SUBSCRIBE_MODE == 'spider':\n            return self._spider_file\n        return self._rss_file\n\n    def remote_refresh(self, channel: MessageChannel, userid: Union[str, int] = None):\n        \"\"\"\n        远程刷新订阅，发送消息\n        \"\"\"\n        self.post_message(Notification(channel=channel,\n                                       title=f\"开始刷新种子 ...\", userid=userid))\n        self.refresh()\n        self.post_message(Notification(channel=channel,\n                                       title=f\"种子刷新完成！\", userid=userid))\n\n    def get_torrents(self, stype: Optional[str] = None) -> Dict[str, List[Context]]:\n        \"\"\"\n        获取当前缓存的种子\n        :param stype: 强制指定缓存类型，spider:爬虫缓存，rss:rss缓存\n        \"\"\"\n\n        if not stype:\n            stype = settings.SUBSCRIBE_MODE\n\n        # 读取缓存\n        if stype == 'spider':\n            torrents_cache = self.load_cache(self._spider_file) or {}\n        else:\n            torrents_cache = self.load_cache(self._rss_file) or {}\n\n        # 兼容性处理：为旧版本的Context对象添加失败次数字段\n        self._ensure_context_compatibility(torrents_cache)\n\n        return torrents_cache\n\n    async def async_get_torrents(self, stype: Optional[str] = None) -> Dict[str, List[Context]]:\n        \"\"\"\n        异步获取当前缓存的种子\n        :param stype: 强制指定缓存类型，spider:爬虫缓存，rss:rss缓存\n        \"\"\"\n\n        if not stype:\n            stype = settings.SUBSCRIBE_MODE\n\n        # 异步读取缓存\n        if stype == 'spider':\n            torrents_cache = await self.async_load_cache(self._spider_file) or {}\n        else:\n            torrents_cache = await self.async_load_cache(self._rss_file) or {}\n\n        # 兼容性处理：为旧版本的Context对象添加失败次数字段\n        self._ensure_context_compatibility(torrents_cache)\n\n        return torrents_cache\n\n    def clear_torrents(self):\n        \"\"\"\n        清理种子缓存数据\n        \"\"\"\n        logger.info(f'开始清理种子缓存数据 ...')\n        self.remove_cache(self._spider_file)\n        self.remove_cache(self._rss_file)\n        logger.info(f'种子缓存数据清理完成')\n\n    async def async_clear_torrents(self):\n        \"\"\"\n        异步清理种子缓存数据\n        \"\"\"\n        logger.info(f'开始异步清理种子缓存数据 ...')\n        await self.async_remove_cache(self._spider_file)\n        await self.async_remove_cache(self._rss_file)\n        logger.info(f'异步种子缓存数据清理完成')\n\n    def browse(self, domain: str, keyword: Optional[str] = None, cat: Optional[str] = None,\n               page: Optional[int] = 0) -> List[TorrentInfo]:\n        \"\"\"\n        浏览站点首页内容，返回种子清单，TTL缓存5分钟\n        :param domain: 站点域名\n        :param keyword: 搜索标题\n        :param cat: 搜索分类\n        :param page: 页码\n        \"\"\"\n        logger.info(f'开始获取站点 {domain} 最新种子 ...')\n        site = SitesHelper().get_indexer(domain)\n        if not site:\n            logger.error(f'站点 {domain} 不存在！')\n            return []\n        return self.refresh_torrents(site=site, keyword=keyword, cat=cat, page=page)\n\n    async def async_browse(self, domain: str, keyword: Optional[str] = None, cat: Optional[str] = None,\n                           page: Optional[int] = 0) -> List[TorrentInfo]:\n        \"\"\"\n        异步浏览站点首页内容，返回种子清单，TTL缓存5分钟\n        :param domain: 站点域名\n        :param keyword: 搜索标题\n        :param cat: 搜索分类\n        :param page: 页码\n        \"\"\"\n        logger.info(f'开始获取站点 {domain} 最新种子 ...')\n        site = await SitesHelper().async_get_indexer(domain)\n        if not site:\n            logger.error(f'站点 {domain} 不存在！')\n            return []\n        return await self.async_refresh_torrents(site=site, keyword=keyword, cat=cat, page=page)\n\n    def rss(self, domain: str) -> List[TorrentInfo]:\n        \"\"\"\n        获取站点RSS内容，返回种子清单，TTL缓存3分钟\n        :param domain: 站点域名\n        \"\"\"\n        logger.info(f'开始获取站点 {domain} RSS ...')\n        site = SitesHelper().get_indexer(domain)\n        if not site:\n            logger.error(f'站点 {domain} 不存在！')\n            return []\n        if not site.get(\"rss\"):\n            logger.error(f'站点 {domain} 未配置RSS地址！')\n            return []\n        # 解析RSS\n        rss_items = RssHelper().parse(site.get(\"rss\"), True if site.get(\"proxy\") else False,\n                                      timeout=int(site.get(\"timeout\") or 30), ua=site.get(\"ua\") if site.get(\"ua\") else None)\n        if rss_items is None:\n            # rss过期，尝试保留原配置生成新的rss\n            self.__renew_rss_url(domain=domain, site=site)\n            return []\n        if not rss_items:\n            logger.error(f'站点 {domain} 未获取到RSS数据！')\n            return []\n        # 组装种子\n        ret_torrents: List[TorrentInfo] = []\n        try:\n            for item in rss_items:\n                if not item.get(\"title\"):\n                    continue\n                torrentinfo = TorrentInfo(\n                    site=site.get(\"id\"),\n                    site_name=site.get(\"name\"),\n                    site_cookie=site.get(\"cookie\"),\n                    site_ua=site.get(\"ua\") or settings.USER_AGENT,\n                    site_proxy=site.get(\"proxy\"),\n                    site_order=site.get(\"pri\"),\n                    site_downloader=site.get(\"downloader\"),\n                    title=item.get(\"title\"),\n                    enclosure=item.get(\"enclosure\"),\n                    page_url=item.get(\"link\"),\n                    size=item.get(\"size\"),\n                    pubdate=item[\"pubdate\"].strftime(\"%Y-%m-%d %H:%M:%S\") if item.get(\"pubdate\") else None,\n                )\n                ret_torrents.append(torrentinfo)\n        finally:\n            rss_items.clear()\n            del rss_items\n        return ret_torrents\n\n    def refresh(self, stype: Optional[str] = None, sites: List[int] = None) -> Dict[str, List[Context]]:\n        \"\"\"\n        刷新站点最新资源，识别并缓存起来\n        :param stype: 强制指定缓存类型，spider:爬虫缓存，rss:rss缓存\n        :param sites: 强制指定站点ID列表，为空则读取设置的订阅站点\n        \"\"\"\n\n        def __is_no_cache_site(_domain: str) -> bool:\n            \"\"\"\n            判断站点是否不需要缓存\n            \"\"\"\n            for url_key in settings.NO_CACHE_SITE_KEY.split(','):\n                if url_key in _domain:\n                    return True\n            return False\n\n        # 刷新类型\n        if not stype:\n            stype = settings.SUBSCRIBE_MODE\n\n        # 刷新站点\n        if not sites:\n            sites = SystemConfigOper().get(SystemConfigKey.RssSites) or []\n\n        # 读取缓存\n        torrents_cache = self.get_torrents()\n\n        # 缓存过滤掉无效种子\n        for _domain, _torrents in torrents_cache.items():\n            torrents_cache[_domain] = [_torrent for _torrent in _torrents\n                                       if not TorrentHelper().is_invalid(_torrent.torrent_info.enclosure)]\n\n        # 需要刷新的站点domain\n        domains = []\n        # 遍历站点缓存资源\n        for indexer in SitesHelper().get_indexers():\n            if global_vars.is_system_stopped:\n                break\n            # 未开启的站点不刷新\n            if sites and indexer.get(\"id\") not in sites:\n                continue\n            domain = StringUtils.get_url_domain(indexer.get(\"domain\"))\n            domains.append(domain)\n            if stype == \"spider\":\n                # 刷新首页种子\n                torrents: List[TorrentInfo] = []\n                # 读取第0页和第1页\n                for page in range(2):\n                    page_torrents = self.browse(domain=domain, page=page)\n                    if page_torrents:\n                        torrents.extend(page_torrents)\n                    else:\n                        # 如果某一页没有数据，说明已经到最后一页，停止获取\n                        break\n            else:\n                # 刷新RSS种子\n                torrents: List[TorrentInfo] = self.rss(domain=domain)\n            # 按pubdate降序排列\n            torrents.sort(key=lambda x: x.pubdate or '', reverse=True)\n            # 取前N条\n            torrents = torrents[:settings.CONF.refresh]\n            if torrents:\n                if __is_no_cache_site(domain):\n                    # 不需要缓存的站点，直接处理\n                    logger.info(f'{indexer.get(\"name\")} 有 {len(torrents)} 个种子 (不缓存)')\n                    torrents_cache[domain] = []\n                else:\n                    # 过滤出没有处理过的种子 - 优化：使用集合查找，避免重复创建字符串列表\n                    cached_signatures = {f'{t.torrent_info.title}{t.torrent_info.description}'\n                                         for t in torrents_cache.get(domain) or []}\n                    torrents = [torrent for torrent in torrents\n                                if f'{torrent.title}{torrent.description}' not in cached_signatures]\n                if torrents:\n                    logger.info(f'{indexer.get(\"name\")} 有 {len(torrents)} 个新种子')\n                else:\n                    logger.info(f'{indexer.get(\"name\")} 没有新种子')\n                    continue\n                try:\n                    for torrent in torrents:\n                        if global_vars.is_system_stopped:\n                            break\n                        if not torrent.enclosure:\n                            logger.warn(f\"缺少种子链接，忽略处理: {torrent.title}\")\n                            continue\n                        logger.info(f'处理资源：{torrent.title} ...')\n                        # 识别\n                        meta = MetaInfo(title=torrent.title, subtitle=torrent.description)\n                        if torrent.title != meta.org_string:\n                            logger.info(f'种子名称应用识别词后发生改变：{torrent.title} => {meta.org_string}')\n                        # 使用站点种子分类，校正类型识别\n                        if meta.type != MediaType.TV \\\n                                and torrent.category == MediaType.TV.value:\n                            meta.type = MediaType.TV\n                        # 识别媒体信息\n                        mediainfo: MediaInfo = MediaChain().recognize_by_meta(meta)\n                        if not mediainfo:\n                            logger.warn(f'{torrent.title} 未识别到媒体信息')\n                            # 存储空的媒体信息\n                            mediainfo = MediaInfo()\n                        # 清理多余数据，减少内存占用\n                        mediainfo.clear()\n                        # 上下文\n                        context = Context(meta_info=meta, media_info=mediainfo, torrent_info=torrent)\n                        # 如果未识别到媒体信息，设置初始失败次数为1\n                        if not mediainfo or (not mediainfo.tmdb_id and not mediainfo.douban_id):\n                            context.media_recognize_fail_count = 1\n                        # 添加到缓存\n                        if not torrents_cache.get(domain):\n                            torrents_cache[domain] = [context]\n                        else:\n                            torrents_cache[domain].append(context)\n                        # 如果超过了限制条数则移除掉前面的\n                        if len(torrents_cache[domain]) > settings.CONF.torrents:\n                            torrents_cache[domain] = torrents_cache[domain][-settings.CONF.torrents:]\n                finally:\n                    torrents.clear()\n                    del torrents\n            else:\n                logger.info(f'{indexer.get(\"name\")} 没有获取到种子')\n\n        # 保存缓存到本地\n        if stype == \"spider\":\n            self.save_cache(torrents_cache, self._spider_file)\n        else:\n            self.save_cache(torrents_cache, self._rss_file)\n\n        # 去除不在站点范围内的缓存种子\n        if sites and torrents_cache:\n            torrents_cache = {k: v for k, v in torrents_cache.items() if k in domains}\n\n        return torrents_cache\n\n    @staticmethod\n    def _ensure_context_compatibility(torrents_cache: Dict[str, List[Context]]):\n        \"\"\"\n        确保Context对象的兼容性，为旧版本添加缺失的字段\n        \"\"\"\n        for domain, contexts in torrents_cache.items():\n            for context in contexts:\n                # 如果Context对象没有media_recognize_fail_count字段，添加默认值\n                if not hasattr(context, 'media_recognize_fail_count'):\n                    context.media_recognize_fail_count = 0\n                    # 如果媒体信息未识别，设置初始失败次数\n                    if (not context.media_info or\n                            (not context.media_info.tmdb_id and not context.media_info.douban_id)):\n                        context.media_recognize_fail_count = 1\n\n    def __renew_rss_url(self, domain: str, site: dict):\n        \"\"\"\n        保留原配置生成新的rss地址\n        \"\"\"\n        try:\n            # RSS链接过期\n            logger.error(f\"站点 {domain} RSS链接已过期，正在尝试自动获取！\")\n            # 自动生成rss地址\n            rss_url, errmsg = RssHelper().get_rss_link(\n                url=site.get(\"url\"),\n                cookie=site.get(\"cookie\"),\n                ua=site.get(\"ua\") or settings.USER_AGENT,\n                proxy=True if site.get(\"proxy\") else False,\n                timeout=site.get(\"timeout\"),\n            )\n            if rss_url:\n                # 获取新的日期的passkey\n                match = re.search(r'passkey=([a-zA-Z0-9]+)', rss_url)\n                if match:\n                    new_passkey = match.group(1)\n                    # 获取过期rss除去passkey部分\n                    new_rss = re.sub(r'&passkey=([a-zA-Z0-9]+)', f'&passkey={new_passkey}', site.get(\"rss\"))\n                    logger.info(f\"更新站点 {domain} RSS地址 ...\")\n                    SiteOper().update_rss(domain=domain, rss=new_rss)\n                else:\n                    # 发送消息\n                    self.post_message(\n                        Notification(mtype=NotificationType.SiteMessage, title=f\"站点 {domain} RSS链接已过期\",\n                                     link=settings.MP_DOMAIN('#/site'))\n                    )\n            else:\n                self.post_message(\n                    Notification(mtype=NotificationType.SiteMessage, title=f\"站点 {domain} RSS链接已过期\",\n                                 link=settings.MP_DOMAIN('#/site')))\n        except Exception as e:\n            logger.error(f\"站点 {domain} RSS链接自动获取失败：{str(e)} - {traceback.format_exc()}\")\n            self.post_message(Notification(mtype=NotificationType.SiteMessage, title=f\"站点 {domain} RSS链接已过期\",\n                                           link=settings.MP_DOMAIN('#/site')))\n"
  },
  {
    "path": "app/chain/transfer.py",
    "content": "import queue\nimport re\nimport threading\nimport traceback\nfrom copy import deepcopy\nfrom pathlib import Path\nfrom typing import List, Optional, Tuple, Union, Dict, Callable\n\nfrom app import schemas\nfrom app.chain import ChainBase\nfrom app.chain.media import MediaChain\nfrom app.chain.storage import StorageChain\nfrom app.chain.subscribe import SubscribeChain\nfrom app.chain.tmdb import TmdbChain\nfrom app.core.config import settings, global_vars\nfrom app.core.context import MediaInfo\nfrom app.core.event import eventmanager\nfrom app.core.meta import MetaBase\nfrom app.core.metainfo import MetaInfoPath\nfrom app.db.downloadhistory_oper import DownloadHistoryOper\nfrom app.db.models.downloadhistory import DownloadHistory\nfrom app.db.models.transferhistory import TransferHistory\nfrom app.db.systemconfig_oper import SystemConfigOper\nfrom app.db.transferhistory_oper import TransferHistoryOper\nfrom app.helper.directory import DirectoryHelper\nfrom app.helper.format import FormatParser\nfrom app.helper.progress import ProgressHelper\nfrom app.log import logger\nfrom app.schemas import StorageOperSelectionEventData\nfrom app.schemas import TransferInfo, Notification, EpisodeFormat, FileItem, TransferDirectoryConf, \\\n    TransferTask, TransferQueue, TransferJob, TransferJobTask\nfrom app.schemas.exception import OperationInterrupted\nfrom app.schemas.types import TorrentStatus, EventType, MediaType, ProgressKey, NotificationType, MessageChannel, \\\n    SystemConfigKey, ChainEventType, ContentType\nfrom app.utils.mixins import ConfigReloadMixin\nfrom app.utils.singleton import Singleton\nfrom app.utils.string import StringUtils\nfrom app.utils.system import SystemUtils\n\n# 下载器锁\ndownloader_lock = threading.Lock()\n# 作业锁\njob_lock = threading.Lock()\n# 任务锁\ntask_lock = threading.Lock()\n\n\nclass JobManager:\n    \"\"\"\n    作业管理器\n    task任务负责一个文件的整理，job作业负责一个媒体的整理\n    \"\"\"\n\n    # 整理中的作业\n    _job_view: Dict[Tuple, TransferJob] = {}\n    # 汇总季集清单\n    _season_episodes: Dict[Tuple, List[int]] = {}\n\n    def __init__(self):\n        self._job_view = {}\n        self._season_episodes = {}\n\n    @staticmethod\n    def __get_meta_id(meta: MetaBase = None, season: Optional[int] = None) -> Tuple:\n        \"\"\"\n        获取元数据ID\n        \"\"\"\n        return meta.name, season\n\n    @staticmethod\n    def __get_media_id(media: MediaInfo = None, season: Optional[int] = None) -> Tuple:\n        \"\"\"\n        获取媒体ID\n        \"\"\"\n        if not media:\n            return None, season\n        return media.tmdb_id or media.douban_id, season\n\n    def __get_id(self, task: TransferTask = None) -> Tuple:\n        \"\"\"\n        获取作业ID\n        \"\"\"\n        if task.mediainfo:\n            return self.__get_media_id(media=task.mediainfo, season=task.meta.begin_season)\n        else:\n            return self.__get_meta_id(meta=task.meta, season=task.meta.begin_season)\n\n    @staticmethod\n    def __get_media(task: TransferTask) -> schemas.MediaInfo:\n        \"\"\"\n        获取媒体信息\n        \"\"\"\n        if task.mediainfo:\n            # 有媒体信息\n            mediainfo = deepcopy(task.mediainfo)\n            mediainfo.clear()\n            return schemas.MediaInfo(**mediainfo.to_dict())\n        else:\n            # 没有媒体信息\n            meta: MetaBase = task.meta\n            return schemas.MediaInfo(\n                title=meta.name,\n                year=meta.year,\n                title_year=f\"{meta.name} ({meta.year})\",\n                type=meta.type.value if meta.type else None\n            )\n\n    @staticmethod\n    def __get_meta(task: TransferTask) -> schemas.MetaInfo:\n        \"\"\"\n        获取元数据\n        \"\"\"\n        return schemas.MetaInfo(**task.meta.to_dict())\n\n    def add_task(self, task: TransferTask, state: Optional[str] = \"waiting\") -> bool:\n        \"\"\"\n        添加整理任务，自动分组到对应的作业中\n        :return: True表示任务已添加，False表示任务无效或已存在（重复）\n        \"\"\"\n        if not all([task, task.meta, task.fileitem]):\n            return False\n        with job_lock:\n            __mediaid__ = self.__get_id(task)\n            if __mediaid__ not in self._job_view:\n                self._job_view[__mediaid__] = TransferJob(\n                    media=self.__get_media(task),\n                    season=task.meta.begin_season,\n                    tasks=[TransferJobTask(\n                        fileitem=task.fileitem,\n                        meta=self.__get_meta(task),\n                        downloader=task.downloader,\n                        download_hash=task.download_hash,\n                        state=state\n                    )]\n                )\n            else:\n                # 不重复添加任务\n                if any([t.fileitem == task.fileitem for t in self._job_view[__mediaid__].tasks]):\n                    logger.debug(f\"任务 {task.fileitem.name} 已存在，跳过重复添加\")\n                    return False\n                self._job_view[__mediaid__].tasks.append(\n                    TransferJobTask(\n                        fileitem=task.fileitem,\n                        meta=self.__get_meta(task),\n                        downloader=task.downloader,\n                        download_hash=task.download_hash,\n                        state=state\n                    )\n                )\n            # 添加季集信息\n            if self._season_episodes.get(__mediaid__):\n                self._season_episodes[__mediaid__].extend(task.meta.episode_list)\n                self._season_episodes[__mediaid__] = list(set(self._season_episodes[__mediaid__]))\n            else:\n                self._season_episodes[__mediaid__] = task.meta.episode_list\n            return True\n\n    def running_task(self, task: TransferTask):\n        \"\"\"\n        设置任务为运行中\n        \"\"\"\n        with job_lock:\n            __mediaid__ = self.__get_id(task)\n            if __mediaid__ not in self._job_view:\n                return\n            # 更新状态\n            for t in self._job_view[__mediaid__].tasks:\n                if t.fileitem == task.fileitem:\n                    t.state = \"running\"\n                    break\n\n    def finish_task(self, task: TransferTask):\n        \"\"\"\n        设置任务为完成/成功\n        \"\"\"\n        with job_lock:\n            __mediaid__ = self.__get_id(task)\n            if __mediaid__ not in self._job_view:\n                return\n            # 更新状态\n            for t in self._job_view[__mediaid__].tasks:\n                if t.fileitem == task.fileitem:\n                    t.state = \"completed\"\n                    break\n\n    def fail_task(self, task: TransferTask):\n        \"\"\"\n        设置任务为失败\n        \"\"\"\n        with job_lock:\n            __mediaid__ = self.__get_id(task)\n            if __mediaid__ not in self._job_view:\n                return\n            # 更新状态\n            for t in self._job_view[__mediaid__].tasks:\n                if t.fileitem == task.fileitem:\n                    t.state = \"failed\"\n                    break\n            # 移除剧集信息\n            if __mediaid__ in self._season_episodes:\n                self._season_episodes[__mediaid__] = list(\n                    set(self._season_episodes[__mediaid__]) - set(task.meta.episode_list)\n                )\n\n    def remove_task(self, fileitem: FileItem) -> Optional[TransferJobTask]:\n        \"\"\"\n        根据文件项移除任务\n        \"\"\"\n        with job_lock:\n            for mediaid in list(self._job_view):\n                job = self._job_view[mediaid]\n                for task in job.tasks:\n                    if task.fileitem == fileitem:\n                        job.tasks.remove(task)\n                        # 如果没有作业了，则移除作业\n                        if not job.tasks:\n                            self._job_view.pop(mediaid)\n                        # 移除季集信息\n                        if mediaid in self._season_episodes:\n                            self._season_episodes[mediaid] = list(\n                                set(self._season_episodes[mediaid]) - set(task.meta.episode_list)\n                            )\n                        return task\n            return None\n\n    def remove_job(self, task: TransferTask) -> Optional[TransferJob]:\n        \"\"\"\n        移除任务对应的作业（强制，线程不安全）\n        \"\"\"\n        with job_lock:\n            __mediaid__ = self.__get_id(task)\n            if __mediaid__ in self._job_view:\n                # 移除季集信息\n                if __mediaid__ in self._season_episodes:\n                    self._season_episodes.pop(__mediaid__)\n                return self._job_view.pop(__mediaid__)\n            return None\n\n    def try_remove_job(self, task: TransferTask):\n        \"\"\"\n        尝试移除任务对应的作业（严格检查未完成作业，线程安全）\n        \"\"\"\n        with job_lock:\n            __metaid__ = self.__get_meta_id(meta=task.meta, season=task.meta.begin_season)\n            __mediaid__ = self.__get_media_id(media=task.mediainfo, season=task.meta.begin_season)\n\n            meta_done = True\n            if __metaid__ in self._job_view:\n                meta_done = all(\n                    t.state in [\"completed\", \"failed\"] for t in self._job_view[__metaid__].tasks\n                )\n\n            media_done = True\n            if __mediaid__ in self._job_view:\n                media_done = all(\n                    t.state in [\"completed\", \"failed\"] for t in self._job_view[__mediaid__].tasks\n                )\n\n            if meta_done and media_done:\n                __id__ = self.__get_id(task)\n                if __id__ in self._job_view:\n                    # 移除季集信息\n                    if __id__ in self._season_episodes:\n                        self._season_episodes.pop(__id__)\n                    self._job_view.pop(__id__)\n\n    def is_done(self, task: TransferTask) -> bool:\n        \"\"\"\n        检查任务对应的作业是否整理完成（不管成功还是失败）\n        \"\"\"\n        with job_lock:\n            __metaid__ = self.__get_meta_id(meta=task.meta, season=task.meta.begin_season)\n            __mediaid__ = self.__get_media_id(media=task.mediainfo, season=task.meta.begin_season)\n            if __metaid__ in self._job_view:\n                meta_done = all(\n                    task.state in [\"completed\", \"failed\"] for task in self._job_view[__metaid__].tasks\n                )\n            else:\n                meta_done = True\n            if __mediaid__ in self._job_view:\n                media_done = all(\n                    task.state in [\"completed\", \"failed\"] for task in self._job_view[__mediaid__].tasks\n                )\n            else:\n                media_done = True\n            return meta_done and media_done\n\n    def is_finished(self, task: TransferTask) -> bool:\n        \"\"\"\n        检查任务对应的作业是否已完成且有成功的记录\n        \"\"\"\n        with job_lock:\n            __metaid__ = self.__get_meta_id(meta=task.meta, season=task.meta.begin_season)\n            __mediaid__ = self.__get_media_id(media=task.mediainfo, season=task.meta.begin_season)\n            if __metaid__ in self._job_view:\n                meta_finished = all(\n                    task.state in [\"completed\", \"failed\"] for task in self._job_view[__metaid__].tasks\n                )\n            else:\n                meta_finished = True\n            if __mediaid__ in self._job_view:\n                tasks = self._job_view[__mediaid__].tasks\n                media_finished = all(\n                    task.state in [\"completed\", \"failed\"] for task in tasks\n                ) and any(\n                    task.state == \"completed\" for task in tasks\n                )\n            else:\n                media_finished = True\n            return meta_finished and media_finished\n\n    def is_success(self, task: TransferTask) -> bool:\n        \"\"\"\n        检查任务对应的作业是否全部成功\n        \"\"\"\n        with job_lock:\n            __metaid__ = self.__get_meta_id(meta=task.meta, season=task.meta.begin_season)\n            __mediaid__ = self.__get_media_id(media=task.mediainfo, season=task.meta.begin_season)\n            if __metaid__ in self._job_view:\n                meta_success = all(\n                    task.state in [\"completed\"] for task in self._job_view[__metaid__].tasks\n                )\n            else:\n                meta_success = True\n            if __mediaid__ in self._job_view:\n                media_success = all(\n                    task.state in [\"completed\"] for task in self._job_view[__mediaid__].tasks\n                )\n            else:\n                media_success = True\n            return meta_success and media_success\n\n    def get_all_torrent_hashes(self) -> set[str]:\n        \"\"\"\n        获取所有种子的哈希值集合\n        \"\"\"\n        with job_lock:\n            return {\n                task.download_hash\n                for job in self._job_view.values()\n                for task in job.tasks\n            }\n\n    def is_torrent_done(self, download_hash: str) -> bool:\n        \"\"\"\n        检查指定种子的所有任务是否都已完成\n        \"\"\"\n        with job_lock:\n            if any(\n                task.state not in {\"completed\", \"failed\"}\n                for job in self._job_view.values()\n                for task in job.tasks\n                if task.download_hash == download_hash\n            ):\n                return False\n            return True\n\n    def is_torrent_success(self, download_hash: str) -> bool:\n        \"\"\"\n        检查指定种子的所有任务是否都已成功\n        \"\"\"\n        with job_lock:\n            if any(\n                task.state != \"completed\"\n                for job in self._job_view.values()\n                for task in job.tasks\n                if task.download_hash == download_hash\n            ):\n                return False\n            return True\n\n    def has_tasks(self, meta: MetaBase, mediainfo: Optional[MediaInfo] = None, season: Optional[int] = None) -> bool:\n        \"\"\"\n        判断作业是否还有任务正在处理\n        \"\"\"\n        with job_lock:\n            if mediainfo:\n                __mediaid__ = self.__get_media_id(media=mediainfo, season=season)\n                if __mediaid__ in self._job_view:\n                    return True\n\n            __metaid__ = self.__get_meta_id(meta=meta, season=season)\n            return __metaid__ in self._job_view and len(self._job_view[__metaid__].tasks) > 0\n\n    def success_tasks(self, media: MediaInfo, season: Optional[int] = None) -> List[TransferJobTask]:\n        \"\"\"\n        获取作业中所有成功的任务\n        \"\"\"\n        with job_lock:\n            __mediaid__ = self.__get_media_id(media=media, season=season)\n            if __mediaid__ not in self._job_view:\n                return []\n            return [task for task in self._job_view[__mediaid__].tasks if task.state == \"completed\"]\n\n    def all_tasks(self, media: MediaInfo, season: Optional[int] = None) -> List[TransferJobTask]:\n        \"\"\"\n        获取作业中全部任务\n        \"\"\"\n        with job_lock:\n            __mediaid__ = self.__get_media_id(media=media, season=season)\n            if __mediaid__ not in self._job_view:\n                return []\n            return self._job_view[__mediaid__].tasks\n\n    def count(self, media: MediaInfo, season: Optional[int] = None) -> int:\n        \"\"\"\n        获取作业中成功总数\n        \"\"\"\n        with job_lock:\n            __mediaid__ = self.__get_media_id(media=media, season=season)\n            if __mediaid__ not in self._job_view:\n                return 0\n            return len([task for task in self._job_view[__mediaid__].tasks if task.state == \"completed\"])\n\n    def size(self, media: MediaInfo, season: Optional[int] = None) -> int:\n        \"\"\"\n        获取作业中所有成功文件总大小\n        \"\"\"\n        with job_lock:\n            __mediaid__ = self.__get_media_id(media=media, season=season)\n            if __mediaid__ not in self._job_view:\n                return 0\n            return sum([\n                task.fileitem.size if task.fileitem.size is not None\n                else (\n                    SystemUtils.get_directory_size(Path(task.fileitem.path)) if task.fileitem.storage == \"local\" else 0)\n                for task in self._job_view[__mediaid__].tasks\n                if task.state == \"completed\"\n            ])\n\n    def total(self) -> int:\n        \"\"\"\n        获取所有任务总数\n        \"\"\"\n        with job_lock:\n            return sum([len(job.tasks) for job in self._job_view.values()])\n\n    def list_jobs(self) -> List[TransferJob]:\n        \"\"\"\n        获取所有作业的任务列表\n        \"\"\"\n        with job_lock:\n            return list(self._job_view.values())\n\n    def season_episodes(self, media: MediaInfo, season: Optional[int] = None) -> List[int]:\n        \"\"\"\n        获取作业的季集清单\n        \"\"\"\n        with job_lock:\n            __mediaid__ = self.__get_media_id(media=media, season=season)\n            return self._season_episodes.get(__mediaid__) or []\n\n\nclass TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):\n    \"\"\"\n    文件整理处理链\n    \"\"\"\n\n    CONFIG_WATCH = {\n        \"TRANSFER_THREADS\",\n    }\n\n    def __init__(self):\n        super().__init__()\n        # 主要媒体文件后缀\n        self._media_exts = settings.RMT_MEDIAEXT\n        # 字幕文件后缀\n        self._subtitle_exts = settings.RMT_SUBEXT\n        # 音频文件后缀\n        self._audio_exts = settings.RMT_AUDIOEXT\n        # 可处理的文件后缀（视频文件、字幕、音频文件）\n        self._allowed_exts = self._media_exts + self._audio_exts + self._subtitle_exts\n        # 待整理任务队列\n        self._queue = queue.Queue()\n        # 文件整理线程\n        self._transfer_threads = []\n        # 队列间隔时间（秒）\n        self._transfer_interval = 15\n        # 事件管理器\n        self.jobview = JobManager()\n        # 转移成功的文件清单\n        self._success_target_files: Dict[str, List[str]] = {}\n        # 整理进度进度\n        self._progress = ProgressHelper(ProgressKey.FileTransfer)\n        # 队列相关状态\n        self._threads = []\n        self._queue_active = False\n        self._active_tasks = 0\n        self._processed_num = 0\n        self._fail_num = 0\n        self._total_num = 0\n        # 启动整理任务\n        self.__init()\n\n    def __init(self):\n        \"\"\"\n        启动文件整理线程\n        \"\"\"\n        self._queue_active = True\n        for i in range(settings.TRANSFER_THREADS):\n            logger.info(f\"启动文件整理线程 {i + 1} ...\")\n            thread = threading.Thread(target=self.__start_transfer,\n                                      name=f\"transfer-{i}\",\n                                      daemon=True)\n            self._threads.append(thread)\n            thread.start()\n\n    def __stop(self):\n        \"\"\"\n        停止文件整理进程\n        \"\"\"\n        self._queue_active = False\n        for thread in self._threads:\n            thread.join()\n        self._threads = []\n        logger.info(\"文件整理线程已停止\")\n\n    def on_config_changed(self):\n        self.__stop()\n        self.__init()\n\n    def __is_subtitle_file(self, fileitem: FileItem) -> bool:\n        \"\"\"\n        判断是否为字幕文件\n        \"\"\"\n        if not fileitem.extension:\n            return False\n        return True if f\".{fileitem.extension.lower()}\" in self._subtitle_exts else False\n\n    def __is_audio_file(self, fileitem: FileItem) -> bool:\n        \"\"\"\n        判断是否为音频文件\n        \"\"\"\n        if not fileitem.extension:\n            return False\n        return True if f\".{fileitem.extension.lower()}\" in self._audio_exts else False\n\n    def __is_media_file(self, fileitem: FileItem) -> bool:\n        \"\"\"\n        判断是否为主要媒体文件\n        \"\"\"\n        if fileitem.type == \"dir\":\n            # 蓝光原盘判断\n            return StorageChain().is_bluray_folder(fileitem)\n        if not fileitem.extension:\n            return False\n        return True if f\".{fileitem.extension.lower()}\" in self._media_exts else False\n\n    def __is_allowed_file(self, fileitem: FileItem) -> bool:\n        \"\"\"\n        判断是否允许的扩展名\n        \"\"\"\n        if not fileitem.extension:\n            return False\n        return True if f\".{fileitem.extension.lower()}\" in self._allowed_exts else False\n\n    @staticmethod\n    def __is_allow_filesize(fileitem: FileItem, min_filesize: int) -> bool:\n        \"\"\"\n        判断是否满足最小文件大小\n        \"\"\"\n        return True if not min_filesize or (fileitem.size or 0) > min_filesize * 1024 * 1024 else False\n\n    def __default_callback(self, task: TransferTask,\n                           transferinfo: TransferInfo, /) -> Tuple[bool, str]:\n        \"\"\"\n        整理完成后处理\n        \"\"\"\n\n        # 状态\n        ret_status = True\n        # 错误信息\n        ret_message = \"\"\n\n        def __notify():\n            \"\"\"\n            完成时发送消息、刮削事件、移除任务等\n            \"\"\"\n            # 更新文件数量\n            transferinfo.file_count = self.jobview.count(task.mediainfo, task.meta.begin_season) or 1\n            # 更新文件大小\n            transferinfo.total_size = self.jobview.size(task.mediainfo,\n                                                        task.meta.begin_season) or task.fileitem.size\n            # 更新文件清单\n            with job_lock:\n                transferinfo.file_list_new = self._success_target_files.pop(transferinfo.target_diritem.path, [])\n\n            # 发送通知，实时手动整理时不发\n            if transferinfo.need_notify and (task.background or not task.manual):\n                se_str = None\n                if task.mediainfo.type == MediaType.TV:\n                    season_episodes = self.jobview.season_episodes(task.mediainfo, task.meta.begin_season)\n                    if season_episodes:\n                        se_str = f\"{task.meta.season} {StringUtils.format_ep(season_episodes)}\"\n                    else:\n                        se_str = f\"{task.meta.season}\"\n                # 发送入库成功消息\n                self.send_transfer_message(meta=task.meta,\n                                           mediainfo=task.mediainfo,\n                                           transferinfo=transferinfo,\n                                           season_episode=se_str,\n                                           username=task.username)\n\n            # 刮削事件\n            if transferinfo.need_scrape and self.__is_media_file(task.fileitem):\n                self.eventmanager.send_event(EventType.MetadataScrape, {\n                    'meta': task.meta,\n                    'mediainfo': task.mediainfo,\n                    'fileitem': transferinfo.target_diritem,\n                    'file_list': transferinfo.file_list_new,\n                    'overwrite': False\n                })\n\n        transferhis = TransferHistoryOper()\n\n        # 转移失败\n        if not transferinfo.success:\n            logger.warn(f\"{task.fileitem.name} 入库失败：{transferinfo.message}\")\n\n            # 新增转移失败历史记录\n            history = transferhis.add_fail(\n                fileitem=task.fileitem,\n                mode=transferinfo.transfer_type if transferinfo else '',\n                downloader=task.downloader,\n                download_hash=task.download_hash,\n                meta=task.meta,\n                mediainfo=task.mediainfo,\n                transferinfo=transferinfo\n            )\n\n            # 整理失败事件\n            if self.__is_media_file(task.fileitem):\n                # 主要媒体文件整理失败事件\n                self.eventmanager.send_event(EventType.TransferFailed, {\n                    'fileitem': task.fileitem,\n                    'meta': task.meta,\n                    'mediainfo': task.mediainfo,\n                    'transferinfo': transferinfo,\n                    'downloader': task.downloader,\n                    'download_hash': task.download_hash,\n                    'transfer_history_id': history.id if history else None,\n                })\n            elif self.__is_subtitle_file(task.fileitem):\n                # 字幕整理失败事件\n                self.eventmanager.send_event(EventType.SubtitleTransferFailed, {\n                    'fileitem': task.fileitem,\n                    'meta': task.meta,\n                    'mediainfo': task.mediainfo,\n                    'transferinfo': transferinfo,\n                    'downloader': task.downloader,\n                    'download_hash': task.download_hash,\n                    'transfer_history_id': history.id if history else None,\n                })\n            elif self.__is_audio_file(task.fileitem):\n                # 音频文件整理失败事件\n                self.eventmanager.send_event(EventType.AudioTransferFailed, {\n                    'fileitem': task.fileitem,\n                    'meta': task.meta,\n                    'mediainfo': task.mediainfo,\n                    'transferinfo': transferinfo,\n                    'downloader': task.downloader,\n                    'download_hash': task.download_hash,\n                    'transfer_history_id': history.id if history else None,\n                })\n\n            # 发送失败消息\n            self.post_message(Notification(\n                mtype=NotificationType.Manual,\n                title=f\"{task.mediainfo.title_year} {task.meta.season_episode} 入库失败！\",\n                text=f\"原因：{transferinfo.message or '未知'}\",\n                image=task.mediainfo.get_message_image(),\n                username=task.username,\n                link=settings.MP_DOMAIN('#/history')\n            ))\n\n            # 设置任务失败\n            self.jobview.fail_task(task)\n\n            # 返回失败\n            ret_status = False\n            ret_message = transferinfo.message\n\n        else:\n            # 转移成功\n            logger.info(f\"{task.fileitem.name} 入库成功：{transferinfo.target_diritem.path}\")\n\n            # 新增task转移成功历史记录\n            history = transferhis.add_success(\n                fileitem=task.fileitem,\n                mode=transferinfo.transfer_type if transferinfo else '',\n                downloader=task.downloader,\n                download_hash=task.download_hash,\n                meta=task.meta,\n                mediainfo=task.mediainfo,\n                transferinfo=transferinfo\n            )\n\n            # task整理完成事件\n            if self.__is_media_file(task.fileitem):\n                # 主要媒体文件整理完成事件\n                self.eventmanager.send_event(EventType.TransferComplete, {\n                    'fileitem': task.fileitem,\n                    'meta': task.meta,\n                    'mediainfo': task.mediainfo,\n                    'transferinfo': transferinfo,\n                    'downloader': task.downloader,\n                    'download_hash': task.download_hash,\n                    'transfer_history_id': history.id if history else None,\n                })\n            elif self.__is_subtitle_file(task.fileitem):\n                # 字幕整理完成事件\n                self.eventmanager.send_event(EventType.SubtitleTransferComplete, {\n                    'fileitem': task.fileitem,\n                    'meta': task.meta,\n                    'mediainfo': task.mediainfo,\n                    'transferinfo': transferinfo,\n                    'downloader': task.downloader,\n                    'download_hash': task.download_hash,\n                    'transfer_history_id': history.id if history else None,\n                })\n            elif self.__is_audio_file(task.fileitem):\n                # 音频文件整理完成事件\n                self.eventmanager.send_event(EventType.AudioTransferComplete, {\n                    'fileitem': task.fileitem,\n                    'meta': task.meta,\n                    'mediainfo': task.mediainfo,\n                    'transferinfo': transferinfo,\n                    'downloader': task.downloader,\n                    'download_hash': task.download_hash,\n                    'transfer_history_id': history.id if history else None,\n                })\n\n            # task登记转移成功文件清单\n            target_dir_path = transferinfo.target_diritem.path\n            target_files = transferinfo.file_list_new\n            with job_lock:\n                if self._success_target_files.get(target_dir_path):\n                    self._success_target_files[target_dir_path].extend(target_files)\n                else:\n                    self._success_target_files[target_dir_path] = target_files\n\n            # 设置任务成功\n            self.jobview.finish_task(task)\n\n        # 全部整理完成且有成功的任务时，发送消息和事件\n        if self.jobview.is_finished(task):\n            __notify()\n\n        # 只要该种子的所有任务都已整理完成，则设置种子状态为已整理\n        if task.download_hash and self.jobview.is_torrent_done(task.download_hash):\n            self.transfer_completed(hashs=task.download_hash, downloader=task.downloader)\n\n        # 移动模式，全部成功时删除空目录和种子文件\n        if transferinfo.transfer_type in [\"move\"]:\n            # 全部整理成功时\n            if self.jobview.is_success(task):\n                # 所有成功的业务\n                tasks = self.jobview.success_tasks(task.mediainfo, task.meta.begin_season)\n                # 获取整理屏蔽词\n                transfer_exclude_words = SystemConfigOper().get(SystemConfigKey.TransferExcludeWords)\n                processed_hashes = set()\n                for t in tasks:\n                    if t.download_hash and t.download_hash not in processed_hashes:\n                        # 检查该种子的所有任务（跨作业）是否都已成功\n                        if self.jobview.is_torrent_success(t.download_hash):\n                            processed_hashes.add(t.download_hash)\n                            if self._can_delete_torrent(t.download_hash, t.downloader, transfer_exclude_words):\n                                # 移除种子及文件\n                                if self.remove_torrents(t.download_hash, downloader=t.downloader):\n                                    logger.info(f\"移动模式删除种子成功：{t.download_hash}\")\n                    if not t.download_hash and t.fileitem:\n                        # 删除剩余空目录\n                        StorageChain().delete_media_file(t.fileitem, delete_self=False)\n\n        return ret_status, ret_message\n\n    def put_to_queue(self, task: TransferTask) -> bool:\n        \"\"\"\n        添加到待整理队列\n        :param task: 任务信息\n        :return: True表示任务已添加到队列，False表示任务无效或已存在（重复）\n        \"\"\"\n        if not task:\n            return False\n        # 维护整理任务视图，如果任务已存在则不添加到队列\n        if not self.__put_to_jobview(task):\n            return False\n        # 添加到队列\n        self._queue.put(TransferQueue(\n            task=task,\n            callback=self.__default_callback\n        ))\n        return True\n\n    def __put_to_jobview(self, task: TransferTask) -> bool:\n        \"\"\"\n        添加到作业视图\n        :return: True表示任务已添加，False表示任务无效或已存在（重复）\n        \"\"\"\n        return self.jobview.add_task(task)\n\n    def remove_from_queue(self, fileitem: FileItem):\n        \"\"\"\n        从待整理队列移除\n        \"\"\"\n        if not fileitem:\n            return\n        self.jobview.remove_task(fileitem)\n\n    def __start_transfer(self):\n        \"\"\"\n        处理队列\n        \"\"\"\n        while not global_vars.is_system_stopped and self._queue_active:\n            try:\n                item: TransferQueue = self._queue.get(block=True, timeout=self._transfer_interval)\n                if not item:\n                    continue\n\n                task = item.task\n                if not task:\n                    self._queue.task_done()\n                    continue\n\n                # 文件信息\n                fileitem = task.fileitem\n\n                with task_lock:\n                    # 获取当前最新总数\n                    current_total = self.jobview.total()\n                    # 更新总数，取当前总数和当前已处理+运行中+队列中的最大值\n                    self._total_num = max(self._total_num, current_total)\n\n                    # 如果当前没有在运行的任务且处理数为0，说明是一个新序列的开始\n                    if self._active_tasks == 0 and self._processed_num == 0:\n                        logger.info(\"开始整理队列处理...\")\n                        # 启动进度\n                        self._progress.start()\n                        # 重置计数\n                        self._processed_num = 0\n                        self._fail_num = 0\n                        __process_msg = f\"开始整理队列处理，当前共 {self._total_num} 个文件 ...\"\n                        logger.info(__process_msg)\n                        self._progress.update(value=0,\n                                              text=__process_msg)\n                    # 增加运行中的任务数\n                    self._active_tasks += 1\n\n                try:\n                    # 更新进度\n                    __process_msg = f\"正在整理 {fileitem.name} ...\"\n                    logger.info(__process_msg)\n                    with task_lock:\n                        self._progress.update(\n                            value=(self._processed_num / self._total_num * 100) if self._total_num else 0,\n                            text=__process_msg)\n                    # 整理\n                    state, err_msg = self.__handle_transfer(task=task, callback=item.callback)\n\n                    with task_lock:\n                        if not state:\n                            # 任务失败\n                            self._fail_num += 1\n                        # 更新进度\n                        self._processed_num += 1\n                        __process_msg = f\"{fileitem.name} 整理完成\"\n                        logger.info(__process_msg)\n                        self._progress.update(\n                            value=(self._processed_num / self._total_num * 100) if self._total_num else 100,\n                            text=__process_msg)\n                except Exception as e:\n                    logger.error(f\"{fileitem.name} 整理任务处理出现错误：{e} - {traceback.format_exc()}\")\n                    with task_lock:\n                        self._processed_num += 1\n                        self._fail_num += 1\n                finally:\n                    self._queue.task_done()\n                    with task_lock:\n                        # 减少运行中的任务数\n                        self._active_tasks -= 1\n                        # 检查是否所有任务都已完成且队列为空\n                        if self._active_tasks == 0 and self._queue.empty():\n                            # 结束进度\n                            __end_msg = f\"整理队列处理完成，共整理 {self._processed_num} 个文件，失败 {self._fail_num} 个\"\n                            logger.info(__end_msg)\n                            self._progress.update(value=100,\n                                                  text=__end_msg)\n                            self._progress.end()\n                            # 重置计数\n                            self._processed_num = 0\n                            self._fail_num = 0\n\n            except queue.Empty:\n                # 即使队列空了，如果还有任务在运行，也不应该结束进度\n                # 这部分逻辑已经在 finally 的 active_tasks == 0 中处理了\n                continue\n            except Exception as e:\n                logger.error(f\"整理队列处理出现错误：{e} - {traceback.format_exc()}\")\n\n    def __handle_transfer(self, task: TransferTask,\n                          callback: Optional[Callable] = None) -> Optional[Tuple[bool, str]]:\n        \"\"\"\n        处理整理任务\n        \"\"\"\n        try:\n            # 识别\n            transferhis = TransferHistoryOper()\n            mediainfo = task.mediainfo\n            mediainfo_changed = False\n            if not mediainfo:\n                download_history = task.download_history\n                # 下载用户\n                if download_history:\n                    task.username = download_history.username\n                    # 识别媒体信息\n                    if download_history.tmdbid or download_history.doubanid:\n                        # 下载记录中已存在识别信息\n                        mediainfo: Optional[MediaInfo] = self.recognize_media(mtype=MediaType(download_history.type),\n                                                                              tmdbid=download_history.tmdbid,\n                                                                              doubanid=download_history.doubanid,\n                                                                              episode_group=download_history.episode_group)\n                        if mediainfo:\n                            # 更新自定义媒体类别\n                            if download_history.media_category:\n                                mediainfo.category = download_history.media_category\n                else:\n                    # 识别媒体信息\n                    mediainfo = MediaChain().recognize_by_meta(task.meta)\n\n                # 更新媒体图片\n                if mediainfo:\n                    self.obtain_images(mediainfo=mediainfo)\n\n                if not mediainfo:\n                    # 新增整理失败历史记录\n                    his = transferhis.add_fail(\n                        fileitem=task.fileitem,\n                        mode=task.transfer_type,\n                        meta=task.meta,\n                        downloader=task.downloader,\n                        download_hash=task.download_hash\n                    )\n                    self.post_message(Notification(\n                        mtype=NotificationType.Manual,\n                        title=f\"{task.fileitem.name} 未识别到媒体信息，无法入库！\",\n                        text=f\"回复：\\n```\\n/redo {his.id} [tmdbid]|[类型]\\n```\\n手动识别整理。\",\n                        username=task.username,\n                        link=settings.MP_DOMAIN('#/history')\n                    ))\n                    # 任务失败，直接移除task\n                    self.jobview.remove_task(task.fileitem)\n                    return False, \"未识别到媒体信息\"\n\n                mediainfo_changed = True\n\n            # 如果未开启新增已入库媒体是否跟随TMDB信息变化则根据tmdbid查询之前的title\n            if not settings.SCRAP_FOLLOW_TMDB:\n                transfer_history = transferhis.get_by_type_tmdbid(tmdbid=mediainfo.tmdb_id,\n                                                                  mtype=mediainfo.type.value)\n                if transfer_history and mediainfo.title != transfer_history.title:\n                    mediainfo.title = transfer_history.title\n                    mediainfo_changed = True\n\n            if mediainfo_changed:\n                # 更新任务信息\n                task.mediainfo = mediainfo\n                # 更新队列任务\n                curr_task = self.jobview.remove_task(task.fileitem)\n                self.jobview.add_task(task, state=curr_task.state if curr_task else \"waiting\")\n\n            # 获取集数据\n            if task.mediainfo.type == MediaType.TV and not task.episodes_info:\n                # 判断注意season为0的情况\n                season_num = task.mediainfo.season\n                if season_num is None and task.meta.season_seq:\n                    if task.meta.season_seq.isdigit():\n                        season_num = int(task.meta.season_seq)\n                # 默认值1\n                if season_num is None:\n                    season_num = 1\n                task.episodes_info = TmdbChain().tmdb_episodes(\n                    tmdbid=task.mediainfo.tmdb_id,\n                    season=season_num,\n                    episode_group=task.mediainfo.episode_group\n                )\n\n            # 查询整理目标目录\n            if not task.target_directory:\n                if task.target_path:\n                    # 指定目标路径，`手动整理`场景下使用，忽略源目录匹配，使用指定目录匹配\n                    task.target_directory = DirectoryHelper().get_dir(media=task.mediainfo,\n                                                                      dest_path=task.target_path,\n                                                                      target_storage=task.target_storage)\n                else:\n                    # 启用源目录匹配时，根据源目录匹配下载目录，否则按源目录同盘优先原则，如无源目录，则根据媒体信息获取目标目录\n                    task.target_directory = DirectoryHelper().get_dir(media=task.mediainfo,\n                                                                      storage=task.fileitem.storage,\n                                                                      src_path=Path(task.fileitem.path),\n                                                                      target_storage=task.target_storage)\n            if not task.target_storage and task.target_directory:\n                task.target_storage = task.target_directory.library_storage\n\n            # 正在处理\n            self.jobview.running_task(task)\n\n            # 广播事件，请示额外的源存储支持\n            source_oper = None\n            source_event_data = StorageOperSelectionEventData(\n                storage=task.fileitem.storage,\n            )\n            source_event = eventmanager.send_event(ChainEventType.StorageOperSelection, source_event_data)\n            # 使用事件返回的上下文数据\n            if source_event and source_event.event_data:\n                source_event_data: StorageOperSelectionEventData = source_event.event_data\n                if source_event_data.storage_oper:\n                    source_oper = source_event_data.storage_oper\n\n            # 广播事件，请示额外的目标存储支持\n            target_oper = None\n            target_event_data = StorageOperSelectionEventData(\n                storage=task.target_storage,\n            )\n            target_event = eventmanager.send_event(ChainEventType.StorageOperSelection, target_event_data)\n            # 使用事件返回的上下文数据\n            if target_event and target_event.event_data:\n                target_event_data: StorageOperSelectionEventData = target_event.event_data\n                if target_event_data.storage_oper:\n                    target_oper = target_event_data.storage_oper\n\n            # 执行整理\n            transferinfo: TransferInfo = self.transfer(fileitem=task.fileitem,\n                                                       meta=task.meta,\n                                                       mediainfo=task.mediainfo,\n                                                       target_directory=task.target_directory,\n                                                       target_storage=task.target_storage,\n                                                       target_path=task.target_path,\n                                                       transfer_type=task.transfer_type,\n                                                       episodes_info=task.episodes_info,\n                                                       scrape=task.scrape,\n                                                       library_type_folder=task.library_type_folder,\n                                                       library_category_folder=task.library_category_folder,\n                                                       source_oper=source_oper,\n                                                       target_oper=target_oper)\n            if not transferinfo:\n                logger.error(\"文件整理模块运行失败\")\n                return False, \"文件整理模块运行失败\"\n\n            # 回调，位置传参：任务、整理结果\n            if callback:\n                return callback(task, transferinfo)\n\n            return transferinfo.success, transferinfo.message\n\n        finally:\n            # 移除已完成的任务\n            self.jobview.try_remove_job(task)\n\n    def get_queue_tasks(self) -> List[TransferJob]:\n        \"\"\"\n        获取整理任务列表\n        \"\"\"\n        return self.jobview.list_jobs()\n\n    def recommend_name(self, meta: MetaBase, mediainfo: MediaInfo) -> Optional[str]:\n        \"\"\"\n        获取重命名后的名称\n        :param meta: 元数据\n        :param mediainfo: 媒体信息\n        :return: 重命名后的名称（含目录）\n        \"\"\"\n        return self.run_module(\"recommend_name\", meta=meta, mediainfo=mediainfo)\n\n    def process(self) -> bool:\n        \"\"\"\n        获取下载器中的种子列表，并执行整理\n        \"\"\"\n        # 全局锁，避免定时服务重复\n        with downloader_lock:\n            # 获取下载器监控目录\n            download_dirs = DirectoryHelper().get_download_dirs()\n\n            # 如果没有下载器监控的目录则不处理\n            if not any(dir_info.monitor_type == \"downloader\" and dir_info.storage == \"local\"\n                       for dir_info in download_dirs):\n                return True\n\n            logger.info(\"开始整理下载器中已经完成下载的文件 ...\")\n\n            # 从下载器获取种子列表\n            if torrents_list := self.list_torrents(status=TorrentStatus.TRANSFER):\n                seen = set()\n                existing_hashes = self.jobview.get_all_torrent_hashes()\n                torrents = [\n                    torrent\n                    for torrent in torrents_list\n                    if (h := torrent.hash) not in existing_hashes\n                       # 排除多下载器返回的重复种子\n                       and (h not in seen and (seen.add(h) or True))\n                ]\n            else:\n                torrents = []\n\n            if not torrents:\n                logger.info(\"没有已完成下载但未整理的任务\")\n                return False\n\n            logger.info(f\"获取到 {len(torrents)} 个已完成的下载任务\")\n\n            try:\n                for torrent in torrents:\n                    if global_vars.is_system_stopped:\n                        break\n\n                    # 文件路径\n                    file_path = torrent.path\n                    if not file_path.exists():\n                        logger.warn(f\"文件不存在：{file_path}\")\n                        continue\n\n                    # 检查是否为下载器监控目录中的文件\n                    is_downloader_monitor = False\n                    for dir_info in download_dirs:\n                        if dir_info.monitor_type != \"downloader\":\n                            continue\n                        if not dir_info.download_path:\n                            continue\n                        if file_path.is_relative_to(Path(dir_info.download_path)):\n                            is_downloader_monitor = True\n                            break\n                    if not is_downloader_monitor:\n                        logger.debug(f\"文件 {file_path} 不在下载器监控目录中，不通过下载器进行整理\")\n                        continue\n\n                    # 查询下载记录识别情况\n                    downloadhis: DownloadHistory = DownloadHistoryOper().get_by_hash(torrent.hash)\n                    if downloadhis:\n                        # 类型\n                        try:\n                            mtype = MediaType(downloadhis.type)\n                        except ValueError:\n                            mtype = MediaType.TV\n                        # 识别媒体信息\n                        mediainfo = self.recognize_media(mtype=mtype,\n                                                         tmdbid=downloadhis.tmdbid,\n                                                         doubanid=downloadhis.doubanid,\n                                                         episode_group=downloadhis.episode_group)\n                        if mediainfo:\n                            # 补充图片\n                            self.obtain_images(mediainfo)\n                            # 更新自定义媒体类别\n                            if downloadhis.media_category:\n                                mediainfo.category = downloadhis.media_category\n\n                    else:\n                        # 非MoviePilot下载的任务，按文件识别\n                        mediainfo = None\n\n                    # 执行异步整理，匹配源目录\n                    self.do_transfer(\n                        fileitem=FileItem(\n                            storage=\"local\",\n                            path=file_path.as_posix() + (\"/\" if file_path.is_dir() else \"\"),\n                            type=\"dir\" if not file_path.is_file() else \"file\",\n                            name=file_path.name,\n                            size=file_path.stat().st_size,\n                            extension=file_path.suffix.lstrip('.'),\n                        ),\n                        mediainfo=mediainfo,\n                        downloader=torrent.downloader,\n                        download_hash=torrent.hash\n                    )\n\n            finally:\n                torrents.clear()\n                del torrents\n\n            return True\n\n    def __get_trans_fileitems(\n        self,\n        fileitem: FileItem,\n        predicate: Optional[Callable[[FileItem, bool], bool]],\n        verify_file_exists: bool = True,\n    ) -> List[Tuple[FileItem, bool]]:\n        \"\"\"\n        获取待整理文件项列表\n\n        :param fileitem: 源文件项\n        :param predicate: 用于筛选目录或文件项\n            该函数接收两个参数：\n\n            - `file_item`: 需要判断的文件项（类型为 `FileItem`）\n            - `is_bluray_dir`: 表示该项是否为蓝光原盘目录（布尔值）\n\n            函数应返回 `True` 表示保留该项，`False` 表示过滤掉\n\n            若 `predicate` 为 `None`，则默认保留所有项\n        :param verify_file_exists: 验证目录或文件是否存在，默认值为 `True`\n        \"\"\"\n        if global_vars.is_system_stopped:\n            raise OperationInterrupted()\n\n        storagechain = StorageChain()\n\n        def __is_bluray_sub(_path: str) -> bool:\n            \"\"\"\n            判断是否蓝光原盘目录内的子目录或文件\n            \"\"\"\n            return True if re.search(r\"BDMV[/\\\\]STREAM\", _path, re.IGNORECASE) else False\n\n        def __get_bluray_dir(_storage: str, _path: Path) -> Optional[FileItem]:\n            \"\"\"\n            获取蓝光原盘BDMV目录的上级目录\n            \"\"\"\n            for p in _path.parents:\n                if p.name == \"BDMV\":\n                    return storagechain.get_file_item(storage=_storage, path=p.parent)\n            return None\n\n        def _apply_predicate(file_item: FileItem, is_bluray_dir: bool) -> List[Tuple[FileItem, bool]]:\n            if predicate is None or predicate(file_item, is_bluray_dir):\n                return [(file_item, is_bluray_dir)]\n            return []\n\n        if verify_file_exists:\n            latest_fileitem = storagechain.get_item(fileitem)\n            if not latest_fileitem:\n                logger.warn(f\"目录或文件不存在：{fileitem.path}\")\n                return []\n            # 确保从历史记录重新整理时 能获得最新的源文件大小、修改日期等\n            fileitem = latest_fileitem\n\n        # 是否蓝光原盘子目录或文件\n        if __is_bluray_sub(fileitem.path):\n            if bluray_dir := __get_bluray_dir(fileitem.storage, Path(fileitem.path)):\n                # 返回该文件所在的原盘根目录\n                return _apply_predicate(bluray_dir, True)\n\n        # 单文件\n        if fileitem.type == \"file\":\n            return _apply_predicate(fileitem, False)\n\n        # 是否蓝光原盘根目录\n        sub_items = storagechain.list_files(fileitem, recursion=False) or []\n        if storagechain.contains_bluray_subdirectories(sub_items):\n            # 当前目录是原盘根目录，不需要递归\n            return _apply_predicate(fileitem, True)\n\n        # 不是原盘根目录 递归获取目录内需要整理的文件项列表\n        return [\n            item\n            for sub_item in sub_items\n            for item in (\n                self.__get_trans_fileitems(\n                    sub_item, predicate, verify_file_exists=False\n                )\n                if sub_item.type == \"dir\"\n                else _apply_predicate(sub_item, False)\n            )\n        ]\n\n    def do_transfer(self, fileitem: FileItem,\n                    meta: MetaBase = None, mediainfo: MediaInfo = None,\n                    target_directory: TransferDirectoryConf = None,\n                    target_storage: Optional[str] = None, target_path: Path = None,\n                    transfer_type: Optional[str] = None, scrape: Optional[bool] = None,\n                    library_type_folder: Optional[bool] = None, library_category_folder: Optional[bool] = None,\n                    season: Optional[int] = None, epformat: EpisodeFormat = None, min_filesize: Optional[int] = 0,\n                    downloader: Optional[str] = None, download_hash: Optional[str] = None,\n                    force: Optional[bool] = False, background: Optional[bool] = True,\n                    manual: Optional[bool] = False, continue_callback: Callable = None) -> Tuple[bool, str]:\n        \"\"\"\n        执行一个复杂目录的整理操作\n        :param fileitem: 文件项\n        :param meta: 元数据\n        :param mediainfo: 媒体信息\n        :param target_directory:  目标目录配置\n        :param target_storage: 目标存储器\n        :param target_path: 目标路径\n        :param transfer_type: 整理类型\n        :param scrape: 是否刮削元数据\n        :param library_type_folder: 媒体库类型子目录\n        :param library_category_folder: 媒体库类别子目录\n        :param season: 季\n        :param epformat: 剧集格式\n        :param min_filesize: 最小文件大小(MB)\n        :param downloader: 下载器\n        :param download_hash: 下载记录hash\n        :param force: 是否强制整理\n        :param background: 是否后台运行\n        :param manual: 是否手动整理\n        :param continue_callback: 继续处理回调\n        返回：成功标识，错误信息\n        \"\"\"\n        # 是否全部成功\n        all_success = True\n\n        # 自定义格式\n        formaterHandler = FormatParser(eformat=epformat.format,\n                                       details=epformat.detail,\n                                       part=epformat.part,\n                                       offset=epformat.offset) if epformat else None\n\n        # 整理屏蔽词\n        transfer_exclude_words = SystemConfigOper().get(SystemConfigKey.TransferExcludeWords)\n        # 汇总错误信息\n        err_msgs: List[str] = []\n\n        def _filter(file_item: FileItem, is_bluray_dir: bool) -> bool:\n            \"\"\"\n            过滤文件项\n\n            :return: True 表示保留，False 表示排除\n            \"\"\"\n            if continue_callback and not continue_callback():\n                raise OperationInterrupted()\n            # 有集自定义格式，过滤文件\n            if formaterHandler and not formaterHandler.match(file_item.name):\n                return False\n            # 过滤后缀和大小（蓝光目录、附加文件不过滤）\n            if (\n                not is_bluray_dir\n                and not self.__is_subtitle_file(file_item)\n                and not self.__is_audio_file(file_item)\n            ):\n                if not self.__is_media_file(file_item):\n                    return False\n                if not self.__is_allow_filesize(file_item, min_filesize):\n                    return False\n            # 回收站及隐藏的文件不处理\n            if (\n                file_item.path.find(\"/@Recycle/\") != -1\n                or file_item.path.find(\"/#recycle/\") != -1\n                or file_item.path.find(\"/.\") != -1\n                or file_item.path.find(\"/@eaDir\") != -1\n            ):\n                logger.debug(f\"{file_item.path} 是回收站或隐藏的文件\")\n                return False\n            # 整理屏蔽词不处理\n            if self._is_blocked_by_exclude_words(file_item.path, transfer_exclude_words):\n                return False\n            return True\n\n        try:\n            # 获取经过筛选后的待整理文件项列表\n            file_items = self.__get_trans_fileitems(fileitem, predicate=_filter)\n        except OperationInterrupted:\n            return False, f\"{fileitem.name} 已取消\"\n\n        if not file_items:\n            logger.warn(f\"{fileitem.path} 没有找到可整理的媒体文件\")\n            return False, f\"{fileitem.name} 没有找到可整理的媒体文件\"\n\n        logger.info(f\"正在计划整理 {len(file_items)} 个文件...\")\n\n        # 整理所有文件\n        transfer_tasks: List[TransferTask] = []\n        try:\n            for file_item, bluray_dir in file_items:\n                if global_vars.is_system_stopped:\n                    raise OperationInterrupted()\n                if continue_callback and not continue_callback():\n                    raise OperationInterrupted()\n                file_path = Path(file_item.path)\n\n                # 整理成功的不再处理\n                if not force:\n                    transferd = TransferHistoryOper().get_by_src(file_item.path, storage=file_item.storage)\n                    if transferd:\n                        if not transferd.status:\n                            all_success = False\n                        logger.info(f\"{file_item.path} 已整理过，如需重新处理，请删除整理记录。\")\n                        err_msgs.append(f\"{file_item.name} 已整理过\")\n                        continue\n\n                # 提前获取下载历史，以便获取自定义识别词\n                download_history = None\n                downloadhis = DownloadHistoryOper()\n                if download_hash:\n                    # 先按hash查询\n                    download_history = downloadhis.get_by_hash(download_hash)\n                elif bluray_dir:\n                    # 蓝光原盘，按目录名查询\n                    download_history = downloadhis.get_by_path(file_path.as_posix())\n                else:\n                    # 按文件全路径查询\n                    download_file = downloadhis.get_file_by_fullpath(file_path.as_posix())\n                    if download_file:\n                        download_history = downloadhis.get_by_hash(download_file.download_hash)\n\n                if not meta:\n                    subscribe_custom_words = None\n                    if download_history and isinstance(download_history.note, dict):\n                        # 使用source动态获取订阅\n                        subscribe = SubscribeChain().get_subscribe_by_source(download_history.note.get(\"source\"))\n                        subscribe_custom_words = subscribe.custom_words.split(\n                            \"\\n\") if subscribe and subscribe.custom_words else None\n                    # 文件元数据(优先使用订阅识别词)\n                    file_meta = MetaInfoPath(file_path, custom_words=subscribe_custom_words)\n                else:\n                    file_meta = meta\n\n                # 合并季\n                if season is not None:\n                    file_meta.begin_season = season\n\n                if not file_meta:\n                    all_success = False\n                    logger.error(f\"{file_path.name} 无法识别有效信息\")\n                    err_msgs.append(f\"{file_path.name} 无法识别有效信息\")\n                    continue\n\n                # 自定义识别\n                if formaterHandler:\n                    # 开始集、结束集、PART\n                    begin_ep, end_ep, part = formaterHandler.split_episode(file_name=file_path.name,\n                                                                           file_meta=file_meta)\n                    if begin_ep is not None:\n                        file_meta.begin_episode = begin_ep\n                    if part is not None:\n                        file_meta.part = part\n                    if end_ep is not None:\n                        file_meta.end_episode = end_ep\n\n                # 获取下载Hash\n                if download_history and (not downloader or not download_hash):\n                    _downloader = download_history.downloader\n                    _download_hash = download_history.download_hash\n                else:\n                    _downloader = downloader\n                    _download_hash = download_hash\n\n                # 后台整理\n                transfer_task = TransferTask(\n                    fileitem=file_item,\n                    meta=file_meta,\n                    mediainfo=mediainfo,\n                    target_directory=target_directory,\n                    target_storage=target_storage,\n                    target_path=target_path,\n                    transfer_type=transfer_type,\n                    scrape=scrape,\n                    library_type_folder=library_type_folder,\n                    library_category_folder=library_category_folder,\n                    downloader=_downloader,\n                    download_hash=_download_hash,\n                    download_history=download_history,\n                    manual=manual,\n                    background=background\n                )\n                if background:\n                    if self.put_to_queue(task=transfer_task):\n                        logger.info(f\"{file_path.name} 已添加到整理队列\")\n                    else:\n                        logger.debug(f\"{file_path.name} 已在整理队列中，跳过\")\n                else:\n                    # 加入列表\n                    if self.__put_to_jobview(transfer_task):\n                        transfer_tasks.append(transfer_task)\n                    else:\n                        logger.debug(f\"{file_path.name} 已在整理列表中，跳过\")\n        except OperationInterrupted:\n            return False, f\"{fileitem.name} 已取消\"\n        finally:\n            file_items.clear()\n            del file_items\n\n        # 实时整理\n        if transfer_tasks:\n            # 总数量\n            total_num = len(transfer_tasks)\n            # 已处理数量\n            processed_num = 0\n            # 失败数量\n            fail_num = 0\n            # 已完成文件\n            finished_files = []\n\n            # 启动进度\n            progress = ProgressHelper(ProgressKey.FileTransfer)\n            progress.start()\n            __process_msg = f\"开始整理，共 {total_num} 个文件 ...\"\n            logger.info(__process_msg)\n            progress.update(value=0,\n                            text=__process_msg)\n            try:\n                for transfer_task in transfer_tasks:\n                    if global_vars.is_system_stopped:\n                        break\n                    if continue_callback and not continue_callback():\n                        break\n                    # 更新进度\n                    __process_msg = f\"正在整理 （{processed_num + fail_num + 1}/{total_num}）{transfer_task.fileitem.name} ...\"\n                    logger.info(__process_msg)\n                    progress.update(value=(processed_num + fail_num) / total_num * 100,\n                                    text=__process_msg,\n                                    data={\n                                        \"current\": Path(transfer_task.fileitem.path).as_posix(),\n                                        \"finished\": finished_files,\n                                    })\n                    state, err_msg = self.__handle_transfer(\n                        task=transfer_task,\n                        callback=self.__default_callback\n                    )\n                    if not state:\n                        all_success = False\n                        logger.warn(f\"{transfer_task.fileitem.name} {err_msg}\")\n                        err_msgs.append(f\"{transfer_task.fileitem.name} {err_msg}\")\n                        fail_num += 1\n                    else:\n                        processed_num += 1\n                    # 记录已完成\n                    finished_files.append(Path(transfer_task.fileitem.path).as_posix())\n            finally:\n                transfer_tasks.clear()\n                del transfer_tasks\n\n            # 整理结束\n            __end_msg = f\"整理队列处理完成，共整理 {total_num} 个文件，失败 {fail_num} 个\"\n            logger.info(__end_msg)\n            progress.update(value=100,\n                            text=__end_msg,\n                            data={})\n            progress.end()\n\n        error_msg = \"、\".join(err_msgs[:2]) + (f\"，等{len(err_msgs)}个文件错误！\" if len(err_msgs) > 2 else \"\")\n        return all_success, error_msg\n\n    def remote_transfer(self, arg_str: str, channel: MessageChannel,\n                        userid: Union[str, int] = None, source: Optional[str] = None):\n        \"\"\"\n        远程重新整理，参数 历史记录ID TMDBID|类型\n        \"\"\"\n\n        def args_error():\n            self.post_message(Notification(channel=channel, source=source,\n                                           title=\"请输入正确的命令格式：/redo [id] [tmdbid/豆瓣id]|[类型]，\"\n                                                 \"[id]整理记录编号\", userid=userid))\n\n        if not arg_str:\n            args_error()\n            return\n        arg_strs = str(arg_str).split()\n        if len(arg_strs) != 2:\n            args_error()\n            return\n        # 历史记录ID\n        logid = arg_strs[0]\n        if not logid.isdigit():\n            args_error()\n            return\n        # TMDBID/豆瓣ID\n        id_strs = arg_strs[1].split('|')\n        media_id = id_strs[0]\n        if not logid.isdigit():\n            args_error()\n            return\n        # 类型\n        type_str = id_strs[1] if len(id_strs) > 1 else None\n        if not type_str or type_str not in [MediaType.MOVIE.value, MediaType.TV.value]:\n            args_error()\n            return\n        state, errmsg = self.__re_transfer(logid=int(logid),\n                                           mtype=MediaType(type_str),\n                                           mediaid=media_id)\n        if not state:\n            self.post_message(Notification(channel=channel, title=\"手动整理失败\", source=source,\n                                           text=errmsg, userid=userid, link=settings.MP_DOMAIN('#/history')))\n            return\n\n    def __re_transfer(self, logid: int, mtype: MediaType = None,\n                      mediaid: Optional[str] = None) -> Tuple[bool, str]:\n        \"\"\"\n        根据历史记录，重新识别整理，只支持简单条件\n        :param logid: 历史记录ID\n        :param mtype: 媒体类型\n        :param mediaid: TMDB ID/豆瓣ID\n        \"\"\"\n        # 查询历史记录\n        history: TransferHistory = TransferHistoryOper().get(logid)\n        if not history:\n            logger.error(f\"整理记录不存在，ID：{logid}\")\n            return False, \"整理记录不存在\"\n        # 按源目录路径重新整理\n        src_path = Path(history.src)\n        if not src_path.exists():\n            return False, f\"源目录不存在：{src_path}\"\n        # 查询媒体信息\n        if mtype and mediaid:\n            mediainfo = self.recognize_media(mtype=mtype, tmdbid=int(mediaid) if str(mediaid).isdigit() else None,\n                                             doubanid=mediaid, episode_group=history.episode_group)\n            if mediainfo:\n                # 更新媒体图片\n                self.obtain_images(mediainfo=mediainfo)\n        else:\n            mediainfo = MediaChain().recognize_by_path(str(src_path), episode_group=history.episode_group)\n        if not mediainfo:\n            return False, f\"未识别到媒体信息，类型：{mtype.value}，id：{mediaid}\"\n        # 重新执行整理\n        logger.info(f\"{src_path.name} 识别为：{mediainfo.title_year}\")\n\n        # 删除旧的已整理文件\n        if history.dest_fileitem:\n            # 解析目标文件对象\n            dest_fileitem = FileItem(**history.dest_fileitem)\n            StorageChain().delete_file(dest_fileitem)\n\n        # 强制整理\n        if history.src_fileitem:\n            state, errmsg = self.do_transfer(fileitem=FileItem(**history.src_fileitem),\n                                             mediainfo=mediainfo,\n                                             download_hash=history.download_hash,\n                                             force=True,\n                                             background=False,\n                                             manual=True)\n            if not state:\n                return False, errmsg\n\n        return True, \"\"\n\n    def manual_transfer(self,\n                        fileitem: FileItem,\n                        target_storage: Optional[str] = None,\n                        target_path: Path = None,\n                        tmdbid: Optional[int] = None,\n                        doubanid: Optional[str] = None,\n                        mtype: MediaType = None,\n                        season: Optional[int] = None,\n                        episode_group: Optional[str] = None,\n                        transfer_type: Optional[str] = None,\n                        epformat: EpisodeFormat = None,\n                        min_filesize: Optional[int] = 0,\n                        scrape: Optional[bool] = None,\n                        library_type_folder: Optional[bool] = None,\n                        library_category_folder: Optional[bool] = None,\n                        force: Optional[bool] = False,\n                        background: Optional[bool] = False,\n                        downloader: Optional[str] = None,\n                        download_hash: Optional[str] = None) -> Tuple[bool, Union[str, list]]:\n        \"\"\"\n        手动整理，支持复杂条件，带进度显示\n        :param fileitem: 文件项\n        :param target_storage: 目标存储\n        :param target_path: 目标路径\n        :param tmdbid: TMDB ID\n        :param doubanid: 豆瓣ID\n        :param mtype: 媒体类型\n        :param season: 季度\n        :param episode_group: 剧集组\n        :param transfer_type: 整理类型\n        :param epformat: 剧集格式\n        :param min_filesize: 最小文件大小(MB)\n        :param scrape: 是否刮削元数据\n        :param library_type_folder: 是否按类型建立目录\n        :param library_category_folder: 是否按类别建立目录\n        :param force: 是否强制整理\n        :param background: 是否后台运行\n        :param downloader: 下载器名称\n        :param download_hash: 下载任务哈希\n        \"\"\"\n        logger.info(f\"手动整理：{fileitem.path} ...\")\n        if tmdbid or doubanid:\n            # 有输入TMDBID时单个识别\n            # 识别媒体信息\n            mediainfo: MediaInfo = MediaChain().recognize_media(tmdbid=tmdbid, doubanid=doubanid,\n                                                                mtype=mtype, episode_group=episode_group)\n            if not mediainfo:\n                return (False,\n                        f\"媒体信息识别失败，tmdbid：{tmdbid}，doubanid：{doubanid}，type: {mtype.value if mtype else None}\")\n            else:\n                # 更新媒体图片\n                self.obtain_images(mediainfo=mediainfo)\n\n            # 开始整理\n            state, errmsg = self.do_transfer(\n                fileitem=fileitem,\n                target_storage=target_storage,\n                target_path=target_path,\n                mediainfo=mediainfo,\n                transfer_type=transfer_type,\n                season=season,\n                epformat=epformat,\n                min_filesize=min_filesize,\n                scrape=scrape,\n                library_type_folder=library_type_folder,\n                library_category_folder=library_category_folder,\n                force=force,\n                background=background,\n                manual=True,\n                downloader=downloader,\n                download_hash=download_hash\n            )\n            if not state:\n                return False, errmsg\n\n            logger.info(f\"{fileitem.path} 整理完成\")\n            return True, \"\"\n        else:\n            # 没有输入TMDBID时，按文件识别\n            state, errmsg = self.do_transfer(fileitem=fileitem,\n                                             target_storage=target_storage,\n                                             target_path=target_path,\n                                             transfer_type=transfer_type,\n                                             season=season,\n                                             epformat=epformat,\n                                             min_filesize=min_filesize,\n                                             scrape=scrape,\n                                             library_type_folder=library_type_folder,\n                                             library_category_folder=library_category_folder,\n                                             force=force,\n                                             background=background,\n                                             manual=True,\n                                             downloader=downloader,\n                                             download_hash=download_hash)\n            return state, errmsg\n\n    def send_transfer_message(self, meta: MetaBase, mediainfo: MediaInfo,\n                              transferinfo: TransferInfo, season_episode: Optional[str] = None,\n                              username: Optional[str] = None):\n        \"\"\"\n        发送入库成功的消息\n        \"\"\"\n        self.post_message(\n            Notification(\n                mtype=NotificationType.Organize,\n                ctype=ContentType.OrganizeSuccess,\n                image=mediainfo.get_message_image(),\n                username=username,\n                link=settings.MP_DOMAIN('#/history')\n            ),\n            meta=meta,\n            mediainfo=mediainfo,\n            transferinfo=transferinfo,\n            season_episode=season_episode,\n            username=username\n        )\n\n    @staticmethod\n    def _is_blocked_by_exclude_words(file_path: str, exclude_words: list) -> bool:\n        \"\"\"\n        检查文件是否被整理屏蔽词阻止处理\n        :param file_path: 文件路径\n        :param exclude_words: 整理屏蔽词列表\n        :return: 如果被屏蔽返回True，否则返回False\n        \"\"\"\n        if not exclude_words:\n            return False\n\n        for keyword in exclude_words:\n            if keyword and re.search(r\"%s\" % keyword, file_path, re.IGNORECASE):\n                logger.warn(f\"{file_path} 命中屏蔽词 {keyword}\")\n                return True\n        return False\n\n    def _can_delete_torrent(self, download_hash: str, downloader: str, transfer_exclude_words) -> bool:\n        \"\"\"\n        检查是否可以删除种子文件\n        :param download_hash: 种子Hash\n        :param downloader: 下载器名称\n        :param transfer_exclude_words: 整理屏蔽词\n        :return: 如果可以删除返回True，否则返回False\n        \"\"\"\n        try:\n            # 获取种子信息\n            torrents = self.list_torrents(hashs=download_hash, downloader=downloader)\n            if not torrents:\n                return False\n\n            # 未下载完成\n            if torrents[0].progress < 100:\n                return False\n\n            # 获取种子文件列表\n            torrent_files = self.torrent_files(download_hash, downloader)\n            if not torrent_files:\n                return False\n\n            if not isinstance(torrent_files, list):\n                torrent_files = torrent_files.data\n\n            # 检查是否有媒体文件未被屏蔽且存在\n            save_path = torrents[0].path.parent\n            for file in torrent_files:\n                file_path = save_path / file.name\n                # 如果存在未被屏蔽的媒体文件，则不删除种子\n                if (file_path.suffix in self._allowed_exts\n                        and not self._is_blocked_by_exclude_words(file_path.as_posix(), transfer_exclude_words)\n                        and file_path.exists()):\n                    return False\n\n            # 所有媒体文件都被屏蔽或不存在，可以删除种子\n            return True\n\n        except Exception as e:\n            logger.error(f\"检查种子 {download_hash} 是否需要删除失败：{e}\")\n            return False\n"
  },
  {
    "path": "app/chain/tvdb.py",
    "content": "from typing import List\n\nfrom app.chain import ChainBase\n\n\nclass TvdbChain(ChainBase):\n    \"\"\"\n    Tvdb处理链，单例运行\n    \"\"\"\n\n    def get_tvdbid_by_name(self, title: str) -> List[int]:\n        tvdb_info_list = self.run_module(\"search_tvdb\", title=title)\n        return [int(item[\"tvdb_id\"]) for item in tvdb_info_list]\n"
  },
  {
    "path": "app/chain/user.py",
    "content": "import secrets\nfrom typing import Optional, Tuple, Union\n\nfrom app.chain import ChainBase\nfrom app.core.config import settings\nfrom app.core.security import get_password_hash, verify_password\nfrom app.db.models.user import User\nfrom app.db.user_oper import UserOper\nfrom app.log import logger\nfrom app.schemas import AuthCredentials, AuthInterceptCredentials\nfrom app.schemas.types import ChainEventType\nfrom app.utils.otp import OtpUtils\n\nPASSWORD_INVALID_CREDENTIALS_MESSAGE = \"用户名或密码或二次校验码不正确\"\n\n\nclass UserChain(ChainBase):\n    \"\"\"\n    用户链，处理多种认证协议\n    \"\"\"\n\n    def user_authenticate(\n            self,\n            username: Optional[str] = None,\n            password: Optional[str] = None,\n            mfa_code: Optional[str] = None,\n            code: Optional[str] = None,\n            grant_type: Optional[str] = \"password\"\n    ) -> Union[Tuple[bool, Optional[str]], Tuple[bool, Optional[User]]]:\n        \"\"\"\n        认证用户，根据不同的 grant_type 处理不同的认证流程\n\n        :param username: 用户名，适用于 \"password\" grant_type\n        :param password: 用户密码，适用于 \"password\" grant_type\n        :param mfa_code: 一次性密码，适用于 \"password\" grant_type\n        :param code: 授权码，适用于 \"authorization_code\" grant_type\n        :param grant_type: 认证类型，如 \"password\", \"authorization_code\", \"client_credentials\"\n        :return:\n            - 对于成功的认证，返回 (True, User)\n            - 对于失败的认证，返回 (False, \"错误信息\")\n        \"\"\"\n        credentials = AuthCredentials(\n            username=username,\n            password=password,\n            mfa_code=mfa_code,\n            code=code,\n            grant_type=grant_type\n        )\n        logger.debug(f\"认证类型：{grant_type}，开始准备对用户 {username} 进行身份校验\")\n        if credentials.grant_type == \"password\":\n            # Password 认证\n            success, user_or_message = self.password_authenticate(credentials=credentials)\n            if success:\n                # 如果用户启用了二次验证码，则进一步验证\n                mfa_result = self._verify_mfa(user_or_message, credentials.mfa_code)\n                if mfa_result == \"MFA_REQUIRED\":\n                    return False, \"MFA_REQUIRED\"\n                elif not mfa_result:\n                    return False, PASSWORD_INVALID_CREDENTIALS_MESSAGE\n                logger.info(f\"用户 {username} 通过密码认证成功\")\n                return True, user_or_message\n            else:\n                # 用户不存在或密码错误，考虑辅助认证\n                if settings.AUXILIARY_AUTH_ENABLE:\n                    logger.warning(\"密码认证失败，尝试通过外部服务进行辅助认证 ...\")\n                    aux_success, aux_user_or_message = self.auxiliary_authenticate(credentials=credentials)\n                    if aux_success:\n                        # 辅助认证成功后再验证二次验证码\n                        mfa_result = self._verify_mfa(aux_user_or_message, credentials.mfa_code)\n                        if mfa_result == \"MFA_REQUIRED\":\n                            return False, \"MFA_REQUIRED\"\n                        elif not mfa_result:\n                            return False, PASSWORD_INVALID_CREDENTIALS_MESSAGE\n                        return True, aux_user_or_message\n                    else:\n                        return False, PASSWORD_INVALID_CREDENTIALS_MESSAGE\n                else:\n                    logger.debug(f\"辅助认证未启用，用户 {username} 认证失败\")\n                    return False, PASSWORD_INVALID_CREDENTIALS_MESSAGE\n        elif credentials.grant_type == \"authorization_code\":\n            # 处理其他认证类型的分支\n            if settings.AUXILIARY_AUTH_ENABLE:\n                aux_success, aux_user_or_message = self.auxiliary_authenticate(credentials=credentials)\n                if aux_success:\n                    return True, aux_user_or_message\n                else:\n                    return False, \"认证失败\"\n            else:\n                return False, \"认证失败\"\n        else:\n            logger.debug(f\"辅助认证未启用，认证类型 {grant_type} 未实现\")\n            return False, \"不支持的认证类型\"\n\n    @staticmethod\n    def password_authenticate(credentials: AuthCredentials) -> Tuple[bool, Union[User, str]]:\n        \"\"\"\n        密码认证\n\n        :param credentials: 认证凭证，包含用户名、密码以及可选的 MFA 认证码\n        :return:\n            - 成功时返回 (True, User)，其中 User 是认证通过的用户对象\n            - 失败时返回 (False, \"错误信息\")\n        \"\"\"\n        if not credentials or credentials.grant_type != \"password\":\n            logger.info(\"密码认证失败，认证类型不匹配\")\n            return False, PASSWORD_INVALID_CREDENTIALS_MESSAGE\n\n        user = UserOper().get_by_name(name=credentials.username)\n        if not user:\n            logger.info(f\"密码认证失败，用户 {credentials.username} 不存在\")\n            return False, PASSWORD_INVALID_CREDENTIALS_MESSAGE\n\n        if not user.is_active:\n            logger.info(f\"密码认证失败，用户 {credentials.username} 已被禁用\")\n            return False, PASSWORD_INVALID_CREDENTIALS_MESSAGE\n\n        if not verify_password(credentials.password, str(user.hashed_password)):\n            logger.info(f\"密码认证失败，用户 {credentials.username} 的密码验证不通过\")\n            return False, PASSWORD_INVALID_CREDENTIALS_MESSAGE\n\n        return True, user\n\n    def auxiliary_authenticate(self, credentials: AuthCredentials) -> Tuple[bool, Union[User, str]]:\n        \"\"\"\n        辅助用户认证\n\n        :param credentials: 认证凭证，包含必要的认证信息\n        :return:\n            - 成功时返回 (True, User)，其中 User 是认证通过的用户对象\n            - 失败时返回 (False, \"错误信息\")\n        \"\"\"\n        if not credentials:\n            return False, \"认证凭证无效\"\n\n        # 检查是否因为用户被禁用\n        useroper = UserOper()\n        if credentials.username:\n            user = useroper.get_by_name(name=credentials.username)\n            if user and not user.is_active:\n                logger.info(f\"用户 {user.name} 已被禁用，跳过后续身份校验\")\n                return False, PASSWORD_INVALID_CREDENTIALS_MESSAGE\n\n        logger.debug(f\"认证类型：{credentials.grant_type}，尝试通过系统模块进行辅助认证，用户: {credentials.username}\")\n        result = self.run_module(\"user_authenticate\", credentials=credentials)\n\n        if not result:\n            logger.debug(f\"通过系统模块辅助认证失败，尝试触发 {ChainEventType.AuthVerification} 事件\")\n            event = self.eventmanager.send_event(etype=ChainEventType.AuthVerification, data=credentials)\n            if not event or not event.event_data:\n                logger.error(f\"认证类型：{credentials.grant_type}，辅助认证失败，未返回有效数据\")\n                return False, f\"认证类型：{credentials.grant_type}，辅助认证事件失败或无效\"\n\n            credentials = event.event_data  # 使用事件返回的认证数据\n        else:\n            logger.info(f\"通过系统模块辅助认证成功，用户: {credentials.username}\")\n            credentials = result  # 使用模块认证返回的认证数据\n\n        # 处理认证成功的逻辑\n        success = self._process_auth_success(username=credentials.username, credentials=credentials)\n        if success:\n            logger.info(f\"用户 {credentials.username} 辅助认证通过\")\n            return True, useroper.get_by_name(credentials.username)\n        else:\n            logger.warning(f\"用户 {credentials.username} 辅助认证未通过\")\n            return False, PASSWORD_INVALID_CREDENTIALS_MESSAGE\n\n    @staticmethod\n    def _verify_mfa(user: User, mfa_code: Optional[str]) -> Union[bool, str]:\n        \"\"\"\n        验证 MFA（二次验证码）\n        检查用户是否启用了 OTP 或 PassKey，如果启用了任何一种，都需要提供验证\n\n        :param user: 用户对象\n        :param mfa_code: 二次验证码（如果提供了则验证OTP）\n        :return: \n            - 如果验证成功返回 True\n            - 如果需要MFA但未提供，返回 \"MFA_REQUIRED\"\n            - 如果MFA验证失败，返回 False\n        \"\"\"\n        # 检查用户是否有PassKey\n        from app.db.models.passkey import PassKey\n        has_passkey = bool(PassKey.get_by_user_id(db=None, user_id=user.id))\n        \n        # 如果用户既没有启用OTP也没有PassKey，直接通过\n        if not user.is_otp and not has_passkey:\n            return True\n        \n        # 如果用户启用了OTP或PassKey，但没有提供验证码，需要进行二次验证\n        if not mfa_code:\n            logger.info(f\"用户 {user.name} 已启用双重验证（OTP: {user.is_otp}, PassKey: {has_passkey}），需要提供验证码\")\n            return \"MFA_REQUIRED\"\n        \n        # 如果提供了验证码，且用户启用了 OTP，则验证 OTP\n        if user.is_otp:\n            if not OtpUtils.check(str(user.otp_secret), mfa_code):\n                logger.info(f\"用户 {user.name} 的 MFA 认证失败\")\n                return False\n            # OTP 验证成功\n            return True\n\n        # 用户未启用 OTP，此时提供的 mfa_code 无效；如果启用了 PassKey，则仍需通过 PassKey 验证\n        if has_passkey:\n            logger.info(\n                f\"用户 {user.name} 未启用 OTP，但已启用 PassKey，提供的 MFA 验证码将被忽略，仍需通过 PassKey 验证\"\n            )\n            return \"MFA_REQUIRED\"\n        \n        return True\n\n    def _process_auth_success(self, username: str, credentials: AuthCredentials) -> bool:\n        \"\"\"\n        处理辅助认证成功的逻辑，返回用户对象或创建新用户\n\n        :param username: 用户名\n        :param credentials: 认证凭证，包含 token、channel、service 等信息\n        :return:\n            - 如果认证成功并且用户存在或已创建，返回 User 对象\n            - 如果认证被拦截或失败，返回 None\n        \"\"\"\n        if not username:\n            logger.info(f\"未能获取到对应的用户信息，{credentials.grant_type} 认证不通过\")\n            return False\n\n        token, channel, service = credentials.token, credentials.channel, credentials.service\n        if not all([token, channel, service]):\n            logger.info(f\"用户 {username} 未通过 {credentials.grant_type} 认证，必要信息不足\")\n            return False\n\n        # 触发认证通过的拦截事件\n        intercept_event = self.eventmanager.send_event(\n            etype=ChainEventType.AuthIntercept,\n            data=AuthInterceptCredentials(username=username, channel=channel, service=service,\n                                          token=token, status=\"completed\")\n        )\n        if intercept_event and intercept_event.event_data:\n            intercept_data: AuthInterceptCredentials = intercept_event.event_data\n            if intercept_data.cancel:\n                logger.warning(\n                    f\"认证被拦截，用户：{username}，渠道：{channel}，服务：{service}，拦截源：{intercept_data.source}\")\n                return False\n\n        # 检查用户是否存在，如果不存在且当前为密码认证时则创建新用户\n        useroper = UserOper()\n        user = useroper.get_by_name(name=username)\n        if user:\n            # 如果用户存在，但是已经被禁用，则直接响应\n            if not user.is_active:\n                logger.info(f\"辅助认证失败，用户 {username} 已被禁用\")\n                return False\n            anonymized_token = f\"{token[:len(token) // 2]}********\"\n            logger.info(\n                f\"认证类型：{credentials.grant_type}，用户：{username}，渠道：{channel}，\"\n                f\"服务：{service} 认证成功，token：{anonymized_token}\")\n            return True\n        else:\n            if credentials.grant_type == \"password\":\n                useroper.add(name=username, is_active=True, is_superuser=False,\n                             hashed_password=get_password_hash(secrets.token_urlsafe(16)))\n                logger.info(f\"用户 {username} 不存在，已通过 {credentials.grant_type} 认证并已创建普通用户\")\n                return True\n            else:\n                logger.warning(\n                    f\"认证类型：{credentials.grant_type}，用户：{username}，渠道：{channel}，\"\n                    f\"服务：{service} 认证不通过，未能在本地找到对应的用户信息\")\n                return False\n"
  },
  {
    "path": "app/chain/webhook.py",
    "content": "from typing import Any\n\nfrom app.chain import ChainBase\nfrom app.schemas.types import EventType\n\n\nclass WebhookChain(ChainBase):\n    \"\"\"\n    Webhook处理链\n    \"\"\"\n\n    def message(self, body: Any, form: Any, args: Any) -> None:\n        \"\"\"\n        处理Webhook报文并发送事件\n        \"\"\"\n        # 获取主体内容\n        event_info = self.webhook_parser(body=body, form=form, args=args)\n        if not event_info:\n            return\n        # 广播事件\n        self.eventmanager.send_event(EventType.WebhookMessage, event_info)\n"
  },
  {
    "path": "app/chain/workflow.py",
    "content": "import base64\nimport pickle\nimport threading\nfrom collections import defaultdict, deque\nfrom concurrent.futures import ThreadPoolExecutor\nfrom time import sleep\nfrom typing import List, Tuple, Optional\n\nfrom pydantic.fields import Callable\n\nfrom app.chain import ChainBase\nfrom app.core.config import global_vars\nfrom app.core.event import Event, eventmanager\nfrom app.workflow import WorkFlowManager\nfrom app.db.models import Workflow\nfrom app.db.workflow_oper import WorkflowOper\nfrom app.log import logger\nfrom app.schemas import ActionContext, ActionFlow, Action, ActionExecution\nfrom app.schemas.types import EventType\n\n\nclass WorkflowExecutor:\n    \"\"\"\n    工作流执行器\n    \"\"\"\n\n    def __init__(self, workflow: Workflow, step_callback: Callable = None):\n        \"\"\"\n        初始化工作流执行器\n        :param workflow: 工作流对象\n        :param step_callback: 步骤回调函数\n        \"\"\"\n        # 工作流数据\n        self.workflow = workflow\n        self.step_callback = step_callback\n        self.actions = {action['id']: Action(**action) for action in workflow.actions}\n        self.flows = [ActionFlow(**flow) for flow in workflow.flows]\n        self.total_actions = len(self.actions)\n        self.finished_actions = 0\n\n        self.success = True\n        self.errmsg = \"\"\n\n        # 工作流管理器\n        self.workflowmanager = WorkFlowManager()\n        # 线程安全队列\n        self.queue = deque()\n        # 锁用于保证线程安全\n        self.lock = threading.Lock()\n        # 线程池\n        self.executor = ThreadPoolExecutor()\n        # 跟踪运行中的任务数\n        self.running_tasks = 0\n\n        # 构建邻接表、入度表\n        self.adjacency = defaultdict(list)\n        self.indegree = defaultdict(int)\n        for flow in self.flows:\n            source = flow.source\n            target = flow.target\n            self.adjacency[source].append(target)\n            self.indegree[target] += 1\n\n        # 初始化所有节点的入度（确保未被引用的节点入度为0）\n        for action_id in self.actions:\n            if action_id not in self.indegree:\n                self.indegree[action_id] = 0\n\n        # 初始上下文\n        if workflow.current_action and workflow.context:\n            logger.info(f\"工作流已执行动作：{workflow.current_action}\")\n            # Base64解码\n            decoded_data = base64.b64decode(workflow.context[\"content\"])\n            # 反序列化数据\n            self.context = pickle.loads(decoded_data)\n        else:\n            self.context = ActionContext()\n\n        # 恢复工作流\n        global_vars.workflow_resume(self.workflow.id)\n        # 初始化队列，添加入度为0的节点\n        for action_id in self.actions:\n            if self.indegree[action_id] == 0:\n                self.queue.append(action_id)\n\n    def execute(self):\n        \"\"\"\n        执行工作流\n        \"\"\"\n        while True:\n            with self.lock:\n                # 退出条件：队列为空且无运行任务\n                if not self.queue and self.running_tasks == 0:\n                    break\n                # 退出条件：出现了错误\n                if not self.success:\n                    break\n                if not self.queue:\n                    sleep(0.1)\n                    continue\n                # 取出队首节点\n                node_id = self.queue.popleft()\n                # 标记任务开始\n                self.running_tasks += 1\n\n            # 已停机\n            if global_vars.is_workflow_stopped(self.workflow.id):\n                global_vars.workflow_resume(self.workflow.id)\n                break\n\n            # 已执行的跳过\n            if (self.workflow.current_action\n                    and node_id in self.workflow.current_action.split(',')):\n                continue\n\n            # 提交任务到线程池\n            future = self.executor.submit(\n                self.execute_node,\n                self.workflow.id,\n                node_id,\n                self.context\n            )\n            future.add_done_callback(self.on_node_complete)\n\n    def execute_node(self, workflow_id: int, node_id: int,\n                     context: ActionContext) -> Tuple[Action, bool, str, ActionContext]:\n        \"\"\"\n        执行单个节点操作，返回修改后的上下文和节点ID\n        \"\"\"\n        action = self.actions[node_id]\n        state, message, result_ctx = self.workflowmanager.excute(workflow_id, action, context=context)\n        return action, state, message, result_ctx\n\n    def on_node_complete(self, future):\n        \"\"\"\n        节点完成回调：更新上下文、处理后继节点\n        \"\"\"\n        action, state, message, result_ctx = future.result()\n\n        try:\n            self.finished_actions += 1\n            # 更新当前进度\n            self.context.progress = round(self.finished_actions / self.total_actions) * 100\n\n            # 补充执行历史\n            self.context.execute_history.append(\n                ActionExecution(\n                    action=action.name,\n                    result=state,\n                    message=message\n                )\n            )\n\n            # 节点执行失败\n            if not state:\n                self.success = False\n                self.errmsg = f\"{action.name} 失败\"\n                return\n\n            with self.lock:\n                # 更新主上下文\n                self.merge_context(result_ctx)\n                # 回调\n                if self.step_callback:\n                    self.step_callback(action, self.context)\n\n            # 处理后继节点\n            successors = self.adjacency.get(action.id, [])\n            for succ_id in successors:\n                with self.lock:\n                    self.indegree[succ_id] -= 1\n                    if self.indegree[succ_id] == 0:\n                        self.queue.append(succ_id)\n        finally:\n            # 标记任务完成\n            with self.lock:\n                self.running_tasks -= 1\n\n    def merge_context(self, context: ActionContext):\n        \"\"\"\n        合并上下文\n        \"\"\"\n        for key, value in context.model_dump().items():\n            if not getattr(self.context, key, None):\n                setattr(self.context, key, value)\n\n\nclass WorkflowChain(ChainBase):\n    \"\"\"\n    工作流链\n    \"\"\"\n\n    @eventmanager.register(EventType.WorkflowExecute)\n    def event_process(self, event: Event):\n        \"\"\"\n        事件触发工作流执行\n        \"\"\"\n        workflow_id = event.event_data.get('workflow_id')\n        if not workflow_id:\n            return\n        self.process(workflow_id, from_begin=False)\n\n    @staticmethod\n    def process(workflow_id: int, from_begin: Optional[bool] = True) -> Tuple[bool, str]:\n        \"\"\"\n        处理工作流\n        :param workflow_id: 工作流ID\n        :param from_begin: 是否从头开始，默认为True\n        \"\"\"\n        workflowoper = WorkflowOper()\n\n        def save_step(action: Action, context: ActionContext):\n            \"\"\"\n            保存上下文到数据库\n            \"\"\"\n            # 序列化数据\n            serialized_data = pickle.dumps(context)\n            # 使用Base64编码字节流\n            encoded_data = base64.b64encode(serialized_data).decode('utf-8')\n            workflowoper.step(workflow_id, action_id=action.id, context={\n                \"content\": encoded_data\n            })\n\n        # 重置工作流\n        if from_begin:\n            workflowoper.reset(workflow_id)\n\n        # 查询工作流数据\n        workflow = workflowoper.get(workflow_id)\n        if not workflow:\n            logger.warn(f\"工作流 {workflow_id} 不存在\")\n            return False, \"工作流不存在\"\n        if not workflow.actions:\n            logger.warn(f\"工作流 {workflow.name} 无动作\")\n            return False, \"工作流无动作\"\n        if not workflow.flows:\n            logger.warn(f\"工作流 {workflow.name} 无流程\")\n            return False, \"工作流无流程\"\n\n        logger.info(f\"开始执行工作流 {workflow.name}，共 {len(workflow.actions)} 个动作 ...\")\n        workflowoper.start(workflow_id)\n\n        # 执行工作流\n        executor = WorkflowExecutor(workflow, step_callback=save_step)\n        executor.execute()\n\n        if not executor.success:\n            logger.info(f\"工作流 {workflow.name} 执行失败：{executor.errmsg}\")\n            workflowoper.fail(workflow_id, result=executor.errmsg)\n            return False, executor.errmsg\n        else:\n            logger.info(f\"工作流 {workflow.name} 执行完成\")\n            workflowoper.success(workflow_id)\n            return True, \"\"\n\n    @staticmethod\n    def get_workflows() -> List[Workflow]:\n        \"\"\"\n        获取工作流列表\n        \"\"\"\n        return WorkflowOper().list_enabled()\n\n    @staticmethod\n    def get_timer_workflows() -> List[Workflow]:\n        \"\"\"\n        获取定时触发的工作流列表\n        \"\"\"\n        return WorkflowOper().get_timer_triggered_workflows()\n\n    @staticmethod\n    def get_event_workflows() -> List[Workflow]:\n        \"\"\"\n        获取事件触发的工作流列表\n        \"\"\"\n        return WorkflowOper().get_event_triggered_workflows()\n"
  },
  {
    "path": "app/command.py",
    "content": "import copy\nimport threading\nimport traceback\nfrom typing import Any, Union, Dict, Optional\n\nfrom app.chain import ChainBase\nfrom app.chain.download import DownloadChain\nfrom app.chain.message import MessageChain\nfrom app.chain.site import SiteChain\nfrom app.chain.subscribe import SubscribeChain\nfrom app.chain.system import SystemChain\nfrom app.chain.transfer import TransferChain\nfrom app.core.event import Event as ManagerEvent, eventmanager, Event\nfrom app.core.plugin import PluginManager\nfrom app.helper.message import MessageHelper\nfrom app.helper.thread import ThreadHelper\nfrom app.log import logger\nfrom app.scheduler import Scheduler\nfrom app.schemas import Notification, CommandRegisterEventData\nfrom app.schemas.types import EventType, MessageChannel, ChainEventType\nfrom app.utils.object import ObjectUtils\nfrom app.utils.singleton import Singleton\nfrom app.utils.structures import DictUtils\n\n\nclass CommandChain(ChainBase):\n    pass\n\n\nclass Command(metaclass=Singleton):\n    \"\"\"\n    全局命令管理，消费事件\n    \"\"\"\n\n    def __init__(self):\n        # 插件管理器\n        super().__init__()\n        # 注册的命令集合\n        self._registered_commands = {}\n        # 所有命令集合\n        self._commands = {}\n        # 内建命令集合\n        self._preset_commands = {\n            \"/cookiecloud\": {\n                \"id\": \"cookiecloud\",\n                \"type\": \"scheduler\",\n                \"description\": \"同步站点\",\n                \"category\": \"站点\"\n            },\n            \"/sites\": {\n                \"func\": SiteChain().remote_list,\n                \"description\": \"查询站点\",\n                \"category\": \"站点\",\n                \"data\": {}\n            },\n            \"/site_cookie\": {\n                \"func\": SiteChain().remote_cookie,\n                \"description\": \"更新站点Cookie\",\n                \"data\": {}\n            },\n            \"/site_statistic\": {\n                \"func\": SiteChain().remote_refresh_userdatas,\n                \"description\": \"站点数据统计\",\n                \"data\": {}\n            },\n            \"/site_enable\": {\n                \"func\": SiteChain().remote_enable,\n                \"description\": \"启用站点\",\n                \"data\": {}\n            },\n            \"/site_disable\": {\n                \"func\": SiteChain().remote_disable,\n                \"description\": \"禁用站点\",\n                \"data\": {}\n            },\n            \"/mediaserver_sync\": {\n                \"id\": \"mediaserver_sync\",\n                \"type\": \"scheduler\",\n                \"description\": \"同步媒体服务器\",\n                \"category\": \"管理\"\n            },\n            \"/subscribes\": {\n                \"func\": SubscribeChain().remote_list,\n                \"description\": \"查询订阅\",\n                \"category\": \"订阅\",\n                \"data\": {}\n            },\n            \"/subscribe_refresh\": {\n                \"id\": \"subscribe_refresh\",\n                \"type\": \"scheduler\",\n                \"description\": \"刷新订阅\",\n                \"category\": \"订阅\"\n            },\n            \"/subscribe_search\": {\n                \"id\": \"subscribe_search\",\n                \"type\": \"scheduler\",\n                \"description\": \"搜索订阅\",\n                \"category\": \"订阅\"\n            },\n            \"/subscribe_delete\": {\n                \"func\": SubscribeChain().remote_delete,\n                \"description\": \"删除订阅\",\n                \"data\": {}\n            },\n            \"/subscribe_tmdb\": {\n                \"id\": \"subscribe_tmdb\",\n                \"type\": \"scheduler\",\n                \"description\": \"订阅元数据更新\"\n            },\n            \"/downloading\": {\n                \"func\": DownloadChain().remote_downloading,\n                \"description\": \"正在下载\",\n                \"category\": \"管理\",\n                \"data\": {}\n            },\n            \"/transfer\": {\n                \"id\": \"transfer\",\n                \"type\": \"scheduler\",\n                \"description\": \"下载文件整理\",\n                \"category\": \"管理\"\n            },\n            \"/redo\": {\n                \"func\": TransferChain().remote_transfer,\n                \"description\": \"手动整理\",\n                \"data\": {}\n            },\n            \"/clear_cache\": {\n                \"func\": SystemChain().remote_clear_cache,\n                \"description\": \"清理缓存\",\n                \"category\": \"管理\",\n                \"data\": {}\n            },\n            \"/restart\": {\n                \"func\": SystemChain().restart,\n                \"description\": \"重启系统\",\n                \"category\": \"管理\",\n                \"data\": {}\n            },\n            \"/version\": {\n                \"func\": SystemChain().version,\n                \"description\": \"当前版本\",\n                \"category\": \"管理\",\n                \"data\": {}\n            },\n            \"/clear_session\": {\n                \"func\": MessageChain().remote_clear_session,\n                \"description\": \"清除会话\",\n                \"category\": \"管理\",\n                \"data\": {}\n            }\n        }\n        # 插件命令集合\n        self._plugin_commands = {}\n        # 其他命令集合\n        self._other_commands = {}\n        # 初始化锁\n        self._rlock = threading.RLock()\n        # 插件管理\n        self.pluginmanager = PluginManager()\n        # 定时服务管理\n        self.scheduler = Scheduler()\n        # 消息管理器\n        self.messagehelper = MessageHelper()\n        # 初始化命令\n        self.init_commands()\n\n    def init_commands(self, pid: Optional[str] = None) -> None:\n        \"\"\"\n        初始化菜单命令\n        \"\"\"\n        # 使用线程池提交后台任务，避免引起阻塞\n        ThreadHelper().submit(self.__init_commands_background, pid)\n\n    def __init_commands_background(self, pid: Optional[str] = None) -> None:\n        \"\"\"\n        后台初始化菜单命令\n        \"\"\"\n        try:\n            with self._rlock:\n                logger.debug(\"Acquired lock for initializing commands in background.\")\n                self._plugin_commands = self.__build_plugin_commands(pid)\n                self._commands = {\n                    **self._preset_commands,\n                    **self._plugin_commands,\n                    **self._other_commands\n                }\n\n                # 强制触发注册\n                force_register = False\n                # 触发事件允许可以拦截和调整命令\n                event, initial_commands = self.__trigger_register_commands_event()\n\n                if event and event.event_data:\n                    # 如果事件返回有效的 event_data，使用事件中调整后的命令\n                    event_data: CommandRegisterEventData = event.event_data\n                    # 如果事件被取消，跳过命令注册\n                    if event_data.cancel:\n                        logger.debug(f\"Command initialization canceled by event: {event_data.source}\")\n                        return\n                    # 如果拦截源与插件标识一致时，这里认为需要强制触发注册\n                    if pid is not None and pid == event_data.source:\n                        force_register = True\n                    initial_commands = event_data.commands or {}\n                    logger.debug(f\"Registering command count from event: {len(initial_commands)}\")\n                else:\n                    logger.debug(f\"Registering initial command count: {len(initial_commands)}\")\n\n                # initial_commands 必须是 self._commands 的子集\n                filtered_initial_commands = DictUtils.filter_keys_to_subset(initial_commands, self._commands)\n                # 如果 filtered_initial_commands 为空，则跳过注册\n                if not filtered_initial_commands and not force_register:\n                    logger.debug(\"Filtered commands are empty, skipping registration.\")\n                    return\n\n                # 对比调整后的命令与当前命令\n                if filtered_initial_commands != self._registered_commands or force_register:\n                    logger.debug(\"Command set has changed or force registration is enabled.\")\n                    self._registered_commands = filtered_initial_commands\n                    CommandChain().register_commands(commands=filtered_initial_commands)\n                else:\n                    logger.debug(\"Command set unchanged, skipping broadcast registration.\")\n        except Exception as e:\n            logger.error(f\"Error occurred during command initialization in background: {e}\", exc_info=True)\n\n    def __trigger_register_commands_event(self) -> tuple[Optional[Event], dict]:\n        \"\"\"\n        触发事件，允许调整命令数据\n        \"\"\"\n\n        def add_commands(source, command_type):\n            \"\"\"\n            添加命令集合\n            \"\"\"\n            for cmd, command in source.items():\n                if not command.get(\"show\", True):\n                    continue\n\n                command_data = {\n                    \"type\": command_type,\n                    \"description\": command.get(\"description\"),\n                    \"category\": command.get(\"category\")\n                }\n                # 如果有 pid，则添加到命令数据中\n                plugin_id = command.get(\"pid\")\n                if plugin_id:\n                    command_data[\"pid\"] = plugin_id\n                commands[cmd] = command_data\n\n        # 初始化命令字典\n        commands: Dict[str, dict] = {}\n        add_commands(self._preset_commands, \"preset\")\n        add_commands(self._plugin_commands, \"plugin\")\n        add_commands(self._other_commands, \"other\")\n\n        # 触发事件允许可以拦截和调整命令\n        event_data = CommandRegisterEventData(commands=commands, origin=\"CommandChain\", service=None)\n        event = eventmanager.send_event(ChainEventType.CommandRegister, event_data)\n        return event, commands\n\n    def __build_plugin_commands(self, _: Optional[str] = None) -> Dict[str, dict]:\n        \"\"\"\n        构建插件命令\n        \"\"\"\n        # 为了保证命令顺序的一致性，目前这里没有直接使用 pid 获取单一插件命令，后续如果存在性能问题，可以考虑优化这里的逻辑\n        plugin_commands = {}\n        for command in self.pluginmanager.get_plugin_commands():\n            cmd = command.get(\"cmd\")\n            if cmd:\n                plugin_commands[cmd] = {\n                    \"pid\": command.get(\"pid\"),\n                    \"func\": self.send_plugin_event,\n                    \"description\": command.get(\"desc\"),\n                    \"category\": command.get(\"category\"),\n                    \"show\": command.get(\"show\", True),\n                    \"data\": {\n                        \"etype\": command.get(\"event\"),\n                        \"data\": command.get(\"data\")\n                    }\n                }\n        return plugin_commands\n\n    def __run_command(self, command: Dict[str, any], data_str: Optional[str] = \"\",\n                      channel: MessageChannel = None, source: Optional[str] = None, userid: Union[str, int] = None):\n        \"\"\"\n        运行定时服务\n        \"\"\"\n        if command.get(\"type\") == \"scheduler\":\n            # 定时服务\n            if userid:\n                CommandChain().post_message(\n                    Notification(\n                        channel=channel,\n                        source=source,\n                        title=f\"开始执行 {command.get('description')} ...\",\n                        userid=userid\n                    )\n                )\n\n            # 执行定时任务\n            self.scheduler.start(job_id=command.get(\"id\"))\n\n            if userid:\n                CommandChain().post_message(\n                    Notification(\n                        channel=channel,\n                        source=source,\n                        title=f\"{command.get('description')} 执行完成\",\n                        userid=userid\n                    )\n                )\n        else:\n            # 命令\n            cmd_data = copy.deepcopy(command['data']) if command.get('data') else {}\n            args_num = ObjectUtils.arguments(command['func'])\n            if args_num > 0:\n                if cmd_data:\n                    # 有内置参数直接使用内置参数\n                    data = cmd_data.get(\"data\") or {}\n                    data['channel'] = channel\n                    data['source'] = source\n                    data['user'] = userid\n                    if data_str:\n                        data['arg_str'] = data_str\n                    cmd_data['data'] = data\n                    command['func'](**cmd_data)\n                elif args_num == 3:\n                    # 没有输入参数，只输入渠道来源、用户ID和消息来源\n                    command['func'](channel, userid, source)\n                elif args_num > 3:\n                    # 多个输入参数：用户输入、用户ID\n                    command['func'](data_str, channel, userid, source)\n            else:\n                # 没有参数\n                command['func']()\n\n    def get_commands(self):\n        \"\"\"\n        获取命令列表\n        \"\"\"\n        return self._commands\n\n    def get(self, cmd: str) -> Any:\n        \"\"\"\n        获取命令\n        \"\"\"\n        return self._commands.get(cmd, {})\n\n    def register(self, cmd: str, func: Any, data: Optional[dict] = None,\n                 desc: Optional[str] = None, category: Optional[str] = None,\n                 show: bool = True) -> None:\n        \"\"\"\n        注册单个命令\n        \"\"\"\n        # 单独调用的，统一注册到其他\n        self._other_commands[cmd] = {\n            \"func\": func,\n            \"description\": desc,\n            \"category\": category,\n            \"data\": data or {},\n            \"show\": show\n        }\n\n    def execute(self, cmd: str, data_str: Optional[str] = \"\",\n                channel: MessageChannel = None, source: Optional[str] = None,\n                userid: Union[str, int] = None) -> None:\n        \"\"\"\n        执行命令\n        \"\"\"\n        command = self.get(cmd)\n        if command:\n            try:\n                if userid:\n                    logger.info(f\"用户 {userid} 开始执行：{command.get('description')} ...\")\n                else:\n                    logger.info(f\"开始执行：{command.get('description')} ...\")\n\n                # 执行命令\n                self.__run_command(command, data_str=data_str,\n                                   channel=channel, source=source, userid=userid)\n\n                if userid:\n                    logger.info(f\"用户 {userid} {command.get('description')} 执行完成\")\n                else:\n                    logger.info(f\"{command.get('description')} 执行完成\")\n            except Exception as err:\n                logger.error(f\"执行命令 {cmd} 出错：{str(err)} - {traceback.format_exc()}\")\n                self.messagehelper.put(title=f\"执行命令 {cmd} 出错\",\n                                       message=str(err),\n                                       role=\"system\")\n\n    @staticmethod\n    def send_plugin_event(etype: EventType, data: dict) -> None:\n        \"\"\"\n        发送插件命令\n        \"\"\"\n        eventmanager.send_event(etype, data)\n\n    @eventmanager.register(EventType.CommandExcute)\n    def command_event(self, event: ManagerEvent) -> None:\n        \"\"\"\n        注册命令执行事件\n        event_data: {\n            \"cmd\": \"/xxx args\"\n        }\n        \"\"\"\n        # 命令参数\n        event_str = event.event_data.get('cmd')\n        # 消息渠道\n        event_channel = event.event_data.get('channel')\n        # 消息来源\n        event_source = event.event_data.get('source')\n        # 消息用户\n        event_user = event.event_data.get('user')\n        if event_str:\n            cmd = event_str.split()[0]\n            args = \" \".join(event_str.split()[1:])\n            if self.get(cmd):\n                self.execute(cmd=cmd, data_str=args,\n                             channel=event_channel, source=event_source, userid=event_user)\n\n    @eventmanager.register(EventType.ModuleReload)\n    def module_reload_event(self, _: ManagerEvent) -> None:\n        \"\"\"\n        注册模块重载事件\n        \"\"\"\n        # 发生模块重载时，重新注册命令\n        self.init_commands()\n"
  },
  {
    "path": "app/core/__init__.py",
    "content": ""
  },
  {
    "path": "app/core/cache.py",
    "content": "import contextvars\nimport inspect\nimport shutil\nimport tempfile\nimport threading\nfrom abc import ABC, abstractmethod\nfrom contextlib import contextmanager, asynccontextmanager\nfrom functools import wraps\nfrom pathlib import Path\nfrom typing import Any, Dict, Optional, Generator, AsyncGenerator, Tuple, Literal, Union\n\nimport aiofiles\nimport aioshutil\nfrom anyio import Path as AsyncPath\nfrom cachetools import LRUCache as MemoryLRUCache\nfrom cachetools import TTLCache as MemoryTTLCache\nfrom cachetools.keys import hashkey\n\nfrom app.core.config import settings\nfrom app.helper.redis import RedisHelper, AsyncRedisHelper\nfrom app.log import logger\n\n# 默认缓存区\nDEFAULT_CACHE_REGION = \"DEFAULT\"\n# 默认缓存大小\nDEFAULT_CACHE_SIZE = 1024\n# 默认缓存有效期\nDEFAULT_CACHE_TTL = 365 * 24 * 60 * 60\n\n# 上下文变量来控制缓存行为\n_fresh = contextvars.ContextVar('fresh', default=False)\n\n\nclass CacheBackend(ABC):\n    \"\"\"\n    缓存后端基类，定义通用的缓存接口\n    \"\"\"\n\n    def __getitem__(self, key: str) -> Any:\n        \"\"\"\n        获取缓存项，类似 dict[key]\n        \"\"\"\n        value = self.get(key)\n        if value is None:\n            raise KeyError(key)\n        return value\n\n    def __setitem__(self, key: str, value: Any) -> None:\n        \"\"\"\n        设置缓存项，类似 dict[key] = value\n        \"\"\"\n        self.set(key, value)\n\n    def __delitem__(self, key: str) -> None:\n        \"\"\"\n        删除缓存项，类似 del dict[key]\n        \"\"\"\n        if not self.exists(key):\n            raise KeyError(key)\n        self.delete(key)\n\n    def __contains__(self, key: str) -> bool:\n        \"\"\"\n        检查键是否存在，类似 key in dict\n        \"\"\"\n        return self.exists(key)\n\n    def __iter__(self):\n        \"\"\"\n        返回缓存的迭代器，类似 iter(dict)\n        \"\"\"\n        for key, _ in self.items():\n            yield key\n\n    def __len__(self) -> int:\n        \"\"\"\n        返回缓存项的数量，类似 len(dict)\n        \"\"\"\n        return sum(1 for _ in self.items())\n\n    @abstractmethod\n    def set(self, key: str, value: Any, ttl: Optional[int] = None,\n            region: Optional[str] = DEFAULT_CACHE_REGION, **kwargs) -> None:\n        \"\"\"\n        设置缓存\n\n        :param key: 缓存的键\n        :param value: 缓存的值\n        :param ttl: 缓存的存活时间，单位秒\n        :param region: 缓存的区\n        :param kwargs: 其他参数\n        \"\"\"\n        pass\n\n    @abstractmethod\n    def exists(self, key: str, region: Optional[str] = DEFAULT_CACHE_REGION) -> bool:\n        \"\"\"\n        判断缓存键是否存在\n\n        :param key: 缓存的键\n        :param region: 缓存的区\n        :return: 存在返回 True，否则返回 False\n        \"\"\"\n        pass\n\n    @abstractmethod\n    def get(self, key: str, region: Optional[str] = DEFAULT_CACHE_REGION) -> Any:\n        \"\"\"\n        获取缓存\n\n        :param key: 缓存的键\n        :param region: 缓存的区\n        :return: 返回缓存的值，如果缓存不存在返回 None\n        \"\"\"\n        pass\n\n    @abstractmethod\n    def delete(self, key: str, region: Optional[str] = DEFAULT_CACHE_REGION) -> None:\n        \"\"\"\n        删除缓存\n\n        :param key: 缓存的键\n        :param region: 缓存的区\n        \"\"\"\n        pass\n\n    @abstractmethod\n    def clear(self, region: Optional[str] = DEFAULT_CACHE_REGION) -> None:\n        \"\"\"\n        清除指定区域的缓存或全部缓存\n\n        :param region: 缓存的区，为None时清空所有区缓存\n        \"\"\"\n        pass\n\n    @abstractmethod\n    def items(self, region: Optional[str] = DEFAULT_CACHE_REGION) -> Generator[Tuple[str, Any], None, None]:\n        \"\"\"\n        获取指定区域的所有缓存项\n\n        :param region: 缓存的区\n        :return: 返回一个字典，包含所有缓存键值对\n        \"\"\"\n        pass\n\n    def keys(self, region: Optional[str] = DEFAULT_CACHE_REGION) -> Generator[str, None, None]:\n        \"\"\"\n        获取所有缓存键，类似 dict.keys()\n        \"\"\"\n        for key, _ in self.items(region=region):\n            yield key\n\n    def values(self, region: Optional[str] = DEFAULT_CACHE_REGION) -> Generator[Any, None, None]:\n        \"\"\"\n        获取所有缓存值，类似 dict.values()\n        \"\"\"\n        for _, value in self.items(region=region):\n            yield value\n\n    def update(self, other: Dict[str, Any], region: Optional[str] = DEFAULT_CACHE_REGION,\n               ttl: Optional[int] = None, **kwargs) -> None:\n        \"\"\"\n        更新缓存，类似 dict.update()\n        \"\"\"\n        for key, value in other.items():\n            self.set(key, value, ttl=ttl, region=region, **kwargs)\n\n    def pop(self, key: str, default: Any = None, region: Optional[str] = DEFAULT_CACHE_REGION) -> Any:\n        \"\"\"\n        弹出缓存项，类似 dict.pop()\n        \"\"\"\n        value = self.get(key, region=region)\n        if value is not None:\n            self.delete(key, region=region)\n            return value\n        if default is not None:\n            return default\n        raise KeyError(key)\n\n    def popitem(self, region: Optional[str] = DEFAULT_CACHE_REGION) -> Tuple[str, Any]:\n        \"\"\"\n        弹出最后一个缓存项，类似 dict.popitem()\n        \"\"\"\n        items = list(self.items(region=region))\n        if not items:\n            raise KeyError(\"popitem(): cache is empty\")\n        key, value = items[-1]\n        self.delete(key, region=region)\n        return key, value\n\n    def setdefault(self, key: str, default: Any = None, region: Optional[str] = DEFAULT_CACHE_REGION,\n                   ttl: Optional[int] = None, **kwargs) -> Any:\n        \"\"\"\n        设置默认值，类似 dict.setdefault()\n        \"\"\"\n        value = self.get(key, region=region)\n        if value is None:\n            self.set(key, default, ttl=ttl, region=region, **kwargs)\n            return default\n        return value\n\n    @abstractmethod\n    def close(self) -> None:\n        \"\"\"\n        关闭缓存连接\n        \"\"\"\n        pass\n\n    @staticmethod\n    def get_region(region: Optional[str] = None) -> str:\n        \"\"\"\n        获取缓存的区\n        \"\"\"\n        return f\"region:{region}\" if region else \"region:default\"\n\n    @staticmethod\n    def is_redis() -> bool:\n        \"\"\"\n        判断当前缓存后端是否为 Redis\n        \"\"\"\n        return settings.CACHE_BACKEND_TYPE == \"redis\"\n\n\nclass AsyncCacheBackend(CacheBackend):\n    \"\"\"\n    缓存后端基类，定义通用的缓存接口（异步）\n    \"\"\"\n\n    @abstractmethod\n    async def set(self, key: str, value: Any, ttl: Optional[int] = None,\n                  region: Optional[str] = DEFAULT_CACHE_REGION, **kwargs) -> None:\n        \"\"\"\n        设置缓存\n\n        :param key: 缓存的键\n        :param value: 缓存的值\n        :param ttl: 缓存的存活时间，单位秒\n        :param region: 缓存的区\n        :param kwargs: 其他参数\n        \"\"\"\n        pass\n\n    @abstractmethod\n    async def exists(self, key: str, region: Optional[str] = DEFAULT_CACHE_REGION) -> bool:\n        \"\"\"\n        判断缓存键是否存在\n\n        :param key: 缓存的键\n        :param region: 缓存的区\n        :return: 存在返回 True，否则返回 False\n        \"\"\"\n        pass\n\n    @abstractmethod\n    async def get(self, key: str, region: Optional[str] = DEFAULT_CACHE_REGION) -> Any:\n        \"\"\"\n        获取缓存\n\n        :param key: 缓存的键\n        :param region: 缓存的区\n        :return: 返回缓存的值，如果缓存不存在返回 None\n        \"\"\"\n        pass\n\n    @abstractmethod\n    async def delete(self, key: str, region: Optional[str] = DEFAULT_CACHE_REGION) -> None:\n        \"\"\"\n        删除缓存\n\n        :param key: 缓存的键\n        :param region: 缓存的区\n        \"\"\"\n        pass\n\n    @abstractmethod\n    async def clear(self, region: Optional[str] = DEFAULT_CACHE_REGION) -> None:\n        \"\"\"\n        清除指定区域的缓存或全部缓存\n\n        :param region: 缓存的区，为None时清空所有区缓存\n        \"\"\"\n        pass\n\n    @abstractmethod\n    async def items(self, region: Optional[str] = DEFAULT_CACHE_REGION) -> AsyncGenerator[Tuple[str, Any], None]:\n        \"\"\"\n        获取指定区域的所有缓存项\n\n        :param region: 缓存的区\n        :return: 返回一个字典，包含所有缓存键值对\n        \"\"\"\n        pass\n\n    async def keys(self, region: Optional[str] = DEFAULT_CACHE_REGION) -> AsyncGenerator[str, None]:\n        \"\"\"\n        获取所有缓存键，类似 dict.keys()（异步）\n        \"\"\"\n        async for key, _ in self.items(region=region):\n            yield key\n\n    async def values(self, region: Optional[str] = DEFAULT_CACHE_REGION) -> AsyncGenerator[Any, None]:\n        \"\"\"\n        获取所有缓存值，类似 dict.values()（异步）\n        \"\"\"\n        async for _, value in self.items(region=region):\n            yield value\n\n    async def update(self, other: Dict[str, Any], region: Optional[str] = DEFAULT_CACHE_REGION,\n                     ttl: Optional[int] = None, **kwargs) -> None:\n        \"\"\"\n        更新缓存，类似 dict.update()（异步）\n        \"\"\"\n        for key, value in other.items():\n            await self.set(key, value, ttl=ttl, region=region, **kwargs)\n\n    async def pop(self, key: str, default: Any = None, region: Optional[str] = DEFAULT_CACHE_REGION) -> Any:\n        \"\"\"\n        弹出缓存项，类似 dict.pop()（异步）\n        \"\"\"\n        value = await self.get(key, region=region)\n        if value is not None:\n            await self.delete(key, region=region)\n            return value\n        if default is not None:\n            return default\n        raise KeyError(key)\n\n    async def popitem(self, region: Optional[str] = DEFAULT_CACHE_REGION) -> Tuple[str, Any]:\n        \"\"\"\n        弹出最后一个缓存项，类似 dict.popitem()（异步）\n        \"\"\"\n        items = []\n        async for item in self.items(region=region):\n            items.append(item)\n        if not items:\n            raise KeyError(\"popitem(): cache is empty\")\n        key, value = items[-1]\n        await self.delete(key, region=region)\n        return key, value\n\n    async def setdefault(self, key: str, default: Any = None, region: Optional[str] = DEFAULT_CACHE_REGION,\n                         ttl: Optional[int] = None, **kwargs) -> Any:\n        \"\"\"\n        设置默认值，类似 dict.setdefault()（异步）\n        \"\"\"\n        value = await self.get(key, region=region)\n        if value is None:\n            await self.set(key, default, ttl=ttl, region=region, **kwargs)\n            return default\n        return value\n\n    @abstractmethod\n    async def close(self) -> None:\n        \"\"\"\n        关闭缓存连接\n        \"\"\"\n        pass\n\n\nclass MemoryBackend(CacheBackend):\n    \"\"\"\n    基于 `cachetools.TTLCache` 实现的缓存后端\n    \"\"\"\n\n    # 类变量 _region_caches 的互斥锁\n    _lock = threading.Lock()\n    # 存储各个 region 的缓存实例，region -> TTLCache\n    _region_caches: Dict[str, Union[MemoryTTLCache, MemoryLRUCache]] = {}\n\n    def __init__(self, cache_type: Literal['ttl', 'lru'] = 'ttl',\n                 maxsize: Optional[int] = None, ttl: Optional[int] = None):\n        \"\"\"\n        初始化缓存实例\n\n        :param cache_type: 缓存类型，支持 'ttl'（默认）和 'lru'\n        :param maxsize: 缓存的最大条目数\n        :param ttl: 默认缓存存活时间，单位秒\n        \"\"\"\n        self.cache_type = cache_type\n        self.maxsize = maxsize or DEFAULT_CACHE_SIZE\n        self.ttl = ttl or DEFAULT_CACHE_TTL\n\n    def __get_region_cache(self, region: str) -> Optional[Union[MemoryTTLCache, MemoryLRUCache]]:\n        \"\"\"\n        获取指定区域的缓存实例，如果不存在则返回 None\n        \"\"\"\n        region = self.get_region(region)\n        return self._region_caches.get(region)\n\n    def set(self, key: str, value: Any, ttl: Optional[int] = None,\n            region: Optional[str] = DEFAULT_CACHE_REGION, **kwargs) -> None:\n        \"\"\"\n        设置缓存值支持每个 key 独立配置 TTL\n\n        :param key: 缓存的键\n        :param value: 缓存的值\n        :param ttl: 缓存的存活时间，不传入为永久缓存，单位秒\n        :param region: 缓存的区\n        \"\"\"\n        ttl = ttl or self.ttl\n        maxsize = kwargs.get(\"maxsize\", self.maxsize)\n        region = self.get_region(region)\n        # 设置缓存值\n        with self._lock:\n            # 如果该 key 尚未有缓存实例，则创建一个新的 TTLCache 实例\n            region_cache = self._region_caches.setdefault(\n                region,\n                MemoryTTLCache(maxsize=maxsize, ttl=ttl) if self.cache_type == 'ttl'\n                else MemoryLRUCache(maxsize=maxsize)\n            )\n            region_cache[key] = value\n\n    def exists(self, key: str, region: Optional[str] = DEFAULT_CACHE_REGION) -> bool:\n        \"\"\"\n        判断缓存键是否存在\n\n        :param key: 缓存的键\n        :param region: 缓存的区\n        :return: 存在返回 True，否则返回 False\n        \"\"\"\n        region_cache = self.__get_region_cache(region)\n        if region_cache is None:\n            return False\n        return key in region_cache\n\n    def get(self, key: str, region: Optional[str] = DEFAULT_CACHE_REGION) -> Any:\n        \"\"\"\n        获取缓存的值\n\n        :param key: 缓存的键\n        :param region: 缓存的区\n        :return: 返回缓存的值，如果缓存不存在返回 None\n        \"\"\"\n        region_cache = self.__get_region_cache(region)\n        if region_cache is None:\n            return None\n        return region_cache.get(key)\n\n    def delete(self, key: str, region: Optional[str] = DEFAULT_CACHE_REGION):\n        \"\"\"\n        删除缓存\n\n        :param key: 缓存的键\n        :param region: 缓存的区\n        \"\"\"\n        region_cache = self.__get_region_cache(region)\n        if region_cache is None:\n            return\n        with self._lock:\n            del region_cache[key]\n\n    def clear(self, region: Optional[str] = DEFAULT_CACHE_REGION) -> None:\n        \"\"\"\n        清除指定区域的缓存或全部缓存\n\n        :param region: 缓存的区，为None时清空所有区缓存\n        \"\"\"\n        if region:\n            # 清理指定缓存区\n            region_cache = self.__get_region_cache(region)\n            if region_cache:\n                with self._lock:\n                    region_cache.clear()\n                logger.debug(f\"Cleared cache for region: {region}\")\n        else:\n            # 清除所有区域的缓存\n            for region_cache in self._region_caches.values():\n                with self._lock:\n                    region_cache.clear()\n            logger.info(\"Cleared all cache\")\n\n    def items(self, region: Optional[str] = DEFAULT_CACHE_REGION) -> Generator[Tuple[str, Any], None, None]:\n        \"\"\"\n        获取指定区域的所有缓存项\n\n        :param region: 缓存的区\n        :return: 返回一个字典，包含所有缓存键值对\n        \"\"\"\n        region_cache = self.__get_region_cache(region)\n        if region_cache is None:\n            yield from ()\n            return\n        # 使用锁保护迭代过程，避免在迭代时缓存被修改\n        with self._lock:\n            # 创建快照避免并发修改问题\n            items_snapshot = list(region_cache.items())\n        for item in items_snapshot:\n            yield item\n\n    def close(self) -> None:\n        \"\"\"\n        内存缓存不需要关闭资源\n        \"\"\"\n        pass\n\n\nclass AsyncMemoryBackend(AsyncCacheBackend):\n    \"\"\"\n    基于 `cachetools.TTLCache` 实现的异步缓存后端\n    \"\"\"\n\n    def __init__(self, cache_type: Literal['ttl', 'lru'] = 'ttl',\n                 maxsize: Optional[int] = None, ttl: Optional[int] = None):\n        \"\"\"\n        初始化缓存实例\n\n        :param cache_type: 缓存类型，支持 'ttl'（默认）和 'lru'\n        :param maxsize: 缓存的最大条目数\n        :param ttl: 默认缓存存活时间，单位秒\n        \"\"\"\n        self._backend = MemoryBackend(cache_type=cache_type, maxsize=maxsize, ttl=ttl)\n\n    async def set(self, key: str, value: Any, ttl: Optional[int] = None,\n                  region: Optional[str] = DEFAULT_CACHE_REGION, **kwargs) -> None:\n        \"\"\"\n        设置缓存值支持每个 key 独立配置 TTL\n\n        :param key: 缓存的键\n        :param value: 缓存的值\n        :param ttl: 缓存的存活时间，不传入为永久缓存，单位秒\n        :param region: 缓存的区\n        \"\"\"\n        return self._backend.set(key=key, value=value, ttl=ttl, region=region, **kwargs)\n\n    async def exists(self, key: str, region: Optional[str] = DEFAULT_CACHE_REGION) -> bool:\n        \"\"\"\n        判断缓存键是否存在\n\n        :param key: 缓存的键\n        :param region: 缓存的区\n        :return: 存在返回 True，否则返回 False\n        \"\"\"\n        return self._backend.exists(key=key, region=region)\n\n    async def get(self, key: str, region: Optional[str] = DEFAULT_CACHE_REGION) -> Any:\n        \"\"\"\n        获取缓存的值\n\n        :param key: 缓存的键\n        :param region: 缓存的区\n        :return: 返回缓存的值，如果缓存不存在返回 None\n        \"\"\"\n        return self._backend.get(key=key, region=region)\n\n    async def delete(self, key: str, region: Optional[str] = DEFAULT_CACHE_REGION):\n        \"\"\"\n        删除缓存\n\n        :param key: 缓存的键\n        :param region: 缓存的区\n        \"\"\"\n        return self._backend.delete(key=key, region=region)\n\n    async def clear(self, region: Optional[str] = DEFAULT_CACHE_REGION) -> None:\n        \"\"\"\n        清除指定区域的缓存或全部缓存\n\n        :param region: 缓存的区，为None时清空所有区缓存\n        \"\"\"\n        return self._backend.clear(region=region)\n\n    async def items(self, region: Optional[str] = DEFAULT_CACHE_REGION) -> AsyncGenerator[Tuple[str, Any], None]:\n        \"\"\"\n        获取指定区域的所有缓存项\n\n        :param region: 缓存的区\n        :return: 返回一个字典，包含所有缓存键值对\n        \"\"\"\n        for item in self._backend.items(region):\n            yield item\n\n    async def close(self) -> None:\n        \"\"\"\n        内存缓存不需要关闭资源\n        \"\"\"\n        pass\n\n\nclass RedisBackend(CacheBackend):\n    \"\"\"\n    基于 Redis 实现的缓存后端，支持通过 Redis 存储缓存\n    \"\"\"\n\n    def __init__(self, ttl: Optional[int] = None):\n        \"\"\"\n        初始化 Redis 缓存实例\n\n        :param ttl: 缓存的存活时间，单位秒\n        \"\"\"\n        self.ttl = ttl\n        self.redis_helper = RedisHelper()\n\n    def set(self, key: str, value: Any, ttl: Optional[int] = None,\n            region: Optional[str] = DEFAULT_CACHE_REGION, **kwargs) -> None:\n        \"\"\"\n        设置缓存\n\n        :param key: 缓存的键\n        :param value: 缓存的值\n        :param ttl: 缓存的存活时间，未传入则为永久缓存，单位秒\n        :param region: 缓存的区\n        :param kwargs: kwargs\n        \"\"\"\n        ttl = ttl or self.ttl\n        self.redis_helper.set(key, value, ttl=ttl, region=region, **kwargs)\n\n    def exists(self, key: str, region: Optional[str] = DEFAULT_CACHE_REGION) -> bool:\n        \"\"\"\n        判断缓存键是否存在\n\n        :param key: 缓存的键\n        :param region: 缓存的区\n        :return: 存在返回 True，否则返回 False\n        \"\"\"\n        return self.redis_helper.exists(key, region=region)\n\n    def get(self, key: str, region: Optional[str] = DEFAULT_CACHE_REGION) -> Optional[Any]:\n        \"\"\"\n        获取缓存的值\n\n        :param key: 缓存的键\n        :param region: 缓存的区\n        :return: 返回缓存的值，如果缓存不存在返回 None\n        \"\"\"\n        return self.redis_helper.get(key, region=region)\n\n    def delete(self, key: str, region: Optional[str] = DEFAULT_CACHE_REGION) -> None:\n        \"\"\"\n        删除缓存\n\n        :param key: 缓存的键\n        :param region: 缓存的区\n        \"\"\"\n        self.redis_helper.delete(key, region=region)\n\n    def clear(self, region: Optional[str] = DEFAULT_CACHE_REGION) -> None:\n        \"\"\"\n        清除指定区域的缓存或全部缓存\n\n        :param region: 缓存的区，为None时清空所有区缓存\n        \"\"\"\n        self.redis_helper.clear(region=region)\n\n    def items(self, region: Optional[str] = DEFAULT_CACHE_REGION) -> Generator[Tuple[str, Any], None, None]:\n        \"\"\"\n        获取指定区域的所有缓存项\n\n        :param region: 缓存的区\n        :return: 返回一个字典，包含所有缓存键值对\n        \"\"\"\n        return self.redis_helper.items(region=region)\n\n    def close(self) -> None:\n        \"\"\"\n        关闭 Redis 客户端的连接池\n        \"\"\"\n        self.redis_helper.close()\n\n\nclass AsyncRedisBackend(AsyncCacheBackend):\n    \"\"\"\n    基于 Redis 实现的缓存后端，支持通过 Redis 存储缓存\n    \"\"\"\n\n    def __init__(self, ttl: Optional[int] = None):\n        \"\"\"\n        初始化 Redis 缓存实例\n\n        :param ttl: 缓存的存活时间，单位秒\n        \"\"\"\n        self.ttl = ttl\n        self.redis_helper = AsyncRedisHelper()\n\n    async def set(self, key: str, value: Any, ttl: Optional[int] = None,\n                  region: Optional[str] = DEFAULT_CACHE_REGION, **kwargs) -> None:\n        \"\"\"\n        设置缓存\n\n        :param key: 缓存的键\n        :param value: 缓存的值\n        :param ttl: 缓存的存活时间，未传入则为永久缓存，单位秒\n        :param region: 缓存的区\n        :param kwargs: kwargs\n        \"\"\"\n        ttl = ttl or self.ttl\n        await self.redis_helper.set(key, value, ttl=ttl, region=region, **kwargs)\n\n    async def exists(self, key: str, region: Optional[str] = DEFAULT_CACHE_REGION) -> bool:\n        \"\"\"\n        判断缓存键是否存在\n\n        :param key: 缓存的键\n        :param region: 缓存的区\n        :return: 存在返回 True，否则返回 False\n        \"\"\"\n        return await self.redis_helper.exists(key, region=region)\n\n    async def get(self, key: str, region: Optional[str] = DEFAULT_CACHE_REGION) -> Optional[Any]:\n        \"\"\"\n        获取缓存的值\n\n        :param key: 缓存的键\n        :param region: 缓存的区\n        :return: 返回缓存的值，如果缓存不存在返回 None\n        \"\"\"\n        return await self.redis_helper.get(key, region=region)\n\n    async def delete(self, key: str, region: Optional[str] = DEFAULT_CACHE_REGION) -> None:\n        \"\"\"\n        删除缓存\n\n        :param key: 缓存的键\n        :param region: 缓存的区\n        \"\"\"\n        await self.redis_helper.delete(key, region=region)\n\n    async def clear(self, region: Optional[str] = DEFAULT_CACHE_REGION) -> None:\n        \"\"\"\n        清除指定区域的缓存或全部缓存\n\n        :param region: 缓存的区，为None时清空所有区缓存\n        \"\"\"\n        await self.redis_helper.clear(region=region)\n\n    async def items(self, region: Optional[str] = DEFAULT_CACHE_REGION) -> AsyncGenerator[Tuple[str, Any], None]:\n        \"\"\"\n        获取指定区域的所有缓存项\n\n        :param region: 缓存的区\n        :return: 返回一个字典，包含所有缓存键值对\n        \"\"\"\n        async for item in self.redis_helper.items(region=region):\n            yield item\n\n    async def close(self) -> None:\n        \"\"\"\n        关闭 Redis 客户端的连接池\n        \"\"\"\n        await self.redis_helper.close()\n\n\nclass FileBackend(CacheBackend):\n    \"\"\"\n    基于 文件系统 实现的缓存后端\n    \"\"\"\n\n    def __init__(self, base: Path):\n        \"\"\"\n        初始化文件缓存实例\n        \"\"\"\n        self.base = base\n        if not self.base.exists():\n            self.base.mkdir(parents=True, exist_ok=True)\n\n    def set(self, key: str, value: Any, region: Optional[str] = DEFAULT_CACHE_REGION, **kwargs) -> None:\n        \"\"\"\n        设置缓存\n\n        :param key: 缓存的键\n        :param value: 缓存的值\n        :param region: 缓存的区\n        :param kwargs: kwargs\n        \"\"\"\n        cache_path = self.base / region / key\n        # 确保缓存目录存在\n        cache_path.parent.mkdir(parents=True, exist_ok=True)\n        # 将值序列化为字符串存储\n        with tempfile.NamedTemporaryFile(dir=cache_path.parent, delete=False) as tmp_file:\n            tmp_file.write(value)\n            temp_path = Path(tmp_file.name)\n        temp_path.replace(cache_path)\n\n    def exists(self, key: str, region: Optional[str] = DEFAULT_CACHE_REGION) -> bool:\n        \"\"\"\n        判断缓存键是否存在\n\n        :param key: 缓存的键\n        :param region: 缓存的区\n        :return: 存在返回 True，否则返回 False\n        \"\"\"\n        cache_path = self.base / region / key\n        return cache_path.exists()\n\n    def get(self, key: str, region: Optional[str] = DEFAULT_CACHE_REGION) -> Optional[Any]:\n        \"\"\"\n        获取缓存的值\n\n        :param key: 缓存的键\n        :param region: 缓存的区\n        :return: 返回缓存的值，如果缓存不存在返回 None\n        \"\"\"\n        cache_path = self.base / region / key\n        if not cache_path.exists():\n            return None\n        with open(cache_path, 'rb') as f:\n            return f.read()\n\n    def delete(self, key: str, region: Optional[str] = DEFAULT_CACHE_REGION) -> None:\n        \"\"\"\n        删除缓存\n\n        :param key: 缓存的键\n        :param region: 缓存的区\n        \"\"\"\n        cache_path = self.base / region / key\n        if cache_path.exists():\n            cache_path.unlink()\n\n    def clear(self, region: Optional[str] = DEFAULT_CACHE_REGION) -> None:\n        \"\"\"\n        清除指定区域的缓存或全部缓存\n\n        :param region: 缓存的区，为None时清空所有区缓存\n        \"\"\"\n        if region:\n            # 清理指定缓存区\n            cache_path = self.base / region\n            if cache_path.exists():\n                for item in cache_path.iterdir():\n                    if item.is_file():\n                        item.unlink()\n                    else:\n                        shutil.rmtree(item, ignore_errors=True)\n        else:\n            # 清除所有区域的缓存\n            for item in self.base.iterdir():\n                if item.is_file():\n                    item.unlink()\n                else:\n                    shutil.rmtree(item, ignore_errors=True)\n\n    def items(self, region: Optional[str] = DEFAULT_CACHE_REGION) -> Generator[Tuple[str, Any], None, None]:\n        \"\"\"\n        获取指定区域的所有缓存项\n\n        :param region: 缓存的区\n        :return: 返回一个字典，包含所有缓存键值对\n        \"\"\"\n        cache_path = self.base / region\n        if not cache_path.exists():\n            yield from ()\n            return\n        for item in cache_path.iterdir():\n            if item.is_file():\n                with open(item, 'r') as f:\n                    yield item.as_posix(), f.read()\n\n    def close(self) -> None:\n        \"\"\"\n        关闭 Redis 客户端的连接池\n        \"\"\"\n        pass\n\n\nclass AsyncFileBackend(AsyncCacheBackend):\n    \"\"\"\n    基于 文件系统 实现的缓存后端（异步模式）\n    \"\"\"\n\n    def __init__(self, base: Path):\n        \"\"\"\n        初始化文件缓存实例\n        \"\"\"\n        self.base = base\n        if not self.base.exists():\n            self.base.mkdir(parents=True, exist_ok=True)\n\n    async def set(self, key: str, value: Any, region: Optional[str] = DEFAULT_CACHE_REGION, **kwargs) -> None:\n        \"\"\"\n        设置缓存\n\n        :param key: 缓存的键\n        :param value: 缓存的值\n        :param region: 缓存的区\n        :param kwargs: kwargs\n        \"\"\"\n        cache_path = AsyncPath(self.base) / region / key\n        # 确保缓存目录存在\n        await cache_path.parent.mkdir(parents=True, exist_ok=True)\n        # 保存文件\n        async with aiofiles.tempfile.NamedTemporaryFile(dir=cache_path.parent, delete=False) as tmp_file:\n            await tmp_file.write(value)\n            temp_path = AsyncPath(tmp_file.name)\n        await temp_path.replace(cache_path)\n\n    async def exists(self, key: str, region: Optional[str] = DEFAULT_CACHE_REGION) -> bool:\n        \"\"\"\n        判断缓存键是否存在\n\n        :param key: 缓存的键\n        :param region: 缓存的区\n        :return: 存在返回 True，否则返回 False\n        \"\"\"\n        cache_path = AsyncPath(self.base) / region / key\n        return await cache_path.exists()\n\n    async def get(self, key: str, region: Optional[str] = DEFAULT_CACHE_REGION) -> Optional[Any]:\n        \"\"\"\n        获取缓存的值\n\n        :param key: 缓存的键\n        :param region: 缓存的区\n        :return: 返回缓存的值，如果缓存不存在返回 None\n        \"\"\"\n        cache_path = AsyncPath(self.base) / region / key\n        if not await cache_path.exists():\n            return None\n        async with aiofiles.open(cache_path, 'rb') as f:\n            return await f.read()\n\n    async def delete(self, key: str, region: Optional[str] = DEFAULT_CACHE_REGION) -> None:\n        \"\"\"\n        删除缓存\n\n        :param key: 缓存的键\n        :param region: 缓存的区\n        \"\"\"\n        cache_path = AsyncPath(self.base) / region / key\n        if await cache_path.exists():\n            await cache_path.unlink()\n\n    async def clear(self, region: Optional[str] = DEFAULT_CACHE_REGION) -> None:\n        \"\"\"\n        清除指定区域的缓存或全部缓存\n\n        :param region: 缓存的区，为None时清空所有区缓存\n        \"\"\"\n        if region:\n            # 清理指定缓存区\n            cache_path = AsyncPath(self.base) / region\n            if await cache_path.exists():\n                async for item in cache_path.iterdir():\n                    if await item.is_file():\n                        await item.unlink()\n                    else:\n                        await aioshutil.rmtree(item, ignore_errors=True)\n        else:\n            # 清除所有区域的缓存\n            async for item in AsyncPath(self.base).iterdir():\n                if await item.is_file():\n                    await item.unlink()\n                else:\n                    await aioshutil.rmtree(item, ignore_errors=True)\n\n    async def items(self, region: Optional[str] = DEFAULT_CACHE_REGION) -> AsyncGenerator[Tuple[str, Any], None]:\n        \"\"\"\n        获取指定区域的所有缓存项\n\n        :param region: 缓存的区\n        :return: 返回一个字典，包含所有缓存键值对\n        \"\"\"\n        cache_path = AsyncPath(self.base) / region\n        if not await cache_path.exists():\n            yield \"\", None\n            return\n        async for item in cache_path.iterdir():\n            if await item.is_file():\n                async with aiofiles.open(item, 'r') as f:\n                    yield item.as_posix(), await f.read()\n\n    async def close(self) -> None:\n        \"\"\"\n        关闭 Redis 客户端的连接池\n        \"\"\"\n        pass\n\n\n@contextmanager\ndef fresh(fresh: bool = True):\n    \"\"\"\n    是否获取新数据（不使用缓存的值）\n\n    Usage:\n    with fresh():\n        result = some_cached_function()\n    \"\"\"\n    token = _fresh.set(fresh or is_fresh())\n    try:\n        yield\n    finally:\n        _fresh.reset(token)\n\n@asynccontextmanager\nasync def async_fresh(fresh: bool = True):\n    \"\"\"\n    是否获取新数据（不使用缓存的值）\n\n    Usage:\n    async with async_fresh():\n        result = await some_async_cached_function()\n    \"\"\"\n    token = _fresh.set(fresh or is_fresh())\n    try:\n        yield\n    finally:\n        _fresh.reset(token)\n\ndef is_fresh() -> bool:\n    \"\"\"\n    是否获取新数据\n    \"\"\"\n    try:\n        return _fresh.get()\n    except LookupError:\n        return False\n\ndef FileCache(base: Path = settings.TEMP_PATH, ttl: Optional[int] = None) -> CacheBackend:\n    \"\"\"\n    获取文件缓存后端实例（Redis或文件系统），ttl仅在Redis环境中有效\n    \"\"\"\n    if settings.CACHE_BACKEND_TYPE == \"redis\":\n        # 如果使用 Redis，则设置缓存的存活时间为配置的天数转换为秒\n        return RedisBackend(ttl=ttl or settings.TEMP_FILE_DAYS * 24 * 3600)\n    else:\n        # 如果使用文件系统，在停止服务时会自动清理过期文件\n        return FileBackend(base=base)\n\n\ndef AsyncFileCache(base: Path = settings.TEMP_PATH, ttl: Optional[int] = None) -> AsyncCacheBackend:\n    \"\"\"\n    获取文件异步缓存后端实例（Redis或文件系统），ttl仅在Redis环境中有效\n    \"\"\"\n    if settings.CACHE_BACKEND_TYPE == \"redis\":\n        # 如果使用 Redis，则设置缓存的存活时间为配置的天数转换为秒\n        return AsyncRedisBackend(ttl=ttl or settings.TEMP_FILE_DAYS * 24 * 3600)\n    else:\n        # 如果使用文件系统，在停止服务时会自动清理过期文件\n        return AsyncFileBackend(base=base)\n\n\ndef Cache(cache_type: Literal['ttl', 'lru'] = 'ttl',\n          maxsize: Optional[int] = None,\n          ttl: Optional[int] = None) -> CacheBackend:\n    \"\"\"\n    根据配置获取缓存后端实例（内存或Redis），maxsize仅在未启用Redis时生效\n\n    :param cache_type: 缓存类型，仅使用内存缓存时生效，支持 'ttl'（默认）和 'lru'\n    :param maxsize: 缓存的最大条目数，仅使用cachetools时生效\n    :param ttl: 缓存的默认存活时间，单位秒\n    :return: 返回缓存后端实例\n    \"\"\"\n    if settings.CACHE_BACKEND_TYPE == \"redis\":\n        return RedisBackend(ttl=ttl)\n    else:\n        # 使用内存缓存，maxsize需要有值\n        return MemoryBackend(cache_type=cache_type, maxsize=maxsize, ttl=ttl)\n\n\ndef AsyncCache(cache_type: Literal['ttl', 'lru'] = 'ttl',\n               maxsize: Optional[int] = None,\n               ttl: Optional[int] = None) -> AsyncCacheBackend:\n    \"\"\"\n    根据配置获取异步缓存后端实例（内存或Redis），maxsize仅在未启用Redis时生效\n\n    :param cache_type: 缓存类型，仅使用内存缓存时生效，支持 'ttl'（默认）和 'lru'\n    :param maxsize: 缓存的最大条目数，仅使用cachetools时生效\n    :param ttl: 缓存的默认存活时间，单位秒\n    :return: 返回异步缓存后端实例\n    \"\"\"\n    if settings.CACHE_BACKEND_TYPE == \"redis\":\n        return AsyncRedisBackend(ttl=ttl)\n    else:\n        # 使用异步内存缓存，maxsize需要有值\n        return AsyncMemoryBackend(cache_type=cache_type, maxsize=maxsize, ttl=ttl)\n\n\ndef cached(region: Optional[str] = None, maxsize: Optional[int] = 1024, ttl: Optional[int] = None,\n           skip_none: Optional[bool] = True, skip_empty: Optional[bool] = False, shared_key: Optional[str] = None):\n    \"\"\"\n    自定义缓存装饰器，支持为每个 key 动态传递 maxsize 和 ttl\n\n    :param region: 缓存区域的标识符，默认根据模块名、函数名等自动生成标识\n    :param maxsize: 缓存区内的最大条目数\n    :param ttl: 缓存的存活时间，单位秒，未传入则为永久缓存，单位秒\n    :param skip_none: 跳过 None 缓存，默认为 True\n    :param skip_empty: 跳过空值缓存（如 None, [], {}, \"\", set()），默认为 False\n    :param shared_key: 同步/异步函数共享缓存的键，默认使用函数名（异步函数名会标准化为同步格式，如移除 `async_` 前缀）\n    :return: 装饰器函数\n    \"\"\"\n\n    def decorator(func):\n\n        def should_cache(value: Any) -> bool:\n            \"\"\"\n            判断是否应该缓存结果，如果返回值是 None 或空值则不缓存\n\n            :param value: 要判断的缓存值\n            :return: 是否缓存结果\n            \"\"\"\n            if skip_none and value is None:\n                return False\n            # if skip_empty and value in [None, [], {}, \"\", set()]:\n            if skip_empty and not value:\n                return False\n            return True\n\n        def is_valid_cache_value(_cache_key: str, _cached_value: Any, _cache_region: str) -> bool:\n            \"\"\"\n            判断指定的值是否为一个有效的缓存值\n\n            :param _cache_key: 缓存的键\n            :param _cached_value: 缓存的值\n            :param _cache_region: 缓存的区\n            :return: 若值是有效的缓存值返回 True，否则返回 False\n            \"\"\"\n            # 如果 skip_none 为 False，且 value 为 None，需要判断缓存实际是否存在\n            if not skip_none and _cached_value is None:\n                if not cache_backend.exists(key=_cache_key, region=_cache_region):\n                    return False\n            return True\n\n        async def async_is_valid_cache_value(_cache_key: str, _cached_value: Any, _cache_region: str) -> bool:\n            \"\"\"\n            判断指定的值是否为一个有效的缓存值（异步版本）\n\n            :param _cache_key: 缓存的键\n            :param _cached_value: 缓存的值\n            :param _cache_region: 缓存的区\n            :return: 若值是有效的缓存值返回 True，否则返回 False\n            \"\"\"\n            # 如果 skip_none 为 False，且 value 为 None，需要判断缓存实际是否存在\n            if not skip_none and _cached_value is None:\n                if not await cache_backend.exists(key=_cache_key, region=_cache_region):\n                    return False\n            return True\n\n        def __standardize_func_name() -> str:\n            \"\"\"\n            将异步函数名标准化为同步函数的命名，以生成统一的缓存键\n            \"\"\"\n            # XXX 假设异步函数名与同步版本仅差`async_`前缀或`_async`后缀（当前MP代码大多符合），否则需通过`shared_key`参数显式指定\n            return (\n                func.__name__.removeprefix(\"async_\").removesuffix(\"_async\")\n                if is_async\n                else func.__name__\n            )\n\n        def __get_cache_key(args, kwargs) -> str:\n            \"\"\"\n            根据函数和参数生成缓存键\n\n            :param args: 位置参数\n            :param kwargs: 关键字参数\n            :return: 缓存键\n            \"\"\"\n            signature = inspect.signature(func)\n            # 绑定传入的参数并应用默认值\n            bound = signature.bind(*args, **kwargs)\n            bound.apply_defaults()\n            # 忽略第一个参数，如果它是实例(self)或类(cls)\n            parameters = list(signature.parameters.keys())\n            if parameters and parameters[0] in (\"self\", \"cls\"):\n                bound.arguments.pop(parameters[0], None)\n            # 按照函数签名顺序提取参数值列表\n            keys = [\n                bound.arguments[param] for param in signature.parameters if param in bound.arguments\n            ]\n            # 使用有序参数生成缓存键\n            return f\"{func_name}_{hashkey(*keys)}\"\n\n        # 被装饰函数的上层名称（如类名或外层函数名）\n        enclosing_name = (\n            func.__qualname__[:last_dot]\n            if (last_dot := func.__qualname__.rfind(\".\")) != -1\n            else \"\"\n        )\n        # 检查是否为异步函数\n        is_async = inspect.iscoroutinefunction(func)\n        # 生成标准化后的函数名称，用于同步/异步函数共享缓存\n        func_name = shared_key if shared_key else __standardize_func_name()\n        # 获取缓存区\n        cache_region = (\n            region if region is not None else f\"{func.__module__}:{enclosing_name}:{func_name}\"\n        )\n\n        if is_async:\n            # 异步函数使用异步缓存后端\n            cache_backend = AsyncCache(cache_type=\"ttl\" if ttl else \"lru\", maxsize=maxsize, ttl=ttl)\n            # 异步函数的缓存装饰器\n            @wraps(func)\n            async def async_wrapper(*args, **kwargs):\n                # 获取缓存键\n                cache_key = __get_cache_key(args, kwargs)\n\n                if not is_fresh():\n                    # 尝试获取缓存\n                    cached_value = await cache_backend.get(cache_key, region=cache_region)\n                    if should_cache(cached_value) and await async_is_valid_cache_value(cache_key, cached_value,\n                                                                                    cache_region):\n                        return cached_value\n                # 执行异步函数并缓存结果\n                result = await func(*args, **kwargs)\n                # 判断是否需要缓存\n                if not should_cache(result):\n                    return result\n                # 设置缓存（如果有传入的 maxsize 和 ttl，则覆盖默认值）\n                await cache_backend.set(cache_key, result, ttl=ttl, maxsize=maxsize, region=cache_region)\n                return result\n\n            async def cache_clear():\n                \"\"\"\n                清理缓存区\n                \"\"\"\n                await cache_backend.clear(region=cache_region)\n\n            async_wrapper.cache_region = cache_region\n            async_wrapper.cache_clear = cache_clear\n            return async_wrapper\n        else:\n            # 同步函数使用同步缓存后端\n            cache_backend = Cache(cache_type=\"ttl\" if ttl else \"lru\", maxsize=maxsize, ttl=ttl)\n            # 同步函数的缓存装饰器\n            @wraps(func)\n            def wrapper(*args, **kwargs):\n                # 获取缓存键\n                cache_key = __get_cache_key(args, kwargs)\n\n                if not is_fresh():\n                    # 尝试获取缓存\n                    cached_value = cache_backend.get(cache_key, region=cache_region)\n                    if should_cache(cached_value) and is_valid_cache_value(cache_key, cached_value, cache_region):\n                        return cached_value\n                # 执行函数并缓存结果\n                result = func(*args, **kwargs)\n                # 判断是否需要缓存\n                if not should_cache(result):\n                    return result\n                # 设置缓存（如果有传入的 maxsize 和 ttl，则覆盖默认值）\n                cache_backend.set(cache_key, result, ttl=ttl, maxsize=maxsize, region=cache_region)\n                return result\n\n            def cache_clear():\n                \"\"\"\n                清理缓存区\n                \"\"\"\n                cache_backend.clear(region=cache_region)\n\n            wrapper.cache_region = cache_region\n            wrapper.cache_clear = cache_clear\n            return wrapper\n\n    return decorator\n\n\nclass CacheProxy:\n    \"\"\"\n    缓存代理类，将缓存后端的方法直接代理到实例上\n    \"\"\"\n\n    def __init__(self, cache_backend: CacheBackend, region: str):\n        \"\"\"\n        初始化缓存代理\n\n        :param cache_backend: 缓存后端实例\n        :param region: 缓存区域\n        \"\"\"\n        self._cache_backend = cache_backend\n        self._region = region\n\n    def __getitem__(self, key):\n        \"\"\"\n        获取缓存项\n        \"\"\"\n        value = self._cache_backend.get(key, region=self._region)\n        if value is None:\n            raise KeyError(key)\n        return value\n\n    def __setitem__(self, key, value):\n        \"\"\"\n        设置缓存项\n        \"\"\"\n        kwargs = {'region': self._region}\n        self._cache_backend.set(key, value, **kwargs)\n\n    def __delitem__(self, key):\n        \"\"\"\n        删除缓存项\n        \"\"\"\n        if not self._cache_backend.exists(key, region=self._region):\n            raise KeyError(key)\n        self._cache_backend.delete(key, region=self._region)\n\n    def __contains__(self, key):\n        \"\"\"\n        检查键是否存在\n        \"\"\"\n        return self._cache_backend.exists(key, region=self._region)\n\n    def __iter__(self):\n        \"\"\"\n        返回缓存的迭代器\n        \"\"\"\n        for key, _ in self._cache_backend.items(region=self._region):\n            yield key\n\n    def __len__(self):\n        \"\"\"\n        返回缓存项的数量\n        \"\"\"\n        return sum(1 for _ in self._cache_backend.items(region=self._region))\n\n    def is_redis(self) -> bool:\n        \"\"\"\n        检查当前缓存后端是否为 Redis\n        \"\"\"\n        return self._cache_backend.is_redis()\n\n    def get(self, key: str, **kwargs) -> Any:\n        \"\"\"\n        获取缓存值\n        \"\"\"\n        kwargs.setdefault('region', self._region)\n        return self._cache_backend.get(key, **kwargs)\n\n    def set(self, key: str, value: Any, **kwargs) -> None:\n        \"\"\"\n        设置缓存值\n        \"\"\"\n        kwargs.setdefault('region', self._region)\n        self._cache_backend.set(key, value, **kwargs)\n\n    def delete(self, key: str, **kwargs) -> None:\n        \"\"\"\n        删除缓存值\n        \"\"\"\n        kwargs.setdefault('region', self._region)\n        self._cache_backend.delete(key, **kwargs)\n\n    def exists(self, key: str, **kwargs) -> bool:\n        \"\"\"\n        检查缓存键是否存在\n        \"\"\"\n        kwargs.setdefault('region', self._region)\n        return self._cache_backend.exists(key, **kwargs)\n\n    def clear(self, **kwargs) -> None:\n        \"\"\"\n        清除缓存\n        \"\"\"\n        kwargs.setdefault('region', self._region)\n        self._cache_backend.clear(**kwargs)\n\n    def items(self, **kwargs):\n        \"\"\"\n        获取所有缓存项\n        \"\"\"\n        kwargs.setdefault('region', self._region)\n        return self._cache_backend.items(**kwargs)\n\n    def keys(self, **kwargs):\n        \"\"\"\n        获取所有缓存键\n        \"\"\"\n        kwargs.setdefault('region', self._region)\n        return self._cache_backend.keys(**kwargs)\n\n    def values(self, **kwargs):\n        \"\"\"\n        获取所有缓存值\n        \"\"\"\n        kwargs.setdefault('region', self._region)\n        return self._cache_backend.values(**kwargs)\n\n    def update(self, other: Dict[str, Any], **kwargs) -> None:\n        \"\"\"\n        更新缓存\n        \"\"\"\n        kwargs.setdefault('region', self._region)\n        self._cache_backend.update(other, **kwargs)\n\n    def pop(self, key: str, default: Any = None, **kwargs) -> Any:\n        \"\"\"\n        弹出缓存项\n        \"\"\"\n        kwargs.setdefault('region', self._region)\n        return self._cache_backend.pop(key, default, **kwargs)\n\n    def popitem(self, **kwargs) -> Tuple[str, Any]:\n        \"\"\"\n        弹出最后一个缓存项\n        \"\"\"\n        kwargs.setdefault('region', self._region)\n        return self._cache_backend.popitem(**kwargs)\n\n    def setdefault(self, key: str, default: Any = None, **kwargs) -> Any:\n        \"\"\"\n        设置默认值\n        \"\"\"\n        kwargs.setdefault('region', self._region)\n        return self._cache_backend.setdefault(key, default, **kwargs)\n\n    def close(self) -> None:\n        \"\"\"\n        关闭缓存连接\n        \"\"\"\n        self._cache_backend.close()\n\n\nclass TTLCache(CacheProxy):\n    \"\"\"\n    基于 TTL 的缓存类，兼容 cachetools.TTLCache 接口\n    使用项目的缓存后端实现，支持 Redis 和内存缓存\n    \"\"\"\n\n    def __init__(self,\n                 region: Optional[str] = DEFAULT_CACHE_REGION,\n                 maxsize: Optional[int] = DEFAULT_CACHE_SIZE,\n                 ttl: Optional[int] = DEFAULT_CACHE_TTL):\n        \"\"\"\n        初始化 TTL 缓存\n\n        :param maxsize: 缓存的最大条目数\n        :param ttl: 缓存的存活时间，单位秒\n        :param region: 缓存的区，为 None 时使用默认区\n        \"\"\"\n        super().__init__(Cache(cache_type='ttl', maxsize=maxsize, ttl=ttl), region)\n\n\nclass LRUCache(CacheProxy):\n    \"\"\"\n    基于 LRU 的缓存类，兼容 cachetools.LRUCache 接口\n    使用项目的缓存后端实现，支持 Redis 和内存缓存\n    \"\"\"\n\n    def __init__(self,\n                 region: Optional[str] = DEFAULT_CACHE_REGION,\n                 maxsize: Optional[int] = DEFAULT_CACHE_SIZE\n                 ):\n        \"\"\"\n        初始化 LRU 缓存\n\n        :param maxsize: 缓存的最大条目数\n        :param region: 缓存的区，为 None 时使用默认区\n        \"\"\"\n        super().__init__(Cache(cache_type='lru', maxsize=maxsize), region)\n"
  },
  {
    "path": "app/core/config.py",
    "content": "import asyncio\nimport copy\nimport json\nimport os\nimport platform\nimport re\nimport secrets\nimport sys\nimport threading\nfrom asyncio import AbstractEventLoop\nfrom pathlib import Path\nfrom typing import Any, Dict, List, Optional, Tuple, Type\nfrom urllib.parse import urlparse\n\nfrom dotenv import set_key\nfrom pydantic import BaseModel, Field, ConfigDict, model_validator\nfrom pydantic_settings import BaseSettings, SettingsConfigDict\n\nfrom app.log import logger, log_settings, LogConfigModel\nfrom app.schemas import MediaType\nfrom app.utils.system import SystemUtils\nfrom app.utils.url import UrlUtils\nfrom version import APP_VERSION\n\n\nclass SystemConfModel(BaseModel):\n    \"\"\"\n    系统关键资源大小配置\n    \"\"\"\n    # 缓存种子数量\n    torrents: int = 0\n    # 订阅刷新处理数量\n    refresh: int = 0\n    # TMDB请求缓存数量\n    tmdb: int = 0\n    # 豆瓣请求缓存数量\n    douban: int = 0\n    # Bangumi请求缓存数量\n    bangumi: int = 0\n    # Fanart请求缓存数量\n    fanart: int = 0\n    # 元数据缓存过期时间（秒）\n    meta: int = 0\n    # 调度器数量\n    scheduler: int = 0\n    # 线程池大小\n    threadpool: int = 0\n\n\nclass ConfigModel(BaseModel):\n    \"\"\"\n    Pydantic 配置模型，描述所有配置项及其类型和默认值\n    \"\"\"\n\n    model_config = ConfigDict(extra=\"ignore\")  # 忽略未定义的配置项\n\n    # ==================== 基础应用配置 ====================\n    # 项目名称\n    PROJECT_NAME: str = \"MoviePilot\"\n    # 域名 格式；https://movie-pilot.org\n    APP_DOMAIN: str = \"\"\n    # API路径\n    API_V1_STR: str = \"/api/v1\"\n    # 前端资源路径\n    FRONTEND_PATH: str = \"/public\"\n    # 时区\n    TZ: str = \"Asia/Shanghai\"\n    # API监听地址\n    HOST: str = \"0.0.0.0\"\n    # API监听端口\n    PORT: int = 3001\n    # 前端监听端口\n    NGINX_PORT: int = 3000\n    # 配置文件目录\n    CONFIG_DIR: Optional[str] = None\n    # 是否调试模式\n    DEBUG: bool = False\n    # 是否开发模式\n    DEV: bool = False\n    # 高级设置模式\n    ADVANCED_MODE: bool = True\n\n    # ==================== 安全认证配置 ====================\n    # 密钥\n    SECRET_KEY: str = secrets.token_urlsafe(32)\n    # RESOURCE密钥\n    RESOURCE_SECRET_KEY: str = secrets.token_urlsafe(32)\n    # 允许的域名\n    ALLOWED_HOSTS: list = Field(default_factory=lambda: [\"*\"])\n    # TOKEN过期时间\n    ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 8\n    # RESOURCE_TOKEN过期时间\n    RESOURCE_ACCESS_TOKEN_EXPIRE_SECONDS: int = 60 * 30\n    # 超级管理员初始用户名\n    SUPERUSER: str = \"admin\"\n    # 超级管理员初始密码\n    SUPERUSER_PASSWORD: Optional[str] = None\n    # 辅助认证，允许通过外部服务进行认证、单点登录以及自动创建用户\n    AUXILIARY_AUTH_ENABLE: bool = False\n    # API密钥，需要更换\n    API_TOKEN: Optional[str] = None\n    # 用户认证站点\n    AUTH_SITE: str = \"\"\n\n    # ==================== 数据库配置 ====================\n    # 数据库类型，支持 sqlite 和 postgresql，默认使用 sqlite\n    DB_TYPE: str = \"sqlite\"\n    # 是否在控制台输出 SQL 语句，默认关闭\n    DB_ECHO: bool = False\n    # 数据库连接超时时间（秒），默认为 60 秒\n    DB_TIMEOUT: int = 60\n    # 是否启用 WAL 模式，仅适用于SQLite，默认开启\n    DB_WAL_ENABLE: bool = True\n    # 数据库连接池类型，QueuePool, NullPool\n    DB_POOL_TYPE: str = \"QueuePool\"\n    # 是否在获取连接时进行预先 ping 操作\n    DB_POOL_PRE_PING: bool = True\n    # 数据库连接的回收时间（秒）\n    DB_POOL_RECYCLE: int = 300\n    # 数据库连接池获取连接的超时时间（秒）\n    DB_POOL_TIMEOUT: int = 30\n    # SQLite 连接池大小\n    DB_SQLITE_POOL_SIZE: int = 10\n    # SQLite 连接池溢出数量\n    DB_SQLITE_MAX_OVERFLOW: int = 50\n    # PostgreSQL 主机地址\n    DB_POSTGRESQL_HOST: str = \"localhost\"\n    # PostgreSQL 端口\n    DB_POSTGRESQL_PORT: int = 5432\n    # PostgreSQL 数据库名\n    DB_POSTGRESQL_DATABASE: str = \"moviepilot\"\n    # PostgreSQL 用户名\n    DB_POSTGRESQL_USERNAME: str = \"moviepilot\"\n    # PostgreSQL 密码\n    DB_POSTGRESQL_PASSWORD: str = \"moviepilot\"\n    # PostgreSQL 连接池大小\n    DB_POSTGRESQL_POOL_SIZE: int = 10\n    # PostgreSQL 连接池溢出数量\n    DB_POSTGRESQL_MAX_OVERFLOW: int = 50\n\n    # ==================== 缓存配置 ====================\n    # 缓存类型，支持 cachetools 和 redis，默认使用 cachetools\n    CACHE_BACKEND_TYPE: str = \"cachetools\"\n    # 缓存连接字符串，仅外部缓存（如 Redis、Memcached）需要\n    CACHE_BACKEND_URL: Optional[str] = \"redis://localhost:6379\"\n    # Redis 缓存最大内存限制，未配置时，如开启大内存模式时为 \"1024mb\"，未开启时为 \"256mb\"\n    CACHE_REDIS_MAXMEMORY: Optional[str] = None\n    # 全局图片缓存，将媒体图片缓存到本地\n    GLOBAL_IMAGE_CACHE: bool = False\n    # 全局图片缓存保留天数\n    GLOBAL_IMAGE_CACHE_DAYS: int = 7\n    # 临时文件保留天数\n    TEMP_FILE_DAYS: int = 3\n    # 元数据识别缓存过期时间（小时），0为自动\n    META_CACHE_EXPIRE: int = 0\n\n    # ==================== 网络代理配置 ====================\n    # 网络代理服务器地址\n    PROXY_HOST: Optional[str] = None\n    # 是否启用DOH解析域名\n    DOH_ENABLE: bool = False\n    # 使用 DOH 解析的域名列表\n    DOH_DOMAINS: str = (\"api.themoviedb.org,\"\n                        \"api.tmdb.org,\"\n                        \"webservice.fanart.tv,\"\n                        \"api.github.com,\"\n                        \"github.com,\"\n                        \"raw.githubusercontent.com,\"\n                        \"codeload.github.com,\"\n                        \"api.telegram.org\")\n    # DOH 解析服务器列表\n    DOH_RESOLVERS: str = \"1.0.0.1,1.1.1.1,9.9.9.9,149.112.112.112\"\n\n    # ==================== 媒体元数据配置 ====================\n    # 媒体搜索来源 themoviedb/douban/bangumi，多个用,分隔\n    SEARCH_SOURCE: str = \"themoviedb\"\n    # 媒体识别来源 themoviedb/douban\n    RECOGNIZE_SOURCE: str = \"themoviedb\"\n    # 刮削来源 themoviedb/douban\n    SCRAP_SOURCE: str = \"themoviedb\"\n    # 电视剧动漫的分类genre_ids\n    ANIME_GENREIDS: List[int] = Field(default=[16])\n\n    # ==================== TMDB配置 ====================\n    # TMDB图片地址\n    TMDB_IMAGE_DOMAIN: str = \"image.tmdb.org\"\n    # TMDB API地址\n    TMDB_API_DOMAIN: str = \"api.themoviedb.org\"\n    # TMDB元数据语言\n    TMDB_LOCALE: str = \"zh\"\n    # 刮削使用TMDB原始语种图片\n    TMDB_SCRAP_ORIGINAL_IMAGE: bool = False\n    # TMDB API Key\n    TMDB_API_KEY: str = \"db55323b8d3e4154498498a75642b381\"\n\n    # ==================== TVDB配置 ====================\n    # TVDB API Key\n    TVDB_V4_API_KEY: str = \"ed2aa66b-7899-4677-92a7-67bc9ce3d93a\"\n    TVDB_V4_API_PIN: str = \"\"\n\n    # ==================== Fanart配置 ====================\n    # Fanart开关\n    FANART_ENABLE: bool = True\n    # Fanart语言\n    FANART_LANG: str = \"zh,en\"\n    # Fanart API Key\n    FANART_API_KEY: str = \"d2d31f9ecabea050fc7d68aa3146015f\"\n\n    # ==================== 云盘配置 ====================\n    # 115 AppId\n    U115_APP_ID: str = \"100196807\"\n    # 115 OAuth2 Server 地址\n    U115_AUTH_SERVER: str = \"https://movie-pilot.org\"\n    # Alipan AppId\n    ALIPAN_APP_ID: str = \"ac1bf04dc9fd4d9aaabb65b4a668d403\"\n\n    # ==================== 系统升级配置 ====================\n    # 重启自动升级\n    MOVIEPILOT_AUTO_UPDATE: str = 'release'\n    # 自动检查和更新站点资源包（站点索引、认证等）\n    AUTO_UPDATE_RESOURCE: bool = True\n\n    # ==================== 媒体文件格式配置 ====================\n    # 支持的视频文件后缀格式\n    RMT_MEDIAEXT: list = Field(\n        default_factory=lambda: ['.mp4', '.mkv', '.ts', '.iso',\n                                 '.rmvb', '.avi', '.mov', '.mpeg',\n                                 '.mpg', '.wmv', '.3gp', '.asf',\n                                 '.m4v', '.flv', '.m2ts', '.strm',\n                                 '.tp', '.f4v']\n    )\n    # 支持的字幕文件后缀格式\n    RMT_SUBEXT: list = Field(default_factory=lambda: ['.srt', '.ass', '.ssa', '.sup'])\n    # 支持的音轨文件后缀格式\n    RMT_AUDIOEXT: list = Field(\n        default_factory=lambda: ['.aac', '.ac3', '.amr', '.caf', '.cda', '.dsf',\n                                 '.dff', '.kar', '.m4a', '.mp1', '.mp2', '.mp3',\n                                 '.mid', '.mod', '.mka', '.mpc', '.nsf', '.ogg',\n                                 '.pcm', '.rmi', '.s3m', '.snd', '.spx', '.tak',\n                                 '.tta', '.vqf', '.wav', '.wma',\n                                 '.aifc', '.aiff', '.alac', '.adif', '.adts',\n                                 '.flac', '.midi', '.opus', '.sfalc']\n    )\n\n    # ==================== 媒体服务器配置 ====================\n    # 媒体服务器同步间隔（小时）\n    MEDIASERVER_SYNC_INTERVAL: int = 6\n\n    # ==================== 订阅配置 ====================\n    # 订阅模式\n    SUBSCRIBE_MODE: str = \"spider\"\n    # RSS订阅模式刷新时间间隔（分钟）\n    SUBSCRIBE_RSS_INTERVAL: int = 30\n    # 订阅数据共享\n    SUBSCRIBE_STATISTIC_SHARE: bool = True\n    # 订阅搜索开关\n    SUBSCRIBE_SEARCH: bool = False\n    # 订阅搜索时间间隔（小时）\n    SUBSCRIBE_SEARCH_INTERVAL: int = 24\n    # 检查本地媒体库是否存在资源开关\n    LOCAL_EXISTS_SEARCH: bool = True\n\n    # ==================== 站点配置 ====================\n    # 站点数据刷新间隔（小时）\n    SITEDATA_REFRESH_INTERVAL: int = 6\n    # 读取和发送站点消息\n    SITE_MESSAGE: bool = True\n    # 不能缓存站点资源的站点域名，多个使用,分隔\n    NO_CACHE_SITE_KEY: str = \"m-team\"\n    # OCR服务器地址，用于识别站点验证码\n    OCR_HOST: str = \"https://movie-pilot.org\"\n    # 仿真类型：playwright 或 flaresolverr\n    BROWSER_EMULATION: str = \"playwright\"\n    # FlareSolverr 服务地址，例如 http://127.0.0.1:8191\n    FLARESOLVERR_URL: Optional[str] = None\n\n    # ==================== 搜索配置 ====================\n    # 搜索多个名称\n    SEARCH_MULTIPLE_NAME: bool = False\n    # 最大搜索名称数量\n    MAX_SEARCH_NAME_LIMIT: int = 3\n\n    # ==================== 下载配置 ====================\n    # 种子标签\n    TORRENT_TAG: str = \"MOVIEPILOT\"\n    # 下载站点字幕\n    DOWNLOAD_SUBTITLE: bool = True\n    # 交互搜索自动下载用户ID，使用,分割\n    AUTO_DOWNLOAD_USER: Optional[str] = None\n    # 下载器临时文件后缀\n    DOWNLOAD_TMPEXT: list = Field(default_factory=lambda: ['.!qb', '.part'])\n\n    # ==================== CookieCloud配置 ====================\n    # CookieCloud是否启动本地服务\n    COOKIECLOUD_ENABLE_LOCAL: Optional[bool] = False\n    # CookieCloud服务器地址\n    COOKIECLOUD_HOST: str = \"https://movie-pilot.org/cookiecloud\"\n    # CookieCloud用户KEY\n    COOKIECLOUD_KEY: Optional[str] = None\n    # CookieCloud端对端加密密码\n    COOKIECLOUD_PASSWORD: Optional[str] = None\n    # CookieCloud同步间隔（分钟）\n    COOKIECLOUD_INTERVAL: Optional[int] = 60 * 24\n    # CookieCloud同步黑名单，多个域名,分割\n    COOKIECLOUD_BLACKLIST: Optional[str] = None\n\n    # ==================== 整理配置 ====================\n    # 文件整理线程数\n    TRANSFER_THREADS: int = 1\n    # 电影重命名格式\n    MOVIE_RENAME_FORMAT: str = \"{{title}}{% if year %} ({{year}}){% endif %}\" \\\n                               \"/{{title}}{% if year %} ({{year}}){% endif %}{% if part %}-{{part}}{% endif %}{% if videoFormat %} - {{videoFormat}}{% endif %}\" \\\n                               \"{{fileExt}}\"\n    # 电视剧重命名格式\n    TV_RENAME_FORMAT: str = \"{{title}}{% if year %} ({{year}}){% endif %}\" \\\n                            \"/Season {{season}}\" \\\n                            \"/{{title}} - {{season_episode}}{% if part %}-{{part}}{% endif %}{% if episode %} - 第 {{episode}} 集{% endif %}\" \\\n                            \"{{fileExt}}\"\n    # 重命名时支持的S0别名\n    RENAME_FORMAT_S0_NAMES: list = Field(default=[\"Specials\", \"SPs\"])\n    # 为指定默认字幕添加.default后缀\n    DEFAULT_SUB: Optional[str] = \"zh-cn\"\n    # 新增已入库媒体是否跟随TMDB信息变化\n    SCRAP_FOLLOW_TMDB: bool = True\n    # 优先使用辅助识别\n    RECOGNIZE_PLUGIN_FIRST: bool = False\n\n    # ==================== 服务地址配置 ====================\n    # 服务器地址，对应 https://github.com/jxxghp/MoviePilot-Server 项目\n    MP_SERVER_HOST: str = \"https://movie-pilot.org\"\n\n    # ==================== 个性化 ====================\n    # 登录页面电影海报,tmdb/bing/mediaserver\n    WALLPAPER: str = \"tmdb\"\n    # 自定义壁纸api地址\n    CUSTOMIZE_WALLPAPER_API_URL: Optional[str] = None\n\n    # ==================== 插件配置 ====================\n    # 插件市场仓库地址，多个地址使用,分隔，地址以/结尾\n    PLUGIN_MARKET: str = (\"https://github.com/jxxghp/MoviePilot-Plugins,\"\n                          \"https://github.com/thsrite/MoviePilot-Plugins,\"\n                          \"https://github.com/honue/MoviePilot-Plugins,\"\n                          \"https://github.com/InfinityPacer/MoviePilot-Plugins,\"\n                          \"https://github.com/DDSRem-Dev/MoviePilot-Plugins,\"\n                          \"https://github.com/madrays/MoviePilot-Plugins,\"\n                          \"https://github.com/justzerock/MoviePilot-Plugins,\"\n                          \"https://github.com/KoWming/MoviePilot-Plugins,\"\n                          \"https://github.com/wikrin/MoviePilot-Plugins,\"\n                          \"https://github.com/HankunYu/MoviePilot-Plugins,\"\n                          \"https://github.com/baozaodetudou/MoviePilot-Plugins,\"\n                          \"https://github.com/Aqr-K/MoviePilot-Plugins,\"\n                          \"https://github.com/hotlcc/MoviePilot-Plugins-Third,\"\n                          \"https://github.com/gxterry/MoviePilot-Plugins,\"\n                          \"https://github.com/DzAvril/MoviePilot-Plugins,\"\n                          \"https://github.com/mrtian2016/MoviePilot-Plugins,\"\n                          \"https://github.com/Hqyel/MoviePilot-Plugins-Third,\"\n                          \"https://github.com/xijin285/MoviePilot-Plugins,\"\n                          \"https://github.com/Seed680/MoviePilot-Plugins,\"\n                          \"https://github.com/imaliang/MoviePilot-Plugins\")\n    # 插件安装数据共享\n    PLUGIN_STATISTIC_SHARE: bool = True\n    # 是否开启插件热加载\n    PLUGIN_AUTO_RELOAD: bool = False\n\n    # ==================== Github & PIP ====================\n    # Github token，提高请求api限流阈值 ghp_****\n    GITHUB_TOKEN: Optional[str] = None\n    # Github代理服务器，格式：https://mirror.ghproxy.com/\n    GITHUB_PROXY: Optional[str] = ''\n    # pip镜像站点，格式：https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple\n    PIP_PROXY: Optional[str] = ''\n    # 指定的仓库Github token，多个仓库使用,分隔，格式：{user1}/{repo1}:ghp_****,{user2}/{repo2}:github_pat_****\n    REPO_GITHUB_TOKEN: Optional[str] = None\n\n    # ==================== 性能配置 ====================\n    # 大内存模式\n    BIG_MEMORY_MODE: bool = False\n    # 是否启用编码探测的性能模式\n    ENCODING_DETECTION_PERFORMANCE_MODE: bool = True\n    # 编码探测的最低置信度阈值\n    ENCODING_DETECTION_MIN_CONFIDENCE: float = 0.8\n    # 主动内存回收时间间隔（分钟），0为不启用\n    MEMORY_GC_INTERVAL: int = 30\n\n    # ==================== 安全配置 ====================\n    # 允许的图片缓存域名\n    SECURITY_IMAGE_DOMAINS: list = Field(default=[\n        \"image.tmdb.org\",\n        \"static-mdb.v.geilijiasu.com\",\n        \"bing.com\",\n        \"doubanio.com\",\n        \"lain.bgm.tv\",\n        \"raw.githubusercontent.com\",\n        \"github.com\",\n        \"thetvdb.com\",\n        \"cctvpic.com\",\n        \"iqiyipic.com\",\n        \"hdslb.com\",\n        \"cmvideo.cn\",\n        \"ykimg.com\",\n        \"qpic.cn\"\n    ])\n    # 允许的图片文件后缀格式\n    SECURITY_IMAGE_SUFFIXES: list = Field(default=[\".jpg\", \".jpeg\", \".png\", \".webp\", \".gif\", \".svg\", \".avif\"])\n    # PassKey 是否强制用户验证（生物识别等）\n    PASSKEY_REQUIRE_UV: bool = True\n    # 允许在未启用 OTP 时直接注册 PassKey\n    PASSKEY_ALLOW_REGISTER_WITHOUT_OTP: bool = False\n\n    # ==================== 工作流配置 ====================\n    # 工作流数据共享\n    WORKFLOW_STATISTIC_SHARE: bool = True\n\n    # ==================== 存储配置 ====================\n    # 对rclone进行快照对比时，是否检查文件夹的修改时间\n    RCLONE_SNAPSHOT_CHECK_FOLDER_MODTIME: bool = True\n    # 对OpenList进行快照对比时，是否检查文件夹的修改时间\n    OPENLIST_SNAPSHOT_CHECK_FOLDER_MODTIME: bool = True\n    # 对阿里云盘进行快照对比时，是否检查文件夹的修改时间（默认关闭，因为阿里云盘目录时间不随子文件变更而更新）\n    ALIPAN_SNAPSHOT_CHECK_FOLDER_MODTIME: bool = False\n\n    # ==================== Docker配置 ====================\n    # Docker Client API地址\n    DOCKER_CLIENT_API: Optional[str] = \"tcp://127.0.0.1:38379\"\n    # Playwright浏览器类型，chromium/firefox\n    PLAYWRIGHT_BROWSER_TYPE: str = \"chromium\"\n\n    # ==================== AI智能体配置 ====================\n    # AI智能体开关\n    AI_AGENT_ENABLE: bool = False\n    # 合局AI智能体\n    AI_AGENT_GLOBAL: bool = False\n    # LLM提供商 (openai/google/deepseek)\n    LLM_PROVIDER: str = \"deepseek\"\n    # LLM模型名称\n    LLM_MODEL: str = \"deepseek-chat\"\n    # LLM API密钥\n    LLM_API_KEY: Optional[str] = None\n    # LLM基础URL（用于自定义API端点）\n    LLM_BASE_URL: Optional[str] = \"https://api.deepseek.com\"\n    # LLM最大上下文Token数量（K）\n    LLM_MAX_CONTEXT_TOKENS: int = 64\n    # LLM温度参数\n    LLM_TEMPERATURE: float = 0.1\n    # LLM最大迭代次数\n    LLM_MAX_ITERATIONS: int = 128\n    # LLM工具调用超时时间（秒）\n    LLM_TOOL_TIMEOUT: int = 300\n    # 是否启用详细日志\n    LLM_VERBOSE: bool = False\n    # 最大记忆消息数量\n    LLM_MAX_MEMORY_MESSAGES: int = 30\n    # 内存记忆保留天数\n    LLM_MEMORY_RETENTION_DAYS: int = 1\n    # Redis记忆保留天数（如果使用Redis）\n    LLM_REDIS_MEMORY_RETENTION_DAYS: int = 7\n    # 是否启用AI推荐\n    AI_RECOMMEND_ENABLED: bool = False\n    # AI推荐用户偏好\n    AI_RECOMMEND_USER_PREFERENCE: str = \"\"\n    # Tavily API密钥（用于网络搜索）\n    TAVILY_API_KEY: str = \"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\"\n\n    # AI推荐条目数量限制\n    AI_RECOMMEND_MAX_ITEMS: int = 50\n\n\n\nclass Settings(BaseSettings, ConfigModel, LogConfigModel):\n    \"\"\"\n    系统配置类\n    \"\"\"\n\n    model_config = SettingsConfigDict(\n        case_sensitive=True,\n        env_file=SystemUtils.get_env_path(),\n        env_file_encoding=\"utf-8\",\n    )\n\n    def __init__(self, **kwargs):\n        super().__init__(**kwargs)\n        # 初始化配置目录及子目录\n        for path in [self.CONFIG_PATH, self.TEMP_PATH, self.LOG_PATH, self.COOKIE_PATH]:\n            if not path.exists():\n                path.mkdir(parents=True, exist_ok=True)\n        # 如果是二进制程序，确保配置文件存在\n        if SystemUtils.is_frozen():\n            app_env_path = self.CONFIG_PATH / \"app.env\"\n            if not app_env_path.exists():\n                SystemUtils.copy(self.INNER_CONFIG_PATH / \"app.env\", app_env_path)\n\n    @staticmethod\n    def validate_api_token(value: Any, original_value: Any) -> Tuple[Any, bool]:\n        \"\"\"\n        校验 API_TOKEN\n        \"\"\"\n        if isinstance(value, (list, dict, set)):\n            value = copy.deepcopy(value)\n        value = value.strip() if isinstance(value, str) else None\n        if not value or len(value) < 16:\n            new_token = secrets.token_urlsafe(16)\n            if not value:\n                logger.info(f\"'API_TOKEN' 未设置，已随机生成新的【API_TOKEN】{new_token}\")\n            else:\n                logger.warning(f\"'API_TOKEN' 长度不足 16 个字符，存在安全隐患，已随机生成新的【API_TOKEN】{new_token}\")\n            return new_token, True\n        return value, str(value) != str(original_value)\n\n    @staticmethod\n    def generic_type_converter(value: Any, original_value: Any, expected_type: Type, default: Any, field_name: str,\n                               raise_exception: bool = False) -> Tuple[Any, bool]:\n        \"\"\"\n        通用类型转换函数，根据预期类型转换值。如果转换失败，返回默认值\n        :return: 元组 (转换后的值, 是否需要更新)\n        \"\"\"\n        if isinstance(value, (list, dict, set)):\n            value = copy.deepcopy(value)\n        # 如果 value 是 None，仍需要检查与 original_value 是否不一致\n        if value is None:\n            return default, str(value) != str(original_value)\n\n        if isinstance(value, str):\n            value = value.strip()\n\n        try:\n            if expected_type is bool:\n                if isinstance(value, bool):\n                    return value, str(value).lower() != str(original_value).lower()\n                if isinstance(value, str):\n                    value_clean = value.lower()\n                    bool_map = {\n                        \"false\": False, \"no\": False, \"0\": False, \"off\": False,\n                        \"true\": True, \"yes\": True, \"1\": True, \"on\": True\n                    }\n                    if value_clean in bool_map:\n                        converted = bool_map[value_clean]\n                        return converted, str(converted).lower() != str(original_value).lower()\n                elif isinstance(value, (int, float)):\n                    converted = bool(value)\n                    return converted, str(converted).lower() != str(original_value).lower()\n                return default, True\n            elif expected_type is int:\n                if isinstance(value, int):\n                    return value, str(value) != str(original_value)\n                if isinstance(value, str):\n                    converted = int(value)\n                    return converted, str(converted) != str(original_value)\n            elif expected_type is float:\n                if isinstance(value, float):\n                    return value, str(value) != str(original_value)\n                if isinstance(value, str):\n                    converted = float(value)\n                    return converted, str(converted) != str(original_value)\n            elif expected_type is str:\n                converted = str(value).strip()\n                return converted, converted != str(original_value)\n            elif expected_type is list:\n                if isinstance(value, list):\n                    return value, str(value) != str(original_value)\n                if isinstance(value, str):\n                    items = json.loads(value)\n                    if isinstance(original_value, list):\n                        return items, items != original_value\n                    else:\n                        return items, str(items) != str(original_value)\n            else:\n                return value, str(value) != str(original_value)\n        except (ValueError, TypeError) as e:\n            if raise_exception:\n                raise ValueError(f\"配置项 '{field_name}' 的值 '{value}' 无法转换成正确的类型\") from e\n            logger.error(\n                f\"配置项 '{field_name}' 的值 '{value}' 无法转换成正确的类型，使用默认值 '{default}'，错误信息: {e}\")\n        return default, True\n\n    @model_validator(mode='before')\n    @classmethod\n    def generic_type_validator(cls, data: Any):  # noqa\n        \"\"\"\n        通用校验器，尝试将配置值转换为期望的类型\n        \"\"\"\n        if not isinstance(data, dict):\n            return data\n\n        # 处理 API_TOKEN 特殊验证\n        if 'API_TOKEN' in data:\n            converted_value, needs_update = cls.validate_api_token(data['API_TOKEN'], data['API_TOKEN'])\n            if needs_update:\n                cls.update_env_config(\"API_TOKEN\", data[\"API_TOKEN\"], converted_value)\n                data['API_TOKEN'] = converted_value\n\n        # 对其他字段进行类型转换\n        for field_name, field_info in cls.model_fields.items():\n            if field_name not in data:\n                continue\n            value = data[field_name]\n            if value is None:\n                continue\n\n            field = cls.model_fields.get(field_name)\n            if field:\n                converted_value, needs_update = cls.generic_type_converter(\n                    value, value, field.annotation, field.default, field_name\n                )\n                if needs_update:\n                    cls.update_env_config(field_name, value, converted_value)\n                    data[field_name] = converted_value\n\n        return data\n\n    @staticmethod\n    def update_env_config(field_name: str, original_value: Any, converted_value: Any) -> Tuple[bool, str]:\n        \"\"\"\n        更新 env 配置\n        \"\"\"\n        message = None\n        is_converted = original_value is not None and str(original_value) != str(converted_value)\n        if is_converted:\n            message = f\"配置项 '{field_name}' 的值 '{original_value}' 无效，已替换为 '{converted_value}'\"\n            logger.warning(message)\n\n        if field_name in os.environ:\n            message = f\"配置项 '{field_name}' 已在环境变量中设置，请手动更新以保持一致性\"\n            logger.warning(message)\n            return False, message\n        else:\n            # 如果是列表、字典或集合类型，将其转换为JSON字符串\n            if isinstance(converted_value, (list, dict, set)):\n                value_to_write = json.dumps(converted_value)\n            else:\n                value_to_write = str(converted_value) if converted_value is not None else \"\"\n\n            set_key(dotenv_path=SystemUtils.get_env_path(), key_to_set=field_name, value_to_set=value_to_write,\n                    quote_mode=\"always\")\n            if is_converted:\n                logger.info(f\"配置项 '{field_name}' 已自动修正并写入到 'app.env' 文件\")\n        return True, message\n\n    def update_setting(self, key: str, value: Any) -> Tuple[Optional[bool], str]:\n        \"\"\"\n        更新单个配置项\n        :param key: 配置项的名称\n        :param value: 配置项的新值\n        :return: (是否成功 True 成功/False 失败/None 无需更新, 错误信息)\n        \"\"\"\n        if not hasattr(self, key):\n            return False, f\"配置项 '{key}' 不存在\"\n\n        try:\n            field = Settings.model_fields[key]\n            original_value = getattr(self, key)\n            if key == \"API_TOKEN\":\n                converted_value, needs_update = self.validate_api_token(value, original_value)\n            else:\n                converted_value, needs_update = self.generic_type_converter(\n                    value, original_value, field.annotation, field.default, key\n                )\n            # 如果没有抛出异常，则统一使用 converted_value 进行更新\n            if needs_update or str(value) != str(converted_value):\n                success, message = self.update_env_config(key, value, converted_value)\n                # 仅成功更新配置时，才更新内存\n                if success:\n                    setattr(self, key, converted_value)\n                    if hasattr(log_settings, key):\n                        setattr(log_settings, key, converted_value)\n                return success, message\n            return None, \"\"\n        except Exception as e:\n            return False, str(e)\n\n    def update_settings(self, env: Dict[str, Any]) -> Dict[str, Tuple[Optional[bool], str]]:\n        \"\"\"\n        更新多个配置项\n        \"\"\"\n        results = {}\n        for k, v in env.items():\n            results[k] = self.update_setting(k, v)\n        return results\n\n    @property\n    def VERSION_FLAG(self) -> str:\n        \"\"\"\n        版本标识，用来区分重大版本，为空则为v1，不允许外部修改\n        \"\"\"\n        return \"v2\"\n\n    @property\n    def USER_AGENT(self) -> str:\n        \"\"\"\n        全局用户代理字符串\n        \"\"\"\n        return f\"{self.PROJECT_NAME}/{APP_VERSION[1:]} ({platform.system()} {platform.release()}; {SystemUtils.cpu_arch()})\"\n\n    @property\n    def NORMAL_USER_AGENT(self) -> str:\n        \"\"\"\n        默认浏览器用户代理字符串\n        \"\"\"\n        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\"\n\n    @property\n    def INNER_CONFIG_PATH(self):\n        return self.ROOT_PATH / \"config\"\n\n    @property\n    def CONFIG_PATH(self):\n        if self.CONFIG_DIR:\n            return Path(self.CONFIG_DIR)\n        elif SystemUtils.is_docker():\n            return Path(\"/config\")\n        elif SystemUtils.is_frozen():\n            return Path(sys.executable).parent / \"config\"\n        return self.ROOT_PATH / \"config\"\n\n    @property\n    def TEMP_PATH(self):\n        return self.CONFIG_PATH / \"temp\"\n\n    @property\n    def CACHE_PATH(self):\n        return self.CONFIG_PATH / \"cache\"\n\n    @property\n    def ROOT_PATH(self):\n        return Path(__file__).parents[2]\n\n    @property\n    def PLUGIN_DATA_PATH(self):\n        return self.CONFIG_PATH / \"plugins\"\n\n    @property\n    def LOG_PATH(self):\n        return self.CONFIG_PATH / \"logs\"\n\n    @property\n    def COOKIE_PATH(self):\n        return self.CONFIG_PATH / \"cookies\"\n\n    @property\n    def CONF(self) -> SystemConfModel:\n        \"\"\"\n        根据内存模式返回系统配置\n        \"\"\"\n        if self.BIG_MEMORY_MODE:\n            return SystemConfModel(\n                torrents=200,\n                refresh=100,\n                tmdb=1024,\n                douban=512,\n                bangumi=512,\n                fanart=512,\n                meta=(self.META_CACHE_EXPIRE or 72) * 3600,\n                scheduler=100,\n                threadpool=100\n            )\n        return SystemConfModel(\n            torrents=100,\n            refresh=50,\n            tmdb=256,\n            douban=256,\n            bangumi=256,\n            fanart=128,\n            meta=(self.META_CACHE_EXPIRE or 24) * 3600,\n            scheduler=50,\n            threadpool=50\n        )\n\n    @property\n    def PROXY(self):\n        if self.PROXY_HOST:\n            return {\n                \"http\": self.PROXY_HOST,\n                \"https\": self.PROXY_HOST,\n            }\n        return None\n\n    @property\n    def PROXY_SERVER(self):\n        if self.PROXY_HOST:\n            try:\n                parsed = urlparse(self.PROXY_HOST)\n                if not parsed.scheme:\n                    return {\"server\": self.PROXY_HOST}\n                host = parsed.hostname or \"\"\n                port = f\":{parsed.port}\" if parsed.port else \"\"\n                server = f\"{parsed.scheme}://{host}{port}\"\n                proxy = {\"server\": server}\n                if parsed.username:\n                    proxy[\"username\"] = parsed.username\n                if parsed.password:\n                    proxy[\"password\"] = parsed.password\n                return proxy\n            except Exception as err:\n                logger.error(f\"解析代理服务器地址 '{self.PROXY_HOST}' 时出错: {err}\")\n                return {\"server\": self.PROXY_HOST}\n        return None\n\n    @property\n    def GITHUB_HEADERS(self):\n        \"\"\"\n        Github请求头\n        \"\"\"\n        if self.GITHUB_TOKEN:\n            return {\n                \"Authorization\": f\"Bearer {self.GITHUB_TOKEN}\",\n                \"User-Agent\": self.NORMAL_USER_AGENT,\n            }\n        return {}\n\n    def REPO_GITHUB_HEADERS(self, repo: str = None):\n        \"\"\"\n        Github指定的仓库请求头\n        :param repo: 指定的仓库名称，格式为 \"user/repo\"。如果为空，或者没有找到指定仓库请求头，则返回默认的请求头信息\n        :return: Github请求头\n        \"\"\"\n        # 如果没有传入指定的仓库名称，或没有配置指定的仓库Token，则返回默认的请求头信息\n        if not repo or not self.REPO_GITHUB_TOKEN:\n            return self.GITHUB_HEADERS\n        headers = {}\n        # 格式：{user1}/{repo1}:ghp_****,{user2}/{repo2}:github_pat_****\n        token_pairs = self.REPO_GITHUB_TOKEN.split(\",\")\n        for token_pair in token_pairs:\n            try:\n                parts = token_pair.split(\":\")\n                if len(parts) != 2:\n                    print(f\"无效的令牌格式: {token_pair}\")\n                    continue\n                repo_info = parts[0].strip()\n                token = parts[1].strip()\n                if not repo_info or not token:\n                    print(f\"无效的令牌或仓库信息: {token_pair}\")\n                    continue\n                headers[repo_info] = {\n                    \"Authorization\": f\"Bearer {token}\",\n                    \"User-Agent\": self.NORMAL_USER_AGENT,\n                }\n            except Exception as e:\n                print(f\"处理令牌对 '{token_pair}' 时出错: {e}\")\n        # 如果传入了指定的仓库名称，则返回该仓库的请求头信息，否则返回默认请求头\n        return headers.get(repo, self.GITHUB_HEADERS)\n\n    @property\n    def VAPID(self):\n        return {\n            \"subject\": f\"mailto:{self.SUPERUSER}@movie-pilot.org\",\n            \"publicKey\": \"BH3w49sZA6jXUnE-yt4jO6VKh73lsdsvwoJ6Hx7fmPIDKoqGiUl2GEoZzy-iJfn4SfQQcx7yQdHf9RknwrL_lSM\",\n            \"privateKey\": \"JTixnYY0vEw97t9uukfO3UWKfHKJdT5kCQDiv3gu894\"\n        }\n\n    def MP_DOMAIN(self, url: str = None):\n        if not self.APP_DOMAIN:\n            return None\n        return UrlUtils.combine_url(host=self.APP_DOMAIN, path=url)\n\n    def RENAME_FORMAT(self, media_type: MediaType):\n        \"\"\"\n        获取指定类型的重命名格式\n\n        :param media_type: MediaType.TV 或 MediaType.Movie\n        :return: 重命名格式\n        \"\"\"\n        rename_format = (\n            self.TV_RENAME_FORMAT\n            if media_type == MediaType.TV\n            else self.MOVIE_RENAME_FORMAT\n        )\n        # 规范重命名格式\n        rename_format = rename_format.replace(\"\\\\\", \"/\")\n        rename_format = re.sub(r'/+', '/', rename_format)\n        return rename_format.strip(\"/\")\n\n    def TMDB_IMAGE_URL(\n        self, file_path: Optional[str], file_size: str = \"original\"\n    ) -> Optional[str]:\n        \"\"\"\n        获取TMDB图片网址\n\n        :param file_path: TMDB API返回的xxx_path\n        :param file_size: 图片大小，例如：'original', 'w500' 等\n        :return: 图片的完整URL，如果 file_path 为空则返回 None\n        \"\"\"\n        if not file_path:\n            return None\n        return (\n            f\"https://{self.TMDB_IMAGE_DOMAIN}/t/p/{file_size}/{file_path.removeprefix('/')}\"\n        )\n\n\n# 实例化配置\nsettings = Settings()\n\n\nclass GlobalVar(object):\n    \"\"\"\n    全局标识\n    \"\"\"\n    # 系统停止事件\n    STOP_EVENT: threading.Event = threading.Event()\n    # webpush订阅\n    SUBSCRIPTIONS: List[dict] = []\n    # 需应急停止的工作流\n    EMERGENCY_STOP_WORKFLOWS: List[int] = []\n    # 需应急停止文件整理\n    EMERGENCY_STOP_TRANSFER: List[str] = []\n    # 当前事件循环\n    CURRENT_EVENT_LOOP: AbstractEventLoop = asyncio.get_event_loop()\n\n    def stop_system(self):\n        \"\"\"\n        停止系统\n        \"\"\"\n        self.STOP_EVENT.set()\n\n    @property\n    def is_system_stopped(self):\n        \"\"\"\n        是否停止\n        \"\"\"\n        return self.STOP_EVENT.is_set()\n\n    def get_subscriptions(self):\n        \"\"\"\n        获取webpush订阅\n        \"\"\"\n        return self.SUBSCRIPTIONS\n\n    def push_subscription(self, subscription: dict):\n        \"\"\"\n        添加webpush订阅\n        \"\"\"\n        self.SUBSCRIPTIONS.append(subscription)\n\n    def stop_workflow(self, workflow_id: int):\n        \"\"\"\n        停止工作流\n        \"\"\"\n        if workflow_id not in self.EMERGENCY_STOP_WORKFLOWS:\n            self.EMERGENCY_STOP_WORKFLOWS.append(workflow_id)\n\n    def workflow_resume(self, workflow_id: int):\n        \"\"\"\n        恢复工作流\n        \"\"\"\n        if workflow_id in self.EMERGENCY_STOP_WORKFLOWS:\n            self.EMERGENCY_STOP_WORKFLOWS.remove(workflow_id)\n\n    def is_workflow_stopped(self, workflow_id: int) -> bool:\n        \"\"\"\n        是否停止工作流\n        \"\"\"\n        return self.is_system_stopped or workflow_id in self.EMERGENCY_STOP_WORKFLOWS\n\n    def stop_transfer(self, path: str):\n        \"\"\"\n        停止文件整理\n        \"\"\"\n        if path not in self.EMERGENCY_STOP_TRANSFER:\n            self.EMERGENCY_STOP_TRANSFER.append(path)\n\n    def is_transfer_stopped(self, path: str) -> bool:\n        \"\"\"\n        是否停止文件整理\n        \"\"\"\n        if self.is_system_stopped:\n            return True\n        if path in self.EMERGENCY_STOP_TRANSFER:\n            self.EMERGENCY_STOP_TRANSFER.remove(path)\n            return True\n        return False\n\n    @property\n    def loop(self) -> AbstractEventLoop:\n        \"\"\"\n        当前循环\n        \"\"\"\n        return self.CURRENT_EVENT_LOOP\n\n    def set_loop(self, loop: AbstractEventLoop):\n        \"\"\"\n        设置循环\n        \"\"\"\n        self.CURRENT_EVENT_LOOP = loop\n\n\n# 全局标识\nglobal_vars = GlobalVar()\n"
  },
  {
    "path": "app/core/context.py",
    "content": "import re\nfrom dataclasses import dataclass, field\nfrom datetime import datetime\nfrom typing import List, Dict, Any, Tuple, Optional\n\nfrom app.core.config import settings\nfrom app.core.meta import MetaBase\nfrom app.core.metainfo import MetaInfo\nfrom app.schemas.types import MediaType\nfrom app.utils.string import StringUtils\n\n\n@dataclass\nclass TorrentInfo:\n    # 站点ID\n    site: int = None\n    # 站点名称\n    site_name: str = None\n    # 站点Cookie\n    site_cookie: str = None\n    # 站点UA\n    site_ua: str = None\n    # 站点是否使用代理\n    site_proxy: bool = False\n    # 站点优先级\n    site_order: int = 0\n    # 站点下载器\n    site_downloader: str = None\n    # 种子名称\n    title: str = None\n    # 种子副标题\n    description: str = None\n    # IMDB ID\n    imdbid: str = None\n    # 种子链接\n    enclosure: str = None\n    # 详情页面\n    page_url: str = None\n    # 种子大小\n    size: float = 0.0\n    # 做种者\n    seeders: int = 0\n    # 下载者\n    peers: int = 0\n    # 完成者\n    grabs: int = 0\n    # 发布时间\n    pubdate: str = None\n    # 已过时间\n    date_elapsed: str = None\n    # 免费截止时间\n    freedate: str = None\n    # 上传因子\n    uploadvolumefactor: float = None\n    # 下载因子\n    downloadvolumefactor: float = None\n    # HR\n    hit_and_run: bool = False\n    # 种子标签\n    labels: list = field(default_factory=list)\n    # 种子优先级\n    pri_order: int = 0\n    # 种子分类 电影/电视剧\n    category: str = None\n\n    def __setattr__(self, name: str, value: Any):\n        self.__dict__[name] = value\n\n    def __get_properties(self):\n        \"\"\"\n        获取属性列表\n        \"\"\"\n        property_names = []\n        for member_name in dir(self.__class__):\n            member = getattr(self.__class__, member_name)\n            if isinstance(member, property):\n                property_names.append(member_name)\n        return property_names\n\n    def from_dict(self, data: dict):\n        \"\"\"\n        从字典中初始化\n        \"\"\"\n        properties = self.__get_properties()\n        for key, value in data.items():\n            if key in properties:\n                continue\n            setattr(self, key, value)\n\n    @staticmethod\n    def get_free_string(upload_volume_factor: float, download_volume_factor: float) -> str:\n        \"\"\"\n        计算促销类型\n        \"\"\"\n        if upload_volume_factor is None or download_volume_factor is None:\n            return \"未知\"\n        free_strs = {\n            \"1.00 1.00\": \"普通\",\n            \"1.00 0.00\": \"免费\",\n            \"2.00 1.00\": \"2X\",\n            \"4.00 1.00\": \"4X\",\n            \"2.00 0.00\": \"2X免费\",\n            \"4.00 0.00\": \"4X免费\",\n            \"1.00 0.50\": \"50%\",\n            \"2.00 0.50\": \"2X 50%\",\n            \"1.00 0.70\": \"70%\",\n            \"1.00 0.30\": \"30%\",\n            \"1.00 0.75\": \"75%\",\n            \"1.00 0.25\": \"25%\"\n        }\n        return free_strs.get('%.2f %.2f' % (upload_volume_factor, download_volume_factor), \"未知\")\n\n    @property\n    def volume_factor(self):\n        \"\"\"\n        返回促销信息\n        \"\"\"\n        return self.get_free_string(self.uploadvolumefactor, self.downloadvolumefactor)\n\n    @property\n    def freedate_diff(self):\n        \"\"\"\n        返回免费剩余时间\n        \"\"\"\n        if not self.freedate:\n            return \"\"\n        return StringUtils.diff_time_str(self.freedate)\n\n    def pub_minutes(self) -> float:\n        \"\"\"\n        返回发布时间距离当前时间的分钟数\n        \"\"\"\n        if not self.pubdate:\n            return 0\n        try:\n            pub_date = datetime.strptime(self.pubdate, \"%Y-%m-%d %H:%M:%S\")\n            now_datetime = datetime.now()\n            return (now_datetime - pub_date).total_seconds() // 60\n        except Exception as e:\n            print(f\"种子发布时间获取失败: {e}\")\n            return 0\n\n    def to_dict(self):\n        \"\"\"\n        返回字典\n        \"\"\"\n        dicts = vars(self).copy()\n        dicts[\"volume_factor\"] = self.volume_factor\n        dicts[\"freedate_diff\"] = self.freedate_diff\n        return dicts\n\n\n@dataclass\nclass MediaInfo:\n    # 来源：themoviedb、douban、bangumi\n    source: str = None\n    # 类型 电影、电视剧\n    type: MediaType = None\n    # 媒体标题\n    title: str = None\n    # 英文标题\n    en_title: str = None\n    # 香港标题\n    hk_title: str = None\n    # 台湾标题\n    tw_title: str = None\n    # 新加坡标题\n    sg_title: str = None\n    # 年份\n    year: str = None\n    # 季\n    season: int = None\n    # TMDB ID\n    tmdb_id: int = None\n    # IMDB ID\n    imdb_id: str = None\n    # TVDB ID\n    tvdb_id: int = None\n    # 豆瓣ID\n    douban_id: str = None\n    # Bangumi ID\n    bangumi_id: int = None\n    # 合集ID\n    collection_id: int = None\n    # 媒体原语种\n    original_language: str = None\n    # 媒体原发行标题\n    original_title: str = None\n    # 媒体发行日期\n    release_date: str = None\n    # 背景图片\n    backdrop_path: str = None\n    # 海报图片\n    poster_path: str = None\n    # LOGO\n    logo_path: str = None\n    # 评分\n    vote_average: float = None\n    # 描述\n    overview: str = None\n    # 风格ID\n    genre_ids: list = field(default_factory=list)\n    # 所有别名和译名\n    names: list = field(default_factory=list)\n    # 各季的剧集清单信息\n    seasons: Dict[int, list] = field(default_factory=dict)\n    # 各季详情\n    season_info: List[dict] = field(default_factory=list)\n    # 各季的年份\n    season_years: dict = field(default_factory=dict)\n    # 二级分类\n    category: str = \"\"\n    # TMDB INFO\n    tmdb_info: dict = field(default_factory=dict)\n    # 豆瓣 INFO\n    douban_info: dict = field(default_factory=dict)\n    # Bangumi INFO\n    bangumi_info: dict = field(default_factory=dict)\n    # 导演\n    directors: List[dict] = field(default_factory=list)\n    # 演员\n    actors: List[dict] = field(default_factory=list)\n    # 是否成人内容\n    adult: bool = False\n    # 创建人\n    created_by: list = field(default_factory=list)\n    # 集时长\n    episode_run_time: list = field(default_factory=list)\n    # 风格\n    genres: List[dict] = field(default_factory=list)\n    # 首播日期\n    first_air_date: str = None\n    # 首页\n    homepage: str = None\n    # 语种\n    languages: list = field(default_factory=list)\n    # 最后上映日期\n    last_air_date: str = None\n    # 流媒体平台\n    networks: list = field(default_factory=list)\n    # 集数\n    number_of_episodes: int = None\n    # 季数\n    number_of_seasons: int = None\n    # 原产国\n    origin_country: list = field(default_factory=list)\n    # 原名\n    original_name: str = None\n    # 出品公司\n    production_companies: list = field(default_factory=list)\n    # 出品国\n    production_countries: list = field(default_factory=list)\n    # 语种\n    spoken_languages: list = field(default_factory=list)\n    # 所有发行日期\n    release_dates: list = field(default_factory=list)\n    # 状态\n    status: str = None\n    # 标签\n    tagline: str = None\n    # 评价数量\n    vote_count: int = None\n    # 流行度\n    popularity: float = None\n    # 时长\n    runtime: int = None\n    # 下一集\n    next_episode_to_air: dict = field(default_factory=dict)\n    # 内容分级\n    content_rating: str = None\n    # 全部剧集组\n    episode_groups: List[dict] = field(default_factory=list)\n    # 剧集组\n    episode_group: str = None\n\n    def __post_init__(self):\n        # 设置媒体信息\n        if self.tmdb_info:\n            self.set_tmdb_info(self.tmdb_info)\n        if self.douban_info:\n            self.set_douban_info(self.douban_info)\n        if self.bangumi_info:\n            self.set_bangumi_info(self.bangumi_info)\n\n    def __setattr__(self, name: str, value: Any):\n        self.__dict__[name] = value\n\n    def __get_properties(self):\n        \"\"\"\n        获取属性列表\n        \"\"\"\n        property_names = []\n        for member_name in dir(self.__class__):\n            member = getattr(self.__class__, member_name)\n            if isinstance(member, property):\n                property_names.append(member_name)\n        return property_names\n\n    def from_dict(self, data: dict):\n        \"\"\"\n        从字典中初始化\n        \"\"\"\n        properties = self.__get_properties()\n        for key, value in data.items():\n            if key in properties:\n                continue\n            setattr(self, key, value)\n        if isinstance(self.type, str):\n            self.type = MediaType(self.type)\n\n    def set_image(self, name: str, image: str):\n        \"\"\"\n        设置图片地址\n        \"\"\"\n        setattr(self, f\"{name}_path\", image)\n\n    def get_image(self, name: str):\n        \"\"\"\n        获取图片地址\n        \"\"\"\n        try:\n            return getattr(self, f\"{name}_path\")\n        except AttributeError:\n            return None\n\n    def set_category(self, cat: str):\n        \"\"\"\n        设置二级分类\n        \"\"\"\n        self.category = cat or \"\"\n\n    def set_tmdb_info(self, info: dict):\n        \"\"\"\n        初始化媒信息\n        \"\"\"\n\n        def __directors_actors(tmdbinfo: dict) -> Tuple[List[dict], List[dict]]:\n            \"\"\"\n            查询导演和演员\n            :param tmdbinfo: TMDB元数据\n            :return: 导演列表，演员列表\n            \"\"\"\n            \"\"\"\n            \"cast\": [\n              {\n                \"adult\": false,\n                \"gender\": 2,\n                \"id\": 3131,\n                \"known_for_department\": \"Acting\",\n                \"name\": \"Antonio Banderas\",\n                \"original_name\": \"Antonio Banderas\",\n                \"popularity\": 60.896,\n                \"profile_path\": \"/iWIUEwgn2KW50MssR7tdPeFoRGW.jpg\",\n                \"cast_id\": 2,\n                \"character\": \"Puss in Boots (voice)\",\n                \"credit_id\": \"6052480e197de4006bb47b9a\",\n                \"order\": 0\n              }\n            ],\n            \"crew\": [\n              {\n                \"adult\": false,\n                \"gender\": 2,\n                \"id\": 5524,\n                \"known_for_department\": \"Production\",\n                \"name\": \"Andrew Adamson\",\n                \"original_name\": \"Andrew Adamson\",\n                \"popularity\": 9.322,\n                \"profile_path\": \"/qqIAVKAe5LHRbPyZUlptsqlo4Kb.jpg\",\n                \"credit_id\": \"63b86b2224b33300a0585bf1\",\n                \"department\": \"Production\",\n                \"job\": \"Executive Producer\"\n              }\n            ]\n            \"\"\"\n            if not tmdbinfo:\n                return [], []\n            _credits = tmdbinfo.get(\"credits\")\n            if not _credits:\n                return [], []\n            directors = []\n            actors = []\n            for cast in _credits.get(\"cast\") or []:\n                if cast.get(\"known_for_department\") == \"Acting\":\n                    actors.append(cast)\n            for crew in _credits.get(\"crew\") or []:\n                if crew.get(\"job\") in [\"Director\", \"Writer\", \"Editor\", \"Producer\"]:\n                    directors.append(crew)\n            return directors, actors\n\n        if not info:\n            return\n        # 来源\n        self.source = \"themoviedb\"\n        # 本体\n        self.tmdb_info = info\n        # 类型\n        if isinstance(info.get('media_type'), MediaType):\n            self.type = info.get('media_type')\n        elif info.get('media_type'):\n            self.type = MediaType.MOVIE if info.get(\"media_type\") == \"movie\" else MediaType.TV\n        else:\n            self.type = MediaType.MOVIE if info.get(\"title\") else MediaType.TV\n        # TMDBID\n        self.tmdb_id = info.get('id')\n        if not self.tmdb_id:\n            return\n        # 额外ID\n        if info.get(\"external_ids\"):\n            self.tvdb_id = info.get(\"external_ids\", {}).get(\"tvdb_id\")\n            self.imdb_id = info.get(\"external_ids\", {}).get(\"imdb_id\")\n        # 合集ID\n        self.collection_id = info.get('collection_id')\n        # 评分\n        self.vote_average = round(float(info.get('vote_average')), 1) if info.get('vote_average') else 0\n        # 描述\n        self.overview = info.get('overview')\n        # 风格\n        self.genre_ids = info.get('genre_ids') or []\n        # 原语种\n        self.original_language = info.get('original_language')\n        # 英文标题\n        self.en_title = info.get('en_title')\n        # 香港标题\n        self.hk_title = info.get('hk_title')\n        # 台湾标题\n        self.tw_title = info.get('tw_title')\n        # 新加坡标题\n        self.sg_title = info.get('sg_title')\n        if self.type == MediaType.MOVIE:\n            # 标题\n            self.title = info.get('title')\n            # 原标题\n            self.original_title = info.get('original_title')\n            # 发行日期\n            self.release_date = info.get('release_date')\n            if self.release_date:\n                # 年份\n                self.year = self.release_date[:4]\n            # 所有发行日期\n            self.release_dates = [\n                {\n                    \"date\": release_date.get(\"release_date\"),\n                    \"iso_code\": result.get(\"iso_3166_1\"),\n                    \"note\": release_date.get(\"note\"),\n                    \"type\": release_date.get(\"type\"),\n                }\n                for result in info.get(\"release_dates\", {}).get(\"results\", [])\n                for release_date in result.get(\"release_dates\", [])\n                if release_date.get(\"release_date\")\n            ]\n        else:\n            # 电视剧\n            self.title = info.get('name')\n            # 原标题\n            self.original_title = info.get('original_name')\n            # 发行日期\n            self.release_date = info.get('first_air_date')\n            if self.release_date:\n                # 年份\n                self.year = self.release_date[:4]\n            # 季集信息\n            if info.get('seasons'):\n                self.season_info = info.get('seasons')\n                for seainfo in info.get('seasons'):\n                    # 季\n                    season = seainfo.get(\"season_number\")\n                    if season is None:\n                        continue\n                    # 集\n                    episode_count = seainfo.get(\"episode_count\")\n                    self.seasons[season] = list(range(1, episode_count + 1))\n                    # 年份\n                    air_date = seainfo.get(\"air_date\")\n                    if air_date:\n                        self.season_years[season] = air_date[:4]\n            # 剧集组\n            if info.get(\"episode_groups\"):\n                self.episode_groups = info.pop(\"episode_groups\").get(\"results\") or []\n\n        # 海报\n        if path := info.get('poster_path'):\n            self.poster_path = settings.TMDB_IMAGE_URL(path)\n        # 背景\n        if path := info.get('backdrop_path'):\n            self.backdrop_path = settings.TMDB_IMAGE_URL(path)\n        # 导演和演员\n        self.directors, self.actors = __directors_actors(info)\n        # 别名和译名\n        self.names = info.get('names') or []\n        # 剩余属性赋值\n        for key, value in info.items():\n            if not value:\n                continue\n            if not hasattr(self, key):\n                continue\n            current_value = getattr(self, key)\n            if current_value:\n                continue\n            if current_value is None:\n                setattr(self, key, value)\n            elif type(current_value) is type(value):\n                setattr(self, key, value)\n\n    def set_douban_info(self, info: dict):\n        \"\"\"\n        初始化豆瓣信息\n        \"\"\"\n        if not info:\n            return\n        # 来源\n        self.source = \"douban\"\n        # 本体\n        self.douban_info = info\n        # 豆瓣ID\n        self.douban_id = str(info.get(\"id\"))\n        # 类型\n        if not self.type:\n            if isinstance(info.get('media_type'), MediaType):\n                self.type = info.get('media_type')\n            elif info.get(\"subtype\"):\n                self.type = MediaType.MOVIE if info.get(\"subtype\") == \"movie\" else MediaType.TV\n            elif info.get(\"target_type\"):\n                self.type = MediaType.MOVIE if info.get(\"target_type\") == \"movie\" else MediaType.TV\n            elif info.get(\"type_name\"):\n                self.type = MediaType(info.get(\"type_name\"))\n            elif info.get(\"uri\"):\n                self.type = MediaType.MOVIE if \"/movie/\" in info.get(\"uri\") else MediaType.TV\n            elif info.get(\"type\") and info.get(\"type\") in [\"movie\", \"tv\"]:\n                self.type = MediaType.MOVIE if info.get(\"type\") == \"movie\" else MediaType.TV\n        # 标题\n        if not self.title:\n            self.title = info.get(\"title\")\n        # 英文标题，暂时不支持\n        if not self.en_title:\n            self.en_title = info.get('original_title')\n        # 原语种标题\n        if not self.original_title:\n            self.original_title = info.get(\"original_title\")\n        # 年份\n        if not self.year:\n            self.year = info.get(\"year\")[:4] if info.get(\"year\") else None\n            if not self.year and info.get(\"extra\"):\n                self.year = info.get(\"extra\").get(\"year\")\n        # 识别标题中的季\n        meta = MetaInfo(info.get(\"title\"))\n        # 季\n        if self.season is None:\n            self.season = meta.begin_season\n            if self.season is not None:\n                self.type = MediaType.TV\n            elif not self.type:\n                self.type = MediaType.MOVIE\n        # 评分\n        if not self.vote_average:\n            rating = info.get(\"rating\")\n            if rating:\n                vote_average = float(rating.get(\"value\"))\n            else:\n                vote_average = 0\n            self.vote_average = vote_average\n        # 发行日期\n        if not self.release_date:\n            if info.get(\"release_date\"):\n                self.release_date = info.get(\"release_date\")\n            elif info.get(\"pubdate\") and isinstance(info.get(\"pubdate\"), list):\n                release_date = info.get(\"pubdate\")[0]\n                if release_date:\n                    match = re.search(r'\\d{4}-\\d{2}-\\d{2}', release_date)\n                    if match:\n                        self.release_date = match.group()\n        # 海报\n        if not self.poster_path:\n            if info.get(\"pic\"):\n                self.poster_path = info.get(\"pic\", {}).get(\"large\")\n            if not self.poster_path and info.get(\"cover_url\"):\n                # imageView2/0/q/80/w/9999/h/120/format/webp ->  imageView2/1/w/500/h/750/format/webp\n                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\"))\n            if not self.poster_path and info.get(\"cover\"):\n                if info.get(\"cover\").get(\"url\"):\n                    self.poster_path = info.get(\"cover\").get(\"url\")\n                else:\n                    self.poster_path = info.get(\"cover\").get(\"large\", {}).get(\"url\")\n        # 简介\n        if not self.overview:\n            self.overview = info.get(\"intro\") or info.get(\"card_subtitle\") or \"\"\n            if not self.overview:\n                if info.get(\"extra\", {}).get(\"info\"):\n                    extra_info = info.get(\"extra\").get(\"info\")\n                    if extra_info:\n                        self.overview = \"，\".join([\"：\".join(item) for item in extra_info])\n        # 从简介中提取年份\n        if self.overview and not self.year:\n            match = re.search(r'\\d{4}', self.overview)\n            if match:\n                self.year = match.group()\n        # 导演和演员\n        if not self.directors:\n            self.directors = info.get(\"directors\") or []\n        if not self.actors:\n            self.actors = info.get(\"actors\") or []\n        # 别名\n        if not self.names:\n            akas = info.get(\"aka\")\n            if akas:\n                self.names = [re.sub(r'\\([港台豆友译名]+\\)', \"\", aka) for aka in akas]\n        # 剧集\n        if self.type == MediaType.TV and not self.seasons:\n            meta = MetaInfo(info.get(\"title\"))\n            season = meta.begin_season if meta.begin_season is not None else 1\n            episodes_count = info.get(\"episodes_count\")\n            if episodes_count:\n                self.seasons[season] = list(range(1, episodes_count + 1))\n        # 季年份\n        if self.type == MediaType.TV and not self.season_years:\n            season = self.season if self.season is not None else 1\n            self.season_years = {\n                season: self.year\n            }\n        # 风格\n        if not self.genres:\n            self.genres = [{\"id\": genre, \"name\": genre} for genre in info.get(\"genres\") or []]\n        # 时长\n        if not self.runtime and info.get(\"durations\"):\n            # 查找数字\n            match = re.search(r'\\d+', info.get(\"durations\")[0])\n            if match:\n                self.runtime = int(match.group())\n        # 国家\n        if not self.production_countries:\n            self.production_countries = [{\"id\": country, \"name\": country} for country in info.get(\"countries\") or []]\n        # 剩余属性赋值\n        for key, value in info.items():\n            if not value:\n                continue\n            if not hasattr(self, key):\n                continue\n            current_value = getattr(self, key)\n            if current_value:\n                continue\n            if current_value is None:\n                setattr(self, key, value)\n            elif type(current_value) is type(value):\n                setattr(self, key, value)\n\n    def set_bangumi_info(self, info: dict):\n        \"\"\"\n        初始化Bangumi信息\n        \"\"\"\n        if not info:\n            return\n        # 来源\n        self.source = \"bangumi\"\n        # 本体\n        self.bangumi_info = info\n        # 豆瓣ID\n        self.bangumi_id = info.get(\"id\")\n        # 类型\n        if not self.type:\n            self.type = MediaType.TV\n        # 标题\n        if not self.title:\n            self.title = info.get(\"name_cn\") or info.get(\"name\")\n        # 原语种标题\n        if not self.original_title:\n            self.original_title = info.get(\"name\")\n        # 识别标题中的季\n        meta = MetaInfo(self.title)\n        # 季\n        if self.season is None:\n            self.season = meta.begin_season\n        # 评分\n        if not self.vote_average:\n            rating = info.get(\"rating\")\n            if rating:\n                vote_average = float(rating.get(\"score\"))\n            else:\n                vote_average = 0\n            self.vote_average = vote_average\n        # 发行日期\n        if not self.release_date:\n            self.release_date = info.get(\"date\") or info.get(\"air_date\")\n            # 年份\n            if not self.year:\n                self.year = self.release_date[:4] if self.release_date else None\n        # 海报\n        if not self.poster_path:\n            if info.get(\"images\"):\n                self.poster_path = info.get(\"images\", {}).get(\"large\")\n            if not self.poster_path and info.get(\"image\"):\n                self.poster_path = info.get(\"image\")\n        # 简介\n        if not self.overview:\n            self.overview = info.get(\"summary\")\n        # 别名\n        if not self.names:\n            infobox = info.get(\"infobox\")\n            if infobox:\n                akas = [item.get(\"value\") for item in infobox if item.get(\"key\") == \"别名\"]\n                if akas:\n                    self.names = [aka.get(\"v\") for aka in akas[0]]\n\n        # 剧集\n        if self.type == MediaType.TV and not self.seasons:\n            meta = MetaInfo(self.title)\n            season = meta.begin_season if meta.begin_season is not None else 1\n            episodes_count = info.get(\"total_episodes\")\n            if episodes_count:\n                self.seasons[season] = list(range(1, episodes_count + 1))\n        # 演员\n        if not self.actors:\n            self.actors = info.get(\"actors\") or []\n\n    @property\n    def title_year(self):\n        if self.title:\n            return \"%s (%s)\" % (self.title, self.year) if self.year else self.title\n        return \"\"\n\n    @property\n    def detail_link(self):\n        \"\"\"\n        TMDB媒体详情页地址\n        \"\"\"\n        if self.tmdb_id:\n            if self.type == MediaType.MOVIE:\n                return \"https://www.themoviedb.org/movie/%s\" % self.tmdb_id\n            else:\n                return \"https://www.themoviedb.org/tv/%s\" % self.tmdb_id\n        elif self.douban_id:\n            return \"https://movie.douban.com/subject/%s\" % self.douban_id\n        elif self.bangumi_id:\n            return \"http://bgm.tv/subject/%s\" % self.bangumi_id\n        return \"\"\n\n    @property\n    def stars(self):\n        \"\"\"\n        返回评分星星个数\n        \"\"\"\n        if not self.vote_average:\n            return \"\"\n        return \"\".rjust(int(self.vote_average), \"★\")\n\n    @property\n    def vote_star(self):\n        if self.vote_average:\n            return \"评分：%s\" % self.stars\n        return \"\"\n\n    def get_backdrop_image(self, default: bool = False):\n        \"\"\"\n        返回背景图片地址\n        \"\"\"\n        if self.backdrop_path:\n            return self.backdrop_path.replace(\"original\", \"w500\")\n        return default or \"\"\n\n    def get_message_image(self, default: Optional[bool] = None):\n        \"\"\"\n        返回消息图片地址\n        \"\"\"\n        if self.backdrop_path:\n            return self.backdrop_path.replace(\"original\", \"w500\")\n        return self.get_poster_image(default=default)\n\n    def get_poster_image(self, default: Optional[bool] = None):\n        \"\"\"\n        返回海报图片地址\n        \"\"\"\n        if self.poster_path:\n            return self.poster_path.replace(\"original\", \"w500\")\n        return default or \"\"\n\n    def get_overview_string(self, max_len: Optional[int] = 140):\n        \"\"\"\n        返回带限定长度的简介信息\n        :param max_len: 内容长度\n        :return:\n        \"\"\"\n        overview = str(self.overview).strip()\n        placeholder = ' ...'\n        max_len = max(len(placeholder), max_len - len(placeholder))\n        overview = (overview[:max_len] + placeholder) if len(overview) > max_len else overview\n        return overview\n\n    def to_dict(self):\n        \"\"\"\n        返回字典\n        \"\"\"\n        dicts = vars(self).copy()\n        dicts[\"type\"] = self.type.value if self.type else None\n        dicts[\"detail_link\"] = self.detail_link\n        dicts[\"title_year\"] = self.title_year\n        dicts[\"tmdb_info\"] = None\n        dicts[\"douban_info\"] = None\n        dicts[\"bangumi_info\"] = None\n        return dicts\n\n    def clear(self):\n        \"\"\"\n        去除多余数据，减小体积\n        \"\"\"\n        self.tmdb_info = {}\n        self.douban_info = {}\n        self.bangumi_info = {}\n        self.seasons = {}\n        self.genres = []\n        self.season_info = []\n        self.names = []\n        self.actors = []\n        self.directors = []\n        self.production_companies = []\n        self.production_countries = []\n        self.spoken_languages = []\n        self.networks = []\n        self.next_episode_to_air = {}\n        self.episode_groups = []\n\n\n@dataclass\nclass Context:\n    \"\"\"\n    上下文对象\n    \"\"\"\n\n    # 识别信息\n    meta_info: MetaBase = None\n    # 媒体信息\n    media_info: MediaInfo = None\n    # 种子信息\n    torrent_info: TorrentInfo = None\n    # 媒体识别失败次数\n    media_recognize_fail_count: int = 0\n\n    def to_dict(self):\n        \"\"\"\n        转换为字典\n        \"\"\"\n        return {\n            \"meta_info\": self.meta_info.to_dict() if self.meta_info else None,\n            \"torrent_info\": self.torrent_info.to_dict() if self.torrent_info else None,\n            \"media_info\": self.media_info.to_dict() if self.media_info else None,\n            \"media_recognize_fail_count\": self.media_recognize_fail_count\n        }\n"
  },
  {
    "path": "app/core/event.py",
    "content": "import asyncio\nimport importlib\nimport inspect\nimport random\nimport threading\nimport time\nimport traceback\nimport uuid\nfrom queue import Empty, PriorityQueue\nfrom typing import Callable, Dict, List, Optional, Tuple, Union, Any\n\nfrom fastapi.concurrency import run_in_threadpool\n\nfrom app.core.config import global_vars\nfrom app.helper.thread import ThreadHelper\nfrom app.log import logger\nfrom app.schemas import ChainEventData\nfrom app.schemas.types import ChainEventType, EventType\nfrom app.utils.limit import ExponentialBackoffRateLimiter\nfrom app.utils.singleton import Singleton\n\nDEFAULT_EVENT_PRIORITY = 10  # 事件的默认优先级\nMIN_EVENT_CONSUMER_THREADS = 1  # 最小事件消费者线程数\nINITIAL_EVENT_QUEUE_IDLE_TIMEOUT_SECONDS = 1  # 事件队列空闲时的初始超时时间（秒）\nMAX_EVENT_QUEUE_IDLE_TIMEOUT_SECONDS = 5  # 事件队列空闲时的最大超时时间（秒）\n\n\nclass Event:\n    \"\"\"\n    事件类，封装事件的基本信息\n    \"\"\"\n\n    def __init__(self, event_type: Union[EventType, ChainEventType],\n                 event_data: Optional[Union[Dict, ChainEventData]] = None,\n                 priority: Optional[int] = DEFAULT_EVENT_PRIORITY):\n        \"\"\"\n        :param event_type: 事件的类型，支持 EventType 或 ChainEventType\n        :param event_data: 可选，事件携带的数据，默认为空字典\n        :param priority: 可选，事件的优先级，默认为 10\n        \"\"\"\n        self.event_id = str(uuid.uuid4())  # 事件ID\n        self.event_type = event_type  # 事件类型\n        self.event_data = event_data or {}  # 事件数据\n        self.priority = priority  # 事件优先级\n\n    def __repr__(self) -> str:\n        \"\"\"\n        重写 __repr__ 方法，用于返回事件的详细信息，包括事件类型、事件ID和优先级\n        \"\"\"\n        event_kind = Event.get_event_kind(self.event_type)\n        return f\"<{event_kind}: {self.event_type.value}, ID: {self.event_id}, Priority: {self.priority}>\"\n\n    def __lt__(self, other):\n        \"\"\"\n        定义事件对象的比较规则，基于优先级比较\n        优先级小的事件会被认为“更小”，优先级高的事件将被认为“更大”\n        \"\"\"\n        return self.priority < other.priority\n\n    @staticmethod\n    def get_event_kind(event_type: Union[EventType, ChainEventType]) -> str:\n        \"\"\"\n        根据事件类型判断事件是广播事件还是链式事件\n        :param event_type: 事件类型，支持 EventType 或 ChainEventType\n        :return: 返回 Broadcast Event 或 Chain Event\n        \"\"\"\n        return \"Broadcast Event\" if isinstance(event_type, EventType) else \"Chain Event\"\n\n\nclass EventManager(metaclass=Singleton):\n    \"\"\"\n    EventManager 负责管理和调度广播事件和链式事件，包括订阅、发送和处理事件\n    \"\"\"\n\n    def __init__(self):\n        # 动态线程池，用于消费事件\n        self.__executor = ThreadHelper()\n        # 用于保存启动的事件消费者线程\n        self.__consumer_threads = []\n        # 优先级队列\n        self.__event_queue = PriorityQueue()\n        # 广播事件的订阅者\n        self.__broadcast_subscribers: Dict[EventType, Dict[str, Callable]] = {}\n        # 链式事件的订阅者\n        self.__chain_subscribers: Dict[ChainEventType, Dict[str, tuple[int, Callable]]] = {}\n        # 禁用的事件处理器集合\n        self.__disabled_handlers = set()\n        # 禁用的事件处理器类集合\n        self.__disabled_classes = set()\n        # 线程锁\n        self.__lock = threading.Lock()\n        # 退出事件\n        self.__event = threading.Event()\n\n    def start(self):\n        \"\"\"\n        开始广播事件处理线程\n        \"\"\"\n        # 启动消费者线程用于处理广播事件\n        self.__event.set()\n        for _ in range(MIN_EVENT_CONSUMER_THREADS):\n            thread = threading.Thread(target=self.__broadcast_consumer_loop, daemon=True)\n            thread.start()\n            self.__consumer_threads.append(thread)  # 将线程对象保存到列表中\n\n    def stop(self):\n        \"\"\"\n        停止广播事件处理线程\n        \"\"\"\n        logger.info(\"正在停止事件处理...\")\n        self.__event.clear()  # 停止广播事件处理\n        try:\n            # 通过遍历保存的线程来等待它们完成\n            for consumer_thread in self.__consumer_threads:\n                consumer_thread.join()\n            logger.info(\"事件处理停止完成\")\n        except Exception as e:\n            logger.error(f\"停止事件处理线程出错：{str(e)} - {traceback.format_exc()}\")\n\n    def check(self, etype: Union[EventType, ChainEventType]) -> bool:\n        \"\"\"\n        检查是否有启用的事件处理器可以响应某个事件类型\n        :param etype: 事件类型 (EventType 或 ChainEventType)\n        :return: 返回是否存在可用的处理器\n        \"\"\"\n        if isinstance(etype, ChainEventType):\n            handlers = self.__chain_subscribers.get(etype, {})\n            return any(\n                self.__is_handler_enabled(handler)\n                for _, handler in handlers.values()\n            )\n        else:\n            handlers = self.__broadcast_subscribers.get(etype, {})\n            return any(\n                self.__is_handler_enabled(handler)\n                for handler in handlers.values()\n            )\n\n    def send_event(self, etype: Union[EventType, ChainEventType], data: Optional[Union[Dict, ChainEventData]] = None,\n                   priority: Optional[int] = DEFAULT_EVENT_PRIORITY) -> Optional[Event]:\n        \"\"\"\n        发送事件，根据事件类型决定是广播事件还是链式事件\n        :param etype: 事件类型 (EventType 或 ChainEventType)\n        :param data: 可选，事件数据\n        :param priority: 广播事件的优先级，默认为 10\n        :return: 如果是链式事件，返回处理后的事件数据；否则返回 None\n        \"\"\"\n        event = Event(etype, data, priority)\n        if isinstance(etype, EventType):\n            return self.__trigger_broadcast_event(event)\n        elif isinstance(etype, ChainEventType):\n            return self.__trigger_chain_event(event)\n        else:\n            logger.error(f\"Unknown event type: {etype}\")\n        return None\n\n    async def async_send_event(self, etype: Union[EventType, ChainEventType],\n                               data: Optional[Union[Dict, ChainEventData]] = None,\n                               priority: Optional[int] = DEFAULT_EVENT_PRIORITY) -> Optional[Event]:\n        \"\"\"\n        异步发送事件，根据事件类型决定是广播事件还是链式事件\n        :param etype: 事件类型 (EventType 或 ChainEventType)\n        :param data: 可选，事件数据\n        :param priority: 广播事件的优先级，默认为 10\n        :return: 如果是链式事件，返回处理后的事件数据；否则返回 None\n        \"\"\"\n        event = Event(etype, data, priority)\n        if isinstance(etype, EventType):\n            return self.__trigger_broadcast_event(event)\n        elif isinstance(etype, ChainEventType):\n            return await self.__trigger_chain_event_async(event)\n        else:\n            logger.error(f\"Unknown event type: {etype}\")\n        return None\n\n    def add_event_listener(self, event_type: Union[EventType, ChainEventType], handler: Callable,\n                           priority: Optional[int] = DEFAULT_EVENT_PRIORITY):\n        \"\"\"\n        注册事件处理器，将处理器添加到对应的事件订阅列表中\n        :param event_type: 事件类型 (EventType 或 ChainEventType)\n        :param handler: 处理器\n        :param priority: 可选，链式事件的优先级，默认为 10；广播事件不需要优先级\n        \"\"\"\n        with self.__lock:\n            handler_identifier = self.__get_handler_identifier(handler)\n\n            if isinstance(event_type, ChainEventType):\n                # 链式事件，按优先级排序\n                if event_type not in self.__chain_subscribers:\n                    self.__chain_subscribers[event_type] = {}\n                handlers = self.__chain_subscribers[event_type]\n                if handler_identifier in handlers:\n                    handlers.pop(handler_identifier)\n                else:\n                    logger.debug(\n                        f\"Subscribed to chain event: {event_type.value}, \"\n                        f\"Priority: {priority} - {handler_identifier}\")\n                handlers[handler_identifier] = (priority, handler)\n                # 根据优先级排序\n                self.__chain_subscribers[event_type] = dict(\n                    sorted(self.__chain_subscribers[event_type].items(), key=lambda x: x[1][0])\n                )\n            else:\n                # 广播事件\n                if event_type not in self.__broadcast_subscribers:\n                    self.__broadcast_subscribers[event_type] = {}\n                handlers = self.__broadcast_subscribers[event_type]\n                if handler_identifier in handlers:\n                    handlers.pop(handler_identifier)\n                else:\n                    logger.debug(f\"Subscribed to broadcast event: {event_type.value} - {handler_identifier}\")\n                handlers[handler_identifier] = handler\n\n    def remove_event_listener(self, event_type: Union[EventType, ChainEventType], handler: Callable):\n        \"\"\"\n        移除事件处理器，将处理器从对应事件的订阅列表中删除\n        :param event_type: 事件类型 (EventType 或 ChainEventType)\n        :param handler: 要移除的处理器\n        \"\"\"\n        with self.__lock:\n            handler_identifier = self.__get_handler_identifier(handler)\n\n            if isinstance(event_type, ChainEventType) and event_type in self.__chain_subscribers:\n                self.__chain_subscribers[event_type].pop(handler_identifier, None)\n                logger.debug(f\"Unsubscribed from chain event: {event_type.value} - {handler_identifier}\")\n            elif event_type in self.__broadcast_subscribers:\n                self.__broadcast_subscribers[event_type].pop(handler_identifier, None)\n                logger.debug(f\"Unsubscribed from broadcast event: {event_type.value} - {handler_identifier}\")\n\n    def disable_event_handler(self, target: Union[Callable, type]):\n        \"\"\"\n        禁用指定的事件处理器或事件处理器类\n        :param target: 处理器函数或类\n        \"\"\"\n        identifier = self.__get_handler_identifier(target)\n        if identifier in self.__disabled_handlers or identifier in self.__disabled_classes:\n            return\n        if isinstance(target, type):\n            self.__disabled_classes.add(identifier)\n            logger.debug(f\"Disabled event handler class - {identifier}\")\n        else:\n            self.__disabled_handlers.add(identifier)\n            logger.debug(f\"Disabled event handler - {identifier}\")\n\n    def enable_event_handler(self, target: Union[Callable, type]):\n        \"\"\"\n        启用指定的事件处理器或事件处理器类\n        :param target: 处理器函数或类\n        \"\"\"\n        identifier = self.__get_handler_identifier(target)\n        if isinstance(target, type):\n            self.__disabled_classes.discard(identifier)\n            logger.debug(f\"Enabled event handler class - {identifier}\")\n        else:\n            self.__disabled_handlers.discard(identifier)\n            logger.debug(f\"Enabled event handler - {identifier}\")\n\n    def visualize_handlers(self) -> List[Dict]:\n        \"\"\"\n        可视化所有事件处理器，包括是否被禁用的状态\n        :return: 处理器列表，包含事件类型、处理器标识符、优先级（如果有）和状态\n        \"\"\"\n\n        def parse_handler_data(data):\n            \"\"\"\n            解析处理器数据，判断是否包含优先级\n            :param data: 订阅者数据，可能是元组或单一值\n            :return: (priority, handler)，若没有优先级则返回 (None, handler)\n            \"\"\"\n            if isinstance(data, tuple) and len(data) == 2:\n                return data\n            return None, data\n\n        handler_info = []\n        # 统一处理广播事件和链式事件\n        for event_type, subscribers in {**self.__broadcast_subscribers, **self.__chain_subscribers}.items():\n            for handler_identifier, handler_data in subscribers.items():\n                # 解析优先级和处理器\n                priority, handler = parse_handler_data(handler_data)\n                # 检查处理器的启用状态\n                status = \"enabled\" if self.__is_handler_enabled(handler) else \"disabled\"\n                # 构建处理器信息字典\n                handler_dict = {\n                    \"event_type\": event_type.value,\n                    \"handler_identifier\": handler_identifier,\n                    \"status\": status\n                }\n                if priority is not None:\n                    handler_dict[\"priority\"] = priority\n                handler_info.append(handler_dict)\n        return handler_info\n\n    @classmethod\n    def __get_handler_identifier(cls, target: Union[Callable, type]) -> Optional[str]:\n        \"\"\"\n        获取处理器或处理器类的唯一标识符，包括模块名和类名/方法名\n        :param target: 处理器函数或类\n        :return: 唯一标识符\n        \"\"\"\n        # 统一使用 inspect.getmodule 来获取模块名\n        module = inspect.getmodule(target)\n        module_name = module.__name__ if module else \"unknown_module\"\n\n        # 使用 __qualname__ 获取目标的限定名\n        qualname = target.__qualname__\n        return f\"{module_name}.{qualname}\"\n\n    @classmethod\n    def __get_class_from_callable(cls, handler: Callable) -> Optional[str]:\n        \"\"\"\n        获取可调用对象所属类的唯一标识符\n        :param handler: 可调用对象（函数、方法等）\n        :return: 类的唯一标识符\n        \"\"\"\n        # 对于绑定方法，通过 __self__.__class__ 获取类\n        if inspect.ismethod(handler) and hasattr(handler, \"__self__\"):\n            return cls.__get_handler_identifier(handler.__self__.__class__)\n\n        # 对于类实例（实现了 __call__ 方法）\n        if not inspect.isfunction(handler) and hasattr(handler, \"__call__\"):\n            handler_cls = handler.__class__  # noqa\n            return cls.__get_handler_identifier(handler_cls)\n\n        # 对于未绑定方法、静态方法、类方法，使用 __qualname__ 提取类信息\n        qualname_parts = handler.__qualname__.split(\".\")\n        if len(qualname_parts) > 1:\n            class_name = \".\".join(qualname_parts[:-1])\n            module = inspect.getmodule(handler)\n            module_name = module.__name__ if module else \"unknown_module\"\n            return f\"{module_name}.{class_name}\"\n        return None\n\n    def __is_handler_enabled(self, handler: Callable) -> bool:\n        \"\"\"\n        检查处理器是否已启用（没有被禁用）\n        :param handler: 处理器函数\n        :return: 如果处理器启用则返回 True，否则返回 False\n        \"\"\"\n        # 获取处理器的唯一标识符\n        handler_id = self.__get_handler_identifier(handler)\n\n        # 获取处理器所属类的唯一标识符\n        class_id = self.__get_class_from_callable(handler)\n\n        # 检查处理器或类是否被禁用，只要其中之一被禁用则返回 False\n        if handler_id in self.__disabled_handlers or (class_id is not None and class_id in self.__disabled_classes):\n            return False\n\n        return True\n\n    def __trigger_chain_event(self, event: Event) -> Optional[Event]:\n        \"\"\"\n        触发链式事件，按顺序调用订阅的处理器，并记录处理耗时\n        \"\"\"\n        logger.debug(f\"Triggering synchronous chain event: {event}\")\n        dispatch = self.__dispatch_chain_event(event)\n        return event if dispatch else None\n\n    async def __trigger_chain_event_async(self, event: Event) -> Optional[Event]:\n        \"\"\"\n        异步触发链式事件，按顺序调用订阅的处理器，并记录处理耗时\n        \"\"\"\n        logger.debug(f\"Triggering asynchronous chain event: {event}\")\n        dispatch = await self.__dispatch_chain_event_async(event)\n        return event if dispatch else None\n\n    def __trigger_broadcast_event(self, event: Event):\n        \"\"\"\n        触发广播事件，将事件插入到优先级队列中\n        :param event: 要处理的事件对象\n        \"\"\"\n        logger.debug(f\"Triggering broadcast event: {event}\")\n        self.__event_queue.put((event.priority, event))\n\n    def __dispatch_chain_event(self, event: Event) -> bool:\n        \"\"\"\n        同步方式调度链式事件，按优先级顺序逐个调用事件处理器，并记录每个处理器的处理时间\n        :param event: 要调度的事件对象\n        \"\"\"\n        handlers = self.__chain_subscribers.get(event.event_type, {})\n        if not handlers:\n            logger.debug(f\"No handlers found for chain event: {event}\")\n            return False\n\n        # 过滤出启用的处理器\n        enabled_handlers = {handler_id: (priority, handler) for handler_id, (priority, handler) in handlers.items()\n                            if self.__is_handler_enabled(handler)}\n\n        if not enabled_handlers:\n            logger.debug(f\"No enabled handlers found for chain event: {event}. Skipping execution.\")\n            return False\n\n        self.__log_event_lifecycle(event, \"Started\")\n        for handler_id, (priority, handler) in enabled_handlers.items():\n            start_time = time.time()\n            self.__safe_invoke_handler(handler, event)\n            logger.debug(\n                f\"{self.__get_handler_identifier(handler)} (Priority: {priority}), \"\n                f\"completed in {time.time() - start_time:.3f}s for event: {event}\"\n            )\n        self.__log_event_lifecycle(event, \"Completed\")\n        return True\n\n    async def __dispatch_chain_event_async(self, event: Event) -> bool:\n        \"\"\"\n        异步方式调度链式事件，按优先级顺序逐个调用事件处理器，并记录每个处理器的处理时间\n        :param event: 要调度的事件对象\n        \"\"\"\n        handlers = self.__chain_subscribers.get(event.event_type, {})\n        if not handlers:\n            logger.debug(f\"No handlers found for chain event: {event}\")\n            return False\n\n        # 过滤出启用的处理器\n        enabled_handlers = {handler_id: (priority, handler) for handler_id, (priority, handler) in handlers.items()\n                            if self.__is_handler_enabled(handler)}\n\n        if not enabled_handlers:\n            logger.debug(f\"No enabled handlers found for chain event: {event}. Skipping execution.\")\n            return False\n\n        self.__log_event_lifecycle(event, \"Started\")\n        for handler_id, (priority, handler) in enabled_handlers.items():\n            start_time = time.time()\n            await self.__safe_invoke_handler_async(handler, event)\n            logger.debug(\n                f\"{self.__get_handler_identifier(handler)} (Priority: {priority}), \"\n                f\"completed in {time.time() - start_time:.3f}s for event: {event}\"\n            )\n        self.__log_event_lifecycle(event, \"Completed\")\n        return True\n\n    def __dispatch_broadcast_event(self, event: Event):\n        \"\"\"\n        异步方式调度广播事件，通过线程池逐个调用事件处理器\n        :param event: 要调度的事件对象\n        \"\"\"\n        handlers = self.__broadcast_subscribers.get(event.event_type, {})\n        if not handlers:\n            logger.debug(f\"No handlers found for broadcast event: {event}\")\n            return\n        # 为每个处理器提供独立的事件实例，防止某个处理器对 event_data 的修改影响其他处理器\n        for handler_id, handler in handlers.items():\n            # 仅浅拷贝顶层字典，避免不必要的深拷贝开销；这样可以隔离键级别的替换/赋值\n            if isinstance(event.event_data, dict):\n                event_data_copy = event.event_data.copy()\n            else:\n                event_data_copy = event.event_data\n            isolated_event = Event(event_type=event.event_type,\n                                   event_data=event_data_copy,\n                                   priority=event.priority)\n            if inspect.iscoroutinefunction(handler):\n                # 对于异步函数，直接在事件循环中运行\n                asyncio.run_coroutine_threadsafe(\n                    self.__safe_invoke_handler_async(handler, isolated_event),\n                    global_vars.loop\n                )\n            else:\n                # 对于同步函数，在线程池中运行\n                self.__executor.submit(self.__safe_invoke_handler, handler, isolated_event)\n\n    def __safe_invoke_handler(self, handler: Callable, event: Event):\n        \"\"\"\n        调用处理器，处理链式或广播事件\n        :param handler: 处理器\n        :param event: 事件对象\n        \"\"\"\n        if not self.__is_handler_enabled(handler):\n            logger.debug(f\"Handler {self.__get_handler_identifier(handler)} is disabled. Skipping execution\")\n            return\n\n        self.__invoke_handler_by_type_sync(handler, event)\n\n    async def __safe_invoke_handler_async(self, handler: Callable, event: Event):\n        \"\"\"\n        异步调用处理器，处理链式事件\n        :param handler: 处理器\n        :param event: 事件对象\n        \"\"\"\n        if not self.__is_handler_enabled(handler):\n            logger.debug(f\"Handler {self.__get_handler_identifier(handler)} is disabled. Skipping execution\")\n            return\n\n        await self.__invoke_handler_by_type_async(handler, event)\n\n    def __invoke_handler_by_type_sync(self, handler: Callable, event: Event):\n        \"\"\"\n        同步方式根据处理器类型调用相应的方法\n        :param handler: 处理器\n        :param event: 要处理的事件对象\n        \"\"\"\n        class_name, method_name = self.__parse_handler_names(handler)\n\n        from app.core.plugin import PluginManager\n        from app.core.module import ModuleManager\n\n        plugin_manager = PluginManager()\n        module_manager = ModuleManager()\n\n        if class_name in plugin_manager.get_plugin_ids():\n            # 插件处理器\n            plugin = plugin_manager.running_plugins.get(class_name)\n            if not plugin:\n                return\n            method = getattr(plugin, method_name, None)\n            if not method:\n                return\n            try:\n                method(event)\n            except Exception as e:\n                self.__handle_event_error(event=event, module_name=plugin.name,\n                                          class_name=class_name, method_name=method_name, e=e)\n        elif class_name in module_manager.get_module_ids():\n            # 模块处理器\n            module = module_manager.get_running_module(class_name)\n            if not module:\n                return\n            method = getattr(module, method_name, None)\n            if not method:\n                return\n            try:\n                method(event)\n            except Exception as e:\n                self.__handle_event_error(event=event, module_name=module.get_name(),\n                                          class_name=class_name, method_name=method_name, e=e)\n        else:\n            # 全局处理器\n            class_obj = self.__get_class_instance(class_name)\n            if not class_obj or not hasattr(class_obj, method_name):\n                return\n            method = getattr(class_obj, method_name, None)\n            if not method:\n                return\n            try:\n                method(event)\n            except Exception as e:\n                self.__handle_event_error(event=event, module_name=class_name,\n                                          class_name=class_name, method_name=method_name, e=e)\n\n    async def __invoke_handler_by_type_async(self, handler: Callable, event: Event):\n        \"\"\"\n        异步方式根据处理器类型调用相应的方法\n        :param handler: 处理器\n        :param event: 要处理的事件对象\n        \"\"\"\n        class_name, method_name = self.__parse_handler_names(handler)\n\n        from app.core.plugin import PluginManager\n        from app.core.module import ModuleManager\n\n        plugin_manager = PluginManager()\n        module_manager = ModuleManager()\n\n        if class_name in plugin_manager.get_plugin_ids():\n            await self.__invoke_plugin_method_async(plugin_manager, class_name, method_name, event)\n        elif class_name in module_manager.get_module_ids():\n            await self.__invoke_module_method_async(module_manager, class_name, method_name, event)\n        else:\n            await self.__invoke_global_method_async(class_name, method_name, event)\n\n    @staticmethod\n    def __parse_handler_names(handler: Callable) -> Tuple[str, str]:\n        \"\"\"\n        解析处理器的类名和方法名\n        :param handler: 处理器\n        :return: (class_name, method_name)\n        \"\"\"\n        names = handler.__qualname__.split(\".\")\n        return names[0], names[1]\n\n    async def __invoke_plugin_method_async(self, handler: Any, class_name: str, method_name: str, event: Event):\n        \"\"\"\n        异步调用插件方法\n        \"\"\"\n        plugin = handler.running_plugins.get(class_name)\n        if not plugin:\n            return\n        method = getattr(plugin, method_name, None)\n        if not method:\n            return\n        try:\n            if inspect.iscoroutinefunction(method):\n                await method(event)\n            else:\n                # 插件同步函数在异步环境中运行，避免阻塞\n                await run_in_threadpool(method, event)\n        except Exception as e:\n            self.__handle_event_error(event=event, module_name=plugin.name,\n                                      class_name=class_name, method_name=method_name, e=e)\n\n    async def __invoke_module_method_async(self, handler: Any, class_name: str, method_name: str, event: Event):\n        \"\"\"\n        异步调用模块方法\n        \"\"\"\n        module = handler.get_running_module(class_name)\n        if not module:\n            return\n        method = getattr(module, method_name, None)\n        if not method:\n            return\n        try:\n            if inspect.iscoroutinefunction(method):\n                await method(event)\n            else:\n                method(event)\n        except Exception as e:\n            self.__handle_event_error(event=event, module_name=module.get_name(),\n                                      class_name=class_name, method_name=method_name, e=e)\n\n    async def __invoke_global_method_async(self, class_name: str, method_name: str, event: Event):\n        \"\"\"\n        异步调用全局对象方法\n        \"\"\"\n        class_obj = self.__get_class_instance(class_name)\n        if not class_obj:\n            return\n        method = getattr(class_obj, method_name, None)\n        if not method:\n            return\n        try:\n            if inspect.iscoroutinefunction(method):\n                await method(event)\n            else:\n                method(event)\n        except Exception as e:\n            self.__handle_event_error(event=event, module_name=class_name,\n                                      class_name=class_name, method_name=method_name, e=e)\n\n    @staticmethod\n    def __get_class_instance(class_name: str):\n        \"\"\"\n        根据类名获取类实例，首先检查全局变量中是否存在该类，如果不存在则尝试动态导入模块。\n        :param class_name: 类的名称\n        :return: 类的实例\n        \"\"\"\n        # 检查类是否在全局变量中\n        if class_name in globals():\n            try:\n                class_obj = globals()[class_name]()\n                return class_obj\n            except Exception as e:\n                logger.error(f\"事件处理出错：创建全局类实例出错：{str(e)} - {traceback.format_exc()}\")\n                return None\n\n        # 如果类不在全局变量中，尝试动态导入模块并创建实例\n        try:\n            if class_name.endswith(\"Manager\"):\n                module_name = f\"app.core.{class_name[:-7].lower()}\"\n                module = importlib.import_module(module_name)\n            elif class_name.endswith(\"Chain\"):\n                module_name = f\"app.chain.{class_name[:-5].lower()}\"\n                module = importlib.import_module(module_name)\n            elif class_name.endswith(\"Helper\"):\n                # 特殊处理 Async 类\n                if class_name.startswith(\"Async\"):\n                    module_name = f\"app.helper.{class_name[5:-6].lower()}\"\n                else:\n                    module_name = f\"app.helper.{class_name[:-6].lower()}\"\n                module = importlib.import_module(module_name)\n            else:\n                module_name = f\"app.{class_name.lower()}\"\n                module = importlib.import_module(module_name)\n            if hasattr(module, class_name):\n                class_obj = getattr(module, class_name)()\n                return class_obj\n            else:\n                logger.debug(f\"事件处理出错：模块 {module_name} 中没有找到类 {class_name}\")\n        except Exception as e:\n            logger.debug(f\"事件处理出错：{str(e)} - {traceback.format_exc()}\")\n        return None\n\n    def __broadcast_consumer_loop(self):\n        \"\"\"\n        持续从队列中提取事件的后台广播消费者线程\n        \"\"\"\n        jitter_factor = 0.1\n        rate_limiter = ExponentialBackoffRateLimiter(base_wait=INITIAL_EVENT_QUEUE_IDLE_TIMEOUT_SECONDS,\n                                                     max_wait=MAX_EVENT_QUEUE_IDLE_TIMEOUT_SECONDS,\n                                                     backoff_factor=2.0,\n                                                     source=\"BroadcastConsumer\",\n                                                     enable_logging=False)\n        while self.__event.is_set():\n            try:\n                priority, event = self.__event_queue.get(timeout=rate_limiter.current_wait)\n                rate_limiter.reset()\n                self.__dispatch_broadcast_event(event)\n            except Empty:\n                rate_limiter.current_wait = rate_limiter.current_wait * random.uniform(1, 1 + jitter_factor)\n                rate_limiter.trigger_limit()\n\n    @staticmethod\n    def __log_event_lifecycle(event: Event, stage: str):\n        \"\"\"\n        记录事件的生命周期日志\n        \"\"\"\n        logger.debug(f\"{stage} - {event}\")\n\n    def __handle_event_error(self, event: Event, module_name: str,\n                             class_name: str, method_name: str, e: Exception):\n        \"\"\"\n        全局错误处理器，用于处理事件处理中的异常\n        \"\"\"\n        logger.error(f\"{module_name} 事件处理出错：{str(e)} - {traceback.format_exc()}\")\n\n        # 发送系统错误通知\n        from app.helper.message import MessageHelper\n        MessageHelper().put(title=f\"{module_name} 处理事件 {event.event_type} 时出错\",\n                            message=f\"{class_name}.{method_name}：{str(e)}\",\n                            role=\"system\")\n        self.send_event(\n            EventType.SystemError,\n            {\n                \"type\": \"event\",\n                \"event_type\": event.event_type,\n                \"event_handle\": f\"{class_name}.{method_name}\",\n                \"error\": str(e),\n                \"traceback\": traceback.format_exc()\n            }\n        )\n\n    def register(self, etype: Union[EventType, ChainEventType, List[Union[EventType, ChainEventType]], type],\n                 priority: Optional[int] = DEFAULT_EVENT_PRIORITY):\n        \"\"\"\n        事件注册装饰器，用于将函数注册为事件的处理器\n        :param etype:\n            - 单个事件类型成员 (如 EventType.MetadataScrape, ChainEventType.PluginAction)\n            - 事件类型类 (EventType, ChainEventType)\n            - 或事件类型成员的列表\n        :param priority: 可选，链式事件的优先级，默认为 DEFAULT_EVENT_PRIORITY\n        \"\"\"\n\n        def decorator(f: Callable):\n            # 将输入的事件类型统一转换为列表格式\n            if isinstance(etype, list):\n                # 传入的已经是列表，直接使用\n                event_list = etype\n            else:\n                # 不是列表则包裹成单一元素的列表\n                event_list = [etype]\n\n            # 遍历列表，处理每个事件类型\n            for event in event_list:\n                if isinstance(event, (EventType, ChainEventType)):\n                    self.add_event_listener(event, f, priority)\n                elif isinstance(event, type) and issubclass(event, (EventType, ChainEventType)):\n                    # 如果是 EventType 或 ChainEventType 类，提取该类中的所有成员\n                    for et in event.__members__.values():\n                        self.add_event_listener(et, f, priority)\n                else:\n                    raise ValueError(f\"无效的事件类型: {event}\")\n\n            return f\n\n        return decorator\n\n\n# 全局实例定义\neventmanager = EventManager()\n"
  },
  {
    "path": "app/core/meta/__init__.py",
    "content": "from .metabase import MetaBase\nfrom .metavideo import MetaVideo\nfrom .metaanime import MetaAnime\n"
  },
  {
    "path": "app/core/meta/customization.py",
    "content": "import regex as re\n\nfrom app.db.systemconfig_oper import SystemConfigOper\nfrom app.schemas.types import SystemConfigKey\nfrom app.utils.singleton import Singleton\n\n\nclass CustomizationMatcher(metaclass=Singleton):\n    \"\"\"\n    识别自定义占位符\n    \"\"\"\n\n    def __init__(self):\n        self.systemconfig = SystemConfigOper()\n        self.customization = None\n        self.custom_separator = None\n\n    def match(self, title=None):\n        \"\"\"\n        :param title: 资源标题或文件名\n        :return: 匹配结果\n        \"\"\"\n        if not title:\n            return \"\"\n        if not self.customization:\n            # 自定义占位符\n            customization = self.systemconfig.get(SystemConfigKey.Customization)\n            if not customization:\n                return \"\"\n            if isinstance(customization, str):\n                customization = customization.replace(\"\\n\", \";\").replace(\"|\", \";\").strip(\";\").split(\";\")\n            self.customization = \"|\".join([f\"({item})\" for item in customization])\n\n        customization_re = re.compile(r\"%s\" % self.customization)\n        # 处理重复多次的情况，保留先后顺序（按添加自定义占位符的顺序）\n        unique_customization = {}\n        for item in re.findall(customization_re, title):\n            if not isinstance(item, tuple):\n                item = (item,)\n            for i in range(len(item)):\n                if item[i] and unique_customization.get(item[i]) is None:\n                    unique_customization[item[i]] = i\n        unique_customization = list(dict(sorted(unique_customization.items(), key=lambda x: x[1])).keys())\n        separator = self.custom_separator or \"@\"\n        return separator.join(unique_customization)\n"
  },
  {
    "path": "app/core/meta/metaanime.py",
    "content": "import re\nimport traceback\n\nimport zhconv\nimport anitopy\nfrom app.core.meta.customization import CustomizationMatcher\nfrom app.core.meta.metabase import MetaBase\nfrom app.core.meta.releasegroup import ReleaseGroupsMatcher\nfrom app.log import logger\nfrom app.utils.string import StringUtils\nfrom app.schemas.types import MediaType\n\n\nclass MetaAnime(MetaBase):\n    \"\"\"\n    识别动漫\n    \"\"\"\n    _anime_no_words = ['CHS&CHT', 'MP4', 'GB MP4', 'WEB-DL']\n    _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\"\n    _fps_re = r\"(\\d{2,3})(?=FPS)\"\n\n    def __init__(self, title: str, subtitle: str = None, isfile: bool = False):\n        super().__init__(title, subtitle, isfile)\n        if not title:\n            return\n        # 调用第三方模块识别动漫\n        try:\n            original_title = title\n            # 字幕组信息会被预处理掉\n            anitopy_info_origin = anitopy.parse(title)\n            title = self.__prepare_title(title)\n            anitopy_info = anitopy.parse(title)\n            if anitopy_info:\n                # 名称\n                name = anitopy_info.get(\"anime_title\")\n                if not name or name in self._anime_no_words or (len(name) < 5 and not StringUtils.is_chinese(name)):\n                    anitopy_info = anitopy.parse(\"[ANIME]\" + title)\n                    if anitopy_info:\n                        name = anitopy_info.get(\"anime_title\")\n                if not name or name in self._anime_no_words or (len(name) < 5 and not StringUtils.is_chinese(name)):\n                    name_match = re.search(r'\\[(.+?)]', title)\n                    if name_match and name_match.group(1):\n                        name = name_match.group(1).strip()\n                # 拆份中英文名称\n                if name:\n                    _split_flag = True\n                    # 按/拆分中英文\n                    if name.find(\"/\") != -1:\n                        names = name.split(\"/\")\n                        if StringUtils.is_chinese(names[0]):\n                            self.cn_name = names[0]\n                            if len(names) > 1:\n                                self.en_name = names[1]\n                            _split_flag = False\n                        elif StringUtils.is_chinese(names[-1]):\n                            self.cn_name = names[-1]\n                            if len(names) > 1:\n                                self.en_name = names[0]\n                            _split_flag = False\n                        else:\n                            name = names[-1]\n                    # 拆分中英文\n                    if _split_flag:\n                        lastword_type = \"\"\n                        for word in name.split():\n                            if not word:\n                                continue\n                            if word.endswith(']'):\n                                word = word[:-1]\n                            if word.isdigit():\n                                if lastword_type == \"cn\":\n                                    self.cn_name = \"%s %s\" % (self.cn_name or \"\", word)\n                                elif lastword_type == \"en\":\n                                    self.en_name = \"%s %s\" % (self.en_name or \"\", word)\n                            elif StringUtils.is_chinese(word):\n                                self.cn_name = \"%s %s\" % (self.cn_name or \"\", word)\n                                lastword_type = \"cn\"\n                            else:\n                                self.en_name = \"%s %s\" % (self.en_name or \"\", word)\n                                lastword_type = \"en\"\n                if self.cn_name:\n                    _, self.cn_name, _, _, _, _ = StringUtils.get_keyword(self.cn_name)\n                    if self.cn_name:\n                        self.cn_name = re.sub(r'%s' % self._name_nostring_re, '', self.cn_name, flags=re.IGNORECASE).strip()\n                if self.en_name:\n                    self.en_name = re.sub(r'%s' % self._name_nostring_re, '', self.en_name, flags=re.IGNORECASE).strip().title()\n                    self._name = StringUtils.str_title(self.en_name)\n                # 年份\n                year = anitopy_info.get(\"anime_year\")\n                if str(year).isdigit():\n                    self.year = str(year)\n                # 季号\n                anime_season = anitopy_info.get(\"anime_season\")\n                if isinstance(anime_season, list):\n                    if len(anime_season) == 1:\n                        begin_season = anime_season[0]\n                        end_season = None\n                    else:\n                        begin_season = anime_season[0]\n                        end_season = anime_season[-1]\n                elif anime_season:\n                    begin_season = anime_season\n                    end_season = None\n                else:\n                    begin_season = None\n                    end_season = None\n                if begin_season:\n                    self.begin_season = int(begin_season)\n                    if end_season and int(end_season) != self.begin_season:\n                        self.end_season = int(end_season)\n                        self.total_season = (self.end_season - self.begin_season) + 1\n                    else:\n                        self.total_season = 1\n                    self.type = MediaType.TV\n                # 集号\n                episode_number = anitopy_info.get(\"episode_number\")\n                if isinstance(episode_number, list):\n                    if len(episode_number) == 1:\n                        begin_episode = episode_number[0]\n                        end_episode = None\n                    else:\n                        begin_episode = episode_number[0]\n                        end_episode = episode_number[-1]\n                elif episode_number:\n                    begin_episode = episode_number\n                    end_episode = None\n                else:\n                    begin_episode = None\n                    end_episode = None\n                if begin_episode:\n                    try:\n                        self.begin_episode = int(begin_episode)\n                        if end_episode and int(end_episode) != self.begin_episode:\n                            self.end_episode = int(end_episode)\n                            self.total_episode = (self.end_episode - self.begin_episode) + 1\n                        else:\n                            self.total_episode = 1\n                    except Exception as err:\n                        logger.debug(f\"解析集数失败：{str(err)} - {traceback.format_exc()}\")\n                        self.begin_episode = None\n                        self.end_episode = None\n                    self.type = MediaType.TV\n                # 类型\n                if not self.type:\n                    anime_type = anitopy_info.get('anime_type')\n                    if isinstance(anime_type, list):\n                        anime_type = anime_type[0]\n                    if anime_type and anime_type.upper() == \"TV\":\n                        self.type = MediaType.TV\n                    else:\n                        self.type = MediaType.MOVIE\n                # 分辨率\n                self.resource_pix = anitopy_info.get(\"video_resolution\")\n                if isinstance(self.resource_pix, list):\n                    self.resource_pix = self.resource_pix[0]\n                if self.resource_pix:\n                    if re.search(r'x', self.resource_pix, re.IGNORECASE):\n                        self.resource_pix = re.split(r'[Xx]', self.resource_pix)[-1] + \"p\"\n                    else:\n                        self.resource_pix = self.resource_pix.lower()\n                    if str(self.resource_pix).isdigit():\n                        self.resource_pix = str(self.resource_pix) + \"p\"\n                # 制作组/字幕组\n                self.resource_team = \\\n                    ReleaseGroupsMatcher().match(title=original_title) or \\\n                    anitopy_info_origin.get(\"release_group\") or None\n                # 自定义占位符\n                self.customization = CustomizationMatcher().match(title=original_title) or None\n                # 视频编码\n                self.video_encode = anitopy_info.get(\"video_term\")\n                if isinstance(self.video_encode, list):\n                    self.video_encode = self.video_encode[0]\n                # 音频编码\n                self.audio_encode = anitopy_info.get(\"audio_term\")\n                if isinstance(self.audio_encode, list):\n                    self.audio_encode = self.audio_encode[0]\n                # 帧率信息\n                self.__init_anime_fps(anitopy_info, original_title)\n                # 解析副标题，只要季和集\n                self.init_subtitle(self.org_string)\n                if not self._subtitle_flag and self.subtitle:\n                    self.init_subtitle(self.subtitle)\n            if not self.type:\n                self.type = MediaType.TV\n        except Exception as e:\n            logger.error(f\"解析动漫信息失败：{str(e)} - {traceback.format_exc()}\")\n\n    def __init_anime_fps(self, anitopy_info: dict, original_title: str):\n        \"\"\"\n        从原始标题中提取帧率信息，与MetaVideo保持完全一致的实现\n        \"\"\"\n        re_res = re.search(rf\"({self._fps_re})\", original_title, re.IGNORECASE)\n        if re_res:\n            fps_value = None\n            if re_res.group(1):  # FPS格式\n                fps_value = re_res.group(1)\n                    \n            if fps_value and fps_value.isdigit():\n                # 只存储纯数值\n                self.fps = int(fps_value)\n\n    @staticmethod\n    def __prepare_title(title: str):\n        \"\"\"\n        对命名进行预处理\n        \"\"\"\n        if not title:\n            return title\n        # 所有【】换成[]\n        title = title.replace(\"【\", \"[\").replace(\"】\", \"]\").strip()\n        # 截掉xx番剧漫\n        match = re.search(r\"新番|月?番|[日美国][漫剧]\", title)\n        if match and match.span()[1] < len(title) - 1:\n            title = re.sub(\".*番.|.*[日美国][漫剧].\", \"\", title)\n        elif match:\n            title = title[:title.rfind('[')]\n        # 截掉分类\n        first_item = title.split(']')[0]\n        if first_item and re.search(r\"[动漫画纪录片电影视连续剧集日美韩中港台海外亚洲华语大陆综艺原盘高清]{2,}|TV|Animation|Movie|Documentar|Anime\",\n                                    zhconv.convert(first_item, \"zh-hans\"),\n                                    re.IGNORECASE):\n            title = re.sub(r\"^[^]]*]\", \"\", title).strip()\n        # 去掉大小\n        title = re.sub(r'[0-9.]+\\s*[MGT]i?B(?![A-Z]+)', \"\", title, flags=re.IGNORECASE)\n        # 将TVxx改为xx\n        title = re.sub(r\"\\[TV\\s+(\\d{1,4})\", r\"[\\1\", title, flags=re.IGNORECASE)\n        # 将4K转为2160p\n        title = re.sub(r'\\[4k]', '2160p', title, flags=re.IGNORECASE)\n        # 处理/分隔的中英文标题\n        names = title.split(\"]\")\n        if len(names) > 1 and title.find(\"- \") == -1:\n            titles = []\n            for name in names:\n                if not name:\n                    continue\n                left_char = ''\n                if name.startswith('['):\n                    left_char = '['\n                    name = name[1:]\n                if name and name.find(\"/\") != -1:\n                    if name.split(\"/\")[-1].strip():\n                        titles.append(\"%s%s\" % (left_char, name.split(\"/\")[-1].strip()))\n                    else:\n                        titles.append(\"%s%s\" % (left_char, name.split(\"/\")[0].strip()))\n                elif name:\n                    if StringUtils.is_chinese(name) and not StringUtils.is_all_chinese(name):\n                        if not re.search(r\"\\[\\d+\", name, re.IGNORECASE):\n                            name = re.sub(r'[\\d|#:：\\-()（）\\u4e00-\\u9fff]', '', name).strip()\n                        if not name or name.strip().isdigit():\n                            continue\n                    if name == '[':\n                        titles.append(\"\")\n                    else:\n                        titles.append(\"%s%s\" % (left_char, name.strip()))\n            return \"]\".join(titles)\n        return title\n"
  },
  {
    "path": "app/core/meta/metabase.py",
    "content": "import traceback\nfrom dataclasses import dataclass\nfrom typing import Union, Optional, List, Self\n\nimport cn2an\nimport regex as re\n\nfrom app.log import logger\nfrom app.schemas.types import MediaType\nfrom app.utils.string import StringUtils\n\n\n@dataclass\nclass MetaBase(object):\n    \"\"\"\n    媒体信息基类\n    \"\"\"\n    # 是否处理的文件\n    isfile: bool = False\n    # 原标题字符串（未经过识别词处理）\n    title: str = \"\"\n    # 识别用字符串（经过识别词处理后）\n    org_string: Optional[str] = None\n    # 副标题\n    subtitle: Optional[str] = None\n    # 类型 电影、电视剧\n    type: MediaType = MediaType.UNKNOWN\n    # 识别的中文名\n    cn_name: Optional[str] = None\n    # 识别的英文名\n    en_name: Optional[str] = None\n    # 年份\n    year: Optional[str] = None\n    # 总季数\n    total_season: int = 0\n    # 识别的开始季 数字\n    begin_season: Optional[int] = None\n    # 识别的结束季 数字\n    end_season: Optional[int] = None\n    # 总集数\n    total_episode: int = 0\n    # 识别的开始集\n    begin_episode: Optional[int] = None\n    # 识别的结束集\n    end_episode: Optional[int] = None\n    # Partx Cd Dvd Disk Disc\n    part: Optional[str] = None\n    # 识别的资源类型\n    resource_type: Optional[str] = None\n    # 识别的效果\n    resource_effect: Optional[str] = None\n    # 识别的分辨率\n    resource_pix: Optional[str] = None\n    # 识别的制作组/字幕组\n    resource_team: Optional[str] = None\n    # 识别的自定义占位符\n    customization: Optional[str] = None\n    # 识别的流媒体平台\n    web_source: Optional[str] = None\n    # 视频编码\n    video_encode: Optional[str] = None\n    # 音频编码\n    audio_encode: Optional[str] = None\n    # 应用的识别词信息\n    apply_words: Optional[List[str]] = None\n    # 附加信息\n    tmdbid: int = None\n    doubanid: str = None\n    # 帧率信息（纯数值）\n    fps: Optional[int] = None\n\n\n    # 副标题解析\n    _subtitle_flag = False\n    _title_episodel_re = r\"Episode\\s+(\\d{1,4})\"\n    _subtitle_season_re = r\"(?<![全共]\\s*)[第\\s]+([0-9一二三四五六七八九十S\\-]+)\\s*季(?!\\s*[全共])\"\n    _subtitle_season_all_re = r\"[全共]\\s*([0-9一二三四五六七八九十]+)\\s*季\"\n    _subtitle_episode_re = r\"(?<![全共]\\s*)[第\\s]+([0-9一二三四五六七八九十百零EP]+)\\s*[集话話期幕](?!\\s*[全共])\"\n    _subtitle_episode_between_re = r\"[第]*\\s*([0-9一二三四五六七八九十百零]+)\\s*[集话話期幕]?\\s*-\\s*第*\\s*([0-9一二三四五六七八九十百零]+)\\s*[集话話期幕]\"\n    _subtitle_episode_all_re = r\"([0-9一二三四五六七八九十百零]+)\\s*集\\s*全|[全共]\\s*([0-9一二三四五六七八九十百零]+)\\s*[集话話期幕]\"\n\n    def __init__(self, title: str, subtitle: str = None, isfile: bool = False):\n        if not title:\n            return\n        self.org_string = title.strip() if title else None\n        self.subtitle = subtitle.strip() if subtitle else None\n        self.isfile = isfile\n\n    @property\n    def name(self) -> str:\n        \"\"\"\n        返回名称\n        \"\"\"\n        if self.cn_name and StringUtils.is_all_chinese(self.cn_name):\n            return self.cn_name\n        elif self.en_name:\n            return self.en_name\n        elif self.cn_name:\n            return self.cn_name\n        return \"\"\n\n    @name.setter\n    def name(self, name: str):\n        \"\"\"\n        设置名称\n        \"\"\"\n        if StringUtils.is_all_chinese(name):\n            self.cn_name = name\n        else:\n            self.en_name = name\n            self.cn_name = None\n\n    def init_subtitle(self, title_text: str):\n        \"\"\"\n        副标题识别\n        \"\"\"\n        if not title_text:\n            return\n        title_text = f\" {title_text} \"\n        if re.search(r\"%s\" % self._title_episodel_re, title_text, re.IGNORECASE):\n            episode_str = re.search(r'%s' % self._title_episodel_re, title_text, re.IGNORECASE)\n            if episode_str:\n                try:\n                    episode = int(episode_str.group(1))\n                except Exception as err:\n                    logger.debug(f'识别集失败：{str(err)} - {traceback.format_exc()}')\n                    return\n                if episode >= 10000:\n                    return\n                if self.begin_episode is None:\n                    self.begin_episode = episode\n                    self.total_episode = 1\n                self.type = MediaType.TV\n                self._subtitle_flag = True\n        elif re.search(r'[全第季集话話期幕]', title_text, re.IGNORECASE):\n            # 全x季 x季全\n            season_all_str = re.search(r\"%s\" % self._subtitle_season_all_re, title_text, re.IGNORECASE)\n            if season_all_str:\n                season_all = season_all_str.group(1)\n                if not season_all:\n                    season_all = season_all_str.group(2)\n                if season_all and self.begin_season is None and self.begin_episode is None:\n                    try:\n                        self.total_season = int(cn2an.cn2an(season_all.strip(), mode='smart'))\n                    except Exception as err:\n                        logger.debug(f'识别季失败：{str(err)} - {traceback.format_exc()}')\n                        return\n                    self.begin_season = 1\n                    self.end_season = self.total_season\n                    self.type = MediaType.TV\n                    self._subtitle_flag = True\n                return\n            # 第x季\n            season_str = re.search(r'%s' % self._subtitle_season_re, title_text, re.IGNORECASE)\n            if season_str:\n                seasons = season_str.group(1)\n                if seasons:\n                    seasons = seasons.upper().replace(\"S\", \"\").strip()\n                else:\n                    return\n                try:\n                    end_season = None\n                    if seasons.find('-') != -1:\n                        seasons = seasons.split('-')\n                        begin_season = int(cn2an.cn2an(seasons[0].strip(), mode='smart'))\n                        if len(seasons) > 1:\n                            end_season = int(cn2an.cn2an(seasons[1].strip(), mode='smart'))\n                    else:\n                        begin_season = int(cn2an.cn2an(seasons, mode='smart'))\n                except Exception as err:\n                    logger.debug(f'识别季失败：{str(err)} - {traceback.format_exc()}')\n                    return\n                if begin_season and begin_season > 100:\n                    return\n                if end_season and end_season > 100:\n                    return\n                if self.begin_season is None and isinstance(begin_season, int):\n                    self.begin_season = begin_season\n                    self.total_season = 1\n                if self.begin_season is not None \\\n                        and self.end_season is None \\\n                        and isinstance(end_season, int) \\\n                        and end_season != self.begin_season:\n                    self.end_season = end_season\n                    self.total_season = (self.end_season - self.begin_season) + 1\n                self.type = MediaType.TV\n                self._subtitle_flag = True\n            # 第x-x集 第x集-x集\n            episode_between_str = re.search(r'%s' % self._subtitle_episode_between_re, title_text, re.IGNORECASE)\n            if episode_between_str:\n                episodes = episode_between_str.groups()\n                if episodes:\n                    begin_episode = episodes[0]\n                    end_episode = episodes[1]\n                else:\n                    return\n                try:\n                    begin_episode = int(cn2an.cn2an(begin_episode.strip(), mode='smart'))\n                    end_episode = int(cn2an.cn2an(end_episode.strip(), mode='smart'))\n                except Exception as err:\n                    logger.debug(f'识别集失败：{str(err)} - {traceback.format_exc()}')\n                    return\n                if begin_episode and begin_episode >= 10000:\n                    return\n                if end_episode and end_episode >= 10000:\n                    return\n                if self.begin_episode is None and isinstance(begin_episode, int):\n                    self.begin_episode = begin_episode\n                    self.total_episode = 1\n                if self.begin_episode is not None \\\n                        and self.end_episode is None \\\n                        and isinstance(end_episode, int) \\\n                        and end_episode != self.begin_episode:\n                    self.end_episode = end_episode\n                    self.total_episode = (self.end_episode - self.begin_episode) + 1\n                self.type = MediaType.TV\n                self._subtitle_flag = True\n                return\n            # 第x集\n            episode_str = re.search(r'%s' % self._subtitle_episode_re, title_text, re.IGNORECASE)\n            if episode_str:\n                episodes = episode_str.group(1)\n                if episodes:\n                    episodes = episodes.upper().replace(\"E\", \"\").replace(\"P\", \"\").strip()\n                else:\n                    return\n                try:\n                    end_episode = None\n                    if episodes.find('-') != -1:\n                        episodes = episodes.split('-')\n                        begin_episode = int(cn2an.cn2an(episodes[0].strip(), mode='smart'))\n                        if len(episodes) > 1:\n                            end_episode = int(cn2an.cn2an(episodes[1].strip(), mode='smart'))\n                    else:\n                        begin_episode = int(cn2an.cn2an(episodes, mode='smart'))\n                except Exception as err:\n                    logger.debug(f'识别集失败：{str(err)} - {traceback.format_exc()}')\n                    return\n                if begin_episode and begin_episode >= 10000:\n                    return\n                if end_episode and end_episode >= 10000:\n                    return\n                if self.begin_episode is None and isinstance(begin_episode, int):\n                    self.begin_episode = begin_episode\n                    self.total_episode = 1\n                if self.begin_episode is not None \\\n                        and self.end_episode is None \\\n                        and isinstance(end_episode, int) \\\n                        and end_episode != self.begin_episode:\n                    self.end_episode = end_episode\n                    self.total_episode = (self.end_episode - self.begin_episode) + 1\n                self.type = MediaType.TV\n                self._subtitle_flag = True\n                return\n            # x集全/全x集\n            episode_all_str = re.search(r'%s' % self._subtitle_episode_all_re, title_text, re.IGNORECASE)\n            if episode_all_str:\n                episode_all = episode_all_str.group(1)\n                if not episode_all:\n                    episode_all = episode_all_str.group(2)\n                if episode_all and self.begin_episode is None:\n                    try:\n                        self.total_episode = int(cn2an.cn2an(episode_all.strip(), mode='smart'))\n                    except Exception as err:\n                        logger.debug(f'识别集失败：{str(err)} - {traceback.format_exc()}')\n                        return\n                    self.type = MediaType.TV\n                    self._subtitle_flag = True\n                return\n\n    @property\n    def season(self) -> str:\n        \"\"\"\n        返回开始季、结束季字符串，确定是剧集没有季的返回S01\n        \"\"\"\n        if self.begin_season is not None:\n            return \"S%s\" % str(self.begin_season).rjust(2, \"0\") \\\n                if self.end_season is None \\\n                else \"S%s-S%s\" % \\\n                     (str(self.begin_season).rjust(2, \"0\"),\n                      str(self.end_season).rjust(2, \"0\"))\n        else:\n            if self.type == MediaType.TV:\n                return \"S01\"\n            else:\n                return \"\"\n\n    @property\n    def sea(self) -> str:\n        \"\"\"\n        返回开始季字符串，确定是剧集没有季的返回空\n        \"\"\"\n        if self.begin_season is not None:\n            return self.season\n        else:\n            return \"\"\n\n    @property\n    def season_seq(self) -> str:\n        \"\"\"\n        返回begin_season 的数字，电视剧没有季的返回1\n        \"\"\"\n        if self.begin_season is not None:\n            return str(self.begin_season)\n        else:\n            if self.type == MediaType.TV:\n                return \"1\"\n            else:\n                return \"\"\n\n    @property\n    def season_list(self) -> List[int]:\n        \"\"\"\n        返回季的数组\n        \"\"\"\n        if self.begin_season is None:\n            if self.type == MediaType.TV:\n                return [1]\n            else:\n                return []\n        elif self.end_season is not None:\n            return [season for season in range(self.begin_season, self.end_season + 1)]\n        else:\n            return [self.begin_season]\n\n    @property\n    def episode(self) -> str:\n        \"\"\"\n        返回开始集、结束集字符串\n        \"\"\"\n        if self.begin_episode is not None:\n            return \"E%s\" % str(self.begin_episode).rjust(2, \"0\") \\\n                if self.end_episode is None \\\n                else \"E%s-E%s\" % \\\n                     (\n                         str(self.begin_episode).rjust(2, \"0\"),\n                         str(self.end_episode).rjust(2, \"0\"))\n        else:\n            return \"\"\n\n    @property\n    def episode_list(self) -> List[int]:\n        \"\"\"\n        返回集的数组\n        \"\"\"\n        if self.begin_episode is None:\n            return []\n        elif self.end_episode is not None:\n            return [episode for episode in range(self.begin_episode, self.end_episode + 1)]\n        else:\n            return [self.begin_episode]\n\n    @property\n    def episodes(self) -> str:\n        \"\"\"\n        返回集的并列表达方式，用于支持单文件多集\n        \"\"\"\n        return \"E%s\" % \"E\".join(str(episode).rjust(2, '0') for episode in self.episode_list)\n\n    @property\n    def episode_seqs(self) -> str:\n        \"\"\"\n        返回单文件多集的集数表达方式，用于支持单文件多集\n        \"\"\"\n        episodes = self.episode_list\n        if episodes:\n            # 集 xx\n            if len(episodes) == 1:\n                return str(episodes[0])\n            else:\n                return \"%s-%s\" % (episodes[0], episodes[-1])\n        else:\n            return \"\"\n\n    @property\n    def episode_seq(self) -> str:\n        \"\"\"\n        返回begin_episode 的数字\n        \"\"\"\n        episodes = self.episode_list\n        if episodes:\n            return str(episodes[0])\n        else:\n            return \"\"\n\n    @property\n    def season_episode(self) -> str:\n        \"\"\"\n        返回季集字符串\n        \"\"\"\n        if self.type == MediaType.TV:\n            seaion = self.season\n            episode = self.episode\n            if seaion and episode:\n                return \"%s %s\" % (seaion, episode)\n            elif seaion:\n                return \"%s\" % seaion\n            elif episode:\n                return \"%s\" % episode\n        else:\n            return \"\"\n        return \"\"\n\n    @property\n    def resource_term(self) -> str:\n        \"\"\"\n        返回资源类型字符串，含分辨率\n        \"\"\"\n        ret_string = \"\"\n        if self.resource_type:\n            ret_string = f\"{ret_string} {self.resource_type}\"\n        if self.resource_effect:\n            ret_string = f\"{ret_string} {self.resource_effect}\"\n        if self.resource_pix:\n            ret_string = f\"{ret_string} {self.resource_pix}\"\n        return ret_string\n\n    @property\n    def edition(self) -> str:\n        \"\"\"\n        返回资源类型字符串，不含分辨率\n        \"\"\"\n        ret_string = \"\"\n        if self.resource_type:\n            ret_string = f\"{ret_string} {self.resource_type}\"\n        if self.resource_effect:\n            ret_string = f\"{ret_string} {self.resource_effect}\"\n        return ret_string.strip()\n\n    @property\n    def release_group(self) -> str:\n        \"\"\"\n        返回发布组/字幕组字符串\n        \"\"\"\n        if self.resource_team:\n            return self.resource_team\n        else:\n            return \"\"\n\n    @property\n    def video_term(self) -> str:\n        \"\"\"\n        返回视频编码\n        \"\"\"\n        return self.video_encode or \"\"\n\n    @property\n    def audio_term(self) -> str:\n        \"\"\"\n        返回音频编码\n        \"\"\"\n        return self.audio_encode or \"\"\n\n    @property\n    def frame_rate(self) -> int:\n        \"\"\"\n        返回帧率信息\n        \"\"\"\n        return self.fps or None\n\n    def is_in_season(self, season: Union[list, int, str]) -> bool:\n        \"\"\"\n        是否包含季\n        \"\"\"\n        if isinstance(season, list):\n            if self.end_season is not None:\n                meta_season = list(range(self.begin_season, self.end_season + 1))\n            else:\n                if self.begin_season is not None:\n                    meta_season = [self.begin_season]\n                else:\n                    meta_season = [1]\n\n            return set(meta_season).issuperset(set(season))\n        else:\n            if self.end_season is not None:\n                return self.begin_season <= int(season) <= self.end_season\n            else:\n                if self.begin_season is not None:\n                    return int(season) == self.begin_season\n                else:\n                    return int(season) == 1\n\n    def is_in_episode(self, episode: Union[list, int, str]) -> bool:\n        \"\"\"\n        是否包含集\n        \"\"\"\n        if isinstance(episode, list):\n            if self.end_episode is not None:\n                meta_episode = list(range(self.begin_episode, self.end_episode + 1))\n            else:\n                meta_episode = [self.begin_episode]\n            return set(meta_episode).issuperset(set(episode))\n        else:\n            if self.end_episode is not None:\n                return self.begin_episode <= int(episode) <= self.end_episode\n            else:\n                return int(episode) == self.begin_episode\n\n    def set_season(self, sea: Union[list, int, str]):\n        \"\"\"\n        更新季\n        \"\"\"\n        if not sea:\n            return\n        if isinstance(sea, list):\n            if len(sea) == 1 and str(sea[0]).isdigit():\n                self.begin_season = int(sea[0])\n                self.end_season = None\n            elif len(sea) > 1 and str(sea[0]).isdigit() and str(sea[-1]).isdigit():\n                self.begin_season = int(sea[0])\n                self.end_season = int(sea[-1])\n        elif str(sea).isdigit():\n            self.begin_season = int(sea)\n            self.end_season = None\n\n    def set_episode(self, ep: Union[list, int, str]):\n        \"\"\"\n        更新集\n        \"\"\"\n        if not ep:\n            return\n        if isinstance(ep, list):\n            if len(ep) == 1 and str(ep[0]).isdigit():\n                self.begin_episode = int(ep[0])\n                self.end_episode = None\n            elif len(ep) > 1 and str(ep[0]).isdigit() and str(ep[-1]).isdigit():\n                self.begin_episode = int(ep[0])\n                self.end_episode = int(ep[-1])\n                self.total_episode = (self.end_episode - self.begin_episode) + 1\n        elif str(ep).isdigit():\n            self.begin_episode = int(ep)\n            self.end_episode = None\n\n    def set_episodes(self, begin: int, end: int):\n        \"\"\"\n        设置开始集结束集\n        \"\"\"\n        if begin:\n            self.begin_episode = begin\n        if end:\n            self.end_episode = end\n        if self.begin_episode and self.end_episode:\n            self.total_episode = (self.end_episode - self.begin_episode) + 1\n\n    def merge(self, meta: Self):\n        \"\"\"\n        合并Meta信息\n        \"\"\"\n        # 类型\n        if self.type == MediaType.UNKNOWN \\\n                and meta.type != MediaType.UNKNOWN:\n            self.type = meta.type\n        # 名称\n        if not self.name:\n            self.cn_name = meta.cn_name\n            self.en_name = meta.en_name\n        # 年份\n        if not self.year:\n            self.year = meta.year\n        # 季\n        if (self.type == MediaType.TV\n                and self.begin_season is None):\n            self.begin_season = meta.begin_season\n            self.end_season = meta.end_season\n            self.total_season = meta.total_season\n        # 开始集\n        if (self.type == MediaType.TV\n                and self.begin_episode is None):\n            self.begin_episode = meta.begin_episode\n            self.end_episode = meta.end_episode\n            self.total_episode = meta.total_episode\n        # 版本\n        if not self.resource_type:\n            self.resource_type = meta.resource_type\n        # 分辨率\n        if not self.resource_pix:\n            self.resource_pix = meta.resource_pix\n        # 制作组/字幕组\n        if not self.resource_team:\n            self.resource_team = meta.resource_team\n        # 自定义占位符\n        if not self.customization:\n            self.customization = meta.customization\n        # 特效\n        if not self.resource_effect:\n            self.resource_effect = meta.resource_effect\n        # 视频编码\n        if not self.video_encode:\n            self.video_encode = meta.video_encode\n        # 音频编码\n        if not self.audio_encode:\n            self.audio_encode = meta.audio_encode\n        # 帧率信息\n        if not self.fps:\n            self.fps = meta.fps\n        # Part\n        if not self.part:\n            self.part = meta.part\n        # tmdbid\n        if not self.tmdbid and meta.tmdbid:\n            self.tmdbid = meta.tmdbid\n        # doubanid\n        if not self.doubanid and meta.doubanid:\n            self.doubanid = meta.doubanid\n\n    def to_dict(self):\n        \"\"\"\n        转为字典\n        \"\"\"\n        dicts = vars(self).copy()\n        dicts[\"type\"] = self.type.value if self.type else None\n        dicts[\"season_episode\"] = self.season_episode\n        dicts[\"edition\"] = self.edition\n        dicts[\"name\"] = self.name\n        dicts[\"episode_list\"] = self.episode_list\n        return dicts\n"
  },
  {
    "path": "app/core/meta/metavideo.py",
    "content": "import re\nfrom typing import Optional\n\nfrom Pinyin2Hanzi import is_pinyin\n\nfrom app.core.config import settings\nfrom app.core.meta.customization import CustomizationMatcher\nfrom app.core.meta.metabase import MetaBase\nfrom app.core.meta.releasegroup import ReleaseGroupsMatcher\nfrom app.schemas.types import MediaType\nfrom app.utils.string import StringUtils\nfrom app.utils.tokens import Tokens\nfrom app.core.meta.streamingplatform import StreamingPlatforms\n\n\nclass MetaVideo(MetaBase):\n    \"\"\"\n    识别电影、电视剧\n    \"\"\"\n    # 控制标位区\n    _stop_name_flag = False\n    _stop_cnname_flag = False\n    _last_token = \"\"\n    _last_token_type = \"\"\n    _continue_flag = True\n    _unknown_name_str = \"\"\n    _source = \"\"\n    _effect = []\n    # 正则式区\n    _season_re = r\"S(\\d{3})|^S(\\d{1,3})$|S(\\d{1,3})E\"\n    _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})\"\n    _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}$)\"\n    _roman_numerals = r\"^(?=[MDCLXVI])M*(C[MD]|D?C{0,3})(X[CL]|L?X{0,3})(I[XV]|V?I{0,3})$\"\n    _source_re = r\"^BLURAY$|^HDTV$|^UHDTV$|^HDDVD$|^WEBRIP$|^DVDRIP$|^BDRIP$|^BLU$|^WEB$|^BD$|^HDRip$|^REMUX$|^UHD$\"\n    _effect_re = r\"^SDR$|^HDR\\d*$|^DOLBY$|^DOVI$|^DV$|^3D$|^REPACK$|^HLG$|^HDR10(\\+|Plus)$|^EDR$|^HQ$\"\n    _resources_type_re = r\"%s|%s\" % (_source_re, _effect_re)\n    _name_no_begin_re = r\"^[\\[【].+?[\\]】]\"\n    _name_no_chinese_re = r\".*版|.*字幕\"\n    _name_se_words = ['共', '第', '季', '集', '话', '話', '期']\n    _name_movie_words = ['剧场版', '劇場版', '电影版', '電影版']\n    _name_nostring_re = r\"^PTS|^JADE|^AOD|^CHC|^[A-Z]{1,4}TV[\\-0-9UVHDK]*\" \\\n                        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$\" \\\n                        r\"|[第\\s共]+[0-9一二三四五六七八九十\\-\\s]+季\" \\\n                        r\"|[第\\s共]+[0-9一二三四五六七八九十百零\\-\\s]+[集话話]\" \\\n                        r\"|连载|日剧|美剧|电视剧|动画片|动漫|欧美|西德|日韩|超高清|高清|无水印|下载|蓝光|翡翠台|梦幻天堂·龙网|★?\\d*月?新番\" \\\n                        r\"|最终季|合集|[多中国英葡法俄日韩德意西印泰台港粤双文语简繁体特效内封官译外挂]+字幕|版本|出品|台版|港版|\\w+字幕组|\\w+字幕社\" \\\n                        r\"|未删减版|UNCUT$|UNRATE$|WITH EXTRAS$|RERIP$|SUBBED$|PROPER$|REPACK$|SEASON$|EPISODE$|Complete$|Extended$|Extended Version$\" \\\n                        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}\" \\\n                        r\"|CD[\\s.]*[1-9]|DVD[\\s.]*[1-9]|DISK[\\s.]*[1-9]|DISC[\\s.]*[1-9]\" \\\n                        r\"|[248]K|\\d{3,4}[PIX]+\" \\\n                        r\"|CD[\\s.]*[1-9]|DVD[\\s.]*[1-9]|DISK[\\s.]*[1-9]|DISC[\\s.]*[1-9]|\\s+GB\"\n    _resources_pix_re = r\"^[SBUHD]*(\\d{3,4}[PI]+)|\\d{3,4}X(\\d{3,4})\"\n    _resources_pix_re2 = r\"(^[248]+K)\"\n    _video_encode_re = r\"^(H26[45])$|^(x26[45])$|^AVC$|^HEVC$|^VC\\d?$|^MPEG\\d?$|^Xvid$|^DivX$|^AV1$|^HDR\\d*$|^AVS(\\+|[23])$\"\n    _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$\"\n    _fps_re = r\"(\\d{2,3})(?=FPS)\"\n    def __init__(self, title: str, subtitle: str = None, isfile: bool = False):\n        \"\"\"\n        初始化\n        :param title: 标题，文件为去掉了后缀\n        :param subtitle: 副标题\n        :param isfile: 是否是文件名\n        \"\"\"\n        super().__init__(title, subtitle, isfile)\n        if not title:\n            return\n        original_title = title\n        self._source = \"\"\n        self._effect = []\n        self._index = 0\n        # 判断是否纯数字命名\n        if isfile \\\n                and title.isdigit() \\\n                and len(title) < 5:\n            self.begin_episode = int(title)\n            self.type = MediaType.TV\n            return\n        # 全名为Season xx 及 Sxx 直接返回\n        season_full_res = re.search(r\"^(?:Season\\s+|S)(\\d{1,3})$\", title, re.IGNORECASE)\n        if season_full_res:\n            self.type = MediaType.TV\n            season = season_full_res.group(1)\n            if season:\n                self.begin_season = int(season)\n                self.total_season = 1\n            return\n        # 去掉名称中第1个[]的内容\n        title = re.sub(r'%s' % self._name_no_begin_re, \"\", title, count=1)\n        # 把xxxx-xxxx年份换成前一个年份，常出现在季集上\n        title = re.sub(r'([\\s.]+)(\\d{4})-(\\d{4})', r'\\1\\2', title)\n        # 把大小去掉\n        title = re.sub(r'[0-9.]+\\s*[MGT]i?B(?![A-Z]+)', \"\", title, flags=re.IGNORECASE)\n        # 把年月日去掉\n        title = re.sub(r'\\d{4}[\\s._-]\\d{1,2}[\\s._-]\\d{1,2}', \"\", title)\n        # 拆分tokens\n        tokens = Tokens(title)\n        # 实例化StreamingPlatforms对象\n        streaming_platforms = StreamingPlatforms()\n        # 解析名称、年份、季、集、资源类型、分辨率等\n        token = tokens.get_next()\n        while token:\n            self._index += 1  # 更新当前处理的token索引\n            # Part\n            self.__init_part(token, tokens)\n            # 标题\n            if self._continue_flag:\n                self.__init_name(token)\n            # 年份\n            if self._continue_flag:\n                self.__init_year(token)\n            # 分辨率\n            if self._continue_flag:\n                self.__init_resource_pix(token)\n            # 季\n            if self._continue_flag:\n                self.__init_season(token)\n            # 集\n            if self._continue_flag:\n                self.__init_episode(token)\n            # 资源类型\n            if self._continue_flag:\n                self.__init_resource_type(token)\n            # 流媒体平台\n            if self._continue_flag:\n                self.__init_web_source(token, tokens, streaming_platforms)\n            # 视频编码\n            if self._continue_flag:\n                self.__init_video_encode(token)\n            # 音频编码\n            if self._continue_flag:\n                self.__init_audio_encode(token)\n            # 帧率\n            if self._continue_flag:\n                self.__init_fps(token)\n            # 取下一个，直到没有为卡\n            token = tokens.get_next()\n            self._continue_flag = True\n        # 合成质量\n        if self._effect:\n            self._effect.reverse()\n            self.resource_effect = \" \".join(self._effect)\n        if self._source:\n            self.resource_type = self._source.strip()\n        # 提取原盘DIY\n        if self.resource_type and \"BluRay\" in self.resource_type:\n            if (self.subtitle and re.findall(r'D[Ii]Y', self.subtitle)) \\\n                    or re.findall(r'-D[Ii]Y@', original_title):\n                self.resource_type = f\"{self.resource_type} DIY\"\n        # 解析副标题，只要季和集\n        self.init_subtitle(self.org_string)\n        if not self._subtitle_flag and self.subtitle:\n            self.init_subtitle(self.subtitle)\n        # 去掉名字中不需要的干扰字符，过短的纯数字不要\n        self.cn_name = self.__fix_name(self.cn_name)\n        self.en_name = StringUtils.str_title(self.__fix_name(self.en_name))\n        # 处理part\n        if self.part and self.part.upper() == \"PART\":\n            self.part = None\n        # 没有中文标题时，尝试中描述中获取中文名\n        if not self.cn_name and self.en_name and self.subtitle:\n            if self.__is_pinyin(self.en_name):\n                # 英文名是拼音\n                cn_name = self.__get_title_from_description(self.subtitle)\n                if cn_name and len(cn_name) == len(self.en_name.split()):\n                    # 中文名和拼音单词数相同，认为是中文名\n                    self.cn_name = cn_name\n        # 制作组/字幕组\n        self.resource_team = ReleaseGroupsMatcher().match(title=original_title) or None\n        # 自定义占位符\n        self.customization = CustomizationMatcher().match(title=original_title) or None\n\n    @staticmethod\n    def __get_title_from_description(description: str) -> Optional[str]:\n        \"\"\"\n        从描述中提取标题\n        \"\"\"\n        if not description:\n            return None\n        titles = re.split(r'[\\s/|]+', description)\n        if StringUtils.is_chinese(titles[0]):\n            return titles[0]\n        return None\n\n    @staticmethod\n    def __is_pinyin(name_str: Optional[str]) -> bool:\n        \"\"\"\n        判断是否拼音\n        \"\"\"\n        if not name_str:\n            return False\n        for n in name_str.lower().split():\n            if not is_pinyin(n):\n                return False\n        return True\n\n    def __fix_name(self, name: Optional[str]):\n        \"\"\"\n        去掉名字中不需要的干扰字符\n        \"\"\"\n        if not name:\n            return name\n        name = re.sub(r'%s' % self._name_nostring_re, '', name,\n                      flags=re.IGNORECASE).strip()\n        name = re.sub(r'\\s+', ' ', name)\n        if name.isdecimal() \\\n                and int(name) < 1800 \\\n                and not self.year \\\n                and not self.begin_season \\\n                and not self.resource_pix \\\n                and not self.resource_type \\\n                and not self.audio_encode \\\n                and not self.video_encode:\n            if self.begin_episode is None:\n                self.begin_episode = int(name)\n                name = None\n            elif self.is_in_episode(int(name)) and not self.begin_season:\n                name = None\n        return name\n\n    def __init_name(self, token: Optional[str]):\n        \"\"\"\n        识别名称\n        \"\"\"\n        if not token:\n            return\n        # 回收标题\n        if self._unknown_name_str:\n            if not self.cn_name:\n                if not self.en_name:\n                    self.en_name = self._unknown_name_str\n                elif self._unknown_name_str != self.year:\n                    self.en_name = \"%s %s\" % (self.en_name, self._unknown_name_str)\n                self._last_token_type = \"enname\"\n            self._unknown_name_str = \"\"\n        if self._stop_name_flag:\n            return\n        if token.upper() == \"AKA\":\n            self._continue_flag = False\n            self._stop_name_flag = True\n            return\n        if token in self._name_se_words:\n            self._last_token_type = 'name_se_words'\n            return\n        if StringUtils.is_chinese(token):\n            # 含有中文，直接做为标题（连着的数字或者英文会保留），且不再取用后面出现的中文\n            self._last_token_type = \"cnname\"\n            if not self.cn_name:\n                self.cn_name = token\n            elif not self._stop_cnname_flag:\n                if re.search(\"%s\" % self._name_movie_words, token, flags=re.IGNORECASE) \\\n                        or (not re.search(\"%s\" % self._name_no_chinese_re, token, flags=re.IGNORECASE)\n                            and not re.search(\"%s\" % self._name_se_words, token, flags=re.IGNORECASE)):\n                    self.cn_name = \"%s %s\" % (self.cn_name, token)\n                self._stop_cnname_flag = True\n        else:\n            is_roman_digit = re.search(self._roman_numerals, token)\n            # 阿拉伯数字或者罗马数字\n            if token.isdigit() or is_roman_digit:\n                # 第季集后面的不要\n                if self._last_token_type == 'name_se_words':\n                    return\n                if self.name:\n                    # 名字后面以 0 开头的不要，极有可能是集\n                    if token.startswith('0'):\n                        return\n                    # 检查是否真正的数字\n                    if token.isdigit():\n                        try:\n                            int(token)\n                        except ValueError:\n                            return\n                    # 中文名后面跟的数字不是年份的极有可能是集\n                    if not is_roman_digit \\\n                            and self._last_token_type == \"cnname\" \\\n                            and int(token) < 1900:\n                        return\n                    if (token.isdigit() and len(token) < 4) or is_roman_digit:\n                        # 4位以下的数字或者罗马数字，拼装到已有标题中\n                        if self._last_token_type == \"cnname\":\n                            self.cn_name = \"%s %s\" % (self.cn_name, token)\n                        elif self._last_token_type == \"enname\":\n                            self.en_name = \"%s %s\" % (self.en_name, token)\n                        self._continue_flag = False\n                    elif token.isdigit() and len(token) == 4:\n                        # 4位数字，可能是年份，也可能真的是标题的一部分，也有可能是集\n                        if not self._unknown_name_str:\n                            self._unknown_name_str = token\n                else:\n                    # 名字未出现前的第一个数字，记下来\n                    if not self._unknown_name_str:\n                        self._unknown_name_str = token\n            elif re.search(r\"%s\" % self._season_re, token, re.IGNORECASE):\n                # 季的处理\n                if self.en_name and re.search(r\"SEASON$\", self.en_name, re.IGNORECASE):\n                    # 如果匹配到季，英文名结尾为Season，说明Season属于标题，不应在后续作为干扰词去除\n                    self.en_name += ' '\n                self._stop_name_flag = True\n                return\n            elif re.search(r\"%s\" % self._episode_re, token, re.IGNORECASE) \\\n                    or re.search(r\"(%s)\" % self._resources_type_re, token, re.IGNORECASE) \\\n                    or re.search(r\"%s\" % self._resources_pix_re, token, re.IGNORECASE):\n                # 集、来源、版本等不要\n                self._stop_name_flag = True\n                return\n            else:\n                # 后缀名不要\n                media_exts = settings.RMT_MEDIAEXT + settings.RMT_SUBEXT + settings.RMT_AUDIOEXT\n                if \".%s\".lower() % token in media_exts:\n                    return\n                # 英文或者英文+数字，拼装起来\n                if self.en_name:\n                    self.en_name = \"%s %s\" % (self.en_name, token)\n                else:\n                    self.en_name = token\n                self._last_token_type = \"enname\"\n\n    def __init_part(self, token: str, tokens: Tokens):\n        \"\"\"\n        识别Part\n        \"\"\"\n        if not self.name:\n            return\n        if not self.year \\\n                and not self.begin_season \\\n                and not self.begin_episode \\\n                and not self.resource_pix \\\n                and not self.resource_type:\n            return\n        re_res = re.search(r\"%s\" % self._part_re, token, re.IGNORECASE)\n        if re_res:\n            if not self.part:\n                self.part = re_res.group(1)\n            nextv = tokens.cur()\n            if nextv \\\n                    and ((nextv.isdigit() and (len(nextv) == 1 or len(nextv) == 2 and nextv.startswith('0')))\n                         or nextv.upper() in ['A', 'B', 'C', 'I', 'II', 'III']):\n                self.part = \"%s%s\" % (self.part, nextv)\n                tokens.get_next()\n            self._last_token_type = \"part\"\n            self._continue_flag = False\n            # self._stop_name_flag = False\n\n    def __init_year(self, token: str):\n        \"\"\"\n        识别年份\n        \"\"\"\n        if not self.name:\n            return\n        if not token.isdigit():\n            return\n        if len(token) != 4:\n            return\n        if not 1900 < int(token) < 2050:\n            return\n        if self.year:\n            if self.en_name:\n                self.en_name = \"%s %s\" % (self.en_name.strip(), self.year)\n            elif self.cn_name:\n                self.cn_name = \"%s %s\" % (self.cn_name, self.year)\n        elif self.en_name and re.search(r\"SEASON$\", self.en_name, re.IGNORECASE):\n            # 如果匹配到年，且英文名结尾为Season，说明Season属于标题，不应在后续作为干扰词去除\n            self.en_name += ' '\n        self.year = token\n        self._last_token_type = \"year\"\n        self._continue_flag = False\n        self._stop_name_flag = True\n\n    def __init_resource_pix(self, token: str):\n        \"\"\"\n        识别分辨率\n        \"\"\"\n        if not self.name:\n            return\n        re_res = re.findall(r\"%s\" % self._resources_pix_re, token, re.IGNORECASE)\n        if re_res:\n            self._last_token_type = \"pix\"\n            self._continue_flag = False\n            self._stop_name_flag = True\n            resource_pix = None\n            for pixs in re_res:\n                if isinstance(pixs, tuple):\n                    pix_t = None\n                    for pix_i in pixs:\n                        if pix_i:\n                            pix_t = pix_i\n                            break\n                    if pix_t:\n                        resource_pix = pix_t\n                else:\n                    resource_pix = pixs\n                if resource_pix and not self.resource_pix:\n                    self.resource_pix = resource_pix.lower()\n                    break\n            if self.resource_pix \\\n                    and self.resource_pix.isdigit() \\\n                    and self.resource_pix[-1] not in 'kpi':\n                self.resource_pix = \"%sp\" % self.resource_pix\n        else:\n            re_res = re.search(r\"%s\" % self._resources_pix_re2, token, re.IGNORECASE)\n            if re_res:\n                self._last_token_type = \"pix\"\n                self._continue_flag = False\n                self._stop_name_flag = True\n                if not self.resource_pix:\n                    self.resource_pix = re_res.group(1).lower()\n\n    def __init_season(self, token: str):\n        \"\"\"\n        识别季\n        \"\"\"\n        re_res = re.findall(r\"%s\" % self._season_re, token, re.IGNORECASE)\n        if re_res:\n            self._last_token_type = \"season\"\n            self.type = MediaType.TV\n            self._stop_name_flag = True\n            self._continue_flag = True\n            for se in re_res:\n                if isinstance(se, tuple):\n                    se_t = None\n                    for se_i in se:\n                        if se_i and str(se_i).isdigit():\n                            se_t = se_i\n                            break\n                    if se_t:\n                        se = int(se_t)\n                    else:\n                        break\n                else:\n                    se = int(se)\n                if self.begin_season is None:\n                    self.begin_season = se\n                    self.total_season = 1\n                else:\n                    if se > self.begin_season:\n                        self.end_season = se\n                        self.total_season = (self.end_season - self.begin_season) + 1\n                        if self.isfile and self.total_season > 1:\n                            self.end_season = None\n                            self.total_season = 1\n        elif token.isdigit():\n            try:\n                int(token)\n            except ValueError:\n                return\n            if self._last_token_type == \"SEASON\" \\\n                    and self.begin_season is None \\\n                    and len(token) < 3:\n                self.begin_season = int(token)\n                self.total_season = 1\n                self._last_token_type = \"season\"\n                self._stop_name_flag = True\n                self._continue_flag = False\n                self.type = MediaType.TV\n        elif token.upper() == \"SEASON\" and self.begin_season is None:\n            self._last_token_type = \"SEASON\"\n        elif self.type == MediaType.TV and self.begin_season is None:\n            self.begin_season = 1\n\n    def __init_episode(self, token: str):\n        \"\"\"\n        识别集\n        \"\"\"\n        re_res = re.findall(r\"%s\" % self._episode_re, token, re.IGNORECASE)\n        if re_res:\n            self._last_token_type = \"episode\"\n            self._continue_flag = False\n            self._stop_name_flag = True\n            self.type = MediaType.TV\n            for se in re_res:\n                if isinstance(se, tuple):\n                    se_t = None\n                    for se_i in se:\n                        if se_i and str(se_i).isdigit():\n                            se_t = se_i\n                            break\n                    if se_t:\n                        se = int(se_t)\n                    else:\n                        break\n                else:\n                    se = int(se)\n                if self.begin_episode is None:\n                    self.begin_episode = se\n                    self.total_episode = 1\n                else:\n                    if se > self.begin_episode:\n                        self.end_episode = se\n                        self.total_episode = (self.end_episode - self.begin_episode) + 1\n                        if self.isfile and self.total_episode > 2:\n                            self.end_episode = None\n                            self.total_episode = 1\n        elif token.isdigit():\n            try:\n                int(token)\n            except ValueError:\n                return\n            if self.begin_episode is not None \\\n                    and self.end_episode is None \\\n                    and len(token) < 5 \\\n                    and int(token) > self.begin_episode \\\n                    and self._last_token_type == \"episode\":\n                self.end_episode = int(token)\n                self.total_episode = (self.end_episode - self.begin_episode) + 1\n                if self.isfile and self.total_episode > 2:\n                    self.end_episode = None\n                    self.total_episode = 1\n                self._continue_flag = False\n                self.type = MediaType.TV\n            elif self.begin_episode is None \\\n                    and 1 < len(token) < 4 \\\n                    and self._last_token_type != \"year\" \\\n                    and self._last_token_type != \"videoencode\" \\\n                    and token != self._unknown_name_str:\n                self.begin_episode = int(token)\n                self.total_episode = 1\n                self._last_token_type = \"episode\"\n                self._continue_flag = False\n                self._stop_name_flag = True\n                self.type = MediaType.TV\n            elif self._last_token_type == \"EPISODE\" \\\n                    and self.begin_episode is None \\\n                    and len(token) < 5:\n                self.begin_episode = int(token)\n                self.total_episode = 1\n                self._last_token_type = \"episode\"\n                self._continue_flag = False\n                self._stop_name_flag = True\n                self.type = MediaType.TV\n        elif token.upper() == \"EPISODE\":\n            self._last_token_type = \"EPISODE\"\n\n    def __init_resource_type(self, token):\n        \"\"\"\n        识别资源类型\n        \"\"\"\n        if not self.name:\n            return\n        if token.upper() == \"DL\" \\\n                and self._last_token_type == \"source\" \\\n                and self._last_token == \"WEB\":\n            self._source = \"WEB-DL\"\n            self._continue_flag = False\n            return\n        elif token.upper() == \"RAY\" \\\n                and self._last_token_type == \"source\" \\\n                and self._last_token == \"BLU\":\n            # UHD BluRay组合\n            if self._source == \"UHD\":\n                self._source = \"UHD BluRay\"\n            else:\n                self._source = \"BluRay\"\n            self._continue_flag = False\n            return\n        elif token.upper() == \"WEBDL\":\n            self._source = \"WEB-DL\"\n            self._continue_flag = False\n            return\n            # UHD REMUX组合\n        if token.upper() == \"REMUX\" \\\n                and self._source == \"BluRay\":\n            self._source = \"BluRay REMUX\"\n            self._continue_flag = False\n            return\n        elif token.upper() == \"BLURAY\" \\\n                and self._source == \"UHD\":\n            self._source = \"UHD BluRay\"\n            self._continue_flag = False\n            return\n        source_res = re.search(r\"(%s)\" % self._source_re, token, re.IGNORECASE)\n        if source_res:\n            self._last_token_type = \"source\"\n            self._continue_flag = False\n            self._stop_name_flag = True\n            if not self._source:\n                self._source = source_res.group(1)\n                self._last_token = self._source.upper()\n            return\n        effect_res = re.search(r\"(%s)\" % self._effect_re, token, re.IGNORECASE)\n        if effect_res:\n            self._last_token_type = \"effect\"\n            self._continue_flag = False\n            self._stop_name_flag = True\n            effect = effect_res.group(1)\n            if effect not in self._effect:\n                self._effect.append(effect)\n            self._last_token = effect.upper()\n\n    def __init_web_source(self, token: str, tokens: Tokens, streaming_platforms: StreamingPlatforms):\n        \"\"\"\n        识别流媒体平台\n        \"\"\"\n        if not self.name:\n            return\n\n        platform_name = None\n        query_range = 1\n\n        prev_token = None\n        prev_idx = self._index - 2\n        if 0 <= prev_idx < len(tokens.tokens):\n            prev_token = tokens.tokens[prev_idx]\n\n        next_token = tokens.peek()\n\n        if streaming_platforms.is_streaming_platform(token):\n            platform_name = streaming_platforms.get_streaming_platform_name(token)\n        else:\n            for adjacent_token, is_next in [(prev_token, False), (next_token, True)]:\n                if not adjacent_token or platform_name:\n                    continue\n\n                for separator in [\" \", \"-\"]:\n                    if is_next:\n                        combined_token = f\"{token}{separator}{adjacent_token}\"\n                    else:\n                        combined_token = f\"{adjacent_token}{separator}{token}\"\n\n                    if streaming_platforms.is_streaming_platform(combined_token):\n                        platform_name = streaming_platforms.get_streaming_platform_name(combined_token)\n                        query_range = 2\n                        if is_next:\n                            tokens.get_next()\n                        break\n\n        if not platform_name:\n            return\n\n        web_tokens = [\"WEB\", \"DL\", \"WEBDL\", \"WEBRIP\"]\n        match_start_idx = self._index - query_range\n        match_end_idx = self._index - 1\n        start_index = max(0, match_start_idx - query_range)\n        end_index = min(len(tokens.tokens), match_end_idx + 1 + query_range)\n        tokens_to_check = tokens.tokens[start_index:end_index]\n\n        if any(tok and tok.upper() in web_tokens for tok in tokens_to_check):\n            self.web_source = platform_name\n            self._continue_flag = False\n\n    def __init_video_encode(self, token: str):\n        \"\"\"\n        识别视频编码\n        \"\"\"\n        if not self.name:\n            return\n        if not self.year \\\n                and not self.resource_pix \\\n                and not self.resource_type \\\n                and not self.begin_season \\\n                and not self.begin_episode:\n            return\n        re_res = re.search(r\"(%s)\" % self._video_encode_re, token, re.IGNORECASE)\n        if re_res:\n            self._continue_flag = False\n            self._stop_name_flag = True\n            self._last_token_type = \"videoencode\"\n            if not self.video_encode:\n                if re_res.group(2):\n                    self.video_encode = re_res.group(2).upper()\n                elif re_res.group(3):\n                    self.video_encode = re_res.group(3).lower()\n                else:\n                    self.video_encode = re_res.group(1).upper()\n                self._last_token = self.video_encode\n            elif self.video_encode == \"10bit\":\n                self.video_encode = f\"{re_res.group(1).upper()} 10bit\"\n                self._last_token = re_res.group(1).upper()\n        elif token.upper() in ['H', 'X']:\n            self._continue_flag = False\n            self._stop_name_flag = True\n            self._last_token_type = \"videoencode\"\n            self._last_token = token.upper() if token.upper() == \"H\" else token.lower()\n        elif token in [\"264\", \"265\"] \\\n                and self._last_token_type == \"videoencode\" \\\n                and self._last_token in ['H', 'X']:\n            self.video_encode = \"%s%s\" % (self._last_token, token)\n        elif token.isdigit() \\\n                and self._last_token_type == \"videoencode\" \\\n                and self._last_token in ['VC', 'MPEG']:\n            self.video_encode = \"%s%s\" % (self._last_token, token)\n        elif token.upper() == \"10BIT\":\n            self._last_token_type = \"videoencode\"\n            if not self.video_encode:\n                self.video_encode = \"10bit\"\n            else:\n                self.video_encode = f\"{self.video_encode} 10bit\"\n\n    def __init_audio_encode(self, token: str):\n        \"\"\"\n        识别音频编码\n        \"\"\"\n        if not self.name:\n            return\n        if not self.year \\\n                and not self.resource_pix \\\n                and not self.resource_type \\\n                and not self.begin_season \\\n                and not self.begin_episode:\n            return\n        re_res = re.search(r\"(%s)\" % self._audio_encode_re, token, re.IGNORECASE)\n        if re_res:\n            self._continue_flag = False\n            self._stop_name_flag = True\n            self._last_token_type = \"audioencode\"\n            self._last_token = re_res.group(1).upper()\n            if not self.audio_encode:\n                self.audio_encode = re_res.group(1)\n            else:\n                if self.audio_encode.upper() == \"DTS\":\n                    self.audio_encode = \"%s-%s\" % (self.audio_encode, re_res.group(1))\n                else:\n                    self.audio_encode = \"%s %s\" % (self.audio_encode, re_res.group(1))\n        elif token.isdigit() \\\n                and self._last_token_type == \"audioencode\":\n            if self.audio_encode:\n                if self._last_token.isdigit():\n                    self.audio_encode = \"%s.%s\" % (self.audio_encode, token)\n                elif self.audio_encode[-1].isdigit():\n                    self.audio_encode = \"%s %s.%s\" % (self.audio_encode[:-1], self.audio_encode[-1], token)\n                else:\n                    self.audio_encode = \"%s %s\" % (self.audio_encode, token)\n            self._last_token = token\n\n    def __init_fps(self, token: str):\n        \"\"\"\n        识别帧率\n        \"\"\"\n        if not self.name:\n            return\n\n        re_res = re.search(rf\"({self._fps_re})\", token, re.IGNORECASE)\n        if re_res:\n            self._continue_flag = False\n            self._stop_name_flag = True\n            self._last_token_type = \"fps\"\n            # 提取帧率数值\n            fps_value = None\n            if re_res.group(1):  # FPS格式\n                fps_value = re_res.group(1)\n            \n            if fps_value and fps_value.isdigit():\n                # 只存储纯数值\n                self.fps = int(fps_value)\n                self._last_token = f\"{self.fps}FPS\"\n"
  },
  {
    "path": "app/core/meta/releasegroup.py",
    "content": "import regex as re\n\nfrom app.db.systemconfig_oper import SystemConfigOper\nfrom app.schemas.types import SystemConfigKey\nfrom app.utils.singleton import Singleton\n\n\nclass ReleaseGroupsMatcher(metaclass=Singleton):\n    \"\"\"\n    识别制作组、字幕组\n    \"\"\"\n    # 内置组\n    RELEASE_GROUPS: dict = {\n        \"0ff\": ['FF(?:(?:A|WE)B|CD|E(?:DU|B)|TV)'],\n        \"1pt\": [],\n        \"52pt\": [],\n        \"audiences\": ['Audies', 'AD(?:Audio|E(?:book|)|Music|Web)'],\n        \"azusa\": [],\n        \"beitai\": ['BeiTai'],\n        \"btschool\": ['Bts(?:CHOOL|HD|PAD|TV)', 'Zone'],\n        \"carpt\": ['CarPT'],\n        \"chdbits\": ['CHD(?:Bits|PAD|(?:|HK)TV|WEB|)', 'StBOX', 'OneHD', 'Lee', 'xiaopie'],\n        \"discfan\": [],\n        \"dragonhd\": [],\n        \"eastgame\": ['(?:(?:iNT|(?:HALFC|Mini(?:S|H|FH)D))-|)TLF'],\n        \"filelist\": [],\n        \"gainbound\": ['(?:DG|GBWE)B'],\n        \"hares\": ['Hares(?:(?:M|T)V|Web|)'],\n        \"hd4fans\": [],\n        \"hdarea\": ['HDA(?:pad|rea|TV)', 'EPiC'],\n        \"hdatmos\": [],\n        \"hdbd\": [],\n        \"hdchina\": ['HDC(?:hina|TV|)', 'k9611', 'tudou', 'iHD'],\n        \"hddolby\": ['D(?:ream|BTV)', '(?:HD|QHstudI)o'],\n        \"hdfans\": ['beAst(?:TV|)'],\n        \"hdhome\": ['HDH(?:ome|Pad|TV|WEB|)'],\n        \"hdpt\": ['HDPT(?:Web|)'],\n        \"hdsky\": ['HDS(?:ky|TV|Pad|WEB|)', 'AQLJ'],\n        \"hdtime\": [],\n        \"HDU\": [],\n        \"hdvideo\": [],\n        \"hdzone\": ['HDZ(?:one|)'],\n        \"hhanclub\": ['HHWEB'],\n        \"hitpt\": [],\n        \"htpt\": ['HTPT'],\n        \"iptorrents\": [],\n        \"joyhd\": [],\n        \"keepfrds\": ['FRDS', 'Yumi', 'cXcY'],\n        \"lemonhd\": ['L(?:eague(?:(?:C|H)D|(?:M|T)V|NF|WEB)|HD)', 'i18n', 'CiNT'],\n        \"mteam\": ['MTeam(?:TV|)', 'MPAD', 'MWeb'],\n        \"nanyangpt\": [],\n        \"nicept\": [],\n        \"oshen\": [],\n        \"ourbits\": ['Our(?:Bits|TV)', 'FLTTH', 'Ao', 'PbK', 'MGs', 'iLove(?:HD|TV)'],\n        \"panda\": ['Panda', 'AilMWeb'],\n        \"piggo\": ['PiGo(?:NF|(?:H|WE)B)'],\n        \"ptchina\": [],\n        \"pterclub\": ['PTer(?:DIY|Game|(?:M|T)V|WEB|)'],\n        \"pthome\": ['PTH(?:Audio|eBook|music|ome|tv|WEB|)'],\n        \"ptmsg\": [],\n        \"ptsbao\": ['PTsbao', 'OPS', 'F(?:Fans(?:AIeNcE|BD|D(?:VD|IY)|TV|WEB)|HDMv)', 'SGXT'],\n        \"pttime\": [],\n        \"putao\": ['PuTao'],\n        \"soulvoice\": [],\n        \"springsunday\": ['CMCT(?:V|)'],\n        \"sharkpt\": ['Shark(?:WEB|DIY|TV|MV|)'],\n        \"tccf\": [],\n        \"tjupt\": ['TJUPT'],\n        \"totheglory\": ['TTG', 'WiKi', 'NGB', 'DoA', '(?:ARi|ExRE)N'],\n        \"U2\": [],\n        \"ultrahd\": [],\n        \"others\": ['B(?:MDru|eyondHD|TN)', 'C(?:fandora|trlhd|MRG)', 'DON', 'EVO', 'FLUX', 'HONE(?:yG|)',\n                   'N(?:oGroup|T(?:b|G))', 'PandaMoon', 'SMURF', 'T(?:EPES|aengoo|rollHD )'],\n        \"anime\": ['ANi', 'HYSUB', 'KTXP', 'LoliHouse', 'MCE', 'Nekomoe kissaten', 'SweetSub', 'MingY',\n                  '(?:Lilith|NC)-Raws', '织梦字幕组', '枫叶字幕组', '猎户手抄部', '喵萌奶茶屋', '漫猫字幕社',\n                  '霜庭云花Sub', '北宇治字幕组', '氢气烤肉架', '云歌字幕组', '萌樱字幕组', '极影字幕社',\n                  '悠哈璃羽字幕社',\n                  '❀拨雪寻春❀', '沸羊羊(?:制作|字幕组)', '(?:桜|樱)都字幕组'],\n        \"forge\": ['FROG(?:E|Web|)'],\n        \"ubits\": ['UB(?:its|WEB|TV)'],\n    }\n\n    def __init__(self):\n        release_groups = []\n        for site_groups in self.RELEASE_GROUPS.values():\n            for release_group in site_groups:\n                release_groups.append(release_group)\n        self.__release_groups = '|'.join(release_groups)\n\n    def match(self, title: str = None, groups: str = None):\n        \"\"\"\n        :param title: 资源标题或文件名\n        :param groups: 制作组/字幕组\n        :return: 匹配结果\n        \"\"\"\n        if not title:\n            return \"\"\n        if not groups:\n            # 自定义组\n            custom_release_groups = SystemConfigOper().get(SystemConfigKey.CustomReleaseGroups)\n            if isinstance(custom_release_groups, list):\n                custom_release_groups = list(filter(None, custom_release_groups))\n            if custom_release_groups:\n                custom_release_groups_str = '|'.join(custom_release_groups)\n                groups = f\"{self.__release_groups}|{custom_release_groups_str}\"\n            else:\n                groups = self.__release_groups\n        title = f\"{title} \"\n        groups_re = re.compile(r\"(?<=[-@\\[￡【&])(?:(?:%s))(?=$|[@.\\s\\]\\[】&])\" % groups, re.I)\n        unique_groups = []\n        for item in re.findall(groups_re, title):\n            item_str = item[0] if isinstance(item, tuple) else item\n            if item_str not in unique_groups:\n                unique_groups.append(item_str)\n\n        return \"@\".join(unique_groups)\n"
  },
  {
    "path": "app/core/meta/streamingplatform.py",
    "content": "from typing import Optional, List, Tuple\n\nfrom app.utils.singleton import Singleton\n\n\nclass StreamingPlatforms(metaclass=Singleton):\n    \"\"\"\n    流媒体平台简称与全称。\n    \"\"\"\n    STREAMING_PLATFORMS: List[Tuple[str, str]] = [\n        (\"AMZN\", \"Amazon\"),\n        (\"NF\", \"Netflix\"),\n        (\"ATVP\", \"Apple TV+\"),\n        (\"iT\", \"iTunes\"),\n        (\"DSNP\", \"Disney+\"),\n        (\"HS\", \"Hotstar\"),\n        (\"APPS\", \"Disney+ MENA\"),\n        (\"PMTP\", \"Paramount+\"),\n        (\"HMAX\", \"Max\"),\n        (\"\", \"Max\"),\n        (\"HULU\", \"Hulu Networks\"),\n        (\"MA\", \"Movies Anywhere\"),\n        (\"BCORE\", \"Bravia Core\"),\n        (\"MS\", \"Microsoft Store\"),\n        (\"SHO\", \"Showtime\"),\n        (\"STAN\", \"Stan\"),\n        (\"PCOK\", \"Peacock\"),\n        (\"SKST\", \"SkyShowtime\"),\n        (\"NOW\", \"Now\"),\n        (\"FXTL\", \"Foxtel Now\"),\n        (\"BNGE\", \"Binge\"),\n        (\"CRKL\", \"Crackle\"),\n        (\"RKTN\", \"Rakuten TV\"),\n        (\"ALL4\", \"Channel 4\"),\n        (\"AS\", \"Adult Swim\"),\n        (\"BRTB\", \"Brtb TV\"),\n        (\"CNLP\", \"Canal+\"),\n        (\"CRIT\", \"Criterion Channel\"),\n        (\"DSCP\", \"Discovery+\"),\n        (\"FOOD\", \"Food Network\"),\n        (\"MUBI\", \"Mubi\"),\n        (\"PLAY\", \"Google Play\"),\n        (\"YT\", \"YouTube\"),\n        (\"\", \"friDay\"),\n        (\"\", \"KKTV\"),\n        (\"\", \"ofiii\"),\n        (\"\", \"LiTV\"),\n        (\"\", \"MyVideo\"),\n        (\"Hami\", \"Hami Video\"),\n        (\"HamiVideo\", \"Hami Video\"),\n        (\"MW\", \"meWATCH\"),\n        (\"CATCHPLAY\", \"CATCHPLAY+\"),\n        (\"CPP\", \"CATCHPLAY+\"),\n        (\"LINETV\", \"LINE TV\"),\n        (\"VIU\", \"Viu\"),\n        (\"IQ\", \"\"),\n        (\"\", \"WeTV\"),\n        (\"ABMA\", \"Abema\"),\n        (\"ADN\", \"\"),\n        (\"AT-X\", \"\"),\n        (\"Baha\", \"\"),\n        (\"BG\", \"B-Global\"),\n        (\"CR\", \"Crunchyroll\"),\n        (\"\", \"DMM\"),\n        (\"FOD\", \"\"),\n        (\"FUNi\", \"Funimation\"),\n        (\"HIDI\", \"HIDIVE\"),\n        (\"UNXT\", \"U-NEXT\"),\n        (\"FAA\", \"Filmarchiv Austria\"),\n        (\"CC\", \"Comedy Central\"),\n        (\"iP\", \"BBC iPlayer\"),\n        (\"9NOW\", \"9Now\"),\n        (\"ABC\", \"\"),\n        (\"\", \"AMC\"),\n        (\"\", \"ZEE5\"),\n        (\"\", \"WAVO\"),\n        (\"SHAHID\", \"Shahid\"),\n        (\"Flixole\", \"FlixOlé\"),\n        (\"TOU\", \"Ici TOU.TV\"),\n        (\"ROKU\", \"Roku\"),\n        (\"KNPY\", \"Kanopy\"),\n        (\"SNXT\", \"Sun NXT\"),\n        (\"CUR\", \"Curiosity Stream\"),\n        (\"MY5\", \"Channel 5\"),\n        (\"AHA\", \"aha\"),\n        (\"WOWP\", \"WOW Presents Plus\"),\n        (\"JC\", \"JioCinema\"),\n        (\"\", \"Dekkoo\"),\n        (\"FILMZIE\", \"Filmzie\"),\n        (\"HoiChoi\", \"Hoichoi\"),\n        (\"VIKI\", \"Rakuten Viki\"),\n        (\"SF\", \"SF Anytime\"),\n        (\"PLEX\", \"Plex\"),\n        (\"SHDR\", \"Shudder\"),\n        (\"CRAV\", \"Crave\"),\n        (\"CPE\", \"Cineplex Entertainment\"),\n        (\"JF HC\", \"\"),\n        (\"JF\", \"\"),\n        (\"JFFP\", \"\"),\n        (\"VIAP\", \"Viaplay\"),\n        (\"TUBI\", \"TubiTV\"),\n        (\"\", \"PBS\"),\n        (\"PBSK\", \"PBS KIDS\"),\n        (\"LGP\", \"Lionsgate Play\"),\n        (\"\", \"CTV\"),\n        (\"\", \"Cineverse\"),\n        (\"LN\", \"Love Nature\"),\n        (\"MP\", \"Movistar Plus+\"),\n        (\"RUNTIME\", \"Runtime\"),\n        (\"STZ\", \"STARZ\"),\n        (\"FUBO\", \"fuboTV\"),\n        (\"TENK\", \"Tënk\"),\n        (\"KNOW\", \"Knowledge Network\"),\n        (\"TVO\", \"tvo\"),\n        (\"\", \"OVID\"),\n        (\"CBC\", \"CBC Gem\"),\n        (\"FANDOR\", \"fandor\"),\n        (\"CW\", \"The CW\"),\n        (\"KNPY\", \"Kanopy\"),\n        (\"FREE\", \"Freeform\"),\n        (\"AE\", \"A&E\"),\n        (\"LIFE\", \"Lifetime\"),\n        (\"WWEN\", \"WWE Network\"),\n        (\"CMAX\", \"Cinemax\"),\n        (\"HLMK\", \"Hallmark\"),\n        (\"BYU\", \"BYUtv\"),\n        (\"\", \"ViX\"),\n        (\"VICE\", \"Viceland\"),\n        (\"\", \"TVING\"),\n        (\"USAN\", \"USA Network\"),\n        (\"FOX\", \"\"),\n        (\"\", \"TCM\"),\n        (\"BRAV\", \"BravoTV\"),\n        (\"\", \"TNT\"),\n        (\"\", \"ZDF\"),\n        (\"\", \"IndieFlix\"),\n        (\"\", \"TLC\"),\n        (\"\", \"HGTV\"),\n        (\"ANPL\", \"Animal Planet\"),\n        (\"TRVL\", \"Travel Channel\"),\n        (\"\", \"VH1\"),\n        (\"SAINA\", \"Saina Play\"),\n        (\"SP\", \"Saina Play\"),\n        (\"OXGN\", \"Oxygen\"),\n        (\"PSN\", \"PlayStation Network\"),\n        (\"PMNT\", \"Paramount Network\"),\n        (\"FAWESOME\", \"Fawesome\"),\n        (\"KLASSIKI\", \"Klassiki\"),\n        (\"STRP\", \"Star+\"),\n        (\"NATG\", \"National Geographic\"),\n        (\"REVEEL\", \"Reveel\"),\n        (\"FYI\", \"FYI Network\"),\n        (\"WatchiT\", \"WATCH IT\"),\n        (\"ITVX\", \"ITV\"),\n        (\"GAIA\", \"Gaia\"),\n        (\"\", \"FlixLatino\"),\n        (\"CNNP\", \"CNN+\"),\n        (\"TROMA\", \"Troma\"),\n        (\"IVI\", \"Ivi\"),\n        (\"9NOW\", \"9Now\"),\n        (\"A3P\", \"Atresplayer\"),\n        (\"7PLUS\", \"7plus\"),\n        (\"\", \"SBS\"),\n        (\"TEN\", \"10Play\"),\n        (\"AUBC\", \"\"),\n        (\"DSNY\", \"Disney Networks\"),\n        (\"OSN\", \"OSN+\"),\n        (\"SVT\", \"Sveriges Television\"),\n        (\"LACINETEK\", \"LaCinetek\"),\n        (\"\", \"Maxdome\"),\n        (\"RTL\", \"RTL+\"),\n        (\"ARTE\", \"Arte\"),\n        (\"JOYN\", \"Joyn\"),\n        (\"TV2\", \"TV 2\"),\n        (\"3SAT\", \"3sat\"),\n        (\"FILMINGO\", \"filmingo\"),\n        (\"\", \"WOW\"),\n        (\"OKKO\", \"Okko\"),\n        (\"\", \"Go3\"),\n        (\"ARGP\", \"Argo\"),\n        (\"VOYO\", \"Voyo\"),\n        (\"VMAX\", \"vivamax\"),\n        (\"FILMIN\", \"Filmin\"),\n        (\"\", \"Mitele\"),\n        (\"MY5\", \"Channel 5\"),\n        (\"\", \"ARD\"),\n        (\"BK\", \"Bentkey\"),\n        (\"BOOM\", \"Boomerang\"),\n        (\"\", \"CBS\"),\n        (\"CLBI\", \"Club illico\"),\n        (\"CMOR\", \"C More\"),\n        (\"CMT\", \"\"),\n        (\"\", \"CNBC\"),\n        (\"COOK\", \"Cooking Channel\"),\n        (\"CWS\", \"CW Seed\"),\n        (\"DCU\", \"DC Universe\"),\n        (\"DDY\", \"Digiturk Dilediğin Yerde\"),\n        (\"DEST\", \"Destination America\"),\n        (\"DISC\", \"Discovery Channel\"),\n        (\"DW\", \"DailyWire+\"),\n        (\"DLWP\", \"DailyWire+\"),\n        (\"DPLY\", \"dplay\"),\n        (\"DRPO\", \"Dropout\"),\n        (\"EPIX\", \"EPIX MGM+\"),\n        (\"ESQ\", \"Esquire\"),\n        (\"ETV\", \"E!\"),\n        (\"FBWatch\", \"Facebook Watch\"),\n        (\"FPT\", \"FPT Play\"),\n        (\"FTV\", \"France.tv\"),\n        (\"GLOB\", \"GloboSat Play\"),\n        (\"GLBO\", \"Globoplay\"),\n        (\"GO90\", \"go90\"),\n        (\"HIST\", \"History Channel\"),\n        (\"HPLAY\", \"Hungama Play\"),\n        (\"KS\", \"Kaleidescape\"),\n        (\"\", \"MBC\"),\n        (\"MMAX\", \"ManoramaMAX\"),\n        (\"MNBC\", \"MSNBC\"),\n        (\"MTOD\", \"Motor Trend OnDemand\"),\n        (\"NBC\", \"\"),\n        (\"NBLA\", \"Nebula\"),\n        (\"NICK\", \"Nickelodeon\"),\n        (\"ODK\", \"OnDemandKorea\"),\n        (\"POGO\", \"PokerGO\"),\n        (\"PUHU\", \"puhutv\"),\n        (\"QIBI\", \"Quibi\"),\n        (\"RTE\", \"RTÉ\"),\n        (\"SESO\", \"Seeso\"),\n        (\"SPIK\", \"Spike\"),\n        (\"SS\", \"Simply South\"),\n        (\"SYFY\", \"SyFy\"),\n        (\"TIMV\", \"TIMvision\"),\n        (\"TK\", \"Tentkotta\"),\n        (\"\", \"TV4\"),\n        (\"TVL\", \"TV Land\"),\n        (\"\", \"TVNZ\"),\n        (\"\", \"UKTV\"),\n        (\"VLCT\", \"Discovery Velocity\"),\n        (\"VMEO\", \"Vimeo\"),\n        (\"VRV\", \"VRV Defunct\"),\n        (\"WTCH\", \"Watcha\"),\n        (\"\", \"NowPlayer\"),\n        (\"HuluJP\", \"Hulu Networks\"),\n        (\"Gaga\", \"GagaOOLala\"),\n        (\"MyTVS\", \"MyTVSuper\"),\n        (\"\", \"BBC\"),\n        (\"CC\", \"Comedy Central\"),\n        (\"NowE\", \"Now E\"),\n        (\"WAVVE\", \"Wavve\"),\n        (\"SE\", \"\"),\n        (\"\", \"BritBox\"),\n        (\"AOD\", \"Anime on Demand\"),\n        (\"AF\", \"\"),\n        (\"BCH\", \"Bandai Channel\"),\n        (\"VMJ\", \"VideoMarket\"),\n        (\"LFTL\", \"Laftel\"),\n        (\"WAKA\", \"Wakanim\"),\n        (\"WAKANIM\", \"Wakanim\"),\n        (\"AO\", \"AnimeOnegai\"),\n        (\"\", \"Lemino\"),\n        (\"VIDIO\", \"Vidio\"),\n        (\"TVER\", \"TVer\"),\n        (\"\", \"MBS\"),\n        (\"LFTLNET\", \"Laftel\"),\n        (\"JONU\", \"Jonu Play\"),\n        (\"PlutoTV\", \"Pluto TV\"),\n        (\"AbemaTV\", \"Abema\"),\n        (\"\", \"dTV\"),\n        (\"NYMEY\", \"Nymey\"),\n        (\"SMNS\", \"SAMANSA\"),\n        (\"CTHP\", \"CATCHPLAY+\"),\n        (\"HBOGO\", \"HBO GO\"),\n        (\"HBO\", \"HBO\"),\n        (\"FPTP\", \"FPT Play\"),\n        (\"\", \"LOCIPO\"),\n        (\"DANT\", \"DANET\"),\n        (\"OV\", \"OceanVeil\"),\n    ]\n\n    def __init__(self):\n        \"\"\"初始化流媒体平台匹配器\"\"\"\n        self._lookup_cache = {}\n        self._build_cache()\n\n    def _build_cache(self) -> None:\n        \"\"\"\n        构建查询缓存。\n        \"\"\"\n        self._lookup_cache.clear()\n        for short_name, full_name in self.STREAMING_PLATFORMS:\n            canonical_name = full_name or short_name\n            if not canonical_name:\n                continue\n\n            aliases = {short_name, full_name}\n            for alias in aliases:\n                if alias:\n                    self._lookup_cache[alias.upper()] = canonical_name\n\n    def get_streaming_platform_name(self, platform_code: str) -> Optional[str]:\n        \"\"\"\n        根据流媒体平台简称或全称获取标准名称。\n        \"\"\"\n        if platform_code is None:\n            return None\n        return self._lookup_cache.get(platform_code.upper())\n\n    def is_streaming_platform(self, name: str) -> bool:\n        \"\"\"\n        判断给定的字符串是否为已知的流媒体平台代码或名称。\n        \"\"\"\n        if name is None:\n            return False\n        return name.upper() in self._lookup_cache\n"
  },
  {
    "path": "app/core/meta/words.py",
    "content": "from typing import List, Tuple\n\nimport cn2an\nimport regex as re\n\nfrom app.db.systemconfig_oper import SystemConfigOper\nfrom app.log import logger\nfrom app.schemas.types import SystemConfigKey\nfrom app.utils.singleton import Singleton\n\n\nclass WordsMatcher(metaclass=Singleton):\n\n    def __init__(self):\n        self.systemconfig = SystemConfigOper()\n\n    def prepare(self, title: str, custom_words: List[str] = None) -> Tuple[str, List[str]]:\n        \"\"\"\n        预处理标题，支持三种格式\n        1：屏蔽词\n        2：被替换词 => 替换词\n        3：前定位词 <> 后定位词 >> 偏移量（EP）\n        \"\"\"\n        appley_words = []\n        # 读取自定义识别词\n        words: List[str] = custom_words or self.systemconfig.get(SystemConfigKey.CustomIdentifiers) or []\n        for word in words:\n            if not word or word.startswith(\"#\"):\n                continue\n            try:\n                if word.count(\" => \") and word.count(\" && \") and word.count(\" >> \") and word.count(\" <> \"):\n                    # 替换词\n                    thc = str(re.findall(r'(.*?)\\s*=>', word)[0]).strip()\n                    # 被替换词\n                    bthc = str(re.findall(r'=>\\s*(.*?)\\s*&&', word)[0]).strip()\n                    # 集偏移前字段\n                    pyq = str(re.findall(r'&&\\s*(.*?)\\s*<>', word)[0]).strip()\n                    # 集偏移后字段\n                    pyh = str(re.findall(r'<>(.*?)\\s*>>', word)[0]).strip()\n                    # 集偏移\n                    offsets = str(re.findall(r'>>\\s*(.*?)$', word)[0]).strip()\n                    # 替换词\n                    title, message, state = self.__replace_regex(title, thc, bthc)\n                    if state:\n                        # 替换词成功再进行集偏移\n                        title, message, state = self.__episode_offset(title, pyq, pyh, offsets)\n                elif word.count(\" => \"):\n                    # 替换词\n                    strings = word.split(\" => \")\n                    title, message, state = self.__replace_regex(title, strings[0], strings[1])\n                elif word.count(\" >> \") and word.count(\" <> \"):\n                    # 集偏移\n                    strings = word.split(\" <> \")\n                    offsets = strings[1].split(\" >> \")\n                    strings[1] = offsets[0]\n                    title, message, state = self.__episode_offset(title, strings[0], strings[1], offsets[1])\n                else:\n                    # 屏蔽词\n                    if not word.strip():\n                        continue\n                    title, message, state = self.__replace_regex(title, word, \"\")\n\n                if state:\n                    appley_words.append(word)\n\n            except Exception as err:\n                logger.warn(f\"自定义识别词 {word} 预处理标题失败：{str(err)} - 标题：{title}\")\n\n        return title, appley_words\n\n    @staticmethod\n    def __replace_regex(title: str, replaced: str, replace: str) -> Tuple[str, str, bool]:\n        \"\"\"\n        正则替换\n        \"\"\"\n        try:\n            if not re.findall(r'%s' % replaced, title):\n                return title, \"\", False\n            else:\n                return re.sub(r'%s' % replaced, r'%s' % replace, title), \"\", True\n        except Exception as err:\n            logger.warn(f\"自定义识别词正则替换失败：{str(err)} - 标题：{title}，被替换词：{replaced}，替换词：{replace}\")\n            return title, str(err), False\n\n    @staticmethod\n    def __episode_offset(title: str, front: str, back: str, offset: str) -> Tuple[str, str, bool]:\n        \"\"\"\n        集数偏移\n        \"\"\"\n        try:\n            if back and not re.findall(r'%s' % back, title):\n                return title, \"\", False\n            if front and not re.findall(r'%s' % front, title):\n                return title, \"\", False\n            offset_word_info_re = re.compile(r'(?<=%s.*?)[0-9一二三四五六七八九十]+(?=.*?%s)' % (front, back))\n            episode_nums_str = re.findall(offset_word_info_re, title)\n            if not episode_nums_str:\n                return title, \"\", False\n            episode_nums_offset_str = []\n            offset_order_flag = False\n            for episode_num_str in episode_nums_str:\n                episode_num_int = int(cn2an.cn2an(episode_num_str, \"smart\"))\n                offset_caculate = offset.replace(\"EP\", str(episode_num_int))\n                episode_num_offset_int = int(eval(offset_caculate))\n                # 向前偏移\n                if episode_num_int > episode_num_offset_int:\n                    offset_order_flag = True\n                # 向后偏移\n                elif episode_num_int < episode_num_offset_int:\n                    offset_order_flag = False\n                # 原值是中文数字，转换回中文数字，阿拉伯数字则还原0的填充\n                if not episode_num_str.isdigit():\n                    episode_num_offset_str = cn2an.an2cn(episode_num_offset_int, \"low\")\n                else:\n                    count_0 = re.findall(r\"^0+\", episode_num_str)\n                    if count_0:\n                        episode_num_offset_str = f\"{count_0[0]}{episode_num_offset_int}\"\n                    else:\n                        episode_num_offset_str = str(episode_num_offset_int)\n                episode_nums_offset_str.append(episode_num_offset_str)\n            episode_nums_dict = dict(zip(episode_nums_str, episode_nums_offset_str))\n            # 集数向前偏移，集数按升序处理\n            if offset_order_flag:\n                episode_nums_list = sorted(episode_nums_dict.items(), key=lambda x: x[1])\n            # 集数向后偏移，集数按降序处理\n            else:\n                episode_nums_list = sorted(episode_nums_dict.items(), key=lambda x: x[1], reverse=True)\n            for episode_num in episode_nums_list:\n                episode_offset_re = re.compile(\n                    r'(?<=%s.*?)%s(?=.*?%s)' % (front, episode_num[0], back))\n                title = re.sub(episode_offset_re, r'%s' % episode_num[1], title)\n            return title, \"\", True\n        except Exception as err:\n            logger.warn(f\"自定义识别词集数偏移失败：{str(err)} - 标题：{title}，前定位词：{front}，后定位词：{back}，偏移量：{offset}\")\n            return title, str(err), False\n"
  },
  {
    "path": "app/core/metainfo.py",
    "content": "from pathlib import Path\nfrom typing import Tuple, List, Optional\n\nimport regex as re\n\nfrom app.core.config import settings\nfrom app.core.meta import MetaAnime, MetaVideo, MetaBase\nfrom app.core.meta.words import WordsMatcher\nfrom app.log import logger\nfrom app.schemas.types import MediaType\n\n\ndef MetaInfo(title: str, subtitle: Optional[str] = None, custom_words: List[str] = None) -> MetaBase:\n    \"\"\"\n    根据标题和副标题识别元数据\n    :param title: 标题、种子名、文件名\n    :param subtitle: 副标题、描述\n    :param custom_words: 自定义识别词列表\n    :return: MetaAnime、MetaVideo\n    \"\"\"\n    # 原标题\n    org_title = title\n    # 预处理标题\n    title, apply_words = WordsMatcher().prepare(title, custom_words=custom_words)\n    # 获取标题中媒体信息\n    title, metainfo = find_metainfo(title)\n    # 判断是否处理文件\n    media_exts = settings.RMT_MEDIAEXT + settings.RMT_SUBEXT + settings.RMT_AUDIOEXT\n    if title and Path(title).suffix.lower() in media_exts:\n        isfile = True\n        # 去掉后缀\n        title = Path(title).stem\n    else:\n        isfile = False\n    # 识别\n    meta = MetaAnime(title, subtitle, isfile) if is_anime(title) else MetaVideo(title, subtitle, isfile)\n    # 记录原标题\n    meta.title = org_title\n    #  记录使用的识别词\n    meta.apply_words = apply_words or []\n    # 修正媒体信息\n    if metainfo.get('tmdbid'):\n        try:\n            meta.tmdbid = int(metainfo['tmdbid'])\n        except ValueError as _:\n            logger.warn(\"tmdbid 必须是数字\")\n    if metainfo.get('doubanid'):\n        meta.doubanid = metainfo['doubanid']\n    if metainfo.get('type'):\n        meta.type = metainfo['type']\n    if metainfo.get('begin_season'):\n        meta.begin_season = metainfo['begin_season']\n    if metainfo.get('end_season'):\n        meta.end_season = metainfo['end_season']\n    if metainfo.get('total_season'):\n        meta.total_season = metainfo['total_season']\n    if metainfo.get('begin_episode'):\n        meta.begin_episode = metainfo['begin_episode']\n    if metainfo.get('end_episode'):\n        meta.end_episode = metainfo['end_episode']\n    if metainfo.get('total_episode'):\n        meta.total_episode = metainfo['total_episode']\n    return meta\n\n\ndef MetaInfoPath(path: Path, custom_words: List[str] = None) -> MetaBase:\n    \"\"\"\n    根据路径识别元数据\n    :param path: 路径\n    :param custom_words: 自定义识别词列表\n    \"\"\"\n    # 文件元数据，不包含后缀\n    file_meta = MetaInfo(title=path.name, custom_words=custom_words)\n    # 上级目录元数据\n    dir_meta = MetaInfo(title=path.parent.name, custom_words=custom_words)\n    if file_meta.type == MediaType.TV or dir_meta.type != MediaType.TV:\n        # 合并元数据\n        file_meta.merge(dir_meta)\n    # 上上级目录元数据\n    root_meta = MetaInfo(title=path.parent.parent.name, custom_words=custom_words)\n    if file_meta.type == MediaType.TV or root_meta.type != MediaType.TV:\n        # 合并元数据\n        file_meta.merge(root_meta)\n    return file_meta\n\n\ndef is_anime(name: str) -> bool:\n    \"\"\"\n    判断是否为动漫\n    :param name: 名称\n    :return: 是否动漫\n    \"\"\"\n    if not name:\n        return False\n    if re.search(r'【[+0-9XVPI-]+】\\s*【', name, re.IGNORECASE):\n        return True\n    if re.search(r'\\s+-\\s+[\\dv]{1,4}\\s+', name, re.IGNORECASE):\n        return True\n    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}\",\n                 name,\n                 re.IGNORECASE):\n        return False\n    if re.search(r'\\[[+0-9XVPI-]+]\\s*\\[', name, re.IGNORECASE):\n        return True\n    return False\n\n\ndef find_metainfo(title: str) -> Tuple[str, dict]:\n    \"\"\"\n    从标题中提取媒体信息\n    \"\"\"\n    metainfo = {\n        'tmdbid': None,\n        'doubanid': None,\n        'type': None,\n        'begin_season': None,\n        'end_season': None,\n        'total_season': None,\n        'begin_episode': None,\n        'end_episode': None,\n        'total_episode': None,\n    }\n    if not title:\n        return title, metainfo\n    # 从标题中提取媒体信息 格式为{[tmdbid=xxx;type=xxx;s=xxx;e=xxx]}\n    results = re.findall(r'(?<={\\[)[\\W\\w]+(?=]})', title)\n    if results:\n        for result in results:\n            # 查找tmdbid信息\n            tmdbid = re.findall(r'(?<=tmdbid=)\\d+', result)\n            if tmdbid and tmdbid[0].isdigit():\n                metainfo['tmdbid'] = tmdbid[0]\n            # 查找豆瓣id信息\n            doubanid = re.findall(r'(?<=doubanid=)\\d+', result)\n            if doubanid and doubanid[0].isdigit():\n                metainfo['doubanid'] = doubanid[0]\n            # 查找媒体类型\n            mtype = re.findall(r'(?<=type=)\\w+', result)\n            if mtype:\n                if mtype[0] == \"movies\":\n                    metainfo['type'] = MediaType.MOVIE\n                elif mtype[0] == \"tv\":\n                    metainfo['type'] = MediaType.TV\n            # 查找季信息\n            begin_season = re.findall(r'(?<=s=)\\d+', result)\n            if begin_season and begin_season[0].isdigit():\n                metainfo['begin_season'] = int(begin_season[0])\n            end_season = re.findall(r'(?<=s=\\d+-)\\d+', result)\n            if end_season and end_season[0].isdigit():\n                metainfo['end_season'] = int(end_season[0])\n            # 查找集信息\n            begin_episode = re.findall(r'(?<=e=)\\d+', result)\n            if begin_episode and begin_episode[0].isdigit():\n                metainfo['begin_episode'] = int(begin_episode[0])\n            end_episode = re.findall(r'(?<=e=\\d+-)\\d+', result)\n            if end_episode and end_episode[0].isdigit():\n                metainfo['end_episode'] = int(end_episode[0])\n            # 去除title中该部分\n            if tmdbid or mtype or begin_season or end_season or begin_episode or end_episode:\n                title = title.replace(f\"{{[{result}]}}\", '')\n\n    # 支持Emby格式的ID标签\n    # 1. [tmdbid=xxxx] 或 [tmdbid-xxxx] 格式\n    tmdb_match = re.search(r'\\[tmdbid[=\\-](\\d+)\\]', title)\n    if tmdb_match:\n        metainfo['tmdbid'] = tmdb_match.group(1)\n        title = re.sub(r'\\[tmdbid[=\\-](\\d+)\\]', '', title).strip()\n\n    # 2. [tmdb=xxxx] 或 [tmdb-xxxx] 格式\n    if not metainfo['tmdbid']:\n        tmdb_match = re.search(r'\\[tmdb[=\\-](\\d+)\\]', title)\n        if tmdb_match:\n            metainfo['tmdbid'] = tmdb_match.group(1)\n            title = re.sub(r'\\[tmdb[=\\-](\\d+)\\]', '', title).strip()\n\n    # 3. {tmdbid=xxxx} 或 {tmdbid-xxxx} 格式\n    if not metainfo['tmdbid']:\n        tmdb_match = re.search(r'\\{tmdbid[=\\-](\\d+)\\}', title)\n        if tmdb_match:\n            metainfo['tmdbid'] = tmdb_match.group(1)\n            title = re.sub(r'\\{tmdbid[=\\-](\\d+)\\}', '', title).strip()\n\n    # 4. {tmdb=xxxx} 或 {tmdb-xxxx} 格式\n    if not metainfo['tmdbid']:\n        tmdb_match = re.search(r'\\{tmdb[=\\-](\\d+)\\}', title)\n        if tmdb_match:\n            metainfo['tmdbid'] = tmdb_match.group(1)\n            title = re.sub(r'\\{tmdb[=\\-](\\d+)\\}', '', title).strip()\n\n    # 计算季集总数\n    if metainfo.get('begin_season') and metainfo.get('end_season'):\n        if metainfo['begin_season'] > metainfo['end_season']:\n            metainfo['begin_season'], metainfo['end_season'] = metainfo['end_season'], metainfo['begin_season']\n        metainfo['total_season'] = metainfo['end_season'] - metainfo['begin_season'] + 1\n    elif metainfo.get('begin_season') and not metainfo.get('end_season'):\n        metainfo['total_season'] = 1\n    if metainfo.get('begin_episode') and metainfo.get('end_episode'):\n        if metainfo['begin_episode'] > metainfo['end_episode']:\n            metainfo['begin_episode'], metainfo['end_episode'] = metainfo['end_episode'], metainfo['begin_episode']\n        metainfo['total_episode'] = metainfo['end_episode'] - metainfo['begin_episode'] + 1\n    elif metainfo.get('begin_episode') and not metainfo.get('end_episode'):\n        metainfo['total_episode'] = 1\n    return title, metainfo\n"
  },
  {
    "path": "app/core/module.py",
    "content": "import traceback\nfrom typing import Generator, Optional, Tuple, Any, Union, List\n\nfrom app.core.config import settings\nfrom app.core.event import eventmanager\nfrom app.helper.module import ModuleHelper\nfrom app.log import logger\nfrom app.schemas.types import EventType, ModuleType, DownloaderType, MediaServerType, MessageChannel, StorageSchema, \\\n    OtherModulesType\nfrom app.utils.object import ObjectUtils\nfrom app.utils.singleton import Singleton\n\n\nclass ModuleManager(metaclass=Singleton):\n    \"\"\"\n    模块管理器\n    \"\"\"\n\n    # 子模块类型集合\n    SubType = Union[DownloaderType, MediaServerType, MessageChannel, StorageSchema, OtherModulesType]\n\n    def __init__(self):\n        # 模块列表\n        self._modules: dict = {}\n        # 运行态模块列表\n        self._running_modules: dict = {}\n        self.load_modules()\n\n    def load_modules(self):\n        \"\"\"\n        加载所有模块\n        \"\"\"\n        # 扫描模块目录\n        modules = ModuleHelper.load(\n            \"app.modules\",\n            filter_func=lambda _, obj: hasattr(obj, 'init_module') and hasattr(obj, 'init_setting')\n        )\n        self._running_modules = {}\n        self._modules = {}\n        for module in modules:\n            module_id = module.__name__\n            self._modules[module_id] = module\n            try:\n                # 生成实例\n                _module = module()\n                # 初始化模块\n                if self.check_setting(_module.init_setting()):\n                    # 通过模板开关控制加载\n                    _module.init_module()\n                    self._running_modules[module_id] = _module\n                    logger.debug(f\"Moudle Loaded：{module_id}\")\n            except Exception as err:\n                logger.error(f\"Load Moudle Error：{module_id}，{str(err)} - {traceback.format_exc()}\", exc_info=True)\n\n    def stop(self):\n        \"\"\"\n        停止所有模块\n        \"\"\"\n        logger.info(\"正在停止所有模块...\")\n        for module_id, module in self._running_modules.items():\n            if hasattr(module, \"stop\"):\n                try:\n                    module.stop()\n                    logger.debug(f\"Moudle Stoped：{module_id}\")\n                except Exception as err:\n                    logger.error(f\"Stop Moudle Error：{module_id}，{str(err)} - {traceback.format_exc()}\", exc_info=True)\n        logger.info(\"所有模块停止完成\")\n\n    def reload(self):\n        \"\"\"\n        重新加载所有模块\n        \"\"\"\n        self.stop()\n        self.load_modules()\n        eventmanager.send_event(etype=EventType.ModuleReload, data={})\n\n    def test(self, modleid: str) -> Tuple[bool, str]:\n        \"\"\"\n        测试模块\n        \"\"\"\n        if modleid not in self._running_modules:\n            return False, \"\"\n        module = self._running_modules[modleid]\n        if hasattr(module, \"test\") \\\n                and ObjectUtils.check_method(getattr(module, \"test\")):\n            result = module.test()\n            if not result:\n                return False, \"\"\n            return result\n        return True, \"模块不支持测试\"\n\n    @staticmethod\n    def check_setting(setting: Optional[tuple]) -> bool:\n        \"\"\"\n        检查开关是否己打开，开关使用,分隔多个值，符合其中即代表开启\n        \"\"\"\n        if not setting:\n            return True\n        switch, value = setting\n        option = getattr(settings, switch)\n        if not option:\n            return False\n        if option and value is True:\n            return True\n        if value in option:\n            return True\n        return False\n\n    def get_running_module(self, module_id: str) -> Any:\n        \"\"\"\n        根据模块id获取模块运行实例\n        \"\"\"\n        if not module_id:\n            return None\n        if not self._running_modules:\n            return None\n        return self._running_modules.get(module_id)\n\n    def get_running_modules(self, method: str) -> Generator:\n        \"\"\"\n        获取实现了同一方法的模块列表\n        \"\"\"\n        if not self._running_modules:\n            return\n        for _, module in self._running_modules.items():\n            if hasattr(module, method) \\\n                    and ObjectUtils.check_method(getattr(module, method)):\n                yield module\n\n    def get_running_type_modules(self, module_type: ModuleType) -> Generator:\n        \"\"\"\n        获取指定类型的模块列表\n        \"\"\"\n        if not self._running_modules:\n            return\n        for _, module in self._running_modules.items():\n            if hasattr(module, 'get_type') \\\n                    and module.get_type() == module_type:\n                yield module\n\n    def get_running_subtype_module(self, module_subtype: SubType) -> Generator:\n        \"\"\"\n        获取指定子类型的模块\n        \"\"\"\n        if not self._running_modules:\n            return\n        for _, module in self._running_modules.items():\n            if hasattr(module, 'get_subtype') \\\n                    and module.get_subtype() == module_subtype:\n                yield module\n\n    def get_module(self, module_id: str) -> Any:\n        \"\"\"\n        根据模块id获取模块\n        \"\"\"\n        if not module_id:\n            return None\n        if not self._modules:\n            return None\n        return self._modules.get(module_id)\n\n    def get_modules(self) -> dict:\n        \"\"\"\n        获取模块列表\n        \"\"\"\n        return self._modules\n\n    def get_module_ids(self) -> List[str]:\n        \"\"\"\n        获取模块id列表\n        \"\"\"\n        return list(self._modules.keys())\n"
  },
  {
    "path": "app/core/plugin.py",
    "content": "import ast\nimport asyncio\nimport concurrent\nimport concurrent.futures\nimport importlib.util\nimport inspect\nimport os\nimport posixpath\nimport sys\nimport threading\nimport time\nimport traceback\nfrom concurrent.futures import ThreadPoolExecutor, as_completed\nfrom pathlib import Path\nfrom typing import Any, Dict, List, Optional, Type, Union, Callable, Tuple\n\nfrom fastapi import HTTPException\nfrom starlette import status\nfrom watchfiles import watch\n\nfrom app import schemas\nfrom app.core.cache import fresh, async_fresh\nfrom app.core.config import settings\nfrom app.core.event import eventmanager\nfrom app.db.plugindata_oper import PluginDataOper\nfrom app.db.systemconfig_oper import SystemConfigOper\nfrom app.helper.plugin import PluginHelper\nfrom app.helper.sites import SitesHelper  # noqa\nfrom app.log import logger\nfrom app.schemas.types import EventType, SystemConfigKey\nfrom app.utils.crypto import RSAUtils\nfrom app.utils.mixins import ConfigReloadMixin\nfrom app.utils.object import ObjectUtils\nfrom app.utils.singleton import Singleton\nfrom app.utils.string import StringUtils\nfrom app.utils.system import SystemUtils\n\n\nclass PluginManager(ConfigReloadMixin, metaclass=Singleton):\n    \"\"\"插件管理器\"\"\"\n    CONFIG_WATCH = {\"DEV\", \"PLUGIN_AUTO_RELOAD\"}\n\n    def __init__(self):\n        # 插件列表\n        self._plugins: dict = {}\n        # 运行态插件列表\n        self._running_plugins: dict = {}\n        # 配置Key\n        self._config_key: str = \"plugin.%s\"\n        # 监控线程\n        self._monitor_thread: Optional[threading.Thread] = None\n        # 监控停止事件\n        self._stop_monitor_event = threading.Event()\n        # 开发者模式监测插件修改\n        if settings.DEV or settings.PLUGIN_AUTO_RELOAD:\n            self.__start_monitor()\n\n    def init_config(self):\n        # 停止已有插件\n        self.stop()\n        # 启动插件\n        self.start()\n\n    def start(self, pid: Optional[str] = None):\n        \"\"\"\n        启动加载插件\n        :param pid: 插件ID，为空加载所有插件\n        \"\"\"\n\n        def check_module(module: Any):\n            \"\"\"\n            检查模块\n            \"\"\"\n            if not hasattr(module, 'init_plugin') or not hasattr(module, \"plugin_name\"):\n                return False\n            return True\n\n        # 已安装插件\n        installed_plugins = SystemConfigOper().get(SystemConfigKey.UserInstalledPlugins) or []\n        # 扫描插件目录，只加载符合条件的插件\n        plugins = self._load_selective_plugins(pid, installed_plugins, check_module)\n        # 排序\n        plugins.sort(key=lambda x: x.plugin_order if hasattr(x, \"plugin_order\") else 0)\n        for plugin in plugins:\n            plugin_id = plugin.__name__\n            if pid and plugin_id != pid:\n                continue\n            try:\n                # 判断插件是否满足认证要求，如不满足则不进行实例化\n                if not self.__set_and_check_auth_level(plugin=plugin):\n                    # 如果是插件热更新实例，这里则进行替换\n                    if plugin_id in self._plugins:\n                        self._plugins[plugin_id] = plugin\n                    continue\n                # 存储Class\n                self._plugins[plugin_id] = plugin\n                # 生成实例\n                plugin_obj = plugin()\n                # 生效插件配置\n                plugin_obj.init_plugin(self.get_plugin_config(plugin_id))\n                # 存储运行实例\n                self._running_plugins[plugin_id] = plugin_obj\n                logger.info(f\"加载插件：{plugin_id} 版本：{plugin_obj.plugin_version}\")\n                # 启用的插件才设置事件注册状态可用\n                if plugin_obj.get_state():\n                    eventmanager.enable_event_handler(plugin)\n                else:\n                    eventmanager.disable_event_handler(plugin)\n            except Exception as err:\n                logger.error(f\"加载插件 {plugin_id} 出错：{str(err)} - {traceback.format_exc()}\")\n\n    def init_plugin(self, plugin_id: str, conf: dict):\n        \"\"\"\n        初始化插件\n        :param plugin_id: 插件ID\n        :param conf: 插件配置\n        \"\"\"\n        plugin = self._running_plugins.get(plugin_id)\n        if not plugin:\n            return\n        # 初始化插件\n        plugin.init_plugin(conf)\n        # 检查插件状态并启用/禁用事件处理器\n        if plugin.get_state():\n            # 启用插件类的事件处理器\n            eventmanager.enable_event_handler(type(plugin))\n        else:\n            # 禁用插件类的事件处理器\n            eventmanager.disable_event_handler(type(plugin))\n\n    def stop(self, pid: Optional[str] = None):\n        \"\"\"\n        停止插件服务\n        :param pid: 插件ID，为空停止所有插件\n        \"\"\"\n        # 停止插件\n        if pid:\n            logger.info(f\"正在停止插件 {pid}...\")\n            plugin_obj = self._running_plugins.get(pid)\n            if not plugin_obj:\n                logger.debug(f\"插件 {pid} 不存在或未加载\")\n                return\n            plugins = {pid: plugin_obj}\n        else:\n            logger.info(\"正在停止所有插件...\")\n            plugins = self._running_plugins\n        for plugin_id, plugin in plugins.items():\n            eventmanager.disable_event_handler(type(plugin))\n            self.__stop_plugin(plugin)\n        # 清空对像\n        if pid:\n            # 清空指定插件\n            self._plugins.pop(pid, None)\n            self._running_plugins.pop(pid, None)\n            # 清除插件模块缓存，包括所有子模块\n            self._clear_plugin_modules(pid)\n        else:\n            # 清空\n            self._plugins = {}\n            self._running_plugins = {}\n            # 清除所有插件模块缓存\n            self._clear_plugin_modules()\n        logger.info(\"插件停止完成\")\n\n    @staticmethod\n    def _load_selective_plugins(pid: Optional[str], installed_plugins: List[str],\n                                check_module_func: Callable) -> List[Any]:\n        \"\"\"\n        选择性加载插件，只import符合条件的插件\n        :param pid: 指定插件ID，为空则加载所有已安装插件\n        :param installed_plugins: 已安装插件列表\n        :param check_module_func: 模块检查函数\n        :return: 插件类列表\n        \"\"\"\n        import importlib\n\n        plugins = []\n        plugins_dir = settings.ROOT_PATH / \"app\" / \"plugins\"\n\n        if not plugins_dir.exists():\n            logger.warning(f\"插件目录不存在：{plugins_dir}\")\n            return plugins\n\n        # 确定需要加载的插件目录名称列表\n        if pid:\n            # 加载指定插件\n            target_plugins = [pid.lower()]\n        else:\n            # 加载已安装插件\n            target_plugins = [plugin_id.lower() for plugin_id in installed_plugins]\n\n        if not target_plugins:\n            logger.debug(\"没有需要加载的插件\")\n            return plugins\n\n        # 扫描plugins目录\n        _loaded_modules = set()\n        for plugin_dir in plugins_dir.iterdir():\n            if not plugin_dir.is_dir() or plugin_dir.name.startswith('_'):\n                continue\n\n            # 检查是否是需要加载的插件\n            if plugin_dir.name not in target_plugins:\n                logger.debug(f\"跳过插件目录：{plugin_dir.name}（不在加载列表中）\")\n                continue\n\n            # 检查__init__.py是否存在\n            init_file = plugin_dir / \"__init__.py\"\n            if not init_file.exists():\n                logger.debug(f\"跳过插件目录：{plugin_dir.name}（缺少__init__.py）\")\n                continue\n\n            try:\n                # 构建模块名\n                module_name = f\"app.plugins.{plugin_dir.name}\"\n                logger.debug(f\"正在导入插件模块：{module_name}\")\n\n                # 导入模块\n                module = importlib.import_module(module_name)\n\n                # 检查模块中的类\n                for name, obj in module.__dict__.items():\n                    if name.startswith('_') or not isinstance(obj, type):\n                        continue\n                    if name in _loaded_modules:\n                        continue\n                    if check_module_func(obj):\n                        _loaded_modules.add(name)\n                        plugins.append(obj)\n                        logger.debug(f\"找到符合条件的插件类：{name}\")\n                        break\n\n            except Exception as err:\n                logger.error(f\"加载插件 {plugin_dir.name} 失败：{str(err)} - {traceback.format_exc()}\")\n\n        return plugins\n\n    @property\n    def running_plugins(self) -> Dict[str, Any]:\n        \"\"\"\n        获取运行态插件列表\n        :return: 运行态插件列表\n        \"\"\"\n        return self._running_plugins\n\n    @property\n    def plugins(self) -> Dict[str, Any]:\n        \"\"\"\n        获取插件列表\n        :return: 插件列表\n        \"\"\"\n        return self._plugins\n\n    def on_config_changed(self):\n        self.reload_monitor()\n\n    def get_reload_name(self) -> str:\n        return \"插件文件修改监测\"\n\n    def reload_monitor(self):\n        \"\"\"\n        重新加载插件文件修改监测\n        \"\"\"\n        if settings.DEV or settings.PLUGIN_AUTO_RELOAD:\n            # 先关闭已有监测，再重新启动\n            self.stop_monitor()\n            self.__start_monitor()\n        else:\n            self.stop_monitor()\n\n    def __start_monitor(self):\n        \"\"\"\n        启用监测插件文件修改监测\n        \"\"\"\n        if self._monitor_thread and self._monitor_thread.is_alive():\n            logger.info(\"插件文件修改监测已经在运行中...\")\n            return\n\n        logger.info(\"开始监测插件文件修改...\")\n\n        # 在启动新线程之前，确保停止事件是清除状态\n        self._stop_monitor_event.clear()\n\n        # 创建并启动监控线程\n        self._monitor_thread = threading.Thread(\n            target=self._run_file_watcher,\n            daemon=True\n        )\n        self._monitor_thread.start()\n\n    def stop_monitor(self):\n        \"\"\"\n        停止监测插件文件修改监测\n        \"\"\"\n        if self._monitor_thread and self._monitor_thread.is_alive():\n            logger.info(\"正在停止插件文件修改监测...\")\n            self._stop_monitor_event.set()\n            self._monitor_thread.join(timeout=5)\n            if self._monitor_thread.is_alive():\n                logger.warning(\"插件文件修改监测线程在5秒内未能正常停止。\")\n            self._monitor_thread = None\n            logger.info(\"插件文件修改监测停止完成\")\n        else:\n            logger.info(\"未启用插件文件修改监测，无需停止\")\n\n    def _run_file_watcher(self):\n        \"\"\"\n        运行 watchfiles 监视器的主循环。\n        \"\"\"\n        # 监视插件目录\n        plugins_path = str(settings.ROOT_PATH / \"app\" / \"plugins\")\n        logger.info(\">>> 监控线程已启动，准备进入watch循环...\")\n        # 使用 watchfiles 监视目录变化，并响应变化事件\n        # Todo: yield_on_timeout = True 时，每秒检查停止事件，会返回空集合；后续可以考虑用来做心跳之类的功能？\n        for changes in watch(plugins_path, stop_event=self._stop_monitor_event, rust_timeout=1000,\n                             yield_on_timeout=True):\n            # 如果收到停止事件，退出循环\n            if not changes:\n                continue\n\n            # 处理变化事件\n            plugins_to_reload = set()\n            for _change_type, path_str in changes:\n                event_path = Path(path_str)\n\n                # 跳过非 .py 文件以及 pycache 目录中的文件\n                if not event_path.name.endswith(\".py\") or \"__pycache__\" in event_path.parts:\n                    continue\n\n                # 解析插件ID\n                pid = self._get_plugin_id_from_path(event_path)\n                # 跳过无效插件文件\n                if pid:\n                    # 收集需要重载的插件ID，自动去重，避免重复重载\n                    plugins_to_reload.add(pid)\n\n            # 触发重载\n            if plugins_to_reload:\n                logger.info(f\"检测到插件文件变化，准备重载: {list(plugins_to_reload)}\")\n                for pid in plugins_to_reload:\n                    try:\n                        self.reload_plugin(pid)\n                    except Exception as e:\n                        logger.error(f\"插件 {pid} 热重载失败: {e}\", exc_info=True)\n\n    @staticmethod\n    def _get_plugin_id_from_path(event_path: Path) -> Optional[str]:\n        \"\"\"\n        根据文件路径解析出插件的ID。\n        :param event_path: 被修改文件的 Path 对象。\n        :return: 插件ID字符串，如果不是有效插件文件则返回 None。\n        \"\"\"\n        try:\n            plugins_root = settings.ROOT_PATH / \"app\" / \"plugins\"\n            # 确保修改的文件在 plugins 目录下\n            if not event_path.is_relative_to(plugins_root):\n                return None\n\n            try:\n                plugin_dir_name = event_path.relative_to(plugins_root).parts[0]\n                plugin_dir = plugins_root / plugin_dir_name\n            except (ValueError, IndexError):\n                return None\n\n            init_file = plugin_dir / \"__init__.py\"\n            if not init_file.exists():\n                return None\n\n            # 读取 __init__.py 文件，查找插件主类名\n            with open(init_file, \"r\", encoding=\"utf-8\") as f:\n                source_code = f.read()\n\n            tree = ast.parse(source_code)\n\n            # 遍历AST，查找继承自 _PluginBase 的类\n            for node in ast.walk(tree):\n                # 检查节点是否为类定义\n                if isinstance(node, ast.ClassDef):\n                    # 遍历该类的所有基类\n                    for base in node.bases:\n                        # 检查基类是否是我们寻找的 _PluginBase\n                        # ast.Name 用于处理简单的基类名\n                        if isinstance(base, ast.Name) and base.id == '_PluginBase':\n                            # 返回这个类的名字\n                            return node.name\n\n            return None\n        except Exception as e:\n            logger.error(f\"从路径解析插件ID时出错: {e}\")\n            return None\n\n    @staticmethod\n    def __stop_plugin(plugin: Any):\n        \"\"\"\n        停止插件\n        :param plugin: 插件实例\n        \"\"\"\n        try:\n            # 关闭数据库\n            if hasattr(plugin, \"close\"):\n                plugin.close()\n            # 关闭插件\n            if hasattr(plugin, \"stop_service\"):\n                plugin.stop_service()\n        except Exception as e:\n            logger.warn(f\"停止插件 {plugin.get_name()} 时发生错误: {str(e)}\")\n\n    def remove_plugin(self, plugin_id: str):\n        \"\"\"\n        从内存中移除一个插件\n        :param plugin_id: 插件ID\n        \"\"\"\n        self.stop(plugin_id)\n\n    def reload_plugin(self, plugin_id: str):\n        \"\"\"\n        将一个插件重新加载到内存\n        :param plugin_id: 插件ID\n        \"\"\"\n        # 先移除插件实例\n        self.stop(plugin_id)\n        # 重新加载\n        self.start(plugin_id)\n        # 广播事件\n        eventmanager.send_event(EventType.PluginReload, data={\"plugin_id\": plugin_id})\n\n    @staticmethod\n    def _clear_plugin_modules(plugin_id: Optional[str] = None):\n        \"\"\"\n        清除插件及其所有子模块的缓存\n        :param plugin_id: 插件ID\n        \"\"\"\n\n        # 构建插件模块前缀\n        if plugin_id:\n            plugin_module_prefix = f\"app.plugins.{plugin_id.lower()}\"\n        else:\n            plugin_module_prefix = \"app.plugins\"\n\n        # 收集需要删除的模块名（创建模块名列表的副本以避免迭代时修改字典）\n        modules_to_remove = []\n        for module_name in list(sys.modules.keys()):\n            if module_name == plugin_module_prefix or module_name.startswith(plugin_module_prefix + \".\"):\n                modules_to_remove.append(module_name)\n\n        # 删除模块\n        for module_name in modules_to_remove:\n            try:\n                del sys.modules[module_name]\n                logger.debug(f\"已清除插件模块缓存：{module_name}\")\n            except KeyError:\n                # 模块可能已经被删除\n                pass\n\n        importlib.invalidate_caches()\n        logger.debug(\"已清除查找器的缓存\")\n\n        if plugin_id:\n            if modules_to_remove:\n                logger.info(f\"插件 {plugin_id} 共清除 {len(modules_to_remove)} 个模块缓存：{modules_to_remove}\")\n            else:\n                logger.debug(f\"插件 {plugin_id} 没有找到需要清除的模块缓存\")\n\n    def sync(self) -> List[str]:\n        \"\"\"\n        安装本地不存在或需要更新的插件\n        \"\"\"\n\n        def install_plugin(plugin):\n            start_time = time.time()\n            state, msg = PluginHelper().install(pid=plugin.id, repo_url=plugin.repo_url, force_install=True)\n            elapsed_time = time.time() - start_time\n            if state:\n                logger.info(\n                    f\"插件 {plugin.plugin_name} 安装成功，版本：{plugin.plugin_version}，耗时：{elapsed_time:.2f} 秒\")\n                sync_plugins.append(plugin.id)\n            else:\n                logger.error(\n                    f\"插件 {plugin.plugin_name} v{plugin.plugin_version} 安装失败：{msg}，耗时：{elapsed_time:.2f} 秒\")\n                failed_plugins.append(plugin.id)\n\n        if SystemUtils.is_frozen():\n            return []\n\n        # 获取已安装插件列表\n        install_plugins = SystemConfigOper().get(SystemConfigKey.UserInstalledPlugins) or []\n        # 获取在线插件列表\n        online_plugins = self.get_online_plugins()\n        # 确定需要安装的插件\n        plugins_to_install = [\n            plugin for plugin in online_plugins\n            if plugin.id in install_plugins and not self.is_plugin_exists(plugin.id, plugin.plugin_version)\n        ]\n\n        if not plugins_to_install:\n            return []\n        logger.info(\"开始安装第三方插件...\")\n        sync_plugins = []\n        failed_plugins = []\n\n        # 使用 ThreadPoolExecutor 进行并发安装\n        total_start_time = time.time()\n        with ThreadPoolExecutor(max_workers=5) as executor:\n            futures = {\n                executor.submit(install_plugin, plugin): plugin\n                for plugin in plugins_to_install\n            }\n            for future in as_completed(futures):\n                plugin = futures[future]\n                try:\n                    future.result()\n                except Exception as exc:\n                    logger.error(f\"插件 {plugin.plugin_name} 安装过程中出现异常: {exc}\")\n\n        total_elapsed_time = time.time() - total_start_time\n        logger.info(\n            f\"第三方插件安装完成，成功：{len(sync_plugins)} 个，\"\n            f\"失败：{len(failed_plugins)} 个，总耗时：{total_elapsed_time:.2f} 秒\"\n        )\n        return sync_plugins\n\n    @staticmethod\n    def install_plugin_missing_dependencies() -> List[str]:\n        \"\"\"\n        安装插件中缺失或不兼容的依赖项\n        \"\"\"\n        pluginhelper = PluginHelper()\n        # 第一步：获取需要安装的依赖项列表\n        missing_dependencies = pluginhelper.find_missing_dependencies()\n        if not missing_dependencies:\n            return missing_dependencies\n        logger.debug(f\"检测到缺失的依赖项: {missing_dependencies}\")\n        logger.info(f\"开始安装缺失的依赖项，共 {len(missing_dependencies)} 个...\")\n        # 第二步：安装依赖项并返回结果\n        total_start_time = time.time()\n        success, message = pluginhelper.install_dependencies(missing_dependencies)\n        total_elapsed_time = time.time() - total_start_time\n        if success:\n            logger.info(f\"已完成 {len(missing_dependencies)} 个依赖项安装，总耗时：{total_elapsed_time:.2f} 秒\")\n        else:\n            logger.warning(f\"存在缺失依赖项安装失败，请尝试手动安装，总耗时：{total_elapsed_time:.2f} 秒\")\n        return missing_dependencies\n\n    def get_plugin_config(self, pid: str) -> dict:\n        \"\"\"\n        获取插件配置\n        :param pid: 插件ID\n        \"\"\"\n        if not self._plugins.get(pid):\n            return {}\n        conf = SystemConfigOper().get(self._config_key % pid)\n        if conf:\n            # 去掉空Key\n            return {k: v for k, v in conf.items() if k}\n        return {}\n\n    def save_plugin_config(self, pid: str, conf: dict, force: bool = False) -> bool:\n        \"\"\"\n        保存插件配置\n        :param pid: 插件ID\n        :param conf: 配置\n        :param force: 强制保存\n        \"\"\"\n        if not force and not self._plugins.get(pid):\n            return False\n        SystemConfigOper().set(self._config_key % pid, conf)\n        return True\n\n    def delete_plugin_config(self, pid: str) -> bool:\n        \"\"\"\n        删除插件配置\n        :param pid: 插件ID\n        \"\"\"\n        if not self._plugins.get(pid):\n            return False\n        return SystemConfigOper().delete(self._config_key % pid)\n\n    def delete_plugin_data(self, pid: str) -> bool:\n        \"\"\"\n        删除插件数据\n        :param pid: 插件ID\n        \"\"\"\n        if not self._plugins.get(pid):\n            return False\n        PluginDataOper().del_data(pid)\n        return True\n\n    def get_plugin_state(self, pid: str) -> bool:\n        \"\"\"\n        获取插件状态\n        :param pid: 插件ID\n        \"\"\"\n        plugin = self._running_plugins.get(pid)\n        return plugin.get_state() if plugin else False\n\n    def get_plugin_commands(self, pid: Optional[str] = None) -> List[Dict[str, Any]]:\n        \"\"\"\n        获取插件命令\n        [{\n            \"cmd\": \"/xx\",\n            \"event\": EventType.xx,\n            \"desc\": \"xxxx\",\n            \"data\": {},\n            \"pid\": \"\",\n        }]\n        \"\"\"\n        ret_commands = []\n        # 创建字典快照避免并发修改\n        running_plugins_snapshot = dict(self._running_plugins)\n        for plugin_id, plugin in running_plugins_snapshot.items():\n            if pid and pid != plugin_id:\n                continue\n            if hasattr(plugin, \"get_command\") and ObjectUtils.check_method(plugin.get_command):\n                try:\n                    if not plugin.get_state():\n                        continue\n                    commands = plugin.get_command() or []\n                    for command in commands:\n                        command[\"pid\"] = plugin_id\n                    ret_commands.extend(commands)\n                except Exception as e:\n                    logger.error(f\"获取插件命令出错：{str(e)}\")\n        return ret_commands\n\n    def get_plugin_apis(self, pid: Optional[str] = None) -> List[Dict[str, Any]]:\n        \"\"\"\n        获取插件API\n        [{\n            \"path\": \"/xx\",\n            \"endpoint\": self.xxx,\n            \"methods\": [\"GET\", \"POST\"],\n            \"summary\": \"API名称\",\n            \"description\": \"API说明\",\n            \"allow_anonymous\": false\n        }]\n        \"\"\"\n        ret_apis = []\n        if pid:\n            plugins = {pid: self._running_plugins.get(pid)}\n        else:\n            plugins = self._running_plugins\n        for plugin_id, plugin in plugins.items():\n            if pid and pid != plugin_id:\n                continue\n            if hasattr(plugin, \"get_api\") and ObjectUtils.check_method(plugin.get_api):\n                try:\n                    apis = plugin.get_api() or []\n                    for api in apis:\n                        api[\"path\"] = f\"/{plugin_id}{api['path']}\"\n                        if not api.get(\"auth\"):\n                            api[\"auth\"] = \"apikey\"\n                    ret_apis.extend(apis)\n                except Exception as e:\n                    logger.error(f\"获取插件 {plugin_id} API出错：{str(e)}\")\n        return ret_apis\n\n    def get_plugin_services(self, pid: Optional[str] = None) -> List[Dict[str, Any]]:\n        \"\"\"\n        获取插件服务\n        [{\n            \"id\": \"服务ID\",\n            \"name\": \"服务名称\",\n            \"trigger\": \"触发器：cron、interval、date、CronTrigger.from_crontab()\",\n            \"func\": self.xxx,\n            \"kwargs\": {} # 定时器参数,\n            \"func_kwargs\": {} # 方法参数\n        }]\n        \"\"\"\n        ret_services = []\n        # 创建字典快照避免并发修改\n        running_plugins_snapshot = dict(self._running_plugins)\n        for plugin_id, plugin in running_plugins_snapshot.items():\n            if pid and pid != plugin_id:\n                continue\n            if hasattr(plugin, \"get_service\") and ObjectUtils.check_method(plugin.get_service):\n                try:\n                    if not plugin.get_state():\n                        continue\n                    services = plugin.get_service() or []\n                    ret_services.extend(services)\n                except Exception as e:\n                    logger.error(f\"获取插件 {plugin_id} 服务出错：{str(e)}\")\n        return ret_services\n\n    def get_plugin_modules(self, pid: Optional[str] = None) -> Dict[tuple, Dict[str, Any]]:\n        \"\"\"\n        获取插件模块\n        {\n            plugin_id: {\n                method: function\n            }\n        }\n        \"\"\"\n        ret_modules = {}\n        # 创建字典快照避免并发修改\n        running_plugins_snapshot = dict(self._running_plugins)\n        for plugin_id, plugin in running_plugins_snapshot.items():\n            if pid and pid != plugin_id:\n                continue\n            if hasattr(plugin, \"get_module\") and ObjectUtils.check_method(plugin.get_module):\n                try:\n                    if not plugin.get_state():\n                        continue\n                    plugin_module = plugin.get_module() or []\n                    ret_modules[(plugin_id, plugin.get_name())] = plugin_module\n                except Exception as e:\n                    logger.error(f\"获取插件 {plugin_id} 模块出错：{str(e)}\")\n        return ret_modules\n\n    def get_plugin_actions(self, pid: Optional[str] = None) -> List[Dict[str, Any]]:\n        \"\"\"\n        获取插件动作\n        [{\n            \"id\": \"动作ID\",\n            \"name\": \"动作名称\",\n            \"func\": self.xxx,\n            \"kwargs\": {} # 需要附加传递的参数\n        }]\n        \"\"\"\n        ret_actions = []\n        # 创建字典快照避免并发修改\n        running_plugins_snapshot = dict(self._running_plugins)\n        for plugin_id, plugin in running_plugins_snapshot.items():\n            if pid and pid != plugin_id:\n                continue\n            if hasattr(plugin, \"get_actions\") and ObjectUtils.check_method(plugin.get_actions):\n                try:\n                    if not plugin.get_state():\n                        continue\n                    actions = plugin.get_actions()\n                    if actions:\n                        ret_actions.append({\n                            \"plugin_id\": plugin_id,\n                            \"plugin_name\": plugin.plugin_name,\n                            \"actions\": actions\n                        })\n                except Exception as e:\n                    logger.error(f\"获取插件 {plugin_id} 动作出错：{str(e)}\")\n        return ret_actions\n\n    def get_plugin_agent_tools(self, pid: Optional[str] = None) -> List[Dict[str, Any]]:\n        \"\"\"\n        获取插件智能体工具\n        [{\n            \"plugin_id\": \"插件ID\",\n            \"plugin_name\": \"插件名称\",\n            \"tools\": [ToolClass1, ToolClass2, ...]\n        }]\n        \"\"\"\n        ret_tools = []\n        # 创建字典快照避免并发修改\n        running_plugins_snapshot = dict(self._running_plugins)\n        for plugin_id, plugin in running_plugins_snapshot.items():\n            if pid and pid != plugin_id:\n                continue\n            if hasattr(plugin, \"get_agent_tools\") and ObjectUtils.check_method(plugin.get_agent_tools):\n                try:\n                    if not plugin.get_state():\n                        continue\n                    tools = plugin.get_agent_tools()\n                    if tools:\n                        ret_tools.append({\n                            \"plugin_id\": plugin_id,\n                            \"plugin_name\": plugin.plugin_name,\n                            \"tools\": tools\n                        })\n                except Exception as e:\n                    logger.error(f\"获取插件 {plugin_id} 智能体工具出错：{str(e)}\")\n        return ret_tools\n\n    @staticmethod\n    def get_plugin_remote_entry(plugin_id: str, dist_path: str) -> str:\n        \"\"\"\n        获取插件的远程入口地址\n        :param plugin_id: 插件 ID\n        :param dist_path: 插件的分发路径\n        :return: 远程入口地址\n        \"\"\"\n        dist_path = dist_path.strip(\"/\")\n        path = posixpath.join(\n            \"plugin\",\n            \"file\",\n            plugin_id.lower(),\n            dist_path,\n            \"remoteEntry.js\",\n        )\n        if not path.startswith(\"/\"):\n            path = \"/\" + path\n        return path\n\n    def get_plugin_remotes(self, pid: Optional[str] = None) -> List[Dict[str, Any]]:\n        \"\"\"\n        获取插件联邦组件列表\n        \"\"\"\n        remotes = []\n        # 创建字典快照避免并发修改\n        running_plugins_snapshot = dict(self._running_plugins)\n        for plugin_id, plugin in running_plugins_snapshot.items():\n            if pid and pid != plugin_id:\n                continue\n            if hasattr(plugin, \"get_render_mode\"):\n                render_mode, dist_path = plugin.get_render_mode()\n                if render_mode != \"vue\":\n                    continue\n                remotes.append({\n                    \"id\": plugin_id,\n                    \"url\": self.get_plugin_remote_entry(plugin_id, dist_path),\n                    \"name\": plugin.plugin_name,\n                })\n        return remotes\n\n    def get_plugin_dashboard_meta(self) -> List[Dict[str, str]]:\n        \"\"\"\n        获取所有插件仪表盘元信息\n        \"\"\"\n        dashboard_meta = []\n        # 创建字典快照避免并发修改\n        running_plugins_snapshot = dict(self._running_plugins)\n        for plugin_id, plugin in running_plugins_snapshot.items():\n            if not hasattr(plugin, \"get_dashboard\") or not ObjectUtils.check_method(plugin.get_dashboard):\n                continue\n            try:\n                if not plugin.get_state():\n                    continue\n                # 如果是多仪表盘实现\n                if hasattr(plugin, \"get_dashboard_meta\") and ObjectUtils.check_method(plugin.get_dashboard_meta):\n                    meta = plugin.get_dashboard_meta()\n                    if meta:\n                        dashboard_meta.extend([{\n                            \"id\": plugin_id,\n                            \"name\": m.get(\"name\"),\n                            \"key\": m.get(\"key\"),\n                        } for m in meta if m])\n                else:\n                    dashboard_meta.append({\n                        \"id\": plugin_id,\n                        \"name\": plugin.plugin_name,\n                        \"key\": \"\",\n                    })\n            except Exception as e:\n                logger.error(f\"获取插件[{plugin_id}]仪表盘元数据出错：{str(e)}\")\n        return dashboard_meta\n\n    def get_plugin_dashboard(self, pid: str, key: str, user_agent: str = None) -> schemas.PluginDashboard:\n        \"\"\"\n        获取插件仪表盘\n        \"\"\"\n\n        def __get_params_count(func: Callable):\n            \"\"\"\n            获取函数的参数信息\n            \"\"\"\n            signature = inspect.signature(func)\n            return len(signature.parameters)\n\n        # 获取插件实例\n        plugin_instance = self.running_plugins.get(pid)\n        if not plugin_instance:\n            raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f\"插件 {pid} 不存在或未加载\")\n\n        # 渲染模式\n        render_mode, _ = plugin_instance.get_render_mode()\n        # 获取插件仪表板\n        try:\n            # 检查方法的参数个数\n            params_count = __get_params_count(plugin_instance.get_dashboard)\n            if params_count > 1:\n                dashboard: Tuple = plugin_instance.get_dashboard(key=key, user_agent=user_agent)\n            elif params_count > 0:\n                dashboard: Tuple = plugin_instance.get_dashboard(user_agent=user_agent)\n            else:\n                dashboard: Tuple = plugin_instance.get_dashboard()\n        except Exception as e:\n            logger.error(f\"插件 {pid} 调用方法 get_dashboard 出错: {str(e)}\")\n            raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,\n                                detail=f\"插件 {pid} 调用方法 get_dashboard 出错: {str(e)}\")\n        cols, attrs, elements = dashboard\n        return schemas.PluginDashboard(\n            id=pid,\n            name=plugin_instance.plugin_name,\n            key=key,\n            render_mode=render_mode,\n            cols=cols or {},\n            attrs=attrs or {},\n            elements=elements\n        )\n\n    def get_plugin_attr(self, pid: str, attr: str) -> Any:\n        \"\"\"\n        获取插件属性\n        :param pid: 插件ID\n        :param attr: 属性名\n        \"\"\"\n        plugin = self._running_plugins.get(pid)\n        if not plugin:\n            return None\n        if not hasattr(plugin, attr):\n            return None\n        return getattr(plugin, attr)\n\n    def run_plugin_method(self, pid: str, method: str, *args, **kwargs) -> Any:\n        \"\"\"\n        运行插件方法\n        :param pid: 插件ID\n        :param method: 方法名\n        :param args: 参数\n        :param kwargs: 关键字参数\n        \"\"\"\n        plugin = self._running_plugins.get(pid)\n        if not plugin:\n            return None\n        if not hasattr(plugin, method):\n            return None\n        return getattr(plugin, method)(*args, **kwargs)\n\n    async def async_run_plugin_method(self, pid: str, method: str, *args, **kwargs) -> Any:\n        \"\"\"\n        异步运行插件方法\n        :param pid: 插件ID\n        :param method: 方法名\n        :param args: 参数\n        :param kwargs: 关键字参数\n        \"\"\"\n        plugin = self._running_plugins.get(pid)\n        if not plugin:\n            return None\n        if not hasattr(plugin, method):\n            return None\n        method_func = getattr(plugin, method)\n        if asyncio.iscoroutinefunction(method_func):\n            return await method_func(*args, **kwargs)\n        else:\n            return method_func(*args, **kwargs)\n\n    def get_plugin_ids(self) -> List[str]:\n        \"\"\"\n        获取所有插件ID\n        \"\"\"\n        return list(self._plugins.keys())\n\n    def get_running_plugin_ids(self) -> List[str]:\n        \"\"\"\n        获取所有运行态插件ID\n        \"\"\"\n        return list(self._running_plugins.keys())\n\n    def get_online_plugins(self, force: bool = False) -> List[schemas.Plugin]:\n        \"\"\"\n        获取所有在线插件信息\n        \"\"\"\n        if not settings.PLUGIN_MARKET:\n            return []\n\n        # 用于存储高于 v1 版本的插件（如 v2, v3 等）\n        higher_version_plugins = []\n        # 用于存储 v1 版本插件\n        base_version_plugins = []\n\n        # 使用多线程获取线上插件\n        with concurrent.futures.ThreadPoolExecutor() as executor:\n            futures_to_version = {}\n            for m in settings.PLUGIN_MARKET.split(\",\"):\n                if not m:\n                    continue\n                # 提交任务获取 v1 版本插件，存储 future 到 version 的映射\n                base_future = executor.submit(self.get_plugins_from_market, m, None, force)\n                futures_to_version[base_future] = \"base_version\"\n\n                # 提交任务获取高版本插件（如 v2、v3），存储 future 到 version 的映射\n                if settings.VERSION_FLAG:\n                    higher_version_future = executor.submit(self.get_plugins_from_market, m,\n                                                            settings.VERSION_FLAG, force)\n                    futures_to_version[higher_version_future] = \"higher_version\"\n\n            # 按照完成顺序处理结果\n            for future in concurrent.futures.as_completed(futures_to_version):\n                plugins = future.result()\n                version = futures_to_version[future]\n\n                if plugins:\n                    if version == \"higher_version\":\n                        higher_version_plugins.extend(plugins)  # 收集高版本插件\n                    else:\n                        base_version_plugins.extend(plugins)  # 收集 v1 版本插件\n\n        return self._process_plugins_list(higher_version_plugins, base_version_plugins)\n\n    def get_local_plugins(self) -> List[schemas.Plugin]:\n        \"\"\"\n        获取所有本地已下载的插件信息\n        \"\"\"\n        # 返回值\n        plugins = []\n        # 已安装插件\n        installed_apps = SystemConfigOper().get(SystemConfigKey.UserInstalledPlugins) or []\n        for pid, plugin_class in self._plugins.items():\n            # 运行状插件\n            plugin_obj = self._running_plugins.get(pid)\n            # 基本属性\n            plugin = schemas.Plugin()\n            # ID\n            plugin.id = pid\n            # 安装状态\n            if pid in installed_apps:\n                plugin.installed = True\n            else:\n                plugin.installed = False\n            # 运行状态\n            if plugin_obj and hasattr(plugin_obj, \"get_state\"):\n                try:\n                    state = plugin_obj.get_state()\n                except Exception as e:\n                    logger.error(f\"获取插件 {pid} 状态出错：{str(e)}\")\n                    state = False\n                plugin.state = state\n            else:\n                plugin.state = False\n            # 是否有详情页面\n            if hasattr(plugin_class, \"get_page\"):\n                if ObjectUtils.check_method(plugin_class.get_page):\n                    plugin.has_page = True\n                else:\n                    plugin.has_page = False\n            # 公钥\n            if hasattr(plugin_class, \"plugin_public_key\"):\n                plugin.plugin_public_key = plugin_class.plugin_public_key\n            # 权限\n            if not self.__set_and_check_auth_level(plugin=plugin, source=plugin_class):\n                continue\n            # 名称\n            if hasattr(plugin_class, \"plugin_name\"):\n                plugin.plugin_name = plugin_class.plugin_name\n            # 描述\n            if hasattr(plugin_class, \"plugin_desc\"):\n                plugin.plugin_desc = plugin_class.plugin_desc\n            # 版本\n            if hasattr(plugin_class, \"plugin_version\"):\n                plugin.plugin_version = plugin_class.plugin_version\n            # 图标\n            if hasattr(plugin_class, \"plugin_icon\"):\n                plugin.plugin_icon = plugin_class.plugin_icon\n            # 作者\n            if hasattr(plugin_class, \"plugin_author\"):\n                plugin.plugin_author = plugin_class.plugin_author\n            # 作者链接\n            if hasattr(plugin_class, \"author_url\"):\n                plugin.author_url = plugin_class.author_url\n            # 加载顺序\n            if hasattr(plugin_class, \"plugin_order\"):\n                plugin.plugin_order = plugin_class.plugin_order\n            # 是否需要更新\n            plugin.has_update = False\n            # 本地标志\n            plugin.is_local = True\n            # 汇总\n            plugins.append(plugin)\n        # 根据加载排序重新排序\n        plugins.sort(key=lambda x: x.plugin_order if hasattr(x, \"plugin_order\") else 0)\n        return plugins\n\n    @staticmethod\n    def is_plugin_exists(pid: str, version: str = None) -> bool:\n        \"\"\"\n        判断插件是否存在，并满足版本要求(有传入version时)\n        :param pid: 插件ID\n        :param version: 插件版本\n        \"\"\"\n        if not pid:\n            return False\n        try:\n            # 构建包名\n            package_name = f\"app.plugins.{pid.lower()}\"\n            # 检查包是否存在\n            spec = importlib.util.find_spec(package_name)\n            package_exists = spec is not None and spec.origin is not None\n            logger.debug(f\"{pid} exists: {package_exists}\")\n            if not package_exists:\n                return False\n\n            local_version = PluginManager().get_plugin_attr(pid=pid, attr=\"plugin_version\")\n            if not local_version:\n                return False\n\n            if version and not StringUtils.compare_version(local_version, \">=\", version):\n                logger.warn(f\"Plugin {pid} version: {local_version} (older than version: {version})\")\n                return False\n\n            return True\n        except Exception as e:\n            logger.debug(f\"获取插件是否在本地包中存在失败，{e}\")\n            return False\n\n    def get_plugins_from_market(self, market: str,\n                                package_version: Optional[str] = None,\n                                force: bool = False) -> Optional[List[schemas.Plugin]]:\n        \"\"\"\n        从指定的市场获取插件信息\n        :param market: 市场的 URL 或标识\n        :param package_version: 首选插件版本 (如 \"v2\", \"v3\")，如果不指定则获取 v1 版本\n        :param force: 是否强制刷新（忽略缓存）\n        :return: 返回插件的列表，若获取失败返回 []\n        \"\"\"\n        if not market:\n            return []\n        # 已安装插件\n        installed_apps = SystemConfigOper().get(SystemConfigKey.UserInstalledPlugins) or []\n        # 获取在线插件\n        with fresh(force):\n            online_plugins = PluginHelper().get_plugins(market, package_version)\n        if online_plugins is None:\n            logger.warning(\n                f\"获取{package_version if package_version else ''}插件库失败：{market}，请检查 GitHub 网络连接\")\n            return []\n        ret_plugins = []\n        add_time = len(online_plugins)\n        for pid, plugin_info in online_plugins.items():\n            plugin = self._process_plugin_info(pid, plugin_info, market, installed_apps, add_time, package_version)\n            if plugin:\n                ret_plugins.append(plugin)\n            add_time -= 1\n\n        return ret_plugins\n\n    @staticmethod\n    def _process_plugins_list(higher_version_plugins: List[schemas.Plugin],\n                              base_version_plugins: List[schemas.Plugin]) -> List[schemas.Plugin]:\n        \"\"\"\n        处理插件列表：合并、去重、排序、保留最高版本\n        :param higher_version_plugins: 高版本插件列表\n        :param base_version_plugins: 基础版本插件列表\n        :return: 处理后的插件列表\n        \"\"\"\n        # 优先处理高版本插件\n        all_plugins = []\n        all_plugins.extend(higher_version_plugins)\n        # 将未出现在高版本插件列表中的 v1 插件加入 all_plugins\n        higher_plugin_ids = {f\"{p.id}{p.plugin_version}\" for p in higher_version_plugins}\n        all_plugins.extend([p for p in base_version_plugins if f\"{p.id}{p.plugin_version}\" not in higher_plugin_ids])\n        # 去重\n        all_plugins = list({f\"{p.id}{p.plugin_version}\": p for p in all_plugins}.values())\n        # 所有插件按 repo 在设置中的顺序排序\n        all_plugins.sort(\n            key=lambda x: settings.PLUGIN_MARKET.split(\",\").index(x.repo_url) if x.repo_url else 0\n        )\n        # 相同 ID 的插件保留版本号最大的版本\n        max_versions = {}\n        for p in all_plugins:\n            if p.id not in max_versions or StringUtils.compare_version(p.plugin_version, \">\", max_versions[p.id]):\n                max_versions[p.id] = p.plugin_version\n        result = [p for p in all_plugins if p.plugin_version == max_versions[p.id]]\n        logger.info(f\"共获取到 {len(result)} 个线上插件\")\n        return result\n\n    def _process_plugin_info(self, pid: str, plugin_info: dict, market: str,\n                             installed_apps: List[str], add_time: int,\n                             package_version: Optional[str] = None) -> Optional[schemas.Plugin]:\n        \"\"\"\n        处理单个插件信息，创建 schemas.Plugin 对象\n        :param pid: 插件ID\n        :param plugin_info: 插件信息字典\n        :param market: 市场URL\n        :param installed_apps: 已安装插件列表\n        :param add_time: 添加顺序\n        :param package_version: 包版本\n        :return: 创建的插件对象，如果验证失败返回None\n        \"\"\"\n        if not isinstance(plugin_info, dict):\n            return None\n\n        # 如 package_version 为空，则需要判断插件是否兼容当前版本\n        if not package_version:\n            if plugin_info.get(settings.VERSION_FLAG) is not True:\n                # 插件当前版本不兼容\n                return None\n\n        # 运行状插件\n        plugin_obj = self._running_plugins.get(pid)\n        # 非运行态插件\n        plugin_static = self._plugins.get(pid)\n        # 基本属性\n        plugin = schemas.Plugin()\n        # ID\n        plugin.id = pid\n        # 安装状态\n        if pid in installed_apps and plugin_static:\n            plugin.installed = True\n        else:\n            plugin.installed = False\n        # 是否有新版本\n        plugin.has_update = False\n        if plugin_static:\n            installed_version = getattr(plugin_static, \"plugin_version\")\n            if StringUtils.compare_version(installed_version, \"<\", plugin_info.get(\"version\")):\n                # 需要更新\n                plugin.has_update = True\n        # 运行状态\n        if plugin_obj and hasattr(plugin_obj, \"get_state\"):\n            try:\n                state = plugin_obj.get_state()\n            except Exception as e:\n                logger.error(f\"获取插件 {pid} 状态出错：{str(e)}\")\n                state = False\n            plugin.state = state\n        else:\n            plugin.state = False\n        # 是否有详情页面\n        plugin.has_page = False\n        if plugin_obj and hasattr(plugin_obj, \"get_page\"):\n            if ObjectUtils.check_method(plugin_obj.get_page):\n                plugin.has_page = True\n        # 公钥\n        if plugin_info.get(\"key\"):\n            plugin.plugin_public_key = plugin_info.get(\"key\")\n        # 权限\n        if not self.__set_and_check_auth_level(plugin=plugin, source=plugin_info):\n            return None\n        # 名称\n        if plugin_info.get(\"name\"):\n            plugin.plugin_name = plugin_info.get(\"name\")\n        # 描述\n        if plugin_info.get(\"description\"):\n            plugin.plugin_desc = plugin_info.get(\"description\")\n        # 版本\n        if plugin_info.get(\"version\"):\n            plugin.plugin_version = plugin_info.get(\"version\")\n        # 图标\n        if plugin_info.get(\"icon\"):\n            plugin.plugin_icon = plugin_info.get(\"icon\")\n        # 标签\n        if plugin_info.get(\"labels\"):\n            plugin.plugin_label = plugin_info.get(\"labels\")\n        # 作者\n        if plugin_info.get(\"author\"):\n            plugin.plugin_author = plugin_info.get(\"author\")\n        # 更新历史\n        if plugin_info.get(\"history\"):\n            plugin.history = plugin_info.get(\"history\")\n        # 仓库链接\n        plugin.repo_url = market\n        # 本地标志\n        plugin.is_local = False\n        # 添加顺序\n        plugin.add_time = add_time\n\n        return plugin\n\n    async def async_get_online_plugins(self, force: bool = False) -> List[schemas.Plugin]:\n        \"\"\"\n        异步获取所有在线插件信息\n        :param force: 是否强制刷新（忽略缓存）\n        \"\"\"\n        if not settings.PLUGIN_MARKET:\n            return []\n\n        # 用于存储高于 v1 版本的插件（如 v2, v3 等）\n        higher_version_plugins = []\n        # 用于存储 v1 版本插件\n        base_version_plugins = []\n\n        # 使用异步并发获取线上插件\n        import asyncio\n        tasks = []\n        task_to_version = {}\n\n        for m in settings.PLUGIN_MARKET.split(\",\"):\n            if not m:\n                continue\n            # 创建任务获取 v1 版本插件\n            base_task = asyncio.create_task(self.async_get_plugins_from_market(m, None, force))\n            tasks.append(base_task)\n            task_to_version[base_task] = \"base_version\"\n\n            # 创建任务获取高版本插件（如 v2、v3）\n            if settings.VERSION_FLAG:\n                higher_version_task = asyncio.create_task(\n                    self.async_get_plugins_from_market(m, settings.VERSION_FLAG, force))\n                tasks.append(higher_version_task)\n                task_to_version[higher_version_task] = \"higher_version\"\n\n        # 并发执行所有任务\n        if tasks:\n            completed_tasks = await asyncio.gather(*tasks, return_exceptions=True)\n            for i, result in enumerate(completed_tasks):\n                task = tasks[i]\n                version = task_to_version[task]\n\n                # 检查是否有异常\n                if isinstance(result, Exception):\n                    logger.error(f\"获取插件市场数据失败：{str(result)}\")\n                    continue\n\n                plugins = result\n                if plugins:\n                    if version == \"higher_version\":\n                        higher_version_plugins.extend(plugins)  # 收集高版本插件\n                    else:\n                        base_version_plugins.extend(plugins)  # 收集 v1 版本插件\n\n        return self._process_plugins_list(higher_version_plugins, base_version_plugins)\n\n    async def async_get_plugins_from_market(self, market: str,\n                                            package_version: Optional[str] = None,\n                                            force: bool = False) -> Optional[List[schemas.Plugin]]:\n        \"\"\"\n        异步从指定的市场获取插件信息\n        :param market: 市场的 URL 或标识\n        :param package_version: 首选插件版本 (如 \"v2\", \"v3\")，如果不指定则获取 v1 版本\n        :param force: 是否强制刷新（忽略缓存）\n        :return: 返回插件的列表，若获取失败返回 []\n        \"\"\"\n        if not market:\n            return []\n        # 已安装插件\n        installed_apps = SystemConfigOper().get(SystemConfigKey.UserInstalledPlugins) or []\n        # 获取在线插件\n        async with async_fresh(force):\n            online_plugins = await PluginHelper().async_get_plugins(market, package_version)\n        if online_plugins is None:\n            logger.warning(\n                f\"获取{package_version if package_version else ''}插件库失败：{market}，请检查 GitHub 网络连接\")\n            return []\n        ret_plugins = []\n        add_time = len(online_plugins)\n        for pid, plugin_info in online_plugins.items():\n            plugin = self._process_plugin_info(pid, plugin_info, market, installed_apps, add_time, package_version)\n            if plugin:\n                ret_plugins.append(plugin)\n            add_time -= 1\n\n        return ret_plugins\n\n    @staticmethod\n    def __set_and_check_auth_level(plugin: Union[schemas.Plugin, Type[Any]],\n                                   source: Optional[Union[dict, Type[Any]]] = None) -> bool:\n        \"\"\"\n        设置并检查插件的认证级别\n        :param plugin: 插件对象或包含 auth_level 属性的对象\n        :param source: 可选的字典对象或类对象，可能包含 \"level\" 或 \"auth_level\" 键\n        :return: 如果插件的认证级别有效且当前环境的认证级别满足要求，返回 True，否则返回 False\n        \"\"\"\n        # 检查并赋值 source 中的 level 或 auth_level\n        if source:\n            if isinstance(source, dict) and \"level\" in source:\n                plugin.auth_level = source.get(\"level\")\n            elif hasattr(source, \"auth_level\"):\n                plugin.auth_level = source.auth_level\n        # 如果 source 为空且 plugin 本身没有 auth_level，直接返回 True\n        elif not hasattr(plugin, \"auth_level\"):\n            return True\n\n        # auth_level 级别说明\n        # 1 - 所有用户可见\n        # 2 - 站点认证用户可见\n        # 3 - 站点&密钥认证可见\n        # 99 - 站点&特殊密钥认证可见\n        # 如果当前站点认证级别大于 1 且插件级别为 99，并存在插件公钥，说明为特殊密钥认证，通过密钥匹配进行认证\n        siteshelper = SitesHelper()\n        if siteshelper.auth_level > 1 and plugin.auth_level == 99 and hasattr(plugin, \"plugin_public_key\"):\n            plugin_id = plugin.id if isinstance(plugin, schemas.Plugin) else plugin.__name__\n            public_key = plugin.plugin_public_key\n            if public_key:\n                private_key = PluginManager.__get_plugin_private_key(plugin_id)\n                verify = RSAUtils.verify_rsa_keys(public_key=public_key, private_key=private_key)\n                return verify\n        # 如果当前站点认证级别小于插件级别，则返回 False\n        if siteshelper.auth_level < plugin.auth_level:\n            return False\n        return True\n\n    @staticmethod\n    def __get_plugin_private_key(plugin_id: str) -> Optional[str]:\n        \"\"\"\n        根据插件标识获取对应的私钥\n        :param plugin_id: 插件标识\n        :return: 对应的插件私钥，如果未找到则返回 None\n        \"\"\"\n        try:\n            # 将插件标识转换为大写并构建环境变量名称\n            env_var_name = f\"PLUGIN_{plugin_id.upper()}_PRIVATE_KEY\"\n            private_key = os.environ.get(env_var_name)\n            return private_key\n        except Exception as e:\n            logger.debug(f\"获取插件 {plugin_id} 的私钥时发生错误：{e}\")\n            return None\n\n    def clone_plugin(self, plugin_id: str, suffix: str, name: str, description: str,\n                     version: str = None, icon: str = None) -> Tuple[bool, str]:\n        \"\"\"\n        创建插件分身\n        :param plugin_id: 原插件ID\n        :param suffix: 分身后缀\n        :param name: 分身名称\n        :param description: 分身描述\n        :param version: 自定义版本号\n        :param icon: 自定义图标URL\n        :return: (是否成功, 错误信息)\n        \"\"\"\n        try:\n            # 验证参数\n            if not plugin_id or not suffix:\n                return False, \"插件ID和分身后缀不能为空\"\n\n            # 检查原插件是否存在\n            if plugin_id not in self._plugins:\n                return False, f\"原插件 {plugin_id} 不存在\"\n\n            # 生成分身插件ID\n            clone_id = f\"{plugin_id}{suffix.lower()}\"\n\n            # 检查分身插件是否已存在\n            if self.is_plugin_exists(clone_id):\n                return False, f\"分身插件 {clone_id} 已存在\"\n\n            # 获取原插件目录\n            original_plugin_dir = Path(settings.ROOT_PATH) / \"app\" / \"plugins\" / plugin_id.lower()\n            if not original_plugin_dir.exists():\n                return False, f\"原插件目录 {original_plugin_dir} 不存在\"\n\n            # 创建分身插件目录\n            clone_plugin_dir = Path(settings.ROOT_PATH) / \"app\" / \"plugins\" / clone_id.lower()\n\n            # 复制插件目录\n            import shutil\n            shutil.copytree(original_plugin_dir, clone_plugin_dir)\n            logger.info(f\"已复制插件目录：{original_plugin_dir} -> {clone_plugin_dir}\")\n\n            # 修改插件文件内容\n            success, msg = self._modify_plugin_files(\n                plugin_dir=clone_plugin_dir,\n                original_id=plugin_id,\n                suffix=suffix.lower(),\n                name=name,\n                description=description,\n                version=version,\n                icon=icon\n            )\n\n            if not success:\n                # 如果修改失败，清理已创建的目录\n                if clone_plugin_dir.exists():\n                    shutil.rmtree(clone_plugin_dir)\n                return False, msg\n\n            # 将分身插件添加到已安装列表\n            systemconfig = SystemConfigOper()\n            installed_plugins = systemconfig.get(SystemConfigKey.UserInstalledPlugins) or []\n            if clone_id not in installed_plugins:\n                installed_plugins.append(clone_id)\n                systemconfig.set(SystemConfigKey.UserInstalledPlugins, installed_plugins)\n\n            # 为分身插件创建初始配置（从原插件复制配置）\n            logger.info(f\"正在为分身插件 {clone_id} 创建初始配置...\")\n            original_config = self.get_plugin_config(plugin_id)\n            if original_config:\n                # 复制原插件配置作为分身插件的初始配置\n                clone_config = original_config.copy()\n                # 可以在这里修改一些默认值，比如禁用分身插件\n                # 默认禁用分身插件，让用户手动配置\n                clone_config['enable'] = False\n                clone_config['enabled'] = False\n                self.save_plugin_config(clone_id, clone_config, force=True)\n                logger.info(f\"已为分身插件 {clone_id} 设置初始配置\")\n            else:\n                logger.info(f\"原插件 {plugin_id} 没有配置，分身插件 {clone_id} 将使用默认配置\")\n\n            # 注册分身插件的API和服务\n            logger.info(f\"正在注册分身插件 {clone_id} ...\")\n            PluginManager().reload_plugin(clone_id)\n            # 确保分身插件正确初始化配置\n            if clone_id in self._running_plugins:\n                clone_instance = self._running_plugins[clone_id]\n                clone_config = self.get_plugin_config(clone_id)\n                if clone_config:\n                    logger.info(f\"正在为分身插件 {clone_id} 重新初始化配置...\")\n                    clone_instance.init_plugin(clone_config)\n                    logger.info(f\"分身插件 {clone_id} 配置重新初始化完成\")\n\n            logger.info(f\"插件分身 {clone_id} 创建成功\")\n            return True, clone_id\n\n        except Exception as e:\n            logger.error(f\"创建插件分身失败：{str(e)}\")\n            return False, f\"创建插件分身失败：{str(e)}\"\n\n    def _modify_plugin_files(self, plugin_dir: Path, original_id: str, suffix: str,\n                             name: str, description: str, version: str = None,\n                             icon: str = None) -> Tuple[bool, str]:\n        \"\"\"\n        修改插件文件中的类名和相关信息\n        :param plugin_dir: 插件目录\n        :param original_id: 原插件ID\n        :param suffix: 分身后缀\n        :param name: 分身名称\n        :param description: 分身描述\n        :param version: 自定义版本号\n        :param icon: 自定义图标URL\n        :return: (是否成功, 错误信息)\n        \"\"\"\n        try:\n            # 获取原插件类\n            original_plugin_class = self._plugins.get(original_id)\n            if not original_plugin_class:\n                return False, f\"无法获取原插件类 {original_id}\"\n\n            # 获取原类名\n            original_class_name = original_plugin_class.__name__\n            clone_class_name = f\"{original_class_name}{suffix}\"\n\n            # 修改 __init__.py 文件\n            init_file = plugin_dir / \"__init__.py\"\n            if init_file.exists():\n                success, msg = self._modify_python_file(\n                    file_path=init_file,\n                    original_class_name=original_class_name,\n                    clone_class_name=clone_class_name,\n                    name=name,\n                    description=description,\n                    version=version,\n                    icon=icon\n                )\n                if not success:\n                    return False, msg\n\n            # 检查是否为联邦插件（存在dist目录）\n            dist_dir = plugin_dir / \"dist\"\n            if dist_dir.exists():\n                success, msg = self._modify_federation_files(\n                    dist_dir=dist_dir,\n                    original_class_name=original_class_name,\n                    clone_class_name=clone_class_name\n                )\n                if not success:\n                    return False, msg\n\n            return True, \"文件修改成功\"\n\n        except Exception as e:\n            logger.error(f\"修改插件文件失败：{str(e)}\")\n            return False, f\"修改插件文件失败：{str(e)}\"\n\n    @staticmethod\n    def _modify_python_file(file_path: Path, original_class_name: str,\n                            clone_class_name: str, name: str, description: str,\n                            version: str = None, icon: str = None) -> Tuple[bool, str]:\n        \"\"\"\n        修改Python文件中的类名和插件信息\n        \"\"\"\n        try:\n            with open(file_path, 'r', encoding='utf-8') as f:\n                content = f.read()\n\n            # 替换类名\n            content = content.replace(f\"class {original_class_name}\", f\"class {clone_class_name}\")\n\n            # 替换插件名称和描述\n            import re\n\n            # 替换 plugin_name\n            if name:\n                content = re.sub(\n                    r'plugin_name\\s*=\\s*[\"\\'][^\"\\']*[\"\\']',\n                    f'plugin_name = \"{name}\"',\n                    content\n                )\n\n            # 替换 plugin_desc\n            if description:\n                content = re.sub(\n                    r'plugin_desc\\s*=\\s*[\"\\'][^\"\\']*[\"\\']',\n                    f'plugin_desc = \"{description}\"',\n                    content\n                )\n\n            # 替换 plugin_config_prefix（如果存在）\n            content = re.sub(\n                r'plugin_config_prefix\\s*=\\s*[\"\\'][^\"\\']*[\"\\']',\n                f'plugin_config_prefix = \"{clone_class_name.lower()}_\"',\n                content\n            )\n\n            # 替换 plugin_version（如果提供了自定义版本）\n            if version:\n                content = re.sub(\n                    r'plugin_version\\s*=\\s*[\"\\'][^\"\\']*[\"\\']',\n                    f'plugin_version = \"{version}\"',\n                    content\n                )\n\n            # 替换 plugin_icon（如果提供了自定义图标）\n            if icon and icon.strip():\n                old_content = content\n                content = re.sub(\n                    r'plugin_icon\\s*=\\s*[\"\\'][^\"\\']*[\"\\']',\n                    f'plugin_icon = \"{icon}\"',\n                    content\n                )\n                if old_content != content:\n                    logger.info(f\"已替换插件图标为: {icon}\")\n                else:\n                    logger.warning(f\"插件图标替换失败，未找到匹配的图标设置\")\n            else:\n                logger.info(\"未提供自定义图标，保持原插件图标\")\n\n            # 添加分身标志\n            if \"def init_plugin(self\" in content:\n                init_index = content.index(\"def init_plugin(self\")\n                # 在 def init_plugin(self 前添加 is_clone = True\n                content = content[:init_index] + \"is_clone = True\\n\\n    \" + content[init_index:]\n\n            with open(file_path, 'w', encoding='utf-8') as f:\n                f.write(content)\n\n            logger.debug(f\"已修改Python文件：{file_path}\")\n            return True, \"Python文件修改成功\"\n\n        except Exception as e:\n            logger.error(f\"修改Python文件失败：{str(e)}\")\n            return False, f\"修改Python文件失败：{str(e)}\"\n\n    def _modify_federation_files(self, dist_dir: Path, original_class_name: str,\n                                 clone_class_name: str) -> Tuple[bool, str]:\n        \"\"\"\n        修改联邦插件的前端文件\n        \"\"\"\n        try:\n            # 获取原始插件名（从类名推导）\n            original_plugin_name = original_class_name\n            clone_plugin_name = clone_class_name\n\n            # 遍历dist目录下的所有文件\n            for file_path in dist_dir.rglob(\"*\"):\n                if not file_path.is_file():\n                    continue\n\n                # 处理JS文件\n                if file_path.suffix == '.js':\n                    try:\n                        with open(file_path, 'r', encoding='utf-8') as f:\n                            content = f.read()\n\n                        # 替换类名引用（精确匹配）\n                        content = content.replace(original_class_name, clone_class_name)\n                        # 替换插件名引用（如果存在）\n                        content = content.replace(f'\"{original_plugin_name}\"', f'\"{clone_plugin_name}\"')\n                        content = content.replace(f\"'{original_plugin_name}'\", f\"'{clone_plugin_name}'\")\n                        # 替换CSS key中的类名（联邦插件特有）\n                        content = content.replace(f'css__{original_class_name}__', f'css__{clone_class_name}__')\n                        # 替换可能的小写类名引用\n                        content = content.replace(original_class_name.lower(), clone_class_name.lower())\n\n                        with open(file_path, 'w', encoding='utf-8') as f:\n                            f.write(content)\n\n                        logger.debug(f\"已修改联邦插件JS文件：{file_path}\")\n\n                    except Exception as e:\n                        logger.warning(f\"修改联邦插件文件 {file_path} 失败：{str(e)}\")\n                        continue\n\n                # 处理CSS文件\n                elif file_path.suffix == '.css':\n                    try:\n                        with open(file_path, 'r', encoding='utf-8') as f:\n                            content = f.read()\n\n                        # 替换CSS中可能的类名引用\n                        content = content.replace(original_class_name.lower(),\n                                                  clone_class_name.lower()).replace(original_class_name,\n                                                                                    clone_class_name)\n\n                        with open(file_path, 'w', encoding='utf-8') as f:\n                            f.write(content)\n\n                        logger.debug(f\"已修改联邦插件CSS文件：{file_path}\")\n\n                    except Exception as e:\n                        logger.warning(f\"修改联邦插件CSS文件 {file_path} 失败：{str(e)}\")\n                        continue\n\n            # 重命名构建文件（如果需要）\n            self._rename_federation_assets(dist_dir, original_class_name, clone_class_name)\n\n            return True, \"联邦插件文件修改完成\"\n\n        except Exception as e:\n            logger.error(f\"修改联邦插件文件失败：{str(e)}\")\n            return False, f\"修改联邦插件文件失败：{str(e)}\"\n\n    @staticmethod\n    def _rename_federation_assets(dist_dir: Path, original_class_name: str, clone_class_name: str):\n        \"\"\"\n        重命名联邦插件的资源文件，避免文件名冲突\n        \"\"\"\n        try:\n            # 查找包含原类名的文件并重命名\n            for file_path in dist_dir.glob(\"*\"):\n                if not file_path.is_file():\n                    continue\n\n                file_name = file_path.name\n                # 如果文件名包含原类名，则重命名\n                if original_class_name.lower() in file_name.lower():\n                    new_name = file_name.replace(\n                        original_class_name.lower(),\n                        clone_class_name.lower()\n                    )\n                    new_path = file_path.parent / new_name\n\n                    # 避免重命名冲突\n                    if not new_path.exists():\n                        file_path.rename(new_path)\n                        logger.debug(f\"重命名联邦插件文件：{file_name} -> {new_name}\")\n\n        except Exception as e:\n            # 重命名失败不影响整体流程\n            logger.warning(f\"重命名联邦插件资源文件失败：{str(e)}\")\n"
  },
  {
    "path": "app/core/security.py",
    "content": "import base64\nimport datetime\nimport hashlib\nimport hmac\nimport json\nimport os\nimport traceback\nfrom datetime import timedelta\nfrom typing import Any, Union, Annotated, Optional\n\nimport jwt\nfrom Crypto.Cipher import AES\nfrom Crypto.Util.Padding import pad\nfrom cryptography.fernet import Fernet\nfrom fastapi import HTTPException, status, Security, Request, Response\nfrom fastapi.security import OAuth2PasswordBearer, APIKeyHeader, APIKeyQuery, APIKeyCookie\nfrom passlib.context import CryptContext\n\nfrom app import schemas\nfrom app.core.cache import cached\nfrom app.core.config import settings\nfrom app.log import logger\n\npwd_context = CryptContext(schemes=[\"bcrypt\"], deprecated=\"auto\")\nALGORITHM = \"HS256\"\n\n# OAuth2PasswordBearer 用于 JWT Token 认证\noauth2_scheme_manual_error = OAuth2PasswordBearer(\n    auto_error=False,  # 禁用自动错误处理，用以支持API令牌鉴权\n    tokenUrl=f\"{settings.API_V1_STR}/login/access-token\"\n)\n\n# RESOURCE TOKEN 通过 Cookie 认证\nresource_token_cookie = APIKeyCookie(name=settings.PROJECT_NAME, auto_error=False, scheme_name=\"resource_token_cookie\")\n\n# API TOKEN 通过 QUERY 认证\napi_token_query = APIKeyQuery(name=\"token\", auto_error=False, scheme_name=\"api_token_query\")\n\n# API KEY 通过 Header 认证\napi_key_header = APIKeyHeader(name=\"X-API-KEY\", auto_error=False, scheme_name=\"api_key_header\")\n\n# API KEY 通过 QUERY 认证\napi_key_query = APIKeyQuery(name=\"apikey\", auto_error=False, scheme_name=\"api_key_query\")\n\n\ndef __get_api_token(\n        token_query: Annotated[str | None, Security(api_token_query)] = None\n) -> str | None:\n    \"\"\"\n    从 URL 查询参数中获取 API Token\n    :param token_query: 从 URL 中的 `token` 查询参数获取 API Token\n    :return: 返回获取到的 API Token，若无则返回 None\n    \"\"\"\n    return token_query\n\n\ndef __get_api_key(\n        key_query: Annotated[str | None, Security(api_key_query)] = None,\n        key_header: Annotated[str | None, Security(api_key_header)] = None\n) -> str | None:\n    \"\"\"\n    从 URL 查询参数或请求头部获取 API Key，优先使用请求头\n    :param key_query: URL 中的 `apikey` 查询参数\n    :param key_header: 请求头中的 `X-API-KEY` 参数\n    :return: 返回从 URL 或请求头中获取的 API Key，若无则返回 None\n    \"\"\"\n    return key_header or key_query # 首选请求头\n\n\n@cached(maxsize=1, ttl=600)\ndef __create_superuser_token_payload() -> schemas.TokenPayload:\n    \"\"\"\n    创建管理员用户的TokenPayload\n\n    :return: 管理员TokenPayload\n    \"\"\"\n    # 延迟导入\n    # pylint: disable=import-outside-toplevel\n    # pylint: disable=no-name-in-module\n    from app.db.user_oper import UserOper\n    from app.helper.sites import SitesHelper  # noqa\n\n    user = UserOper().get_by_name(settings.SUPERUSER)\n    if not user or not user.is_superuser:\n        raise HTTPException(\n            status_code=status.HTTP_401_UNAUTHORIZED,\n            detail=\"用户权限不足\",\n        )\n    return schemas.TokenPayload(\n        sub=user.id,\n        username=user.name,\n        super_user=user.is_superuser,\n        level=SitesHelper().auth_level,\n        purpose=\"authentication\",\n    )\n\n\ndef create_access_token(\n        userid: Union[str, Any],\n        username: str,\n        super_user: Optional[bool] = False,\n        expires_delta: Optional[timedelta] = None,\n        level: Optional[int] = 1,\n        purpose: Optional[str] = \"authentication\"\n) -> str:\n    \"\"\"\n    创建 JWT 访问令牌，包含用户 ID、用户名、是否为超级用户以及权限等级\n    :param userid: 用户的唯一标识符，通常是字符串或整数\n    :param username: 用户名，用于标识用户的账户名\n    :param super_user: 是否为超级用户，默认值为 False\n    :param expires_delta: 令牌的有效期时长，如果不提供则根据用途使用默认过期时间\n    :param level: 用户的权限级别，默认为 1\n    :param purpose: 令牌的用途，\"authentication\" 或 \"resource\"\n    :return: 编码后的 JWT 令牌字符串\n    :raises ValueError: 如果 expires_delta 为负数\n    \"\"\"\n    if purpose == \"resource\":\n        default_expire = timedelta(seconds=settings.RESOURCE_ACCESS_TOKEN_EXPIRE_SECONDS)\n        secret_key = settings.RESOURCE_SECRET_KEY\n    else:\n        default_expire = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)\n        secret_key = settings.SECRET_KEY\n\n    if expires_delta is not None:\n        if expires_delta.total_seconds() <= 0:\n            raise ValueError(\"过期时间必须为正数\")\n        expire = datetime.datetime.now(datetime.UTC) + expires_delta\n    else:\n        expire = datetime.datetime.now(datetime.UTC) + default_expire\n\n    to_encode = {\n        \"exp\": expire,\n        \"iat\": datetime.datetime.now(datetime.UTC),\n        \"sub\": str(userid),\n        \"username\": username,\n        \"super_user\": super_user,\n        \"level\": level,\n        \"purpose\": purpose\n    }\n\n    encoded_jwt = jwt.encode(to_encode, secret_key, algorithm=ALGORITHM)\n    return encoded_jwt\n\n\ndef __set_or_refresh_resource_token_cookie(request: Request, response: Response, payload: schemas.TokenPayload):\n    \"\"\"\n    设置资源令牌 Cookie\n    :param request: 包含请求相关的上下文数据\n    :param response: 用于在服务器响应时设置 Cookie\n    :param payload: 已通过身份验证的 TokenPayload 对象\n    \"\"\"\n    resource_token = request.cookies.get(settings.PROJECT_NAME)\n\n    if resource_token:\n        # 检查令牌剩余时间\n        try:\n            decoded_token = jwt.decode(resource_token, settings.RESOURCE_SECRET_KEY, algorithms=[ALGORITHM])\n            exp = decoded_token.get(\"exp\")\n            if exp:\n                remaining_time = datetime.datetime.fromtimestamp(exp, tz=datetime.UTC) - datetime.datetime.now(datetime.UTC)\n                # 根据剩余时长提前刷新令牌\n                if remaining_time < timedelta(seconds=(settings.RESOURCE_ACCESS_TOKEN_EXPIRE_SECONDS / 3)):\n                    raise jwt.ExpiredSignatureError\n        except jwt.PyJWTError:\n            logger.debug(f\"Token error occurred. refreshing token\")\n        except Exception as e:\n            logger.debug(f\"Unexpected error occurred while decoding token: {e}\")\n        else:\n            # 如果令牌有效且没有即将过期，则不需要刷新\n            return\n\n    # 创建新的资源访问令牌\n    resource_token_expires = timedelta(seconds=settings.RESOURCE_ACCESS_TOKEN_EXPIRE_SECONDS)\n    resource_token = create_access_token(\n        userid=payload.sub,\n        username=payload.username,\n        super_user=payload.super_user,\n        expires_delta=resource_token_expires,\n        level=payload.level,\n        purpose=\"resource\"\n    )\n\n    # 设置会话级别的 HttpOnly Cookie\n    response.set_cookie(\n        key=settings.PROJECT_NAME,\n        value=resource_token,\n        httponly=True,\n        secure=request.url.scheme == \"https\",  # 根据当前请求的协议设置 secure 属性\n        samesite=\"lax\"  # 不同浏览器对 \"Strict\" 的处理可能不同，设置 SameSite 为 \"Lax\"，以平衡安全性和兼容性\n    )\n\n\ndef __verify_token(token: str, purpose: Optional[str] = \"authentication\") -> schemas.TokenPayload:\n    \"\"\"\n    使用 JWT Token 进行身份认证并解析 Token 的内容\n    :param token: JWT 令牌\n    :param purpose: 期望的令牌用途，默认为 \"authentication\"\n    :return: 包含用户身份信息的 Token 负载数据\n    :raises HTTPException: 如果令牌无效或用途不匹配\n    \"\"\"\n    try:\n        if purpose == \"resource\":\n            secret_key = settings.RESOURCE_SECRET_KEY\n        else:\n            secret_key = settings.SECRET_KEY\n\n        if not token:\n            raise HTTPException(\n                status_code=status.HTTP_403_FORBIDDEN,\n                detail=f\"{purpose} token not found\"\n            )\n\n        payload = jwt.decode(\n            token, secret_key, algorithms=[ALGORITHM]\n        )\n\n        token_payload = schemas.TokenPayload(**payload)\n\n        if token_payload.purpose != purpose:\n            raise jwt.InvalidTokenError(\"令牌用途不匹配\")\n\n        return schemas.TokenPayload(**payload)\n    except (jwt.DecodeError, jwt.InvalidTokenError, jwt.ImmatureSignatureError):\n        raise HTTPException(\n            status_code=status.HTTP_403_FORBIDDEN,\n            detail=\"token校验不通过\",\n        )\n\n\ndef verify_token(\n        request: Request,\n        response: Response,\n        jwt_token: Annotated[str | None, Security(oauth2_scheme_manual_error)],\n        api_key: Annotated[str | None, Security(__get_api_key)],\n        api_token: Annotated[str | None, Security(__get_api_token)],\n) -> schemas.TokenPayload:\n    \"\"\"\n    验证 JWT 令牌并自动处理 resource_token 写入\n\n    如果缺少JWT令牌再尝试用API令牌鉴权\n\n    :param request: 请求对象，用于访问 Cookie 和请求信息\n    :param response: 响应对象，用于设置 Cookie\n    :param jwt_token: 从 Authorization 头部获取的 JWT 令牌\n    :param api_key: 从 查询参数`apikey` 或 请求头`X-API-KEY` 获取 API Token\n    :param api_token: 从 查询参数`token` 获取 API Token\n    :return: 解析后的 TokenPayload\n    :raises HTTPException: 如果令牌无效或用途不匹配\n    \"\"\"\n    if jwt_token:\n        # 验证并解析 JWT 认证令牌\n        payload = __verify_token(token=jwt_token, purpose=\"authentication\")\n\n        # 如果没有 resource_token，生成并写入到 Cookie\n        __set_or_refresh_resource_token_cookie(request, response, payload)\n\n        return payload\n    elif api_key:\n        verify_apikey(api_key)\n        return __create_superuser_token_payload()\n    elif api_token:\n        verify_apitoken(api_token)\n        return __create_superuser_token_payload()\n    else:\n        raise HTTPException(\n            status_code=status.HTTP_401_UNAUTHORIZED,\n            detail=\"Not authenticated\",\n            headers={\"WWW-Authenticate\": \"Bearer\"},\n        )\n\n\ndef verify_resource_token(\n        resource_token: Annotated[str, Security(resource_token_cookie)]\n) -> schemas.TokenPayload:\n    \"\"\"\n    验证资源访问令牌（从 Cookie 中获取）\n    :param resource_token: 从 Cookie 中获取的资源访问令牌\n    :return: 解析后的 TokenPayload\n    :raises HTTPException: 如果资源访问令牌无效\n    \"\"\"\n    # 验证并解析资源访问令牌\n    return __verify_token(token=resource_token, purpose=\"resource\")\n\n\ndef __verify_key(key: str | None, expected_key: str, key_type: str) -> str:\n    \"\"\"\n    通用的 API Key 或 Token 验证函数\n    :param key: 从请求中获取的 API Key 或 Token\n    :param expected_key: 系统配置中的期望值，用于验证的 API Key 或 Token\n    :param key_type: 键的类型（例如 \"API_KEY\" 或 \"API_TOKEN\"），用于错误消息\n    :return: 返回校验通过的 API Key 或 Token\n    :raises HTTPException: 如果校验不通过，抛出 401 错误\n    \"\"\"\n    if not key or key != expected_key:\n        raise HTTPException(\n            status_code=status.HTTP_401_UNAUTHORIZED,\n            detail=f\"{key_type} 校验不通过\"\n        )\n    return key\n\n\ndef verify_apitoken(token: Annotated[str | None, Security(__get_api_token)]) -> str:\n    \"\"\"\n    使用 API Token 进行身份认证\n    :param token: API Token，从 URL 查询参数中获取 token=xxx\n    :return: 返回校验通过的 API Token\n    \"\"\"\n    return __verify_key(token, settings.API_TOKEN, \"token\")\n\n\ndef verify_apikey(apikey: Annotated[str | None, Security(__get_api_key)]) -> str:\n    \"\"\"\n    使用 API Key 进行身份认证\n    :param apikey: API Key，从 URL 查询参数中获取 apikey=xxx，或请求头中获取 X-API-KEY=xxx\n    :return: 返回校验通过的 API Key\n    \"\"\"\n    return __verify_key(apikey, settings.API_TOKEN, \"apikey\")\n\n\ndef verify_password(plain_password: str, hashed_password: str) -> bool:\n    return pwd_context.verify(plain_password, hashed_password)\n\n\ndef get_password_hash(password: str) -> str:\n    return pwd_context.hash(password)\n\n\ndef decrypt(data: bytes, key: bytes) -> Optional[bytes]:\n    \"\"\"\n    解密二进制数据\n    \"\"\"\n    fernet = Fernet(key)\n    try:\n        return fernet.decrypt(data)\n    except Exception as e:\n        logger.error(f\"解密失败：{str(e)} - {traceback.format_exc()}\")\n        return None\n\n\ndef encrypt_message(message: str, key: bytes) -> str:\n    \"\"\"\n    使用给定的key对消息进行加密，并返回加密后的字符串\n    \"\"\"\n    f = Fernet(key)\n    encrypted_message = f.encrypt(message.encode())\n    return encrypted_message.decode()\n\n\ndef hash_sha256(message: str) -> str:\n    \"\"\"\n    对字符串做hash运算\n    \"\"\"\n    return hashlib.sha256(message.encode()).hexdigest()\n\n\ndef aes_decrypt(data: str, key: str) -> str:\n    \"\"\"\n    AES解密\n    \"\"\"\n    if not data:\n        return \"\"\n    data = base64.b64decode(data)\n    iv = data[:16]\n    encrypted = data[16:]\n    # 使用AES-256-CBC解密\n    cipher = AES.new(key.encode('utf-8'), AES.MODE_CBC, iv)\n    result = cipher.decrypt(encrypted)\n    # 去除填充\n    padding = result[-1]\n    if padding < 1 or padding > AES.block_size:\n        return \"\"\n    result = result[:-padding]\n    return result.decode('utf-8')\n\n\ndef aes_encrypt(data: str, key: str) -> str:\n    \"\"\"\n    AES加密\n    \"\"\"\n    if not data:\n        return \"\"\n    # 使用AES-256-CBC加密\n    cipher = AES.new(key.encode('utf-8'), AES.MODE_CBC)\n    # 填充\n    padding = AES.block_size - len(data) % AES.block_size\n    data += chr(padding) * padding\n    result = cipher.encrypt(data.encode('utf-8'))\n    # 使用base64编码\n    return base64.b64encode(cipher.iv + result).decode('utf-8')\n\n\ndef nexusphp_encrypt(data_str: str, key: bytes) -> str:\n    \"\"\"\n    NexusPHP加密\n    \"\"\"\n    # 生成16字节长的随机字符串\n    iv = os.urandom(16)\n    # 对向量进行 Base64 编码\n    iv_base64 = base64.b64encode(iv)\n    # 加密数据\n    cipher = AES.new(key, AES.MODE_CBC, iv)\n    ciphertext = cipher.encrypt(pad(data_str.encode(), AES.block_size))\n    ciphertext_base64 = base64.b64encode(ciphertext)\n    # 对向量的字符串表示进行签名\n    mac = hmac.new(key, msg=iv_base64 + ciphertext_base64, digestmod=hashlib.sha256).hexdigest()\n    # 构造 JSON 字符串\n    json_str = json.dumps({\n        'iv': iv_base64.decode(),\n        'value': ciphertext_base64.decode(),\n        'mac': mac,\n        'tag': ''\n    })\n\n    # 对 JSON 字符串进行 Base64 编码\n    return base64.b64encode(json_str.encode()).decode()\n"
  },
  {
    "path": "app/db/__init__.py",
    "content": "import asyncio\nfrom typing import Any, Generator, List, Optional, Self, Tuple, AsyncGenerator, Union\n\nfrom sqlalchemy import NullPool, QueuePool, and_, create_engine, inspect, text, select, delete, Column, Integer, \\\n    Sequence, Identity\nfrom sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker\nfrom sqlalchemy.orm import Session, as_declarative, declared_attr, scoped_session, sessionmaker\n\nfrom app.core.config import settings\n\n\ndef get_id_column():\n    \"\"\"\n    根据数据库类型返回合适的ID列定义\n    \"\"\"\n    if settings.DB_TYPE.lower() == \"postgresql\":\n        # PostgreSQL使用SERIAL类型，让数据库自动处理序列\n        return Column(Integer, Identity(start=1, cycle=True), primary_key=True, index=True)\n    else:\n        # SQLite使用Sequence\n        return Column(Integer, Sequence('id'), primary_key=True, index=True)\n\n\ndef _get_database_engine(is_async: bool = False):\n    \"\"\"\n    获取数据库连接参数并设置WAL模式\n    :param is_async: 是否创建异步引擎，True - 异步引擎, False - 同步引擎\n    :return: 返回对应的数据库引擎\n    \"\"\"\n    # 根据数据库类型选择连接方式\n    if settings.DB_TYPE.lower() == \"postgresql\":\n        return _get_postgresql_engine(is_async)\n    else:\n        return _get_sqlite_engine(is_async)\n\n\ndef _get_sqlite_engine(is_async: bool = False):\n    \"\"\"\n    获取SQLite数据库引擎\n    \"\"\"\n    # 连接参数\n    _connect_args = {\n        \"timeout\": settings.DB_TIMEOUT,\n    }\n    # 启用 WAL 模式时的额外配置\n    if settings.DB_WAL_ENABLE:\n        _connect_args[\"check_same_thread\"] = False\n\n    # 创建同步引擎\n    if not is_async:\n        # 根据池类型设置 poolclass 和相关参数\n        _pool_class = NullPool if settings.DB_POOL_TYPE == \"NullPool\" else QueuePool\n\n        # 数据库参数\n        _db_kwargs = {\n            \"url\": f\"sqlite:///{settings.CONFIG_PATH}/user.db\",\n            \"pool_pre_ping\": settings.DB_POOL_PRE_PING,\n            \"echo\": settings.DB_ECHO,\n            \"poolclass\": _pool_class,\n            \"pool_recycle\": settings.DB_POOL_RECYCLE,\n            \"connect_args\": _connect_args\n        }\n\n        # 当使用 QueuePool 时，添加 QueuePool 特有的参数\n        if _pool_class == QueuePool:\n            _db_kwargs.update({\n                \"pool_size\": settings.DB_SQLITE_POOL_SIZE,\n                \"pool_timeout\": settings.DB_POOL_TIMEOUT,\n                \"max_overflow\": settings.DB_SQLITE_MAX_OVERFLOW\n            })\n\n        # 创建数据库引擎\n        engine = create_engine(**_db_kwargs)\n\n        # 设置WAL模式\n        _journal_mode = \"WAL\" if settings.DB_WAL_ENABLE else \"DELETE\"\n        with engine.connect() as connection:\n            current_mode = connection.execute(text(f\"PRAGMA journal_mode={_journal_mode};\")).scalar()\n            print(f\"SQLite database journal mode set to: {current_mode}\")\n\n        return engine\n    else:\n        # 数据库参数，只能使用 NullPool\n        _db_kwargs = {\n            \"url\": f\"sqlite+aiosqlite:///{settings.CONFIG_PATH}/user.db\",\n            \"pool_pre_ping\": settings.DB_POOL_PRE_PING,\n            \"echo\": settings.DB_ECHO,\n            \"poolclass\": NullPool,\n            \"pool_recycle\": settings.DB_POOL_RECYCLE,\n            \"connect_args\": _connect_args\n        }\n        # 创建异步数据库引擎\n        async_engine = create_async_engine(**_db_kwargs)\n\n        # 设置WAL模式\n        _journal_mode = \"WAL\" if settings.DB_WAL_ENABLE else \"DELETE\"\n\n        async def set_async_wal_mode():\n            \"\"\"\n            设置异步引擎的WAL模式\n            \"\"\"\n            async with async_engine.connect() as _connection:\n                result = await _connection.execute(text(f\"PRAGMA journal_mode={_journal_mode};\"))\n                _current_mode = result.scalar()\n                print(f\"Async SQLite database journal mode set to: {_current_mode}\")\n\n        try:\n            asyncio.run(set_async_wal_mode())\n        except Exception as e:\n            print(f\"Failed to set async SQLite WAL mode: {e}\")\n\n        return async_engine\n\n\ndef _get_postgresql_engine(is_async: bool = False):\n    \"\"\"\n    获取PostgreSQL数据库引擎\n    \"\"\"\n    # 构建PostgreSQL连接URL\n    if settings.DB_POSTGRESQL_PASSWORD:\n        db_url = f\"postgresql://{settings.DB_POSTGRESQL_USERNAME}:{settings.DB_POSTGRESQL_PASSWORD}@{settings.DB_POSTGRESQL_HOST}:{settings.DB_POSTGRESQL_PORT}/{settings.DB_POSTGRESQL_DATABASE}\"\n    else:\n        db_url = f\"postgresql://{settings.DB_POSTGRESQL_USERNAME}@{settings.DB_POSTGRESQL_HOST}:{settings.DB_POSTGRESQL_PORT}/{settings.DB_POSTGRESQL_DATABASE}\"\n\n    # PostgreSQL连接参数\n    _connect_args = {}\n\n    # 创建同步引擎\n    if not is_async:\n        # 根据池类型设置 poolclass 和相关参数\n        _pool_class = NullPool if settings.DB_POOL_TYPE == \"NullPool\" else QueuePool\n\n        # 数据库参数\n        _db_kwargs = {\n            \"url\": db_url,\n            \"pool_pre_ping\": settings.DB_POOL_PRE_PING,\n            \"echo\": settings.DB_ECHO,\n            \"poolclass\": _pool_class,\n            \"pool_recycle\": settings.DB_POOL_RECYCLE,\n            \"connect_args\": _connect_args\n        }\n\n        # 当使用 QueuePool 时，添加 QueuePool 特有的参数\n        if _pool_class == QueuePool:\n            _db_kwargs.update({\n                \"pool_size\": settings.DB_POSTGRESQL_POOL_SIZE,\n                \"pool_timeout\": settings.DB_POOL_TIMEOUT,\n                \"max_overflow\": settings.DB_POSTGRESQL_MAX_OVERFLOW\n            })\n\n        # 创建数据库引擎\n        engine = create_engine(**_db_kwargs)\n        print(f\"PostgreSQL database connected to {settings.DB_POSTGRESQL_HOST}:{settings.DB_POSTGRESQL_PORT}/{settings.DB_POSTGRESQL_DATABASE}\")\n\n        return engine\n    else:\n        # 构建异步PostgreSQL连接URL\n        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}\"\n\n        # 数据库参数，只能使用 NullPool\n        _db_kwargs = {\n            \"url\": async_db_url,\n            \"pool_pre_ping\": settings.DB_POOL_PRE_PING,\n            \"echo\": settings.DB_ECHO,\n            \"poolclass\": NullPool,\n            \"pool_recycle\": settings.DB_POOL_RECYCLE,\n            \"connect_args\": _connect_args\n        }\n        # 创建异步数据库引擎\n        async_engine = create_async_engine(**_db_kwargs)\n        print(f\"Async PostgreSQL database connected to {settings.DB_POSTGRESQL_HOST}:{settings.DB_POSTGRESQL_PORT}/{settings.DB_POSTGRESQL_DATABASE}\")\n\n        return async_engine\n\n\n# 同步数据库引擎\nEngine = _get_database_engine(is_async=False)\n\n# 异步数据库引擎\nAsyncEngine = _get_database_engine(is_async=True)\n\n# 同步会话工厂\nSessionFactory = sessionmaker(bind=Engine)\n\n# 异步会话工厂\nAsyncSessionFactory = async_sessionmaker(bind=AsyncEngine, class_=AsyncSession)\n\n# 同步多线程全局使用的数据库会话\nScopedSession = scoped_session(SessionFactory)\n\n\ndef get_db() -> Generator:\n    \"\"\"\n    获取数据库会话，用于WEB请求\n    :return: Session\n    \"\"\"\n    db = None\n    try:\n        db = SessionFactory()\n        yield db\n    finally:\n        if db:\n            db.close()\n\n\nasync def get_async_db() -> AsyncGenerator[AsyncSession, None]:\n    \"\"\"\n    获取异步数据库会话，用于WEB请求\n    :return: AsyncSession\n    \"\"\"\n    async with AsyncSessionFactory() as session:\n        try:\n            yield session\n        finally:\n            await session.close()\n\n\nasync def close_database():\n    \"\"\"\n    关闭所有数据库连接并清理资源\n    \"\"\"\n    try:\n        # 释放同步连接池\n        Engine.dispose()  # noqa\n        # 释放异步连接池\n        await AsyncEngine.dispose()\n    except Exception as err:\n        print(f\"Error while disposing database connections: {err}\")\n\n\ndef _get_args_db(args: tuple, kwargs: dict) -> Optional[Session]:\n    \"\"\"\n    从参数中获取数据库Session对象\n    \"\"\"\n    db = None\n    if args:\n        for arg in args:\n            if isinstance(arg, Session):\n                db = arg\n                break\n    if kwargs:\n        for key, value in kwargs.items():\n            if isinstance(value, Session):\n                db = value\n                break\n    return db\n\n\ndef _get_args_async_db(args: tuple, kwargs: dict) -> Optional[AsyncSession]:\n    \"\"\"\n    从参数中获取异步数据库AsyncSession对象\n    \"\"\"\n    db = None\n    if args:\n        for arg in args:\n            if isinstance(arg, AsyncSession):\n                db = arg\n                break\n    if kwargs:\n        for key, value in kwargs.items():\n            if isinstance(value, AsyncSession):\n                db = value\n                break\n    return db\n\n\ndef _update_args_db(args: tuple, kwargs: dict, db: Session) -> Tuple[tuple, dict]:\n    \"\"\"\n    更新参数中的数据库Session对象，关键字传参时更新db的值，否则更新第1或第2个参数\n    \"\"\"\n    if kwargs and 'db' in kwargs:\n        kwargs['db'] = db\n    elif args:\n        if args[0] is None:\n            args = (db, *args[1:])\n        else:\n            args = (args[0], db, *args[2:])\n    return args, kwargs\n\n\ndef _update_args_async_db(args: tuple, kwargs: dict, db: AsyncSession) -> Tuple[tuple, dict]:\n    \"\"\"\n    更新参数中的异步数据库AsyncSession对象，关键字传参时更新db的值，否则更新第1或第2个参数\n    \"\"\"\n    if kwargs and 'db' in kwargs:\n        kwargs['db'] = db\n    elif args:\n        if args[0] is None:\n            args = (db, *args[1:])\n        else:\n            args = (args[0], db, *args[2:])\n    return args, kwargs\n\n\ndef db_update(func):\n    \"\"\"\n    数据库更新类操作装饰器，第一个参数必须是数据库会话或存在db参数\n    \"\"\"\n\n    def wrapper(*args, **kwargs):\n        # 是否关闭数据库会话\n        _close_db = False\n        # 从参数中获取数据库会话\n        db = _get_args_db(args, kwargs)\n        if not db:\n            # 如果没有获取到数据库会话，创建一个\n            db = ScopedSession()\n            # 标记需要关闭数据库会话\n            _close_db = True\n            # 更新参数中的数据库会话\n            args, kwargs = _update_args_db(args, kwargs, db)\n        try:\n            # 执行函数\n            result = func(*args, **kwargs)\n            # 提交事务\n            db.commit()\n        except Exception as err:\n            # 回滚事务\n            db.rollback()\n            raise err\n        finally:\n            # 关闭数据库会话\n            if _close_db:\n                db.close()\n        return result\n\n    return wrapper\n\n\ndef async_db_update(func):\n    \"\"\"\n    异步数据库更新类操作装饰器，第一个参数必须是异步数据库会话或存在db参数\n    \"\"\"\n\n    async def wrapper(*args, **kwargs):\n        # 是否关闭数据库会话\n        _close_db = False\n        # 从参数中获取异步数据库会话\n        db = _get_args_async_db(args, kwargs)\n        if not db:\n            # 如果没有获取到异步数据库会话，创建一个\n            db = AsyncSessionFactory()\n            # 标记需要关闭数据库会话\n            _close_db = True\n            # 更新参数中的异步数据库会话\n            args, kwargs = _update_args_async_db(args, kwargs, db)\n        try:\n            # 执行函数\n            result = await func(*args, **kwargs)\n            # 提交事务\n            await db.commit()\n        except Exception as err:\n            # 回滚事务\n            await db.rollback()\n            raise err\n        finally:\n            # 关闭数据库会话\n            if _close_db:\n                await db.close()\n        return result\n\n    return wrapper\n\n\ndef db_query(func):\n    \"\"\"\n    数据库查询操作装饰器，第一个参数必须是数据库会话或存在db参数\n    注意：db.query列表数据时，需要转换为list返回\n    \"\"\"\n\n    def wrapper(*args, **kwargs):\n        # 是否关闭数据库会话\n        _close_db = False\n        # 从参数中获取数据库会话\n        db = _get_args_db(args, kwargs)\n        if not db:\n            # 如果没有获取到数据库会话，创建一个\n            db = ScopedSession()\n            # 标记需要关闭数据库会话\n            _close_db = True\n            # 更新参数中的数据库会话\n            args, kwargs = _update_args_db(args, kwargs, db)\n        try:\n            # 执行函数\n            result = func(*args, **kwargs)\n        except Exception as err:\n            raise err\n        finally:\n            # 关闭数据库会话\n            if _close_db:\n                db.close()\n        return result\n\n    return wrapper\n\n\ndef async_db_query(func):\n    \"\"\"\n    异步数据库查询操作装饰器，第一个参数必须是异步数据库会话或存在db参数\n    注意：db.query列表数据时，需要转换为list返回\n    \"\"\"\n\n    async def wrapper(*args, **kwargs):\n        # 是否关闭数据库会话\n        _close_db = False\n        # 从参数中获取异步数据库会话\n        db = _get_args_async_db(args, kwargs)\n        if not db:\n            # 如果没有获取到异步数据库会话，创建一个\n            db = AsyncSessionFactory()\n            # 标记需要关闭数据库会话\n            _close_db = True\n            # 更新参数中的异步数据库会话\n            args, kwargs = _update_args_async_db(args, kwargs, db)\n        try:\n            # 执行函数\n            result = await func(*args, **kwargs)\n        except Exception as err:\n            raise err\n        finally:\n            # 关闭数据库会话\n            if _close_db:\n                await db.close()\n        return result\n\n    return wrapper\n\n\n@as_declarative()\nclass Base:\n    id: Any\n    __name__: str\n\n    @db_update\n    def create(self, db: Session):\n        db.add(self)\n\n    @async_db_update\n    async def async_create(self, db: AsyncSession):\n        db.add(self)\n        await db.flush()\n        return self\n\n    @classmethod\n    @db_query\n    def get(cls, db: Session, rid: int) -> Self:\n        return db.query(cls).filter(and_(cls.id == rid)).first()\n\n    @classmethod\n    @async_db_query\n    async def async_get(cls, db: AsyncSession, rid: int) -> Self:\n        result = await db.execute(select(cls).where(and_(cls.id == rid)))\n        return result.scalars().first()\n\n    @db_update\n    def update(self, db: Session, payload: dict):\n        for key, value in payload.items():\n            setattr(self, key, value)\n        if inspect(self).detached:\n            db.add(self)\n\n    @async_db_update\n    async def async_update(self, db: AsyncSession, payload: dict):\n        for key, value in payload.items():\n            setattr(self, key, value)\n        if inspect(self).detached:\n            db.add(self)\n\n    @classmethod\n    @db_update\n    def delete(cls, db: Session, rid):\n        db.query(cls).filter(and_(cls.id == rid)).delete()\n\n    @classmethod\n    @async_db_update\n    async def async_delete(cls, db: AsyncSession, rid):\n        result = await db.execute(select(cls).where(and_(cls.id == rid)))\n        user = result.scalars().first()\n        if user:\n            await db.delete(user)\n\n    @classmethod\n    @db_update\n    def truncate(cls, db: Session):\n        db.query(cls).delete()\n\n    @classmethod\n    @async_db_update\n    async def async_truncate(cls, db: AsyncSession):\n        await db.execute(delete(cls))\n\n    @classmethod\n    @db_query\n    def list(cls, db: Session) -> List[Self]:\n        return db.query(cls).all()\n\n    @classmethod\n    @async_db_query\n    async def async_list(cls, db: AsyncSession) -> Sequence[Self]:\n        result = await db.execute(select(cls))\n        return result.scalars().all()\n\n    def to_dict(self):\n        return {c.name: getattr(self, c.name, None) for c in self.__table__.columns}  # noqa\n\n    @declared_attr\n    def __tablename__(self) -> str:\n        return self.__name__.lower()\n\n\nclass DbOper:\n    \"\"\"\n    数据库操作基类\n    \"\"\"\n\n    def __init__(self, db: Union[Session, AsyncSession] = None):\n        self._db = db\n"
  },
  {
    "path": "app/db/downloadhistory_oper.py",
    "content": "from typing import List, Optional\n\nfrom app.db import DbOper\nfrom app.db.models.downloadhistory import DownloadHistory, DownloadFiles\n\n\nclass DownloadHistoryOper(DbOper):\n    \"\"\"\n    下载历史管理\n    \"\"\"\n\n    def get_by_path(self, path: str) -> DownloadHistory:\n        \"\"\"\n        按路径查询下载记录\n        :param path: 数据key\n        \"\"\"\n        return DownloadHistory.get_by_path(self._db, path)\n\n    def get_by_hash(self, download_hash: str) -> DownloadHistory:\n        \"\"\"\n        按Hash查询下载记录\n        :param download_hash: 数据key\n        \"\"\"\n        return DownloadHistory.get_by_hash(self._db, download_hash)\n\n    def get_by_mediaid(self, tmdbid: int, doubanid: str) -> List[DownloadHistory]:\n        \"\"\"\n        按媒体ID查询下载记录\n        :param tmdbid: tmdbid\n        :param doubanid: doubanid\n        \"\"\"\n        return DownloadHistory.get_by_mediaid(self._db, tmdbid=tmdbid, doubanid=doubanid)\n\n    def add(self, **kwargs):\n        \"\"\"\n        新增下载历史\n        \"\"\"\n        DownloadHistory(**kwargs).create(self._db)\n\n    def add_files(self, file_items: List[dict]):\n        \"\"\"\n        新增下载历史文件\n        \"\"\"\n        for file_item in file_items:\n            downloadfile = DownloadFiles(**file_item)\n            downloadfile.create(self._db)\n\n    def truncate_files(self):\n        \"\"\"\n        清空下载历史文件记录\n        \"\"\"\n        DownloadFiles.truncate(self._db)\n\n    def get_files_by_hash(self, download_hash: str, state: Optional[int] = None) -> List[DownloadFiles]:\n        \"\"\"\n        按Hash查询下载文件记录\n        :param download_hash: 数据key\n        :param state: 删除状态\n        \"\"\"\n        return DownloadFiles.get_by_hash(self._db, download_hash, state)\n\n    def get_file_by_fullpath(self, fullpath: str) -> DownloadFiles:\n        \"\"\"\n        按fullpath查询下载文件记录\n        :param fullpath: 数据key\n        \"\"\"\n        return DownloadFiles.get_by_fullpath(self._db, fullpath=fullpath, all_files=False)\n\n    def get_files_by_fullpath(self, fullpath: str) -> List[DownloadFiles]:\n        \"\"\"\n        按fullpath查询下载文件记录\n        :param fullpath: 数据key\n        \"\"\"\n        return DownloadFiles.get_by_fullpath(self._db, fullpath=fullpath, all_files=True)\n\n    def get_files_by_savepath(self, fullpath: str) -> List[DownloadFiles]:\n        \"\"\"\n        按savepath查询下载文件记录\n        :param fullpath: 数据key\n        \"\"\"\n        return DownloadFiles.get_by_savepath(self._db, fullpath)\n\n    def delete_file_by_fullpath(self, fullpath: str):\n        \"\"\"\n        按fullpath删除下载文件记录\n        :param fullpath: 数据key\n        \"\"\"\n        DownloadFiles.delete_by_fullpath(self._db, fullpath)\n\n    def get_hash_by_fullpath(self, fullpath: str) -> str:\n        \"\"\"\n        按fullpath查询下载文件记录hash\n        :param fullpath: 数据key\n        \"\"\"\n        fileinfo: DownloadFiles = DownloadFiles.get_by_fullpath(self._db, fullpath=fullpath, all_files=False)\n        if fileinfo:\n            return fileinfo.download_hash\n        return \"\"\n\n    def list_by_page(self, page: Optional[int] = 1, count: Optional[int] = 30) -> List[DownloadHistory]:\n        \"\"\"\n        分页查询下载历史\n        \"\"\"\n        return DownloadHistory.list_by_page(self._db, page, count)\n\n    def truncate(self):\n        \"\"\"\n        清空下载记录\n        \"\"\"\n        DownloadHistory.truncate(self._db)\n\n    def get_last_by(self, mtype=None, title: Optional[str] = None, year: Optional[str] = None,\n                    season: Optional[str] = None, episode: Optional[str] = None, tmdbid=None) -> List[DownloadHistory]:\n        \"\"\"\n        按类型、标题、年份、季集查询下载记录\n        tmdbid + mtype 或 title + year\n        \"\"\"\n        return DownloadHistory.get_last_by(db=self._db,\n                                           mtype=mtype,\n                                           title=title,\n                                           year=year,\n                                           season=season,\n                                           episode=episode,\n                                           tmdbid=tmdbid)\n\n    def list_by_user_date(self, date: str, username: Optional[str] = None) -> List[DownloadHistory]:\n        \"\"\"\n        查询某用户某时间之前的下载历史\n        \"\"\"\n        return DownloadHistory.list_by_user_date(db=self._db,\n                                                 date=date,\n                                                 username=username)\n\n    def list_by_date(self, date: str, type: str, tmdbid: str, seasons: Optional[str] = None) -> List[DownloadHistory]:\n        \"\"\"\n        查询某时间之后的下载历史\n        \"\"\"\n        return DownloadHistory.list_by_date(db=self._db,\n                                            date=date,\n                                            type=type,\n                                            tmdbid=tmdbid,\n                                            seasons=seasons)\n\n    def list_by_type(self, mtype: str, days: Optional[int] = 7) -> List[DownloadHistory]:\n        \"\"\"\n        获取指定类型的下载历史\n        \"\"\"\n        return DownloadHistory.list_by_type(db=self._db,\n                                            mtype=mtype,\n                                            days=days)\n\n    def delete_history(self, historyid):\n        \"\"\"\n        删除下载记录\n        \"\"\"\n        DownloadHistory.delete(self._db, historyid)\n\n    def delete_downloadfile(self, downloadfileid):\n        \"\"\"\n        删除下载文件记录\n        \"\"\"\n        DownloadFiles.delete(self._db, downloadfileid)\n"
  },
  {
    "path": "app/db/init.py",
    "content": "from alembic.command import upgrade\nfrom alembic.config import Config\n\nfrom app.core.config import settings\nfrom app.db import Engine, Base\nfrom app.log import logger\n\n\ndef init_db():\n    \"\"\"\n    初始化数据库\n    \"\"\"\n    # 全量建表\n    Base.metadata.create_all(bind=Engine) # noqa\n\n\ndef update_db():\n    \"\"\"\n    更新数据库\n    \"\"\"\n    script_location = settings.ROOT_PATH / 'database'\n    try:\n        alembic_cfg = Config()\n        alembic_cfg.set_main_option('script_location', str(script_location))\n        \n        # 根据数据库类型设置不同的URL\n        if settings.DB_TYPE.lower() == \"postgresql\":\n            if settings.DB_POSTGRESQL_PASSWORD:\n                db_url = f\"postgresql://{settings.DB_POSTGRESQL_USERNAME}:{settings.DB_POSTGRESQL_PASSWORD}@{settings.DB_POSTGRESQL_HOST}:{settings.DB_POSTGRESQL_PORT}/{settings.DB_POSTGRESQL_DATABASE}\"\n            else:\n                db_url = f\"postgresql://{settings.DB_POSTGRESQL_USERNAME}@{settings.DB_POSTGRESQL_HOST}:{settings.DB_POSTGRESQL_PORT}/{settings.DB_POSTGRESQL_DATABASE}\"\n        else:\n            db_location = settings.CONFIG_PATH / 'user.db'\n            db_url = f\"sqlite:///{db_location}\"\n            \n        alembic_cfg.set_main_option('sqlalchemy.url', db_url)\n        upgrade(alembic_cfg, 'head')\n    except Exception as e:\n        logger.error(f'数据库更新失败：{str(e)}')\n"
  },
  {
    "path": "app/db/mediaserver_oper.py",
    "content": "from typing import Optional\n\nfrom sqlalchemy.orm import Session\n\nfrom app.db import DbOper\nfrom app.db.models.mediaserver import MediaServerItem\n\n\nclass MediaServerOper(DbOper):\n    \"\"\"\n    媒体服务器数据管理\n    \"\"\"\n\n    def __init__(self, db: Session = None):\n        super().__init__(db)\n\n    def add(self, **kwargs) -> bool:\n        \"\"\"\n        新增媒体服务器数据\n        \"\"\"\n        # MediaServerItem中没有的属性剔除\n        kwargs = {k: v for k, v in kwargs.items() if hasattr(MediaServerItem, k)}\n        item = MediaServerItem(**kwargs)\n        if not item.get_by_itemid(self._db, kwargs.get(\"item_id\")):\n            item.create(self._db)\n            return True\n        return False\n\n    def empty(self, server: Optional[str] = None):\n        \"\"\"\n        清空媒体服务器数据\n        \"\"\"\n        MediaServerItem.empty(self._db, server)\n\n    def exists(self, **kwargs) -> Optional[MediaServerItem]:\n        \"\"\"\n        判断媒体服务器数据是否存在\n        \"\"\"\n        if kwargs.get(\"tmdbid\"):\n            # 优先按TMDBID查\n            item = MediaServerItem.exist_by_tmdbid(self._db, tmdbid=kwargs.get(\"tmdbid\"),\n                                                   mtype=kwargs.get(\"mtype\"))\n        elif kwargs.get(\"title\"):\n            # 按标题、类型、年份查\n            item = MediaServerItem.exists_by_title(self._db, title=kwargs.get(\"title\"),\n                                                   mtype=kwargs.get(\"mtype\"), year=kwargs.get(\"year\"))\n        else:\n            return None\n        if not item:\n            return None\n\n        if kwargs.get(\"season\") is not None:\n            # 判断季是否存在\n            if not item.seasoninfo:\n                return None\n            seasoninfo = item.seasoninfo or {}\n            if kwargs.get(\"season\") not in seasoninfo.keys():\n                return None\n        return item\n\n    async def async_exists(self, **kwargs) -> Optional[MediaServerItem]:\n        \"\"\"\n        异步判断媒体服务器数据是否存在\n        \"\"\"\n        if kwargs.get(\"tmdbid\"):\n            # 优先按TMDBID查\n            item = await MediaServerItem.async_exist_by_tmdbid(self._db, tmdbid=kwargs.get(\"tmdbid\"),\n                                                               mtype=kwargs.get(\"mtype\"))\n        elif kwargs.get(\"title\"):\n            # 按标题、类型、年份查\n            item = await MediaServerItem.async_exists_by_title(self._db, title=kwargs.get(\"title\"),\n                                                               mtype=kwargs.get(\"mtype\"), year=kwargs.get(\"year\"))\n        else:\n            return None\n        if not item:\n            return None\n\n        if kwargs.get(\"season\") is not None:\n            # 判断季是否存在\n            if not item.seasoninfo:\n                return None\n            seasoninfo = item.seasoninfo or {}\n            if kwargs.get(\"season\") not in seasoninfo.keys():\n                return None\n        return item\n\n    def get_item_id(self, **kwargs) -> Optional[str]:\n        \"\"\"\n        获取媒体服务器数据ID\n        \"\"\"\n        item = self.exists(**kwargs)\n        if not item:\n            return None\n        return str(item.item_id)\n\n    async def async_get_item_id(self, **kwargs) -> Optional[str]:\n        \"\"\"\n        异步获取媒体服务器数据ID\n        \"\"\"\n        item = await self.async_exists(**kwargs)\n        if not item:\n            return None\n        return str(item.item_id)\n"
  },
  {
    "path": "app/db/message_oper.py",
    "content": "import time\nfrom typing import Optional, Union\n\nfrom sqlalchemy.orm import Session\n\nfrom app.db import DbOper\nfrom app.db.models.message import Message\nfrom app.schemas import MessageChannel, NotificationType\n\n\nclass MessageOper(DbOper):\n    \"\"\"\n    消息数据管理\n    \"\"\"\n\n    def __init__(self, db: Session = None):\n        super().__init__(db)\n\n    def add(self,\n            channel: MessageChannel = None,\n            source: Optional[str] = None,\n            mtype: NotificationType = None,\n            title: Optional[str] = None,\n            text: Optional[str] = None,\n            image: Optional[str] = None,\n            link: Optional[str] = None,\n            userid: Optional[str] = None,\n            action: Optional[int] = 1,\n            note: Union[list, dict] = None,\n            **kwargs):\n        \"\"\"\n        新增消息\n        :param channel: 消息渠道\n        :param source: 来源\n        :param mtype: 消息类型\n        :param title: 标题\n        :param text: 文本内容\n        :param image: 图片\n        :param link: 链接\n        :param userid: 用户ID\n        :param action: 消息方向：0-接收息，1-发送消息\n        :param note: 附件json\n        \"\"\"\n        kwargs.update({\n            \"channel\": channel.value if channel else '',\n            \"source\": source,\n            \"mtype\": mtype.value if mtype else '',\n            \"title\": title,\n            \"text\": text,\n            \"image\": image,\n            \"link\": link,\n            \"userid\": userid,\n            \"action\": action,\n            \"reg_time\": time.strftime(\"%Y-%m-%d %H:%M:%S\", time.localtime()),\n            \"note\": note or {}\n        })\n\n        # 从kwargs中去掉Message中没有的字段\n        for k in list(kwargs.keys()):\n            if k not in Message.__table__.columns.keys():  # noqa\n                kwargs.pop(k)\n\n        Message(**kwargs).create(self._db)\n\n    async def async_add(self,\n                        channel: MessageChannel = None,\n                        source: Optional[str] = None,\n                        mtype: NotificationType = None,\n                        title: Optional[str] = None,\n                        text: Optional[str] = None,\n                        image: Optional[str] = None,\n                        link: Optional[str] = None,\n                        userid: Optional[str] = None,\n                        action: Optional[int] = 1,\n                        note: Union[list, dict] = None,\n                        **kwargs):\n        \"\"\"\n        异步新增消息\n        \"\"\"\n        kwargs.update({\n            \"channel\": channel.value if channel else '',\n            \"source\": source,\n            \"mtype\": mtype.value if mtype else '',\n            \"title\": title,\n            \"text\": text,\n            \"image\": image,\n            \"link\": link,\n            \"userid\": userid,\n            \"action\": action,\n            \"reg_time\": time.strftime(\"%Y-%m-%d %H:%M:%S\", time.localtime()),\n            \"note\": note or {}\n        })\n\n        # 从kwargs中去掉Message中没有的字段\n        for k in list(kwargs.keys()):\n            if k not in Message.__table__.columns.keys():  # noqa\n                kwargs.pop(k)\n\n        await Message(**kwargs).async_create(self._db)\n\n    def list_by_page(self, page: Optional[int] = 1, count: Optional[int] = 30) -> Optional[str]:\n        \"\"\"\n        获取媒体服务器数据ID\n        \"\"\"\n        return Message.list_by_page(self._db, page, count)\n"
  },
  {
    "path": "app/db/models/__init__.py",
    "content": "from .downloadhistory import DownloadHistory, DownloadFiles\nfrom .mediaserver import MediaServerItem\nfrom .passkey import PassKey\nfrom .plugindata import PluginData\nfrom .site import Site\nfrom .siteicon import SiteIcon\nfrom .subscribe import Subscribe\nfrom .systemconfig import SystemConfig\nfrom .transferhistory import TransferHistory\nfrom .user import User\nfrom .userconfig import UserConfig\nfrom .workflow import Workflow\n"
  },
  {
    "path": "app/db/models/downloadhistory.py",
    "content": "import time\nfrom typing import Optional\n\nfrom sqlalchemy import Column, Integer, String, JSON, select\nfrom sqlalchemy.ext.asyncio import AsyncSession\nfrom sqlalchemy.orm import Session\n\nfrom app.db import db_query, db_update, get_id_column, Base, async_db_query\n\n\nclass DownloadHistory(Base):\n    \"\"\"\n    下载历史记录\n    \"\"\"\n    id = get_id_column()\n    # 保存路径\n    path = Column(String, nullable=False, index=True)\n    # 类型 电影/电视剧\n    type = Column(String, nullable=False)\n    # 标题\n    title = Column(String, nullable=False)\n    # 年份\n    year = Column(String)\n    tmdbid = Column(Integer, index=True)\n    imdbid = Column(String)\n    tvdbid = Column(Integer)\n    doubanid = Column(String)\n    # Sxx\n    seasons = Column(String)\n    # Exx\n    episodes = Column(String)\n    # 海报\n    image = Column(String)\n    # 下载器\n    downloader = Column(String)\n    # 下载任务Hash\n    download_hash = Column(String, index=True)\n    # 种子名称\n    torrent_name = Column(String)\n    # 种子描述\n    torrent_description = Column(String)\n    # 种子站点\n    torrent_site = Column(String)\n    # 下载用户\n    userid = Column(String)\n    # 下载用户名/插件名\n    username = Column(String)\n    # 下载渠道\n    channel = Column(String)\n    # 创建时间\n    date = Column(String)\n    # 附加信息\n    note = Column(JSON)\n    # 自定义媒体类别\n    media_category = Column(String)\n    # 剧集组\n    episode_group = Column(String)\n    # 自定义识别词（用于整理时应用）\n    custom_words = Column(String)\n\n    @classmethod\n    @db_query\n    def get_by_hash(cls, db: Session, download_hash: str):\n        return db.query(DownloadHistory).filter(DownloadHistory.download_hash == download_hash).order_by(\n            DownloadHistory.date.desc()\n        ).first()\n\n    @classmethod\n    @db_query\n    def get_by_mediaid(cls, db: Session, tmdbid: int, doubanid: str):\n        if tmdbid:\n            return db.query(DownloadHistory).filter(DownloadHistory.tmdbid == tmdbid).all()\n        elif doubanid:\n            return db.query(DownloadHistory).filter(DownloadHistory.doubanid == doubanid).all()\n        return []\n\n    @classmethod\n    @db_query\n    def list_by_page(cls, db: Session, page: Optional[int] = 1, count: Optional[int] = 30):\n        return db.query(DownloadHistory).offset((page - 1) * count).limit(count).all()\n\n    @classmethod\n    @async_db_query\n    async def async_list_by_page(cls, db: AsyncSession, page: Optional[int] = 1, count: Optional[int] = 30):\n        result = await db.execute(\n            select(cls).offset((page - 1) * count).limit(count)\n        )\n        return result.scalars().all()\n\n    @classmethod\n    @db_query\n    def get_by_path(cls, db: Session, path: str):\n        return db.query(DownloadHistory).filter(DownloadHistory.path == path).first()\n\n    @classmethod\n    @db_query\n    def get_last_by(cls, db: Session, mtype: Optional[str] = None, title: Optional[str] = None,\n                    year: Optional[str] = None, season: Optional[str] = None,\n                    episode: Optional[str] = None, tmdbid: Optional[int] = None):\n        \"\"\"\n        据tmdbid、season、season_episode查询下载记录\n        tmdbid + mtype 或 title + year\n        \"\"\"\n        # TMDBID + 类型\n        if tmdbid and mtype:\n            # 电视剧某季某集\n            if season is not None and episode:\n                return db.query(DownloadHistory).filter(DownloadHistory.tmdbid == tmdbid,\n                                                        DownloadHistory.type == mtype,\n                                                        DownloadHistory.seasons == season,\n                                                        DownloadHistory.episodes == episode).order_by(\n                    DownloadHistory.id.desc()).all()\n            # 电视剧某季\n            elif season is not None:\n                return db.query(DownloadHistory).filter(DownloadHistory.tmdbid == tmdbid,\n                                                        DownloadHistory.type == mtype,\n                                                        DownloadHistory.seasons == season).order_by(\n                    DownloadHistory.id.desc()).all()\n            else:\n                # 电视剧所有季集/电影\n                return db.query(DownloadHistory).filter(DownloadHistory.tmdbid == tmdbid,\n                                                        DownloadHistory.type == mtype).order_by(\n                    DownloadHistory.id.desc()).all()\n        # 标题 + 年份\n        elif title and year:\n            # 电视剧某季某集\n            if season is not None and episode:\n                return db.query(DownloadHistory).filter(DownloadHistory.title == title,\n                                                        DownloadHistory.year == year,\n                                                        DownloadHistory.seasons == season,\n                                                        DownloadHistory.episodes == episode).order_by(\n                    DownloadHistory.id.desc()).all()\n            # 电视剧某季\n            elif season is not None:\n                return db.query(DownloadHistory).filter(DownloadHistory.title == title,\n                                                        DownloadHistory.year == year,\n                                                        DownloadHistory.seasons == season).order_by(\n                    DownloadHistory.id.desc()).all()\n            else:\n                # 电视剧所有季集/电影\n                return db.query(DownloadHistory).filter(DownloadHistory.title == title,\n                                                        DownloadHistory.year == year).order_by(\n                    DownloadHistory.id.desc()).all()\n\n        return []\n\n    @classmethod\n    @db_query\n    def list_by_user_date(cls, db: Session, date: str, username: Optional[str] = None):\n        \"\"\"\n        查询某用户某时间之后的下载历史\n        \"\"\"\n        if username:\n            return db.query(DownloadHistory).filter(DownloadHistory.date < date,\n                                                    DownloadHistory.username == username).order_by(\n                DownloadHistory.id.desc()).all()\n        else:\n            return db.query(DownloadHistory).filter(DownloadHistory.date < date).order_by(\n                DownloadHistory.id.desc()).all()\n\n    @classmethod\n    @db_query\n    def list_by_date(cls, db: Session, date: str, type: str, tmdbid: str, seasons: Optional[str] = None):\n        \"\"\"\n        查询某时间之后的下载历史\n        \"\"\"\n        if seasons:\n            return db.query(DownloadHistory).filter(DownloadHistory.date > date,\n                                                    DownloadHistory.type == type,\n                                                    DownloadHistory.tmdbid == tmdbid,\n                                                    DownloadHistory.seasons == seasons).order_by(\n                DownloadHistory.id.desc()).all()\n        else:\n            return db.query(DownloadHistory).filter(DownloadHistory.date > date,\n                                                    DownloadHistory.type == type,\n                                                    DownloadHistory.tmdbid == tmdbid).order_by(\n                DownloadHistory.id.desc()).all()\n\n    @classmethod\n    @db_query\n    def list_by_type(cls, db: Session, mtype: str, days: int):\n        return db.query(DownloadHistory) \\\n            .filter(DownloadHistory.type == mtype,\n                    DownloadHistory.date >= time.strftime(\"%Y-%m-%d %H:%M:%S\",\n                                                          time.localtime(time.time() - 86400 * int(days)))\n                    ).all()\n\n\nclass DownloadFiles(Base):\n    \"\"\"\n    下载文件记录\n    \"\"\"\n    id = get_id_column()\n    # 下载器\n    downloader = Column(String)\n    # 下载任务Hash\n    download_hash = Column(String, index=True)\n    # 完整路径\n    fullpath = Column(String, index=True)\n    # 保存路径\n    savepath = Column(String, index=True)\n    # 文件相对路径/名称\n    filepath = Column(String)\n    # 种子名称\n    torrentname = Column(String)\n    # 状态 0-已删除 1-正常\n    state = Column(Integer, nullable=False, default=1)\n\n    @classmethod\n    @db_query\n    def get_by_hash(cls, db: Session, download_hash: str, state: Optional[int] = None):\n        if state is not None:\n            return db.query(cls).filter(cls.download_hash == download_hash,\n                                        cls.state == state).all()\n        else:\n            return db.query(cls).filter(cls.download_hash == download_hash).all()\n\n    @classmethod\n    @db_query\n    def get_by_fullpath(cls, db: Session, fullpath: str, all_files: bool = False):\n        if not all_files:\n            return db.query(cls).filter(cls.fullpath == fullpath).order_by(\n                cls.id.desc()).first()\n        else:\n            return db.query(cls).filter(cls.fullpath == fullpath).order_by(\n                cls.id.desc()).all()\n\n    @classmethod\n    @db_query\n    def get_by_savepath(cls, db: Session, savepath: str):\n        return db.query(cls).filter(cls.savepath == savepath).all()\n\n    @classmethod\n    @db_update\n    def delete_by_fullpath(cls, db: Session, fullpath: str):\n        db.query(cls).filter(cls.fullpath == fullpath,\n                             cls.state == 1).update(\n            {\n                \"state\": 0\n            }\n        )\n"
  },
  {
    "path": "app/db/models/mediaserver.py",
    "content": "from datetime import datetime\nfrom typing import Optional\n\nfrom sqlalchemy import Column, Integer, String, JSON\nfrom sqlalchemy import select\nfrom sqlalchemy.ext.asyncio import AsyncSession\nfrom sqlalchemy.orm import Session\n\nfrom app.db import db_query, db_update, get_id_column, async_db_query, Base\n\n\nclass MediaServerItem(Base):\n    \"\"\"\n    媒体服务器媒体条目表\n    \"\"\"\n    id = get_id_column()\n    # 服务器类型\n    server = Column(String)\n    # 媒体库ID\n    library = Column(String)\n    # ID\n    item_id = Column(String, index=True)\n    # 类型\n    item_type = Column(String)\n    # 标题\n    title = Column(String, index=True)\n    # 原标题\n    original_title = Column(String)\n    # 年份\n    year = Column(String)\n    # TMDBID\n    tmdbid = Column(Integer, index=True)\n    # IMDBID\n    imdbid = Column(String, index=True)\n    # TVDBID\n    tvdbid = Column(String, index=True)\n    # 路径\n    path = Column(String)\n    # 季集\n    seasoninfo = Column(JSON, default=dict)\n    # 备注\n    note = Column(JSON)\n    # 同步时间\n    lst_mod_date = Column(String, default=datetime.now().strftime(\"%Y-%m-%d %H:%M:%S\"))\n\n    @classmethod\n    @db_query\n    def get_by_itemid(cls, db: Session, item_id: str):\n        return db.query(cls).filter(cls.item_id == item_id).first()\n\n    @classmethod\n    @db_update\n    def empty(cls, db: Session, server: Optional[str] = None):\n        if server is None:\n            db.query(cls).delete()\n        else:\n            db.query(cls).filter(cls.server == server).delete()\n\n    @classmethod\n    @db_query\n    def exist_by_tmdbid(cls, db: Session, tmdbid: int, mtype: str):\n        return db.query(cls).filter(cls.tmdbid == tmdbid,\n                                    cls.item_type == mtype).first()\n\n    @classmethod\n    @db_query\n    def exists_by_title(cls, db: Session, title: str, mtype: str, year: str):\n        if not mtype and not year:\n            return db.query(cls).filter(cls.title == title).first()\n        elif not year:\n            return db.query(cls).filter(cls.title == title,\n                                        cls.item_type == mtype).first()\n        elif not mtype:\n            return db.query(cls).filter(cls.title == title,\n                                        cls.year == str(year)).first()\n        return db.query(cls).filter(cls.title == title,\n                                    cls.item_type == mtype,\n                                    cls.year == str(year)).first()\n\n    @classmethod\n    @async_db_query\n    async def async_get_by_itemid(cls, db: AsyncSession, item_id: str):\n        result = await db.execute(select(cls).filter(cls.item_id == item_id))\n        return result.scalars().first()\n\n    @classmethod\n    @async_db_query\n    async def async_exist_by_tmdbid(cls, db: AsyncSession, tmdbid: int, mtype: str):\n        result = await db.execute(select(cls).filter(cls.tmdbid == tmdbid,\n                                                     cls.item_type == mtype))\n        return result.scalars().first()\n\n    @classmethod\n    @async_db_query\n    async def async_exists_by_title(cls, db: AsyncSession, title: str, mtype: str, year: str):\n        if not mtype and not year:\n            result = await db.execute(select(cls).filter(cls.title == title))\n        elif not year:\n            result = await db.execute(select(cls).filter(cls.title == title,\n                                                         cls.item_type == mtype))\n        elif not mtype:\n            result = await db.execute(select(cls).filter(cls.title == title,\n                                                         cls.year == str(year)))\n        else:\n            result = await db.execute(select(cls).filter(cls.title == title,\n                                                     cls.item_type == mtype,\n                                                     cls.year == str(year)))\n        return result.scalars().first()\n"
  },
  {
    "path": "app/db/models/message.py",
    "content": "from typing import Optional\n\nfrom sqlalchemy import Column, Integer, String, JSON, select\nfrom sqlalchemy.ext.asyncio import AsyncSession\nfrom sqlalchemy.orm import Session\n\nfrom app.db import db_query, Base, get_id_column, async_db_query\n\n\nclass Message(Base):\n    \"\"\"\n    消息表\n    \"\"\"\n    id = get_id_column()\n    # 消息渠道\n    channel = Column(String)\n    # 消息来源\n    source = Column(String)\n    # 消息类型\n    mtype = Column(String)\n    # 标题\n    title = Column(String)\n    # 文本内容\n    text = Column(String)\n    # 图片\n    image = Column(String)\n    # 链接\n    link = Column(String)\n    # 用户ID\n    userid = Column(String)\n    # 登记时间\n    reg_time = Column(String, index=True)\n    # 消息方向：0-接收息，1-发送消息\n    action = Column(Integer)\n    # 附件json\n    note = Column(JSON)\n\n    @classmethod\n    @db_query\n    def list_by_page(cls, db: Session, page: Optional[int] = 1, count: Optional[int] = 30):\n        return db.query(cls).order_by(cls.reg_time.desc()).offset((page - 1) * count).limit(count).all()\n\n    @classmethod\n    @async_db_query\n    async def async_list_by_page(cls, db: AsyncSession, page: Optional[int] = 1, count: Optional[int] = 30):\n        result = await db.execute(\n            select(cls).order_by(cls.reg_time.desc()).offset((page - 1) * count).limit(count)\n        )\n        return result.scalars().all()\n"
  },
  {
    "path": "app/db/models/passkey.py",
    "content": "from sqlalchemy import Column, Integer, String, Boolean, DateTime, Text, select, ForeignKey\nfrom sqlalchemy.ext.asyncio import AsyncSession\nfrom sqlalchemy.orm import Session\nfrom datetime import datetime\n\nfrom app.db import Base, db_query, db_update, async_db_query, async_db_update, get_id_column\n\n\nclass PassKey(Base):\n    \"\"\"\n    用户PassKey凭证表\n    \"\"\"\n    # ID\n    id = get_id_column()\n    # 用户ID\n    user_id = Column(Integer, ForeignKey('user.id'), nullable=False, index=True)\n    # 凭证ID (credential_id)\n    credential_id = Column(String, nullable=False, unique=True, index=True)\n    # 凭证公钥\n    public_key = Column(Text, nullable=False)\n    # 签名计数器\n    sign_count = Column(Integer, default=0)\n    # 凭证名称（用户自定义）\n    name = Column(String, default=\"通行密钥\")\n    # AAGUID (Authenticator Attestation GUID)\n    aaguid = Column(String, nullable=True)\n    # 创建时间\n    created_at = Column(DateTime, default=datetime.now)\n    # 最后使用时间\n    last_used_at = Column(DateTime, nullable=True)\n    # 是否启用\n    is_active = Column(Boolean, default=True)\n    # 传输方式 (usb, nfc, ble, internal)\n    transports = Column(String, nullable=True)\n\n    @classmethod\n    @db_query\n    def get_by_user_id(cls, db: Session, user_id: int):\n        \"\"\"获取用户的所有PassKey\"\"\"\n        return db.query(cls).filter(cls.user_id == user_id, cls.is_active.is_(True)).all()\n\n    @classmethod\n    @async_db_query\n    async def async_get_by_user_id(cls, db: AsyncSession, user_id: int):\n        \"\"\"异步获取用户的所有PassKey\"\"\"\n        result = await db.execute(\n            select(cls).filter(cls.user_id == user_id, cls.is_active.is_(True))\n        )\n        return result.scalars().all()\n\n    @classmethod\n    @db_query\n    def get_by_credential_id(cls, db: Session, credential_id: str):\n        \"\"\"根据凭证ID获取PassKey\"\"\"\n        return db.query(cls).filter(cls.credential_id == credential_id, cls.is_active.is_(True)).first()\n\n    @classmethod\n    @async_db_query\n    async def async_get_by_credential_id(cls, db: AsyncSession, credential_id: str):\n        \"\"\"异步根据凭证ID获取PassKey\"\"\"\n        result = await db.execute(\n            select(cls).filter(cls.credential_id == credential_id, cls.is_active.is_(True))\n        )\n        return result.scalars().first()\n\n    @classmethod\n    @db_query\n    def get_by_id(cls, db: Session, passkey_id: int):\n        \"\"\"根据ID获取PassKey\"\"\"\n        return db.query(cls).filter(cls.id == passkey_id).first()\n\n    @classmethod\n    @async_db_query\n    async def async_get_by_id(cls, db: AsyncSession, passkey_id: int):\n        \"\"\"异步根据ID获取PassKey\"\"\"\n        result = await db.execute(\n            select(cls).filter(cls.id == passkey_id)\n        )\n        return result.scalars().first()\n\n    @classmethod\n    @db_update\n    def delete_by_id(cls, db: Session, passkey_id: int, user_id: int):\n        \"\"\"删除指定用户的PassKey\"\"\"\n        passkey = db.query(cls).filter(\n            cls.id == passkey_id,\n            cls.user_id == user_id\n        ).first()\n        if passkey:\n            passkey.delete(db, passkey.id)\n            return True\n        return False\n\n    @classmethod\n    @async_db_update\n    async def async_delete_by_id(cls, db: AsyncSession, passkey_id: int, user_id: int):\n        \"\"\"异步删除指定用户的PassKey\"\"\"\n        result = await db.execute(\n            select(cls).filter(\n                cls.id == passkey_id,\n                cls.user_id == user_id\n            )\n        )\n        passkey = result.scalars().first()\n        if passkey:\n            await passkey.async_delete(db, passkey.id)\n            return True\n        return False\n\n    @db_update\n    def update_last_used(self, db: Session, sign_count: int):\n        \"\"\"更新最后使用时间和签名计数\"\"\"\n        self.update(db, {\n            'last_used_at': datetime.now(),\n            'sign_count': sign_count\n        })\n        return True\n\n    @async_db_update\n    async def async_update_last_used(self, db: AsyncSession, sign_count: int):\n        \"\"\"异步更新最后使用时间和签名计数\"\"\"\n        await self.async_update(db, {\n            'last_used_at': datetime.now(),\n            'sign_count': sign_count\n        })\n        return True\n"
  },
  {
    "path": "app/db/models/plugindata.py",
    "content": "from sqlalchemy import Column, String, JSON\nfrom sqlalchemy.orm import Session\n\nfrom app.db import db_query, db_update, get_id_column, Base\n\n\nclass PluginData(Base):\n    \"\"\"\n    插件数据表\n    \"\"\"\n    id = get_id_column()\n    plugin_id = Column(String, nullable=False, index=True)\n    key = Column(String, index=True, nullable=False)\n    value = Column(JSON)\n\n    @classmethod\n    @db_query\n    def get_plugin_data(cls, db: Session, plugin_id: str):\n        return db.query(cls).filter(cls.plugin_id == plugin_id).all()\n\n    @classmethod\n    @db_query\n    def get_plugin_data_by_key(cls, db: Session, plugin_id: str, key: str):\n        return db.query(cls).filter(cls.plugin_id == plugin_id, cls.key == key).first()\n\n    @classmethod\n    @db_update\n    def del_plugin_data_by_key(cls, db: Session, plugin_id: str, key: str):\n        db.query(cls).filter(cls.plugin_id == plugin_id, cls.key == key).delete()\n\n    @classmethod\n    @db_update\n    def del_plugin_data(cls, db: Session, plugin_id: str):\n        db.query(cls).filter(cls.plugin_id == plugin_id).delete()\n\n    @classmethod\n    @db_query\n    def get_plugin_data_by_plugin_id(cls, db: Session, plugin_id: str):\n        return db.query(cls).filter(cls.plugin_id == plugin_id).all()\n"
  },
  {
    "path": "app/db/models/site.py",
    "content": "from datetime import datetime\n\nfrom sqlalchemy import Boolean, Column, Integer, String, JSON, select, delete\nfrom sqlalchemy.ext.asyncio import AsyncSession\nfrom sqlalchemy.orm import Session\n\nfrom app.db import db_query, db_update, Base, async_db_query, async_db_update, get_id_column\n\n\nclass Site(Base):\n    \"\"\"\n    站点表\n    \"\"\"\n    id = get_id_column()\n    # 站点名\n    name = Column(String, nullable=False)\n    # 域名Key\n    domain = Column(String, index=True)\n    # 站点地址\n    url = Column(String, nullable=False)\n    # 站点优先级\n    pri = Column(Integer, default=1)\n    # RSS地址，未启用\n    rss = Column(String)\n    # Cookie\n    cookie = Column(String)\n    # User-Agent\n    ua = Column(String)\n    # ApiKey\n    apikey = Column(String)\n    # Token\n    token = Column(String)\n    # 是否使用代理 0-否，1-是\n    proxy = Column(Integer)\n    # 过滤规则\n    filter = Column(String)\n    # 是否渲染\n    render = Column(Integer)\n    # 是否公开站点\n    public = Column(Integer)\n    # 附加信息\n    note = Column(JSON)\n    # 流控单位周期\n    limit_interval = Column(Integer, default=0)\n    # 流控次数\n    limit_count = Column(Integer, default=0)\n    # 流控间隔\n    limit_seconds = Column(Integer, default=0)\n    # 超时时间\n    timeout = Column(Integer, default=15)\n    # 是否启用\n    is_active = Column(Boolean(), default=True)\n    # 创建时间\n    lst_mod_date = Column(String, default=datetime.now().strftime(\"%Y-%m-%d %H:%M:%S\"))\n    # 下载器\n    downloader = Column(String)\n\n    @classmethod\n    @db_query\n    def get_by_domain(cls, db: Session, domain: str):\n        return db.query(cls).filter(cls.domain == domain).first()\n\n    @classmethod\n    @async_db_query\n    async def async_get_by_domain(cls, db: AsyncSession, domain: str):\n        result = await db.execute(select(cls).where(cls.domain == domain))\n        return result.scalar_one_or_none()\n\n    @classmethod\n    @async_db_query\n    async def async_get_by_name(cls, db: AsyncSession, name: str):\n        result = await db.execute(select(cls).where(cls.name == name))\n        return result.scalar_one_or_none()\n\n    @classmethod\n    @db_query\n    def get_actives(cls, db: Session):\n        return db.query(cls).filter(cls.is_active).all()\n\n    @classmethod\n    @async_db_query\n    async def async_get_actives(cls, db: AsyncSession):\n        result = await db.execute(select(cls).where(cls.is_active))\n        return result.scalars().all()\n\n    @classmethod\n    @db_query\n    def list_order_by_pri(cls, db: Session):\n        return db.query(cls).order_by(cls.pri).all()\n\n    @classmethod\n    @async_db_query\n    async def async_list_order_by_pri(cls, db: AsyncSession):\n        result = await db.execute(select(cls).order_by(cls.pri))\n        return result.scalars().all()\n\n    @classmethod\n    @db_query\n    def get_domains_by_ids(cls, db: Session, ids: list):\n        return [r[0] for r in db.query(cls.domain).filter(cls.id.in_(ids)).all()]\n\n    @classmethod\n    @db_update\n    def reset(cls, db: Session):\n        db.query(cls).delete()\n\n    @classmethod\n    @async_db_update\n    async def async_reset(cls, db: AsyncSession):\n        await db.execute(delete(cls))\n"
  },
  {
    "path": "app/db/models/siteicon.py",
    "content": "from sqlalchemy import Column, String, select\nfrom sqlalchemy.ext.asyncio import AsyncSession\nfrom sqlalchemy.orm import Session\n\nfrom app.db import db_query, Base, get_id_column, async_db_query\n\n\nclass SiteIcon(Base):\n    \"\"\"\n    站点图标表\n    \"\"\"\n    id = get_id_column()\n    # 站点名称\n    name = Column(String, nullable=False)\n    # 域名Key\n    domain = Column(String, index=True)\n    # 图标地址\n    url = Column(String, nullable=False)\n    # 图标Base64\n    base64 = Column(String)\n\n    @classmethod\n    @db_query\n    def get_by_domain(cls, db: Session, domain: str):\n        return db.query(cls).filter(cls.domain == domain).first()\n\n    @classmethod\n    @async_db_query\n    async def async_get_by_domain(cls, db: AsyncSession, domain: str):\n        result = await db.execute(select(cls).where(cls.domain == domain))\n        return result.scalar_one_or_none()\n"
  },
  {
    "path": "app/db/models/sitestatistic.py",
    "content": "from datetime import datetime\n\nfrom sqlalchemy import Column, Integer, String, JSON, select\nfrom sqlalchemy.ext.asyncio import AsyncSession\nfrom sqlalchemy.orm import Session\n\nfrom app.db import db_query, db_update, get_id_column, Base, async_db_query\n\n\nclass SiteStatistic(Base):\n    \"\"\"\n    站点统计表\n    \"\"\"\n    id = get_id_column()\n    # 域名Key\n    domain = Column(String, index=True)\n    # 成功次数\n    success = Column(Integer)\n    # 失败次数\n    fail = Column(Integer)\n    # 平均耗时 秒\n    seconds = Column(Integer)\n    # 最后一次访问状态 0-成功 1-失败\n    lst_state = Column(Integer)\n    # 最后访问时间\n    lst_mod_date = Column(String, default=datetime.now().strftime(\"%Y-%m-%d %H:%M:%S\"))\n    # 耗时记录 Json\n    note = Column(JSON)\n\n    @classmethod\n    @db_query\n    def get_by_domain(cls, db: Session, domain: str):\n        return db.query(cls).filter(cls.domain == domain).first()\n\n    @classmethod\n    @async_db_query\n    async def async_get_by_domain(cls, db: AsyncSession, domain: str):\n        result = await db.execute(select(cls).where(cls.domain == domain))\n        return result.scalar_one_or_none()\n\n    @classmethod\n    @db_update\n    def reset(cls, db: Session):\n        db.query(cls).delete()\n"
  },
  {
    "path": "app/db/models/siteuserdata.py",
    "content": "from datetime import datetime\nfrom typing import Optional\n\nfrom sqlalchemy import Column, Integer, String, Float, JSON, func, or_, select\nfrom sqlalchemy.ext.asyncio import AsyncSession\nfrom sqlalchemy.orm import Session\n\nfrom app.db import db_query, Base, get_id_column, async_db_query\n\n\nclass SiteUserData(Base):\n    \"\"\"\n    站点数据表\n    \"\"\"\n    id = get_id_column()\n    # 站点域名\n    domain = Column(String, index=True)\n    # 站点名称\n    name = Column(String)\n    # 用户名\n    username = Column(String)\n    # 用户ID\n    userid = Column(String)\n    # 用户等级\n    user_level = Column(String)\n    # 加入时间\n    join_at = Column(String)\n    # 积分\n    bonus = Column(Float, default=0)\n    # 上传量\n    upload = Column(Float, default=0)\n    # 下载量\n    download = Column(Float, default=0)\n    # 分享率\n    ratio = Column(Float, default=0)\n    # 做种数\n    seeding = Column(Float, default=0)\n    # 下载数\n    leeching = Column(Float, default=0)\n    # 做种体积\n    seeding_size = Column(Float, default=0)\n    # 下载体积\n    leeching_size = Column(Float, default=0)\n    # 做种人数, 种子大小 JSON\n    seeding_info = Column(JSON, default=dict)\n    # 未读消息\n    message_unread = Column(Integer, default=0)\n    # 未读消息内容 JSON\n    message_unread_contents = Column(JSON, default=list)\n    # 错误信息\n    err_msg = Column(String)\n    # 更新日期\n    updated_day = Column(String, index=True, default=datetime.now().strftime('%Y-%m-%d'))\n    # 更新时间\n    updated_time = Column(String, default=datetime.now().strftime('%H:%M:%S'))\n\n    @classmethod\n    @db_query\n    def get_by_domain(cls, db: Session, domain: str, workdate: Optional[str] = None, worktime: Optional[str] = None):\n        if workdate and worktime:\n            return db.query(cls).filter(cls.domain == domain,\n                                        cls.updated_day == workdate,\n                                        cls.updated_time == worktime).all()\n        elif workdate:\n            return db.query(cls).filter(cls.domain == domain,\n                                        cls.updated_day == workdate).all()\n        return db.query(cls).filter(cls.domain == domain).all()\n\n    @classmethod\n    @async_db_query\n    async def async_get_by_domain(cls, db: AsyncSession, domain: str, workdate: Optional[str] = None, worktime: Optional[str] = None):\n        query = select(cls).filter(cls.domain == domain)\n        if workdate and worktime:\n            query = query.filter(cls.updated_day == workdate, cls.updated_time == worktime)\n        elif workdate:\n            query = query.filter(cls.updated_day == workdate)\n        result = await db.execute(query)\n        return result.scalars().all()\n\n    @classmethod\n    @db_query\n    def get_by_date(cls, db: Session, date: str):\n        return db.query(cls).filter(cls.updated_day == date).all()\n\n    @classmethod\n    @db_query\n    def get_latest(cls, db: Session):\n        \"\"\"\n        获取各站点最新一天的数据\n        \"\"\"\n        subquery = (\n            db.query(\n                cls.domain,\n                func.max(cls.updated_day).label('latest_update_day')\n            )\n            .group_by(cls.domain)\n            .filter(or_(cls.err_msg.is_(None), cls.err_msg == \"\"))\n            .subquery()\n        )\n\n        # 主查询：按 domain 和 updated_day 获取最新的记录\n        return db.query(cls).join(\n            subquery,\n            (cls.domain == subquery.c.domain) &\n            (cls.updated_day == subquery.c.latest_update_day)\n        ).order_by(cls.updated_time.desc()).all()\n\n    @classmethod\n    @async_db_query\n    async def async_get_latest(cls, db: AsyncSession):\n        \"\"\"\n        异步获取各站点最新一天的数据\n        \"\"\"\n        subquery = (\n            select(\n                cls.domain,\n                func.max(cls.updated_day).label('latest_update_day')\n            )\n            .group_by(cls.domain)\n            .filter(or_(cls.err_msg.is_(None), cls.err_msg == \"\"))\n            .subquery()\n        )\n\n        # 主查询：按 domain 和 updated_day 获取最新的记录\n        result = await db.execute(\n            select(cls).join(\n                subquery,\n                (cls.domain == subquery.c.domain) &\n                (cls.updated_day == subquery.c.latest_update_day)\n            ).order_by(cls.updated_time.desc()))\n        return result.scalars().all()\n"
  },
  {
    "path": "app/db/models/subscribe.py",
    "content": "import time\nfrom typing import Optional\n\nfrom sqlalchemy import Column, Integer, String, Float, JSON, select\nfrom sqlalchemy.ext.asyncio import AsyncSession\nfrom sqlalchemy.orm import Session\n\nfrom app.db import db_query, db_update, get_id_column, Base, async_db_query, async_db_update\n\n\nclass Subscribe(Base):\n    \"\"\"\n    订阅表\n    \"\"\"\n    id = get_id_column()\n    # 标题\n    name = Column(String, nullable=False, index=True)\n    # 年份\n    year = Column(String)\n    # 类型\n    type = Column(String)\n    # 搜索关键字\n    keyword = Column(String)\n    tmdbid = Column(Integer, index=True)\n    imdbid = Column(String)\n    tvdbid = Column(Integer)\n    doubanid = Column(String, index=True)\n    bangumiid = Column(Integer, index=True)\n    mediaid = Column(String, index=True)\n    # 季号\n    season = Column(Integer)\n    # 海报\n    poster = Column(String)\n    # 背景图\n    backdrop = Column(String)\n    # 评分，float\n    vote = Column(Float)\n    # 简介\n    description = Column(String)\n    # 过滤规则\n    filter = Column(String)\n    # 包含\n    include = Column(String)\n    # 排除\n    exclude = Column(String)\n    # 质量\n    quality = Column(String)\n    # 分辨率\n    resolution = Column(String)\n    # 特效\n    effect = Column(String)\n    # 总集数\n    total_episode = Column(Integer)\n    # 开始集数\n    start_episode = Column(Integer)\n    # 缺失集数\n    lack_episode = Column(Integer)\n    # 附加信息\n    note = Column(JSON)\n    # 状态：N-新建 R-订阅中 P-待定 S-暂停\n    state = Column(String, nullable=False, index=True, default='N')\n    # 最后更新时间\n    last_update = Column(String)\n    # 创建时间\n    date = Column(String)\n    # 订阅用户\n    username = Column(String)\n    # 订阅站点\n    sites = Column(JSON, default=list)\n    # 下载器\n    downloader = Column(String)\n    # 是否洗版\n    best_version = Column(Integer, default=0)\n    # 当前优先级\n    current_priority = Column(Integer)\n    # 保存路径\n    save_path = Column(String)\n    # 是否使用 imdbid 搜索\n    search_imdbid = Column(Integer, default=0)\n    # 是否手动修改过总集数 0否 1是\n    manual_total_episode = Column(Integer, default=0)\n    # 自定义识别词\n    custom_words = Column(String)\n    # 自定义媒体类别\n    media_category = Column(String)\n    # 过滤规则组\n    filter_groups = Column(JSON, default=list)\n    # 选择的剧集组\n    episode_group = Column(String)\n\n    @classmethod\n    @db_query\n    def exists(cls, db: Session, tmdbid: Optional[int] = None, doubanid: Optional[str] = None,\n               season: Optional[int] = None):\n        if tmdbid:\n            if season is not None:\n                return db.query(cls).filter(cls.tmdbid == tmdbid,\n                                            cls.season == season).first()\n            return db.query(cls).filter(cls.tmdbid == tmdbid).first()\n        elif doubanid:\n            return db.query(cls).filter(cls.doubanid == doubanid).first()\n        return None\n\n    @classmethod\n    @async_db_query\n    async def async_exists(cls, db: AsyncSession, tmdbid: Optional[int] = None, doubanid: Optional[str] = None,\n                           season: Optional[int] = None):\n        if tmdbid:\n            if season is not None:\n                result = await db.execute(\n                    select(cls).filter(cls.tmdbid == tmdbid, cls.season == season)\n                )\n            else:\n                result = await db.execute(\n                    select(cls).filter(cls.tmdbid == tmdbid)\n                )\n        elif doubanid:\n            result = await db.execute(\n                select(cls).filter(cls.doubanid == doubanid)\n            )\n        else:\n            return None\n        return result.scalars().first()\n\n    @classmethod\n    @db_query\n    def get_by_state(cls, db: Session, state: str):\n        # 如果 state 为空或 None，返回所有订阅\n        if not state:\n            return db.query(cls).all()\n        else:\n            # 如果传入的状态不为空，拆分成多个状态\n            return db.query(cls).filter(cls.state.in_(state.split(','))).all()\n\n    @classmethod\n    @async_db_query\n    async def async_get_by_state(cls, db: AsyncSession, state: str):\n        # 如果 state 为空或 None，返回所有订阅\n        if not state:\n            result = await db.execute(select(cls))\n        else:\n            # 如果传入的状态不为空，拆分成多个状态\n            result = await db.execute(\n                select(cls).filter(cls.state.in_(state.split(',')))\n            )\n        return result.scalars().all()\n\n    @classmethod\n    @db_query\n    def get_by_title(cls, db: Session, title: str, season: Optional[int] = None):\n        if season is not None:\n            return db.query(cls).filter(cls.name == title,\n                                        cls.season == season).first()\n        return db.query(cls).filter(cls.name == title).first()\n\n    @classmethod\n    @async_db_query\n    async def async_get_by_title(cls, db: AsyncSession, title: str, season: Optional[int] = None):\n        if season is not None:\n            result = await db.execute(\n                select(cls).filter(cls.name == title, cls.season == season)\n            )\n        else:\n            result = await db.execute(\n                select(cls).filter(cls.name == title)\n            )\n        return result.scalars().first()\n\n    @classmethod\n    @db_query\n    def get_by_tmdbid(cls, db: Session, tmdbid: int, season: Optional[int] = None):\n        if season is not None:\n            return db.query(cls).filter(cls.tmdbid == tmdbid,\n                                        cls.season == season).all()\n        else:\n            return db.query(cls).filter(cls.tmdbid == tmdbid).all()\n\n    @classmethod\n    @async_db_query\n    async def async_get_by_tmdbid(cls, db: AsyncSession, tmdbid: int, season: Optional[int] = None):\n        if season is not None:\n            result = await db.execute(\n                select(cls).filter(cls.tmdbid == tmdbid, cls.season == season)\n            )\n        else:\n            result = await db.execute(\n                select(cls).filter(cls.tmdbid == tmdbid)\n            )\n        return result.scalars().all()\n\n    @classmethod\n    @db_query\n    def get_by_doubanid(cls, db: Session, doubanid: str):\n        return db.query(cls).filter(cls.doubanid == doubanid).first()\n\n    @classmethod\n    @async_db_query\n    async def async_get_by_doubanid(cls, db: AsyncSession, doubanid: str):\n        result = await db.execute(\n            select(cls).filter(cls.doubanid == doubanid)\n        )\n        return result.scalars().first()\n\n    @classmethod\n    @db_query\n    def get_by_bangumiid(cls, db: Session, bangumiid: int):\n        return db.query(cls).filter(cls.bangumiid == bangumiid).first()\n\n    @classmethod\n    @async_db_query\n    async def async_get_by_bangumiid(cls, db: AsyncSession, bangumiid: int):\n        result = await db.execute(\n            select(cls).filter(cls.bangumiid == bangumiid)\n        )\n        return result.scalars().first()\n\n    @classmethod\n    @db_query\n    def get_by_mediaid(cls, db: Session, mediaid: str):\n        return db.query(cls).filter(cls.mediaid == mediaid).first()\n\n    @classmethod\n    @async_db_query\n    async def async_get_by_mediaid(cls, db: AsyncSession, mediaid: str):\n        result = await db.execute(\n            select(cls).filter(cls.mediaid == mediaid)\n        )\n        return result.scalars().first()\n\n    @classmethod\n    @db_query\n    def get_by(cls, db: Session, type: str, season: Optional[str] = None,\n                tmdbid: Optional[int] = None, doubanid: Optional[str] = None, bangumiid: Optional[str] = None):\n        \"\"\"\n        根据条件查询订阅\n        \"\"\"\n        # TMDBID\n        if tmdbid:\n            if season is not None:\n                result = db.query(cls).filter(\n                    cls.tmdbid == tmdbid, cls.type == type, cls.season == season\n                )\n            else:\n                result = db.query(cls).filter(cls.tmdbid == tmdbid, cls.type == type)\n        # 豆瓣ID\n        elif doubanid:\n            result = db.query(cls).filter(cls.doubanid == doubanid, cls.type == type)\n        # BangumiID\n        elif bangumiid:\n            result = db.query(cls).filter(cls.bangumiid == bangumiid, cls.type == type)\n        else:\n            return None\n\n        return result.first()\n\n    @classmethod\n    @async_db_query\n    async def async_get_by(cls, db: AsyncSession, type: str, season: Optional[str] = None,\n                tmdbid: Optional[int] = None, doubanid: Optional[str] = None, bangumiid: Optional[str] = None):\n        \"\"\"\n        根据条件查询订阅\n        \"\"\"\n        # TMDBID\n        if tmdbid:\n            if season is not None:\n                result = await db.execute(\n                    select(cls).filter(\n                        cls.tmdbid == tmdbid, cls.type == type, cls.season == season\n                    )\n                )\n            else:\n                result = await db.execute(\n                    select(cls).filter(cls.tmdbid == tmdbid, cls.type == type)\n                )\n        # 豆瓣ID\n        elif doubanid:\n            result = await db.execute(\n                select(cls).filter(cls.doubanid == doubanid, cls.type == type)\n            )\n        # BangumiID\n        elif bangumiid:\n            result = await db.execute(\n                select(cls).filter(cls.bangumiid == bangumiid, cls.type == type)\n            )\n        else:\n            return None\n\n        return result.scalars().first()\n\n    @db_update\n    def delete_by_tmdbid(self, db: Session, tmdbid: int, season: int):\n        subscrbies = self.get_by_tmdbid(db, tmdbid, season)\n        for subscrbie in subscrbies:\n            subscrbie.delete(db, subscrbie.id)\n        return True\n\n    @async_db_update\n    async def async_delete_by_tmdbid(self, db: AsyncSession, tmdbid: int, season: int):\n        subscrbies = await self.async_get_by_tmdbid(db, tmdbid, season)\n        for subscrbie in subscrbies:\n            await subscrbie.async_delete(db, subscrbie.id)\n        return True\n\n    @db_update\n    def delete_by_doubanid(self, db: Session, doubanid: str):\n        subscribe = self.get_by_doubanid(db, doubanid)\n        if subscribe:\n            subscribe.delete(db, subscribe.id)\n        return True\n\n    @async_db_update\n    async def async_delete_by_doubanid(self, db: AsyncSession, doubanid: str):\n        subscribe = await self.async_get_by_doubanid(db, doubanid)\n        if subscribe:\n            await subscribe.async_delete(db, subscribe.id)\n        return True\n\n    @db_update\n    def delete_by_mediaid(self, db: Session, mediaid: str):\n        subscribe = self.get_by_mediaid(db, mediaid)\n        if subscribe:\n            subscribe.delete(db, subscribe.id)\n        return True\n\n    @async_db_update\n    async def async_delete_by_mediaid(self, db: AsyncSession, mediaid: str):\n        subscribe = await self.async_get_by_mediaid(db, mediaid)\n        if subscribe:\n            await subscribe.async_delete(db, subscribe.id)\n        return True\n\n    @classmethod\n    @db_query\n    def list_by_username(cls, db: Session, username: str, state: Optional[str] = None, mtype: Optional[str] = None):\n        if mtype:\n            if state:\n                return db.query(cls).filter(cls.state == state,\n                                            cls.username == username,\n                                            cls.type == mtype).all()\n            else:\n                return db.query(cls).filter(cls.username == username,\n                                            cls.type == mtype).all()\n        else:\n            if state:\n                return db.query(cls).filter(cls.state == state,\n                                            cls.username == username).all()\n            else:\n                return db.query(cls).filter(cls.username == username).all()\n\n    @classmethod\n    @async_db_query\n    async def async_list_by_username(cls, db: AsyncSession, username: str, state: Optional[str] = None,\n                                     mtype: Optional[str] = None):\n        if mtype:\n            if state:\n                result = await db.execute(\n                    select(cls).filter(cls.state == state, cls.username == username, cls.type == mtype)\n                )\n            else:\n                result = await db.execute(\n                    select(cls).filter(cls.username == username, cls.type == mtype)\n                )\n        else:\n            if state:\n                result = await db.execute(\n                    select(cls).filter(cls.state == state, cls.username == username)\n                )\n            else:\n                result = await db.execute(\n                    select(cls).filter(cls.username == username)\n                )\n        return result.scalars().all()\n\n    @classmethod\n    @db_query\n    def list_by_type(cls, db: Session, mtype: str, days: int):\n        return db.query(cls) \\\n            .filter(cls.type == mtype,\n                    cls.date >= time.strftime(\"%Y-%m-%d %H:%M:%S\",\n                                              time.localtime(time.time() - 86400 * int(days)))\n                    ).all()\n\n    @classmethod\n    @async_db_query\n    async def async_list_by_type(cls, db: AsyncSession, mtype: str, days: int):\n        result = await db.execute(\n            select(cls).filter(\n                cls.type == mtype,\n                cls.date >= time.strftime(\"%Y-%m-%d %H:%M:%S\",\n                                          time.localtime(time.time() - 86400 * int(days)))\n            )\n        )\n        return result.scalars().all()\n"
  },
  {
    "path": "app/db/models/subscribehistory.py",
    "content": "from typing import Optional\n\nfrom sqlalchemy import Column, Integer, String, Float, JSON, select\nfrom sqlalchemy.ext.asyncio import AsyncSession\nfrom sqlalchemy.orm import Session\n\nfrom app.db import db_query, Base, get_id_column, async_db_query\n\n\nclass SubscribeHistory(Base):\n    \"\"\"\n    订阅历史表\n    \"\"\"\n    id = get_id_column()\n    # 标题\n    name = Column(String, nullable=False, index=True)\n    # 年份\n    year = Column(String)\n    # 类型\n    type = Column(String)\n    # 搜索关键字\n    keyword = Column(String)\n    tmdbid = Column(Integer, index=True)\n    imdbid = Column(String)\n    tvdbid = Column(Integer)\n    doubanid = Column(String, index=True)\n    bangumiid = Column(Integer, index=True)\n    mediaid = Column(String, index=True)\n    # 季号\n    season = Column(Integer)\n    # 海报\n    poster = Column(String)\n    # 背景图\n    backdrop = Column(String)\n    # 评分，float\n    vote = Column(Float)\n    # 简介\n    description = Column(String)\n    # 过滤规则\n    filter = Column(String)\n    # 包含\n    include = Column(String)\n    # 排除\n    exclude = Column(String)\n    # 质量\n    quality = Column(String)\n    # 分辨率\n    resolution = Column(String)\n    # 特效\n    effect = Column(String)\n    # 总集数\n    total_episode = Column(Integer)\n    # 开始集数\n    start_episode = Column(Integer)\n    # 订阅完成时间\n    date = Column(String)\n    # 订阅用户\n    username = Column(String)\n    # 订阅站点\n    sites = Column(JSON)\n    # 是否洗版\n    best_version = Column(Integer, default=0)\n    # 保存路径\n    save_path = Column(String)\n    # 是否使用 imdbid 搜索\n    search_imdbid = Column(Integer, default=0)\n    # 自定义识别词\n    custom_words = Column(String)\n    # 自定义媒体类别\n    media_category = Column(String)\n    # 过滤规则组\n    filter_groups = Column(JSON, default=list)\n    # 剧集组\n    episode_group = Column(String)\n\n    @classmethod\n    @db_query\n    def list_by_type(cls, db: Session, mtype: str, page: Optional[int] = 1, count: Optional[int] = 30):\n        return db.query(cls).filter(\n            cls.type == mtype\n        ).order_by(\n            cls.date.desc()\n        ).offset((page - 1) * count).limit(count).all()\n\n    @classmethod\n    @async_db_query\n    async def async_list_by_type(cls, db: AsyncSession, mtype: str, page: Optional[int] = 1, count: Optional[int] = 30):\n        result = await db.execute(\n            select(cls).filter(\n                cls.type == mtype\n            ).order_by(\n                cls.date.desc()\n            ).offset((page - 1) * count).limit(count)\n        )\n        return result.scalars().all()\n\n    @classmethod\n    @db_query\n    def exists(cls, db: Session, tmdbid: Optional[int] = None, doubanid: Optional[str] = None,\n               season: Optional[int] = None):\n        if tmdbid:\n            if season is not None:\n                return db.query(cls).filter(cls.tmdbid == tmdbid,\n                                            cls.season == season).first()\n            return db.query(cls).filter(cls.tmdbid == tmdbid).first()\n        elif doubanid:\n            return db.query(cls).filter(cls.doubanid == doubanid).first()\n        return None\n\n    @classmethod\n    @async_db_query\n    async def async_exists(cls, db: AsyncSession, tmdbid: Optional[int] = None, doubanid: Optional[str] = None,\n                           season: Optional[int] = None):\n        if tmdbid:\n            if season is not None:\n                result = await db.execute(\n                    select(cls).filter(cls.tmdbid == tmdbid, cls.season == season)\n                )\n            else:\n                result = await db.execute(\n                    select(cls).filter(cls.tmdbid == tmdbid)\n                )\n        elif doubanid:\n            result = await db.execute(\n                select(cls).filter(cls.doubanid == doubanid)\n            )\n        else:\n            return None\n        return result.scalars().first()\n"
  },
  {
    "path": "app/db/models/systemconfig.py",
    "content": "from sqlalchemy import Column, String, JSON, select\nfrom sqlalchemy.ext.asyncio import AsyncSession\nfrom sqlalchemy.orm import Session\n\nfrom app.db import db_query, db_update, Base, async_db_query, get_id_column\n\n\nclass SystemConfig(Base):\n    \"\"\"\n    配置表\n    \"\"\"\n    id = get_id_column()\n    # 主键\n    key = Column(String, index=True)\n    # 值\n    value = Column(JSON)\n\n    @classmethod\n    @db_query\n    def get_by_key(cls, db: Session, key: str):\n        return db.query(cls).filter(cls.key == key).first()\n\n    @classmethod\n    @async_db_query\n    async def async_get_by_key(cls, db: AsyncSession, key: str):\n        result = await db.execute(select(cls).where(cls.key == key))\n        return result.scalar_one_or_none()\n\n    @db_update\n    def delete_by_key(self, db: Session, key: str):\n        systemconfig = self.get_by_key(db, key)\n        if systemconfig:\n            systemconfig.delete(db, systemconfig.id)\n        return True\n"
  },
  {
    "path": "app/db/models/transferhistory.py",
    "content": "import time\nfrom typing import Optional\n\nfrom sqlalchemy import Column, Integer, String, Boolean, func, or_, JSON, select\nfrom sqlalchemy.ext.asyncio import AsyncSession\nfrom sqlalchemy.orm import Session\n\nfrom app.db import db_query, db_update, get_id_column, Base, async_db_query\n\n\nclass TransferHistory(Base):\n    \"\"\"\n    整理记录\n    \"\"\"\n    id = get_id_column()\n    # 源路径\n    src = Column(String, index=True)\n    # 源存储\n    src_storage = Column(String)\n    # 源文件项\n    src_fileitem = Column(JSON, default=dict)\n    # 目标路径\n    dest = Column(String)\n    # 目标存储\n    dest_storage = Column(String)\n    # 目标文件项\n    dest_fileitem = Column(JSON, default=dict)\n    # 转移模式 move/copy/link...\n    mode = Column(String)\n    # 类型 电影/电视剧\n    type = Column(String)\n    # 二级分类\n    category = Column(String)\n    # 标题\n    title = Column(String, index=True)\n    # 年份\n    year = Column(String)\n    tmdbid = Column(Integer, index=True)\n    imdbid = Column(String)\n    tvdbid = Column(Integer)\n    doubanid = Column(String)\n    # Sxx\n    seasons = Column(String)\n    # Exx\n    episodes = Column(String)\n    # 海报\n    image = Column(String)\n    # 下载器\n    downloader = Column(String)\n    # 下载器hash\n    download_hash = Column(String, index=True)\n    # 转移成功状态\n    status = Column(Boolean(), default=True)\n    # 转移失败信息\n    errmsg = Column(String)\n    # 时间\n    date = Column(String, index=True)\n    # 文件清单，以JSON存储\n    files = Column(JSON, default=list)\n    # 剧集组\n    episode_group = Column(String)\n\n    @classmethod\n    @db_query\n    def list_by_title(cls, db: Session, title: str, page: Optional[int] = 1, count: Optional[int] = 30,\n                      status: bool = None):\n        if status is not None:\n            query = db.query(cls).filter(\n                cls.status == status\n            ).order_by(\n                cls.date.desc()\n            )\n        else:\n            query = db.query(cls).filter(or_(\n                cls.title.like(f'%{title}%'),\n                cls.src.like(f'%{title}%'),\n                cls.dest.like(f'%{title}%'),\n            )).order_by(\n                cls.date.desc()\n            )\n        \n        # 当count为负数时，不限制页数查询所有\n        if count >= 0:\n            query = query.offset((page - 1) * count).limit(count)\n        \n        return query.all()\n\n    @classmethod\n    @async_db_query\n    async def async_list_by_title(cls, db: AsyncSession, title: str, page: Optional[int] = 1, count: Optional[int] = 30,\n                                  status: bool = None):\n        if status is not None:\n            query = select(cls).filter(\n                cls.status == status\n            ).order_by(\n                cls.date.desc()\n            )\n        else:\n            query = select(cls).filter(or_(\n                cls.title.like(f'%{title}%'),\n                cls.src.like(f'%{title}%'),\n                cls.dest.like(f'%{title}%'),\n            )).order_by(\n                cls.date.desc()\n            )\n        \n        # 当count为负数时，不限制页数查询所有\n        if count >= 0:\n            query = query.offset((page - 1) * count).limit(count)\n        \n        result = await db.execute(query)\n        return result.scalars().all()\n\n    @classmethod\n    @db_query\n    def list_by_page(cls, db: Session, page: Optional[int] = 1, count: Optional[int] = 30, status: bool = None):\n        if status is not None:\n            query = db.query(cls).filter(\n                cls.status == status\n            ).order_by(\n                cls.date.desc()\n            )\n        else:\n            query = db.query(cls).order_by(\n                cls.date.desc()\n            )\n        \n        # 当count为负数时，不限制页数查询所有\n        if count >= 0:\n            query = query.offset((page - 1) * count).limit(count)\n        \n        return query.all()\n\n    @classmethod\n    @async_db_query\n    async def async_list_by_page(cls, db: AsyncSession, page: Optional[int] = 1, count: Optional[int] = 30,\n                                 status: bool = None):\n        if status is not None:\n            query = select(cls).filter(\n                cls.status == status\n            ).order_by(\n                cls.date.desc()\n            )\n        else:\n            query = select(cls).order_by(\n                cls.date.desc()\n            )\n        \n        # 当count为负数时，不限制页数查询所有\n        if count >= 0:\n            query = query.offset((page - 1) * count).limit(count)\n        \n        result = await db.execute(query)\n        return result.scalars().all()\n\n    @classmethod\n    @db_query\n    def get_by_hash(cls, db: Session, download_hash: str):\n        return db.query(cls).filter(cls.download_hash == download_hash).first()\n\n    @classmethod\n    @db_query\n    def get_by_src(cls, db: Session, src: str, storage: Optional[str] = None):\n        if storage:\n            return db.query(cls).filter(cls.src == src,\n                                        cls.src_storage == storage).first()\n        else:\n            return db.query(cls).filter(cls.src == src).first()\n\n    @classmethod\n    @db_query\n    def get_by_dest(cls, db: Session, dest: str):\n        return db.query(cls).filter(cls.dest == dest).first()\n\n    @classmethod\n    @db_query\n    def list_by_hash(cls, db: Session, download_hash: str):\n        return db.query(cls).filter(cls.download_hash == download_hash).all()\n\n    @classmethod\n    @db_query\n    def statistic(cls, db: Session, days: Optional[int] = 7):\n        \"\"\"\n        统计最近days天的下载历史数量，按日期分组返回每日数量\n        \"\"\"\n        sub_query = db.query(func.substr(cls.date, 1, 10).label('date'),\n                             cls.id.label('id')).filter(\n            cls.date >= time.strftime(\"%Y-%m-%d %H:%M:%S\",\n                                      time.localtime(time.time() - 86400 * days))).subquery()\n        return db.query(sub_query.c.date, func.count(sub_query.c.id)).group_by(sub_query.c.date).all()\n\n    @classmethod\n    @async_db_query\n    async def async_statistic(cls, db: AsyncSession, days: Optional[int] = 7):\n        \"\"\"\n        统计最近days天的下载历史数量，按日期分组返回每日数量\n        \"\"\"\n        sub_query = select(func.substr(cls.date, 1, 10).label('date'),\n                           cls.id.label('id')).filter(\n            cls.date >= time.strftime(\"%Y-%m-%d %H:%M:%S\",\n                                      time.localtime(time.time() - 86400 * days))).subquery()\n        result = await db.execute(\n            select(sub_query.c.date, func.count(sub_query.c.id)).group_by(sub_query.c.date)\n        )\n        return result.all()\n\n    @classmethod\n    @db_query\n    def count(cls, db: Session, status: bool = None):\n        if status is not None:\n            return db.query(func.count(cls.id)).filter(cls.status == status).first()[0]\n        else:\n            return db.query(func.count(cls.id)).first()[0]\n\n    @classmethod\n    @async_db_query\n    async def async_count(cls, db: AsyncSession, status: bool = None):\n        if status is not None:\n            result = await db.execute(\n                select(func.count(cls.id)).filter(cls.status == status)\n            )\n        else:\n            result = await db.execute(\n                select(func.count(cls.id))\n            )\n        return result.scalar()\n\n    @classmethod\n    @db_query\n    def count_by_title(cls, db: Session, title: str, status: bool = None):\n        if status is not None:\n            return db.query(func.count(cls.id)).filter(cls.status == status).first()[0]\n        else:\n            return db.query(func.count(cls.id)).filter(or_(\n                cls.title.like(f'%{title}%'),\n                cls.src.like(f'%{title}%'),\n                cls.dest.like(f'%{title}%')\n            )).first()[0]\n\n    @classmethod\n    @async_db_query\n    async def async_count_by_title(cls, db: AsyncSession, title: str, status: bool = None):\n        if status is not None:\n            result = await db.execute(\n                select(func.count(cls.id)).filter(cls.status == status)\n            )\n        else:\n            result = await db.execute(\n                select(func.count(cls.id)).filter(or_(\n                    cls.title.like(f'%{title}%'),\n                    cls.src.like(f'%{title}%'),\n                    cls.dest.like(f'%{title}%')\n                ))\n            )\n        return result.scalar()\n\n    @classmethod\n    @db_query\n    def list_by(cls, db: Session, mtype: Optional[str] = None, title: Optional[str] = None, year: Optional[str] = None,\n                season: Optional[str] = None,\n                episode: Optional[str] = None, tmdbid: Optional[int] = None, dest: Optional[str] = None):\n        \"\"\"\n        据tmdbid、season、season_episode查询转移记录\n        tmdbid + mtype 或 title + year 必输\n        \"\"\"\n        # TMDBID + 类型\n        if tmdbid and mtype:\n            # 电视剧某季某集\n            if season is not None and episode:\n                return db.query(cls).filter(cls.tmdbid == tmdbid,\n                                            cls.type == mtype,\n                                            cls.seasons == season,\n                                            cls.episodes == episode,\n                                            cls.dest == dest).all()\n            # 电视剧某季\n            elif season is not None:\n                return db.query(cls).filter(cls.tmdbid == tmdbid,\n                                            cls.type == mtype,\n                                            cls.seasons == season).all()\n            else:\n                if dest:\n                    # 电影\n                    return db.query(cls).filter(cls.tmdbid == tmdbid,\n                                                cls.type == mtype,\n                                                cls.dest == dest).all()\n                else:\n                    # 电视剧所有季集\n                    return db.query(cls).filter(cls.tmdbid == tmdbid,\n                                                cls.type == mtype).all()\n        # 标题 + 年份\n        elif title and year:\n            # 电视剧某季某集\n            if season is not None and episode:\n                return db.query(cls).filter(cls.title == title,\n                                            cls.year == year,\n                                            cls.seasons == season,\n                                            cls.episodes == episode,\n                                            cls.dest == dest).all()\n            # 电视剧某季\n            elif season is not None:\n                return db.query(cls).filter(cls.title == title,\n                                            cls.year == year,\n                                            cls.seasons == season).all()\n            else:\n                if dest:\n                    # 电影\n                    return db.query(cls).filter(cls.title == title,\n                                                cls.year == year,\n                                                cls.dest == dest).all()\n                else:\n                    # 电视剧所有季集\n                    return db.query(cls).filter(cls.title == title,\n                                                cls.year == year).all()\n        # 类型 + 转移路径（emby webhook season无tmdbid场景）\n        elif mtype and season is not None and dest:\n            # 电视剧某季\n            return db.query(cls).filter(cls.type == mtype,\n                                        cls.seasons == season,\n                                        cls.dest.like(f\"{dest}%\")).all()\n        return []\n\n    @classmethod\n    @db_query\n    def get_by_type_tmdbid(cls, db: Session, mtype: Optional[str] = None, tmdbid: Optional[int] = None):\n        \"\"\"\n        据tmdbid、type查询转移记录\n        \"\"\"\n        return db.query(cls).filter(cls.tmdbid == tmdbid,\n                                    cls.type == mtype).first()\n\n    @classmethod\n    @db_update\n    def update_download_hash(cls, db: Session, historyid: Optional[int] = None, download_hash: Optional[str] = None):\n        db.query(cls).filter(cls.id == historyid).update(\n            {\n                \"download_hash\": download_hash\n            }\n        )\n\n    @classmethod\n    @db_query\n    def list_by_date(cls, db: Session, date: str):\n        \"\"\"\n        查询某时间之后的转移历史\n        \"\"\"\n        return db.query(cls).filter(cls.date > date).order_by(cls.id.desc()).all()\n"
  },
  {
    "path": "app/db/models/user.py",
    "content": "from sqlalchemy import Boolean, Column, JSON, String, select\nfrom sqlalchemy.ext.asyncio import AsyncSession\nfrom sqlalchemy.orm import Session\n\nfrom app.db import Base, db_query, db_update, async_db_query, async_db_update, get_id_column\n\n\nclass User(Base):\n    \"\"\"\n    用户表\n    \"\"\"\n    # ID\n    id = get_id_column()\n    # 用户名，唯一值\n    name = Column(String, index=True, nullable=False)\n    # 邮箱\n    email = Column(String)\n    # 加密后密码\n    hashed_password = Column(String)\n    # 是否启用\n    is_active = Column(Boolean(), default=True)\n    # 是否管理员\n    is_superuser = Column(Boolean(), default=False)\n    # 头像\n    avatar = Column(String)\n    # 是否启用otp二次验证\n    is_otp = Column(Boolean(), default=False)\n    # otp秘钥\n    otp_secret = Column(String, default=None)\n    # 用户权限 json\n    permissions = Column(JSON, default=dict)\n    # 用户个性化设置 json\n    settings = Column(JSON, default=dict)\n\n    @classmethod\n    @db_query\n    def get_by_name(cls, db: Session, name: str):\n        return db.query(cls).filter(cls.name == name).first()\n\n    @classmethod\n    @async_db_query\n    async def async_get_by_name(cls, db: AsyncSession, name: str):\n        result = await db.execute(\n            select(cls).filter(cls.name == name)\n        )\n        return result.scalars().first()\n\n    @classmethod\n    @db_query\n    def get_by_id(cls, db: Session, user_id: int):\n        return db.query(cls).filter(cls.id == user_id).first()\n\n    @classmethod\n    @async_db_query\n    async def async_get_by_id(cls, db: AsyncSession, user_id: int):\n        result = await db.execute(\n            select(cls).filter(cls.id == user_id)\n        )\n        return result.scalars().first()\n\n    @db_update\n    def delete_by_name(self, db: Session, name: str):\n        user = self.get_by_name(db, name)\n        if user:\n            user.delete(db, user.id)\n        return True\n\n    @async_db_update\n    async def async_delete_by_name(self, db: AsyncSession, name: str):\n        user = await self.async_get_by_name(db, name)\n        if user:\n            await user.async_delete(db, user.id)\n        return True\n\n    @db_update\n    def delete_by_id(self, db: Session, user_id: int):\n        user = self.get_by_id(db, user_id)\n        if user:\n            user.delete(db, user.id)\n        return True\n\n    @async_db_update\n    async def async_delete_by_id(self, db: AsyncSession, user_id: int):\n        user = await self.async_get_by_id(db, user_id)\n        if user:\n            await user.async_delete(db, user.id)\n        return True\n\n    @db_update\n    def update_otp_by_name(self, db: Session, name: str, otp: bool, secret: str):\n        user = self.get_by_name(db, name)\n        if user:\n            user.update(db, {\n                'is_otp': otp,\n                'otp_secret': secret\n            })\n            return True\n        return False\n\n    @async_db_update\n    async def async_update_otp_by_name(self, db: AsyncSession, name: str, otp: bool, secret: str):\n        user = await self.async_get_by_name(db, name)\n        if user:\n            await user.async_update(db, {\n                'is_otp': otp,\n                'otp_secret': secret\n            })\n            return True\n        return False\n"
  },
  {
    "path": "app/db/models/userconfig.py",
    "content": "from sqlalchemy import Column, String, UniqueConstraint, Index, JSON\nfrom sqlalchemy.orm import Session\n\nfrom app.db import db_query, db_update, get_id_column, Base\n\n\nclass UserConfig(Base):\n    \"\"\"\n    用户配置表\n    \"\"\"\n    id = get_id_column()\n    # 用户名\n    username = Column(String, index=True)\n    # 配置键\n    key = Column(String)\n    # 值\n    value = Column(JSON)\n\n    __table_args__ = (\n        # 用户名和配置键联合唯一\n        UniqueConstraint('username', 'key'),\n        Index('ix_userconfig_username_key', 'username', 'key'),\n    )\n\n    @classmethod\n    @db_query\n    def get_by_key(cls, db: Session, username: str, key: str):\n        return db.query(cls) \\\n                 .filter(cls.username == username) \\\n                 .filter(cls.key == key) \\\n                 .first()\n\n    @db_update\n    def delete_by_key(self, db: Session, username: str, key: str):\n        userconfig = self.get_by_key(db=db, username=username, key=key)\n        if userconfig:\n            userconfig.delete(db=db, rid=userconfig.id)\n        return True\n"
  },
  {
    "path": "app/db/models/workflow.py",
    "content": "from datetime import datetime\nfrom typing import Optional\n\nfrom sqlalchemy import Column, Integer, JSON, String, and_, or_, select\nfrom sqlalchemy.ext.asyncio import AsyncSession\n\nfrom app.db import Base, db_query, get_id_column, db_update, async_db_query, async_db_update\n\n\nclass Workflow(Base):\n    \"\"\"\n    工作流表\n    \"\"\"\n    # ID\n    id = get_id_column()\n    # 名称\n    name = Column(String, index=True, nullable=False)\n    # 描述\n    description = Column(String)\n    # 定时器\n    timer = Column(String)\n    # 触发类型：timer-定时触发 event-事件触发 manual-手动触发\n    trigger_type = Column(String, default='timer')\n    # 事件类型（当trigger_type为event时使用）\n    event_type = Column(String)\n    # 事件条件（JSON格式，用于过滤事件）\n    event_conditions = Column(JSON, default=dict)\n    # 状态：W-等待 R-运行中 P-暂停 S-成功 F-失败\n    state = Column(String, nullable=False, index=True, default='W')\n    # 已执行动作（,分隔）\n    current_action = Column(String)\n    # 任务执行结果\n    result = Column(String)\n    # 已执行次数\n    run_count = Column(Integer, default=0)\n    # 任务列表\n    actions = Column(JSON, default=list)\n    # 任务流\n    flows = Column(JSON, default=list)\n    # 执行上下文\n    context = Column(JSON, default=dict)\n    # 创建时间\n    add_time = Column(String, default=datetime.now().strftime('%Y-%m-%d %H:%M:%S'))\n    # 最后执行时间\n    last_time = Column(String)\n\n    @classmethod\n    @db_query\n    def list(cls, db):\n        return db.query(cls).all()\n\n    @classmethod\n    @async_db_query\n    async def async_list(cls, db: AsyncSession):\n        result = await db.execute(select(cls))\n        return result.scalars().all()\n\n    @classmethod\n    @db_query\n    def get_enabled_workflows(cls, db):\n        return db.query(cls).filter(cls.state != 'P').all()\n\n    @classmethod\n    @async_db_query\n    async def async_get_enabled_workflows(cls, db: AsyncSession):\n        result = await db.execute(select(cls).where(cls.state != 'P'))\n        return result.scalars().all()\n\n    @classmethod\n    @db_query\n    def get_timer_triggered_workflows(cls, db):\n        \"\"\"获取定时触发的工作流\"\"\"\n        return db.query(cls).filter(\n            and_(\n                or_(\n                    cls.trigger_type == 'timer',\n                    not cls.trigger_type\n                ),\n                cls.state != 'P'\n            )\n        ).all()\n\n    @classmethod\n    @async_db_query\n    async def async_get_timer_triggered_workflows(cls, db: AsyncSession):\n        \"\"\"异步获取定时触发的工作流\"\"\"\n        result = await db.execute(select(cls).where(\n            and_(\n                or_(\n                    cls.trigger_type == 'timer',\n                    not cls.trigger_type\n                ),\n                cls.state != 'P'\n            )\n        ))\n        return result.scalars().all()\n\n    @classmethod\n    @db_query\n    def get_event_triggered_workflows(cls, db):\n        \"\"\"获取事件触发的工作流\"\"\"\n        return db.query(cls).filter(\n            and_(\n                cls.trigger_type == 'event',\n                cls.state != 'P'\n            )\n        ).all()\n\n    @classmethod\n    @async_db_query\n    async def async_get_event_triggered_workflows(cls, db: AsyncSession):\n        \"\"\"异步获取事件触发的工作流\"\"\"\n        result = await db.execute(select(cls).where(\n            and_(\n                cls.trigger_type == 'event',\n                cls.state != 'P'\n            )\n        ))\n        return result.scalars().all()\n\n    @classmethod\n    @db_query\n    def get_by_name(cls, db, name: str):\n        return db.query(cls).filter(cls.name == name).first()\n\n    @classmethod\n    @async_db_query\n    async def async_get_by_name(cls, db: AsyncSession, name: str):\n        result = await db.execute(select(cls).where(cls.name == name))\n        return result.scalars().first()\n\n    @classmethod\n    @db_update\n    def update_state(cls, db, wid: int, state: str):\n        db.query(cls).filter(cls.id == wid).update({\"state\": state})\n        return True\n\n    @classmethod\n    @async_db_update\n    async def async_update_state(cls, db: AsyncSession, wid: int, state: str):\n        from sqlalchemy import update\n        await db.execute(update(cls).where(cls.id == wid).values(state=state))\n        return True\n\n    @classmethod\n    @db_update\n    def start(cls, db, wid: int):\n        db.query(cls).filter(cls.id == wid).update({\n            \"state\": 'R'\n        })\n        return True\n\n    @classmethod\n    @async_db_update\n    async def async_start(cls, db: AsyncSession, wid: int):\n        from sqlalchemy import update\n        await db.execute(update(cls).where(cls.id == wid).values(state='R'))\n        return True\n\n    @classmethod\n    @db_update\n    def fail(cls, db, wid: int, result: str):\n        db.query(cls).filter(and_(cls.id == wid, cls.state != \"P\")).update({\n            \"state\": 'F',\n            \"result\": result,\n            \"last_time\": datetime.now().strftime('%Y-%m-%d %H:%M:%S')\n        })\n        return True\n\n    @classmethod\n    @async_db_update\n    async def async_fail(cls, db: AsyncSession, wid: int, result: str):\n        from sqlalchemy import update\n        await db.execute(update(cls).where(\n            and_(cls.id == wid, cls.state != \"P\")\n        ).values(\n            state='F',\n            result=result,\n            last_time=datetime.now().strftime('%Y-%m-%d %H:%M:%S')\n        ))\n        return True\n\n    @classmethod\n    @db_update\n    def success(cls, db, wid: int, result: Optional[str] = None):\n        db.query(cls).filter(and_(cls.id == wid, cls.state != \"P\")).update({\n            \"state\": 'S',\n            \"result\": result,\n            \"run_count\": cls.run_count + 1,\n            \"last_time\": datetime.now().strftime('%Y-%m-%d %H:%M:%S')\n        })\n        return True\n\n    @classmethod\n    @async_db_update\n    async def async_success(cls, db: AsyncSession, wid: int, result: Optional[str] = None):\n        from sqlalchemy import update\n        await db.execute(update(cls).where(\n            and_(cls.id == wid, cls.state != \"P\")\n        ).values(\n            state='S',\n            result=result,\n            run_count=cls.run_count + 1,\n            last_time=datetime.now().strftime('%Y-%m-%d %H:%M:%S')\n        ))\n        return True\n\n    @classmethod\n    @db_update\n    def reset(cls, db, wid: int, reset_count: Optional[bool] = False):\n        db.query(cls).filter(cls.id == wid).update({\n            \"state\": 'W',\n            \"result\": None,\n            \"current_action\": None,\n            \"run_count\": 0 if reset_count else cls.run_count,\n        })\n        return True\n\n    @classmethod\n    @async_db_update\n    async def async_reset(cls, db: AsyncSession, wid: int, reset_count: Optional[bool] = False):\n        from sqlalchemy import update\n        await db.execute(update(cls).where(cls.id == wid).values(\n            state='W',\n            result=None,\n            current_action=None,\n            run_count=0 if reset_count else cls.run_count,\n        ))\n        return True\n\n    @classmethod\n    @db_update\n    def update_current_action(cls, db, wid: int, action_id: str, context: dict):\n        db.query(cls).filter(cls.id == wid).update({\n            \"current_action\": cls.current_action + f\",{action_id}\" if cls.current_action else action_id,\n            \"context\": context\n        })\n        return True\n\n    @classmethod\n    @async_db_update\n    async def async_update_current_action(cls, db: AsyncSession, wid: int, action_id: str, context: dict):\n        from sqlalchemy import update\n        # 先获取当前current_action\n        result = await db.execute(select(cls.current_action).where(cls.id == wid))\n        current_action = result.scalar()\n        new_current_action = current_action + f\",{action_id}\" if current_action else action_id\n\n        await db.execute(update(cls).where(cls.id == wid).values(\n            current_action=new_current_action,\n            context=context\n        ))\n        return True\n"
  },
  {
    "path": "app/db/plugindata_oper.py",
    "content": "from typing import Any, Optional\n\nfrom app.db import DbOper\nfrom app.db.models.plugindata import PluginData\n\n\nclass PluginDataOper(DbOper):\n    \"\"\"\n    插件数据管理\n    \"\"\"\n\n    def save(self, plugin_id: str, key: str, value: Any):\n        \"\"\"\n        保存插件数据\n        :param plugin_id: 插件id\n        :param key: 数据key\n        :param value: 数据值\n        \"\"\"\n        plugin = PluginData.get_plugin_data_by_key(self._db, plugin_id, key)\n        if plugin:\n            plugin.update(self._db, {\n                \"value\": value\n            })\n        else:\n            PluginData(plugin_id=plugin_id, key=key, value=value).create(self._db)\n\n    def get_data(self, plugin_id: str, key: Optional[str] = None) -> Any:\n        \"\"\"\n        获取插件数据\n        :param plugin_id: 插件id\n        :param key: 数据key\n        \"\"\"\n        if key:\n            data = PluginData.get_plugin_data_by_key(self._db, plugin_id, key)\n            if not data:\n                return None\n            return data.value\n        else:\n            return PluginData.get_plugin_data(self._db, plugin_id)\n\n    def del_data(self, plugin_id: str, key: Optional[str] = None) -> Any:\n        \"\"\"\n        删除插件数据\n        :param plugin_id: 插件id\n        :param key: 数据key\n        \"\"\"\n        if key:\n            PluginData.del_plugin_data_by_key(self._db, plugin_id, key)\n        else:\n            PluginData.del_plugin_data(self._db, plugin_id)\n\n    def truncate(self):\n        \"\"\"\n        清空插件数据\n        \"\"\"\n        PluginData.truncate(self._db)\n\n    def get_data_all(self, plugin_id: str) -> Any:\n        \"\"\"\n        获取插件所有数据\n        :param plugin_id: 插件id\n        \"\"\"\n        return PluginData.get_plugin_data_by_plugin_id(self._db, plugin_id)\n"
  },
  {
    "path": "app/db/site_oper.py",
    "content": "from datetime import datetime\nfrom typing import List, Tuple, Optional\n\nfrom app.db import DbOper\nfrom app.db.models import SiteIcon\nfrom app.db.models.site import Site\nfrom app.db.models.sitestatistic import SiteStatistic\nfrom app.db.models.siteuserdata import SiteUserData\n\n\nclass SiteOper(DbOper):\n    \"\"\"\n    站点管理\n    \"\"\"\n\n    def add(self, **kwargs) -> Tuple[bool, str]:\n        \"\"\"\n        新增站点\n        \"\"\"\n        site = Site(**kwargs)\n        if not site.get_by_domain(self._db, kwargs.get(\"domain\")):\n            site.create(self._db)\n            return True, \"新增站点成功\"\n        return False, \"站点已存在\"\n\n    def get(self, sid: int) -> Site:\n        \"\"\"\n        查询单个站点\n        \"\"\"\n        return Site.get(self._db, sid)\n\n    async def async_get(self, sid: int) -> Site:\n        \"\"\"\n        异步查询单个站点\n        \"\"\"\n        return await Site.async_get(self._db, sid)\n\n    def list(self) -> List[Site]:\n        \"\"\"\n        获取站点列表\n        \"\"\"\n        return Site.list(self._db)\n\n    async def async_list(self) -> List[Site]:\n        \"\"\"\n        异步获取站点列表\n        \"\"\"\n        return await Site.async_list(self._db)\n\n    def list_order_by_pri(self) -> List[Site]:\n        \"\"\"\n        获取站点列表\n        \"\"\"\n        return Site.list_order_by_pri(self._db)\n\n    def list_active(self) -> List[Site]:\n        \"\"\"\n        按状态获取站点列表\n        \"\"\"\n        return Site.get_actives(self._db)\n\n    async def async_list_active(self) -> List[Site]:\n        \"\"\"\n        异步按状态获取站点列表\n        \"\"\"\n        return await Site.async_get_actives(self._db)\n\n    def delete(self, sid: int):\n        \"\"\"\n        删除站点\n        \"\"\"\n        Site.delete(self._db, sid)\n\n    def update(self, sid: int, payload: dict) -> Site:\n        \"\"\"\n        更新站点\n        \"\"\"\n        site = Site.get(self._db, sid)\n        site.update(self._db, payload)\n        return site\n\n    def get_by_domain(self, domain: str) -> Site:\n        \"\"\"\n        按域名获取站点\n        \"\"\"\n        return Site.get_by_domain(self._db, domain)\n\n    async def async_get_by_domain(self, domain: str) -> Site:\n        \"\"\"\n        异步按域名获取站点\n        \"\"\"\n        return await Site.async_get_by_domain(self._db, domain)\n\n    async def async_get_by_name(self, name: str) -> Site:\n        \"\"\"\n        异步按名称获取站点\n        \"\"\"\n        return await Site.async_get_by_name(self._db, name)\n\n    def get_domains_by_ids(self, ids: List[int]) -> List[str]:\n        \"\"\"\n        按ID获取站点域名\n        \"\"\"\n        return Site.get_domains_by_ids(self._db, ids)\n\n    def exists(self, domain: str) -> bool:\n        \"\"\"\n        判断站点是否存在\n        \"\"\"\n        return Site.get_by_domain(self._db, domain) is not None\n\n    def update_cookie(self, domain: str, cookies: str) -> Tuple[bool, str]:\n        \"\"\"\n        更新站点Cookie\n        \"\"\"\n        site = Site.get_by_domain(self._db, domain)\n        if not site:\n            return False, \"站点不存在\"\n        site.update(self._db, {\n            \"cookie\": cookies\n        })\n        return True, \"更新站点Cookie成功\"\n\n    def update_rss(self, domain: str, rss: str) -> Tuple[bool, str]:\n        \"\"\"\n        更新站点rss\n        \"\"\"\n        site = Site.get_by_domain(self._db, domain)\n        if not site:\n            return False, \"站点不存在\"\n        site.update(self._db, {\n            \"rss\": rss\n        })\n        return True, \"更新站点RSS地址成功\"\n\n    def update_userdata(self, domain: str, name: str, payload: dict) -> Tuple[bool, str]:\n        \"\"\"\n        更新站点用户数据\n        \"\"\"\n        # 当前系统日期\n        current_day = datetime.now().strftime('%Y-%m-%d')\n        current_time = datetime.now().strftime('%H:%M:%S')\n        payload.update({\n            \"domain\": domain,\n            \"name\": name,\n            \"updated_day\": current_day,\n            \"updated_time\": current_time,\n            \"err_msg\": payload.get(\"err_msg\") or \"\"\n        })\n        # 按站点+天判断是否存在数据\n        siteuserdatas = SiteUserData.get_by_domain(self._db, domain=domain, workdate=current_day)\n        if siteuserdatas:\n            # 存在则更新\n            if not payload.get(\"err_msg\"):\n                siteuserdatas[0].update(self._db, payload)\n        else:\n            # 不存在则插入\n            SiteUserData(**payload).create(self._db)\n        return True, \"更新站点用户数据成功\"\n\n    def get_userdata(self) -> List[SiteUserData]:\n        \"\"\"\n        获取站点用户数据\n        \"\"\"\n        return SiteUserData.list(self._db)\n\n    def get_userdata_by_domain(self, domain: str, workdate: Optional[str] = None) -> List[SiteUserData]:\n        \"\"\"\n        获取站点用户数据\n        \"\"\"\n        return SiteUserData.get_by_domain(self._db, domain=domain, workdate=workdate)\n\n    def get_userdata_by_date(self, date: str) -> List[SiteUserData]:\n        \"\"\"\n        获取站点用户数据\n        \"\"\"\n        return SiteUserData.get_by_date(self._db, date)\n\n    def get_userdata_latest(self) -> List[SiteUserData]:\n        \"\"\"\n        获取站点最新数据\n        \"\"\"\n        return SiteUserData.get_latest(self._db)\n\n    def get_icon_by_domain(self, domain: str) -> SiteIcon:\n        \"\"\"\n        按域名获取站点图标\n        \"\"\"\n        return SiteIcon.get_by_domain(self._db, domain)\n\n    def update_icon(self, name: str, domain: str, icon_url: str, icon_base64: str) -> bool:\n        \"\"\"\n        更新站点图标\n        \"\"\"\n        icon_base64 = f\"data:image/ico;base64,{icon_base64}\" if icon_base64 else \"\"\n        siteicon = self.get_icon_by_domain(domain)\n        if not siteicon:\n            SiteIcon(name=name, domain=domain, url=icon_url, base64=icon_base64).create(self._db)\n        elif icon_base64:\n            siteicon.update(self._db, {\n                \"url\": icon_url,\n                \"base64\": icon_base64\n            })\n        return True\n\n    def success(self, domain: str, seconds: Optional[int] = None):\n        \"\"\"\n        站点访问成功\n        \"\"\"\n        lst_date = datetime.now().strftime(\"%Y-%m-%d %H:%M:%S\")\n        sta = SiteStatistic.get_by_domain(self._db, domain)\n        if sta:\n            # 使用深复制确保 note 是全新的字典对象\n            note = dict(sta.note) if sta.note else {}\n            avg_seconds = None\n\n            if seconds is not None:\n                note[lst_date] = seconds or 1\n                avg_times = len(note.keys())\n                if avg_times > 10:\n                    note = dict(sorted(note.items(), key=lambda x: x[0], reverse=True)[:10])\n                avg_seconds = sum([v for v in note.values()]) // avg_times\n\n            sta.update(self._db, {\n                \"success\": sta.success + 1,\n                \"seconds\": avg_seconds or sta.seconds,\n                \"lst_state\": 0,\n                \"lst_mod_date\": lst_date,\n                \"note\": note\n            })\n        else:\n            note = {}\n            if seconds is not None:\n                note = {\n                    lst_date: seconds or 1\n                }\n            SiteStatistic(\n                domain=domain,\n                success=1,\n                fail=0,\n                seconds=seconds or 1,\n                lst_state=0,\n                lst_mod_date=lst_date,\n                note=note\n            ).create(self._db)\n\n    def fail(self, domain: str):\n        \"\"\"\n        站点访问失败\n        \"\"\"\n        lst_date = datetime.now().strftime(\"%Y-%m-%d %H:%M:%S\")\n        sta = SiteStatistic.get_by_domain(self._db, domain)\n        if sta:\n            sta.update(self._db, {\n                \"fail\": sta.fail + 1,\n                \"lst_state\": 1,\n                \"lst_mod_date\": lst_date\n            })\n        else:\n            SiteStatistic(\n                domain=domain,\n                success=0,\n                fail=1,\n                lst_state=1,\n                lst_mod_date=lst_date\n            ).create(self._db)\n\n    async def async_success(self, domain: str, seconds: Optional[int] = None):\n        \"\"\"\n        异步站点访问成功\n        \"\"\"\n        lst_date = datetime.now().strftime(\"%Y-%m-%d %H:%M:%S\")\n        sta = await SiteStatistic.async_get_by_domain(self._db, domain)\n        if sta:\n            # 使用深复制确保 note 是全新的字典对象\n            note = dict(sta.note) if sta.note else {}\n            avg_seconds = None\n\n            if seconds is not None:\n                note[lst_date] = seconds or 1\n                avg_times = len(note.keys())\n                if avg_times > 10:\n                    note = dict(sorted(note.items(), key=lambda x: x[0], reverse=True)[:10])\n                avg_seconds = sum([v for v in note.values()]) // avg_times\n\n            await sta.async_update(self._db, {\n                \"success\": sta.success + 1,\n                \"seconds\": avg_seconds or sta.seconds,\n                \"lst_state\": 0,\n                \"lst_mod_date\": lst_date,\n                \"note\": note\n            })\n        else:\n            note = {}\n            if seconds is not None:\n                note = {\n                    lst_date: seconds or 1\n                }\n            await SiteStatistic(\n                domain=domain,\n                success=1,\n                fail=0,\n                seconds=seconds or 1,\n                lst_state=0,\n                lst_mod_date=lst_date,\n                note=note\n            ).async_create(self._db)\n\n    async def async_fail(self, domain: str):\n        \"\"\"\n        异步站点访问失败\n        \"\"\"\n        lst_date = datetime.now().strftime(\"%Y-%m-%d %H:%M:%S\")\n        sta = await SiteStatistic.async_get_by_domain(self._db, domain)\n        if sta:\n            await sta.async_update(self._db, {\n                \"fail\": sta.fail + 1,\n                \"lst_state\": 1,\n                \"lst_mod_date\": lst_date\n            })\n        else:\n            await SiteStatistic(\n                domain=domain,\n                success=0,\n                fail=1,\n                lst_state=1,\n                lst_mod_date=lst_date\n            ).async_create(self._db)\n"
  },
  {
    "path": "app/db/subscribe_oper.py",
    "content": "import time\nfrom typing import Tuple, List, Optional\n\nfrom app.core.context import MediaInfo\nfrom app.db import DbOper\nfrom app.db.models.subscribe import Subscribe\nfrom app.db.models.subscribehistory import SubscribeHistory\n\n\nclass SubscribeOper(DbOper):\n    \"\"\"\n    订阅管理\n    \"\"\"\n\n    def add(self, mediainfo: MediaInfo, **kwargs) -> Tuple[int, str]:\n        \"\"\"\n        新增订阅\n        \"\"\"\n        subscribe = Subscribe.exists(self._db,\n                                     tmdbid=mediainfo.tmdb_id,\n                                     doubanid=mediainfo.douban_id,\n                                     season=kwargs.get('season'))\n        kwargs.update({\n            \"name\": mediainfo.title,\n            \"year\": mediainfo.year,\n            \"type\": mediainfo.type.value,\n            \"tmdbid\": mediainfo.tmdb_id,\n            \"imdbid\": mediainfo.imdb_id,\n            \"tvdbid\": mediainfo.tvdb_id,\n            \"doubanid\": mediainfo.douban_id,\n            \"bangumiid\": mediainfo.bangumi_id,\n            \"episode_group\": mediainfo.episode_group,\n            \"poster\": mediainfo.get_poster_image(),\n            \"backdrop\": mediainfo.get_backdrop_image(),\n            \"vote\": mediainfo.vote_average,\n            \"description\": mediainfo.overview,\n            \"search_imdbid\": 1 if kwargs.get('search_imdbid') else 0,\n            \"date\": time.strftime(\"%Y-%m-%d %H:%M:%S\", time.localtime())\n        })\n        if not subscribe:\n            subscribe = Subscribe(**kwargs)\n            subscribe.create(self._db)\n            # 查询订阅\n            subscribe = Subscribe.exists(self._db,\n                                         tmdbid=mediainfo.tmdb_id,\n                                         doubanid=mediainfo.douban_id,\n                                         season=kwargs.get('season'))\n            return subscribe.id, \"新增订阅成功\"\n        else:\n            return subscribe.id, \"订阅已存在\"\n\n    async def async_add(self, mediainfo: MediaInfo, **kwargs) -> Tuple[int, str]:\n        \"\"\"\n        异步新增订阅\n        \"\"\"\n        subscribe = await Subscribe.async_exists(self._db,\n                                                 tmdbid=mediainfo.tmdb_id,\n                                                 doubanid=mediainfo.douban_id,\n                                                 season=kwargs.get('season'))\n        kwargs.update({\n            \"name\": mediainfo.title,\n            \"year\": mediainfo.year,\n            \"type\": mediainfo.type.value,\n            \"tmdbid\": mediainfo.tmdb_id,\n            \"imdbid\": mediainfo.imdb_id,\n            \"tvdbid\": mediainfo.tvdb_id,\n            \"doubanid\": mediainfo.douban_id,\n            \"bangumiid\": mediainfo.bangumi_id,\n            \"episode_group\": mediainfo.episode_group,\n            \"poster\": mediainfo.get_poster_image(),\n            \"backdrop\": mediainfo.get_backdrop_image(),\n            \"vote\": mediainfo.vote_average,\n            \"description\": mediainfo.overview,\n            \"search_imdbid\": 1 if kwargs.get('search_imdbid') else 0,\n            \"date\": time.strftime(\"%Y-%m-%d %H:%M:%S\", time.localtime())\n        })\n        if not subscribe:\n            subscribe = Subscribe(**kwargs)\n            await subscribe.async_create(self._db)\n            # 查询订阅\n            subscribe = await Subscribe.async_exists(self._db,\n                                                     tmdbid=mediainfo.tmdb_id,\n                                                     doubanid=mediainfo.douban_id,\n                                                     season=kwargs.get('season'))\n            return subscribe.id, \"新增订阅成功\"\n        else:\n            return subscribe.id, \"订阅已存在\"\n\n    def exists(self, tmdbid: Optional[int] = None, doubanid: Optional[str] = None,\n               season: Optional[int] = None) -> bool:\n        \"\"\"\n        判断是否存在\n        \"\"\"\n        if tmdbid:\n            if season is not None:\n                return True if Subscribe.exists(self._db, tmdbid=tmdbid, season=season) else False\n            else:\n                return True if Subscribe.exists(self._db, tmdbid=tmdbid) else False\n        elif doubanid:\n            return True if Subscribe.exists(self._db, doubanid=doubanid) else False\n        return False\n\n    def get(self, sid: int) -> Subscribe:\n        \"\"\"\n        获取订阅\n        \"\"\"\n        return Subscribe.get(self._db, rid=sid)\n\n    async def async_get(self, sid: int) -> Subscribe:\n        \"\"\"\n        获取订阅\n        \"\"\"\n        return await Subscribe.async_get(self._db, rid=sid)\n\n    def get_by(self, type: str, season: Optional[str] = None, tmdbid: Optional[int] = None,\n               doubanid: Optional[str] = None, bangumiid: Optional[str] = None) -> Optional[Subscribe]:\n        \"\"\"\n        根据条件查询订阅\n        \"\"\"\n        return Subscribe.get_by(self._db, type, season, tmdbid, doubanid, bangumiid)\n\n    async def async_get_by(self, type: str, season: Optional[str] = None, tmdbid: Optional[int] = None,\n                           doubanid: Optional[str] = None, bangumiid: Optional[str] = None) -> Optional[Subscribe]:\n        \"\"\"\n        根据条件查询订阅\n        \"\"\"\n        return await Subscribe.async_get_by(self._db, type, season, tmdbid, doubanid, bangumiid)\n\n    def list(self, state: Optional[str] = None) -> List[Subscribe]:\n        \"\"\"\n        获取订阅列表\n        \"\"\"\n        if state:\n            return Subscribe.get_by_state(self._db, state)\n        return Subscribe.list(self._db)\n\n    async def async_list(self, state: Optional[str] = None) -> List[Subscribe]:\n        \"\"\"\n        异步获取订阅列表\n        \"\"\"\n        if state:\n            return await Subscribe.async_get_by_state(self._db, state)\n        return await Subscribe.async_list(self._db)\n\n    def delete(self, sid: int):\n        \"\"\"\n        删除订阅\n        \"\"\"\n        Subscribe.delete(self._db, rid=sid)\n\n    def update(self, sid: int, payload: dict) -> Subscribe:\n        \"\"\"\n        更新订阅\n        \"\"\"\n        subscribe = self.get(sid)\n        if subscribe:\n            subscribe.update(self._db, payload)\n        return subscribe\n\n    def list_by_tmdbid(self, tmdbid: int, season: Optional[int] = None) -> List[Subscribe]:\n        \"\"\"\n        获取指定tmdb_id的订阅\n        \"\"\"\n        return Subscribe.get_by_tmdbid(self._db, tmdbid=tmdbid, season=season)\n\n    def list_by_username(self, username: str, state: Optional[str] = None,\n                         mtype: Optional[str] = None) -> List[Subscribe]:\n        \"\"\"\n        获取指定用户的订阅\n        \"\"\"\n        return Subscribe.list_by_username(self._db, username=username, state=state, mtype=mtype)\n\n    def list_by_type(self, mtype: str, days: Optional[int] = 7) -> Subscribe:\n        \"\"\"\n        获取指定类型的订阅\n        \"\"\"\n        return Subscribe.list_by_type(self._db, mtype=mtype, days=days)\n\n    def add_history(self, **kwargs):\n        \"\"\"\n        新增订阅\n        \"\"\"\n        # 去除kwargs中 SubscribeHistory 没有的字段\n        kwargs = {k: v for k, v in kwargs.items() if hasattr(SubscribeHistory, k)}\n        # 更新完成订阅时间\n        kwargs.update({\"date\": time.strftime(\"%Y-%m-%d %H:%M:%S\", time.localtime())})\n        # 去掉主键\n        if \"id\" in kwargs:\n            kwargs.pop(\"id\")\n        subscribe = SubscribeHistory(**kwargs)\n        subscribe.create(self._db)\n\n    def exist_history(self, tmdbid: Optional[int] = None, doubanid: Optional[str] = None, season: Optional[int] = None):\n        \"\"\"\n        判断是否存在订阅历史\n        \"\"\"\n        if tmdbid:\n            if season is not None:\n                return True if SubscribeHistory.exists(self._db, tmdbid=tmdbid, season=season) else False\n            else:\n                return True if SubscribeHistory.exists(self._db, tmdbid=tmdbid) else False\n        elif doubanid:\n            return True if SubscribeHistory.exists(self._db, doubanid=doubanid) else False\n        return False\n"
  },
  {
    "path": "app/db/systemconfig_oper.py",
    "content": "import asyncio\nimport copy\nimport threading\nfrom typing import Any, Optional, Union\n\nfrom app.db import DbOper\nfrom app.db.models.systemconfig import SystemConfig\nfrom app.schemas.types import SystemConfigKey\nfrom app.utils.singleton import Singleton\n\n\nclass SystemConfigOper(DbOper, metaclass=Singleton):\n    \"\"\"\n    系统配置管理\n    \"\"\"\n    def __init__(self):\n        \"\"\"\n        加载配置到内存\n        \"\"\"\n        super().__init__()\n        self.__SYSTEMCONF = {}\n        self._rlock = threading.RLock()\n        self._alock = asyncio.Lock()\n        for item in SystemConfig.list(self._db):\n            self.__SYSTEMCONF[item.key] = item.value\n\n    def set(self, key: Union[str, SystemConfigKey], value: Any) -> Optional[bool]:\n        \"\"\"\n        设置系统设置\n        :param key: 配置键\n        :param value: 配置值\n        :return: 是否设置成功（True 成功/False 失败/None 无需更新）\n        \"\"\"\n        if isinstance(key, SystemConfigKey):\n            key = key.value\n        with self._rlock:\n            # 旧值\n            old_value = self.__SYSTEMCONF.get(key)\n            # 更新内存(deepcopy避免内存共享)\n            self.__SYSTEMCONF[key] = copy.deepcopy(value)\n            conf = SystemConfig.get_by_key(self._db, key)\n            if conf:\n                if old_value != value:\n                    if value:\n                        conf.update(self._db, {\"value\": value})\n                    else:\n                        conf.delete(self._db, conf.id)\n                    return True\n                return None\n            else:\n                conf = SystemConfig(key=key, value=value)\n                conf.create(self._db)\n                return True\n\n    async def async_set(self, key: Union[str, SystemConfigKey], value: Any) -> Optional[bool]:\n        \"\"\"\n        异步设置系统设置\n        :param key: 配置键\n        :param value: 配置值\n        :return: 是否设置成功（True 成功/False 失败/None 无需更新）\n        \"\"\"\n        if isinstance(key, SystemConfigKey):\n            key = key.value\n        async with self._alock:\n            conf = await SystemConfig.async_get_by_key(self._db, key)\n            # 确定是否需要更新数据库\n            needs_db_update = False\n            if conf:\n                if conf.value != value:\n                    needs_db_update = True\n            else:  # 记录不存在，总是需要创建/更新\n                needs_db_update = True\n            if not needs_db_update:\n                # 即使数据库值相同，也要确保缓存同步\n                with self._rlock:\n                    self.__SYSTEMCONF[key] = copy.deepcopy(value)\n                return None\n            # 执行数据库更新\n            if conf:\n                if value:\n                    await conf.async_update(self._db, {\"value\": value})\n                else:\n                    await conf.async_delete(self._db, conf.id)\n            else:\n                conf = SystemConfig(key=key, value=value)\n                await conf.async_create(self._db)\n            # 数据库更新成功后，再更新缓存\n            with self._rlock:\n                self.__SYSTEMCONF[key] = copy.deepcopy(value)\n            return True\n\n    def get(self, key: Union[str, SystemConfigKey] = None) -> Any:\n        \"\"\"\n        获取系统设置\n        \"\"\"\n        if isinstance(key, SystemConfigKey):\n            key = key.value\n        if not key:\n            return self.all()\n        with self._rlock:\n            # 避免将__SYSTEMCONF内的值引用出去，会导致set时误判没有变动\n            return copy.deepcopy(self.__SYSTEMCONF.get(key))\n\n    def all(self):\n        \"\"\"\n        获取所有系统设置\n        \"\"\"\n        with self._rlock:\n            # 避免将__SYSTEMCONF内的值引用出去，会导致set时误判没有变动\n            return copy.deepcopy(self.__SYSTEMCONF)\n\n    def delete(self, key: Union[str, SystemConfigKey]) -> bool:\n        \"\"\"\n        删除系统设置\n        \"\"\"\n        if isinstance(key, SystemConfigKey):\n            key = key.value\n        with self._rlock:\n            # 更新内存\n            self.__SYSTEMCONF.pop(key, None)\n            # 写入数据库\n            conf = SystemConfig.get_by_key(self._db, key)\n            if conf:\n                conf.delete(self._db, conf.id)\n            return True\n"
  },
  {
    "path": "app/db/transferhistory_oper.py",
    "content": "import time\nfrom typing import Any, List, Optional\n\nfrom app.core.context import MediaInfo\nfrom app.core.meta import MetaBase\nfrom app.db import DbOper\nfrom app.db.models.transferhistory import TransferHistory\nfrom app.schemas import TransferInfo, FileItem\n\n\nclass TransferHistoryOper(DbOper):\n    \"\"\"\n    转移历史管理\n    \"\"\"\n\n    def get(self, historyid: int) -> TransferHistory:\n        \"\"\"\n        获取转移历史\n        :param historyid: 转移历史id\n        \"\"\"\n        return TransferHistory.get(self._db, historyid)\n\n    def get_by_title(self, title: str) -> List[TransferHistory]:\n        \"\"\"\n        按标题查询转移记录\n        :param title: 数据key\n        \"\"\"\n        return TransferHistory.list_by_title(self._db, title)\n\n    def get_by_src(self, src: str, storage: Optional[str] = None) -> TransferHistory:\n        \"\"\"\n        按源查询转移记录\n        :param src: 数据key\n        :param storage: 存储类型\n        \"\"\"\n        return TransferHistory.get_by_src(self._db, src, storage)\n\n    def get_by_dest(self, dest: str) -> TransferHistory:\n        \"\"\"\n        按转移路径查询转移记录\n        :param dest: 数据key\n        \"\"\"\n        return TransferHistory.get_by_dest(self._db, dest)\n\n    def list_by_hash(self, download_hash: str) -> List[TransferHistory]:\n        \"\"\"\n        按种子hash查询转移记录\n        :param download_hash: 种子hash\n        \"\"\"\n        return TransferHistory.list_by_hash(self._db, download_hash)\n\n    def add(self, **kwargs):\n        \"\"\"\n        新增转移历史\n        \"\"\"\n        kwargs.update({\n            \"date\": time.strftime(\"%Y-%m-%d %H:%M:%S\", time.localtime())\n        })\n        TransferHistory(**kwargs).create(self._db)\n\n    def statistic(self, days: Optional[int] = 7) -> List[Any]:\n        \"\"\"\n        统计最近days天的下载历史数量\n        \"\"\"\n        return TransferHistory.statistic(self._db, days)\n\n    def get_by(self, title: Optional[str] = None, year: Optional[str] = None, mtype: Optional[str] = None,\n               season: Optional[str] = None, episode: Optional[str] = None, tmdbid: Optional[int] = None,\n               dest: Optional[str] = None) -> List[TransferHistory]:\n        \"\"\"\n        按类型、标题、年份、季集查询转移记录\n        \"\"\"\n        return TransferHistory.list_by(db=self._db,\n                                       mtype=mtype,\n                                       title=title,\n                                       dest=dest,\n                                       year=year,\n                                       season=season,\n                                       episode=episode,\n                                       tmdbid=tmdbid)\n\n    def get_by_type_tmdbid(self, mtype: Optional[str] = None, tmdbid: Optional[int] = None) -> TransferHistory:\n        \"\"\"\n        按类型、tmdb查询转移记录\n        \"\"\"\n        return TransferHistory.get_by_type_tmdbid(db=self._db,\n                                                  mtype=mtype,\n                                                  tmdbid=tmdbid)\n\n    def delete(self, historyid):\n        \"\"\"\n        删除转移记录\n        \"\"\"\n        TransferHistory.delete(self._db, historyid)\n\n    def truncate(self):\n        \"\"\"\n        清空转移记录\n        \"\"\"\n        TransferHistory.truncate(self._db)\n\n    def add_force(self, **kwargs) -> TransferHistory:\n        \"\"\"\n        新增转移历史，相同源目录的记录会被删除\n        \"\"\"\n        if kwargs.get(\"src\"):\n            transferhistory = TransferHistory.get_by_src(self._db, kwargs.get(\"src\"))\n            if transferhistory:\n                transferhistory.delete(self._db, transferhistory.id)\n        kwargs.update({\n            \"date\": time.strftime(\"%Y-%m-%d %H:%M:%S\", time.localtime())\n        })\n        TransferHistory(**kwargs).create(self._db)\n        return TransferHistory.get_by_src(self._db, kwargs.get(\"src\"))\n\n    def update_download_hash(self, historyid, download_hash):\n        \"\"\"\n        补充转移记录download_hash\n        \"\"\"\n        TransferHistory.update_download_hash(self._db, historyid, download_hash)\n\n    def add_success(self, fileitem: FileItem, mode: str, meta: MetaBase,\n                    mediainfo: MediaInfo, transferinfo: TransferInfo,\n                    downloader: Optional[str] = None, download_hash: Optional[str] = None):\n        \"\"\"\n        新增转移成功历史记录\n        \"\"\"\n        return self.add_force(\n            src=fileitem.path,\n            src_storage=fileitem.storage,\n            src_fileitem=fileitem.model_dump(),\n            dest=transferinfo.target_item.path if transferinfo.target_item else None,\n            dest_storage=transferinfo.target_item.storage if transferinfo.target_item else None,\n            dest_fileitem=transferinfo.target_item.model_dump() if transferinfo.target_item else None,\n            mode=mode,\n            type=mediainfo.type.value,\n            category=mediainfo.category,\n            title=mediainfo.title,\n            year=mediainfo.year,\n            tmdbid=mediainfo.tmdb_id,\n            imdbid=mediainfo.imdb_id,\n            tvdbid=mediainfo.tvdb_id,\n            doubanid=mediainfo.douban_id,\n            seasons=meta.season,\n            episodes=meta.episode,\n            image=mediainfo.get_poster_image(),\n            downloader=downloader,\n            download_hash=download_hash,\n            status=1,\n            files=transferinfo.file_list\n        )\n\n    def add_fail(self, fileitem: FileItem, mode: str, meta: MetaBase, mediainfo: MediaInfo = None,\n                 transferinfo: TransferInfo = None, downloader: Optional[str] = None, download_hash: Optional[str] = None):\n        \"\"\"\n        新增转移失败历史记录\n        \"\"\"\n        if mediainfo and transferinfo:\n            his = self.add_force(\n                src=fileitem.path,\n                src_storage=fileitem.storage,\n                src_fileitem=fileitem.model_dump(),\n                dest=transferinfo.target_item.path if transferinfo.target_item else None,\n                dest_storage=transferinfo.target_item.storage if transferinfo.target_item else None,\n                dest_fileitem=transferinfo.target_item.model_dump() if transferinfo.target_item else None,\n                mode=mode,\n                type=mediainfo.type.value,\n                category=mediainfo.category,\n                title=mediainfo.title or meta.name,\n                year=mediainfo.year or meta.year,\n                tmdbid=mediainfo.tmdb_id,\n                imdbid=mediainfo.imdb_id,\n                tvdbid=mediainfo.tvdb_id,\n                doubanid=mediainfo.douban_id,\n                seasons=meta.season,\n                episodes=meta.episode,\n                image=mediainfo.get_poster_image(),\n                downloader=downloader,\n                download_hash=download_hash,\n                episode_group=mediainfo.episode_group,\n                status=0,\n                errmsg=transferinfo.message or '未知错误',\n                files=transferinfo.file_list\n            )\n        else:\n            his = self.add_force(\n                title=meta.name,\n                year=meta.year,\n                src=fileitem.path,\n                src_storage=fileitem.storage,\n                src_fileitem=fileitem.model_dump(),\n                mode=mode,\n                seasons=meta.season,\n                episodes=meta.episode,\n                downloader=downloader,\n                download_hash=download_hash,\n                status=0,\n                errmsg=\"未识别到媒体信息\"\n            )\n        return his\n\n    def list_by_date(self, date: str) -> List[TransferHistory]:\n        \"\"\"\n        查询某时间之后的转移历史\n        :param date: 日期\n        \"\"\"\n        return TransferHistory.list_by_date(self._db, date)\n"
  },
  {
    "path": "app/db/user_oper.py",
    "content": "from typing import Optional, List\n\nfrom fastapi import Depends, HTTPException\nfrom sqlalchemy.ext.asyncio import AsyncSession\nfrom sqlalchemy.orm import Session\n\nfrom app import schemas\nfrom app.core.security import verify_token\nfrom app.db import DbOper, get_db, get_async_db\nfrom app.db.models.user import User\n\n\ndef get_current_user(\n        db: Session = Depends(get_db),\n        token_data: schemas.TokenPayload = Depends(verify_token)\n) -> User:\n    \"\"\"\n    获取当前用户\n    \"\"\"\n    user = User.get(db, rid=token_data.sub)\n    if not user:\n        raise HTTPException(status_code=403, detail=\"用户不存在\")\n    return user\n\n\nasync def get_current_user_async(\n        db: AsyncSession = Depends(get_async_db),\n        token_data: schemas.TokenPayload = Depends(verify_token)\n) -> User:\n    \"\"\"\n    异步获取当前用户\n    \"\"\"\n    user = await User.async_get(db, rid=token_data.sub)\n    if not user:\n        raise HTTPException(status_code=403, detail=\"用户不存在\")\n    return user\n\n\ndef get_current_active_user(\n        current_user: User = Depends(get_current_user),\n) -> User:\n    \"\"\"\n    获取当前激活用户\n    \"\"\"\n    if not current_user.is_active:\n        raise HTTPException(status_code=403, detail=\"用户未激活\")\n    return current_user\n\n\nasync def get_current_active_user_async(\n        current_user: User = Depends(get_current_user_async),\n) -> User:\n    \"\"\"\n    异步获取当前激活用户\n    \"\"\"\n    if not current_user.is_active:\n        raise HTTPException(status_code=403, detail=\"用户未激活\")\n    return current_user\n\n\ndef get_current_active_superuser(\n        current_user: User = Depends(get_current_user),\n) -> User:\n    \"\"\"\n    获取当前激活超级管理员\n    \"\"\"\n    if not current_user.is_superuser:\n        raise HTTPException(\n            status_code=400, detail=\"用户权限不足\"\n        )\n    return current_user\n\n\nasync def get_current_active_superuser_async(\n        current_user: User = Depends(get_current_user_async),\n) -> User:\n    \"\"\"\n    异步获取当前激活超级管理员\n    \"\"\"\n    if not current_user.is_superuser:\n        raise HTTPException(\n            status_code=400, detail=\"用户权限不足\"\n        )\n    return current_user\n\n\nclass UserOper(DbOper):\n    \"\"\"\n    用户管理\n    \"\"\"\n\n    def list(self) -> List[User]:\n        \"\"\"\n        获取用户列表\n        \"\"\"\n        return User.list(self._db)\n\n    def add(self, **kwargs):\n        \"\"\"\n        新增用户\n        \"\"\"\n        user = User(**kwargs)\n        user.create(self._db)\n\n    def get_by_name(self, name: str) -> User:\n        \"\"\"\n        根据用户名获取用户\n        \"\"\"\n        return User.get_by_name(self._db, name)\n\n    def get_permissions(self, name: str) -> dict:\n        \"\"\"\n        获取用户权限\n        \"\"\"\n        user = User.get_by_name(self._db, name)\n        if user:\n            return user.permissions or {}\n        return {}\n\n    def get_settings(self, name: str) -> Optional[dict]:\n        \"\"\"\n        获取用户个性化设置，返回None表示用户不存在\n        \"\"\"\n        user = User.get_by_name(self._db, name)\n        if user:\n            return user.settings or {}\n        return None\n\n    def get_setting(self, name: str, key: str) -> Optional[str]:\n        \"\"\"\n        获取用户个性化设置\n        \"\"\"\n        settings = self.get_settings(name)\n        if settings:\n            return settings.get(key)\n        return None\n\n    def get_name(self, **kwargs) -> Optional[str]:\n        \"\"\"\n        根据绑定账号获取用户名称\n        \"\"\"\n        users = self.list()\n        for user in users:\n            user_setting = user.settings\n            if user_setting:\n                for k, v in kwargs.items():\n                    if user_setting.get(k) == str(v):\n                        return user.name\n        return None\n"
  },
  {
    "path": "app/db/userconfig_oper.py",
    "content": "from typing import Any, Union, Dict, Optional\n\nfrom app.db import DbOper\nfrom app.db.models.userconfig import UserConfig\nfrom app.schemas.types import UserConfigKey\nfrom app.utils.singleton import Singleton\n\n\nclass UserConfigOper(DbOper, metaclass=Singleton):\n    \"\"\"\n    用户配置管理\n    \"\"\"\n    def __init__(self):\n        \"\"\"\n        加载配置到内存\n        \"\"\"\n        super().__init__()\n        self.__USERCONF = {}\n        for item in UserConfig.list(self._db):\n            self.__set_config_cache(username=item.username, key=item.key, value=item.value)\n\n    def set(self, username: str, key: Union[str, UserConfigKey], value: Any):\n        \"\"\"\n        设置用户配置\n        \"\"\"\n        if isinstance(key, UserConfigKey):\n            key = key.value\n        # 更新内存\n        self.__set_config_cache(username=username, key=key, value=value)\n        # 写入数据库\n        conf = UserConfig.get_by_key(db=self._db, username=username, key=key)\n        if conf:\n            if value:\n                conf.update(self._db, {\"value\": value})\n            else:\n                conf.delete(self._db, conf.id)\n        else:\n            conf = UserConfig(username=username, key=key, value=value)\n            conf.create(self._db)\n\n    def get(self, username: str, key: Union[str, UserConfigKey] = None) -> Any:\n        \"\"\"\n        获取用户配置\n        \"\"\"\n        if not username:\n            return self.__USERCONF\n        if isinstance(key, UserConfigKey):\n            key = key.value\n        if not key:\n            return self.__get_config_caches(username=username)\n        return self.__get_config_cache(username=username, key=key)\n\n    def __set_config_cache(self, username: str, key: str, value: Any):\n        \"\"\"\n        设置配置缓存\n        \"\"\"\n        if not username or not key:\n            return\n        cache = self.__USERCONF\n        if not cache:\n            cache = {}\n        user_cache = cache.get(username)\n        if not user_cache:\n            user_cache = {}\n            cache[username] = user_cache\n        user_cache[key] = value\n        self.__USERCONF = cache\n\n    def __get_config_caches(self, username: str) -> Optional[Dict[str, Any]]:\n        \"\"\"\n        获取配置缓存\n        \"\"\"\n        if not username or not self.__USERCONF:\n            return None\n        return self.__USERCONF.get(username)\n\n    def __get_config_cache(self, username: str, key: str) -> Any:\n        \"\"\"\n        获取配置缓存\n        \"\"\"\n        if not username or not key or not self.__USERCONF:\n            return None\n        user_cache = self.__get_config_caches(username)\n        if not user_cache:\n            return None\n        return user_cache.get(key)\n"
  },
  {
    "path": "app/db/workflow_oper.py",
    "content": "from typing import List, Tuple, Optional, Any, Coroutine, Sequence\n\nfrom app.db import DbOper\nfrom app.db.models.workflow import Workflow\n\n\nclass WorkflowOper(DbOper):\n    \"\"\"\n    工作流管理\n    \"\"\"\n\n    def add(self, **kwargs) -> Tuple[bool, str]:\n        \"\"\"\n        新增工作流\n        \"\"\"\n        wf = Workflow(**kwargs)\n        if not wf.get_by_name(self._db, kwargs.get(\"name\")):\n            wf.create(self._db)\n            return True, \"新增工作流成功\"\n        return False, \"工作流已存在\"\n\n    def get(self, wid: int) -> Workflow:\n        \"\"\"\n        查询单个工作流\n        \"\"\"\n        return Workflow.get(self._db, wid)\n\n    async def async_get(self, wid: int) -> Workflow:\n        \"\"\"\n        异步查询单个工作流\n        \"\"\"\n        return await Workflow.async_get(self._db, wid)\n\n    def list(self) -> List[Workflow]:\n        \"\"\"\n        获取所有工作流列表\n        \"\"\"\n        return Workflow.list(self._db)\n\n    async def async_list(self) -> Coroutine[Any, Any, Sequence[Any]]:\n        \"\"\"\n        异步获取所有工作流列表\n        \"\"\"\n        return await Workflow.async_list(self._db)\n\n    def list_enabled(self) -> List[Workflow]:\n        \"\"\"\n        获取启用的工作流列表\n        \"\"\"\n        return Workflow.get_enabled_workflows(self._db)\n\n    def get_timer_triggered_workflows(self) -> List[Workflow]:\n        \"\"\"\n        获取定时触发的工作流列表\n        \"\"\"\n        return Workflow.get_timer_triggered_workflows(self._db)\n\n    def get_event_triggered_workflows(self) -> List[Workflow]:\n        \"\"\"\n        获取事件触发的工作流列表\n        \"\"\"\n        return Workflow.get_event_triggered_workflows(self._db)\n\n    def get_by_name(self, name: str) -> Workflow:\n        \"\"\"\n        按名称获取工作流\n        \"\"\"\n        return Workflow.get_by_name(self._db, name)\n\n    async def async_get_by_name(self, name: str) -> Workflow:\n        \"\"\"\n        异步按名称获取工作流\n        \"\"\"\n        return await Workflow.async_get_by_name(self._db, name)\n\n    def start(self, wid: int) -> bool:\n        \"\"\"\n        启动\n        \"\"\"\n        return Workflow.start(self._db, wid)\n\n    def success(self, wid: int, result: Optional[str] = None) -> bool:\n        \"\"\"\n        成功\n        \"\"\"\n        return Workflow.success(self._db, wid, result)\n\n    def fail(self, wid: int, result: str) -> bool:\n        \"\"\"\n        失败\n        \"\"\"\n        return Workflow.fail(self._db, wid, result)\n\n    def step(self, wid: int, action_id: str, context: dict) -> bool:\n        \"\"\"\n        步进\n        \"\"\"\n        return Workflow.update_current_action(self._db, wid, action_id, context)\n\n    def reset(self, wid: int, reset_count: bool = False) -> bool:\n        \"\"\"\n        重置\n        \"\"\"\n        return Workflow.reset(self._db, wid, reset_count=reset_count)\n"
  },
  {
    "path": "app/factory.py",
    "content": "from fastapi import FastAPI\nfrom fastapi.middleware.cors import CORSMiddleware\n\nfrom app.core.config import settings\nfrom app.startup.lifecycle import lifespan\n\n\ndef create_app() -> FastAPI:\n    \"\"\"\n    创建并配置 FastAPI 应用实例。\n    \"\"\"\n    _app = FastAPI(\n        title=settings.PROJECT_NAME,\n        openapi_url=f\"{settings.API_V1_STR}/openapi.json\",\n        lifespan=lifespan\n    )\n\n    # 配置 CORS 中间件\n    _app.add_middleware(\n        CORSMiddleware,  # noqa\n        allow_origins=settings.ALLOWED_HOSTS,\n        allow_credentials=True,\n        allow_methods=[\"*\"],\n        allow_headers=[\"*\"],\n    )\n\n    return _app\n\n\n# 创建 FastAPI 应用实例\napp = create_app()\n"
  },
  {
    "path": "app/helper/__init__.py",
    "content": "from .cloudflare import under_challenge\n"
  },
  {
    "path": "app/helper/browser.py",
    "content": "import uuid\nfrom typing import Callable, Any, Optional\n\nfrom cf_clearance import sync_cf_retry, sync_stealth\nfrom playwright.sync_api import sync_playwright, Page\n\nfrom app.core.config import settings\nfrom app.log import logger\nfrom app.utils.http import RequestUtils, cookie_parse\n\n\nclass PlaywrightHelper:\n    def __init__(self, browser_type=settings.PLAYWRIGHT_BROWSER_TYPE):\n        self.browser_type = browser_type\n\n    @staticmethod\n    def __pass_cloudflare(url: str, page: Page) -> bool:\n        \"\"\"\n        尝试跳过cloudfare验证\n        \"\"\"\n        sync_stealth(page, pure=True)\n        page.goto(url)\n        return sync_cf_retry(page)[0]\n\n    @staticmethod\n    def __fs_cookie_str(cookies: list) -> str:\n        if not cookies:\n            return \"\"\n        return \"; \".join([f\"{c.get('name')}={c.get('value')}\" for c in cookies if c and c.get('name') is not None])\n\n    @staticmethod\n    def __flaresolverr_request(url: str,\n                               cookies: Optional[str] = None,\n                               proxy_config: Optional[dict] = None,\n                               timeout: Optional[int] = 60) -> Optional[dict]:\n        \"\"\"\n        调用 FlareSolverr 解决 Cloudflare 并返回 solution 结果\n        参考: https://github.com/FlareSolverr/FlareSolverr\n        \"\"\"\n        if not settings.FLARESOLVERR_URL:\n            logger.warn(\"未配置 FLARESOLVERR_URL，无法使用 FlareSolverr\")\n            return None\n\n        fs_api = settings.FLARESOLVERR_URL.rstrip(\"/\") + \"/v1\"\n        session_id = None\n\n        try:\n            # 检查是否需要代理认证\n            need_proxy_auth = (proxy_config and proxy_config.get(\"server\") and\n                               (proxy_config.get(\"username\") or proxy_config.get(\"password\")))\n\n            if need_proxy_auth:\n                # 使用 session 模式支持代理认证\n                logger.debug(\"检测到flaresolverr代理需要认证，使用 session 模式\")\n\n                # 1. 创建会话\n                session_id = str(uuid.uuid4())\n                create_payload: dict = {\n                    \"cmd\": \"sessions.create\",\n                    \"session\": session_id\n                }\n\n                # 添加代理配置到会话创建请求\n                if proxy_config and proxy_config.get(\"server\"):\n                    proxy_payload: dict = {\"url\": proxy_config[\"server\"]}\n                    if proxy_config.get(\"username\"):\n                        proxy_payload[\"username\"] = proxy_config[\"username\"]\n                    if proxy_config.get(\"password\"):\n                        proxy_payload[\"password\"] = proxy_config[\"password\"]\n                    create_payload[\"proxy\"] = proxy_payload\n\n                # 创建会话\n                create_result = RequestUtils(content_type=\"application/json\",\n                                             timeout=timeout or 60).post_json(url=fs_api, json=create_payload)\n                if not create_result or create_result.get(\"status\") != \"ok\":\n                    logger.error(\n                        f\"创建 FlareSolverr 会话失败: {create_result.get('message') if create_result else '无响应'}\")\n                    return None\n\n                # 2. 使用会话发送请求\n                request_payload = {\n                    \"cmd\": \"request.get\",\n                    \"url\": url,\n                    \"session\": session_id,\n                    \"maxTimeout\": int(timeout or 60) * 1000,\n                }\n            else:\n                # 使用普通模式（无代理认证）\n                request_payload = {\n                    \"cmd\": \"request.get\",\n                    \"url\": url,\n                    \"maxTimeout\": int(timeout or 60) * 1000,\n                }\n                # 添加代理配置（仅 URL，无认证）\n                if proxy_config and proxy_config.get(\"server\"):\n                    request_payload[\"proxy\"] = {\"url\": proxy_config[\"server\"]}\n\n            # 将 cookies 以数组形式传递给 FlareSolverr\n            if cookies:\n                try:\n                    request_payload[\"cookies\"] = cookie_parse(cookies, array=True)\n                except Exception as e:\n                    logger.debug(f\"解析 cookies 失败，忽略: {str(e)}\")\n\n            # 发送请求\n            data = RequestUtils(content_type=\"application/json\",\n                                timeout=timeout or 60).post_json(url=fs_api, json=request_payload)\n            if not data:\n                logger.error(\"FlareSolverr 返回空响应\")\n                return None\n            if data.get(\"status\") != \"ok\":\n                logger.error(f\"FlareSolverr 调用失败: {data.get('message')}\")\n                return None\n            return data.get(\"solution\")\n        except Exception as e:\n            logger.error(f\"调用 FlareSolverr 失败: {str(e)}\")\n            return None\n        finally:\n            # 清理会话\n            if session_id:\n                try:\n                    destroy_payload = {\n                        \"cmd\": \"sessions.destroy\",\n                        \"session\": session_id\n                    }\n                    RequestUtils(content_type=\"application/json\",\n                                 timeout=10).post_json(url=fs_api, json=destroy_payload)\n                    logger.debug(f\"已清理 FlareSolverr 会话: {session_id}\")\n                except Exception as e:\n                    logger.warning(f\"清理 FlareSolverr 会话失败: {str(e)}\")\n\n    def action(self, url: str,\n               callback: Callable,\n               cookies: Optional[str] = None,\n               ua: Optional[str] = None,\n               proxies: Optional[dict] = None,\n               headless: Optional[bool] = False,\n               timeout: Optional[int] = 60) -> Any:\n        \"\"\"\n        访问网页，接收Page对象并执行操作\n        :param url: 网页地址\n        :param callback: 回调函数，需要接收page对象\n        :param cookies: cookies\n        :param ua: user-agent\n        :param proxies: 代理\n        :param headless: 是否无头模式\n        :param timeout: 超时时间\n        \"\"\"\n        result = None\n        try:\n            with sync_playwright() as playwright:\n                browser = None\n                context = None\n                page = None\n                try:\n                    # 如果配置使用 FlareSolverr，先通过其获取清除后的 cookies 与 UA\n                    fs_cookie_header = None\n                    fs_ua = None\n                    if settings.BROWSER_EMULATION == \"flaresolverr\":\n                        solution = self.__flaresolverr_request(url=url, cookies=cookies,\n                                                               proxy_config=proxies, timeout=timeout)\n                        if solution:\n                            fs_cookie_header = self.__fs_cookie_str(solution.get(\"cookies\", []))\n                            fs_ua = solution.get(\"userAgent\")\n\n                    browser = playwright[self.browser_type].launch(headless=headless)\n                    context = browser.new_context(user_agent=fs_ua or ua, proxy=proxies)\n                    page = context.new_page()\n\n                    # 优先使用 FlareSolverr 返回，其次使用入参\n                    merged_cookie = fs_cookie_header or cookies\n                    if merged_cookie:\n                        page.set_extra_http_headers({\"cookie\": merged_cookie})\n\n                    if settings.BROWSER_EMULATION == \"playwright\":\n                        if not self.__pass_cloudflare(url, page):\n                            logger.warn(\"cloudflare challenge fail！\")\n                    else:\n                        page.goto(url)\n                    page.wait_for_load_state(\"networkidle\", timeout=timeout * 1000)\n\n                    # 回调函数\n                    result = callback(page)\n\n                except Exception as e:\n                    logger.error(f\"网页操作失败: {str(e)}\")\n                finally:\n                    if page:\n                        page.close()\n                    if context:\n                        context.close()\n                    if browser:\n                        browser.close()\n        except Exception as e:\n            logger.error(f\"Playwright初始化失败: {str(e)}\")\n\n        return result\n\n    def get_page_source(self, url: str,\n                        cookies: Optional[str] = None,\n                        ua: Optional[str] = None,\n                        proxies: Optional[dict] = None,\n                        headless: Optional[bool] = False,\n                        timeout: Optional[int] = 60) -> Optional[str]:\n        \"\"\"\n        获取网页源码\n        :param url: 网页地址\n        :param cookies: cookies\n        :param ua: user-agent\n        :param proxies: 代理\n        :param headless: 是否无头模式\n        :param timeout: 超时时间\n        \"\"\"\n        source = None\n        # 如果配置为 FlareSolverr，则直接调用获取页面源码\n        if settings.BROWSER_EMULATION == \"flaresolverr\":\n            try:\n                solution = self.__flaresolverr_request(url=url, cookies=cookies,\n                                                       proxy_config=proxies, timeout=timeout)\n                if solution:\n                    return solution.get(\"response\")\n            except Exception as e:\n                logger.error(f\"FlareSolverr 获取源码失败: {str(e)}\")\n        try:\n            with sync_playwright() as playwright:\n                browser = None\n                context = None\n                page = None\n                try:\n                    browser = playwright[self.browser_type].launch(headless=headless)\n                    context = browser.new_context(user_agent=ua, proxy=proxies)\n                    page = context.new_page()\n\n                    if cookies:\n                        page.set_extra_http_headers({\"cookie\": cookies})\n\n                    if not self.__pass_cloudflare(url, page):\n                        logger.warn(\"cloudflare challenge fail！\")\n                    page.wait_for_load_state(\"networkidle\", timeout=timeout * 1000)\n\n                    source = page.content()\n\n                except Exception as e:\n                    logger.error(f\"获取网页源码失败: {str(e)}\")\n                    source = None\n                finally:\n                    # 确保资源被正确清理\n                    if page:\n                        page.close()\n                    if context:\n                        context.close()\n                    if browser:\n                        browser.close()\n        except Exception as e:\n            logger.error(f\"Playwright初始化失败: {str(e)}\")\n\n        return source\n"
  },
  {
    "path": "app/helper/cloudflare.py",
    "content": "import os\n\nfrom pyquery import PyQuery\n\nfrom app.log import logger\n\nCHALLENGE_TITLES = [\n    # Cloudflare\n    'Just a moment...',\n    '请稍候…',\n    # DDoS-GUARD\n    'DDOS-GUARD',\n]\nCHALLENGE_SELECTORS = [\n    # Cloudflare\n    '#cf-challenge-running', '.ray_id', '.attack-box', '#cf-please-wait', '#challenge-spinner', '#trk_jschal_js',\n    # Custom CloudFlare for EbookParadijs, Film-Paleis, MuziekFabriek and Puur-Hollands\n    'td.info #js_info',\n    # Fairlane / pararius.com\n    'div.vc div.text-box h2'\n]\nSHORT_TIMEOUT = 6\nCF_TIMEOUT = int(os.getenv(\"NASTOOL_CF_TIMEOUT\", \"60\"))\n\n\ndef under_challenge(html_text: str):\n    \"\"\"\n    Check if the page is under challenge\n    :param html_text:\n    :return:\n    \"\"\"\n    # get the page title\n    if not html_text:\n        return False\n    page_title = PyQuery(html_text)('title').text()\n    logger.debug(\"under_challenge page_title=\" + page_title)\n    for title in CHALLENGE_TITLES:\n        if page_title.lower() == title.lower():\n            return True\n    for selector in CHALLENGE_SELECTORS:\n        html_doc = PyQuery(html_text)\n        if html_doc(selector):\n            return True\n    return False\n"
  },
  {
    "path": "app/helper/cookie.py",
    "content": "import base64\nfrom typing import Tuple, Optional\n\nfrom lxml import etree\nfrom playwright.sync_api import Page\n\nfrom app.helper.browser import PlaywrightHelper\nfrom app.helper.ocr import OcrHelper\nfrom app.helper.twofa import TwoFactorAuth\nfrom app.log import logger\nfrom app.utils.http import RequestUtils\nfrom app.utils.site import SiteUtils\nfrom app.utils.string import StringUtils\n\n\nclass CookieHelper:\n    # 站点登录界面元素XPATH\n    _SITE_LOGIN_XPATH = {\n        \"username\": [\n            '//input[@name=\"username\"]',\n            '//input[@id=\"form_item_username\"]',\n            '//input[@id=\"username\"]',\n        ],\n        \"password\": [\n            '//input[@name=\"password\"]',\n            '//input[@id=\"form_item_password\"]',\n            '//input[@id=\"password\"]',\n            '//input[@type=\"password\"]',\n        ],\n        \"captcha\": [\n            '//input[@name=\"imagestring\"]',\n            '//input[@name=\"captcha\"]',\n            '//input[@id=\"form_item_captcha\"]',\n            '//input[@placeholder=\"驗證碼\"]',\n        ],\n        \"captcha_img\": [\n            '//img[@alt=\"captcha\"]/@src',\n            '//img[@alt=\"CAPTCHA\"]/@src',\n            '//img[@alt=\"SECURITY CODE\"]/@src',\n            '//img[@id=\"LAY-user-get-vercode\"]/@src',\n            '//img[contains(@src,\"/api/getCaptcha\")]/@src',\n        ],\n        \"submit\": [\n            '//input[@type=\"submit\"]',\n            '//button[@type=\"submit\"]',\n            '//button[@lay-filter=\"login\"]',\n            '//button[@lay-filter=\"formLogin\"]',\n            '//input[@type=\"button\"][@value=\"登录\"]',\n            '//input[@id=\"submit-btn\"]',\n        ],\n        \"error\": [\n            \"//table[@class='main']//td[@class='text']/text()\",\n        ],\n        \"twostep\": [\n            '//input[@name=\"two_step_code\"]',\n            '//input[@name=\"2fa_secret\"]',\n            '//input[@name=\"otp\"]',\n        ]\n    }\n\n    @staticmethod\n    def parse_cookies(cookies: list) -> str:\n        \"\"\"\n        将浏览器返回的cookies转化为字符串\n        \"\"\"\n        if not cookies:\n            return \"\"\n        cookie_str = \"\"\n        for cookie in cookies:\n            cookie_str += f\"{cookie['name']}={cookie['value']}; \"\n        return cookie_str\n\n    def get_site_cookie_ua(self,\n                           url: str,\n                           username: str,\n                           password: str,\n                           two_step_code: Optional[str] = None,\n                           proxies: Optional[dict] = None,\n                           timeout: int = None) -> Tuple[Optional[str], Optional[str], str]:\n        \"\"\"\n        获取站点cookie和ua\n        :param url: 站点地址\n        :param username: 用户名\n        :param password: 密码\n        :param two_step_code: 二步验证码或密钥\n        :param proxies: 代理\n        :param timeout: 超时时间\n        :return: cookie、ua、message\n        \"\"\"\n\n        def __page_handler(page: Page) -> Tuple[Optional[str], Optional[str], str]:\n            \"\"\"\n            页面处理\n            :return: Cookie和UA\n            \"\"\"\n            # 登录页面代码\n            html_text = page.content()\n            if not html_text:\n                return None, None, \"获取源码失败\"\n            # 查找用户名输入框\n            html = etree.HTML(html_text)\n            try:\n                username_xpath = None\n                for xpath in self._SITE_LOGIN_XPATH.get(\"username\"):\n                    if html.xpath(xpath):\n                        username_xpath = xpath\n                        break\n                if not username_xpath:\n                    return None, None, \"未找到用户名输入框\"\n                # 查找密码输入框\n                password_xpath = None\n                for xpath in self._SITE_LOGIN_XPATH.get(\"password\"):\n                    if html.xpath(xpath):\n                        password_xpath = xpath\n                        break\n                if not password_xpath:\n                    return None, None, \"未找到密码输入框\"\n                # 处理二步验证码\n                otp_code = TwoFactorAuth(two_step_code).get_code()\n                # 查找二步验证码输入框\n                twostep_xpath = None\n                if otp_code:\n                    for xpath in self._SITE_LOGIN_XPATH.get(\"twostep\"):\n                        if html.xpath(xpath):\n                            twostep_xpath = xpath\n                            break\n                # 查找验证码输入框\n                captcha_xpath = None\n                for xpath in self._SITE_LOGIN_XPATH.get(\"captcha\"):\n                    if html.xpath(xpath):\n                        captcha_xpath = xpath\n                        break\n                # 查找验证码图片\n                captcha_img_url = None\n                if captcha_xpath:\n                    for xpath in self._SITE_LOGIN_XPATH.get(\"captcha_img\"):\n                        if html.xpath(xpath):\n                            captcha_img_url = html.xpath(xpath)[0]\n                            break\n                    if not captcha_img_url:\n                        return None, None, \"未找到验证码图片\"\n                # 查找登录按钮\n                submit_xpath = None\n                for xpath in self._SITE_LOGIN_XPATH.get(\"submit\"):\n                    if html.xpath(xpath):\n                        submit_xpath = xpath\n                        break\n                if not submit_xpath:\n                    return None, None, \"未找到登录按钮\"\n\n                # 点击登录按钮\n                try:\n                    # 等待登录按钮准备好\n                    page.wait_for_selector(submit_xpath)\n                    # 输入用户名\n                    page.fill(username_xpath, username)\n                    # 输入密码\n                    page.fill(password_xpath, password)\n                    # 输入二步验证码\n                    if twostep_xpath:\n                        page.fill(twostep_xpath, otp_code)\n                    # 识别验证码\n                    if captcha_xpath and captcha_img_url:\n                        captcha_element = page.query_selector(captcha_xpath)\n                        if captcha_element.is_visible():\n                            # 验证码图片地址\n                            code_url = self.__get_captcha_url(url, captcha_img_url)\n                            # 获取当前的cookie和ua\n                            cookie = self.parse_cookies(page.context.cookies())\n                            ua = page.evaluate(\"() => window.navigator.userAgent\")\n                            # 自动OCR识别验证码\n                            captcha = self.__get_captcha_text(cookie=cookie, ua=ua, code_url=code_url)\n                            if captcha:\n                                logger.info(\"验证码地址为：%s，识别结果：%s\" % (code_url, captcha))\n                            else:\n                                return None, None, \"验证码识别失败\"\n                            # 输入验证码\n                            captcha_element.fill(captcha)\n                        else:\n                            # 不可见元素不处理\n                            pass\n                    # 点击登录按钮\n                    page.click(submit_xpath)\n                    page.wait_for_load_state(\"networkidle\", timeout=30 * 1000)\n                except Exception as e:\n                    logger.error(f\"仿真登录失败：{str(e)}\")\n                    return None, None, f\"仿真登录失败：{str(e)}\"\n\n                # 对于某二次验证码为单页面的站点，输入二次验证码\n                if \"verify\" in page.url:\n                    if not otp_code:\n                        return None, None, \"需要二次验证码\"\n                    html = etree.HTML(page.content())\n                    for xpath in self._SITE_LOGIN_XPATH.get(\"twostep\"):\n                        if html.xpath(xpath):\n                            try:\n                                # 刷新一下 2fa code\n                                otp_code = TwoFactorAuth(two_step_code).get_code()\n                                page.fill(xpath, otp_code)\n                                # 登录按钮 xpath 理论上相同，不再重复查找\n                                page.click(submit_xpath)\n                                page.wait_for_load_state(\"networkidle\", timeout=30 * 1000)\n                            except Exception as e:\n                                logger.error(f\"二次验证码输入失败：{str(e)}\")\n                                return None, None, f\"二次验证码输入失败：{str(e)}\"\n                            break\n\n                # 登录后的源码\n                html_text = page.content()\n                if not html_text:\n                    return None, None, \"获取网页源码失败\"\n                if SiteUtils.is_logged_in(html_text):\n                    return self.parse_cookies(page.context.cookies()), \\\n                        page.evaluate(\"() => window.navigator.userAgent\"), \"\"\n                else:\n                    # 读取错误信息\n                    error_xpath = None\n                    for xpath in self._SITE_LOGIN_XPATH.get(\"error\"):\n                        if html.xpath(xpath):\n                            error_xpath = xpath\n                            break\n                    if not error_xpath:\n                        return None, None, \"登录失败\"\n                    else:\n                        error_msg = html.xpath(error_xpath)[0]\n                        return None, None, error_msg\n            finally:\n                if html:\n                    del html\n\n        if not url or not username or not password:\n            return None, None, \"参数错误\"\n\n        return PlaywrightHelper().action(url=url,\n                                         callback=__page_handler,\n                                         proxies=proxies,\n                                         timeout=timeout)\n\n    @staticmethod\n    def __get_captcha_text(cookie: str, ua: str, code_url: str) -> str:\n        \"\"\"\n        识别验证码图片的内容\n        \"\"\"\n        if not code_url:\n            return \"\"\n        ret = RequestUtils(ua=ua, cookies=cookie).get_res(code_url)\n        if ret:\n            if not ret.content:\n                return \"\"\n            return OcrHelper().get_captcha_text(\n                image_b64=base64.b64encode(ret.content).decode()\n            )\n        else:\n            return \"\"\n\n    @staticmethod\n    def __get_captcha_url(siteurl: str, imageurl: str) -> str:\n        \"\"\"\n        获取验证码图片的URL\n        \"\"\"\n        if not siteurl or not imageurl:\n            return \"\"\n        if imageurl.startswith(\"/\"):\n            imageurl = imageurl[1:]\n        return \"%s/%s\" % (StringUtils.get_base_url(siteurl), imageurl)\n"
  },
  {
    "path": "app/helper/cookiecloud.py",
    "content": "import json\nfrom typing import Any, Dict, Tuple, Optional\n\nfrom app.core.config import settings\nfrom app.log import logger\nfrom app.utils.crypto import CryptoJsUtils, HashUtils\nfrom app.utils.http import RequestUtils\nfrom app.utils.string import StringUtils\nfrom app.utils.url import UrlUtils\n\n\nclass CookieCloudHelper:\n    _ignore_cookies: list = [\"CookieAutoDeleteBrowsingDataCleanup\", \"CookieAutoDeleteCleaningDiscarded\"]\n\n    def __init__(self):\n        self.__sync_setting()\n\n    def __sync_setting(self):\n        \"\"\"\n        同步CookieCloud配置项\n        \"\"\"\n        self._server = UrlUtils.standardize_base_url(settings.COOKIECLOUD_HOST)\n        self._key = StringUtils.safe_strip(settings.COOKIECLOUD_KEY)\n        self._password = StringUtils.safe_strip(settings.COOKIECLOUD_PASSWORD)\n        self._enable_local = settings.COOKIECLOUD_ENABLE_LOCAL\n        self._local_path = settings.COOKIE_PATH\n\n    def download(self) -> Tuple[Optional[dict], str]:\n        \"\"\"\n        从CookieCloud下载数据\n        :return: Cookie数据、错误信息\n        \"\"\"\n        # 更新为最新设置\n        self.__sync_setting()\n\n        if ((not self._server and not self._enable_local)\n                or not self._key\n                or not self._password):\n            return None, \"CookieCloud参数不正确\"\n\n        if self._enable_local:\n            # 开启本地服务时，从本地直接读取数据\n            result = self.__load_local_encrypt_data(self._key)\n            if not result:\n                return {}, \"未从本地CookieCloud服务加载到cookie数据，请检查服务器设置、用户KEY及加密密码是否正确\"\n        else:\n            req_url = UrlUtils.combine_url(host=self._server, path=f\"get/{self._key}\")\n            ret = RequestUtils(content_type=\"application/json\").get_res(url=req_url)\n            if ret and ret.status_code == 200:\n                try:\n                    result = ret.json()\n                    if not result:\n                        return {}, f\"未从{self._server}下载到cookie数据\"\n                except Exception as err:\n                    return {}, f\"从{self._server}下载cookie数据错误：{str(err)}\"\n            elif ret:\n                return None, f\"远程同步CookieCloud失败，错误码：{ret.status_code}\"\n            else:\n                return None, \"CookieCloud请求失败，请检查服务器地址、用户KEY及加密密码是否正确\"\n\n        encrypted = result.get(\"encrypted\")\n        if not encrypted:\n            return {}, \"未获取到cookie密文\"\n        else:\n            crypt_key = self.__get_crypt_key()\n            try:\n                decrypted_data = CryptoJsUtils.decrypt(encrypted, crypt_key).decode(\"utf-8\")\n                result = json.loads(decrypted_data)\n            except Exception as e:\n                return {}, \"cookie解密失败：\" + str(e)\n\n        if not result:\n            return {}, \"cookie解密为空\"\n\n        if result.get(\"cookie_data\"):\n            contents = result.get(\"cookie_data\")\n        else:\n            contents = result\n        # 整理数据,使用domain域名的最后两级作为分组依据\n        domain_groups = {}\n        for site, cookies in contents.items():\n            for cookie in cookies:\n                domain_key = StringUtils.get_url_domain(cookie.get(\"domain\"))\n                if not domain_groups.get(domain_key):\n                    domain_groups[domain_key] = [cookie]\n                else:\n                    domain_groups[domain_key].append(cookie)\n        # 返回错误\n        ret_cookies = {}\n        # 索引器\n        for domain, content_list in domain_groups.items():\n            if not content_list:\n                continue\n            # 只有cf的cookie过滤掉\n            cloudflare_cookie = True\n            for content in content_list:\n                if content[\"name\"] != \"cf_clearance\":\n                    cloudflare_cookie = False\n                    break\n            if cloudflare_cookie:\n                continue\n            # 站点Cookie\n            cookie_str = \";\".join(\n                [f\"{content.get('name')}={content.get('value')}\"\n                 for content in content_list\n                 if content.get(\"name\") and content.get(\"name\") not in self._ignore_cookies]\n            )\n            ret_cookies[domain] = cookie_str\n        return ret_cookies, \"\"\n\n    def __get_crypt_key(self) -> bytes:\n        \"\"\"\n        使用UUID和密码生成CookieCloud的加解密密钥\n        \"\"\"\n        combined_string = f\"{self._key}-{self._password}\"\n        return HashUtils.md5(combined_string)[:16].encode(\"utf-8\")\n\n    def __load_local_encrypt_data(self, uuid: str) -> Dict[str, Any]:\n        \"\"\"\n        获取本地CookieCloud数据\n        \"\"\"\n        file_path = self._local_path / f\"{uuid}.json\"\n        # 检查文件是否存在\n        if not file_path.exists():\n            logger.warn(f\"本地CookieCloud文件不存在：{file_path}\")\n            return {}\n\n        # 读取文件\n        with open(file_path, encoding=\"utf-8\", mode=\"r\") as file:\n            read_content = file.read()\n        data = json.loads(read_content.encode(\"utf-8\"))\n        return data\n"
  },
  {
    "path": "app/helper/directory.py",
    "content": "import re\nfrom pathlib import Path\nfrom typing import List, Optional, Tuple\n\nfrom app import schemas\nfrom app.core.context import MediaInfo\nfrom app.db.systemconfig_oper import SystemConfigOper\nfrom app.log import logger\nfrom app.schemas.types import SystemConfigKey\nfrom app.utils.system import SystemUtils\n\nJINJA2_VAR_PATTERN = re.compile(r\"\\{\\{.*?}}\", re.DOTALL)\n\n\nclass DirectoryHelper:\n    \"\"\"\n    下载目录/媒体库目录帮助类\n    \"\"\"\n\n    @staticmethod\n    def get_dirs() -> List[schemas.TransferDirectoryConf]:\n        \"\"\"\n        获取所有下载目录\n        \"\"\"\n        dir_confs: List[dict] = SystemConfigOper().get(SystemConfigKey.Directories)\n        if not dir_confs:\n            return []\n        return [schemas.TransferDirectoryConf(**d) for d in dir_confs]\n\n    def get_download_dirs(self) -> List[schemas.TransferDirectoryConf]:\n        \"\"\"\n        获取所有下载目录\n        \"\"\"\n        return sorted([d for d in self.get_dirs() if d.download_path], key=lambda x: x.priority)\n\n    def get_local_download_dirs(self) -> List[schemas.TransferDirectoryConf]:\n        \"\"\"\n        获取所有本地的可下载目录\n        \"\"\"\n        return [d for d in self.get_download_dirs() if d.storage == \"local\"]\n\n    def get_library_dirs(self) -> List[schemas.TransferDirectoryConf]:\n        \"\"\"\n        获取所有媒体库目录\n        \"\"\"\n        return sorted([d for d in self.get_dirs() if d.library_path], key=lambda x: x.priority)\n\n    def get_local_library_dirs(self) -> List[schemas.TransferDirectoryConf]:\n        \"\"\"\n        获取所有本地的媒体库目录\n        \"\"\"\n        return [d for d in self.get_library_dirs() if d.library_storage == \"local\"]\n\n    def get_dir(self, media: Optional[MediaInfo], include_unsorted: Optional[bool] = False,\n                storage: Optional[str] = None, src_path: Path = None,\n                target_storage: Optional[str] = None, dest_path: Path = None\n                ) -> Optional[schemas.TransferDirectoryConf]:\n        \"\"\"\n        根据媒体信息获取下载目录、媒体库目录配置\n        :param media: 媒体信息\n        :param include_unsorted: 包含不整理目录\n        :param storage: 源存储类型\n        :param target_storage: 目标存储类型\n        :param src_path: 源目录，有值时直接匹配\n        :param dest_path: 目标目录，有值时直接匹配\n        \"\"\"\n        # 电影/电视剧\n        media_type = media.type.value if media else None\n        dirs = self.get_dirs()\n\n        # 如果存在源目录，并源目录为任一下载目录的子目录时，则进行源目录匹配，否则，允许源目录按同盘优先的逻辑匹配\n        matching_dirs = [d for d in dirs if src_path.is_relative_to(d.download_path)] if src_path else []\n        # 根据是否有匹配的源目录，决定要考虑的目录集合\n        dirs_to_consider = matching_dirs if matching_dirs else dirs\n\n        # 已匹配的目录\n        matched_dirs: List[schemas.TransferDirectoryConf] = []\n        # 按照配置顺序查找\n        for d in dirs_to_consider:\n            # 没有启用整理的目录\n            if not d.monitor_type and not include_unsorted:\n                continue\n            # 源存储类型不匹配\n            if storage and d.storage != storage:\n                continue\n            # 目标存储类型不匹配\n            if target_storage and d.library_storage != target_storage:\n                continue\n            # 有目标目录时，目标目录不匹配媒体库目录\n            if dest_path and dest_path != Path(d.library_path):\n                continue\n            # 目录类型为全部的，符合条件\n            if not media_type or not d.media_type:\n                matched_dirs.append(d)\n                continue\n            # 目录类型相等，目录类别为全部，符合条件\n            if d.media_type == media_type and not d.media_category:\n                matched_dirs.append(d)\n                continue\n            # 目录类型相等，目录类别相等，符合条件\n            if d.media_type == media_type and d.media_category == media.category:\n                matched_dirs.append(d)\n                continue\n        if matched_dirs:\n            if src_path:\n                # 优先源目录同盘\n                for matched_dir in matched_dirs:\n                    matched_path = Path(matched_dir.download_path)\n                    if self._is_same_source((src_path, storage or \"local\"), (matched_path, matched_dir.library_storage)):\n                        return matched_dir\n            return matched_dirs[0]\n        return None\n\n    @staticmethod\n    def _is_same_source(src: Tuple[Path, str],  tar: Tuple[Path, str]) -> bool:\n        \"\"\"\n        判断源目录和目标目录是否在同一存储盘\n\n        :param src: 源目录路径和存储类型\n        :param tar: 目标目录路径和存储类型\n        :return: 是否在同一存储盘\n        \"\"\"\n        src_path, src_storage = src\n        tar_path, tar_storage = tar\n        if \"local\" == tar_storage == src_storage:\n            return SystemUtils.is_same_disk(src_path, tar_path)\n        # 网络存储，直接比较类型\n        return src_storage == tar_storage\n\n    @staticmethod\n    def get_media_root_path(rename_format: str, rename_path: Path) -> Optional[Path]:\n        \"\"\"\n        获取重命名后的媒体文件根路径\n\n        :param rename_format: 重命名格式\n        :param rename_path: 重命名后的路径\n        :return: 媒体文件根路径\n        \"\"\"\n        if not rename_format:\n            logger.error(\"重命名格式不能为空\")\n            return None\n        # 计算重命名中的文件夹层数\n        rename_list = rename_format.split(\"/\")\n        rename_format_level = len(rename_list) - 1\n        # 反向查找标题参数所在层\n        for level, name in enumerate(reversed(rename_list)):\n            if level == 0:\n                # 跳过文件名的标题参数\n                continue\n            matchs = JINJA2_VAR_PATTERN.findall(name)\n            if not matchs:\n                continue\n            # 处理特例，有的人重命名的第一层是年份、分辨率\n            if (any(\"title\" in m for m in matchs)\n                and not any(\"season\" in m for m in matchs)):\n                # 找出最后一层含有标题且不含季参数的目录作为媒体根目录\n                rename_format_level = level\n                break\n        else:\n            # 假定第一层目录是媒体根目录\n            logger.warn(f\"重命名格式 {rename_format} 缺少标题目录\")\n        if rename_format_level > len(rename_path.parents):\n            # 通常因为路径以/结尾，被Path规范化删除了\n            logger.error(f\"路径 {rename_path} 不匹配重命名格式 {rename_format}\")\n            return None\n        if rename_format_level <= 0:\n            # 所有媒体文件都存在一个目录内的特殊需求\n            rename_format_level = 1\n        # 媒体根路径\n        media_root = rename_path.parents[rename_format_level - 1]\n        return media_root\n"
  },
  {
    "path": "app/helper/display.py",
    "content": "from pyvirtualdisplay import Display\n\nfrom app.log import logger\nfrom app.utils.singleton import Singleton\nfrom app.utils.system import SystemUtils\n\nimport os\n\n\nclass DisplayHelper(metaclass=Singleton):\n\n    def __init__(self):\n        self._display = None\n        if not SystemUtils.is_docker():\n            return\n        try:\n            self._display = Display(visible=False, size=(1024, 768), extra_args=[os.environ['DISPLAY']])\n            self._display.start()\n        except Exception as err:\n            logger.error(f\"DisplayHelper init error: {str(err)}\")\n\n    def stop(self):\n        if self._display:\n            logger.info(\"正在停止虚拟显示...\")\n            self._display.stop()\n            logger.info(\"虚拟显示已停止\")\n"
  },
  {
    "path": "app/helper/doh.py",
    "content": "\"\"\"\ndoh函数的实现。\nauthor: https://github.com/C5H12O5/syno-videoinfo-plugin\n\"\"\"\nimport base64\nimport concurrent\nimport concurrent.futures\nimport json\nimport socket\nimport struct\nimport urllib\nimport urllib.request\nfrom threading import Lock\nfrom typing import Dict, Optional\n\nfrom app.core.config import settings\nfrom app.log import logger\nfrom app.utils.mixins import ConfigReloadMixin\nfrom app.utils.singleton import Singleton\n\n# 定义一个全局线程池执行器\n_executor = concurrent.futures.ThreadPoolExecutor()\n\n# 定义默认的DoH配置\n_doh_timeout = 5\n_doh_cache: Dict[str, str] = {}\n_doh_lock = Lock()\n# 保存原始的 socket.getaddrinfo 方法\n_orig_getaddrinfo = socket.getaddrinfo\n\n\ndef enable_doh(enable: bool):\n    \"\"\"\n    对 socket.getaddrinfo 进行补丁\n    \"\"\"\n\n    def _patched_getaddrinfo(host, *args, **kwargs):\n        \"\"\"\n        socket.getaddrinfo的补丁版本。\n        \"\"\"\n        if host not in settings.DOH_DOMAINS.split(\",\"):\n            return _orig_getaddrinfo(host, *args, **kwargs)\n        # 检查主机是否已解析\n        with _doh_lock:\n            ip = _doh_cache.get(\"host\", None)\n        if ip is not None:\n            logger.info(\"已解析 [%s] 为 [%s] (缓存)\", host, ip)\n            return _orig_getaddrinfo(ip, *args, **kwargs)\n        # 使用DoH解析主机\n        futures = []\n        for resolver in settings.DOH_RESOLVERS.split(\",\"):\n            futures.append(_executor.submit(_doh_query, resolver, host))\n        for future in concurrent.futures.as_completed(futures):\n            ip = future.result()\n            if ip is not None:\n                logger.info(\"已解析 [%s] 为 [%s]\", host, ip)\n                with _doh_lock:\n                    _doh_cache[host] = ip\n                host = ip\n                break\n        return _orig_getaddrinfo(host, *args, **kwargs)\n\n    if enable:\n        # 替换 socket.getaddrinfo 方法\n        socket.getaddrinfo = _patched_getaddrinfo\n    else:\n        socket.getaddrinfo = _orig_getaddrinfo\n\n\nclass DohHelper(ConfigReloadMixin, metaclass=Singleton):\n    \"\"\"\n    DoH帮助类，用于处理DNS over HTTPS解析。\n    \"\"\"\n    CONFIG_WATCH = {\"DOH_ENABLE\", \"DOH_DOMAINS\", \"DOH_RESOLVERS\"}\n\n    def __init__(self):\n        enable_doh(settings.DOH_ENABLE)\n\n    def on_config_changed(self):\n        with _doh_lock:\n            # DOH配置有变动的情况下，清空缓存\n            _doh_cache.clear()\n        enable_doh(settings.DOH_ENABLE)\n\n    def get_reload_name(self):\n        return 'DoH'\n\ndef _doh_query(resolver: str, host: str) -> Optional[str]:\n    \"\"\"\n    使用给定的DoH解析器查询给定主机的IP地址。\n    \"\"\"\n\n    # 构造DNS查询消息（RFC 1035）\n    header = b\"\".join(\n        [\n            b\"\\x00\\x00\",  # ID: 0\n            b\"\\x01\\x00\",  # FLAGS: 标准递归查询\n            b\"\\x00\\x01\",  # QDCOUNT: 1\n            b\"\\x00\\x00\",  # ANCOUNT: 0\n            b\"\\x00\\x00\",  # NSCOUNT: 0\n            b\"\\x00\\x00\",  # ARCOUNT: 0\n        ]\n    )\n    question = b\"\".join(\n        [\n            b\"\".join(\n                [\n                    struct.pack(\"B\", len(item)) + item.encode(\"utf-8\")\n                    for item in host.split(\".\")\n                ]\n            )\n            + b\"\\x00\",  # QNAME: 域名序列\n            b\"\\x00\\x01\",  # QTYPE: A\n            b\"\\x00\\x01\",  # QCLASS: IN\n        ]\n    )\n    message = header + question\n\n    try:\n        # 发送GET请求到DoH解析器（RFC 8484）\n        b64message = base64.b64encode(message).decode(\"utf-8\").rstrip(\"=\")\n        url = f\"https://{resolver}/dns-query?dns={b64message}\"\n        headers = {\"Content-Type\": \"application/dns-message\"}\n        logger.debug(\"DoH请求: %s\", url)\n\n        request = urllib.request.Request(url, headers=headers, method=\"GET\")\n        with urllib.request.urlopen(request, timeout=_doh_timeout) as response:\n            logger.debug(\"解析器(%s)响应: %s\", resolver, response.status)\n            if response.status != 200:\n                return None\n            resp_body = response.read()\n\n        # 解析DNS响应消息（RFC 1035）\n        # name（压缩）:2 + type:2 + class:2 + ttl:4 + rdlength:2 = 12字节\n        first_rdata_start = len(header) + len(question) + 12\n        # rdata（A记录）= 4字节\n        first_rdata_end = first_rdata_start + 4\n        # 将rdata转换为IP地址\n        return socket.inet_ntoa(resp_body[first_rdata_start:first_rdata_end])\n    except Exception as e:\n        logger.error(\"解析器(%s)请求错误: %s\", resolver, e)\n        return None\n\n\ndef doh_query_json(resolver: str, host: str) -> Optional[str]:\n    \"\"\"\n    使用给定的DoH解析器查询给定主机的IP地址。\n    \"\"\"\n    url = f\"https://{resolver}/dns-query?name={host}&type=A\"\n    headers = {\"Accept\": \"application/dns-json\"}\n    logger.debug(\"DoH请求: %s\", url)\n    try:\n        request = urllib.request.Request(url, headers=headers, method=\"GET\")\n        with urllib.request.urlopen(request, timeout=_doh_timeout) as response:\n            logger.debug(\"解析器(%s)响应: %s\", resolver, response.status)\n            if response.status != 200:\n                return None\n            response_body = response.read().decode(\"utf-8\")\n            logger.debug(\"<== body: %s\", response_body)\n            answer = json.loads(response_body)[\"Answer\"]\n            return answer[0][\"data\"]\n    except Exception as e:\n        logger.error(\"解析器(%s)请求错误: %s\", resolver, e)\n        return None\n"
  },
  {
    "path": "app/helper/downloader.py",
    "content": "from typing import Optional\n\nfrom app.helper.service import ServiceBaseHelper\nfrom app.schemas import DownloaderConf, ServiceInfo\nfrom app.schemas.types import SystemConfigKey, ModuleType\n\n\nclass DownloaderHelper(ServiceBaseHelper[DownloaderConf]):\n    \"\"\"\n    下载器帮助类\n    \"\"\"\n\n    def __init__(self):\n        super().__init__(\n            config_key=SystemConfigKey.Downloaders,\n            conf_type=DownloaderConf,\n            module_type=ModuleType.Downloader\n        )\n\n    def is_downloader(\n            self,\n            service_type: Optional[str] = None,\n            service: Optional[ServiceInfo] = None,\n            name: Optional[str] = None,\n    ) -> bool:\n        \"\"\"\n        通用的下载器类型判断方法\n        :param service_type: 下载器的类型名称（如 'qbittorrent', 'transmission', 'rtorrent'）\n        :param service: 要判断的服务信息\n        :param name: 服务的名称\n        :return: 如果服务类型或实例为指定类型，返回 True；否则返回 False\n        \"\"\"\n        # 如果未提供 service 则通过 name 获取服务\n        service = service or self.get_service(name=name)\n\n        # 判断服务类型是否为指定类型\n        return bool(service and service.type == service_type)\n"
  },
  {
    "path": "app/helper/format.py",
    "content": "import re\nfrom typing import Tuple, Optional\n\nimport parse\n\nfrom app.core.meta.metabase import MetaBase\n\n\nclass FormatParser(object):\n    _key = \"\"\n    _split_chars = r\"\\.|\\s+|\\(|\\)|\\[|]|-|\\+|【|】|/|～|;|&|\\||#|_|「|」|~\"\n\n    def __init__(self, eformat: str, details: Optional[str] = None, part: Optional[str] = None,\n                 offset: Optional[str] = None, key: Optional[str] = \"ep\"):\n        \"\"\"\n        :params eformat: 格式化字符串\n        :params details: 格式化详情\n        :params part: 分集\n        :params offset: 偏移量 -10/EP*2\n        :prams key: EP关键字\n        \"\"\"\n        self._format = eformat\n        self._start_ep = None\n        self._end_ep = None\n        if not offset:\n            self.__offset = \"EP\"\n        elif \"EP\" in offset:\n            self.__offset = offset\n        else:\n            if offset.startswith(\"-\") or offset.startswith(\"+\"):\n                self.__offset = f\"EP{offset}\"\n            else:\n                self.__offset = f\"EP+{offset}\"\n        self._key = key\n        self._part = None\n        if part:\n            self._part = part\n        if details:\n            if re.compile(\"\\\\d{1,4}-\\\\d{1,4}\").match(details):\n                self._start_ep = details\n                self._end_ep = details\n            else:\n                tmp = details.split(\",\")\n                if len(tmp) > 1:\n                    self._start_ep = int(tmp[0])\n                    self._end_ep = int(tmp[0]) if int(tmp[0]) > int(tmp[1]) else int(tmp[1])\n                else:\n                    self._start_ep = self._end_ep = int(tmp[0])\n\n    @property\n    def format(self):\n        return self._format\n\n    @property\n    def start_ep(self):\n        return self._start_ep\n\n    @property\n    def end_ep(self):\n        return self._end_ep\n\n    @property\n    def part(self):\n        return self._part\n\n    @property\n    def offset(self):\n        return self.__offset\n\n    def match(self, file: str) -> bool:\n        if not self._format:\n            return True\n        s, e = self.__handle_single(file)\n        if not s:\n            return False\n        if self._start_ep is None:\n            return True\n        if self._start_ep <= s <= self._end_ep:\n            return True\n        return False\n\n    def split_episode(self, file_name: str, file_meta: MetaBase) -> Tuple[Optional[int], Optional[int], Optional[str]]:\n        \"\"\"\n        拆分集数，返回开始集数，结束集数，Part信息\n        \"\"\"\n        # 指定的具体集数，直接返回\n        if self._start_ep is not None:\n            if self._start_ep == self._end_ep:\n                # `details` 格式为 `X-X` 或者 `X`\n                if isinstance(self._start_ep, str):\n                    # `details` 格式为 `X-X`\n                    s, e = self._start_ep.split(\"-\")\n                    start_ep = self.__offset.replace(\"EP\", s)\n                    end_ep = self.__offset.replace(\"EP\", e)\n                    if int(s) == int(e):\n                        return int(eval(start_ep)), None, self.part\n                    return int(eval(start_ep)), int(eval(end_ep)), self.part\n                else:\n                    # `details` 格式为 `X`\n                    start_ep = self.__offset.replace(\"EP\", str(self._start_ep))\n                    return int(eval(start_ep)), None, self.part\n            elif not self._format:\n                # `details` 格式为 `X,X`\n                start_ep = self.__offset.replace(\"EP\", str(self._start_ep))\n                end_ep = self.__offset.replace(\"EP\", str(self._end_ep))\n                return int(eval(start_ep)), int(eval(end_ep)), self.part\n        if not self._format:\n            # 未填入`集数定位` 且没有`指定集数` 仅处理`集数偏移`\n            start_ep = eval(self.__offset.replace(\"EP\", str(file_meta.begin_episode))) if file_meta.begin_episode else None\n            end_ep = eval(self.__offset.replace(\"EP\", str(file_meta.end_episode))) if file_meta.end_episode else None\n            return int(start_ep) if start_ep else None, int(end_ep) if end_ep else None, self.part\n        else:\n            # 有`集数定位`\n            s, e = self.__handle_single(file_name)\n            start_ep = self.__offset.replace(\"EP\", str(s)) if s else None\n            end_ep = self.__offset.replace(\"EP\", str(e)) if e else None\n            return int(eval(start_ep)) if start_ep else None, int(eval(end_ep)) if end_ep else None, self.part\n\n    def __handle_single(self, file: str) -> Tuple[Optional[int], Optional[int]]:\n        \"\"\"\n        处理单集，返回单集的开始和结束集数\n        \"\"\"\n        if not self._format:\n            return None, None\n        ret = parse.parse(self._format, file)\n        if not ret or not ret.__contains__(self._key):\n            return None, None\n        episodes = ret.__getitem__(self._key)\n        if not re.compile(r\"^(EP)?(\\d{1,4})(-(EP)?(\\d{1,4}))?$\", re.IGNORECASE).match(episodes):\n            return None, None\n        episode_splits = list(filter(lambda x: re.compile(r'[a-zA-Z]*\\d{1,4}', re.IGNORECASE).match(x),\n                                     re.split(r'%s' % self._split_chars, episodes)))\n        if len(episode_splits) == 1:\n            return int(re.compile(r'[a-zA-Z]*', re.IGNORECASE).sub(\"\", episode_splits[0])), None\n        else:\n            return int(re.compile(r'[a-zA-Z]*', re.IGNORECASE).sub(\"\", episode_splits[0])), int(\n                re.compile(r'[a-zA-Z]*', re.IGNORECASE).sub(\"\", episode_splits[1]))\n"
  },
  {
    "path": "app/helper/image.py",
    "content": "import io\nfrom pathlib import Path\nfrom typing import Optional, List\n\nfrom PIL import Image\n\nfrom app.chain.mediaserver import MediaServerChain\nfrom app.chain.tmdb import TmdbChain\nfrom app.core.cache import cached, FileCache, AsyncFileCache\nfrom app.core.config import settings\nfrom app.log import logger\nfrom app.utils.http import RequestUtils, AsyncRequestUtils\nfrom app.utils.ip import IpUtils\nfrom app.utils.security import SecurityUtils\nfrom app.utils.singleton import Singleton\n\n\nclass WallpaperHelper(metaclass=Singleton):\n    \"\"\"\n    壁纸帮助类\n    \"\"\"\n\n    def get_wallpaper(self) -> Optional[str]:\n        \"\"\"\n        获取登录页面壁纸\n        \"\"\"\n        if settings.WALLPAPER == \"bing\":\n            return self.get_bing_wallpaper()\n        elif settings.WALLPAPER == \"mediaserver\":\n            return self.get_mediaserver_wallpaper()\n        elif settings.WALLPAPER == \"customize\":\n            return self.get_customize_wallpaper()\n        elif settings.WALLPAPER == \"tmdb\":\n            return self.get_tmdb_wallpaper()\n        return ''\n\n    def get_wallpapers(self, num: int = 10) -> List[str]:\n        \"\"\"\n        获取登录页面壁纸列表\n        \"\"\"\n        if settings.WALLPAPER == \"bing\":\n            return self.get_bing_wallpapers(num)\n        elif settings.WALLPAPER == \"mediaserver\":\n            return self.get_mediaserver_wallpapers(num)\n        elif settings.WALLPAPER == \"customize\":\n            return self.get_customize_wallpapers()\n        elif settings.WALLPAPER == \"tmdb\":\n            return self.get_tmdb_wallpapers(num)\n        return []\n\n    @cached(maxsize=1, ttl=3600)\n    def get_tmdb_wallpaper(self) -> Optional[str]:\n        \"\"\"\n        获取TMDB每日壁纸\n        \"\"\"\n        return TmdbChain().get_random_wallpager()\n\n    @cached(maxsize=1, ttl=3600, skip_empty=True)\n    def get_tmdb_wallpapers(self, num: int = 10) -> List[str]:\n        \"\"\"\n        获取7天的TMDB每日壁纸\n        \"\"\"\n        return TmdbChain().get_trending_wallpapers(num)\n\n    @cached(maxsize=1, ttl=3600)\n    def get_bing_wallpaper(self) -> Optional[str]:\n        \"\"\"\n        获取Bing每日壁纸\n        \"\"\"\n        url = \"https://cn.bing.com/HPImageArchive.aspx?format=js&idx=0&n=1\"\n        resp = RequestUtils(timeout=5).get_res(url)\n        if resp and resp.status_code == 200:\n            try:\n                result = resp.json()\n                if isinstance(result, dict):\n                    for image in result.get('images') or []:\n                        return f\"https://cn.bing.com{image.get('url')}\" if 'url' in image else ''\n            except Exception as err:\n                print(str(err))\n        return None\n\n    @cached(maxsize=1, ttl=3600, skip_empty=True)\n    def get_bing_wallpapers(self, num: int = 7) -> List[str]:\n        \"\"\"\n        获取7天的Bing每日壁纸\n        \"\"\"\n        url = f\"https://cn.bing.com/HPImageArchive.aspx?format=js&idx=0&n={num}\"\n        resp = RequestUtils(timeout=5).get_res(url)\n        if resp and resp.status_code == 200:\n            try:\n                result = resp.json()\n                if isinstance(result, dict):\n                    return [f\"https://cn.bing.com{image.get('url')}\" for image in result.get('images') or []]\n            except Exception as err:\n                print(str(err))\n        return []\n\n    @cached(maxsize=1, ttl=3600)\n    def get_mediaserver_wallpaper(self) -> Optional[str]:\n        \"\"\"\n        获取媒体服务器壁纸\n        \"\"\"\n        return MediaServerChain().get_latest_wallpaper()\n\n    @cached(maxsize=1, ttl=3600, skip_empty=True)\n    def get_mediaserver_wallpapers(self, num: int = 10) -> List[str]:\n        \"\"\"\n        获取媒体服务器壁纸列表\n        \"\"\"\n        return MediaServerChain().get_latest_wallpapers(count=num)\n\n    @cached(maxsize=1, ttl=3600)\n    def get_customize_wallpaper(self) -> Optional[str]:\n        \"\"\"\n        获取自定义壁纸api壁纸\n        \"\"\"\n        wallpaper_list = self.get_customize_wallpapers()\n        if wallpaper_list:\n            return wallpaper_list[0]\n        return None\n\n    @cached(maxsize=1, ttl=3600, skip_empty=True)\n    def get_customize_wallpapers(self) -> List[str]:\n        \"\"\"\n        获取自定义壁纸api壁纸\n        \"\"\"\n\n        def find_files_with_suffixes(obj, suffixes: List[str]) -> List[str]:\n            \"\"\"\n            递归查找对象中所有包含特定后缀的文件，返回匹配的字符串列表\n            支持输入：字典、列表、字符串\n            \"\"\"\n            _result = []\n\n            # 处理字符串\n            if isinstance(obj, str):\n                if obj.endswith(tuple(suffixes)):\n                    _result.append(obj)\n\n            # 处理字典\n            elif isinstance(obj, dict):\n                for value in obj.values():\n                    _result.extend(find_files_with_suffixes(value, suffixes))\n\n            # 处理列表\n            elif isinstance(obj, list):\n                for item in obj:\n                    _result.extend(find_files_with_suffixes(item, suffixes))\n\n            return _result\n\n        # 判断是否存在自定义壁纸api\n        if settings.CUSTOMIZE_WALLPAPER_API_URL:\n            wallpaper_list = []\n            resp = RequestUtils(timeout=15).get_res(settings.CUSTOMIZE_WALLPAPER_API_URL)\n            if resp and resp.status_code == 200:\n                # 如果返回的是图片格式\n                content_type = resp.headers.get('Content-Type')\n                if content_type and content_type.lower().startswith('image/'):\n                    wallpaper_list.append(settings.CUSTOMIZE_WALLPAPER_API_URL)\n                else:\n                    try:\n                        result = resp.json()\n                        if isinstance(result, list) or isinstance(result, dict) or isinstance(result, str):\n                            wallpaper_list = find_files_with_suffixes(result, settings.SECURITY_IMAGE_SUFFIXES)\n                    except Exception as err:\n                        print(str(err))\n            return wallpaper_list\n        else:\n            return []\n\n\nclass ImageHelper(metaclass=Singleton):\n\n    def __init__(self):\n        _base_path = settings.CACHE_PATH\n        _ttl = settings.GLOBAL_IMAGE_CACHE_DAYS * 24 * 3600\n        self.file_cache = FileCache(base=_base_path, ttl=_ttl)\n        self.async_file_cache = AsyncFileCache(base=_base_path, ttl=_ttl)\n\n    @staticmethod\n    def _prepare_cache_path(url: str) -> str:\n        \"\"\"缓存路径\"\"\"\n        sanitized_path = SecurityUtils.sanitize_url_path(url)\n        cache_path = Path(sanitized_path)\n        if not cache_path.suffix:\n            cache_path = cache_path.with_suffix(\".jpg\")\n        return cache_path.as_posix()\n\n    @staticmethod\n    def _validate_image(content: bytes) -> bool:\n        \"\"\"验证图片\"\"\"\n        if not content:\n            return False\n        try:\n            Image.open(io.BytesIO(content)).verify()\n            return True\n        except Exception as e:\n            logger.warn(f\"Invalid image format: {e}\")\n            return False\n\n    @staticmethod\n    def _get_request_params(url: str, proxy: Optional[bool], cookies: Optional[str | dict]) -> dict:\n        \"\"\"获取参数\"\"\"\n        referer = \"https://movie.douban.com/\" if \"doubanio.com\" in url else None\n        if proxy is None:\n            proxies = settings.PROXY if not (referer or IpUtils.is_internal(url)) else None\n        else:\n            proxies = settings.PROXY if proxy else None\n        return {\n            \"ua\": settings.NORMAL_USER_AGENT,\n            \"proxies\": proxies,\n            \"referer\": referer,\n            \"cookies\": cookies,\n            \"accept_type\": \"image/avif,image/webp,image/apng,*/*\",\n        }\n\n    def fetch_image(\n        self,\n        url: str,\n        proxy: Optional[bool] = None,\n        use_cache: bool = True,\n        cookies: Optional[str | dict] = None) -> Optional[bytes]:\n        \"\"\"\n        获取图片（同步版本）\n        \"\"\"\n        if not url:\n            return None\n\n        cache_path = self._prepare_cache_path(url)\n\n        # 检查缓存\n        if use_cache:\n            content = self.file_cache.get(cache_path, region=\"images\")\n            if content:\n                return content\n\n        # 请求远程图片\n        params = self._get_request_params(url, proxy, cookies)\n        response = RequestUtils(**params).get_res(url=url)\n        if not response:\n            logger.warn(f\"Failed to fetch image from URL: {url}\")\n            return None\n\n        content = response.content\n        # 验证图片\n        if not self._validate_image(content):\n            return None\n\n        # 保存缓存\n        self.file_cache.set(cache_path, content, region=\"images\")\n        return content\n\n    async def async_fetch_image(\n        self,\n        url: str,\n        proxy: Optional[bool] = None,\n        use_cache: bool = True,\n        cookies: Optional[str | dict] = None) -> Optional[bytes]:\n        \"\"\"\n        获取图片（异步版本）\n        \"\"\"\n        if not url:\n            return None\n\n        cache_path = self._prepare_cache_path(url)\n\n        # 检查缓存\n        if use_cache:\n            content = await self.async_file_cache.get(cache_path, region=\"images\")\n            if content:\n                return content\n\n        # 请求远程图片\n        params = self._get_request_params(url, proxy, cookies)\n        response = await AsyncRequestUtils(**params).get_res(url=url)\n        if not response:\n            logger.warn(f\"Failed to fetch image from URL: {url}\")\n            return None\n\n        content = response.content\n        # 验证图片\n        if not self._validate_image(content):\n            return None\n\n        # 保存缓存\n        await self.async_file_cache.set(cache_path, content, region=\"images\")\n        return content\n"
  },
  {
    "path": "app/helper/llm.py",
    "content": "\"\"\"LLM模型相关辅助功能\"\"\"\nfrom typing import List, Optional\n\nfrom app.core.config import settings\nfrom app.log import logger\n\n\nclass LLMHelper:\n    \"\"\"LLM模型相关辅助功能\"\"\"\n\n    @staticmethod\n    def get_llm(streaming: bool = False, callbacks: Optional[list] = None):\n        \"\"\"\n        获取LLM实例\n        :param streaming: 是否启用流式输出\n        :param callbacks: 回调处理器列表\n        :return: LLM实例\n        \"\"\"\n        provider = settings.LLM_PROVIDER.lower()\n        api_key = settings.LLM_API_KEY\n\n        if not api_key:\n            raise ValueError(\"未配置LLM API Key\")\n\n        if provider == \"google\":\n            if settings.PROXY_HOST:\n                from langchain_openai import ChatOpenAI\n                return ChatOpenAI(\n                    model=settings.LLM_MODEL,\n                    api_key=api_key,\n                    max_retries=3,\n                    base_url=\"https://generativelanguage.googleapis.com/v1beta/openai\",\n                    temperature=settings.LLM_TEMPERATURE,\n                    streaming=streaming,\n                    callbacks=callbacks,\n                    stream_usage=True,\n                    openai_proxy=settings.PROXY_HOST\n                )\n            else:\n                from langchain_google_genai import ChatGoogleGenerativeAI\n                return ChatGoogleGenerativeAI(\n                    model=settings.LLM_MODEL,\n                    google_api_key=api_key,\n                    max_retries=3,\n                    temperature=settings.LLM_TEMPERATURE,\n                    streaming=streaming,\n                    callbacks=callbacks\n                )\n        elif provider == \"deepseek\":\n            from langchain_deepseek import ChatDeepSeek\n            return ChatDeepSeek(\n                model=settings.LLM_MODEL,\n                api_key=api_key,\n                max_retries=3,\n                temperature=settings.LLM_TEMPERATURE,\n                streaming=streaming,\n                callbacks=callbacks,\n                stream_usage=True\n            )\n        else:\n            from langchain_openai import ChatOpenAI\n            return ChatOpenAI(\n                model=settings.LLM_MODEL,\n                api_key=api_key,\n                max_retries=3,\n                base_url=settings.LLM_BASE_URL,\n                temperature=settings.LLM_TEMPERATURE,\n                streaming=streaming,\n                callbacks=callbacks,\n                stream_usage=True,\n                openai_proxy=settings.PROXY_HOST\n            )\n\n    def get_models(self, provider: str, api_key: str, base_url: str = None) -> List[str]:\n        \"\"\"获取模型列表\"\"\"\n        logger.info(f\"获取 {provider} 模型列表...\")\n        if provider == \"google\":\n            return self._get_google_models(api_key)\n        else:\n            return self._get_openai_compatible_models(provider, api_key, base_url)\n\n    @staticmethod\n    def _get_google_models(api_key: str) -> List[str]:\n        \"\"\"获取Google模型列表\"\"\"\n        try:\n            import google.generativeai as genai\n            genai.configure(api_key=api_key)\n            models = genai.list_models()\n            return [m.name for m in models if 'generateContent' in m.supported_generation_methods]\n        except Exception as e:\n            logger.error(f\"获取Google模型列表失败：{e}\")\n            raise e\n\n    @staticmethod\n    def _get_openai_compatible_models(provider: str, api_key: str, base_url: str = None) -> List[str]:\n        \"\"\"获取OpenAI兼容模型列表\"\"\"\n        try:\n            from openai import OpenAI\n\n            if provider == \"deepseek\":\n                base_url = base_url or \"https://api.deepseek.com\"\n\n            client = OpenAI(api_key=api_key, base_url=base_url)\n            models = client.models.list()\n            return [model.id for model in models.data]\n        except Exception as e:\n            logger.error(f\"获取 {provider} 模型列表失败：{e}\")\n            raise e\n"
  },
  {
    "path": "app/helper/mediaserver.py",
    "content": "from typing import Optional\n\nfrom app.helper.service import ServiceBaseHelper\nfrom app.schemas import MediaServerConf, ServiceInfo\nfrom app.schemas.types import SystemConfigKey, ModuleType\n\n\nclass MediaServerHelper(ServiceBaseHelper[MediaServerConf]):\n    \"\"\"\n    媒体服务器帮助类\n    \"\"\"\n\n    def __init__(self):\n        super().__init__(\n            config_key=SystemConfigKey.MediaServers,\n            conf_type=MediaServerConf,\n            module_type=ModuleType.MediaServer\n        )\n\n    def is_media_server(\n            self,\n            service_type: Optional[str] = None,\n            service: Optional[ServiceInfo] = None,\n            name: Optional[str] = None,\n    ) -> bool:\n        \"\"\"\n        通用的媒体服务器类型判断方法\n        :param service_type: 媒体服务器的类型名称（如 'plex', 'emby', 'jellyfin'）\n        :param service: 要判断的服务信息\n        :param name: 服务的名称\n        :return: 如果服务类型或实例为指定类型，返回 True；否则返回 False\n        \"\"\"\n        # 如果未提供 service 则通过 name 获取服务\n        service = service or self.get_service(name=name)\n\n        # 判断服务类型是否为指定类型\n        return bool(service and service.type == service_type)\n"
  },
  {
    "path": "app/helper/message.py",
    "content": "from __future__ import annotations\n\nimport ast\nimport json\nimport queue\nimport re\nimport threading\nimport time\nfrom datetime import datetime\nfrom typing import Any, Literal, Optional, List, Dict, Union\nfrom typing import Callable\n\nfrom jinja2 import Template\n\nfrom app.core.cache import TTLCache\nfrom app.core.config import global_vars\nfrom app.core.context import MediaInfo, TorrentInfo\nfrom app.core.meta import MetaBase\nfrom app.db.systemconfig_oper import SystemConfigOper\nfrom app.log import logger\nfrom app.schemas.message import Notification\nfrom app.schemas.tmdb import TmdbEpisode\nfrom app.schemas.transfer import TransferInfo\nfrom app.schemas.types import SystemConfigKey\nfrom app.utils.singleton import Singleton, SingletonClass\nfrom app.utils.string import StringUtils\n\n\nclass TemplateContextBuilder:\n    \"\"\"\n    模板上下文构建器\n    \"\"\"\n\n    def __init__(self):\n        self._context = {}\n\n    def build(\n            self,\n            meta: Optional[MetaBase] = None,\n            mediainfo: Optional[MediaInfo] = None,\n            torrentinfo: Optional[TorrentInfo] = None,\n            transferinfo: Optional[TransferInfo] = None,\n            file_extension: Optional[str] = None,\n            episodes_info: Optional[List[TmdbEpisode]] = None,\n            include_raw_objects: bool = True,\n            **kwargs\n    ) -> Dict[str, Any]:\n        \"\"\"\n        :param meta: 媒体信息\n        :param mediainfo: 媒体信息\n        :param torrentinfo: 种子信息\n        :param transferinfo: 传输信息\n        :param file_extension: 文件扩展名\n        :param episodes_info: 剧集信息\n        :param include_raw_objects: 是否包含原始对象\n        :return: 渲染上下文字典\n        \"\"\"\n        self._context.clear()\n        self._add_episode_details(meta, episodes_info)\n        self._add_media_info(mediainfo)\n        self._add_transfer_info(transferinfo)\n        self._add_torrent_info(torrentinfo)\n        self._add_file_info(file_extension)\n        if kwargs:\n            self._context.update(kwargs)\n\n        if include_raw_objects:\n            self._add_raw_objects(meta, mediainfo, torrentinfo, transferinfo, episodes_info)\n\n        # 移除空值\n        return {k: v for k, v in self._context.items() if v is not None}\n\n    def _add_media_info(self, mediainfo: MediaInfo):\n        \"\"\"\n        增加媒体信息\n        \"\"\"\n        if not mediainfo:\n            return\n        season_fmt = f\"S{mediainfo.season:02d}\" if mediainfo.season is not None else None\n        base_info = {\n            # 标题\n            \"title\": self.__convert_invalid_characters(mediainfo.title),\n            # 英文标题\n            \"en_title\": self.__convert_invalid_characters(mediainfo.en_title),\n            # 原语种标题\n            \"original_title\": self.__convert_invalid_characters(mediainfo.original_title),\n            # 季号\n            \"season\": self._context.get(\"season\") or mediainfo.season,\n            # Sxx\n            \"season_fmt\": self._context.get(\"season_fmt\") or season_fmt,\n            # 年份\n            \"year\": mediainfo.year or self._context.get(\"year\"),\n            # 媒体标题 + 年份\n            \"title_year\": mediainfo.title_year or self._context.get(\"title_year\"),\n        }\n\n        _meta_season = self._context.get(\"season\")\n        media_info = {\n            # 类型\n            \"type\": mediainfo.type.value,\n            # 类别\n            \"category\": mediainfo.category,\n            # 评分\n            \"vote_average\": mediainfo.vote_average,\n            # 海报\n            \"poster\": mediainfo.get_poster_image(),\n            # 背景图\n            \"backdrop\": mediainfo.get_backdrop_image(),\n            # 季年份根据season值获取\n            \"season_year\": mediainfo.season_years.get(\n                int(_meta_season),\n                None) if (mediainfo.season_years and _meta_season) else None,\n            # 演员\n            \"actors\": '、 '.join([actor['name'] for actor in mediainfo.actors[:5]]),\n            # 简介\n            \"overview\": mediainfo.overview,\n            # TMDBID\n            \"tmdbid\": mediainfo.tmdb_id,\n            # IMDBID\n            \"imdbid\": mediainfo.imdb_id,\n            # 豆瓣ID\n            \"doubanid\": mediainfo.douban_id,\n        }\n        self._context.update({**base_info, **media_info})\n\n    def _add_episode_details(self, meta: Optional[MetaBase], episodes: Optional[List[TmdbEpisode]]):\n        \"\"\"\n        添加剧集详细信息\n        \"\"\"\n        if not meta:\n            return\n\n        episode_data = {\"episode_title\": None, \"episode_date\": None}\n        if meta.begin_episode and episodes:\n            for episode in episodes:\n                if episode.episode_number == meta.begin_episode:\n                    episode_data.update({\n                        \"episode_title\": self.__convert_invalid_characters(episode.name),\n                        \"episode_date\": episode.air_date if episode.air_date else None\n                    })\n                    break\n\n        meta_info = {\n            # 原文件名\n            \"original_name\": meta.title,\n            # 识别名称（优先使用中文）\n            \"name\": meta.name,\n            # 识别的英文名称（可能为空）\n            \"en_name\": meta.en_name,\n            # 年份\n            \"year\": meta.year,\n            # 名字 + 年份\n            \"title_year\": self._context.get(\"title_year\") or \"%s (%s)\" % (\n                meta.name, meta.year) if meta.year else meta.name,\n            # 季号\n            \"season\": meta.season_seq,\n            # Sxx\n            \"season_fmt\": meta.season,\n            # 集号\n            \"episode\": meta.episode_seqs,\n            # 季集 SxxExx\n            \"season_episode\": \"%s%s\" % (meta.season, meta.episode),\n            # 段/节\n            \"part\": meta.part,\n            # 自定义占位符\n            \"customization\": meta.customization,\n            # fps\n            \"fps\": meta.fps,\n        }\n\n        tech_metadata = {\n            # 资源类型\n            \"resourceType\": meta.resource_type,\n            # 特效\n            \"effect\": meta.resource_effect,\n            # 版本\n            \"edition\": meta.edition,\n            # 分辨率\n            \"videoFormat\": meta.resource_pix,\n            # 质量\n            \"resource_term\": meta.resource_term,\n            # 制作组/字幕组\n            \"releaseGroup\": meta.resource_team,\n            # 视频编码\n            \"videoCodec\": meta.video_encode,\n            # 音频编码\n            \"audioCodec\": meta.audio_encode,\n            # 流媒体平台\n            \"webSource\": meta.web_source,\n        }\n        self._context.update({**meta_info, **tech_metadata, **episode_data})\n\n    def _add_torrent_info(self, torrentinfo: Optional[TorrentInfo]):\n        \"\"\"\n        添加种子信息\n        \"\"\"\n        if not torrentinfo:\n            return\n        if torrentinfo.size:\n            if str(torrentinfo.size).replace(\".\", \"\").isdigit():\n                size = StringUtils.str_filesize(torrentinfo.size)\n            else:\n                size = torrentinfo.size\n        else:\n            size = 0\n\n        if torrentinfo.description:\n            html_re = re.compile(r'<[^>]+>', re.S)\n            description = html_re.sub('', torrentinfo.description)\n            torrentinfo.description = re.sub(r'<[^>]+>', '', description)\n\n        torrent_info = {\n            # 种子标题\n            \"torrent_title\": torrentinfo.title,\n            # 发布时间\n            \"pubdate\": torrentinfo.pubdate,\n            # 免费剩余时间\n            \"freedate\": torrentinfo.freedate_diff,\n            # 做种数\n            \"seeders\": torrentinfo.seeders,\n            # 促销信息\n            \"volume_factor\": torrentinfo.volume_factor,\n            # Hit&Run\n            \"hit_and_run\": \"是\" if torrentinfo.hit_and_run else \"否\",\n            # 种子标签\n            \"labels\": ' '.join(torrentinfo.labels),\n            # 描述\n            \"description\": torrentinfo.description,\n            # 站点名称\n            \"site_name\": torrentinfo.site_name,\n            # 种子大小\n            \"size\": size,\n        }\n        self._context.update(torrent_info)\n\n    def _add_transfer_info(self, transferinfo: Optional[TransferInfo]) -> Optional[Dict]:\n        \"\"\"\n        添加文件转移上下文\n        \"\"\"\n        if not transferinfo:\n            return None\n        ctx = {\n            \"transfer_type\": transferinfo.transfer_type,\n            \"file_count\": transferinfo.file_count,\n            \"total_size\": StringUtils.str_filesize(transferinfo.total_size),\n            \"err_msg\": transferinfo.message,\n        }\n        return self._context.update(ctx)\n\n    def _add_file_info(self, file_extension: Optional[str]):\n        \"\"\"\n        添加文件信息\n        \"\"\"\n        if not file_extension:\n            return\n        file_info = {\n            # 文件后缀\n            \"fileExt\": file_extension,\n        }\n        self._context.update(file_info)\n\n    def _add_raw_objects(\n            self,\n            meta: Optional[MetaBase],\n            mediainfo: Optional[MediaInfo],\n            torrentinfo: Optional[TorrentInfo],\n            transferinfo: Optional[TransferInfo],\n            episodes_info: Optional[List[TmdbEpisode]],\n    ):\n        \"\"\"\n        添加原始对象引用\n        \"\"\"\n        raw_objects = {\n            # 文件元数据\n            \"__meta__\": meta,\n            # 识别的媒体信息\n            \"__mediainfo__\": mediainfo,\n            # 种子信息\n            \"__torrentinfo__\": torrentinfo,\n            # 文件转移信息\n            \"__transferinfo__\": transferinfo,\n            # 当前季的全部集信息\n            \"__episodes_info__\": episodes_info,\n        }\n        self._context.update(raw_objects)\n\n    @staticmethod\n    def __convert_invalid_characters(filename: str):\n        \"\"\"\n        将不支持的字符转换为全角字符\n        \"\"\"\n        if not filename:\n            return filename\n        invalid_characters = r'\\/:*?\"<>|'\n        # 创建半角到全角字符的转换表\n        halfwidth_chars = \"\".join([chr(i) for i in range(33, 127)])\n        fullwidth_chars = \"\".join([chr(i + 0xFEE0) for i in range(33, 127)])\n        translation_table = str.maketrans(halfwidth_chars, fullwidth_chars)\n        # 将不支持的字符替换为对应的全角字符\n        for char in invalid_characters:\n            filename = filename.replace(char, char.translate(translation_table))\n        return filename\n\n\nclass TemplateHelper(metaclass=SingletonClass):\n    \"\"\"\n    模板格式渲染帮助类\n    \"\"\"\n\n    def __init__(self):\n        self.builder = TemplateContextBuilder()\n        self.cache = TTLCache(region=\"notification\", maxsize=100, ttl=600)\n\n    @staticmethod\n    def _generate_cache_key(cuntent: Union[str, dict]) -> str:\n        \"\"\"\n        生成缓存键\n        \"\"\"\n        if isinstance(cuntent, dict):\n            base_str = cuntent.get(\"title\", '') + cuntent.get(\"text\", '')\n            return StringUtils.md5_hash(json.dumps(base_str, sort_keys=True, ensure_ascii=False))\n\n        return StringUtils.md5_hash(cuntent)\n\n    def get_cache_context(self, cuntent: Union[str, dict]) -> Optional[dict]:\n        \"\"\"\n        获取缓存上下文\n        \"\"\"\n        cache_key = self._generate_cache_key(cuntent)\n        return self.cache.get(cache_key)\n\n    def set_cache_context(self, cuntent: Union[str, dict], context: dict) -> None:\n        \"\"\"\n        设置缓存上下文\n        \"\"\"\n        cache_key = self._generate_cache_key(cuntent)\n        self.cache[cache_key] = context\n\n    def render(self,\n               template_content: str,\n               template_type: Literal['string', 'dict', 'literal'] = \"literal\",\n               **kwargs) -> Optional[Union[str, dict]]:\n        \"\"\"\n        根据模板格式渲染内容\n        :param template_content: 模板字符串\n        :param template_type: 模板字符串类型(消息通知`literal`, 路径`string`)\n        :param kwargs: 补传业务对象\n        :raises ValueError: 当模板处理过程中出现错误\n        :return: 渲染后的结果\n        \"\"\"\n        try:\n            # 解析模板字符\n            parsed = self.parse_template_content(template_content, template_type)\n            if not parsed:\n                raise ValueError(\"模板解析失败\")\n\n            context = self.builder.build(**kwargs)\n            if not context:\n                raise ValueError(\"上下文构建失败\")\n\n            rendered = self.render_with_context(parsed, context)\n            if not rendered:\n                raise ValueError(\"模板渲染失败\")\n\n            if rendered := rendered if template_type == 'string' else self.__process_formatted_string(rendered):\n                # 缓存上下文\n                self.set_cache_context(rendered, context)\n                # 返回渲染结果\n                return rendered\n            return None\n        except Exception as e:\n            raise ValueError(f\"模板处理失败: {str(e)}\") from e\n\n    @staticmethod\n    def render_with_context(template_content: str, context: dict) -> str:\n        \"\"\"\n        使用指定上下文渲染 Jinja2 模板字符串\n        template_content: Jinja2 模板字符串\n        context: 渲染用的上下文数据\n        \"\"\"\n        # 渲染模板\n        template = Template(template_content)\n        return template.render(context)\n\n    @staticmethod\n    def parse_template_content(template_content: Union[str, dict],\n                               template_type: Literal['string', 'dict', 'literal'] = None) -> Optional[str]:\n        \"\"\"\n        解析模板字符\n        :param template_content 模板格式字符\n        :param template_type 模板字符类型\n        \"\"\"\n\n        def parse_literal(_template_content: str) -> str:\n            \"\"\"\n            解析Python字面量\n            \"\"\"\n            try:\n                template_dict = ast.literal_eval(_template_content) if isinstance(_template_content,\n                                                                                  str) else _template_content\n                if not isinstance(template_dict, dict):\n                    raise ValueError(\"解析结果必须是一个字典\")\n                return json.dumps(template_dict, ensure_ascii=False)\n            except (ValueError, SyntaxError) as err:\n                raise ValueError(f\"无效的Python字面量格式: {str(err)}\")\n\n        try:\n            if template_type:\n                parse_map = {\n                    'string': lambda x: str(x),\n                    'dict': lambda x: json.dumps(x, ensure_ascii=False),\n                    'literal': parse_literal\n                }\n                return parse_map[template_type](template_content)\n\n            # 自动判断模板类型\n            if isinstance(template_content, dict):\n                return json.dumps(template_content, ensure_ascii=False)\n            elif isinstance(template_content, str):\n                try:\n                    json.loads(template_content)\n                    return template_content\n                except json.JSONDecodeError:\n                    try:\n                        return parse_literal(template_content)\n                    except (ValueError, SyntaxError):\n                        return template_content\n            else:\n                raise ValueError(f\"不支持的模板类型: {type(template_content)}\")\n\n        except Exception as e:\n            logger.error(f\"模板解析失败: {str(e)}\")\n            return None\n\n    @staticmethod\n    def __process_formatted_string(rendered: str) -> Optional[Union[dict, str]]:\n        \"\"\"\n        处理格式化字符串\n        保留转义字符\n        \"\"\"\n\n        def restore_chars(obj: Any) -> Any:\n            \"\"\"恢复特殊字符\"\"\"\n            if isinstance(obj, str):\n                return obj.replace('\\\\n', '\\n').replace('\\\\r', '\\r').replace('\\\\t', '\\t').replace('\\\\b', '\\b').replace(\n                    '\\\\f', '\\f')\n            elif isinstance(obj, dict):\n                return {k: restore_chars(v) for k, v in obj.items()}\n            elif isinstance(obj, list):\n                return [restore_chars(item) for item in obj]\n            return obj\n\n            # 定义特殊字符映射\n\n        special_chars = {\n            '\\n': '\\\\n',  # 换行符\n            '\\r': '\\\\r',  # 回车符\n            '\\t': '\\\\t',  # 制表符\n            '\\b': '\\\\b',  # 退格符\n            '\\f': '\\\\f',  # 换页符\n        }\n\n        # 处理特殊字符\n        processed = rendered\n        for char, escape in special_chars.items():\n            processed = processed.replace(char, escape)\n\n        # 尝试解析为JSON\n        try:\n            rendered_dict = json.loads(processed)\n            return restore_chars(rendered_dict)\n        except json.JSONDecodeError:\n            return rendered\n\n    def close(self):\n        \"\"\"\n        清理资源\n        \"\"\"\n        if self.cache:\n            self.cache.close()\n\n\nclass MessageTemplateHelper:\n    \"\"\"\n    消息模板渲染器\n    \"\"\"\n\n    @staticmethod\n    def render(message: Notification, *args, **kwargs) -> Optional[Notification]:\n        \"\"\"\n        渲染消息模板\n        \"\"\"\n        if not MessageTemplateHelper.is_instance_valid(message):\n            if MessageTemplateHelper.meets_update_conditions(message, *args, **kwargs):\n                logger.info(\"将使用模板渲染消息内容\")\n                return MessageTemplateHelper._apply_template_data(message, *args, **kwargs)\n        return message\n\n    @staticmethod\n    def is_instance_valid(message: Notification) -> bool:\n        \"\"\"\n        检查消息是否有效\n        \"\"\"\n        if isinstance(message, Notification):\n            return bool(message.title or message.text)\n        return False\n\n    @staticmethod\n    def meets_update_conditions(message: Notification, *args, **kwargs) -> bool:\n        \"\"\"\n        判断是否满足消息实例更新条件\n\n        满足条件需同时具备：\n        1. 消息为有效Notification实例\n        2. 消息指定了模板类型(ctype)\n        3. 存在待渲染的模板变量数据\n        \"\"\"\n        if isinstance(message, Notification):\n            return True if message.ctype and (args or kwargs) else False\n        return False\n\n    @staticmethod\n    def _apply_template_data(message: Notification, *args, **kwargs) -> Optional[Notification]:\n        \"\"\"\n        更新消息实例\n        \"\"\"\n        try:\n            if template := MessageTemplateHelper._get_template(message):\n                rendered = TemplateHelper().render(template_content=template, *args, **kwargs)\n                for key, value in rendered.items():\n                    if hasattr(message, key):\n                        setattr(message, key, value)\n            return message\n        except Exception as e:\n            logger.error(f\"更新Notification时出现错误：{str(e)}\")\n            return message\n\n    @staticmethod\n    def _get_template(message: Notification) -> Optional[str]:\n        \"\"\"\n        获取消息模板\n        \"\"\"\n        template_dict: dict[str, str] = SystemConfigOper().get(SystemConfigKey.NotificationTemplates)\n        return template_dict.get(message.ctype.value)\n\n\nclass MessageQueueManager(metaclass=SingletonClass):\n    \"\"\"\n    消息发送队列管理器\n    \"\"\"\n\n    def __init__(\n            self,\n            send_callback: Optional[Callable] = None,\n            check_interval: Optional[int] = 10\n    ) -> None:\n        \"\"\"\n        消息队列管理器初始化\n\n        :param send_callback: 实际发送消息的回调函数\n        :param check_interval: 时间检查间隔（秒）\n        \"\"\"\n        self.schedule_periods: List[tuple[int, int, int, int]] = []\n\n        self.init_config()\n\n        self.queue: queue.Queue[Any] = queue.Queue()\n        self.send_callback = send_callback\n        self.check_interval = check_interval\n\n        self._running = True\n        self.thread = threading.Thread(target=self._monitor_loop, daemon=True)\n        self.thread.start()\n\n    def init_config(self):\n        \"\"\"\n        初始化配置\n        \"\"\"\n        self.schedule_periods = self._parse_schedule(\n            SystemConfigOper().get(SystemConfigKey.NotificationSendTime)\n        )\n\n    @staticmethod\n    def _parse_schedule(periods: Union[list, dict]) -> List[tuple[int, int, int, int]]:\n        \"\"\"\n        将字符串时间格式转换为分钟数元组\n        支持格式为 'HH:MM' 或 'HH:MM:SS' 的时间字符串\n        \"\"\"\n        parsed = []\n        if not periods:\n            return parsed\n        if not isinstance(periods, list):\n            periods = [periods]\n        for period in periods:\n            if not period:\n                continue\n            if not period.get('start') or not period.get('end'):\n                continue\n            try:\n                # 处理 start 时间\n                start_parts = period['start'].split(':')\n                if len(start_parts) == 2:\n                    start_h, start_m = map(int, start_parts)\n                elif len(start_parts) >= 3:\n                    start_h, start_m = map(int, start_parts[:2])  # 只取前两个部分 (HH:MM)\n                else:\n                    continue\n                # 处理 end 时间\n                end_parts = period['end'].split(':')\n                if len(end_parts) == 2:\n                    end_h, end_m = map(int, end_parts)\n                elif len(end_parts) >= 3:\n                    end_h, end_m = map(int, end_parts[:2])  # 只取前两个部分 (HH:MM)\n                else:\n                    continue\n\n                parsed.append((start_h, start_m, end_h, end_m))\n            except ValueError as e:\n                logger.error(f\"解析时间周期时出现错误：{period}. 错误：{str(e)}. 跳过此周期。\")\n                continue\n            except Exception as e:\n                logger.error(f\"解析时间周期时出现意外错误：{period}. 错误：{str(e)}. 跳过此周期。\")\n                continue\n        return parsed\n\n    @staticmethod\n    def _time_to_minutes(time_str: str) -> int:\n        \"\"\"\n        将 'HH:MM' 格式转换为分钟数\n        \"\"\"\n        hours, minutes = map(int, time_str.split(':'))\n        return hours * 60 + minutes\n\n    def _is_in_scheduled_time(self, current_time: datetime) -> bool:\n        \"\"\"\n        检查当前时间是否在允许发送的时间段内\n        \"\"\"\n        if not self.schedule_periods:\n            return True\n        current_minutes = current_time.hour * 60 + current_time.minute\n        for period in self.schedule_periods:\n            s_h, s_m, e_h, e_m = period\n            start = s_h * 60 + s_m\n            end = e_h * 60 + e_m\n\n            if start <= end:\n                if start <= current_minutes <= end:\n                    return True\n            else:\n                if current_minutes >= start or current_minutes <= end:\n                    return True\n        return False\n\n    def send_message(self, *args, **kwargs) -> None:\n        \"\"\"\n        发送消息（立即发送或加入队列）\n        \"\"\"\n        immediately = kwargs.pop(\"immediately\", False)\n        if immediately or self._is_in_scheduled_time(datetime.now()):\n            self._send(*args, **kwargs)\n        else:\n            self.queue.put({\n                \"args\": args,\n                \"kwargs\": kwargs\n            })\n            logger.info(f\"消息已加入队列，当前队列长度：{self.queue.qsize()}\")\n\n    async def async_send_message(self, *args, **kwargs) -> None:\n        \"\"\"\n        异步发送消息（直接加入队列）\n        \"\"\"\n        kwargs.pop(\"immediately\", False)\n        self.queue.put({\n            \"args\": args,\n            \"kwargs\": kwargs\n        })\n        logger.info(f\"消息已加入队列，当前队列长度：{self.queue.qsize()}\")\n\n    def _send(self, *args, **kwargs) -> None:\n        \"\"\"\n        实际发送消息（可通过回调函数自定义）\n        \"\"\"\n        if self.send_callback:\n            try:\n                logger.info(f\"发送消息：{kwargs}\")\n                self.send_callback(*args, **kwargs)\n            except Exception as e:\n                logger.error(f\"发送消息错误：{str(e)}\")\n\n    def _monitor_loop(self) -> None:\n        \"\"\"\n        后台线程循环检查时间并处理队列\n        \"\"\"\n        while self._running:\n            current_time = datetime.now()\n            if self._is_in_scheduled_time(current_time):\n                while not self.queue.empty():\n                    if global_vars.is_system_stopped:\n                        break\n                    if not self._is_in_scheduled_time(datetime.now()):\n                        break\n                    try:\n                        message = self.queue.get_nowait()\n                        self._send(*message['args'], **message['kwargs'])\n                        logger.info(f\"队列剩余消息：{self.queue.qsize()}\")\n                    except queue.Empty:\n                        break\n            time.sleep(self.check_interval)\n\n    def stop(self) -> None:\n        \"\"\"\n        停止队列管理器\n        \"\"\"\n        self._running = False\n        logger.info(\"正在停止消息队列...\")\n        self.thread.join()\n        logger.info(\"消息队列已停止\")\n\n\nclass MessageHelper(metaclass=Singleton):\n    \"\"\"\n    消息队列管理器，包括系统消息和用户消息\n    \"\"\"\n\n    def __init__(self):\n        self.sys_queue = queue.Queue()\n        self.user_queue = queue.Queue()\n\n    def put(self, message: Any, role: str = \"plugin\", title: str = None, note: Union[list, dict] = None):\n        \"\"\"\n        存消息\n        :param message: 消息\n        :param role: 消息通道 systm：系统消息，plugin：插件消息，user：用户消息\n        :param title: 标题\n        :param note: 附件json\n        \"\"\"\n        if role in [\"system\", \"plugin\"]:\n            # 没有标题时获取插件名称\n            if role == \"plugin\" and not title:\n                title = \"插件通知\"\n            # 系统通知，默认\n            self.sys_queue.put(json.dumps({\n                \"type\": role,\n                \"title\": title,\n                \"text\": message,\n                \"date\": time.strftime(\"%Y-%m-%d %H:%M:%S\", time.localtime()),\n                \"note\": note\n            }))\n        else:\n            if isinstance(message, str):\n                # 非系统的文本通知\n                self.user_queue.put(json.dumps({\n                    \"title\": title,\n                    \"text\": message,\n                    \"date\": time.strftime(\"%Y-%m-%d %H:%M:%S\", time.localtime()),\n                    \"note\": note\n                }))\n            elif hasattr(message, \"to_dict\"):\n                # 非系统的复杂结构通知，如媒体信息/种子列表等。\n                content = message.to_dict()\n                content['title'] = title\n                content['date'] = time.strftime(\"%Y-%m-%d %H:%M:%S\", time.localtime())\n                content['note'] = note\n                self.user_queue.put(json.dumps(content))\n\n    def get(self, role: str = \"system\") -> Optional[str]:\n        \"\"\"\n        取消息\n        :param role: 消息通道 systm：系统消息，plugin：插件消息，user：用户消息\n        \"\"\"\n        if role == \"system\":\n            if not self.sys_queue.empty():\n                return self.sys_queue.get(block=False)\n        else:\n            if not self.user_queue.empty():\n                return self.user_queue.get(block=False)\n        return None\n\n\ndef stop_message():\n    \"\"\"\n    停止消息服务\n    \"\"\"\n    # 停止消息队列\n    MessageQueueManager().stop()\n    # 关闭消息演染器\n    TemplateHelper().close()\n"
  },
  {
    "path": "app/helper/module.py",
    "content": "# -*- coding: utf-8 -*-\nimport importlib\nimport pkgutil\nimport traceback\nfrom pathlib import Path\nfrom typing import List, Any, Callable\n\nfrom app.log import logger\n\nFilterFuncType = Callable[[str, Any], bool]\n\n\ndef _default_filter(name: str, obj: Any) -> bool:\n    \"\"\"\n    默认过滤器\n    \"\"\"\n    return True if name and obj else False\n\n\nclass ModuleHelper:\n    \"\"\"\n    模块动态加载\n    \"\"\"\n\n    @classmethod\n    def load(cls, package_path: str, filter_func: FilterFuncType = _default_filter) -> List[Any]:\n        \"\"\"\n        导入模块\n        :param package_path: 父包名\n        :param filter_func: 子模块过滤函数，入参为模块名和模块对象，返回True则导入，否则不导入\n        :return: 导入的模块对象列表\n        \"\"\"\n\n        submodules: list = []\n        loaded_modules = set()\n        packages = importlib.import_module(package_path)\n        for importer, package_name, _ in pkgutil.iter_modules(packages.__path__):\n            try:\n                if package_name.startswith('_'):\n                    continue\n                full_package_name = f'{package_path}.{package_name}'\n                module = importlib.import_module(full_package_name)\n                importlib.reload(module)\n                for name, obj in module.__dict__.items():\n                    if name.startswith('_'):\n                        continue\n                    if isinstance(obj, type) and filter_func(name, obj):\n                        if name in loaded_modules:\n                            continue\n                        loaded_modules.add(name)\n                        submodules.append(obj)\n            except Exception as err:\n                logger.debug(f'加载模块 {package_name} 失败：{str(err)} - {traceback.format_exc()}')\n\n        return submodules\n\n    @classmethod\n    def load_with_pre_filter(cls, package_path: str, filter_func: FilterFuncType = _default_filter) -> List[Any]:\n        \"\"\"\n        导入子模块\n        :param package_path: 父包名\n        :param filter_func: 子模块过滤函数，入参为模块名和模块对象，返回True则导入，否则不导入\n        :return: 导入的模块对象列表\n        \"\"\"\n\n        submodules: list = []\n        packages = importlib.import_module(package_path)\n\n        def reload_module_objects(target_module):\n            \"\"\"加载模块并返回对象\"\"\"\n            importlib.reload(target_module)\n            # reload后，重新过滤已经重新加载后的模块中的对象\n            return [\n                obj for name, obj in target_module.__dict__.items()\n                if not name.startswith('_') and isinstance(obj, type) and filter_func(name, obj)\n            ]\n\n        def reload_sub_modules(parent_module, parent_module_name):\n            \"\"\"重新加载一级子模块\"\"\"\n            for sub_importer, sub_module_name, sub_is_pkg in pkgutil.walk_packages(parent_module.__path__,\n                                                                                   parent_module_name + '.'):\n                try:\n                    full_sub_module = importlib.import_module(sub_module_name)\n                    importlib.reload(full_sub_module)\n                except Exception as sub_err:\n                    logger.debug(f'加载子模块 {sub_module_name} 失败：{str(sub_err)} - {traceback.format_exc()}')\n\n        # 遍历包中的所有子模块\n        for importer, package_name, is_pkg in pkgutil.iter_modules(packages.__path__):\n            if package_name.startswith('_'):\n                continue\n            full_package_name = f'{package_path}.{package_name}'\n            try:\n                module = importlib.import_module(full_package_name)\n                # 预检查模块中的对象\n                candidates = [(name, obj) for name, obj in module.__dict__.items() if\n                              not name.startswith('_') and isinstance(obj, type)]\n                # 确定是否需要重新加载\n                if any(filter_func(name, obj) for name, obj in candidates):\n                    # 如果子模块是包，重新加载其子模块\n                    if is_pkg:\n                        reload_sub_modules(module, full_package_name)\n                    submodules.extend(reload_module_objects(module))\n            except Exception as err:\n                logger.debug(f'加载模块 {package_name} 失败：{str(err)} - {traceback.format_exc()}')\n\n        return submodules\n\n    @staticmethod\n    def dynamic_import_all_modules(base_path: Path, package_name: str):\n        \"\"\"\n        动态导入目录下所有模块\n        \"\"\"\n        modules = []\n        # 遍历文件夹，找到所有模块文件\n        for file in base_path.glob(\"*.py\"):\n            file_name = file.stem\n            if file_name != \"__init__\":\n                modules.append(file_name)\n                full_module_name = f\"{package_name}.{file_name}\"\n                importlib.import_module(full_module_name)\n"
  },
  {
    "path": "app/helper/nfo.py",
    "content": "import xml.etree.ElementTree as ET\nfrom pathlib import Path\nfrom typing import List, Optional\n\n\nclass NfoReader:\n    def __init__(self, xml_file_path: Path):\n        self.xml_file_path = xml_file_path\n        self.tree = ET.parse(xml_file_path)\n        self.root = self.tree.getroot()\n\n    def get_element_value(self, element_path) -> Optional[str]:\n        element = self.root.find(element_path)\n        return element.text if element is not None else None\n\n    def get_elements(self, element_path) -> List[ET.Element]:\n        return self.root.findall(element_path)\n"
  },
  {
    "path": "app/helper/notification.py",
    "content": "from typing import Optional\n\nfrom app.helper.service import ServiceBaseHelper\nfrom app.schemas import NotificationConf, ServiceInfo\nfrom app.schemas.types import SystemConfigKey, ModuleType\n\n\nclass NotificationHelper(ServiceBaseHelper[NotificationConf]):\n    \"\"\"\n    消息通知帮助类\n    \"\"\"\n\n    def __init__(self):\n        super().__init__(\n            config_key=SystemConfigKey.Notifications,\n            conf_type=NotificationConf,\n            module_type=ModuleType.Notification\n        )\n\n    def is_notification(\n            self,\n            service_type: Optional[str] = None,\n            service: Optional[ServiceInfo] = None,\n            name: Optional[str] = None,\n    ) -> bool:\n        \"\"\"\n        通用的消息通知服务类型判断方法\n\n        :param service_type: 消息通知服务的类型名称（如 'wechat', 'voicechat', 'telegram', 等）\n        :param service: 要判断的服务信息\n        :param name: 服务的名称\n        :return: 如果服务类型或实例为指定类型，返回 True；否则返回 False\n        \"\"\"\n        # 如果未提供 service 则通过 name 获取服务\n        service = service or self.get_service(name=name)\n\n        # 判断服务类型是否为指定类型\n        return bool(service and service.type == service_type)\n"
  },
  {
    "path": "app/helper/ocr.py",
    "content": "import base64\nfrom typing import Optional\n\nfrom app.core.config import settings\nfrom app.utils.http import RequestUtils\n\n\nclass OcrHelper:\n\n    _ocr_b64_url = f\"{settings.OCR_HOST}/captcha/base64\"\n\n    def get_captcha_text(self, image_url: Optional[str] = None, image_b64: Optional[str] = None,\n                         cookie: Optional[str] = None, ua: Optional[str] = None):\n        \"\"\"\n        根据图片地址，获取验证码图片，并识别内容\n        :param image_url: 图片地址\n        :param image_b64: 图片base64，跳过图片地址下载\n        :param cookie: 下载图片使用的cookie\n        :param ua: 下载图片使用的ua\n        \"\"\"\n        if image_url:\n            ret = RequestUtils(ua=ua,\n                               cookies=cookie).get_res(image_url)\n            if ret is not None:\n                image_bin = ret.content\n                if not image_bin:\n                    return \"\"\n                image_b64 = base64.b64encode(image_bin).decode()\n        if not image_b64:\n            return \"\"\n        ret = RequestUtils(content_type=\"application/json\").post_res(\n            url=self._ocr_b64_url,\n            json={\"base64_img\": image_b64})\n        if ret:\n            return ret.json().get(\"result\")\n        return \"\"\n"
  },
  {
    "path": "app/helper/passkey.py",
    "content": "\"\"\"\nPassKey WebAuthn 辅助工具类\n\"\"\"\nimport base64\nimport json\nimport binascii\nfrom typing import Optional, Tuple, List, Dict, Any\nfrom urllib.parse import urlparse\n\nfrom webauthn import (\n    generate_registration_options,\n    verify_registration_response,\n    generate_authentication_options,\n    verify_authentication_response,\n    options_to_json\n)\nfrom webauthn.helpers import (\n    parse_registration_credential_json,\n    parse_authentication_credential_json\n)\nfrom webauthn.helpers.structs import (\n    PublicKeyCredentialDescriptor,\n    AuthenticatorTransport,\n    UserVerificationRequirement,\n    AuthenticatorAttachment,\n    ResidentKeyRequirement,\n    AuthenticatorSelectionCriteria\n)\nfrom webauthn.helpers.cose import COSEAlgorithmIdentifier\n\nfrom app.core.config import settings\nfrom app.log import logger\n\n\nclass PassKeyHelper:\n    \"\"\"\n    PassKey WebAuthn 辅助类\n    \"\"\"\n\n    @staticmethod\n    def get_rp_id() -> str:\n        \"\"\"\n        获取 Relying Party ID\n        \"\"\"\n        if settings.APP_DOMAIN:\n            app_domain = settings.APP_DOMAIN.strip()\n            # 确保存在协议前缀，以便 urlparse 正确解析主机和端口\n            if not app_domain.startswith(('http://', 'https://')):\n                app_domain = f'https://{app_domain}'\n            parsed = urlparse(app_domain)\n            host = parsed.hostname\n            if host:\n                return host\n            # 从 APP_DOMAIN 中提取域名\n            host = settings.APP_DOMAIN.replace('https://', '').replace('http://', '')\n            # 移除端口号\n            if ':' in host:\n                host = host.split(':')[0]\n            return host\n        # 只有在未配置 APP_DOMAIN 时，才默认为 localhost\n        return 'localhost'\n\n    @staticmethod\n    def get_rp_name() -> str:\n        \"\"\"\n        获取 Relying Party 名称\n        \"\"\"\n        return \"MoviePilot\"\n\n    @staticmethod\n    def get_origin() -> str:\n        \"\"\"\n        获取源地址\n        \"\"\"\n        if settings.APP_DOMAIN:\n            return settings.APP_DOMAIN.rstrip('/')\n        # 如果未配置APP_DOMAIN，使用默认的localhost地址\n        return f'http://localhost:{settings.NGINX_PORT}'\n\n    @staticmethod\n    def standardize_credential_id(credential_id: str) -> str:\n        \"\"\"\n        标准化凭证ID（Base64 URL Safe）\n        \"\"\"\n        try:\n            # Base64解码并重新编码以标准化格式\n            decoded = base64.urlsafe_b64decode(credential_id + '==')\n            return base64.urlsafe_b64encode(decoded).decode('utf-8').rstrip('=')\n        except (binascii.Error, TypeError, ValueError) as e:\n            logger.error(f\"标准化凭证ID失败: {e}\")\n            return credential_id\n\n    @staticmethod\n    def _base64_encode_urlsafe(data: bytes) -> str:\n        \"\"\"\n        Base64 URL Safe 编码（不带填充）\n\n        :param data: 要编码的字节数据\n        :return: Base64 URL Safe 编码的字符串\n        \"\"\"\n        return base64.urlsafe_b64encode(data).decode('utf-8').rstrip('=')\n\n    @staticmethod\n    def _base64_decode_urlsafe(data: str) -> bytes:\n        \"\"\"\n        Base64 URL Safe 解码（自动添加填充）\n\n        :param data: Base64 URL Safe 编码的字符串\n        :return: 解码后的字节数据\n        \"\"\"\n        return base64.urlsafe_b64decode(data + '==')\n\n    @staticmethod\n    def _parse_credential_list(credentials: List[Dict[str, Any]]) -> List[PublicKeyCredentialDescriptor]:\n        \"\"\"\n        解析凭证列表为 PublicKeyCredentialDescriptor 列表\n\n        :param credentials: 凭证字典列表\n        :return: PublicKeyCredentialDescriptor 列表\n        \"\"\"\n        result = []\n        for cred in credentials:\n            try:\n                result.append(\n                    PublicKeyCredentialDescriptor(\n                        id=PassKeyHelper._base64_decode_urlsafe(cred['credential_id']),\n                        transports=[\n                            AuthenticatorTransport(t) for t in cred.get('transports', '').split(',') if t\n                        ] if cred.get('transports') else None\n                    )\n                )\n            except Exception as e:\n                logger.warning(f\"解析凭证失败: {e}\")\n                continue\n        return result\n\n    @staticmethod\n    def _get_user_verification_requirement(user_verification: Optional[str] = None) -> UserVerificationRequirement:\n        \"\"\"\n        获取用户验证要求\n\n        :param user_verification: 指定的用户验证要求，如果不指定则从配置中读取\n        :return: UserVerificationRequirement\n        \"\"\"\n        if user_verification:\n            return UserVerificationRequirement(user_verification)\n        return UserVerificationRequirement.REQUIRED if settings.PASSKEY_REQUIRE_UV \\\n            else UserVerificationRequirement.PREFERRED\n\n    @staticmethod\n    def _get_verification_params(\n        expected_origin: Optional[str] = None,\n        expected_rp_id: Optional[str] = None\n    ) -> Tuple[str, str]:\n        \"\"\"\n        获取验证参数（origin 和 rp_id）\n\n        :param expected_origin: 期望的源地址\n        :param expected_rp_id: 期望的RP ID\n        :return: (origin, rp_id)\n        \"\"\"\n        origin = expected_origin or PassKeyHelper.get_origin()\n        rp_id = expected_rp_id or PassKeyHelper.get_rp_id()\n        return origin, rp_id\n\n    @staticmethod\n    def generate_registration_options(\n        user_id: int,\n        username: str,\n        display_name: Optional[str] = None,\n        existing_credentials: Optional[List[Dict[str, Any]]] = None\n    ) -> Tuple[str, str]:\n        \"\"\"\n        生成注册选项\n        \n        :param user_id: 用户ID\n        :param username: 用户名\n        :param display_name: 显示名称\n        :param existing_credentials: 已存在的凭证列表\n        :return: (options_json, challenge)\n        \"\"\"\n        try:\n            # 用户信息\n            user_id_bytes = str(user_id).encode('utf-8')\n\n            # 排除已有的凭证\n            exclude_credentials = PassKeyHelper._parse_credential_list(existing_credentials) \\\n                if existing_credentials else None\n\n            # 用户验证要求\n            uv_requirement = PassKeyHelper._get_user_verification_requirement()\n\n            # 生成注册选项\n            options = generate_registration_options(\n                rp_id=PassKeyHelper.get_rp_id(),\n                rp_name=PassKeyHelper.get_rp_name(),\n                user_id=user_id_bytes,\n                user_name=username,\n                user_display_name=display_name or username,\n                exclude_credentials=exclude_credentials,\n                authenticator_selection=AuthenticatorSelectionCriteria(\n                    authenticator_attachment=None,\n                    resident_key=ResidentKeyRequirement.REQUIRED,\n                    user_verification=uv_requirement,\n                ),\n                supported_pub_key_algs=[\n                    COSEAlgorithmIdentifier.ECDSA_SHA_256,\n                    COSEAlgorithmIdentifier.RSASSA_PKCS1_v1_5_SHA_256,\n                ]\n            )\n\n            # 转换为JSON\n            options_json = options_to_json(options)\n\n            # 提取challenge（用于后续验证）\n            challenge = PassKeyHelper._base64_encode_urlsafe(options.challenge)\n\n            return options_json, challenge\n\n        except Exception as e:\n            logger.error(f\"生成注册选项失败: {e}\")\n            raise\n\n    @staticmethod\n    def verify_registration_response(\n        credential: Dict[str, Any],\n        expected_challenge: str,\n        expected_origin: Optional[str] = None,\n        expected_rp_id: Optional[str] = None\n    ) -> Tuple[str, str, int, Optional[str]]:\n        \"\"\"\n        验证注册响应\n        \n        :param credential: 客户端返回的凭证\n        :param expected_challenge: 期望的challenge\n        :param expected_origin: 期望的源地址\n        :param expected_rp_id: 期望的RP ID\n        :return: (credential_id, public_key, sign_count, aaguid)\n        \"\"\"\n        try:\n            # 准备验证参数\n            origin, rp_id = PassKeyHelper._get_verification_params(expected_origin, expected_rp_id)\n            # 解码challenge\n            challenge_bytes = PassKeyHelper._base64_decode_urlsafe(expected_challenge)\n\n            # 构建RegistrationCredential对象\n            registration_credential = parse_registration_credential_json(json.dumps(credential))\n\n            # 验证注册响应\n            verification = verify_registration_response(\n                credential=registration_credential,\n                expected_challenge=challenge_bytes,\n                expected_rp_id=rp_id,\n                expected_origin=origin,\n                require_user_verification=settings.PASSKEY_REQUIRE_UV\n            )\n\n            # 提取信息\n            credential_id = PassKeyHelper._base64_encode_urlsafe(verification.credential_id)\n            public_key = PassKeyHelper._base64_encode_urlsafe(verification.credential_public_key)\n            sign_count = verification.sign_count\n            # aaguid 可能已经是字符串格式，也可能是bytes\n            if verification.aaguid:\n                if isinstance(verification.aaguid, bytes):\n                    aaguid = verification.aaguid.hex()\n                else:\n                    aaguid = str(verification.aaguid)\n            else:\n                aaguid = None\n\n            return credential_id, public_key, sign_count, aaguid\n\n        except Exception as e:\n            logger.error(f\"验证注册响应失败: {e}\")\n            raise\n\n    @staticmethod\n    def generate_authentication_options(\n        existing_credentials: Optional[List[Dict[str, Any]]] = None,\n        user_verification: Optional[str] = None\n    ) -> Tuple[str, str]:\n        \"\"\"\n        生成认证选项\n        \n        :param existing_credentials: 已存在的凭证列表（用于限制可用凭证）\n        :param user_verification: 用户验证要求，如果不指定则从配置中读取\n        :return: (options_json, challenge)\n        \"\"\"\n        try:\n            # 允许的凭证\n            allow_credentials = PassKeyHelper._parse_credential_list(existing_credentials) \\\n                if existing_credentials else None\n\n            # 用户验证要求\n            uv_requirement = PassKeyHelper._get_user_verification_requirement(user_verification)\n\n            # 生成认证选项\n            options = generate_authentication_options(\n                rp_id=PassKeyHelper.get_rp_id(),\n                allow_credentials=allow_credentials,\n                user_verification=uv_requirement\n            )\n\n            # 转换为JSON\n            options_json = options_to_json(options)\n\n            # 提取challenge\n            challenge = PassKeyHelper._base64_encode_urlsafe(options.challenge)\n\n            return options_json, challenge\n\n        except Exception as e:\n            logger.error(f\"生成认证选项失败: {e}\")\n            raise\n\n    @staticmethod\n    def verify_authentication_response(\n        credential: Dict[str, Any],\n        expected_challenge: str,\n        credential_public_key: str,\n        credential_current_sign_count: int,\n        expected_origin: Optional[str] = None,\n        expected_rp_id: Optional[str] = None\n    ) -> Tuple[bool, int]:\n        \"\"\"\n        验证认证响应\n        \n        :param credential: 客户端返回的凭证\n        :param expected_challenge: 期望的challenge\n        :param credential_public_key: 凭证公钥\n        :param credential_current_sign_count: 当前签名计数\n        :param expected_origin: 期望的源地址\n        :param expected_rp_id: 期望的RP ID\n        :return: (验证成功, 新的签名计数)\n        \"\"\"\n        try:\n            # 准备验证参数\n            origin, rp_id = PassKeyHelper._get_verification_params(expected_origin, expected_rp_id)\n            # 解码\n            challenge_bytes = PassKeyHelper._base64_decode_urlsafe(expected_challenge)\n            public_key_bytes = PassKeyHelper._base64_decode_urlsafe(credential_public_key)\n\n            # 构建AuthenticationCredential对象\n            authentication_credential = parse_authentication_credential_json(json.dumps(credential))\n\n            # 验证认证响应\n            verification = verify_authentication_response(\n                credential=authentication_credential,\n                expected_challenge=challenge_bytes,\n                expected_rp_id=rp_id,\n                expected_origin=origin,\n                credential_public_key=public_key_bytes,\n                credential_current_sign_count=credential_current_sign_count,\n                require_user_verification=settings.PASSKEY_REQUIRE_UV\n            )\n\n            return True, verification.new_sign_count\n\n        except Exception as e:\n            logger.error(f\"验证认证响应失败: {e}\")\n            return False, credential_current_sign_count\n"
  },
  {
    "path": "app/helper/plugin.py",
    "content": "import importlib\nimport io\nimport json\nimport shutil\nimport site\nimport sys\nimport traceback\nimport zipfile\nfrom pathlib import Path\nfrom typing import Dict, List, Optional, Tuple, Set, Callable, Awaitable\n\nimport aiofiles\nimport aioshutil\nimport httpx\nfrom anyio import Path as AsyncPath\nfrom packaging.requirements import Requirement\nfrom packaging.specifiers import SpecifierSet, InvalidSpecifier\nfrom packaging.version import Version, InvalidVersion\nfrom importlib.metadata import distributions\nfrom requests import Response\n\nfrom app.core.cache import cached\nfrom app.core.config import settings\nfrom app.db.systemconfig_oper import SystemConfigOper\nfrom app.log import logger\nfrom app.schemas.types import SystemConfigKey\nfrom app.utils.http import RequestUtils, AsyncRequestUtils\nfrom app.utils.singleton import WeakSingleton\nfrom app.utils.system import SystemUtils\nfrom app.utils.url import UrlUtils\n\nPLUGIN_DIR = Path(settings.ROOT_PATH) / \"app\" / \"plugins\"\n\n\nclass PluginHelper(metaclass=WeakSingleton):\n    \"\"\"\n    插件市场管理，下载安装插件到本地\n    \"\"\"\n\n    _base_url = \"https://raw.githubusercontent.com/{user}/{repo}/main/\"\n    _install_reg = f\"{settings.MP_SERVER_HOST}/plugin/install/{{pid}}\"\n    _install_report = f\"{settings.MP_SERVER_HOST}/plugin/install\"\n    _install_statistic = f\"{settings.MP_SERVER_HOST}/plugin/statistic\"\n\n    def __init__(self):\n        self.systemconfig = SystemConfigOper()\n        if settings.PLUGIN_STATISTIC_SHARE:\n            if not self.systemconfig.get(SystemConfigKey.PluginInstallReport):\n                if self.install_report():\n                    self.systemconfig.set(SystemConfigKey.PluginInstallReport, \"1\")\n\n    @cached(maxsize=128, ttl=1800)\n    def get_plugins(self, repo_url: str,\n                         package_version: Optional[str] = None) -> Optional[Dict[str, dict]]:\n        \"\"\"\n        获取Github所有最新插件列表\n        :param repo_url: Github仓库地址\n        :param package_version: 首选插件版本 (如 \"v2\", \"v3\")，如果不指定则获取 v1 版本\n        \"\"\"\n        if not repo_url:\n            return None\n\n        user, repo = self.get_repo_info(repo_url)\n        if not user or not repo:\n            return None\n\n        raw_url = self._base_url.format(user=user, repo=repo)\n        package_url = f\"{raw_url}package.{package_version}.json\" if package_version else f\"{raw_url}package.json\"\n\n        res = self.__request_with_fallback(package_url, headers=settings.REPO_GITHUB_HEADERS(repo=f\"{user}/{repo}\"))\n        if res is None:\n            return None\n        if res:\n            content = res.text\n            try:\n                return json.loads(content)\n            except json.JSONDecodeError:\n                if \"404: Not Found\" not in content:\n                    logger.warn(f\"插件包数据解析失败：{content}\")\n                    return None\n        return {}\n\n    def get_plugin_package_version(self, pid: str, repo_url: str,\n                                   package_version: Optional[str] = None) -> Optional[str]:\n        \"\"\"\n        检查并获取指定插件的可用版本，支持多版本优先级加载和版本兼容性检测\n        1. 如果未指定版本，则使用系统配置的默认版本（通过 settings.VERSION_FLAG 设置）\n        2. 优先检查指定版本的插件（如 `package.v2.json`）\n        3. 如果插件不存在于指定版本，检查 `package.json` 文件，查看该插件是否兼容指定版本\n        4. 如果插件不存在或不兼容指定版本，返回 `None`\n        :param pid: 插件 ID，用于在插件列表中查找\n        :param repo_url: 插件仓库的 URL，指定用于获取插件信息的 GitHub 仓库地址\n        :param package_version: 首选插件版本 (如 \"v2\", \"v3\")，如不指定则默认使用系统配置的版本\n        :return: 返回可用的插件版本号 (如 \"v2\"，如果指定版本不可用则返回空字符串表示 v1)，如果插件不可用则返回 None\n        \"\"\"\n        # 如果没有指定版本，则使用当前系统配置的版本（如 \"v2\"）\n        if not package_version:\n            package_version = settings.VERSION_FLAG\n\n        # 优先检查指定版本的插件，即 package.v(x).json 文件中是否存在该插件，如果存在，返回该版本号\n        if pid in (self.get_plugins(repo_url, package_version) or []):\n            return package_version\n\n        # 如果指定版本的插件不存在，检查全局 package.json 文件，查看插件是否兼容指定的版本\n        plugin = (self.get_plugins(repo_url) or {}).get(pid, None)\n        # 检查插件是否明确支持当前指定的版本（如 v2 或 v3），如果支持，返回空字符串表示使用 package.json（v1）\n        if plugin and plugin.get(package_version) is True:\n            return \"\"\n\n        # 如果所有版本都不存在或插件不兼容，返回 None，表示插件不可用\n        return None\n\n    @staticmethod\n    def get_repo_info(repo_url: str) -> Tuple[Optional[str], Optional[str]]:\n        \"\"\"\n        获取GitHub仓库信息\n        \"\"\"\n        if not repo_url:\n            return None, None\n        if not repo_url.endswith(\"/\"):\n            repo_url += \"/\"\n        if repo_url.count(\"/\") < 6:\n            repo_url = f\"{repo_url}main/\"\n        try:\n            user, repo = repo_url.split(\"/\")[-4:-2]\n        except Exception as e:\n            logger.error(f\"解析GitHub仓库地址失败：{str(e)} - {traceback.format_exc()}\")\n            return None, None\n        return user, repo\n\n    @cached(maxsize=1, ttl=1800)\n    def get_statistic(self) -> Dict:\n        \"\"\"\n        获取插件安装统计\n        \"\"\"\n        if not settings.PLUGIN_STATISTIC_SHARE:\n            return {}\n        res = RequestUtils(proxies=settings.PROXY, timeout=10).get_res(self._install_statistic)\n        if res and res.status_code == 200:\n            return res.json()\n        return {}\n\n    def install_reg(self, pid: str, repo_url: Optional[str] = None) -> bool:\n        \"\"\"\n        安装插件统计\n        \"\"\"\n        if not settings.PLUGIN_STATISTIC_SHARE:\n            return False\n        if not pid:\n            return False\n        install_reg_url = self._install_reg.format(pid=pid)\n        res = RequestUtils(\n            proxies=settings.PROXY,\n            content_type=\"application/json\",\n            timeout=5\n        ).post(install_reg_url, json={\n            \"plugin_id\": pid,\n            \"repo_url\": repo_url\n        })\n        if res and res.status_code == 200:\n            return True\n        return False\n\n    def install_report(self, items: Optional[List[Tuple[str, Optional[str]]]] = None) -> bool:\n        \"\"\"\n        上报存量插件安装统计（批量）。支持上送 repo_url。\n        :param items: 可选，形如 [(plugin_id, repo_url), ...]；不传则回落到历史配置，仅上送 plugin_id。\n        \"\"\"\n        if not settings.PLUGIN_STATISTIC_SHARE:\n            return False\n        payload_plugins = []\n        if items:\n            for pid, repo_url in items:\n                if pid:\n                    payload_plugins.append({\"plugin_id\": pid, \"repo_url\": repo_url})\n        else:\n            plugins = self.systemconfig.get(SystemConfigKey.UserInstalledPlugins)\n            if not plugins:\n                return False\n            payload_plugins = [{\"plugin_id\": plugin, \"repo_url\": None} for plugin in plugins]\n        res = RequestUtils(proxies=settings.PROXY,\n                           content_type=\"application/json\",\n                           timeout=5).post(self._install_report,\n                                           json={\"plugins\": payload_plugins})\n        return True if res else False\n\n    def install(self, pid: str, repo_url: str, package_version: Optional[str] = None, force_install: bool = False) \\\n            -> Tuple[bool, str]:\n        \"\"\"\n        安装插件，包括依赖安装和文件下载，相关资源支持自动降级策略\n        1. 检查并获取插件的指定版本，确认版本兼容性\n        2. 从 GitHub 获取文件列表（包括 requirements.txt）\n        3. 删除旧的插件目录（如非强制安装则进行备份）\n        4. 下载并预安装 requirements.txt 中的依赖（如果存在）\n        5. 下载并安装插件的其他文件\n        6. 再次尝试安装依赖（确保安装完整）\n        :param pid: 插件 ID\n        :param repo_url: 插件仓库地址\n        :param package_version: 首选插件版本 (如 \"v2\", \"v3\")，如不指定则默认使用系统配置的版本\n        :param force_install: 是否强制安装插件，默认不启用，启用时不进行备份和恢复操作\n        :return: (是否成功, 错误信息)\n        \"\"\"\n        if SystemUtils.is_frozen():\n            return False, \"可执行文件模式下，只能安装本地插件\"\n\n        # 验证参数\n        if not pid or not repo_url:\n            return False, \"参数错误\"\n\n        # 从 GitHub 的 repo_url 获取用户和项目名\n        user, repo = self.get_repo_info(repo_url)\n        if not user or not repo:\n            return False, \"不支持的插件仓库地址格式\"\n\n        user_repo = f\"{user}/{repo}\"\n\n        if not package_version:\n            package_version = settings.VERSION_FLAG\n\n        # 1. 优先检查指定版本的插件\n        package_version = self.get_plugin_package_version(pid, repo_url, package_version)\n        # 如果 package_version 为None，说明没有找到匹配的插件\n        if package_version is None:\n            msg = f\"{pid} 没有找到适用于当前版本的插件\"\n            logger.debug(msg)\n            return False, msg\n        # package_version 为空，表示从 package.json 中找到插件\n        elif package_version == \"\":\n            logger.debug(f\"{pid} 从 package.json 中找到适用于当前版本的插件\")\n        else:\n            logger.debug(f\"{pid} 从 package.{package_version}.json 中找到适用于当前版本的插件\")\n\n        # 2. 决定安装方式（release 或 文件列表）并执行统一安装流程\n        meta = self.__get_plugin_meta(pid, repo_url, package_version)\n        # 是否release打包\n        is_release = meta.get(\"release\")\n        # 插件版本号\n        plugin_version = meta.get(\"version\")\n        if is_release:\n            # 使用 插件ID_插件版本号 作为 Release tag\n            if not plugin_version:\n                return False, f\"未在插件清单中找到 {pid} 的版本号，无法进行 Release 安装\"\n            # 拼接 release_tag\n            release_tag = f\"{pid}_v{plugin_version}\"\n\n            # 使用 release 进行安装\n            def prepare_release() -> Tuple[bool, str]:\n                return self.__install_from_release(\n                    pid, user_repo, release_tag\n                )\n\n            return self.__install_flow_sync(pid, force_install, prepare_release, repo_url)\n        else:\n            # 如果 release_tag 不存在，说明插件没有发布版本，使用文件列表方式安装\n            def prepare_filelist() -> Tuple[bool, str]:\n                return self.__prepare_content_via_filelist_sync(pid.lower(), user_repo, package_version)\n\n            return self.__install_flow_sync(pid, force_install, prepare_filelist, repo_url)\n\n    def __get_file_list(self, pid: str, user_repo: str, package_version: Optional[str] = None) -> \\\n            Tuple[Optional[list], Optional[str]]:\n        \"\"\"\n        获取插件的文件列表\n        :param pid: 插件 ID\n        :param user_repo: GitHub 仓库的 user/repo 路径\n        :return: (文件列表, 错误信息)\n        \"\"\"\n        file_api = f\"https://api.github.com/repos/{user_repo}/contents/plugins\"\n        # 如果 package_version 存在（如 \"v2\"），则加上版本号\n        if package_version:\n            file_api += f\".{package_version}\"\n        file_api += f\"/{pid.lower()}\"\n\n        res = self.__request_with_fallback(file_api,\n                                           headers=settings.REPO_GITHUB_HEADERS(repo=user_repo),\n                                           is_api=True,\n                                           timeout=30)\n        if res is None:\n            return None, \"连接仓库失败\"\n        elif res.status_code != 200:\n            return None, f\"连接仓库失败：{res.status_code} - \" \\\n                         f\"{'超出速率限制，请设置Github Token或稍后重试' if res.status_code == 403 else res.reason}\"\n\n        try:\n            ret = res.json()\n            if isinstance(ret, list) and len(ret) > 0 and \"message\" not in ret[0]:\n                return ret, \"\"\n            else:\n                return None, \"插件在仓库中不存在或返回数据格式不正确\"\n        except Exception as e:\n            logger.error(f\"插件数据解析失败：{e}\")\n            return None, \"插件数据解析失败\"\n\n    def __download_files(self, pid: str, file_list: List[dict], user_repo: str,\n                         package_version: Optional[str] = None, skip_requirements: bool = False) -> Tuple[bool, str]:\n        \"\"\"\n        下载插件文件\n        :param pid: 插件 ID\n        :param file_list: 要下载的文件列表，包含文件的元数据（包括下载链接）\n        :param user_repo: GitHub 仓库的 user/repo 路径\n        :param skip_requirements: 是否跳过 requirements.txt 文件的下载\n        :return: (是否成功, 错误信息)\n        \"\"\"\n        if not file_list:\n            return False, \"文件列表为空\"\n\n        # 使用栈结构来替代递归调用，避免递归深度过大问题\n        stack = [(pid, file_list)]\n\n        while stack:\n            current_pid, current_file_list = stack.pop()\n\n            for item in current_file_list:\n                # 跳过 requirements.txt 的下载\n                if skip_requirements and item.get(\"name\") == \"requirements.txt\":\n                    continue\n\n                if item.get(\"download_url\"):\n                    logger.debug(f\"正在下载文件：{item.get('path')}\")\n                    res = self.__request_with_fallback(item.get('download_url'),\n                                                       headers=settings.REPO_GITHUB_HEADERS(repo=user_repo))\n                    if not res:\n                        return False, f\"文件 {item.get('path')} 下载失败！\"\n                    elif res.status_code != 200:\n                        return False, f\"下载文件 {item.get('path')} 失败：{res.status_code}\"\n\n                    # 确保文件路径不包含版本号（如 v2、v3），如果有 package_version，移除路径中的版本号\n                    relative_path = item.get(\"path\")\n                    if package_version:\n                        relative_path = relative_path.replace(f\"plugins.{package_version}\", \"plugins\", 1)\n\n                    # 创建插件文件夹并写入文件\n                    file_path = Path(settings.ROOT_PATH) / \"app\" / relative_path\n                    file_path.parent.mkdir(parents=True, exist_ok=True)\n                    with open(file_path, \"w\", encoding=\"utf-8\") as f:\n                        f.write(res.text)\n                    logger.debug(f\"文件 {item.get('path')} 下载成功，保存路径：{file_path}\")\n                else:\n                    # 如果是子目录，则将子目录内容加入栈中继续处理\n                    sub_list, msg = self.__get_file_list(f\"{current_pid}/{item.get('name')}\", user_repo,\n                                                         package_version)\n                    if not sub_list:\n                        return False, msg\n                    stack.append((f\"{current_pid}/{item.get('name')}\", sub_list))\n\n        return True, \"\"\n\n    def __download_and_install_requirements(self, requirements_file_info: dict, pid: str, user_repo: str) \\\n            -> Tuple[bool, str]:\n        \"\"\"\n        下载并安装 requirements.txt 文件中的依赖\n        :param requirements_file_info: requirements.txt 文件的元数据信息\n        :param pid: 插件 ID\n        :param user_repo: GitHub 仓库的 user/repo 路径\n        :return: (是否成功, 错误信息)\n        \"\"\"\n        # 下载 requirements.txt\n        res = self.__request_with_fallback(requirements_file_info.get(\"download_url\"),\n                                           headers=settings.REPO_GITHUB_HEADERS(repo=user_repo))\n        if not res:\n            return False, \"requirements.txt 文件下载失败\"\n        elif res.status_code != 200:\n            return False, f\"下载 requirements.txt 文件失败：{res.status_code}\"\n\n        requirements_txt = res.text\n        if requirements_txt.strip():\n            # 保存并安装依赖\n            requirements_file_path = PLUGIN_DIR / pid.lower() / \"requirements.txt\"\n            requirements_file_path.parent.mkdir(parents=True, exist_ok=True)\n            with open(requirements_file_path, \"w\", encoding=\"utf-8\") as f:\n                f.write(requirements_txt)\n\n            return self.pip_install_with_fallback(requirements_file_path)\n\n        return True, \"\"  # 如果 requirements.txt 为空，视作成功\n\n    def __install_dependencies_if_required(self, pid: str) -> Tuple[bool, bool, str]:\n        \"\"\"\n        安装插件依赖。\n        :param pid: 插件 ID\n        :return: (是否存在依赖，安装是否成功, 错误信息)\n        \"\"\"\n        # 定位插件目录和依赖文件\n        plugin_dir = PLUGIN_DIR / pid.lower()\n        requirements_file = plugin_dir / \"requirements.txt\"\n\n        # 检查是否存在 requirements.txt 文件\n        if requirements_file.exists():\n            logger.info(f\"{pid} 存在依赖，开始尝试安装依赖\")\n            success, error_message = self.pip_install_with_fallback(requirements_file)\n            if success:\n                return True, True, \"\"\n            else:\n                return True, False, error_message\n\n        return False, False, \"不存在依赖\"\n\n    @staticmethod\n    def __backup_plugin(pid: str) -> str:\n        \"\"\"\n        备份旧插件目录\n        :param pid: 插件 ID\n        :return: 备份目录路径\n        \"\"\"\n        plugin_dir = PLUGIN_DIR / pid.lower()\n        backup_dir = Path(settings.TEMP_PATH) / \"plugin_backup\" / pid.lower()\n\n        if plugin_dir.exists():\n            # 备份时清理已有的备份目录，防止残留文件影响\n            if backup_dir.exists():\n                shutil.rmtree(backup_dir, ignore_errors=True)\n                logger.debug(f\"{pid} 旧的备份目录已清理 {backup_dir}\")\n\n            shutil.copytree(plugin_dir, backup_dir, dirs_exist_ok=True)\n            logger.debug(f\"{pid} 插件已备份到 {backup_dir}\")\n\n        return str(backup_dir) if backup_dir.exists() else None\n\n    @staticmethod\n    def __restore_plugin(pid: str, backup_dir: str):\n        \"\"\"\n        还原旧插件目录\n        :param pid: 插件 ID\n        :param backup_dir: 备份目录路径\n        \"\"\"\n        plugin_dir = PLUGIN_DIR / pid.lower()\n        if plugin_dir.exists():\n            shutil.rmtree(plugin_dir, ignore_errors=True)\n            logger.debug(f\"{pid} 已清理插件目录 {plugin_dir}\")\n\n        if Path(backup_dir).exists():\n            shutil.copytree(backup_dir, plugin_dir, dirs_exist_ok=True)\n            logger.debug(f\"{pid} 已还原插件目录 {plugin_dir}\")\n            shutil.rmtree(backup_dir, ignore_errors=True)\n            logger.debug(f\"{pid} 已删除备份目录 {backup_dir}\")\n\n    @staticmethod\n    def __remove_old_plugin(pid: str):\n        \"\"\"\n        删除旧插件\n        :param pid: 插件 ID\n        \"\"\"\n        plugin_dir = PLUGIN_DIR / pid.lower()\n        if plugin_dir.exists():\n            shutil.rmtree(plugin_dir, ignore_errors=True)\n\n    @staticmethod\n    def pip_install_with_fallback(requirements_file: Path) -> Tuple[bool, str]:\n        \"\"\"\n        使用自动降级策略安装依赖，并确保新安装的包可被动态导入\n        :param requirements_file: 依赖的 requirements.txt 文件路径\n        :return: (是否成功, 错误信息)\n        \"\"\"\n        wheels_dir = requirements_file.parent / \"wheels\"\n\n        find_links_option = []\n        if wheels_dir.is_dir():\n            # 如果目录存在，增加 --find-links 选项\n            logger.debug(f\"[PIP] 发现插件内嵌的 wheels 目录: {wheels_dir}，将优先从本地安装。\")\n            find_links_option = [\"--find-links\", str(wheels_dir)]\n        else:\n            # 如果不存在，选项为空列表，对后续命令无影响\n            logger.debug(f\"[PIP] 未发现插件内嵌的 wheels 目录，将仅使用在线源。\")\n\n        base_cmd = [sys.executable, \"-m\", \"pip\", \"install\"] + find_links_option + [\"-r\", str(requirements_file)]\n        strategies = []\n\n        # 添加策略到列表中\n        if settings.PIP_PROXY:\n            strategies.append((\"镜像站\", base_cmd + [\"-i\", settings.PIP_PROXY]))\n        if settings.PROXY_HOST:\n            strategies.append((\"代理\", base_cmd + [\"--proxy\", settings.PROXY_HOST]))\n        strategies.append((\"直连\", base_cmd))\n\n        # 记录当前已安装的包，以便后续刷新\n        before_installation = set(sys.modules.keys())\n\n        # 遍历策略进行安装\n        for strategy_name, pip_command in strategies:\n            logger.debug(f\"[PIP] 尝试使用策略：{strategy_name} 安装依赖，命令：{' '.join(pip_command)}\")\n            success, message = SystemUtils.execute_with_subprocess(pip_command)\n            if success:\n                logger.debug(f\"[PIP] 策略：{strategy_name} 安装依赖成功，输出：{message}\")\n                # 安装成功后刷新Python的模块系统\n                importlib.reload(site)\n                # 获取新安装的模块\n                current_modules = set(sys.modules.keys())\n                new_modules = current_modules - before_installation\n                # 重新加载新安装的模块\n                for module in new_modules:\n                    if module in sys.modules:\n                        del sys.modules[module]\n                logger.debug(f\"[PIP] 已刷新导入系统，新加载的模块: {new_modules}\")\n                return True, message\n            else:\n                logger.error(f\"[PIP] 策略：{strategy_name} 安装依赖失败，错误信息：{message}\")\n\n        return False, \"[PIP] 所有策略均安装依赖失败，请检查网络连接或 PIP 配置\"\n\n    @staticmethod\n    def __request_with_fallback(url: str,\n                                headers: Optional[dict] = None,\n                                timeout: Optional[int] = 60,\n                                is_api: bool = False) -> Optional[Response]:\n        \"\"\"\n        使用自动降级策略，请求资源，优先级依次为镜像站、代理、直连\n        :param url: 目标URL\n        :param headers: 请求头信息\n        :param timeout: 请求超时时间\n        :param is_api: 是否为GitHub API请求，API请求不走镜像站\n        :return: 请求成功则返回 Response，失败返回 None\n        \"\"\"\n        strategies = []\n\n        # 1. 尝试使用镜像站，镜像站一般不支持API请求，因此API请求直接跳过镜像站\n        if not is_api and settings.GITHUB_PROXY:\n            proxy_url = f\"{UrlUtils.standardize_base_url(settings.GITHUB_PROXY)}{url}\"\n            strategies.append((\"镜像站\", proxy_url, {\"headers\": headers, \"timeout\": timeout}))\n\n        # 2. 尝试使用代理\n        if settings.PROXY_HOST:\n            strategies.append((\"代理\", url, {\"headers\": headers, \"proxies\": settings.PROXY, \"timeout\": timeout}))\n\n        # 3. 最后尝试直连\n        strategies.append((\"直连\", url, {\"headers\": headers, \"timeout\": timeout}))\n\n        # 遍历策略并尝试请求\n        for strategy_name, target_url, request_params in strategies:\n            logger.debug(f\"[GitHub] 尝试使用策略：{strategy_name} 请求 URL：{target_url}\")\n\n            try:\n                res = RequestUtils(**request_params).get_res(url=target_url, raise_exception=True)\n                logger.debug(f\"[GitHub] 请求成功，策略：{strategy_name}, URL: {target_url}\")\n                return res\n            except Exception as e:\n                logger.error(f\"[GitHub] 请求失败，策略：{strategy_name}, URL: {target_url}，错误：{str(e)}\")\n\n        logger.error(f\"[GitHub] 所有策略均请求失败，URL: {url}，请检查网络连接或 GitHub 配置\")\n        return None\n\n    def __get_plugin_meta(self, pid: str, repo_url: str,\n                          package_version: Optional[str]) -> dict:\n        try:\n            plugins = (\n                          self.get_plugins(repo_url) if not package_version\n                          else self.get_plugins(repo_url, package_version)\n                      ) or {}\n            meta = plugins.get(pid)\n            return meta if isinstance(meta, dict) else {}\n        except Exception as e:\n            logger.error(f\"获取插件 {pid} 元数据失败：{e}\")\n            return {}\n\n    def __install_flow_sync(self, pid: str, force_install: bool,\n                            prepare_content: Callable[[], Tuple[bool, str]],\n                            repo_url: Optional[str] = None) -> Tuple[bool, str]:\n        \"\"\"\n        同步安装统一流程：备份→清理→准备内容→安装依赖→上报\n        prepare_content 负责把插件文件放到 app/plugins/{pid}\n        \"\"\"\n        backup_dir = None\n        if not force_install:\n            backup_dir = self.__backup_plugin(pid)\n\n        self.__remove_old_plugin(pid)\n\n        success, message = prepare_content()\n        if not success:\n            logger.error(f\"{pid} 准备插件内容失败：{message}\")\n            if backup_dir:\n                self.__restore_plugin(pid, backup_dir)\n                logger.warning(f\"{pid} 插件安装失败，已还原备份插件\")\n            else:\n                self.__remove_old_plugin(pid)\n                logger.warning(f\"{pid} 已清理对应插件目录，请尝试重新安装\")\n            return False, message\n\n        dependencies_exist, dep_ok, dep_msg = self.__install_dependencies_if_required(pid)\n        if dependencies_exist and not dep_ok:\n            logger.error(f\"{pid} 依赖安装失败：{dep_msg}\")\n            if backup_dir:\n                self.__restore_plugin(pid, backup_dir)\n                logger.warning(f\"{pid} 插件安装失败，已还原备份插件\")\n            else:\n                self.__remove_old_plugin(pid)\n                logger.warning(f\"{pid} 已清理对应插件目录，请尝试重新安装\")\n            return False, dep_msg\n\n        self.install_reg(pid, repo_url)\n        return True, \"\"\n\n    def __install_from_release(self, pid: str, user_repo: str, release_tag: str) -> Tuple[bool, str]:\n        \"\"\"\n        通过 GitHub Release 资产文件安装插件。\n        规范：release 中存在名为 \"{pid}_v{version}.zip\" 的资产，zip 根即插件文件；\n        将其全部解压到 app/plugins/{pid}\n        \"\"\"\n        # 拼接资产文件名\n        asset_name = f\"{release_tag.lower()}.zip\"\n\n        release_api = f\"https://api.github.com/repos/{user_repo}/releases/tags/{release_tag}\"\n        rel_res = self.__request_with_fallback(\n            release_api,\n            headers=settings.REPO_GITHUB_HEADERS(repo=user_repo),\n            timeout=30,\n            is_api=True,\n        )\n        if rel_res is None or rel_res.status_code != 200:\n            return False, f\"获取 Release 信息失败：{rel_res.status_code if rel_res else '连接失败'}\"\n\n        try:\n            rel_json = rel_res.json()\n            assets = rel_json.get(\"assets\") or []\n            asset = next((a for a in assets if a.get(\"name\") == asset_name), None)\n            if not asset:\n                return False, f\"未找到资产文件：{asset_name}\"\n            asset_id = asset.get(\"id\")\n            if not asset_id:\n                return False, \"资产缺少ID信息\"\n            # 构建资产的API下载URL\n            download_url = f\"https://api.github.com/repos/{user_repo}/releases/assets/{asset_id}\"\n        except Exception as e:\n            logger.error(f\"解析 Release 信息失败：{e}\")\n            return False, f\"解析 Release 信息失败：{e}\"\n\n        # 使用资产的API端点下载，需要设置Accept头为application/octet-stream\n        headers = settings.REPO_GITHUB_HEADERS(repo=user_repo).copy()\n        headers[\"Accept\"] = \"application/octet-stream\"\n        res = self.__request_with_fallback(download_url, headers=headers, is_api=True)\n        if res is None or res.status_code != 200:\n            return False, f\"下载资产失败：{res.status_code if res else '连接失败'}\"\n\n        try:\n            with zipfile.ZipFile(io.BytesIO(res.content)) as zf:\n                namelist = zf.namelist()\n                if not namelist:\n                    return False, \"压缩包内容为空\"\n                # 若所有条目均在同一顶层目录下（如 pid/），则剥离这一层，避免出现双层目录\n                names_with_slash = [n for n in namelist if '/' in n]\n                base_prefix = ''\n                if names_with_slash and len(names_with_slash) == len(namelist):\n                    first_seg = names_with_slash[0].split('/')[0]\n                    if all(n.startswith(first_seg + '/') for n in namelist):\n                        base_prefix = first_seg + '/'\n\n                dest_base = Path(settings.ROOT_PATH) / \"app\" / \"plugins\" / pid.lower()\n                wrote_any = False\n                for name in namelist:\n                    rel_path = name[len(base_prefix):]\n                    if not rel_path:\n                        continue\n                    if rel_path.endswith('/'):\n                        (dest_base / rel_path.rstrip('/')).mkdir(parents=True, exist_ok=True)\n                        continue\n                    dest_path = dest_base / rel_path\n                    dest_path.parent.mkdir(parents=True, exist_ok=True)\n                    with zf.open(name, 'r') as src, open(dest_path, 'wb') as dst:\n                        dst.write(src.read())\n                    wrote_any = True\n                if not wrote_any:\n                    return False, \"压缩包中无可写入文件\"\n            return True, \"\"\n        except Exception as e:\n            logger.error(f\"解压 Release 压缩包失败：{e}\")\n            return False, f\"解压 Release 压缩包失败：{e}\"\n\n    def find_missing_dependencies(self) -> List[str]:\n        \"\"\"\n        收集所有需要安装或更新的依赖项\n        1. 收集所有插件的依赖项，合并版本约束\n        2. 获取已安装的包及其版本\n        3. 比较已安装的包与所需的依赖项，找出需要安装或升级的包\n        :return: 需要安装或更新的依赖项列表，例如 [\"package1>=1.0.0\", \"package2\"]\n        \"\"\"\n        try:\n            # 收集所有插件的依赖项\n            plugin_dependencies = self.__find_plugin_dependencies()  # 返回格式为 {package_name: version_specifier}\n            # 获取已安装的包及其版本\n            installed_packages = self.__get_installed_packages()  # 返回格式为 {package_name: Version}\n            # 需要安装或更新的依赖项列表\n            dependencies_to_install = []\n            for pkg_name, version_specifier in plugin_dependencies.items():\n                spec_set = SpecifierSet(version_specifier)\n                installed_version = installed_packages.get(pkg_name)\n                if installed_version is None:\n                    # 包未安装，需要安装\n                    if version_specifier:\n                        dependencies_to_install.append(f\"{pkg_name}{version_specifier}\")\n                    else:\n                        dependencies_to_install.append(pkg_name)\n                elif not spec_set.contains(installed_version, prereleases=True):\n                    # 已安装的版本不满足版本约束，需要升级或降级\n                    if version_specifier:\n                        dependencies_to_install.append(f\"{pkg_name}{version_specifier}\")\n                    else:\n                        dependencies_to_install.append(pkg_name)\n                # 已安装的版本满足要求，无需操作\n            return dependencies_to_install\n        except Exception as e:\n            logger.error(f\"收集所有需要安装或更新的依赖项时发生错误：{e}\")\n            return []\n\n    def install_dependencies(self, dependencies: List[str]) -> Tuple[bool, str]:\n        \"\"\"\n        安装指定的依赖项列表\n        :param dependencies: 需要安装或更新的依赖项列表\n        :return: (success, message)\n        \"\"\"\n        if not dependencies:\n            return False, \"没有传入需要安装的依赖项\"\n\n        try:\n            logger.debug(f\"需要安装或更新的依赖项：{dependencies}\")\n            # 创建临时的 requirements.txt 文件用于批量安装\n            requirements_temp_file = Path(settings.TEMP_PATH) / \"plugin_dependencies\" / \"requirements.txt\"\n            requirements_temp_file.parent.mkdir(parents=True, exist_ok=True)\n            with open(requirements_temp_file, \"w\", encoding=\"utf-8\") as f:\n                for dep in dependencies:\n                    f.write(dep + \"\\n\")\n            try:\n                # 使用自动降级策略安装依赖\n                return self.pip_install_with_fallback(requirements_temp_file)\n            finally:\n                # 删除临时文件\n                requirements_temp_file.unlink()\n        except Exception as e:\n            logger.error(f\"安装依赖项时发生错误：{e}\")\n            return False, f\"安装依赖项时发生错误：{e}\"\n\n    def __get_installed_packages(self) -> Dict[str, Version]:\n        \"\"\"\n        获取已安装的包及其版本\n        使用 importlib.metadata 获取当前环境中已安装的包，标准化包名并转换版本信息\n        对于无法解析的版本，记录警告日志并跳过\n        :return: 已安装包的字典，格式为 {package_name: Version}\n        \"\"\"\n        installed_packages = {}\n        try:\n            for dist in distributions():\n                name = dist.metadata.get(\"Name\")\n                if not name:\n                    continue\n                pkg_name = self.__standardize_pkg_name(name)\n                version_str = dist.metadata.get(\"Version\") or getattr(dist, \"version\", None)\n                if not version_str:\n                    continue\n                try:\n                    v = Version(version_str)\n                    if pkg_name not in installed_packages or v > installed_packages[pkg_name]:\n                        installed_packages[pkg_name] = v\n                except InvalidVersion:\n                    logger.debug(f\"无法解析已安装包 '{pkg_name}' 的版本：{version_str}\")\n                    continue\n            return installed_packages\n        except Exception as e:\n            logger.error(f\"获取已安装的包时发生错误：{e}\")\n            return {}\n\n    def __find_plugin_dependencies(self) -> Dict[str, str]:\n        \"\"\"\n        收集所有插件的依赖项\n        遍历 plugins 目录下的所有插件，查找存在 requirements.txt 的插件目录\n        ，并解析其中的依赖项，同时将所有插件的依赖项合并到字典中，方便后续统一处理\n        :return: 依赖项字典，格式为 {package_name: set(version_specifiers)}\n        \"\"\"\n        dependencies = {}\n        try:\n            install_plugins = {\n                plugin_id.lower()  # 对应插件的小写目录名\n                for plugin_id in SystemConfigOper().get(\n                    SystemConfigKey.UserInstalledPlugins\n                ) or []\n            }\n            for plugin_dir in PLUGIN_DIR.iterdir():\n                if plugin_dir.is_dir():\n                    requirements_file = plugin_dir / \"requirements.txt\"\n                    if requirements_file.exists():\n                        if plugin_dir.name not in install_plugins:\n                            # 这个插件不在安装列表中 忽略它的依赖\n                            logger.debug(f\"忽略插件 {plugin_dir.name} 的依赖\")\n                            continue\n                        # 解析当前插件的 requirements.txt，获取依赖项\n                        plugin_deps = self.__parse_requirements(requirements_file)\n                        for pkg_name, version_specifiers in plugin_deps.items():\n                            if pkg_name in dependencies:\n                                # 更新已存在的包的版本约束集合\n                                dependencies[pkg_name].update(version_specifiers)\n                            else:\n                                # 添加新的包及其版本约束\n                                dependencies[pkg_name] = set(version_specifiers)\n            return self.__merge_dependencies(dependencies)\n        except Exception as e:\n            logger.error(f\"收集插件依赖项时发生错误：{e}\")\n            return {}\n\n    def __parse_requirements(self, requirements_file: Path) -> Dict[str, List[str]]:\n        \"\"\"\n        解析 requirements.txt 文件，返回依赖项字典\n        使用 packaging 库解析每一行依赖项，提取包名和版本约束\n        对于无法解析的行，记录警告日志，便于后续检查\n        :param requirements_file: requirements.txt 文件的路径\n        :return: 依赖项字典，格式为 {package_name: [version_specifier]}\n        \"\"\"\n        dependencies = {}\n        try:\n            with open(requirements_file, \"r\", encoding=\"utf-8\") as f:\n                for line in f:\n                    line = line.strip()\n                    if line and not line.startswith('#'):\n                        # 使用 packaging 库解析依赖项\n                        try:\n                            req = Requirement(line)\n                            pkg_name = self.__standardize_pkg_name(req.name)\n                            version_specifier = str(req.specifier)\n                            if pkg_name in dependencies:\n                                dependencies[pkg_name].append(version_specifier)\n                            else:\n                                dependencies[pkg_name] = [version_specifier]\n                        except Exception as e:\n                            logger.debug(f\"无法解析依赖项 '{line}'：{e}\")\n            return dependencies\n        except Exception as e:\n            logger.error(f\"解析 requirements.txt 时发生错误：{e}\")\n            return {}\n\n    @staticmethod\n    def __merge_dependencies(dependencies: Dict[str, Set[str]]) -> Dict[str, str]:\n        \"\"\"\n        合并依赖项，选择每个包的最高版本要求\n        对于多个插件依赖同一包的情况，合并其版本约束，取交集以满足所有插件的要求\n        如果交集为空，表示存在版本冲突，需要根据策略进行处理\n        :param dependencies: 依赖项字典，格式为 {package_name: set(version_specifiers)}\n        :return: 合并后的依赖项字典，格式为 {package_name: version_specifiers}\n        \"\"\"\n        try:\n            merged_dependencies = {}\n            for pkg_name, version_specifiers in dependencies.items():\n                # 合并版本约束\n                spec_set = SpecifierSet()\n                for specifier in version_specifiers:\n                    try:\n                        if specifier:\n                            spec_set &= SpecifierSet(specifier)\n                    except InvalidSpecifier as e:\n                        logger.error(f\"发生版本约束冲突：{e}\")\n                # 将合并后的版本约束添加到结果字典\n                merged_dependencies[pkg_name] = str(spec_set) if spec_set else ''\n            return merged_dependencies\n        except Exception as e:\n            logger.error(f\"合并依赖项时发生错误：{e}\")\n            return {}\n\n    @staticmethod\n    def __standardize_pkg_name(name: str) -> str:\n        \"\"\"\n        标准化包名，将包名转换为小写，连字符与点替换为下划线（与 PEP 503 归一化风格一致）\n\n        :param name: 原始包名\n        :return: 标准化后的包名\n        \"\"\"\n        if not name:\n            return name\n        return name.lower().replace(\"-\", \"_\").replace(\".\", \"_\")\n\n    async def async_get_plugin_package_version(self, pid: str, repo_url: str,\n                                               package_version: Optional[str] = None) -> Optional[str]:\n        \"\"\"\n        异步版本的获取插件版本方法，功能同 get_plugin_package_version\n        \"\"\"\n        if not package_version:\n            package_version = settings.VERSION_FLAG\n\n        if pid in (await self.async_get_plugins(repo_url, package_version) or []):\n            return package_version\n\n        plugin = (await self.async_get_plugins(repo_url) or {}).get(pid, None)\n        if plugin and plugin.get(package_version) is True:\n            return \"\"\n\n        return None\n\n    @staticmethod\n    async def __async_request_with_fallback(url: str,\n                                            headers: Optional[dict] = None,\n                                            timeout: Optional[int] = 60,\n                                            is_api: bool = False) -> Optional[httpx.Response]:\n        \"\"\"\n        使用自动降级策略，异步请求资源，优先级依次为镜像站、代理、直连\n        :param url: 目标URL\n        :param headers: 请求头信息\n        :param timeout: 请求超时时间\n        :param is_api: 是否为GitHub API请求，API请求不走镜像站\n        :return: 请求成功则返回 Response，失败返回 None\n        \"\"\"\n        strategies = []\n\n        # 1. 尝试使用镜像站，镜像站一般不支持API请求，因此API请求直接跳过镜像站\n        if not is_api and settings.GITHUB_PROXY:\n            proxy_url = f\"{UrlUtils.standardize_base_url(settings.GITHUB_PROXY)}{url}\"\n            strategies.append((\"镜像站\", proxy_url, {\"headers\": headers, \"timeout\": timeout}))\n\n        # 2. 尝试使用代理\n        if settings.PROXY_HOST:\n            strategies.append((\"代理\", url, {\"headers\": headers, \"proxies\": settings.PROXY, \"timeout\": timeout}))\n\n        # 3. 最后尝试直连\n        strategies.append((\"直连\", url, {\"headers\": headers, \"timeout\": timeout}))\n\n        # 遍历策略并尝试请求\n        for strategy_name, target_url, request_params in strategies:\n            logger.debug(f\"[GitHub] 尝试使用策略：{strategy_name} 请求 URL：{target_url}\")\n\n            try:\n                res = await AsyncRequestUtils(**request_params).get_res(url=target_url, raise_exception=True)\n                logger.debug(f\"[GitHub] 请求成功，策略：{strategy_name}, URL: {target_url}\")\n                return res\n            except Exception as e:\n                logger.error(f\"[GitHub] 请求失败，策略：{strategy_name}, URL: {target_url}，错误：{str(e)}\")\n\n        logger.error(f\"[GitHub] 所有策略均请求失败，URL: {url}，请检查网络连接或 GitHub 配置\")\n        return None\n\n    @cached(maxsize=128, ttl=1800)\n    async def async_get_plugins(self, repo_url: str,\n                                     package_version: Optional[str] = None) -> Optional[Dict[str, dict]]:\n        \"\"\"\n        异步获取Github所有最新插件列表\n        :param repo_url: Github仓库地址\n        :param package_version: 首选插件版本 (如 \"v2\", \"v3\")，如果不指定则获取 v1 版本\n        \"\"\"\n        if not repo_url:\n            return None\n\n        user, repo = self.get_repo_info(repo_url)\n        if not user or not repo:\n            return None\n\n        raw_url = self._base_url.format(user=user, repo=repo)\n        package_url = f\"{raw_url}package.{package_version}.json\" if package_version else f\"{raw_url}package.json\"\n\n        res = await self.__async_request_with_fallback(package_url,\n                                                       headers=settings.REPO_GITHUB_HEADERS(repo=f\"{user}/{repo}\"))\n        if res is None:\n            return None\n        if res:\n            content = res.text\n            try:\n                return json.loads(content)\n            except json.JSONDecodeError:\n                if \"404: Not Found\" not in content:\n                    logger.warn(f\"插件包数据解析失败：{content}\")\n                    return None\n        return {}\n\n    async def async_get_statistic(self) -> Dict:\n        \"\"\"\n        异步获取插件安装统计\n        \"\"\"\n        if not settings.PLUGIN_STATISTIC_SHARE:\n            return {}\n        res = await AsyncRequestUtils(proxies=settings.PROXY, timeout=10).get_res(self._install_statistic)\n        if res and res.status_code == 200:\n            return res.json()\n        return {}\n\n    async def async_install_reg(self, pid: str, repo_url: Optional[str] = None) -> bool:\n        \"\"\"\n        异步安装插件统计\n        \"\"\"\n        if not settings.PLUGIN_STATISTIC_SHARE:\n            return False\n        if not pid:\n            return False\n        install_reg_url = self._install_reg.format(pid=pid)\n        res = await AsyncRequestUtils(\n            proxies=settings.PROXY,\n            content_type=\"application/json\",\n            timeout=5\n        ).post(install_reg_url, json={\n            \"plugin_id\": pid,\n            \"repo_url\": repo_url\n        })\n        if res and res.status_code == 200:\n            return True\n        return False\n\n    async def async_install_report(self, items: Optional[List[Tuple[str, Optional[str]]]] = None) -> bool:\n        \"\"\"\n        异步上报存量插件安装统计（批量）。支持上送 repo_url。\n        :param items: 可选，形如 [(plugin_id, repo_url), ...]；不传则回落到历史配置，仅上送 plugin_id。\n        \"\"\"\n        if not settings.PLUGIN_STATISTIC_SHARE:\n            return False\n        payload_plugins = []\n        if items:\n            for pid, repo_url in items:\n                if pid:\n                    payload_plugins.append({\"plugin_id\": pid, \"repo_url\": repo_url})\n        else:\n            plugins = self.systemconfig.get(SystemConfigKey.UserInstalledPlugins)\n            if not plugins:\n                return False\n            payload_plugins = [{\"plugin_id\": plugin, \"repo_url\": None} for plugin in plugins]\n        res = await AsyncRequestUtils(proxies=settings.PROXY,\n                                      content_type=\"application/json\",\n                                      timeout=5).post(self._install_report,\n                                                      json={\"plugins\": payload_plugins})\n        return True if res else False\n\n    async def __async_get_file_list(self, pid: str, user_repo: str, package_version: Optional[str] = None) -> \\\n            Tuple[Optional[list], Optional[str]]:\n        \"\"\"\n        异步获取插件的文件列表\n        :param pid: 插件 ID\n        :param user_repo: GitHub 仓库的 user/repo 路径\n        :return: (文件列表, 错误信息)\n        \"\"\"\n        file_api = f\"https://api.github.com/repos/{user_repo}/contents/plugins\"\n        # 如果 package_version 存在（如 \"v2\"），则加上版本号\n        if package_version:\n            file_api += f\".{package_version}\"\n        file_api += f\"/{pid.lower()}\"\n\n        res = await self.__async_request_with_fallback(file_api,\n                                                       headers=settings.REPO_GITHUB_HEADERS(repo=user_repo),\n                                                       is_api=True,\n                                                       timeout=30)\n        if res is None:\n            return None, \"连接仓库失败\"\n        elif res.status_code != 200:\n            return None, f\"连接仓库失败：{res.status_code} - \" \\\n                         f\"{'超出速率限制，请设置Github Token或稍后重试' if res.status_code == 403 else res.text}\"\n\n        try:\n            ret = res.json()\n            if isinstance(ret, list) and len(ret) > 0 and \"message\" not in ret[0]:\n                return ret, \"\"\n            else:\n                return None, \"插件在仓库中不存在或返回数据格式不正确\"\n        except Exception as e:\n            logger.error(f\"插件数据解析失败：{e}\")\n            return None, \"插件数据解析失败\"\n\n    async def __async_download_files(self, pid: str, file_list: List[dict], user_repo: str,\n                                     package_version: Optional[str] = None,\n                                     skip_requirements: bool = False) -> Tuple[bool, str]:\n        \"\"\"\n        异步下载插件文件\n        :param pid: 插件 ID\n        :param file_list: 要下载的文件列表，包含文件的元数据（包括下载链接）\n        :param user_repo: GitHub 仓库的 user/repo 路径\n        :param skip_requirements: 是否跳过 requirements.txt 文件的下载\n        :return: (是否成功, 错误信息)\n        \"\"\"\n        if not file_list:\n            return False, \"文件列表为空\"\n\n        # 使用栈结构来替代递归调用，避免递归深度过大问题\n        stack = [(pid, file_list)]\n\n        while stack:\n            current_pid, current_file_list = stack.pop()\n\n            for item in current_file_list:\n                # 跳过 requirements.txt 的下载\n                if skip_requirements and item.get(\"name\") == \"requirements.txt\":\n                    continue\n\n                if item.get(\"download_url\"):\n                    logger.debug(f\"正在下载文件：{item.get('path')}\")\n                    res = await self.__async_request_with_fallback(item.get('download_url'),\n                                                                   headers=settings.REPO_GITHUB_HEADERS(repo=user_repo))\n                    if not res:\n                        return False, f\"文件 {item.get('path')} 下载失败！\"\n                    elif res.status_code != 200:\n                        return False, f\"下载文件 {item.get('path')} 失败：{res.status_code}\"\n\n                    # 确保文件路径不包含版本号（如 v2、v3），如果有 package_version，移除路径中的版本号\n                    relative_path = item.get(\"path\")\n                    if package_version:\n                        relative_path = relative_path.replace(f\"plugins.{package_version}\", \"plugins\", 1)\n\n                    # 创建插件文件夹并写入文件\n                    file_path = AsyncPath(settings.ROOT_PATH) / \"app\" / relative_path\n                    await file_path.parent.mkdir(parents=True, exist_ok=True)\n                    async with aiofiles.open(file_path, \"w\", encoding=\"utf-8\") as f:\n                        await f.write(res.text)\n                    logger.debug(f\"文件 {item.get('path')} 下载成功，保存路径：{file_path}\")\n                else:\n                    # 如果是子目录，则将子目录内容加入栈中继续处理\n                    sub_list, msg = await self.__async_get_file_list(f\"{current_pid}/{item.get('name')}\", user_repo,\n                                                                     package_version)\n                    if not sub_list:\n                        return False, msg\n                    stack.append((f\"{current_pid}/{item.get('name')}\", sub_list))\n\n        return True, \"\"\n\n    async def __async_download_and_install_requirements(self, requirements_file_info: dict, pid: str, user_repo: str) \\\n            -> Tuple[bool, str]:\n        \"\"\"\n        异步下载并安装 requirements.txt 文件中的依赖\n        :param requirements_file_info: requirements.txt 文件的元数据信息\n        :param pid: 插件 ID\n        :param user_repo: GitHub 仓库的 user/repo 路径\n        :return: (是否成功, 错误信息)\n        \"\"\"\n        # 下载 requirements.txt\n        res = await self.__async_request_with_fallback(requirements_file_info.get(\"download_url\"),\n                                                       headers=settings.REPO_GITHUB_HEADERS(repo=user_repo))\n        if not res:\n            return False, \"requirements.txt 文件下载失败\"\n        elif res.status_code != 200:\n            return False, f\"下载 requirements.txt 文件失败：{res.status_code}\"\n\n        requirements_txt = res.text\n        if requirements_txt.strip():\n            # 保存并安装依赖\n            requirements_file_path = AsyncPath(PLUGIN_DIR) / pid.lower() / \"requirements.txt\"\n            await requirements_file_path.parent.mkdir(parents=True, exist_ok=True)\n            async with aiofiles.open(requirements_file_path, \"w\", encoding=\"utf-8\") as f:\n                await f.write(requirements_txt)\n\n            return self.pip_install_with_fallback(Path(requirements_file_path))\n\n        return True, \"\"  # 如果 requirements.txt 为空，视作成功\n\n    async def __async_backup_plugin(self, pid: str) -> str:\n        \"\"\"\n        异步备份旧插件目录\n        :param pid: 插件 ID\n        :return: 备份目录路径\n        \"\"\"\n        plugin_dir = AsyncPath(PLUGIN_DIR) / pid.lower()\n        backup_dir = AsyncPath(settings.TEMP_PATH) / \"plugin_backup\" / pid.lower()\n\n        if await plugin_dir.exists():\n            # 备份时清理已有的备份目录，防止残留文件影响\n            if await backup_dir.exists():\n                await aioshutil.rmtree(backup_dir, ignore_errors=True)\n                logger.debug(f\"{pid} 旧的备份目录已清理 {backup_dir}\")\n\n            # 异步复制目录\n            await self._async_copytree(plugin_dir, backup_dir)\n            logger.debug(f\"{pid} 插件已备份到 {backup_dir}\")\n\n        return str(backup_dir) if await backup_dir.exists() else None\n\n    async def __async_restore_plugin(self, pid: str, backup_dir: str):\n        \"\"\"\n        异步还原旧插件目录\n        :param pid: 插件 ID\n        :param backup_dir: 备份目录路径\n        \"\"\"\n        plugin_dir = AsyncPath(PLUGIN_DIR) / pid.lower()\n        if await plugin_dir.exists():\n            await aioshutil.rmtree(plugin_dir, ignore_errors=True)\n            logger.debug(f\"{pid} 已清理插件目录 {plugin_dir}\")\n\n        backup_path = AsyncPath(backup_dir)\n        if await backup_path.exists():\n            await self._async_copytree(src=backup_path, dst=plugin_dir)\n            logger.debug(f\"{pid} 已还原插件目录 {plugin_dir}\")\n            await aioshutil.rmtree(backup_path, ignore_errors=True)\n            logger.debug(f\"{pid} 已删除备份目录 {backup_dir}\")\n\n    @staticmethod\n    async def __async_remove_old_plugin(pid: str):\n        \"\"\"\n        异步删除旧插件\n        :param pid: 插件 ID\n        \"\"\"\n        plugin_dir = AsyncPath(PLUGIN_DIR) / pid.lower()\n        if await plugin_dir.exists():\n            await aioshutil.rmtree(plugin_dir, ignore_errors=True)\n\n    async def _async_copytree(self, src: AsyncPath, dst: AsyncPath):\n        \"\"\"\n        异步递归复制目录\n        :param src: 源目录\n        :param dst: 目标目录\n        \"\"\"\n        if not await src.exists():\n            return\n\n        await dst.mkdir(parents=True, exist_ok=True)\n\n        async for item in src.iterdir():\n            dst_item = dst / item.name\n            if await item.is_dir():\n                await self._async_copytree(item, dst_item)\n            else:\n                async with aiofiles.open(item, 'rb') as src_file:\n                    content = await src_file.read()\n                async with aiofiles.open(dst_item, 'wb') as dst_file:\n                    await dst_file.write(content)\n\n    async def __async_install_dependencies_if_required(self, pid: str) -> Tuple[bool, bool, str]:\n        \"\"\"\n        异步安装插件依赖。\n        :param pid: 插件 ID\n        :return: (是否存在依赖，安装是否成功, 错误信息)\n        \"\"\"\n        # 定位插件目录和依赖文件\n        plugin_dir = AsyncPath(PLUGIN_DIR) / pid.lower()\n        requirements_file = plugin_dir / \"requirements.txt\"\n\n        # 检查是否存在 requirements.txt 文件\n        if await requirements_file.exists():\n            logger.info(f\"{pid} 存在依赖，开始尝试安装依赖\")\n            success, error_message = self.pip_install_with_fallback(Path(requirements_file))\n            if success:\n                return True, True, \"\"\n            else:\n                return True, False, error_message\n\n        return False, False, \"不存在依赖\"\n\n    async def async_install_dependencies(self, dependencies: List[str]) -> Tuple[bool, str]:\n        \"\"\"\n        异步安装指定的依赖项列表\n        :param dependencies: 需要安装或更新的依赖项列表\n        :return: (success, message)\n        \"\"\"\n        if not dependencies:\n            return False, \"没有传入需要安装的依赖项\"\n\n        try:\n            logger.debug(f\"需要安装或更新的依赖项：{dependencies}\")\n            # 创建临时的 requirements.txt 文件用于批量安装\n            requirements_temp_file = AsyncPath(settings.TEMP_PATH) / \"plugin_dependencies\" / \"requirements.txt\"\n            await requirements_temp_file.parent.mkdir(parents=True, exist_ok=True)\n\n            async with aiofiles.open(requirements_temp_file, \"w\", encoding=\"utf-8\") as f:\n                for dep in dependencies:\n                    await f.write(dep + \"\\n\")\n\n            try:\n                # 使用自动降级策略安装依赖\n                return self.pip_install_with_fallback(Path(requirements_temp_file))\n            finally:\n                # 删除临时文件\n                await requirements_temp_file.unlink()\n        except Exception as e:\n            logger.error(f\"安装依赖项时发生错误：{e}\")\n            return False, f\"安装依赖项时发生错误：{e}\"\n\n    async def __async_find_plugin_dependencies(self) -> Dict[str, str]:\n        \"\"\"\n        异步收集所有插件的依赖项\n        遍历 plugins 目录下的所有插件，查找存在 requirements.txt 的插件目录\n        ，并解析其中的依赖项，同时将所有插件的依赖项合并到字典中，方便后续统一处理\n        :return: 依赖项字典，格式为 {package_name: set(version_specifiers)}\n        \"\"\"\n        dependencies = {}\n        try:\n            install_plugins = {\n                plugin_id.lower()  # 对应插件的小写目录名\n                for plugin_id in SystemConfigOper().get(\n                    SystemConfigKey.UserInstalledPlugins\n                ) or []\n            }\n\n            plugin_dir_path = AsyncPath(PLUGIN_DIR)\n            async for plugin_dir in plugin_dir_path.iterdir():\n                if await plugin_dir.is_dir():\n                    requirements_file = plugin_dir / \"requirements.txt\"\n                    if await requirements_file.exists():\n                        if plugin_dir.name not in install_plugins:\n                            # 这个插件不在安装列表中 忽略它的依赖\n                            logger.debug(f\"忽略插件 {plugin_dir.name} 的依赖\")\n                            continue\n                        # 解析当前插件的 requirements.txt，获取依赖项\n                        plugin_deps = await self.__async_parse_requirements(requirements_file)\n                        for pkg_name, version_specifiers in plugin_deps.items():\n                            if pkg_name in dependencies:\n                                # 更新已存在的包的版本约束集合\n                                dependencies[pkg_name].update(version_specifiers)\n                            else:\n                                # 添加新的包及其版本约束\n                                dependencies[pkg_name] = set(version_specifiers)\n            return self.__merge_dependencies(dependencies)\n        except Exception as e:\n            logger.error(f\"收集插件依赖项时发生错误：{e}\")\n            return {}\n\n    async def __async_parse_requirements(self, requirements_file: AsyncPath) -> Dict[str, List[str]]:\n        \"\"\"\n        异步解析 requirements.txt 文件，返回依赖项字典\n        使用 packaging 库解析每一行依赖项，提取包名和版本约束\n        对于无法解析的行，记录警告日志，便于后续检查\n        :param requirements_file: requirements.txt 文件的路径\n        :return: 依赖项字典，格式为 {package_name: [version_specifier]}\n        \"\"\"\n        dependencies = {}\n        try:\n            async with aiofiles.open(requirements_file, \"r\", encoding=\"utf-8\") as f:\n                async for line in f:\n                    line = str(line).strip()\n                    if line and not line.startswith('#'):\n                        # 使用 packaging 库解析依赖项\n                        try:\n                            req = Requirement(line)\n                            pkg_name = self.__standardize_pkg_name(req.name)\n                            version_specifier = str(req.specifier)\n                            if pkg_name in dependencies:\n                                dependencies[pkg_name].append(version_specifier)\n                            else:\n                                dependencies[pkg_name] = [version_specifier]\n                        except Exception as e:\n                            logger.debug(f\"无法解析依赖项 '{line}'：{e}\")\n            return dependencies\n        except Exception as e:\n            logger.error(f\"解析 requirements.txt 时发生错误：{e}\")\n            return {}\n\n    async def async_find_missing_dependencies(self) -> List[str]:\n        \"\"\"\n        异步收集所有需要安装或更新的依赖项\n        1. 收集所有插件的依赖项，合并版本约束\n        2. 获取已安装的包及其版本\n        3. 比较已安装的包与所需的依赖项，找出需要安装或升级的包\n        :return: 需要安装或更新的依赖项列表，例如 [\"package1>=1.0.0\", \"package2\"]\n        \"\"\"\n        try:\n            # 收集所有插件的依赖项\n            plugin_dependencies = await self.__async_find_plugin_dependencies()  # 返回格式为 {package_name: version_specifier}\n            # 获取已安装的包及其版本\n            installed_packages = self.__get_installed_packages()  # 返回格式为 {package_name: Version}\n            # 需要安装或更新的依赖项列表\n            dependencies_to_install = []\n            for pkg_name, version_specifier in plugin_dependencies.items():\n                spec_set = SpecifierSet(version_specifier)\n                installed_version = installed_packages.get(pkg_name)\n                if installed_version is None:\n                    # 包未安装，需要安装\n                    if version_specifier:\n                        dependencies_to_install.append(f\"{pkg_name}{version_specifier}\")\n                    else:\n                        dependencies_to_install.append(pkg_name)\n                elif not spec_set.contains(installed_version, prereleases=True):\n                    # 已安装的版本不满足版本约束，需要升级或降级\n                    if version_specifier:\n                        dependencies_to_install.append(f\"{pkg_name}{version_specifier}\")\n                    else:\n                        dependencies_to_install.append(pkg_name)\n                # 已安装的版本满足要求，无需操作\n            return dependencies_to_install\n        except Exception as e:\n            logger.error(f\"收集所有需要安装或更新的依赖项时发生错误：{e}\")\n            return []\n\n    async def async_install(self, pid: str, repo_url: str, package_version: Optional[str] = None,\n                            force_install: bool = False) -> Tuple[bool, str]:\n        \"\"\"\n        异步安装插件，包括依赖安装和文件下载，相关资源支持自动降级策略\n        1. 检查并获取插件的指定版本，确认版本兼容性\n        2. 从 GitHub 获取文件列表（包括 requirements.txt）\n        3. 删除旧的插件目录（如非强制安装则进行备份）\n        4. 下载并预安装 requirements.txt 中的依赖（如果存在）\n        5. 下载并安装插件的其他文件\n        6. 再次尝试安装依赖（确保安装完整）\n        :param pid: 插件 ID\n        :param repo_url: 插件仓库地址\n        :param package_version: 首选插件版本 (如 \"v2\", \"v3\")，如不指定则默认使用系统配置的版本\n        :param force_install: 是否强制安装插件，默认不启用，启用时不进行备份和恢复操作\n        :return: (是否成功, 错误信息)\n        \"\"\"\n        if SystemUtils.is_frozen():\n            return False, \"可执行文件模式下，只能安装本地插件\"\n\n        # 验证参数\n        if not pid or not repo_url:\n            return False, \"参数错误\"\n\n        # 从 GitHub 的 repo_url 获取用户和项目名\n        user, repo = self.get_repo_info(repo_url)\n        if not user or not repo:\n            return False, \"不支持的插件仓库地址格式\"\n\n        user_repo = f\"{user}/{repo}\"\n\n        if not package_version:\n            package_version = settings.VERSION_FLAG\n\n        # 1. 优先检查指定版本的插件\n        package_version = await self.async_get_plugin_package_version(pid, repo_url, package_version)\n        # 如果 package_version 为None，说明没有找到匹配的插件\n        if package_version is None:\n            msg = f\"{pid} 没有找到适用于当前版本的插件\"\n            logger.debug(msg)\n            return False, msg\n        # package_version 为空，表示从 package.json 中找到插件\n        elif package_version == \"\":\n            logger.debug(f\"{pid} 从 package.json 中找到适用于当前版本的插件\")\n        else:\n            logger.debug(f\"{pid} 从 package.{package_version}.json 中找到适用于当前版本的插件\")\n\n        # 2. 统一异步安装流程（release 或 文件列表）\n        meta = await self.__async_get_plugin_meta(pid, repo_url, package_version)\n        # 是否release打包\n        is_release = meta.get(\"release\")\n        # 插件版本号\n        plugin_version = meta.get(\"version\")\n        if is_release:\n            # 使用 插件ID_插件版本号 作为 Release tag\n            if not plugin_version:\n                return False, f\"未在插件清单中找到 {pid} 的版本号，无法进行 Release 安装\"\n            # 拼接 release_tag\n            release_tag = f\"{pid}_v{plugin_version}\"\n\n            # 使用 release 进行安装\n            async def prepare_release() -> Tuple[bool, str]:\n                return await self.__async_install_from_release(\n                    pid, user_repo, release_tag\n                )\n\n            return await self.__install_flow_async(pid, force_install, prepare_release, repo_url)\n        else:\n            # 如果没有 release_tag，则使用文件列表安装方式\n            async def prepare_filelist() -> Tuple[bool, str]:\n                return await self.__prepare_content_via_filelist_async(pid, user_repo, package_version)\n\n            return await self.__install_flow_async(pid, force_install, prepare_filelist, repo_url)\n\n    async def __async_get_plugin_meta(self, pid: str, repo_url: str,\n                                      package_version: Optional[str]) -> dict:\n        try:\n            plugins = (\n                          await self.async_get_plugins(repo_url) if not package_version\n                          else await self.async_get_plugins(repo_url, package_version)\n                      ) or {}\n            meta = plugins.get(pid)\n            return meta if isinstance(meta, dict) else {}\n        except Exception as e:\n            logger.warn(f\"获取插件 {pid} 元数据失败：{e}\")\n            return {}\n\n    async def __install_flow_async(self, pid: str, force_install: bool,\n                                   prepare_content: Callable[[], Awaitable[Tuple[bool, str]]],\n                                   repo_url: Optional[str] = None) -> Tuple[bool, str]:\n        \"\"\"\n        异步安装流程，处理插件内容准备、依赖安装和注册\n        \"\"\"\n        backup_dir = None\n        if not force_install:\n            backup_dir = await self.__async_backup_plugin(pid)\n\n        await self.__async_remove_old_plugin(pid)\n\n        success, message = await prepare_content()\n        if not success:\n            logger.error(f\"{pid} 准备插件内容失败：{message}\")\n            if backup_dir:\n                await self.__async_restore_plugin(pid, backup_dir)\n                logger.warning(f\"{pid} 插件安装失败，已还原备份插件\")\n            else:\n                await self.__async_remove_old_plugin(pid)\n                logger.warning(f\"{pid} 已清理对应插件目录，请尝试重新安装\")\n            return False, message\n\n        dependencies_exist, dep_ok, dep_msg = await self.__async_install_dependencies_if_required(pid)\n        if dependencies_exist and not dep_ok:\n            logger.error(f\"{pid} 依赖安装失败：{dep_msg}\")\n            if backup_dir:\n                await self.__async_restore_plugin(pid, backup_dir)\n                logger.warning(f\"{pid} 插件安装失败，已还原备份插件\")\n            else:\n                await self.__async_remove_old_plugin(pid)\n                logger.warning(f\"{pid} 已清理对应插件目录，请尝试重新安装\")\n            return False, dep_msg\n\n        await self.async_install_reg(pid, repo_url)\n        return True, \"\"\n\n    def __prepare_content_via_filelist_sync(self, pid: str, user_repo: str,\n                                            package_version: Optional[str]) -> Tuple[bool, str]:\n        \"\"\"\n        同步准备插件内容，通过文件列表获取插件文件和依赖\n        \"\"\"\n        file_list, msg = self.__get_file_list(pid, user_repo, package_version)\n        if not file_list:\n            return False, msg\n        requirements_file_info = next((f for f in file_list if f.get(\"name\") == \"requirements.txt\"), None)\n        if requirements_file_info:\n            ok, m = self.__download_and_install_requirements(requirements_file_info, pid, user_repo)\n            if not ok:\n                logger.debug(f\"{pid} 依赖预安装失败：{m}\")\n            else:\n                logger.debug(f\"{pid} 依赖预安装成功\")\n        ok, m = self.__download_files(pid, file_list, user_repo, package_version, True)\n        if not ok:\n            return False, m\n        return True, \"\"\n\n    async def __prepare_content_via_filelist_async(self, pid: str, user_repo: str,\n                                                   package_version: Optional[str]) -> Tuple[bool, str]:\n        \"\"\"\n        异步准备插件内容，通过文件列表获取插件文件和依赖\n        \"\"\"\n        file_list, msg = await self.__async_get_file_list(pid, user_repo, package_version)\n        if not file_list:\n            return False, msg\n        requirements_file_info = next((f for f in file_list if f.get(\"name\") == \"requirements.txt\"), None)\n        if requirements_file_info:\n            ok, m = await self.__async_download_and_install_requirements(requirements_file_info, pid, user_repo)\n            if not ok:\n                logger.debug(f\"{pid} 依赖预安装失败：{m}\")\n            else:\n                logger.debug(f\"{pid} 依赖预安装成功\")\n        ok, m = await self.__async_download_files(pid, file_list, user_repo, package_version, True)\n        if not ok:\n            return False, m\n        return True, \"\"\n\n    async def __async_install_from_release(self, pid: str, user_repo: str, release_tag: str) -> Tuple[bool, str]:\n        \"\"\"\n        通过 GitHub Release 资产文件安装插件（异步）。\n        规范：release 中存在名为 \"{pid}_v{version}.zip\" 的资产，zip 根即插件文件；\n        将其全部解压到 app/plugins/{pid}\n        \"\"\"\n        # 拼接资产文件名\n        asset_name = f\"{release_tag.lower()}.zip\"\n\n        release_api = f\"https://api.github.com/repos/{user_repo}/releases/tags/{release_tag}\"\n        rel_res = await self.__async_request_with_fallback(\n            release_api,\n            headers=settings.REPO_GITHUB_HEADERS(repo=user_repo),\n            timeout=30,\n            is_api=True,\n        )\n        if rel_res is None or rel_res.status_code != 200:\n            return False, f\"获取 Release 信息失败：{rel_res.status_code if rel_res else '连接失败'}\"\n\n        try:\n            rel_json = rel_res.json()\n            assets = rel_json.get(\"assets\") or []\n            asset = next((a for a in assets if a.get(\"name\") == asset_name), None)\n            if not asset:\n                return False, f\"未找到资产文件：{asset_name}\"\n            asset_id = asset.get(\"id\")\n            if not asset_id:\n                return False, \"资产缺少ID信息\"\n            # 构建资产的API下载URL\n            download_url = f\"https://api.github.com/repos/{user_repo}/releases/assets/{asset_id}\"\n        except Exception as e:\n            logger.error(f\"解析 Release 信息失败：{e}\")\n            return False, f\"解析 Release 信息失败：{e}\"\n\n        # 使用资产的API端点下载，需要设置Accept头为application/octet-stream\n        headers = settings.REPO_GITHUB_HEADERS(repo=user_repo).copy()\n        headers[\"Accept\"] = \"application/octet-stream\"\n        res = await self.__async_request_with_fallback(download_url,\n                                                       headers=headers,\n                                                       is_api=True)\n        if res is None or res.status_code != 200:\n            return False, f\"下载资产失败：{res.status_code if res else '连接失败'}\"\n\n        try:\n            with zipfile.ZipFile(io.BytesIO(res.content)) as zf:\n                namelist = zf.namelist()\n                if not namelist:\n                    return False, \"压缩包内容为空\"\n                names_with_slash = [n for n in namelist if '/' in n]\n                base_prefix = ''\n                if names_with_slash and len(names_with_slash) == len(namelist):\n                    first_seg = names_with_slash[0].split('/')[0]\n                    if all(n.startswith(first_seg + '/') for n in namelist):\n                        base_prefix = first_seg + '/'\n\n                dest_base = AsyncPath(settings.ROOT_PATH) / \"app\" / \"plugins\" / pid.lower()\n                wrote_any = False\n                for name in namelist:\n                    rel_path = name[len(base_prefix):]\n                    if not rel_path:\n                        continue\n                    if rel_path.endswith('/'):\n                        await (dest_base / rel_path.rstrip('/')).mkdir(parents=True, exist_ok=True)\n                        continue\n                    dest_path = dest_base / rel_path\n                    await dest_path.parent.mkdir(parents=True, exist_ok=True)\n                    with zf.open(name, 'r') as src:\n                        data = src.read()\n                    async with aiofiles.open(dest_path, 'wb') as dst:\n                        await dst.write(data)\n                    wrote_any = True\n                if not wrote_any:\n                    return False, \"压缩包中无可写入文件\"\n            return True, \"\"\n        except Exception as e:\n            logger.error(f\"解压 Release 压缩包失败：{e}\")\n            return False, f\"解压 Release 压缩包失败：{e}\"\n"
  },
  {
    "path": "app/helper/progress.py",
    "content": "from enum import Enum\nfrom typing import Union, Optional\n\nfrom app.core.cache import TTLCache\nfrom app.schemas.types import ProgressKey\n\n\nclass ProgressHelper:\n    \"\"\"\n    处理进度辅助类\n    \"\"\"\n\n    def __init__(self, key: Union[ProgressKey, str]):\n        if isinstance(key, Enum):\n            key = key.value\n        self._key = key\n        self._progress = TTLCache(region=\"progress\", maxsize=1024, ttl=24 * 60 * 60)\n\n    def __reset(self):\n        \"\"\"\n        重置进度\n        \"\"\"\n        self._progress[self._key] = {\n            \"enable\": False,\n            \"value\": 0,\n            \"text\": \"请稍候...\",\n            \"data\": {}\n        }\n\n    def start(self):\n        \"\"\"\n        开始进度\n        \"\"\"\n        self.__reset()\n        current = self._progress.get(self._key)\n        if not current:\n            return\n        current['enable'] = True\n        self._progress[self._key] = current\n\n    def end(self):\n        \"\"\"\n        结束进度\n        \"\"\"\n        current = self._progress.get(self._key)\n        if not current:\n            return\n        current.update(\n            {\n                \"enable\": False,\n                \"value\": 100,\n                \"text\": \"\"\n            }\n        )\n        self._progress[self._key] = current\n\n    def update(self, value: Union[float, int] = None, text: Optional[str] = None, data: dict = None):\n        \"\"\"\n        更新进度\n        \"\"\"\n        current = self._progress.get(self._key)\n        if not current or not current.get('enable'):\n            return\n        if value:\n            current['value'] = value\n        if text:\n            current['text'] = text\n        if data:\n            if not current.get('data'):\n                current['data'] = {}\n            current['data'].update(data)\n        self._progress[self._key] = current\n\n    def get(self) -> dict:\n        return self._progress.get(self._key)\n"
  },
  {
    "path": "app/helper/redis.py",
    "content": "import json\nimport pickle\nfrom typing import Any, Optional, Generator, Tuple, AsyncGenerator, Union\nfrom urllib.parse import quote\n\nimport redis\nfrom redis.asyncio import Redis\n\nfrom app.core.config import settings\nfrom app.log import logger\nfrom app.utils.mixins import ConfigReloadMixin\nfrom app.utils.singleton import Singleton\n\n# 类型缓存集合，针对非容器简单类型\n_complex_serializable_types = set()\n_simple_serializable_types = set()\n\n# 默认连接参数\n_socket_timeout = 30\n_socket_connect_timeout = 5\n_health_check_interval = 60\n\n\ndef serialize(value: Any) -> bytes:\n    \"\"\"\n    将值序列化为二进制数据，根据序列化方式标识格式\n    \"\"\"\n\n    def _is_container_type(t):\n        \"\"\"\n        判断是否为容器类型\n        \"\"\"\n        return t in (list, dict, tuple, set)\n\n    vt = type(value)\n    # 针对非容器类型使用缓存策略\n    if not _is_container_type(vt):\n        # 如果已知需要复杂序列化\n        if vt in _complex_serializable_types:\n            return b\"PICKLE\" + b\"\\x00\" + pickle.dumps(value)\n        # 如果已知可以简单序列化\n        if vt in _simple_serializable_types:\n            json_data = json.dumps(value).encode(\"utf-8\")\n            return b\"JSON\" + b\"\\x00\" + json_data\n        # 对于未知的非容器类型，尝试简单序列化，如抛出异常，再使用复杂序列化\n        try:\n            json_data = json.dumps(value).encode(\"utf-8\")\n            _simple_serializable_types.add(vt)\n            return b\"JSON\" + b\"\\x00\" + json_data\n        except TypeError:\n            _complex_serializable_types.add(vt)\n            return b\"PICKLE\" + b\"\\x00\" + pickle.dumps(value)\n    else:\n        # 针对容器类型，每次尝试简单序列化，不使用缓存\n        try:\n            json_data = json.dumps(value).encode(\"utf-8\")\n            return b\"JSON\" + b\"\\x00\" + json_data\n        except TypeError:\n            return b\"PICKLE\" + b\"\\x00\" + pickle.dumps(value)\n\n\ndef deserialize(value: bytes) -> Any:\n    \"\"\"\n    将二进制数据反序列化为原始值，根据格式标识区分序列化方式\n    \"\"\"\n    format_marker, data = value.split(b\"\\x00\", 1)\n    if format_marker == b\"JSON\":\n        return json.loads(data.decode(\"utf-8\"))\n    elif format_marker == b\"PICKLE\":\n        return pickle.loads(data)\n    else:\n        raise ValueError(\"Unknown serialization format\")\n\n\nclass RedisHelper(ConfigReloadMixin, metaclass=Singleton):\n    \"\"\"\n    Redis连接和操作助手类，单例模式\n\n    特性：\n    - 管理Redis连接池和客户端\n    - 提供序列化和反序列化功能\n    - 支持内存限制和淘汰策略设置\n    - 提供键名生成和区域管理功能\n    \"\"\"\n    CONFIG_WATCH = {\"CACHE_BACKEND_TYPE\", \"CACHE_BACKEND_URL\", \"CACHE_REDIS_MAXMEMORY\"}\n\n    def __init__(self):\n        \"\"\"\n        初始化Redis助手实例\n        \"\"\"\n        self.redis_url = settings.CACHE_BACKEND_URL\n        self.client = None\n\n    def _connect(self):\n        \"\"\"\n        建立Redis连接\n        \"\"\"\n        try:\n            if self.client is None:\n                self.client = redis.Redis.from_url(\n                    self.redis_url,\n                    decode_responses=False,\n                    socket_timeout=_socket_timeout,\n                    socket_connect_timeout=_socket_connect_timeout,\n                    health_check_interval=_health_check_interval,\n                )\n                # 测试连接，确保Redis可用\n                self.client.ping()\n                logger.info(f\"Successfully connected to Redis：{self.redis_url}\")\n                self.set_memory_limit()\n        except Exception as e:\n            logger.error(f\"Failed to connect to Redis: {e}\")\n            self.client = None\n            raise RuntimeError(\"Redis connection failed\") from e\n\n    def on_config_changed(self):\n        self.close()\n        self._connect()\n\n    def get_reload_name(self):\n        return \"Redis\"\n\n    def set_memory_limit(self, policy: Optional[str] = \"allkeys-lru\"):\n        \"\"\"\n        动态设置Redis最大内存和内存淘汰策略\n\n        :param policy: 淘汰策略（如'allkeys-lru'）\n        \"\"\"\n        try:\n            # 如果有显式值，则直接使用，为0时说明不限制，如果未配置，开启BIG_MEMORY_MODE时为\"1024mb\"，未开启时为\"256mb\"\n            maxmemory = settings.CACHE_REDIS_MAXMEMORY or (\"1024mb\" if settings.BIG_MEMORY_MODE else \"256mb\")\n            self.client.config_set(\"maxmemory\", maxmemory)\n            self.client.config_set(\"maxmemory-policy\", policy)\n            logger.debug(f\"Redis maxmemory set to {maxmemory}, policy: {policy}\")\n        except Exception as e:\n            logger.error(f\"Failed to set Redis maxmemory or policy: {e}\")\n\n    @staticmethod\n    def __get_region(region: Optional[str] = None):\n        \"\"\"\n        获取缓存的区\n        \"\"\"\n        return f\"region:{quote(region)}\" if region else \"region:DEFAULT\"\n\n    def __make_redis_key(self, region: str, key: str) -> str:\n        \"\"\"\n        获取缓存Key\n        \"\"\"\n        # 使用region作为缓存键的一部分\n        region = self.__get_region(region)\n        return f\"{region}:key:{quote(key)}\"\n\n    @staticmethod\n    def __get_original_key(redis_key: Union[str, bytes]) -> str:\n        \"\"\"\n        从Redis键中提取原始key\n        \"\"\"\n        try:\n            if isinstance(redis_key, bytes):\n                redis_key = redis_key.decode(\"utf-8\")\n            parts = redis_key.split(\":key:\")\n            return parts[-1]\n        except Exception as e:\n            logger.warn(f\"Failed to parse redis key: {redis_key}, error: {e}\")\n            return redis_key\n\n    def set(self, key: str, value: Any, ttl: Optional[int] = None,\n            region: Optional[str] = \"DEFAULT\", **kwargs) -> None:\n        \"\"\"\n        设置缓存\n\n        :param key: 缓存的键\n        :param value: 缓存的值\n        :param ttl: 缓存的存活时间，单位秒\n        :param region: 缓存的区\n        :param kwargs: 其他参数\n        \"\"\"\n        try:\n            self._connect()\n            redis_key = self.__make_redis_key(region, key)\n            # 对值进行序列化\n            serialized_value = serialize(value)\n            kwargs.pop(\"maxsize\", None)\n            self.client.set(redis_key, serialized_value, ex=ttl, **kwargs)\n        except Exception as e:\n            logger.error(f\"Failed to set key: {key} in region: {region}, error: {e}\")\n\n    def exists(self, key: str, region: Optional[str] = \"DEFAULT\") -> bool:\n        \"\"\"\n        判断缓存键是否存在\n\n        :param key: 缓存的键\n        :param region: 缓存的区\n        :return: 存在返回True，否则返回False\n        \"\"\"\n        try:\n            self._connect()\n            redis_key = self.__make_redis_key(region, key)\n            return self.client.exists(redis_key) == 1\n        except Exception as e:\n            logger.error(f\"Failed to exists key: {key} region: {region}, error: {e}\")\n            return False\n\n    def get(self, key: str, region: Optional[str] = \"DEFAULT\") -> Optional[Any]:\n        \"\"\"\n        获取缓存的值\n\n        :param key: 缓存的键\n        :param region: 缓存的区\n        :return: 返回缓存的值，如果缓存不存在返回None\n        \"\"\"\n        try:\n            self._connect()\n            redis_key = self.__make_redis_key(region, key)\n            value = self.client.get(redis_key)\n            if value is not None:\n                return deserialize(value)\n            return None\n        except Exception as e:\n            logger.error(f\"Failed to get key: {key} in region: {region}, error: {e}\")\n            return None\n\n    def delete(self, key: str, region: Optional[str] = \"DEFAULT\") -> None:\n        \"\"\"\n        删除缓存\n\n        :param key: 缓存的键\n        :param region: 缓存的区\n        \"\"\"\n        try:\n            self._connect()\n            redis_key = self.__make_redis_key(region, key)\n            self.client.delete(redis_key)\n        except Exception as e:\n            logger.error(f\"Failed to delete key: {key} in region: {region}, error: {e}\")\n\n    def clear(self, region: Optional[str] = None) -> None:\n        \"\"\"\n        清除指定区域的缓存或全部缓存\n\n        :param region: 缓存的区\n        \"\"\"\n        try:\n            self._connect()\n            if region:\n                cache_region = self.__get_region(region)\n                redis_key = f\"{cache_region}:key:*\"\n                with self.client.pipeline() as pipe:\n                    for key in self.client.scan_iter(redis_key):\n                        pipe.delete(key)\n                    pipe.execute()\n                logger.debug(f\"Cleared Redis cache for region: {region}\")\n            else:\n                self.client.flushdb()\n                logger.info(\"All Redis cache Cleared！\")\n        except Exception as e:\n            logger.error(f\"Failed to clear cache, region: {region}, error: {e}\")\n\n    def items(self, region: Optional[str] = None) -> Generator[Tuple[str, Any], None, None]:\n        \"\"\"\n        获取指定区域的所有缓存键值对\n\n        :param region: 缓存的区\n        :return: 返回键值对生成器\n        \"\"\"\n        try:\n            self._connect()\n            if region:\n                cache_region = self.__get_region(region)\n                redis_key = f\"{cache_region}:key:*\"\n                for key in self.client.scan_iter(redis_key):\n                    value = self.client.get(key)\n                    if value is not None:\n                        yield self.__get_original_key(key), deserialize(value)\n            else:\n                for key in self.client.scan_iter(\"*\"):\n                    value = self.client.get(key)\n                    if value is not None:\n                        yield self.__get_original_key(key), deserialize(value)\n        except Exception as e:\n            logger.error(f\"Failed to get items from Redis, region: {region}, error: {e}\")\n\n    def test(self) -> bool:\n        \"\"\"\n        测试Redis连接性\n        \"\"\"\n        try:\n            self._connect()\n            return True\n        except Exception as e:\n            logger.error(f\"Redis connection test failed: {e}\")\n            return False\n\n    def close(self) -> None:\n        \"\"\"\n        关闭Redis客户端的连接池\n        \"\"\"\n        if self.client:\n            self.client.close()\n            self.client = None\n            logger.debug(\"Redis connection closed\")\n\n\nclass AsyncRedisHelper(ConfigReloadMixin, metaclass=Singleton):\n    \"\"\"\n    异步Redis连接和操作助手类，单例模式\n\n    特性：\n    - 管理异步Redis连接池和客户端\n    - 提供序列化和反序列化功能\n    - 支持内存限制和淘汰策略设置\n    - 提供键名生成和区域管理功能\n    - 所有操作都是异步的\n    \"\"\"\n    CONFIG_WATCH = {\"CACHE_BACKEND_TYPE\", \"CACHE_BACKEND_URL\", \"CACHE_REDIS_MAXMEMORY\"}\n\n    def __init__(self):\n        \"\"\"\n        初始化异步Redis助手实例\n        \"\"\"\n        self.redis_url = settings.CACHE_BACKEND_URL\n        self.client: Optional[Redis] = None\n\n    async def _connect(self):\n        \"\"\"\n        建立异步Redis连接\n        \"\"\"\n        try:\n            if self.client is None:\n                self.client = Redis.from_url(\n                    self.redis_url,\n                    decode_responses=False,\n                    socket_timeout=_socket_timeout,\n                    socket_connect_timeout=_socket_connect_timeout,\n                    health_check_interval=_health_check_interval,\n                )\n                # 测试连接，确保Redis可用\n                await self.client.ping()\n                logger.info(f\"Successfully connected to Redis (async)：{self.redis_url}\")\n                await self.set_memory_limit()\n        except Exception as e:\n            logger.error(f\"Failed to connect to Redis (async): {e}\")\n            self.client = None\n            raise RuntimeError(\"Redis async connection failed\") from e\n\n    async def on_config_changed(self):\n        await self.close()\n        await self._connect()\n\n    def get_reload_name(self):\n        return \"Redis (async)\"\n\n    async def set_memory_limit(self, policy: Optional[str] = \"allkeys-lru\"):\n        \"\"\"\n        动态设置Redis最大内存和内存淘汰策略\n\n        :param policy: 淘汰策略（如'allkeys-lru'）\n        \"\"\"\n        try:\n            # 如果有显式值，则直接使用，为0时说明不限制，如果未配置，开启BIG_MEMORY_MODE时为\"1024mb\"，未开启时为\"256mb\"\n            maxmemory = settings.CACHE_REDIS_MAXMEMORY or (\"1024mb\" if settings.BIG_MEMORY_MODE else \"256mb\")\n            await self.client.config_set(\"maxmemory\", maxmemory)\n            await self.client.config_set(\"maxmemory-policy\", policy)\n            logger.debug(f\"Redis maxmemory set to {maxmemory}, policy: {policy} (async)\")\n        except Exception as e:\n            logger.error(f\"Failed to set Redis maxmemory or policy (async): {e}\")\n\n    @staticmethod\n    def __get_region(region: Optional[str] = \"DEFAULT\"):\n        \"\"\"\n        获取缓存的区\n        \"\"\"\n        return f\"region:{region}\" if region else \"region:default\"\n\n    def __make_redis_key(self, region: str, key: str) -> str:\n        \"\"\"\n        获取缓存Key\n        \"\"\"\n        # 使用region作为缓存键的一部分\n        region = self.__get_region(region)\n        return f\"{region}:key:{quote(key)}\"\n\n    @staticmethod\n    def __get_original_key(redis_key: Union[str, bytes]) -> str:\n        \"\"\"\n        从Redis键中提取原始key\n        \"\"\"\n        try:\n            if isinstance(redis_key, bytes):\n                redis_key = redis_key.decode(\"utf-8\")\n            parts = redis_key.split(\":key:\")\n            return parts[-1]\n        except Exception as e:\n            logger.warn(f\"Failed to parse redis key: {redis_key}, error: {e}\")\n            return redis_key\n\n    async def set(self, key: str, value: Any, ttl: Optional[int] = None,\n                  region: Optional[str] = \"DEFAULT\", **kwargs) -> None:\n        \"\"\"\n        异步设置缓存\n\n        :param key: 缓存的键\n        :param value: 缓存的值\n        :param ttl: 缓存的存活时间，单位秒\n        :param region: 缓存的区\n        :param kwargs: 其他参数\n        \"\"\"\n        try:\n            await self._connect()\n            redis_key = self.__make_redis_key(region, key)\n            # 对值进行序列化\n            serialized_value = serialize(value)\n            kwargs.pop(\"maxsize\", None)\n            await self.client.set(redis_key, serialized_value, ex=ttl, **kwargs)\n        except Exception as e:\n            logger.error(f\"Failed to set key (async): {key} in region: {region}, error: {e}\")\n\n    async def exists(self, key: str, region: Optional[str] = \"DEFAULT\") -> bool:\n        \"\"\"\n        异步判断缓存键是否存在\n\n        :param key: 缓存的键\n        :param region: 缓存的区\n        :return: 存在返回True，否则返回False\n        \"\"\"\n        try:\n            await self._connect()\n            redis_key = self.__make_redis_key(region, key)\n            result = await self.client.exists(redis_key)\n            return result == 1\n        except Exception as e:\n            logger.error(f\"Failed to exists key (async): {key} region: {region}, error: {e}\")\n            return False\n\n    async def get(self, key: str, region: Optional[str] = \"DEFAULT\") -> Optional[Any]:\n        \"\"\"\n        异步获取缓存的值\n\n        :param key: 缓存的键\n        :param region: 缓存的区\n        :return: 返回缓存的值，如果缓存不存在返回None\n        \"\"\"\n        try:\n            await self._connect()\n            redis_key = self.__make_redis_key(region, key)\n            value = await self.client.get(redis_key)\n            if value is not None:\n                return deserialize(value)\n            return None\n        except Exception as e:\n            logger.error(f\"Failed to get key (async): {key} in region: {region}, error: {e}\")\n            return None\n\n    async def delete(self, key: str, region: Optional[str] = \"DEFAULT\") -> None:\n        \"\"\"\n        异步删除缓存\n\n        :param key: 缓存的键\n        :param region: 缓存的区\n        \"\"\"\n        try:\n            await self._connect()\n            redis_key = self.__make_redis_key(region, key)\n            await self.client.delete(redis_key)\n        except Exception as e:\n            logger.error(f\"Failed to delete key (async): {key} in region: {region}, error: {e}\")\n\n    async def clear(self, region: Optional[str] = None) -> None:\n        \"\"\"\n        异步清除指定区域的缓存或全部缓存\n\n        :param region: 缓存的区\n        \"\"\"\n        try:\n            await self._connect()\n            if region:\n                cache_region = self.__get_region(region)\n                redis_key = f\"{cache_region}:key:*\"\n                async with self.client.pipeline() as pipe:\n                    async for key in self.client.scan_iter(redis_key):\n                        await pipe.delete(key)\n                    await pipe.execute()\n                logger.debug(f\"Cleared Redis cache for region (async): {region}\")\n            else:\n                await self.client.flushdb()\n                logger.info(\"Cleared all Redis cache (async)\")\n        except Exception as e:\n            logger.error(f\"Failed to clear cache (async), region: {region}, error: {e}\")\n\n    async def items(self, region: Optional[str] = None) -> AsyncGenerator[Tuple[str, Any], None]:\n        \"\"\"\n        获取指定区域的所有缓存键值对\n\n        :param region: 缓存的区\n        :return: 返回键值对生成器\n        \"\"\"\n        try:\n            await self._connect()\n            if region:\n                cache_region = self.__get_region(region)\n                redis_key = f\"{cache_region}:key:*\"\n                async for key in self.client.scan_iter(redis_key):\n                    value = await self.client.get(key)\n                    if value is not None:\n                        yield self.__get_original_key(key), deserialize(value)\n            else:\n                async for key in self.client.scan_iter(\"*\"):\n                    value = await self.client.get(key)\n                    if value is not None:\n                        yield self.__get_original_key(key), deserialize(value)\n        except Exception as e:\n            logger.error(f\"Failed to get items from Redis, region: {region}, error: {e}\")\n\n    async def test(self) -> bool:\n        \"\"\"\n        异步测试Redis连接性\n        \"\"\"\n        try:\n            await self._connect()\n            return True\n        except Exception as e:\n            logger.error(f\"Redis async connection test failed: {e}\")\n            return False\n\n    async def close(self) -> None:\n        \"\"\"\n        关闭异步Redis客户端的连接池\n        \"\"\"\n        if self.client:\n            await self.client.close()\n            self.client = None\n            logger.debug(\"Redis async connection closed\")\n"
  },
  {
    "path": "app/helper/resource.py",
    "content": "import json\nfrom pathlib import Path\n\nfrom app.core.config import settings\nfrom app.helper.sites import SitesHelper  # noqa\nfrom app.helper.system import SystemHelper\nfrom app.log import logger\nfrom app.utils.http import RequestUtils\nfrom app.utils.string import StringUtils\nfrom app.utils.system import SystemUtils\n\n\nclass ResourceHelper:\n    \"\"\"\n    检测和更新资源包\n    \"\"\"\n    # 资源包的git仓库地址\n    _repo = f\"{settings.GITHUB_PROXY}https://raw.githubusercontent.com/jxxghp/MoviePilot-Resources/main/package.v2.json\"\n    _files_api = f\"https://api.github.com/repos/jxxghp/MoviePilot-Resources/contents/resources.v2\"\n    _base_dir: Path = settings.ROOT_PATH\n\n    def __init__(self):\n        self.check()\n\n    @property\n    def proxies(self):\n        return None if settings.GITHUB_PROXY else settings.PROXY\n\n    def check(self):\n        \"\"\"\n        检测是否有更新，如有则下载安装\n        \"\"\"\n        if not settings.AUTO_UPDATE_RESOURCE:\n            return None\n        if SystemUtils.is_frozen():\n            return None\n        logger.info(\"开始检测资源包版本...\")\n        res = RequestUtils(proxies=self.proxies, headers=settings.GITHUB_HEADERS, timeout=10).get_res(self._repo)\n        if res:\n            try:\n                resource_info = json.loads(res.text)\n                online_version = resource_info.get(\"version\")\n                if online_version:\n                    logger.info(f\"最新资源包版本：v{online_version}\")\n                    # 需要更新的资源包\n                    need_updates = {}\n                    # 资源明细\n                    resources: dict = resource_info.get(\"resources\") or {}\n                    for rname, resource in resources.items():\n                        rtype = resource.get(\"type\")\n                        platform = resource.get(\"platform\")\n                        target = resource.get(\"target\")\n                        version = resource.get(\"version\")\n                        # 判断平台\n                        if platform and platform != SystemUtils.platform():\n                            continue\n                        # 判断版本号\n                        if rtype == \"auth\":\n                            # 站点认证资源\n                            local_version = SitesHelper().auth_version\n                        elif rtype == \"sites\":\n                            # 站点索引资源\n                            local_version = SitesHelper().indexer_version\n                        else:\n                            continue\n                        if StringUtils.compare_version(version, \">\", local_version):\n                            logger.info(f\"{rname} 资源包有更新，最新版本：v{version}\")\n                        else:\n                            continue\n                        # 需要安装\n                        need_updates[rname] = target\n                    if need_updates:\n                        # 下载文件信息列表\n                        r = RequestUtils(proxies=settings.PROXY, headers=settings.GITHUB_HEADERS,\n                                         timeout=30).get_res(self._files_api)\n                        if r and not r.ok:\n                            return None, f\"连接仓库失败：{r.status_code} - {r.reason}\"\n                        elif not r:\n                            return None, \"连接仓库失败\"\n                        files_info = r.json()\n                        # 下载资源文件\n                        success = True\n                        for item in files_info:\n                            save_path = need_updates.get(item.get(\"name\"))\n                            if not save_path:\n                                continue\n                            if item.get(\"download_url\"):\n                                logger.info(f\"开始更新资源文件：{item.get('name')} ...\")\n                                download_url = f\"{settings.GITHUB_PROXY}{item.get('download_url')}\"\n                                # 下载资源文件\n                                res = RequestUtils(proxies=self.proxies, headers=settings.GITHUB_HEADERS,\n                                                   timeout=180).get_res(download_url)\n                                if not res:\n                                    logger.error(f\"文件 {item.get('name')} 下载失败！\")\n                                    success = False\n                                    break\n                                elif res.status_code != 200:\n                                    logger.error(f\"下载文件 {item.get('name')} 失败：{res.status_code} - {res.reason}\")\n                                    success = False\n                                    break\n                                # 创建插件文件夹\n                                file_path = self._base_dir / save_path / item.get(\"name\")\n                                if not file_path.parent.exists():\n                                    file_path.parent.mkdir(parents=True, exist_ok=True)\n                                # 写入文件\n                                file_path.write_bytes(res.content)\n                        if success:\n                            logger.info(\"资源包更新完成，开始重启服务...\")\n                            SystemHelper.restart()\n                        else:\n                            logger.warn(\"资源包更新失败，跳过升级！\")\n                    else:\n                        logger.info(\"所有资源已最新，无需更新\")\n            except json.JSONDecodeError:\n                logger.error(\"资源包仓库数据解析失败！\")\n                return None\n        else:\n            logger.warn(\"无法连接资源包仓库！\")\n            return None\n"
  },
  {
    "path": "app/helper/rss.py",
    "content": "import re\nimport traceback\nfrom typing import List, Tuple, Union, Optional\nfrom urllib.parse import urljoin\n\nimport chardet\nfrom lxml import etree\n\nfrom app.core.config import settings\nfrom app.helper.browser import PlaywrightHelper\nfrom app.log import logger\nfrom app.utils.http import RequestUtils\nfrom app.utils.string import StringUtils\n\n\nclass RssHelper:\n    \"\"\"\n    RSS帮助类，解析RSS报文、获取RSS地址等\n    \"\"\"\n\n    # RSS解析限制配置\n    MAX_RSS_SIZE = 50 * 1024 * 1024  # 50MB最大RSS文件大小\n    MAX_RSS_ITEMS = 1000  # 最大解析条目数\n\n    # 各站点RSS链接获取配置\n    rss_link_conf = {\n        \"default\": {\n            \"xpath\": \"//a[@class='faqlink']/@href\",\n            \"url\": \"getrss.php\",\n            \"params\": {\n                \"inclbookmarked\": 0,\n                \"itemsmalldescr\": 1,\n                \"showrows\": 50,\n                \"search_mode\": 1,\n            }\n        },\n        \"hares.top\": {\n            \"xpath\": \"//*[@id='layui-layer100001']/div[2]/div/p[4]/a/@href\",\n            \"url\": \"getrss.php\",\n            \"params\": {\n                \"inclbookmarked\": 0,\n                \"itemsmalldescr\": 1,\n                \"showrows\": 50,\n                \"search_mode\": 1,\n            }\n        },\n        \"et8.org\": {\n            \"xpath\": \"//*[@id='outer']/table/tbody/tr/td/table/tbody/tr/td/a[2]/@href\",\n            \"url\": \"getrss.php\",\n            \"params\": {\n                \"inclbookmarked\": 0,\n                \"itemsmalldescr\": 1,\n                \"showrows\": 50,\n                \"search_mode\": 1,\n            }\n        },\n        \"pttime.org\": {\n            \"xpath\": \"//*[@id='outer']/table/tbody/tr/td/table/tbody/tr/td/text()[5]\",\n            \"url\": \"getrss.php\",\n            \"params\": {\n                \"showrows\": 10,\n                \"inclbookmarked\": 0,\n                \"itemsmalldescr\": 1\n            }\n        },\n        \"ourbits.club\": {\n            \"xpath\": \"//a[@class='gen_rsslink']/@href\",\n            \"url\": \"getrss.php\",\n            \"params\": {\n                \"inclbookmarked\": 0,\n                \"itemsmalldescr\": 1,\n                \"showrows\": 50,\n                \"search_mode\": 1,\n            }\n        },\n        \"totheglory.im\": {\n            \"xpath\": \"//textarea/text()\",\n            \"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\",\n            \"params\": {\n                \"inclbookmarked\": 0,\n                \"itemsmalldescr\": 1,\n                \"showrows\": 50,\n                \"search_mode\": 1,\n            }\n        },\n        \"monikadesign.uk\": {\n            \"xpath\": \"//a/@href\",\n            \"url\": \"rss\",\n            \"params\": {\n                \"inclbookmarked\": 0,\n                \"itemsmalldescr\": 1,\n                \"showrows\": 50,\n                \"search_mode\": 1,\n            }\n        },\n        \"zhuque.in\": {\n            \"xpath\": \"//a/@href\",\n            \"url\": \"user/rss\",\n            \"render\": True,\n            \"params\": {\n                \"inclbookmarked\": 0,\n                \"itemsmalldescr\": 1,\n                \"showrows\": 50,\n                \"search_mode\": 1,\n            }\n        },\n        \"hdchina.org\": {\n            \"xpath\": \"//a[@class='faqlink']/@href\",\n            \"url\": \"getrss.php\",\n            \"params\": {\n                \"inclbookmarked\": 0,\n                \"itemsmalldescr\": 1,\n                \"showrows\": 50,\n                \"search_mode\": 1,\n                \"rsscart\": 0\n            }\n        },\n        \"audiences.me\": {\n            \"xpath\": \"//a[@class='faqlink']/@href\",\n            \"url\": \"getrss.php\",\n            \"params\": {\n                \"inclbookmarked\": 0,\n                \"itemsmalldescr\": 1,\n                \"showrows\": 50,\n                \"search_mode\": 1,\n                \"torrent_type\": 1,\n                \"exp\": 180\n            }\n        },\n        \"shadowflow.org\": {\n            \"xpath\": \"//a[@class='faqlink']/@href\",\n            \"url\": \"getrss.php\",\n            \"params\": {\n                \"inclbookmarked\": 0,\n                \"itemsmalldescr\": 1,\n                \"paid\": 0,\n                \"search_mode\": 0,\n                \"showrows\": 30\n            }\n        },\n        \"hddolby.com\": {\n            \"xpath\": \"//a[@class='faqlink']/@href\",\n            \"url\": \"getrss.php\",\n            \"params\": {\n                \"inclbookmarked\": 0,\n                \"itemsmalldescr\": 1,\n                \"showrows\": 50,\n                \"search_mode\": 1,\n                \"exp\": 180\n            }\n        },\n        \"hdhome.org\": {\n            \"xpath\": \"//a[@class='faqlink']/@href\",\n            \"url\": \"getrss.php\",\n            \"params\": {\n                \"inclbookmarked\": 0,\n                \"itemsmalldescr\": 1,\n                \"showrows\": 50,\n                \"search_mode\": 1,\n                \"exp\": 180\n            }\n        },\n        \"pthome.net\": {\n            \"xpath\": \"//a[@class='faqlink']/@href\",\n            \"url\": \"getrss.php\",\n            \"params\": {\n                \"inclbookmarked\": 0,\n                \"itemsmalldescr\": 1,\n                \"showrows\": 50,\n                \"search_mode\": 1,\n                \"exp\": 180\n            }\n        },\n        \"ptsbao.club\": {\n            \"xpath\": \"//a[@class='faqlink']/@href\",\n            \"url\": \"getrss.php\",\n            \"params\": {\n                \"inclbookmarked\": 0,\n                \"itemsmalldescr\": 1,\n                \"showrows\": 50,\n                \"search_mode\": 1,\n                \"size\": 0\n            }\n        },\n        \"leaves.red\": {\n            \"xpath\": \"//a[@class='faqlink']/@href\",\n            \"url\": \"getrss.php\",\n            \"params\": {\n                \"inclbookmarked\": 0,\n                \"itemsmalldescr\": 1,\n                \"showrows\": 50,\n                \"search_mode\": 0,\n                \"paid\": 2\n            }\n        },\n        \"hdtime.org\": {\n            \"xpath\": \"//a[@class='faqlink']/@href\",\n            \"url\": \"getrss.php\",\n            \"params\": {\n                \"inclbookmarked\": 0,\n                \"itemsmalldescr\": 1,\n                \"showrows\": 50,\n                \"search_mode\": 0,\n            }\n        },\n        \"m-team.io\": {\n            \"xpath\": \"//a[@class='faqlink']/@href\",\n            \"url\": \"getrss.php\",\n            \"params\": {\n                \"showrows\": 50,\n                \"inclbookmarked\": 0,\n                \"itemsmalldescr\": 1,\n                \"https\": 1\n            }\n        },\n        \"u2.dmhy.org\": {\n            \"xpath\": \"//a[@class='faqlink']/@href\",\n            \"url\": \"getrss.php\",\n            \"params\": {\n                \"inclbookmarked\": 0,\n                \"itemsmalldescr\": 1,\n                \"showrows\": 50,\n                \"search_mode\": 1,\n                \"inclautochecked\": 1,\n                \"trackerssl\": 1\n            }\n        },\n    }\n\n    def parse(self, url, proxy: bool = False,\n              timeout: Optional[int] = 15, headers: dict = None, ua: str = None) -> Union[List[dict], None, bool]:\n        \"\"\"\n        解析RSS订阅URL，获取RSS中的种子信息\n        :param url: RSS地址\n        :param proxy: 是否使用代理\n        :param timeout: 请求超时\n        :param headers: 自定义请求头\n        :param ua: 自定义User-Agent\n        :return: 种子信息列表，如为None代表Rss过期，如果为False则为错误\n        \"\"\"\n        # 开始处理\n        ret_array: list = []\n        if not url:\n            return False\n\n        try:\n            ret = RequestUtils(ua=ua,\n                               proxies=settings.PROXY if proxy else None,\n                               timeout=timeout or 30, headers=headers).get_res(url)\n            if not ret:\n                logger.error(f\"获取RSS失败：请求返回空值，URL: {url}\")\n                return False\n        except Exception as err:\n            logger.error(f\"获取RSS失败：{str(err)} - {traceback.format_exc()}\")\n            return False\n\n        if ret:\n            # 检查HTTP状态码\n            if ret.status_code != 200:\n                logger.error(f\"RSS请求失败，状态码: {ret.status_code}, URL: {url}\")\n                return False\n            ret_xml = None\n            root = None\n            try:\n                # 检查响应大小，避免处理过大的RSS文件\n                raw_data = ret.content\n                if raw_data and len(raw_data) > self.MAX_RSS_SIZE:\n                    logger.warning(f\"RSS文件过大: {len(raw_data) / 1024 / 1024:.1f}MB，跳过解析\")\n                    return False\n\n                if raw_data:\n                    try:\n                        result = chardet.detect(raw_data)\n                        encoding = result['encoding']\n                        # 解码为字符串\n                        ret_xml = raw_data.decode(encoding)\n                    except Exception as e:\n                        logger.debug(f\"chardet解码失败：{str(e)}\")\n                        # 探测utf-8解码\n                        match = re.search(r'encoding\\s*=\\s*[\"\\']([^\"\\']+)[\"\\']', ret.text)\n                        if match:\n                            encoding = match.group(1)\n                            if encoding:\n                                ret_xml = raw_data.decode(encoding)\n                        else:\n                            ret.encoding = ret.apparent_encoding\n                if not ret_xml:\n                    ret_xml = ret.text\n\n                # 验证RSS内容是否有效\n                if not ret_xml or not ret_xml.strip():\n                    logger.error(\"RSS内容为空\")\n                    return False\n\n                # 检查是否包含基本的RSS/XML结构\n                ret_xml_stripped = ret_xml.strip()\n                if not ret_xml_stripped.startswith('<'):\n                    logger.error(\"RSS内容不是有效的XML格式\")\n                    return False\n\n                # 使用lxml.etree解析XML\n                parser = None\n                try:\n                    # 创建解析器，禁用网络访问以提高安全性和性能\n                    parser = etree.XMLParser(\n                        recover=True,  # 容错模式\n                        strip_cdata=False,  # 保留CDATA\n                        resolve_entities=False,  # 禁用外部实体解析\n                        no_network=True,  # 禁用网络访问\n                        huge_tree=False  # 禁用大文档解析，避免内存问题\n                    )\n                    root = etree.fromstring(ret_xml.encode('utf-8'), parser=parser)\n                except etree.XMLSyntaxError as xml_error:\n                    logger.debug(f\"XML解析失败：{str(xml_error)}，尝试HTML解析\")\n                    # 如果XML解析失败，尝试作为HTML解析\n                    try:\n                        root = etree.HTML(ret_xml)\n                        if root is not None:\n                            # 查找RSS根节点\n                            rss_root = root.xpath('//rss | //feed')\n                            if rss_root:\n                                root = rss_root[0]\n                    except Exception as e:\n                        logger.error(f\"HTML解析也失败：{str(e)}\")\n                        return False\n                except Exception as general_error:\n                    logger.error(f\"解析RSS时发生未预期错误：{str(general_error)}\")\n                    return False\n                finally:\n                    if parser is not None:\n                        try:\n                            parser.close()\n                        except Exception as close_error:\n                            logger.debug(f\"关闭解析器时出错：{str(close_error)}\")\n                        del parser\n\n                if root is None:\n                    logger.error(\"无法解析RSS内容\")\n                    return False\n\n                # 查找所有item或entry节点\n                items = root.xpath('.//item | .//entry')\n\n                # 限制处理的条目数量\n                items_count = min(len(items), self.MAX_RSS_ITEMS)\n                if len(items) > self.MAX_RSS_ITEMS:\n                    logger.warning(f\"RSS条目过多: {len(items)}，仅处理前{self.MAX_RSS_ITEMS}个\")\n                try:\n                    for item in items[:items_count]:\n                        try:\n                            # 使用xpath提取信息，更高效\n                            title_nodes = item.xpath('.//title')\n                            title = title_nodes[0].text if title_nodes and title_nodes[0].text else \"\"\n                            if not title:\n                                continue\n\n                            # 描述\n                            desc_nodes = item.xpath('.//description | .//summary')\n                            description = desc_nodes[0].text if desc_nodes and desc_nodes[0].text else \"\"\n\n                            # 种子页面\n                            link_nodes = item.xpath('.//link')\n                            if link_nodes:\n                                link = link_nodes[0].text if hasattr(link_nodes[0], 'text') and link_nodes[0].text else link_nodes[0].get('href', '')\n                            else:\n                                link = \"\"\n\n                            # 种子链接\n                            enclosure_nodes = item.xpath('.//enclosure')\n                            enclosure = enclosure_nodes[0].get('url', '') if enclosure_nodes else \"\"\n                            if not enclosure and not link:\n                                continue\n                            # 部分RSS只有link没有enclosure\n                            if not enclosure and link:\n                                enclosure = link\n\n                            # 大小\n                            size = 0\n                            if enclosure_nodes:\n                                size_attr = enclosure_nodes[0].get('length', '0')\n                                if size_attr and str(size_attr).isdigit():\n                                    size = int(size_attr)\n\n                            # 发布日期\n                            pubdate_nodes = item.xpath('./pubDate | ./published | ./updated')\n                            if not pubdate_nodes:\n                                pubdate_nodes = item.xpath('.//*[local-name()=\"pubDate\"] | .//*[local-name()=\"published\"] | .//*[local-name()=\"updated\"]')\n\n                            pubdate = \"\"\n                            if pubdate_nodes and pubdate_nodes[0].text:\n                                pubdate = StringUtils.get_time(pubdate_nodes[0].text)\n                                if pubdate is not None:\n                                    # 转为本地时区\n                                    pubdate = pubdate.astimezone(tz=None)\n\n                            # 获取豆瓣昵称\n                            nickname_nodes = item.xpath('.//*[local-name()=\"creator\"]')\n                            nickname = nickname_nodes[0].text if nickname_nodes and nickname_nodes[0].text else \"\"\n\n                            # 返回对象\n                            tmp_dict = {\n                                'title': title,\n                                'enclosure': enclosure,\n                                'size': size,\n                                'description': description,\n                                'link': link,\n                                'pubdate': pubdate\n                            }\n                            # 如果豆瓣昵称不为空，返回数据增加豆瓣昵称，供doubansync插件获取\n                            if nickname:\n                                tmp_dict['nickname'] = nickname\n                            ret_array.append(tmp_dict)\n\n                        except Exception as e1:\n                            logger.debug(f\"解析RSS条目失败：{str(e1)} - {traceback.format_exc()}\")\n                            continue\n                finally:\n                    items.clear()\n                    del items\n\n            except Exception as e2:\n                logger.error(f\"解析RSS失败：{str(e2)} - {traceback.format_exc()}\")\n                # RSS过期检查\n                _rss_expired_msg = [\n                    \"RSS 链接已过期, 您需要获得一个新的!\",\n                    \"RSS Link has expired, You need to get a new one!\",\n                    \"RSS Link has expired, You need to get new!\"\n                ]\n                if ret_xml in _rss_expired_msg:\n                    return None\n                return False\n            finally:\n                if root is not None:\n                    del root\n                if ret_xml is not None:\n                    del ret_xml\n\n        return ret_array\n\n    def get_rss_link(self, url: str, cookie: str, ua: str, proxy: bool = False, timeout: int = None) -> Tuple[str, str]:\n        \"\"\"\n        获取站点rss地址\n        :param url: 站点地址\n        :param cookie: 站点cookie\n        :param ua: 站点ua\n        :param proxy: 是否使用代理\n        :param timeout: 请求超时时间\n        :return: rss地址、错误信息\n        \"\"\"\n        try:\n            # 获取站点域名\n            domain = StringUtils.get_url_domain(url)\n            # 获取配置\n            site_conf = self.rss_link_conf.get(domain) or self.rss_link_conf.get(\"default\")\n            # RSS地址\n            rss_url = urljoin(url, site_conf.get(\"url\"))\n            # RSS请求参数\n            rss_params = site_conf.get(\"params\")\n            # 请求RSS页面\n            if site_conf.get(\"render\"):\n                html_text = PlaywrightHelper().get_page_source(\n                    url=rss_url,\n                    cookies=cookie,\n                    ua=ua,\n                    proxies=settings.PROXY_SERVER if proxy else None,\n                    timeout=timeout or 60\n                )\n            else:\n                res = RequestUtils(\n                    cookies=cookie,\n                    timeout=timeout or 30,\n                    ua=ua,\n                    proxies=settings.PROXY if proxy else None\n                ).post_res(url=rss_url, data=rss_params)\n                if res:\n                    html_text = res.text\n                elif res is not None:\n                    return \"\", f\"获取 {url} RSS链接失败，错误码：{res.status_code}，错误原因：{res.reason}\"\n                else:\n                    return \"\", f\"获取RSS链接失败：无法连接 {url} \"\n\n            # 解析HTML\n            if html_text:\n                html = None\n                try:\n                    html = etree.HTML(html_text)\n                    if StringUtils.is_valid_html_element(html):\n                        rss_link = html.xpath(site_conf.get(\"xpath\"))\n                        if rss_link:\n                            return str(rss_link[-1]), \"\"\n                finally:\n                    if html is not None:\n                        del html\n\n            return \"\", f\"获取RSS链接失败：{url}\"\n        except Exception as e:\n            return \"\", f\"获取 {url} RSS链接失败：{str(e)}\"\n"
  },
  {
    "path": "app/helper/rule.py",
    "content": "from typing import List, Optional\n\nfrom app.core.context import MediaInfo\nfrom app.db.systemconfig_oper import SystemConfigOper\nfrom app.schemas import FilterRuleGroup, CustomRule\nfrom app.schemas.types import SystemConfigKey\n\n\nclass RuleHelper:\n    \"\"\"\n    规划帮助类\n    \"\"\"\n\n    @staticmethod\n    def get_rule_groups() -> List[FilterRuleGroup]:\n        \"\"\"\n        获取用户所有规则组\n        \"\"\"\n        rule_groups: List[dict] = SystemConfigOper().get(SystemConfigKey.UserFilterRuleGroups)\n        if not rule_groups:\n            return []\n        return [FilterRuleGroup(**group) for group in rule_groups]\n\n    def get_rule_group(self, group_name: str) -> Optional[FilterRuleGroup]:\n        \"\"\"\n        获取规则组\n        \"\"\"\n        rule_groups = self.get_rule_groups()\n        for group in rule_groups:\n            if group.name == group_name:\n                return group\n        return None\n\n    def get_rule_group_by_media(self, media: MediaInfo = None, group_names: list = None) -> List[FilterRuleGroup]:\n        \"\"\"\n        根据媒体信息获取规则组\n        \"\"\"\n        ret_groups = []\n        rule_groups = self.get_rule_groups()\n        if group_names:\n            rule_groups = [group for group in rule_groups if group.name in group_names]\n        for group in rule_groups:\n            if not group.media_type:\n                ret_groups.append(group)\n            elif media and not group.category and group.media_type == media.type.value:\n                ret_groups.append(group)\n            elif media and group.category == media.category:\n                ret_groups.append(group)\n        return ret_groups\n\n    @staticmethod\n    def get_custom_rules() -> List[CustomRule]:\n        \"\"\"\n        获取用户所有自定义规则\n        \"\"\"\n        rules: List[dict] = SystemConfigOper().get(SystemConfigKey.CustomFilterRules)\n        if not rules:\n            return []\n        return [CustomRule(**rule) for rule in rules]\n\n    def get_custom_rule(self, rule_id: str) -> Optional[CustomRule]:\n        \"\"\"\n        获取自定义规则\n        \"\"\"\n        rules = self.get_custom_rules()\n        for rule in rules:\n            if rule.id == rule_id:\n                return rule\n        return None\n"
  },
  {
    "path": "app/helper/service.py",
    "content": "from typing import Dict, List, Optional, Type, TypeVar, Generic, Iterator\n\nfrom app.core.module import ModuleManager\nfrom app.db.systemconfig_oper import SystemConfigOper\nfrom app.schemas import DownloaderConf, MediaServerConf, NotificationConf, NotificationSwitchConf, ServiceInfo\nfrom app.schemas.types import NotificationType, SystemConfigKey, ModuleType\n\nTConf = TypeVar(\"TConf\")\n\n\nclass ServiceConfigHelper:\n    \"\"\"\n    配置帮助类，获取不同类型的服务配置\n    \"\"\"\n\n    @staticmethod\n    def get_configs(config_key: SystemConfigKey, conf_type: Type) -> List:\n        \"\"\"\n        通用获取配置的方法，根据 config_key 获取相应的配置并返回指定类型的配置列表\n\n        :param config_key: 系统配置的 key\n        :param conf_type: 用于实例化配置对象的类类型\n        :return: 配置对象列表\n        \"\"\"\n        config_data = SystemConfigOper().get(config_key)\n        if not config_data:\n            return []\n        # 直接使用 conf_type 来实例化配置对象\n        return [conf_type(**conf) for conf in config_data]\n\n    @staticmethod\n    def get_downloader_configs() -> List[DownloaderConf]:\n        \"\"\"\n        获取下载器的配置\n        \"\"\"\n        return ServiceConfigHelper.get_configs(SystemConfigKey.Downloaders, DownloaderConf)\n\n    @staticmethod\n    def get_mediaserver_configs() -> List[MediaServerConf]:\n        \"\"\"\n        获取媒体服务器的配置\n        \"\"\"\n        return ServiceConfigHelper.get_configs(SystemConfigKey.MediaServers, MediaServerConf)\n\n    @staticmethod\n    def get_notification_configs() -> List[NotificationConf]:\n        \"\"\"\n        获取消息通知渠道的配置\n        \"\"\"\n        return ServiceConfigHelper.get_configs(SystemConfigKey.Notifications, NotificationConf)\n\n    @staticmethod\n    def get_notification_switches() -> List[NotificationSwitchConf]:\n        \"\"\"\n        获取消息通知场景的开关\n        \"\"\"\n        return ServiceConfigHelper.get_configs(SystemConfigKey.NotificationSwitchs, NotificationSwitchConf)\n\n    @staticmethod\n    def get_notification_switch(mtype: NotificationType) -> Optional[str]:\n        \"\"\"\n        获取指定类型的消息通知场景的开关\n        \"\"\"\n        switchs = ServiceConfigHelper.get_notification_switches()\n        for switch in switchs:\n            if switch.type == mtype.value:\n                return switch.action\n        return None\n\n\nclass ServiceBaseHelper(Generic[TConf]):\n    \"\"\"\n    通用服务帮助类，抽象获取配置和服务实例的通用逻辑\n    \"\"\"\n\n    def __init__(self, config_key: SystemConfigKey, conf_type: Type[TConf], module_type: ModuleType):\n        self.modulemanager = ModuleManager()\n        self.config_key = config_key\n        self.conf_type = conf_type\n        self.module_type = module_type\n\n    def get_configs(self, include_disabled: bool = False) -> Dict[str, TConf]:\n        \"\"\"\n        获取配置列表\n\n        :param include_disabled: 是否包含禁用的配置，默认 False（仅返回启用的配置）\n        :return: 配置字典\n        \"\"\"\n        configs: List[TConf] = ServiceConfigHelper.get_configs(self.config_key, self.conf_type)\n        return {\n            config.name: config\n            for config in configs\n            if (config.name and config.type and config.enabled) or include_disabled\n        } if configs else {}\n\n    def get_config(self, name: str) -> Optional[TConf]:\n        \"\"\"\n        获取指定名称配置\n        \"\"\"\n        if not name:\n            return None\n        configs = self.get_configs()\n        return configs.get(name)\n\n    def iterate_module_instances(self) -> Iterator[ServiceInfo]:\n        \"\"\"\n        迭代所有模块的实例及其对应的配置，返回 ServiceInfo 实例\n        \"\"\"\n        configs = self.get_configs()\n        for module in self.modulemanager.get_running_type_modules(self.module_type):\n            if not module:\n                continue\n            module_instances = module.get_instances()\n            if not isinstance(module_instances, dict):\n                continue\n            for name, instance in module_instances.items():\n                if not instance:\n                    continue\n                config = configs.get(name)\n                service_info = ServiceInfo(\n                    name=name,\n                    instance=instance,\n                    module=module,\n                    type=config.type if config else None,\n                    config=config\n                )\n                yield service_info\n\n    def get_services(self, type_filter: Optional[str] = None, name_filters: Optional[List[str]] = None) \\\n            -> Dict[str, ServiceInfo]:\n        \"\"\"\n        获取服务信息列表，并根据类型和名称列表进行过滤\n\n        :param type_filter: 需要过滤的服务类型\n        :param name_filters: 需要过滤的服务名称列表\n        :return: 过滤后的服务信息字典\n        \"\"\"\n        name_filters_set = set(name_filters) if name_filters else None\n\n        return {\n            service_info.name: service_info\n            for service_info in self.iterate_module_instances()\n            if service_info.config and (\n                    type_filter is None or service_info.type == type_filter\n            ) and (\n                       name_filters_set is None or service_info.name in name_filters_set)\n        }\n\n    def get_service(self, name: str, type_filter: Optional[str] = None) -> Optional[ServiceInfo]:\n        \"\"\"\n        获取指定名称的服务信息，并根据类型过滤\n\n        :param name: 服务名称\n        :param type_filter: 需要过滤的服务类型\n        :return: 对应的服务信息，若不存在或类型不匹配则返回 None\n        \"\"\"\n        if not name:\n            return None\n        for service_info in self.iterate_module_instances():\n            if service_info.name == name:\n                if service_info.config and (type_filter is None or service_info.type == type_filter):\n                    return service_info\n        return None\n"
  },
  {
    "path": "app/helper/storage.py",
    "content": "from typing import List, Optional\n\nfrom app import schemas\nfrom app.db.systemconfig_oper import SystemConfigOper\nfrom app.schemas.types import SystemConfigKey\n\n\nclass StorageHelper:\n    \"\"\"\n    存储帮助类\n    \"\"\"\n\n    @staticmethod\n    def get_storagies() -> List[schemas.StorageConf]:\n        \"\"\"\n        获取所有存储设置\n        \"\"\"\n        storage_confs: List[dict] = SystemConfigOper().get(SystemConfigKey.Storages)\n        if not storage_confs:\n            return []\n        return [schemas.StorageConf(**s) for s in storage_confs]\n\n    def get_storage(self, storage: str) -> Optional[schemas.StorageConf]:\n        \"\"\"\n        获取指定存储配置\n        \"\"\"\n        storagies = self.get_storagies()\n        for s in storagies:\n            if s.type == storage:\n                return s\n        return None\n\n    def set_storage(self, storage: str, conf: dict):\n        \"\"\"\n        设置存储配置\n        \"\"\"\n        storagies = self.get_storagies()\n        if not storagies:\n            storagies = [\n                schemas.StorageConf(\n                    type=storage,\n                    config=conf\n                )\n            ]\n        else:\n            for s in storagies:\n                if s.type == storage:\n                    s.config = conf\n                    break\n        SystemConfigOper().set(SystemConfigKey.Storages, [s.model_dump() for s in storagies])\n\n    def add_storage(self, storage: str, name: str, conf: dict):\n        \"\"\"\n        添加存储配置\n        \"\"\"\n        storagies = self.get_storagies()\n        if not storagies:\n            storagies = [\n                schemas.StorageConf(\n                    type=storage,\n                    name=name,\n                    config=conf\n                )\n            ]\n        else:\n            storagies.append(schemas.StorageConf(\n                type=storage,\n                name=name,\n                config=conf\n            ))\n        SystemConfigOper().set(SystemConfigKey.Storages, [s.model_dump() for s in storagies])\n\n    def reset_storage(self, storage: str):\n        \"\"\"\n        重置存储配置\n        \"\"\"\n        storagies = self.get_storagies()\n        for s in storagies:\n            if s.type == storage:\n                s.config = {}\n                break\n        SystemConfigOper().set(SystemConfigKey.Storages, [s.model_dump() for s in storagies])\n"
  },
  {
    "path": "app/helper/subscribe.py",
    "content": "from threading import Thread\nfrom typing import List, Tuple, Optional\n\nfrom app.core.cache import cached\nfrom app.core.config import settings\nfrom app.db.subscribe_oper import SubscribeOper\nfrom app.db.systemconfig_oper import SystemConfigOper\nfrom app.log import logger\nfrom app.schemas.types import SystemConfigKey\nfrom app.utils.http import RequestUtils, AsyncRequestUtils\nfrom app.utils.singleton import WeakSingleton\nfrom app.utils.system import SystemUtils\n\n\nclass SubscribeHelper(metaclass=WeakSingleton):\n    \"\"\"\n    订阅数据统计/订阅分享等\n    \"\"\"\n\n    _sub_reg = f\"{settings.MP_SERVER_HOST}/subscribe/add\"\n\n    _sub_done = f\"{settings.MP_SERVER_HOST}/subscribe/done\"\n\n    _sub_report = f\"{settings.MP_SERVER_HOST}/subscribe/report\"\n\n    _sub_statistic = f\"{settings.MP_SERVER_HOST}/subscribe/statistic\"\n\n    _sub_share = f\"{settings.MP_SERVER_HOST}/subscribe/share\"\n\n    _sub_shares = f\"{settings.MP_SERVER_HOST}/subscribe/shares\"\n\n    _sub_share_statistic = f\"{settings.MP_SERVER_HOST}/subscribe/share/statistics\"\n\n    _sub_fork = f\"{settings.MP_SERVER_HOST}/subscribe/fork/%s\"\n\n    _shares_cache_region = \"subscribe_share\"\n\n    _github_user = None\n\n    _share_user_id = None\n\n    _admin_users = [\n        \"jxxghp\",\n        \"thsrite\",\n        \"InfinityPacer\",\n        \"DDSRem\",\n        \"Aqr-K\",\n        \"Putarku\",\n        \"4Nest\",\n        \"xyswordzoro\",\n        \"wikrin\"\n    ]\n\n    def __init__(self):\n        systemconfig = SystemConfigOper()\n        if settings.SUBSCRIBE_STATISTIC_SHARE:\n            if not systemconfig.get(SystemConfigKey.SubscribeReport):\n                if self.sub_report():\n                    systemconfig.set(SystemConfigKey.SubscribeReport, \"1\")\n        self.get_user_uuid()\n        self.get_github_user()\n\n    @staticmethod\n    def _check_subscribe_share_enabled() -> Tuple[bool, str]:\n        \"\"\"\n        检查订阅分享功能是否开启\n        \"\"\"\n        if not settings.SUBSCRIBE_STATISTIC_SHARE:\n            return False, \"当前没有开启订阅数据共享功能\"\n        return True, \"\"\n\n    @staticmethod\n    def _validate_subscribe(subscribe) -> Tuple[bool, str]:\n        \"\"\"\n        验证订阅是否存在\n        \"\"\"\n        if not subscribe:\n            return False, \"订阅不存在\"\n        return True, \"\"\n\n    @staticmethod\n    def _prepare_subscribe_data(subscribe) -> dict:\n        \"\"\"\n        准备订阅分享数据\n        \"\"\"\n        subscribe_dict = subscribe.to_dict()\n        subscribe_dict.pop(\"id\", None)\n        return subscribe_dict\n\n    def _build_share_payload(self, share_title: str, share_comment: str,\n                             share_user: str, subscribe_dict: dict) -> dict:\n        \"\"\"\n        构建分享请求载荷\n        \"\"\"\n        return {\n            \"share_title\": share_title,\n            \"share_comment\": share_comment,\n            \"share_user\": share_user,\n            \"share_uid\": self._share_user_id,\n            **subscribe_dict\n        }\n\n    def _handle_response(self, res, clear_cache: bool = True) -> Tuple[bool, str]:\n        \"\"\"\n        处理HTTP响应\n        \"\"\"\n        if res is None:\n            return False, \"连接MoviePilot服务器失败\"\n\n        # 检查响应状态\n        if res and res.status_code == 200:\n            # 清除缓存\n            if clear_cache:\n                self.get_shares.cache_clear()\n                self.get_statistic.cache_clear()\n                self.get_share_statistics.cache_clear()\n                self.async_get_shares.cache_clear()\n                self.async_get_statistic.cache_clear()\n                self.async_get_share_statistics.cache_clear()\n            return True, \"\"\n        else:\n            return False, res.json().get(\"message\")\n\n    @staticmethod\n    def _handle_list_response(res) -> List[dict]:\n        \"\"\"\n        处理返回List的HTTP响应\n        \"\"\"\n        if res and res.status_code == 200:\n            return res.json()\n        return []\n\n    @cached(region=_shares_cache_region, maxsize=5, ttl=1800, skip_empty=True)\n    def get_statistic(self, stype: str, page: Optional[int] = 1, count: Optional[int] = 30,\n                      genre_id: Optional[int] = None, min_rating: Optional[float] = None,\n                      max_rating: Optional[float] = None, sort_type: Optional[str] = None) -> List[dict]:\n        \"\"\"\n        获取订阅统计数据\n        \"\"\"\n        enabled, _ = self._check_subscribe_share_enabled()\n        if not enabled:\n            return []\n\n        params = {\n            \"stype\": stype,\n            \"page\": page,\n            \"count\": count\n        }\n\n        # 添加可选参数\n        if genre_id is not None:\n            params[\"genre_id\"] = genre_id\n        if min_rating is not None:\n            params[\"min_rating\"] = min_rating\n        if max_rating is not None:\n            params[\"max_rating\"] = max_rating\n        if sort_type is not None:\n            params[\"sort_type\"] = sort_type\n\n        res = RequestUtils(proxies=settings.PROXY, timeout=15).get_res(self._sub_statistic, params=params)\n\n        return self._handle_list_response(res)\n\n    @cached(region=_shares_cache_region, maxsize=5, ttl=1800, skip_empty=True)\n    async def async_get_statistic(self, stype: str, page: Optional[int] = 1, count: Optional[int] = 30,\n                                  genre_id: Optional[int] = None, min_rating: Optional[float] = None,\n                                  max_rating: Optional[float] = None, sort_type: Optional[str] = None) -> List[dict]:\n        \"\"\"\n        异步获取订阅统计数据\n        \"\"\"\n        enabled, _ = self._check_subscribe_share_enabled()\n        if not enabled:\n            return []\n\n        params = {\n            \"stype\": stype,\n            \"page\": page,\n            \"count\": count\n        }\n\n        # 添加可选参数\n        if genre_id is not None:\n            params[\"genre_id\"] = genre_id\n        if min_rating is not None:\n            params[\"min_rating\"] = min_rating\n        if max_rating is not None:\n            params[\"max_rating\"] = max_rating\n        if sort_type is not None:\n            params[\"sort_type\"] = sort_type\n\n        res = await AsyncRequestUtils(proxies=settings.PROXY, timeout=15).get_res(self._sub_statistic, params=params)\n\n        return self._handle_list_response(res)\n\n    def sub_reg(self, sub: dict) -> bool:\n        \"\"\"\n        新增订阅统计\n        \"\"\"\n        enabled, _ = self._check_subscribe_share_enabled()\n        if not enabled:\n            return False\n        res = RequestUtils(proxies=settings.PROXY, timeout=5, headers={\n            \"Content-Type\": \"application/json\"\n        }).post_res(self._sub_reg, json=sub)\n        if res and res.status_code == 200:\n            return True\n        return False\n\n    async def async_sub_reg(self, sub: dict) -> bool:\n        \"\"\"\n        异步新增订阅统计\n        \"\"\"\n        enabled, _ = self._check_subscribe_share_enabled()\n        if not enabled:\n            return False\n        res = await AsyncRequestUtils(proxies=settings.PROXY, timeout=5, headers={\n            \"Content-Type\": \"application/json\"\n        }).post_res(self._sub_reg, json=sub)\n        if res and res.status_code == 200:\n            return True\n        return False\n\n    def sub_done(self, sub: dict) -> bool:\n        \"\"\"\n        完成订阅统计\n        \"\"\"\n        enabled, _ = self._check_subscribe_share_enabled()\n        if not enabled:\n            return False\n        res = RequestUtils(proxies=settings.PROXY, timeout=5, headers={\n            \"Content-Type\": \"application/json\"\n        }).post_res(self._sub_done, json=sub)\n        if res and res.status_code == 200:\n            return True\n        return False\n\n    def sub_reg_async(self, sub: dict) -> bool:\n        \"\"\"\n        异步新增订阅统计\n        \"\"\"\n        # 开新线程处理\n        Thread(target=self.sub_reg, args=(sub,)).start()\n        return True\n\n    def sub_done_async(self, sub: dict) -> bool:\n        \"\"\"\n        异步完成订阅统计\n        \"\"\"\n        # 开新线程处理\n        Thread(target=self.sub_done, args=(sub,)).start()\n        return True\n\n    def sub_report(self) -> bool:\n        \"\"\"\n        上报存量订阅统计\n        \"\"\"\n        enabled, _ = self._check_subscribe_share_enabled()\n        if not enabled:\n            return False\n        subscribes = SubscribeOper().list()\n        if not subscribes:\n            return True\n        res = RequestUtils(proxies=settings.PROXY, content_type=\"application/json\",\n                           timeout=10).post(self._sub_report,\n                                            json={\n                                                \"subscribes\": [\n                                                    sub.to_dict() for sub in subscribes\n                                                ]\n                                            })\n        return True if res else False\n\n    def sub_share(self, subscribe_id: int,\n                  share_title: str, share_comment: str, share_user: str) -> Tuple[bool, str]:\n        \"\"\"\n        分享订阅\n        \"\"\"\n        # 检查功能是否开启\n        enabled, message = self._check_subscribe_share_enabled()\n        if not enabled:\n            return False, message\n\n        # 获取订阅信息\n        subscribe = SubscribeOper().get(subscribe_id)\n\n        # 验证订阅\n        valid, message = self._validate_subscribe(subscribe)\n        if not valid:\n            return False, message\n\n        # 准备数据\n        subscribe_dict = self._prepare_subscribe_data(subscribe)\n        payload = self._build_share_payload(share_title, share_comment, share_user, subscribe_dict)\n\n        # 发送分享请求\n        res = RequestUtils(proxies=settings.PROXY, content_type=\"application/json\",\n                           timeout=10).post(self._sub_share, json=payload)\n\n        return self._handle_response(res)\n\n    async def async_sub_share(self, subscribe_id: int,\n                              share_title: str, share_comment: str, share_user: str) -> Tuple[bool, str]:\n        \"\"\"\n        异步分享订阅\n        \"\"\"\n        # 检查功能是否开启\n        enabled, message = self._check_subscribe_share_enabled()\n        if not enabled:\n            return False, message\n\n        # 获取订阅信息\n        subscribe = await SubscribeOper().async_get(subscribe_id)\n\n        # 验证订阅\n        valid, message = self._validate_subscribe(subscribe)\n        if not valid:\n            return False, message\n\n        # 准备数据\n        subscribe_dict = self._prepare_subscribe_data(subscribe)\n        payload = self._build_share_payload(share_title, share_comment, share_user, subscribe_dict)\n\n        # 发送分享请求\n        res = await AsyncRequestUtils(proxies=settings.PROXY, content_type=\"application/json\",\n                                      timeout=10).post(self._sub_share, json=payload)\n\n        return self._handle_response(res)\n\n    def share_delete(self, share_id: int) -> Tuple[bool, str]:\n        \"\"\"\n        删除分享\n        \"\"\"\n        # 检查功能是否开启\n        enabled, message = self._check_subscribe_share_enabled()\n        if not enabled:\n            return False, message\n\n        res = RequestUtils(proxies=settings.PROXY,\n                           timeout=5).delete_res(f\"{self._sub_share}/{share_id}\",\n                                                 params={\"share_uid\": self._share_user_id})\n\n        return self._handle_response(res)\n\n    async def async_share_delete(self, share_id: int) -> Tuple[bool, str]:\n        \"\"\"\n        异步删除分享\n        \"\"\"\n        # 检查功能是否开启\n        enabled, message = self._check_subscribe_share_enabled()\n        if not enabled:\n            return False, message\n\n        res = await AsyncRequestUtils(proxies=settings.PROXY,\n                                      timeout=5).delete_res(f\"{self._sub_share}/{share_id}\",\n                                                            params={\"share_uid\": self._share_user_id})\n\n        return self._handle_response(res)\n\n    def sub_fork(self, share_id: int) -> Tuple[bool, str]:\n        \"\"\"\n        复用分享的订阅\n        \"\"\"\n        # 检查功能是否开启\n        enabled, message = self._check_subscribe_share_enabled()\n        if not enabled:\n            return False, message\n\n        res = RequestUtils(proxies=settings.PROXY, timeout=5, headers={\n            \"Content-Type\": \"application/json\"\n        }).get_res(self._sub_fork % share_id)\n\n        return self._handle_response(res, clear_cache=False)\n\n    async def async_sub_fork(self, share_id: int) -> Tuple[bool, str]:\n        \"\"\"\n        异步复用分享的订阅\n        \"\"\"\n        # 检查功能是否开启\n        enabled, message = self._check_subscribe_share_enabled()\n        if not enabled:\n            return False, message\n\n        res = await AsyncRequestUtils(proxies=settings.PROXY, timeout=5, headers={\n            \"Content-Type\": \"application/json\"\n        }).get_res(self._sub_fork % share_id)\n\n        return self._handle_response(res, clear_cache=False)\n\n    @cached(region=_shares_cache_region, maxsize=1, ttl=1800, skip_empty=True)\n    def get_shares(self, name: Optional[str] = None, page: Optional[int] = 1, count: Optional[int] = 30,\n                   genre_id: Optional[int] = None, min_rating: Optional[float] = None,\n                   max_rating: Optional[float] = None, sort_type: Optional[str] = None) -> List[dict]:\n        \"\"\"\n        获取订阅分享数据\n        \"\"\"\n        enabled, _ = self._check_subscribe_share_enabled()\n        if not enabled:\n            return []\n\n        params = {\n            \"name\": name,\n            \"page\": page,\n            \"count\": count\n        }\n        \n        # 添加可选参数\n        if genre_id is not None:\n            params[\"genre_id\"] = genre_id\n        if min_rating is not None:\n            params[\"min_rating\"] = min_rating\n        if max_rating is not None:\n            params[\"max_rating\"] = max_rating\n        if sort_type is not None:\n            params[\"sort_type\"] = sort_type\n\n        res = RequestUtils(proxies=settings.PROXY, timeout=15).get_res(self._sub_shares, params=params)\n\n        return self._handle_list_response(res)\n\n    @cached(region=_shares_cache_region, maxsize=1, ttl=1800, skip_empty=True)\n    async def async_get_shares(self, name: Optional[str] = None, page: Optional[int] = 1, count: Optional[int] = 30,\n                               genre_id: Optional[int] = None, min_rating: Optional[float] = None,\n                               max_rating: Optional[float] = None, sort_type: Optional[str] = None) -> List[dict]:\n        \"\"\"\n        异步获取订阅分享数据\n        \"\"\"\n        enabled, _ = self._check_subscribe_share_enabled()\n        if not enabled:\n            return []\n\n        params = {\n            \"name\": name,\n            \"page\": page,\n            \"count\": count\n        }\n        \n        # 添加可选参数\n        if genre_id is not None:\n            params[\"genre_id\"] = genre_id\n        if min_rating is not None:\n            params[\"min_rating\"] = min_rating\n        if max_rating is not None:\n            params[\"max_rating\"] = max_rating\n        if sort_type is not None:\n            params[\"sort_type\"] = sort_type\n\n        res = await AsyncRequestUtils(proxies=settings.PROXY, timeout=15).get_res(self._sub_shares, params=params)\n\n        return self._handle_list_response(res)\n\n    @cached(region=_shares_cache_region, maxsize=1, ttl=1800, skip_empty=True)\n    def get_share_statistics(self) -> List[dict]:\n        \"\"\"\n        获取订阅分享统计数据\n        \"\"\"\n        enabled, _ = self._check_subscribe_share_enabled()\n        if not enabled:\n            return []\n\n        res = RequestUtils(proxies=settings.PROXY, timeout=15).get_res(self._sub_share_statistic)\n\n        return self._handle_list_response(res)\n\n    @cached(region=_shares_cache_region, maxsize=1, ttl=1800, skip_empty=True)\n    async def async_get_share_statistics(self) -> List[dict]:\n        \"\"\"\n        异步获取订阅分享统计数据\n        \"\"\"\n        enabled, _ = self._check_subscribe_share_enabled()\n        if not enabled:\n            return []\n\n        res = await AsyncRequestUtils(proxies=settings.PROXY, timeout=15).get_res(self._sub_share_statistic)\n\n        return self._handle_list_response(res)\n\n    def get_user_uuid(self) -> str:\n        \"\"\"\n        获取用户uuid\n        \"\"\"\n        if not self._share_user_id:\n            self._share_user_id = SystemUtils.generate_user_unique_id()\n            logger.info(f\"当前用户UUID: {self._share_user_id}\")\n        return self._share_user_id\n\n    def get_github_user(self) -> str:\n        \"\"\"\n        获取github用户\n        \"\"\"\n        if self._github_user is None and settings.GITHUB_HEADERS:\n            res = RequestUtils(headers=settings.GITHUB_HEADERS,\n                               proxies=settings.PROXY,\n                               timeout=15).get_res(f\"https://api.github.com/user\")\n            if res:\n                self._github_user = res.json().get(\"login\")\n                logger.info(f\"当前Github用户: {self._github_user}\")\n        return self._github_user\n\n    def is_admin_user(self) -> bool:\n        \"\"\"\n        判断是否是管理员\n        \"\"\"\n        if not self._github_user:\n            return False\n        if self._github_user in self._admin_users:\n            return True\n        return False\n"
  },
  {
    "path": "app/helper/system.py",
    "content": "import os\nimport signal\nimport threading\nimport time\nfrom pathlib import Path\nfrom typing import Tuple\n\nimport docker\n\nfrom app.core.config import settings\nfrom app.log import logger\nfrom app.utils.mixins import ConfigReloadMixin\nfrom app.utils.system import SystemUtils\n\n\nclass SystemHelper(ConfigReloadMixin):\n    \"\"\"\n    系统工具类，提供系统相关的操作和判断\n    \"\"\"\n    CONFIG_WATCH = {\n        \"DEBUG\",\n        \"LOG_LEVEL\",\n        \"LOG_MAX_FILE_SIZE\",\n        \"LOG_BACKUP_COUNT\",\n        \"LOG_FILE_FORMAT\",\n        \"LOG_CONSOLE_FORMAT\",\n    }\n\n    __system_flag_file = \"/var/log/nginx/__moviepilot__\"\n\n    def on_config_changed(self):\n        logger.update_loggers()\n\n    def get_reload_name(self):\n        return \"日志设置\"\n\n    @staticmethod\n    def can_restart() -> bool:\n        \"\"\"\n        判断是否可以内部重启\n        \"\"\"\n        return (\n                Path(\"/var/run/docker.sock\").exists()\n                or settings.DOCKER_CLIENT_API != \"tcp://127.0.0.1:38379\"\n        )\n\n    @staticmethod\n    def _get_container_id() -> str:\n        \"\"\"\n        获取当前容器ID\n        \"\"\"\n        container_id = None\n        try:\n            with open(\"/proc/self/mountinfo\", \"r\") as f:\n                data = f.read()\n                index_resolv_conf = data.find(\"resolv.conf\")\n                if index_resolv_conf != -1:\n                    index_second_slash = data.rfind(\"/\", 0, index_resolv_conf)\n                    index_first_slash = data.rfind(\"/\", 0, index_second_slash) + 1\n                    container_id = data[index_first_slash:index_second_slash]\n                    if len(container_id) < 20:\n                        index_resolv_conf = data.find(\"/sys/fs/cgroup/devices\")\n                        if index_resolv_conf != -1:\n                            index_second_slash = data.rfind(\" \", 0, index_resolv_conf)\n                            index_first_slash = (\n                                    data.rfind(\"/\", 0, index_second_slash) + 1\n                            )\n                            container_id = data[index_first_slash:index_second_slash]\n        except Exception as e:\n            logger.debug(f\"获取容器ID失败: {str(e)}\")\n        return container_id.strip() if container_id else None\n\n    @staticmethod\n    def _check_restart_policy() -> bool:\n        \"\"\"\n        检查当前容器是否配置了自动重启策略\n        \"\"\"\n        try:\n            # 获取当前容器ID\n            container_id = SystemHelper._get_container_id()\n            if not container_id:\n                return False\n\n            # 创建 Docker 客户端\n            client = docker.DockerClient(base_url=settings.DOCKER_CLIENT_API)\n            # 获取容器信息\n            container = client.containers.get(container_id)\n            restart_policy = container.attrs.get('HostConfig', {}).get('RestartPolicy', {})\n            policy_name = restart_policy.get('Name', 'no')\n            # 检查是否有有效的重启策略\n            auto_restart_policies = ['always', 'unless-stopped', 'on-failure']\n            has_restart_policy = policy_name in auto_restart_policies\n\n            logger.info(f\"容器重启策略: {policy_name}, 支持自动重启: {has_restart_policy}\")\n            return has_restart_policy\n\n        except Exception as e:\n            logger.warning(f\"检查重启策略失败: {str(e)}\")\n            return False\n\n    @staticmethod\n    def restart() -> Tuple[bool, str]:\n        \"\"\"\n        执行Docker重启操作\n        \"\"\"\n        if not SystemUtils.is_docker():\n            return False, \"非Docker环境，无法重启！\"\n\n        try:\n            # 检查容器是否配置了自动重启策略\n            has_restart_policy = SystemHelper._check_restart_policy()\n            if has_restart_policy:\n                # 有重启策略，使用优雅退出方式\n                logger.info(\"检测到容器配置了自动重启策略，使用优雅重启方式...\")\n                # 启动优雅退出超时监控\n                SystemHelper._start_graceful_shutdown_monitor()\n                # 发送SIGTERM信号给当前进程，触发优雅停止\n                os.kill(os.getpid(), signal.SIGTERM)\n                return True, \"\"\n            else:\n                # 没有重启策略，使用Docker API强制重启\n                logger.info(\"容器未配置自动重启策略，使用Docker API重启...\")\n                return SystemHelper._docker_api_restart()\n        except Exception as err:\n            logger.error(f\"重启失败: {str(err)}\")\n            # 降级为Docker API重启\n            logger.warning(\"降级为Docker API重启...\")\n            return SystemHelper._docker_api_restart()\n\n    @staticmethod\n    def _start_graceful_shutdown_monitor():\n        \"\"\"\n        启动优雅退出超时监控\n        如果30秒内进程没有退出，则使用Docker API强制重启\n        \"\"\"\n\n        def monitor_thread():\n            time.sleep(30)  # 等待30秒\n            logger.warning(\"优雅退出超时30秒，使用Docker API强制重启...\")\n            try:\n                SystemHelper._docker_api_restart()\n            except Exception as e:\n                logger.error(f\"强制重启失败: {str(e)}\")\n\n        # 在后台线程中启动监控\n        thread = threading.Thread(target=monitor_thread, daemon=True)\n        thread.start()\n\n    @staticmethod\n    def _docker_api_restart() -> Tuple[bool, str]:\n        \"\"\"\n        使用Docker API重启容器，并尝试优雅停止\n        \"\"\"\n        try:\n            # 创建 Docker 客户端\n            client = docker.DockerClient(base_url=settings.DOCKER_CLIENT_API)\n            container_id = SystemHelper._get_container_id()\n            if not container_id:\n                return False, \"获取容器ID失败！\"\n            # 重启容器\n            client.containers.get(container_id).restart()\n            return True, \"\"\n        except Exception as docker_err:\n            return False, f\"重启时发生错误：{str(docker_err)}\"\n\n    def set_system_modified(self):\n        \"\"\"\n        设置系统已修改标志\n        \"\"\"\n        try:\n            if SystemUtils.is_docker():\n                Path(self.__system_flag_file).touch(exist_ok=True)\n        except Exception as e:\n            print(f\"设置系统修改标志失败: {str(e)}\")\n\n    def is_system_reset(self) -> bool:\n        \"\"\"\n        检查系统是否已被重置\n        :return: 如果系统已重置，返回 True；否则返回 False\n        \"\"\"\n        if SystemUtils.is_docker():\n            return not Path(self.__system_flag_file).exists()\n        return False\n"
  },
  {
    "path": "app/helper/thread.py",
    "content": "from concurrent.futures import ThreadPoolExecutor\n\nfrom app.core.config import settings\nfrom app.utils.singleton import Singleton\n\n\nclass ThreadHelper(metaclass=Singleton):\n    \"\"\"\n    线程池管理\n    \"\"\"\n    def __init__(self):\n        self.pool = ThreadPoolExecutor(max_workers=settings.CONF.threadpool)\n\n    def submit(self, func, *args, **kwargs):\n        \"\"\"\n        提交任务\n        :param func: 函数\n        :param args: 参数\n        :param kwargs: 参数\n        :return: future\n        \"\"\"\n        return self.pool.submit(func, *args, **kwargs)\n\n    def shutdown(self):\n        \"\"\"\n        关闭线程池\n        :return:\n        \"\"\"\n        self.pool.shutdown()\n"
  },
  {
    "path": "app/helper/torrent.py",
    "content": "import datetime\nimport re\nfrom pathlib import Path\nfrom typing import Tuple, Optional, List, Union, Dict, Any\nfrom urllib.parse import unquote\n\nfrom torrentool.api import Torrent\n\nfrom app.core.cache import TTLCache, FileCache\nfrom app.core.config import settings\nfrom app.core.context import Context, TorrentInfo, MediaInfo\nfrom app.core.meta import MetaBase\nfrom app.core.metainfo import MetaInfo\nfrom app.db.site_oper import SiteOper\nfrom app.db.systemconfig_oper import SystemConfigOper\nfrom app.log import logger\nfrom app.schemas.types import MediaType, SystemConfigKey\nfrom app.utils.http import RequestUtils\nfrom app.utils.string import StringUtils\n\n\nclass TorrentHelper:\n    \"\"\"\n    种子帮助类\n    \"\"\"\n\n    def __init__(self):\n        self._invalid_torrents = TTLCache(region=\"invalid_torrents\", maxsize=128, ttl=3600 * 24)\n\n    def download_torrent(self, url: str,\n                         cookie: Optional[str] = None,\n                         ua: Optional[str] = None,\n                         referer: Optional[str] = None,\n                         proxy: Optional[bool] = False) \\\n            -> Tuple[Optional[Path], Optional[Union[str, bytes]], Optional[str], Optional[list], Optional[str]]:\n        \"\"\"\n        把种子下载到本地\n        :return: 种子缓存相对路径【用于索引缓存】, 种子内容、种子主目录、种子文件清单、错误信息\n        \"\"\"\n        if url.startswith(\"magnet:\"):\n            return None, url, \"\", [], f\"磁力链接\"\n        # 构建 torrent 种子文件的缓存路径\n        cache_path = Path(StringUtils.md5_hash(url)).with_suffix(\".torrent\")\n        # 缓存处理器\n        cache_backend = FileCache()\n        # 读取缓存的种子文件\n        torrent_content = cache_backend.get(cache_path.as_posix(), region=\"torrents\")\n        if torrent_content:\n            # 缓存已存在\n            try:\n                # 获取种子目录和文件清单\n                folder_name, file_list = self.get_fileinfo_from_torrent_content(torrent_content)\n                # 无法获取信息，则认为缓存文件无效\n                if not folder_name and not file_list:\n                    raise ValueError(\"无效的缓存种子文件\")\n                # 成功拿到种子数据\n                return cache_path, torrent_content, folder_name, file_list, \"\"\n            except Exception as err:\n                logger.error(f\"处理缓存的种子文件 {cache_path} 时出错: {err}，将重新下载\")\n        # 下载种子文件\n        req = RequestUtils(\n            ua=ua,\n            cookies=cookie,\n            referer=referer,\n            proxies=settings.PROXY if proxy else None\n        ).get_res(url=url, allow_redirects=False)\n        while req and req.status_code in [301, 302]:\n            url = req.headers['Location']\n            if url and url.startswith(\"magnet:\"):\n                return None, url, \"\", [], f\"获取到磁力链接\"\n            req = RequestUtils(\n                ua=ua,\n                cookies=cookie,\n                referer=referer,\n                proxies=settings.PROXY if proxy else None\n            ).get_res(url=url, allow_redirects=False)\n        if req and req.status_code == 200:\n            if not req.content:\n                return cache_path, None, \"\", [], \"未下载到种子数据\"\n            # 解析内容格式\n            if req.content.startswith(b\"magnet:\"):\n                # 磁力链接\n                return cache_path, req.text, \"\", [], f\"获取到磁力链接\"\n            if \"下载种子文件\".encode(\"utf-8\") in req.content:\n                # 首次下载提示页面\n                skip_flag = False\n                try:\n                    forms = re.findall(r'<form.*?action=\"(.*?)\".*?>(.*?)</form>', req.text, re.S)\n                    for form in forms:\n                        action = form[0]\n                        if action != \"?\":\n                            continue\n                        action = url\n                        inputs = re.findall(r'<input.*?name=\"(.*?)\".*?value=\"(.*?)\".*?>', form[1], re.S)\n                        if inputs:\n                            data = {}\n                            for item in inputs:\n                                data[item[0]] = item[1]\n                            # 改写req\n                            req = RequestUtils(\n                                ua=ua,\n                                cookies=cookie,\n                                referer=referer,\n                                proxies=settings.PROXY if proxy else None\n                            ).post_res(url=action, data=data)\n                            if req and req.status_code == 200:\n                                # 检查是不是种子文件，如果不是抛出异常\n                                Torrent.from_string(req.content)\n                                # 跳过成功\n                                logger.info(f\"触发了站点首次种子下载，已自动跳过：{url}\")\n                                skip_flag = True\n                            elif req is not None:\n                                logger.warn(f\"触发了站点首次种子下载，且无法自动跳过，\"\n                                            f\"返回码：{req.status_code}，错误原因：{req.reason}\")\n                            else:\n                                logger.warn(f\"触发了站点首次种子下载，且无法自动跳过：{url}\")\n                        break\n                except Exception as err:\n                    logger.warn(f\"触发了站点首次种子下载，尝试自动跳过时出现错误：{str(err)}，链接：{url}\")\n                if not skip_flag:\n                    return cache_path, None, \"\", [], \"种子数据有误，请确认链接是否正确，如为PT站点则需手工在站点下载一次种子\"\n            # 种子内容\n            if req.content:\n                # 检查是不是种子文件，如果不是仍然抛出异常\n                try:\n                    # 获取种子目录和文件清单\n                    folder_name, file_list = self.get_fileinfo_from_torrent_content(req.content)\n                    if file_list:\n                        # 保存到缓存\n                        cache_backend.set(cache_path.as_posix(), req.content, region=\"torrents\")\n                    # 成功拿到种子数据\n                    return cache_path, req.content, folder_name, file_list, \"\"\n                except Exception as err:\n                    logger.error(f\"种子文件解析失败：{str(err)}\")\n                # 种子数据仍然错误\n                return cache_path, None, \"\", [], \"种子数据有误，请确认链接是否正确\"\n            # 返回失败\n            return cache_path, None, \"\", [], \"\"\n        elif req is None:\n            return cache_path, None, \"\", [], \"无法打开链接\"\n        elif req.status_code == 429:\n            return cache_path, None, \"\", [], \"触发站点流控，请稍后重试\"\n        else:\n            # 把错误的种子记下来，避免重复使用\n            self.add_invalid(url)\n            return cache_path, None, \"\", [], f\"下载种子出错，状态码：{req.status_code}\"\n\n    def get_torrent_info(self, torrent_path: Path) -> Tuple[str, List[str]]:\n        \"\"\"\n        获取种子文件的文件夹名和文件清单\n        :param torrent_path: 种子文件路径\n        :return: 文件夹名、文件清单，单文件种子返回空文件夹名\n        \"\"\"\n        if not torrent_path or not torrent_path.exists():\n            return \"\", []\n        try:\n            torrentinfo = Torrent.from_file(torrent_path)\n            # 获取文件清单\n            return self.get_fileinfo_from_torrent(torrentinfo)\n        except Exception as err:\n            logger.error(f\"种子文件解析失败：{str(err)}\")\n            return \"\", []\n\n    @staticmethod\n    def get_fileinfo_from_torrent(torrent: Torrent) -> Tuple[str, List[str]]:\n        \"\"\"\n        从种子文件中获取文件清单\n        :param torrent: 种子文件对象\n        :return: 文件夹名、文件清单，单文件种子返回空文件夹名\n        \"\"\"\n        if not torrent or not torrent.files:\n            return \"\", []\n        # 获取文件清单\n        if len(torrent.files) == 1 and torrent.files[0].name == torrent.name:\n            # 单文件种子目录名返回空\n            folder_name = \"\"\n            # 单文件种子\n            file_list = [torrent.name]\n        else:\n            # 目录名\n            folder_name = torrent.name\n            # 文件清单，如果一级目录与种子名相同则去掉\n            file_list = []\n            for fileinfo in torrent.files:\n                file_path = Path(fileinfo.name)\n                # 根路径\n                root_path = file_path.parts[0]\n                if root_path == folder_name:\n                    file_list.append(str(file_path.relative_to(root_path)))\n                else:\n                    file_list.append(fileinfo.name)\n        logger.debug(f\"解析种子：{torrent.name} => 目录：{folder_name}，文件清单：{file_list}\")\n        return folder_name, file_list\n\n    def get_fileinfo_from_torrent_content(self, torrent_content: Union[str, bytes]) -> Tuple[str, List[str]]:\n        \"\"\"\n        从种子内容中获取文件夹名和文件清单\n        :param torrent_content: 种子内容\n        :return: 文件夹名、文件清单，单文件种子返回空文件夹名\n        \"\"\"\n\n        if not torrent_content:\n            return \"\", []\n\n        # 检查是否为磁力链接\n        if StringUtils.is_magnet_link(torrent_content):\n            return \"\", []\n\n        try:\n            # 解析种子内容\n            torrentinfo = Torrent.from_string(torrent_content)\n            # 获取文件清单\n            return self.get_fileinfo_from_torrent(torrentinfo)\n        except Exception as err:\n            logger.error(f\"种子内容解析失败：{str(err)}\")\n            return \"\", []\n\n    @staticmethod\n    def get_url_filename(req: Any, url: str) -> str:\n        \"\"\"\n        从下载请求中获取种子文件名\n        \"\"\"\n        if not req:\n            return \"\"\n        disposition = req.headers.get('content-disposition') or \"\"\n        file_name = re.findall(r\"filename=\\\"?(.+)\\\"?\", disposition)\n        if file_name:\n            file_name = unquote(str(file_name[0].encode('ISO-8859-1').decode()).split(\";\")[0].strip())\n            if file_name.endswith('\"'):\n                file_name = file_name[:-1]\n        elif url and url.endswith(\".torrent\"):\n            file_name = unquote(url.split(\"/\")[-1])\n        else:\n            file_name = str(datetime.datetime.now())\n        return file_name\n\n    @staticmethod\n    def sort_torrents(torrent_list: List[Context]) -> List[Context]:\n        \"\"\"\n        对种子对行排序：torrent、site、upload、seeder\n        \"\"\"\n        if not torrent_list:\n            return []\n\n        # 下载规则\n        priority_rule: List[str] = SystemConfigOper().get(\n            SystemConfigKey.TorrentsPriority) or [\"torrent\", \"upload\", \"seeder\"]\n        # 站点上传量\n        site_uploads = {\n            site.name: site.upload for site in SiteOper().get_userdata_latest()\n        }\n\n        def get_sort_str(_context):\n            \"\"\"\n            拼装排序字段\n            \"\"\"\n            _meta = _context.meta_info\n            _torrent = _context.torrent_info\n            _media = _context.media_info\n            # 标题\n            _title = str(_media.title).ljust(200, ' ')\n            # 站点优先级\n            _site_order = str(999 - (_torrent.site_order or 0)).rjust(3, '0')\n            # 站点上传量\n            _site_upload = str(site_uploads.get(_torrent.site_name) or 0).rjust(30, '0')\n            # 资源优先级\n            _torrent_order = str(_torrent.pri_order or 0).rjust(3, '0')\n            # 资源做种数\n            _torrent_seeders = str(_torrent.seeders or 0).rjust(10, '0')\n            # 季集\n            if not _meta.episode_list:\n                # 无集数的排最前面\n                _season_episode = \"%s%s\" % (str(len(_meta.season_list)).rjust(3, '0'), \"9999\")\n            else:\n                # 集数越多的排越前面\n                _season_episode = \"%s%s\" % (str(len(_meta.season_list)).rjust(3, '0'),\n                                            str(len(_meta.episode_list)).rjust(4, '0'))\n            # 根据下载规则的顺序拼装排序字符串\n            _sort_str = _title\n            for rule in priority_rule:\n                if rule == \"torrent\":\n                    _sort_str += _torrent_order\n                elif rule == \"site\":\n                    _sort_str += _site_order\n                elif rule == \"upload\":\n                    _sort_str += _site_upload\n                elif rule == \"seeder\":\n                    _sort_str += _torrent_seeders\n            _sort_str += _season_episode\n            return _sort_str\n\n        # 排序\n        return sorted(torrent_list, key=lambda x: get_sort_str(x), reverse=True)\n\n    def sort_group_torrents(self, torrent_list: List[Context]) -> List[Context]:\n        \"\"\"\n        对媒体信息进行排序、去重\n        \"\"\"\n        if not torrent_list:\n            return []\n\n        # 排序\n        torrent_list = self.sort_torrents(torrent_list)\n\n        # 控重\n        result = []\n        _added = []\n        # 排序后重新加入数组，按真实名称控重，即只取每个名称的第一个\n        for context in torrent_list:\n            # 控重的主链是名称、年份、季、集\n            meta = context.meta_info\n            media = context.media_info\n            if media.type == MediaType.TV:\n                media_name = \"%s%s\" % (media.title_year,\n                                       meta.season_episode)\n            else:\n                media_name = media.title_year\n            if media_name not in _added:\n                _added.append(media_name)\n                result.append(context)\n\n        return result\n\n    @staticmethod\n    def get_torrent_episodes(files: list) -> list:\n        \"\"\"\n        从种子的文件清单中获取所有集数\n        \"\"\"\n        episodes = []\n        for file in files:\n            if not file:\n                continue\n            file_path = Path(file)\n            if not file_path.suffix or file_path.suffix.lower() not in settings.RMT_MEDIAEXT:\n                continue\n            # 只使用文件名识别\n            meta = MetaInfo(file_path.name)\n            if not meta.begin_episode:\n                continue\n            episodes = list(set(episodes).union(set(meta.episode_list)))\n        return episodes\n\n    def is_invalid(self, url: Optional[str]) -> bool:\n        \"\"\"\n        判断种子是否是无效种子\n        \"\"\"\n        return url in self._invalid_torrents if url else True\n\n    def add_invalid(self, url: str):\n        \"\"\"\n        添加无效种子\n        \"\"\"\n        if url not in self._invalid_torrents:\n            self._invalid_torrents[url] = True\n\n    @staticmethod\n    def match_torrent(mediainfo: MediaInfo, torrent_meta: MetaBase, torrent: TorrentInfo) -> bool:\n        \"\"\"\n        检查种子是否匹配媒体信息\n        :param mediainfo: 需要匹配的媒体信息\n        :param torrent_meta: 种子识别信息\n        :param torrent: 种子信息\n        \"\"\"\n        # 比对词条指定的tmdbid\n        if torrent_meta.tmdbid or torrent_meta.doubanid:\n            if torrent_meta.tmdbid and torrent_meta.tmdbid == mediainfo.tmdb_id:\n                logger.info(\n                    f'{mediainfo.title} 通过词表指定TMDBID匹配到资源：{torrent.site_name} - {torrent.title}')\n                return True\n            if torrent_meta.doubanid and torrent_meta.doubanid == mediainfo.douban_id:\n                logger.info(\n                    f'{mediainfo.title} 通过词表指定豆瓣ID匹配到资源：{torrent.site_name} - {torrent.title}')\n                return True\n        # 要匹配的媒体标题、原标题\n        media_titles = {\n                           StringUtils.clear_upper(mediainfo.title),\n                           StringUtils.clear_upper(mediainfo.original_title)\n                       } - {\"\"}\n        # 要匹配的媒体别名、译名\n        media_names = {StringUtils.clear_upper(name) for name in mediainfo.names if name}\n        # 识别的种子中英文名\n        meta_names = {\n                         StringUtils.clear_upper(torrent_meta.cn_name),\n                         StringUtils.clear_upper(torrent_meta.en_name)\n                     } - {\"\"}\n        # 比对种子识别类型\n        if torrent_meta.type == MediaType.TV and mediainfo.type != MediaType.TV:\n            logger.debug(f'{torrent.site_name} - {torrent.title} 种子标题类型为 {torrent_meta.type.value}，'\n                         f'不匹配 {mediainfo.type.value}')\n            return False\n        # 比对种子在站点中的类型\n        if torrent.category == MediaType.TV.value and mediainfo.type != MediaType.TV:\n            logger.debug(f'{torrent.site_name} - {torrent.title} 种子在站点中归类为 {torrent.category}，'\n                         f'不匹配 {mediainfo.type.value}')\n            return False\n        # 比对年份\n        if mediainfo.year:\n            if mediainfo.type == MediaType.TV:\n                # 剧集年份，每季的年份可能不同，没年份时不比较年份（很多剧集种子不带年份）\n                if torrent_meta.year and torrent_meta.year not in [year for year in\n                                                                   mediainfo.season_years.values()]:\n                    logger.debug(f'{torrent.site_name} - {torrent.title} 年份不匹配 {mediainfo.season_years}')\n                    return False\n            else:\n                # 电影年份，上下浮动1年，没年份时不通过\n                if not torrent_meta.year or torrent_meta.year not in [str(int(mediainfo.year) - 1),\n                                                                      mediainfo.year,\n                                                                      str(int(mediainfo.year) + 1)]:\n                    logger.debug(f'{torrent.site_name} - {torrent.title} 年份不匹配 {mediainfo.year}')\n                    return False\n        # 比对标题和原语种标题\n        if meta_names.intersection(media_titles):\n            logger.info(f'{mediainfo.title} 通过标题匹配到资源：{torrent.site_name} - {torrent.title}')\n            return True\n        # 比对别名和译名\n        if media_names:\n            if meta_names.intersection(media_names):\n                logger.info(f'{mediainfo.title} 通过别名或译名匹配到资源：{torrent.site_name} - {torrent.title}')\n                return True\n        # 标题拆分\n        if torrent_meta.org_string:\n            # 只拆分出标题中的非英文单词进行匹配，英文单词容易误匹配（带空格的多个单词组合除外）\n            titles = [StringUtils.clear_upper(t) for t in re.split(\n                r'[\\s/【】.\\[\\]\\-]+',\n                torrent_meta.org_string\n            ) if not StringUtils.is_english_word(t)]\n            # 在标题中判断是否存在标题、原语种标题\n            if media_titles.intersection(titles):\n                logger.info(f'{mediainfo.title} 通过标题匹配到资源：{torrent.site_name} - {torrent.title}')\n                return True\n        # 在副标题中（非英文单词）判断是否存在标题、原语种标题、别名、译名\n        if torrent.description:\n            subtitles = {StringUtils.clear_upper(t) for t in re.split(\n                r'[\\s/【】|]+',\n                torrent.description) if not StringUtils.is_english_word(t)}\n            if media_titles.intersection(subtitles) or media_names.intersection(subtitles):\n                logger.info(f'{mediainfo.title} 通过副标题匹配到资源：{torrent.site_name} - {torrent.title}，'\n                            f'副标题：{torrent.description}')\n                return True\n        # 未匹配\n        logger.debug(f'{torrent.site_name} - {torrent.title} 标题不匹配，识别名称：{meta_names}')\n        return False\n\n    @staticmethod\n    def filter_torrent(torrent_info: TorrentInfo,\n                       filter_params: Dict[str, str]) -> bool:\n        \"\"\"\n        检查种子是否匹配订阅过滤规则\n        \"\"\"\n\n        if not filter_params:\n            return True\n\n        # 匹配内容\n        content = (f\"{torrent_info.title} \"\n                   f\"{torrent_info.description} \"\n                   f\"{' '.join(torrent_info.labels or [])} \"\n                   f\"{torrent_info.volume_factor}\")\n\n        # 包含\n        include = filter_params.get(\"include\")\n        if include:\n            if not re.search(r\"%s\" % include, content, re.I):\n                logger.info(f\"{content} 不匹配包含规则 {include}\")\n                return False\n        # 排除\n        exclude = filter_params.get(\"exclude\")\n        if exclude:\n            if re.search(r\"%s\" % exclude, content, re.I):\n                logger.info(f\"{content} 匹配排除规则 {exclude}\")\n                return False\n        # 质量\n        quality = filter_params.get(\"quality\")\n        if quality:\n            if not re.search(r\"%s\" % quality, torrent_info.title, re.I):\n                logger.info(f\"{torrent_info.title} 不匹配质量规则 {quality}\")\n                return False\n        # 分辨率\n        resolution = filter_params.get(\"resolution\")\n        if resolution:\n            if not re.search(r\"%s\" % resolution, torrent_info.title, re.I):\n                logger.info(f\"{torrent_info.title} 不匹配分辨率规则 {resolution}\")\n                return False\n        # 特效\n        effect = filter_params.get(\"effect\")\n        if effect:\n            if not re.search(r\"%s\" % effect, torrent_info.title, re.I):\n                logger.info(f\"{torrent_info.title} 不匹配特效规则 {effect}\")\n                return False\n\n        # 大小\n        size_range = filter_params.get(\"size\")\n        if size_range:\n            if size_range.find(\"-\") != -1:\n                # 区间\n                size_min, size_max = size_range.split(\"-\")\n                size_min = float(size_min.strip()) * 1024 * 1024\n                size_max = float(size_max.strip()) * 1024 * 1024\n                if torrent_info.size < size_min or torrent_info.size > size_max:\n                    return False\n            elif size_range.startswith(\">\"):\n                # 大于\n                size_min = float(size_range[1:].strip()) * 1024 * 1024\n                if torrent_info.size < size_min:\n                    return False\n            elif size_range.startswith(\"<\"):\n                # 小于\n                size_max = float(size_range[1:].strip()) * 1024 * 1024\n                if torrent_info.size > size_max:\n                    return False\n\n        return True\n\n    @staticmethod\n    def match_season_episodes(torrent: TorrentInfo, meta: MetaBase, season_episodes: Dict[int, list]) -> bool:\n        \"\"\"\n        判断种子是否匹配季集数\n        :param torrent: 种子信息\n        :param meta: 种子元数据\n        :param season_episodes: 季集数 {season:[episodes]}\n        \"\"\"\n        # 匹配季\n        seasons = season_episodes.keys()\n        # 种子季\n        torrent_seasons = meta.season_list\n        if not torrent_seasons:\n            # 按第一季处理\n            torrent_seasons = [1]\n        # 种子集\n        torrent_episodes = meta.episode_list\n        if not set(torrent_seasons).issubset(set(seasons)):\n            # 种子季不在过滤季中\n            logger.debug(\n                f\"种子 {torrent.site_name} - {torrent.title} 包含季 {torrent_seasons} 不是需要的季 {list(seasons)}\")\n            return False\n        if not torrent_episodes:\n            # 整季按匹配处理\n            return True\n        if len(torrent_seasons) == 1:\n            need_episodes = season_episodes.get(torrent_seasons[0])\n            if need_episodes \\\n                    and not set(torrent_episodes).intersection(set(need_episodes)):\n                # 单季集没有交集的不要\n                logger.debug(f\"种子 {torrent.site_name} - {torrent.title} \"\n                             f\"集 {torrent_episodes} 没有需要的集：{need_episodes}\")\n                return False\n        return True\n"
  },
  {
    "path": "app/helper/twofa.py",
    "content": "import base64\nimport hashlib\nimport hmac\nimport struct\nimport sys\nimport time\n\nfrom app.log import logger\n\n\nclass TwoFactorAuth:\n    def __init__(self, code_or_secret: str):\n        if code_or_secret and len(code_or_secret) >= 16:\n            self.code = None\n            self.secret = code_or_secret\n        else:\n            self.code = code_or_secret\n            self.secret = None\n\n    @staticmethod\n    def __calc(secret_key: str) -> str:\n        if not secret_key:\n            return \"\"\n        try:\n            input_time = int(time.time()) // 30\n            key = base64.b32decode(secret_key)\n            msg = struct.pack(\">Q\", input_time)\n            google_code = hmac.new(key, msg, hashlib.sha1).digest()\n            o = (\n                google_code[19] & 15\n                if sys.version_info > (2, 7)\n                else ord(str(google_code[19])) & 15\n            )\n            google_code = str(\n                (struct.unpack(\">I\", google_code[o: o + 4])[0] & 0x7FFFFFFF) % 1000000\n            )\n            return f\"0{google_code}\" if len(google_code) == 5 else google_code\n        except Exception as e:\n            logger.error(f\"计算动态验证码失败：{str(e)}\")\n            return \"\"\n\n    def get_code(self) -> str:\n        return self.code or self.__calc(self.secret)\n"
  },
  {
    "path": "app/helper/workflow.py",
    "content": "import json\nfrom typing import List, Tuple, Optional\n\nfrom app.core.cache import cached\nfrom app.core.config import settings\nfrom app.db.models import Workflow\nfrom app.db.workflow_oper import WorkflowOper\nfrom app.log import logger\nfrom app.utils.http import RequestUtils, AsyncRequestUtils\nfrom app.utils.singleton import WeakSingleton\nfrom app.utils.system import SystemUtils\n\n\nclass WorkflowHelper(metaclass=WeakSingleton):\n    \"\"\"\n    工作流分享等\n    \"\"\"\n\n    _workflow_share = f\"{settings.MP_SERVER_HOST}/workflow/share\"\n\n    _workflow_shares = f\"{settings.MP_SERVER_HOST}/workflow/shares\"\n\n    _workflow_fork = f\"{settings.MP_SERVER_HOST}/workflow/fork/%s\"\n\n    _shares_cache_region = \"workflow_share\"\n\n    _share_user_id = None\n\n    def __init__(self):\n        self.get_user_uuid()\n\n    @staticmethod\n    def _check_workflow_share_enabled() -> Tuple[bool, str]:\n        \"\"\"\n        检查工作流分享功能是否开启\n        \"\"\"\n        if not settings.WORKFLOW_STATISTIC_SHARE:\n            return False, \"当前没有开启工作流数据共享功能\"\n        return True, \"\"\n\n    @staticmethod\n    def _validate_workflow(workflow: Workflow) -> Tuple[bool, str]:\n        \"\"\"\n        验证工作流是否可以分享\n        \"\"\"\n        if not workflow:\n            return False, \"工作流不存在\"\n\n        if not workflow.actions or not workflow.flows:\n            return False, \"请分享有动作和流程的工作流\"\n\n        return True, \"\"\n\n    @staticmethod\n    def _prepare_workflow_data(workflow: Workflow) -> dict:\n        \"\"\"\n        准备工作流分享数据\n        \"\"\"\n        workflow_dict = workflow.to_dict()\n        workflow_dict.pop(\"id\", None)\n        workflow_dict.pop(\"context\", None)\n        workflow_dict['actions'] = json.dumps(workflow_dict['actions'] or [])\n        workflow_dict['flows'] = json.dumps(workflow_dict['flows'] or [])\n        return workflow_dict\n\n    def _build_share_payload(self, share_title: str, share_comment: str,\n                             share_user: str, workflow_dict: dict) -> dict:\n        \"\"\"\n        构建分享请求载荷\n        \"\"\"\n        return {\n            \"share_title\": share_title,\n            \"share_comment\": share_comment,\n            \"share_user\": share_user,\n            \"share_uid\": self._share_user_id,\n            **workflow_dict\n        }\n\n    def _handle_response(self, res, clear_cache: bool = True) -> Tuple[bool, str]:\n        \"\"\"\n        处理HTTP响应\n        \"\"\"\n        if res is None:\n            return False, \"连接MoviePilot服务器失败\"\n\n        # 检查响应状态\n        success = True if res.status_code == 200 else False\n\n        if success:\n            # 清除缓存\n            if clear_cache:\n                self.get_shares.cache_clear()\n                self.async_get_shares.cache_clear()\n            return True, \"\"\n        else:\n            try:\n                error_msg = res.json().get(\"message\", \"未知错误\")\n            except (json.JSONDecodeError, ValueError) as e:\n                logger.error(f\"工作流响应JSON解析失败: {e}\")\n                error_msg = f\"响应解析失败: {res.text[:100]}...\"\n            return False, error_msg\n\n    @staticmethod\n    def _handle_list_response(res) -> List[dict]:\n        \"\"\"\n        处理返回List的HTTP响应\n        \"\"\"\n        if res and res.status_code == 200:\n            try:\n                return res.json()\n            except (json.JSONDecodeError, ValueError) as e:\n                logger.error(f\"工作流列表响应JSON解析失败: {e}\")\n                return []\n        return []\n\n    def workflow_share(self, workflow_id: int,\n                       share_title: str, share_comment: str, share_user: str) -> Tuple[bool, str]:\n        \"\"\"\n        分享工作流\n        \"\"\"\n        # 检查功能是否开启\n        enabled, message = self._check_workflow_share_enabled()\n        if not enabled:\n            return False, message\n\n        # 获取工作流信息\n        workflow = WorkflowOper().get(workflow_id)\n\n        # 验证工作流\n        valid, message = self._validate_workflow(workflow)\n        if not valid:\n            return False, message\n\n        # 准备数据\n        workflow_dict = self._prepare_workflow_data(workflow)\n        payload = self._build_share_payload(share_title, share_comment, share_user, workflow_dict)\n\n        # 发送分享请求\n        res = RequestUtils(proxies=settings.PROXY or {},\n                           content_type=\"application/json\",\n                           timeout=10).post(self._workflow_share, json=payload)\n\n        return self._handle_response(res)\n\n    async def async_workflow_share(self, workflow_id: int,\n                                   share_title: str, share_comment: str, share_user: str) -> Tuple[bool, str]:\n        \"\"\"\n        异步分享工作流\n        \"\"\"\n        # 检查功能是否开启\n        enabled, message = self._check_workflow_share_enabled()\n        if not enabled:\n            return False, message\n\n        # 获取工作流信息\n        workflow = await WorkflowOper().async_get(workflow_id)\n\n        # 验证工作流\n        valid, message = self._validate_workflow(workflow)\n        if not valid:\n            return False, message\n\n        # 准备数据\n        workflow_dict = self._prepare_workflow_data(workflow)\n        payload = self._build_share_payload(share_title, share_comment, share_user, workflow_dict)\n\n        # 发送分享请求\n        res = await AsyncRequestUtils(proxies=settings.PROXY or {},\n                                      content_type=\"application/json\",\n                                      timeout=10).post(self._workflow_share, json=payload)\n\n        return self._handle_response(res)\n\n    def share_delete(self, share_id: int) -> Tuple[bool, str]:\n        \"\"\"\n        删除分享\n        \"\"\"\n        # 检查功能是否开启\n        enabled, message = self._check_workflow_share_enabled()\n        if not enabled:\n            return False, message\n\n        res = RequestUtils(proxies=settings.PROXY or {},\n                           timeout=5).delete_res(f\"{self._workflow_share}/{share_id}\",\n                                                 params={\"share_uid\": self._share_user_id})\n\n        return self._handle_response(res)\n\n    async def async_share_delete(self, share_id: int) -> Tuple[bool, str]:\n        \"\"\"\n        异步删除分享\n        \"\"\"\n        # 检查功能是否开启\n        enabled, message = self._check_workflow_share_enabled()\n        if not enabled:\n            return False, message\n\n        res = await AsyncRequestUtils(proxies=settings.PROXY or {},\n                                      timeout=5).delete_res(f\"{self._workflow_share}/{share_id}\",\n                                                            params={\"share_uid\": self._share_user_id})\n\n        return self._handle_response(res)\n\n    def workflow_fork(self, share_id: int) -> Tuple[bool, str]:\n        \"\"\"\n        复用分享的工作流\n        \"\"\"\n        # 检查功能是否开启\n        enabled, message = self._check_workflow_share_enabled()\n        if not enabled:\n            return False, message\n\n        res = RequestUtils(proxies=settings.PROXY or {}, timeout=5, headers={\n            \"Content-Type\": \"application/json\"\n        }).get_res(self._workflow_fork % share_id)\n\n        return self._handle_response(res, clear_cache=False)\n\n    async def async_workflow_fork(self, share_id: int) -> Tuple[bool, str]:\n        \"\"\"\n        异步复用分享的工作流\n        \"\"\"\n        # 检查功能是否开启\n        enabled, message = self._check_workflow_share_enabled()\n        if not enabled:\n            return False, message\n\n        res = await AsyncRequestUtils(proxies=settings.PROXY or {},\n                                      timeout=5,\n                                      headers={\n                                          \"Content-Type\": \"application/json\"\n                                      }).get_res(self._workflow_fork % share_id)\n\n        return self._handle_response(res, clear_cache=False)\n\n    @cached(region=_shares_cache_region, maxsize=1, skip_empty=True)\n    def get_shares(self, name: Optional[str] = None, page: Optional[int] = 1, count: Optional[int] = 30) -> List[dict]:\n        \"\"\"\n        获取工作流分享数据\n        \"\"\"\n        enabled, _ = self._check_workflow_share_enabled()\n        if not enabled:\n            return []\n\n        res = RequestUtils(proxies=settings.PROXY or {}, timeout=15).get_res(self._workflow_shares, params={\n            \"name\": name,\n            \"page\": page,\n            \"count\": count\n        })\n        return self._handle_list_response(res)\n\n    @cached(region=_shares_cache_region, maxsize=1, skip_empty=True)\n    async def async_get_shares(self, name: Optional[str] = None, page: Optional[int] = 1, count: Optional[int] = 30) -> \\\n            List[dict]:\n        \"\"\"\n        异步获取工作流分享数据\n        \"\"\"\n        enabled, _ = self._check_workflow_share_enabled()\n        if not enabled:\n            return []\n\n        res = await AsyncRequestUtils(proxies=settings.PROXY or {}, timeout=15).get_res(self._workflow_shares, params={\n            \"name\": name,\n            \"page\": page,\n            \"count\": count\n        })\n        return self._handle_list_response(res)\n\n    def get_user_uuid(self) -> str:\n        \"\"\"\n        获取用户uuid\n        \"\"\"\n        if not self._share_user_id:\n            self._share_user_id = SystemUtils.generate_user_unique_id()\n            logger.info(f\"当前用户UUID: {self._share_user_id}\")\n        return self._share_user_id or \"\"\n"
  },
  {
    "path": "app/log.py",
    "content": "import asyncio\nimport logging\nimport queue\nimport sys\nimport threading\nimport time\nfrom concurrent.futures import ThreadPoolExecutor\nfrom datetime import datetime\nfrom logging.handlers import RotatingFileHandler\nfrom pathlib import Path\nfrom typing import Dict, Any, Optional\n\nimport click\nfrom pydantic import BaseModel, ConfigDict\nfrom pydantic_settings import BaseSettings\n\nfrom app.utils.system import SystemUtils\n\n\nclass LogConfigModel(BaseModel):\n    \"\"\"\n    Pydantic 配置模型，描述所有配置项及其类型和默认值\n    \"\"\"\n\n    model_config = ConfigDict(extra=\"ignore\")  # 忽略未定义的配置项\n\n    # 配置文件目录\n    CONFIG_DIR: Optional[str] = None\n    # 是否为调试模式\n    DEBUG: bool = False\n    # 日志级别（DEBUG、INFO、WARNING、ERROR等）\n    LOG_LEVEL: str = \"INFO\"\n    # 日志文件最大大小（单位：MB）\n    LOG_MAX_FILE_SIZE: int = 5\n    # 备份的日志文件数量\n    LOG_BACKUP_COUNT: int = 10\n    # 控制台日志格式\n    LOG_CONSOLE_FORMAT: str = \"%(leveltext)s[%(name)s] %(asctime)s %(message)s\"\n    # 文件日志格式\n    LOG_FILE_FORMAT: str = \"【%(levelname)s】%(asctime)s - %(message)s\"\n    # 异步文件写入队列大小\n    ASYNC_FILE_QUEUE_SIZE: int = 1000\n    # 异步文件写入线程数\n    ASYNC_FILE_WORKERS: int = 2\n    # 批量写入大小\n    BATCH_WRITE_SIZE: int = 50\n    # 写入超时时间（秒）\n    WRITE_TIMEOUT: float = 3.0\n\n\nclass LogSettings(BaseSettings, LogConfigModel):\n    \"\"\"\n    日志设置类\n    \"\"\"\n\n    @property\n    def CONFIG_PATH(self):\n        return SystemUtils.get_config_path(self.CONFIG_DIR)\n\n    @property\n    def LOG_PATH(self):\n        \"\"\"\n        获取日志存储路径\n        \"\"\"\n        return self.CONFIG_PATH / \"logs\"\n\n    @property\n    def LOG_MAX_FILE_SIZE_BYTES(self):\n        \"\"\"\n        将日志文件大小转换为字节（MB -> Bytes）\n        \"\"\"\n        return self.LOG_MAX_FILE_SIZE * 1024 * 1024\n\n    model_config = ConfigDict(\n        case_sensitive=True,\n        env_file=SystemUtils.get_env_path(),\n        env_file_encoding=\"utf-8\"\n    )\n\n\n# 实例化日志设置\nlog_settings = LogSettings()\n\n# 日志级别颜色映射\nlevel_name_colors = {\n    logging.DEBUG: lambda level_name: click.style(str(level_name), fg=\"cyan\"),\n    logging.INFO: lambda level_name: click.style(str(level_name), fg=\"green\"),\n    logging.WARNING: lambda level_name: click.style(str(level_name), fg=\"yellow\"),\n    logging.ERROR: lambda level_name: click.style(str(level_name), fg=\"red\"),\n    logging.CRITICAL: lambda level_name: click.style(str(level_name), fg=\"bright_red\"),\n}\n\n\nclass CustomFormatter(logging.Formatter):\n    \"\"\"\n    自定义日志输出格式\n    \"\"\"\n\n    def __init__(self, fmt=None):\n        super().__init__(fmt)\n\n    def format(self, record):\n        separator = \" \" * (8 - len(record.levelname))\n        record.leveltext = level_name_colors[record.levelno](record.levelname + \":\") + separator\n        return super().format(record)\n\n\nclass LogEntry:\n    \"\"\"\n    日志条目\n    \"\"\"\n\n    def __init__(self, level: str, message: str, file_path: Path, timestamp: datetime = None):\n        self.level = level\n        self.message = message\n        self.file_path = file_path\n        self.timestamp = timestamp or datetime.now()\n\n\nclass NonBlockingFileHandler:\n    \"\"\"\n    非阻塞文件处理器 - 使用RotatingFileHandler实现日志滚动\n    \"\"\"\n    _instance = None\n    _lock = threading.Lock()\n    _rotating_handlers = {}\n\n    def __new__(cls):\n        if cls._instance is None:\n            with cls._lock:\n                if cls._instance is None:\n                    cls._instance = super().__new__(cls)\n        return cls._instance\n\n    def __init__(self):\n        if hasattr(self, '_initialized'):\n            return\n\n        self._initialized = True\n        self._write_queue = queue.Queue(maxsize=log_settings.ASYNC_FILE_QUEUE_SIZE)\n        self._executor = ThreadPoolExecutor(max_workers=log_settings.ASYNC_FILE_WORKERS,\n                                            thread_name_prefix=\"LogWriter\")\n        self._running = True\n\n        # 启动后台写入线程\n        self._write_thread = threading.Thread(target=self._batch_writer, daemon=True)\n        self._write_thread.start()\n\n    def _get_rotating_handler(self, file_path: Path) -> RotatingFileHandler:\n        \"\"\"\n        获取或创建RotatingFileHandler实例\n        \"\"\"\n        if file_path not in self._rotating_handlers:\n            # 确保目录存在\n            file_path.parent.mkdir(parents=True, exist_ok=True)\n\n            # 创建RotatingFileHandler\n            handler = RotatingFileHandler(\n                filename=str(file_path),\n                maxBytes=log_settings.LOG_MAX_FILE_SIZE_BYTES,\n                backupCount=log_settings.LOG_BACKUP_COUNT,\n                encoding='utf-8'\n            )\n\n            # 设置格式化器\n            formatter = logging.Formatter(log_settings.LOG_FILE_FORMAT)\n            handler.setFormatter(formatter)\n\n            self._rotating_handlers[file_path] = handler\n\n        return self._rotating_handlers[file_path]\n\n    def write_log(self, level: str, message: str, file_path: Path):\n        \"\"\"\n        写入日志 - 自动检测协程环境并使用合适的方式\n        \"\"\"\n        entry = LogEntry(level, message, file_path)\n\n        # 检测是否在协程环境中\n        if self._is_in_event_loop():\n            # 在协程环境中，使用非阻塞方式\n            self._write_non_blocking(entry)\n        else:\n            # 不在协程环境中，直接同步写入\n            self._write_sync(entry)\n\n    @staticmethod\n    def _is_in_event_loop() -> bool:\n        \"\"\"\n        检测当前是否在事件循环中\n        \"\"\"\n        try:\n            loop = asyncio.get_running_loop()\n            return loop is not None\n        except RuntimeError:\n            return False\n\n    def _write_non_blocking(self, entry: LogEntry):\n        \"\"\"\n        非阻塞写入（用于协程环境）\n        \"\"\"\n        try:\n            self._write_queue.put_nowait(entry)\n        except queue.Full:\n            # 队列满时，使用线程池处理\n            self._executor.submit(self._write_sync, entry)\n\n    @staticmethod\n    def _write_sync(entry: LogEntry):\n        \"\"\"\n        同步写入日志\n        \"\"\"\n        try:\n            # 获取RotatingFileHandler实例\n            handler = NonBlockingFileHandler()._get_rotating_handler(entry.file_path)\n\n            # 使用RotatingFileHandler的emit方法，只传递原始消息\n            handler.emit(logging.LogRecord(\n                name='',\n                level=getattr(logging, entry.level.upper(), logging.INFO),\n                pathname='',\n                lineno=0,\n                msg=entry.message,\n                args=(),\n                exc_info=None,\n                created=entry.timestamp.timestamp()\n            ))\n        except Exception as e:\n            # 如果文件写入失败，至少输出到控制台\n            print(f\"日志写入失败 {entry.file_path}: {e}\")\n            print(f\"【{entry.level.upper()}】{entry.timestamp} - {entry.message}\")\n\n    def _batch_writer(self):\n        \"\"\"\n        后台批量写入线程\n        \"\"\"\n        while self._running:\n            try:\n                # 收集一批日志条目\n                batch = []\n                end_time = time.time() + log_settings.WRITE_TIMEOUT\n\n                while len(batch) < log_settings.BATCH_WRITE_SIZE and time.time() < end_time:\n                    try:\n                        remaining_time = max(0, end_time - time.time())\n                        entry = self._write_queue.get(timeout=remaining_time)\n                        batch.append(entry)\n                    except queue.Empty:\n                        break\n\n                if batch:\n                    self._write_batch(batch)\n\n            except Exception as e:\n                print(f\"批量写入线程错误: {e}\")\n                time.sleep(0.1)\n\n    def _write_batch(self, batch: list):\n        \"\"\"\n        批量写入日志\n        \"\"\"\n        # 按文件分组\n        file_groups = {}\n        for entry in batch:\n            if entry.file_path not in file_groups:\n                file_groups[entry.file_path] = []\n            file_groups[entry.file_path].append(entry)\n\n        # 批量写入每个文件\n        for file_path, entries in file_groups.items():\n            try:\n                # 获取RotatingFileHandler\n                handler = self._get_rotating_handler(file_path)\n\n                # 批量写入\n                for entry in entries:\n                    # 使用RotatingFileHandler的emit方法，只传递原始消息\n                    handler.emit(logging.LogRecord(\n                        name='',\n                        level=getattr(logging, entry.level.upper(), logging.INFO),\n                        pathname='',\n                        lineno=0,\n                        msg=entry.message,\n                        args=(),\n                        exc_info=None,\n                        created=entry.timestamp.timestamp()\n                    ))\n            except Exception as e:\n                print(f\"批量写入失败 {file_path}: {e}\")\n                # 回退到逐个写入\n                for entry in entries:\n                    self._write_sync(entry)\n\n    def shutdown(self):\n        \"\"\"\n        关闭文件处理器\n        \"\"\"\n        self._running = False\n        if hasattr(self, '_write_thread'):\n            self._write_thread.join(timeout=5)\n        if self._executor:\n            self._executor.shutdown(wait=True)\n\n        # 清理缓存\n        self._rotating_handlers.clear()\n\n\nclass LoggerManager:\n    \"\"\"\n    日志管理\n    \"\"\"\n    # 管理所有的 Logger\n    _loggers: Dict[str, Any] = {}\n    # 默认日志文件名称\n    _default_log_file = \"moviepilot.log\"\n    # 线程锁\n    _lock = threading.Lock()\n    # 非阻塞文件处理器\n    _file_handler = NonBlockingFileHandler()\n\n    def get_logger(self, name: str) -> logging.Logger:\n        \"\"\"\n        获取一个指定名称的、独立的日志记录器。\n        创建一个独立的日志文件，例如 'diag_memory.log'。\n        :param name: 日志记录器的名称，也将用作文件名。\n        :return: 一个配置好的 logging.Logger 实例。\n        \"\"\"\n        # 使用名称作为日志文件名\n        logfile = f\"{name}.log\"\n        with LoggerManager._lock:\n            # 检查是否已经创建过这个 logger\n            _logger = self._loggers.get(logfile)\n            if not _logger:\n                # 如果没有，就使用现有的 __setup_console_logger 来创建一个新的\n                _logger = self.__setup_console_logger(log_file=logfile)\n                self._loggers[logfile] = _logger\n        return _logger\n\n    @staticmethod\n    def __get_caller():\n        \"\"\"\n        获取调用者的文件名称与插件名称\n        如果是插件调用内置的模块, 也能写入到插件日志文件中\n        \"\"\"\n        # 调用者文件名称\n        caller_name = None\n        # 调用者插件名称\n        plugin_name = None\n\n        try:\n            frame = sys._getframe(3)  # noqa\n        except (AttributeError, ValueError):\n            # 如果无法获取帧，返回默认值\n            return \"log.py\", None\n\n        while frame:\n            filepath = Path(frame.f_code.co_filename)\n            parts = filepath.parts\n            # 设定调用者文件名称\n            if not caller_name:\n                if parts[-1] == \"__init__.py\" and len(parts) >= 2:\n                    caller_name = parts[-2]\n                else:\n                    caller_name = parts[-1]\n            # 设定调用者插件名称\n            if \"app\" in parts:\n                if not plugin_name and \"plugins\" in parts:\n                    try:\n                        plugins_index = parts.index(\"plugins\")\n                        if plugins_index + 1 < len(parts):\n                            plugin_candidate = parts[plugins_index + 1]\n                            if plugin_candidate == \"__init__.py\":\n                                plugin_name = \"plugin\"\n                            else:\n                                plugin_name = plugin_candidate\n                            break\n                    except ValueError:\n                        pass\n                if \"main.py\" in parts:\n                    # 已经到达程序的入口，停止遍历\n                    break\n            elif len(parts) != 1:\n                # 已经超出程序范围，停止遍历\n                break\n            # 获取上一个帧\n            try:\n                frame = frame.f_back\n            except AttributeError:\n                break\n        return caller_name or \"log.py\", plugin_name\n\n    @staticmethod\n    def __setup_console_logger(log_file: str):\n        \"\"\"\n        初始化控制台日志实例（文件输出由 NonBlockingFileHandler 处理）\n        :param log_file：日志文件相对路径\n        \"\"\"\n        log_file_path = log_settings.LOG_PATH / log_file\n\n        # 创建新实例\n        _logger = logging.getLogger(log_file_path.stem)\n\n        # 设置日志级别\n        _logger.setLevel(LoggerManager.__get_log_level())\n\n        # 移除已有的 handler，避免重复添加\n        for handler in _logger.handlers:\n            _logger.removeHandler(handler)\n\n        # 只设置终端日志（文件日志由 NonBlockingFileHandler 处理）\n        console_handler = logging.StreamHandler()\n        console_formatter = CustomFormatter(log_settings.LOG_CONSOLE_FORMAT)\n        console_handler.setFormatter(console_formatter)\n        _logger.addHandler(console_handler)\n\n        # 禁止向父级log传递\n        _logger.propagate = False\n\n        return _logger\n\n    def update_loggers(self):\n        \"\"\"\n        更新日志实例\n        \"\"\"\n        with LoggerManager._lock:\n            for _logger in self._loggers.values():\n                self.__update_logger_handlers(_logger)\n\n    @staticmethod\n    def __update_logger_handlers(_logger: logging.Logger):\n        \"\"\"\n        更新 Logger 的 handler 配置\n        :param _logger: 需要更新的 Logger 实例\n        \"\"\"\n        # 更新现有 handler（只有控制台 handler）\n        for handler in _logger.handlers:\n            try:\n                if isinstance(handler, logging.StreamHandler):\n                    # 更新控制台输出格式\n                    console_formatter = CustomFormatter(log_settings.LOG_CONSOLE_FORMAT)\n                    handler.setFormatter(console_formatter)\n            except Exception as e:\n                print(f\"更新日志处理器失败: {handler}. 错误: {e}\")\n        # 更新日志级别\n        _logger.setLevel(LoggerManager.__get_log_level())\n\n    @staticmethod\n    def __get_log_level():\n        \"\"\"\n        获取当前日志级别\n        \"\"\"\n        return logging.DEBUG if log_settings.DEBUG else getattr(logging, log_settings.LOG_LEVEL.upper(), logging.INFO)\n\n    def logger(self, method: str, msg: str, *args, **kwargs):\n        \"\"\"\n        获取模块的logger\n        :param method: 日志方法\n        :param msg: 日志信息\n        \"\"\"\n        # 获取当前日志级别\n        current_level = self.__get_log_level()\n        method_level = getattr(logging, method.upper(), logging.INFO)\n\n        # 如果当前方法的级别低于设定的日志级别，则不处理\n        if method_level < current_level:\n            return\n\n        # 获取调用者文件名和插件名\n        caller_name, plugin_name = self.__get_caller()\n\n        # 格式化消息\n        formatted_msg = f\"{caller_name} - {msg}\"\n        if args:\n            try:\n                formatted_msg = formatted_msg % args\n            except (TypeError, ValueError):\n                # 如果格式化失败，直接拼接\n                formatted_msg = f\"{formatted_msg} {' '.join(str(arg) for arg in args)}\"\n\n        # 区分插件日志\n        if plugin_name:\n            # 使用插件日志文件\n            logfile = Path(\"plugins\") / f\"{plugin_name}.log\"\n        else:\n            # 使用默认日志文件\n            logfile = self._default_log_file\n\n        # 构建完整的日志文件路径\n        log_file_path = log_settings.LOG_PATH / logfile\n\n        # 使用非阻塞文件处理器写入文件日志\n        self._file_handler.write_log(method.upper(), formatted_msg, log_file_path)\n\n        # 同时保持控制台输出（使用标准 logging）\n        with LoggerManager._lock:\n            _logger = self._loggers.get(logfile)\n            if not _logger:\n                _logger = self.__setup_console_logger(log_file=logfile)\n                self._loggers[logfile] = _logger\n\n        # 只在控制台输出，文件写入已由 _file_handler 处理\n        if hasattr(_logger, method):\n            log_method = getattr(_logger, method)\n            log_method(formatted_msg)\n\n    def info(self, msg: str, *args, **kwargs):\n        \"\"\"\n        输出信息级别日志\n        \"\"\"\n        self.logger(\"info\", msg, *args, **kwargs)\n\n    def debug(self, msg: str, *args, **kwargs):\n        \"\"\"\n        输出调试级别日志\n        \"\"\"\n        self.logger(\"debug\", msg, *args, **kwargs)\n\n    def warning(self, msg: str, *args, **kwargs):\n        \"\"\"\n        输出警告级别日志\n        \"\"\"\n        self.logger(\"warning\", msg, *args, **kwargs)\n\n    def warn(self, msg: str, *args, **kwargs):\n        \"\"\"\n        输出警告级别日志（兼容）\n        \"\"\"\n        self.warning(msg, *args, **kwargs)\n\n    def error(self, msg: str, *args, **kwargs):\n        \"\"\"\n        输出错误级别日志\n        \"\"\"\n        self.logger(\"error\", msg, *args, **kwargs)\n\n    def critical(self, msg: str, *args, **kwargs):\n        \"\"\"\n        输出严重错误级别日志\n        \"\"\"\n        self.logger(\"critical\", msg, *args, **kwargs)\n\n    @classmethod\n    def shutdown(cls):\n        \"\"\"\n        关闭日志管理器，清理资源\n        \"\"\"\n        if cls._file_handler:\n            cls._file_handler.shutdown()\n\n\n# 初始化日志管理\nlogger = LoggerManager()\n"
  },
  {
    "path": "app/main.py",
    "content": "import multiprocessing\nimport os\nimport setproctitle\nimport signal\nimport sys\nimport threading\n\nimport uvicorn as uvicorn\nfrom PIL import Image\nfrom uvicorn import Config\n\nfrom app.factory import app\nfrom app.utils.system import SystemUtils\n\n# 禁用输出\nif SystemUtils.is_frozen():\n    sys.stdout = open(os.devnull, 'w')\n    sys.stderr = open(os.devnull, 'w')\n\nfrom app.core.config import settings\nfrom app.db.init import init_db, update_db\n\n# 设置进程名\nsetproctitle.setproctitle(settings.PROJECT_NAME)\n\n# uvicorn服务\nServer = uvicorn.Server(Config(app, host=settings.HOST, port=settings.PORT,\n                               reload=settings.DEV, workers=multiprocessing.cpu_count() * 2 + 1,\n                               timeout_graceful_shutdown=60))\n\n\ndef start_tray():\n    \"\"\"\n    启动托盘图标\n    \"\"\"\n\n    if not SystemUtils.is_frozen():\n        return\n\n    if not SystemUtils.is_windows():\n        return\n\n    def open_web():\n        \"\"\"\n        调用浏览器打开前端页面\n        \"\"\"\n        import webbrowser\n        webbrowser.open(f\"http://localhost:{settings.NGINX_PORT}\")\n\n    def quit_app():\n        \"\"\"\n        退出程序\n        \"\"\"\n        TrayIcon.stop()\n        Server.should_exit = True\n\n    import pystray\n\n    # 托盘图标\n    TrayIcon = pystray.Icon(\n        settings.PROJECT_NAME,\n        icon=Image.open(settings.ROOT_PATH / 'app.ico'),\n        menu=pystray.Menu(\n            pystray.MenuItem(\n                '打开',\n                open_web,\n            ),\n            pystray.MenuItem(\n                '退出',\n                quit_app,\n            )\n        )\n    )\n    # 启动托盘图标\n    threading.Thread(target=TrayIcon.run, daemon=True).start()\n\n\ndef signal_handler(signum, frame):\n    \"\"\"\n    信号处理函数，用于优雅停止服务\n    \"\"\"\n    print(f\"收到信号 {signum}，开始优雅停止服务...\")\n    Server.should_exit = True\n\n\nif __name__ == '__main__':\n    # 注册信号处理器\n    signal.signal(signal.SIGTERM, signal_handler)\n    signal.signal(signal.SIGINT, signal_handler)\n\n    # 启动托盘\n    start_tray()\n    # 初始化数据库\n    init_db()\n    # 更新数据库\n    update_db()\n    # 启动API服务\n    Server.run()"
  },
  {
    "path": "app/modules/__init__.py",
    "content": "from abc import abstractmethod, ABCMeta\nfrom typing import Generic, Tuple, Union, TypeVar, Type, Dict, Optional, Callable\nfrom pathlib import Path\n\nfrom app.helper.service import ServiceConfigHelper\nfrom app.schemas import Notification, NotificationConf, MediaServerConf, DownloaderConf\nfrom app.schemas.types import ModuleType, DownloaderType, MediaServerType, MessageChannel, StorageSchema, \\\n    OtherModulesType, SystemConfigKey\nfrom app.utils.mixins import ConfigReloadMixin\n\n\nclass _ModuleBase(ConfigReloadMixin, metaclass=ABCMeta):\n    \"\"\"\n    模块基类，实现对应方法，在有需要时会被自动调用，返回None代表不启用该模块，将继续执行下一模块\n    输入参数与输出参数一致的，或没有输出的，可以被多个模块重复实现\n    \"\"\"\n\n    def on_config_changed(self):\n        self.init_module()\n\n    def get_reload_name(self):\n        return self.get_name()\n\n    @abstractmethod\n    def init_module(self) -> None:\n        \"\"\"\n        模块初始化\n        \"\"\"\n        pass\n\n    @abstractmethod\n    def init_setting(self) -> Tuple[str, Union[str, bool]]:\n        \"\"\"\n        模块开关设置，返回开关名和开关值，开关值为True时代表有值即打开，不实现该方法或返回None代表不使用开关\n        部分模块支持同时开启多个，此时设置项以,分隔，开关值使用in判断\n        \"\"\"\n        pass\n\n    @staticmethod\n    def get_name() -> str:\n        \"\"\"\n        获取模块名称\n        \"\"\"\n        pass\n\n    @staticmethod\n    def get_type() -> ModuleType:\n        \"\"\"\n        获取模块类型\n        \"\"\"\n        pass\n\n    @staticmethod\n    def get_subtype() -> Union[DownloaderType, MediaServerType, MessageChannel, StorageSchema, OtherModulesType]:\n        \"\"\"\n        获取模块子类型（下载器、媒体服务器、消息通道、存储类型、其他杂项模块类型）\n        \"\"\"\n        pass\n\n    @staticmethod\n    def get_priority() -> int:\n        \"\"\"\n        获取模块优先级，数字越小优先级越高，只有同一接口下优先级才生效\n        \"\"\"\n        pass\n\n    @abstractmethod\n    def stop(self) -> None:\n        \"\"\"\n        如果关闭时模块有服务需要停止，需要实现此方法\n        :return: None，该方法可被多个模块同时处理\n        \"\"\"\n        pass\n\n    @abstractmethod\n    def test(self) -> Optional[Tuple[bool, str]]:\n        \"\"\"\n        模块测试, 返回测试结果和错误信息\n        \"\"\"\n        pass\n\n\n# 定义泛型，用于表示具体的服务类型和配置类型\nTService = TypeVar(\"TService\", bound=object)\nTConf = TypeVar(\"TConf\")\n\n\nclass ServiceBase(Generic[TService, TConf], metaclass=ABCMeta):\n    \"\"\"\n    抽象服务基类，负责服务的初始化、获取实例和配置管理\n    \"\"\"\n\n    def __init__(self):\n        \"\"\"\n        初始化 ServiceBase 类的实例\n        \"\"\"\n        self._configs: Optional[Dict[str, TConf]] = None\n        self._instances: Optional[Dict[str, TService]] = None\n        self._service_name: Optional[str] = None\n\n    def init_service(self, service_name: str,\n                     service_type: Optional[Union[Type[TService], Callable[..., TService]]] = None):\n        \"\"\"\n        初始化服务，获取配置并实例化对应服务\n\n        :param service_name: 服务名称，作为配置匹配的依据\n        :param service_type: 服务的类型，可以是类类型（Type[TService]）、工厂函数（Callable）或 None 来跳过实例化\n        \"\"\"\n        if not service_name:\n            raise Exception(\"service_name is null\")\n        self._service_name = service_name\n        configs = self.get_configs()\n        if configs is None:\n            return\n        self._configs = configs\n        self._instances = {}\n        if not service_type:\n            return\n        for conf in self._configs.values():\n            # 通过服务类型或工厂函数来创建实例\n            if isinstance(service_type, type):\n                # 如果传入的是类类型，调用构造函数实例化\n                self._instances[conf.name] = service_type(name=conf.name, **conf.config)\n            else:\n                # 如果传入的是工厂函数，直接调用工厂函数\n                self._instances[conf.name] = service_type(conf)\n\n    def get_instances(self) -> Dict[str, TService]:\n        \"\"\"\n        获取服务实例列表\n\n        :return: 返回服务实例列表\n        \"\"\"\n        return self._instances or {}\n\n    def get_instance(self, name: Optional[str] = None) -> Optional[TService]:\n        \"\"\"\n        获取指定名称的服务实例\n\n        :param name: 实例名称，可选。如果为 None，则返回默认实例\n        :return: 返回符合条件的服务实例，若不存在则返回 None\n        \"\"\"\n        if not self._instances:\n            return None\n        if name:\n            return self._instances.get(name)\n        name = self.get_default_config_name()\n        return self._instances.get(name) if name else None\n\n    @abstractmethod\n    def get_configs(self) -> Dict[str, TConf]:\n        \"\"\"\n        获取已启用的服务配置字典\n\n        :return: 返回配置字典\n        \"\"\"\n        pass\n\n    def get_config(self, name: Optional[str] = None) -> Optional[TConf]:\n        \"\"\"\n        获取指定名称的服务配置\n\n        :param name: 配置名称，可选。如果为 None，则返回默认服务配置\n        :return: 返回符合条件的配置，若不存在则返回 None\n        \"\"\"\n        if not self._configs:\n            return None\n        if name:\n            return self._configs.get(name)\n        name = self.get_default_config_name()\n        return self._configs.get(name) if name else None\n\n    def get_default_config_name(self) -> Optional[str]:\n        \"\"\"\n        获取默认服务配置的名称\n\n        :return: 默认第一个配置的名称\n        \"\"\"\n        # 默认使用第一个配置的名称\n        first_conf = next(iter(self._configs.values()), None)\n        return first_conf.name if first_conf else None\n\n\nclass _MessageBase(ServiceBase[TService, NotificationConf]):\n    \"\"\"\n    消息基类\n    \"\"\"\n    CONFIG_WATCH = {SystemConfigKey.Notifications.value}\n\n    def __init__(self):\n        \"\"\"\n        初始化消息基类，并设置消息通道\n        \"\"\"\n        super().__init__()\n        self._channel: Optional[MessageChannel] = None\n\n    def get_configs(self) -> Dict[str, NotificationConf]:\n        \"\"\"\n        获取已启用的消息通知渠道的配置字典\n\n        :return: 返回消息通知的配置字典\n        \"\"\"\n        configs = ServiceConfigHelper.get_notification_configs()\n        if not self._service_name:\n            return {}\n        return {conf.name: conf for conf in configs if conf.type == self._service_name and conf.enabled}\n\n    def check_message(self, message: Notification, source: str = None) -> bool:\n        \"\"\"\n        检查消息渠道及消息类型，判断是否处理消息\n\n        :param message: 要检查的通知消息\n        :param source: 消息来源，可选\n        :return: 返回布尔值，表示是否处理该消息\n        \"\"\"\n        # 检查消息渠道\n        if message.channel and message.channel != self._channel:\n            return False\n        # 检查消息来源\n        if message.source and message.source != source:\n            return False\n        # 不是定向发送时，检查消息类型开关\n        if not message.userid and message.mtype:\n            conf = self.get_config(source)\n            if conf:\n                switchs = conf.switchs or []\n                if message.mtype.value not in switchs:\n                    return False\n        return True\n\n\nclass _DownloaderBase(ServiceBase[TService, DownloaderConf]):\n    \"\"\"\n    下载器基类\n    \"\"\"\n    CONFIG_WATCH = {SystemConfigKey.Downloaders.value}\n\n    def __init__(self):\n        \"\"\"\n        初始化下载器基类\n        \"\"\"\n        super().__init__()\n        self._default_config_name: Optional[str] = None\n\n    def init_service(self, service_name: str,\n                     service_type: Optional[Union[Type[TService], Callable[..., TService]]] = None):\n        \"\"\"\n        初始化服务，获取配置并实例化对应服务\n\n        :param service_name: 服务名称，作为配置匹配的依据\n        :param service_type: 服务的类型，可以是类类型（Type[TService]）、工厂函数（Callable）或 None 来跳过实例化\n        \"\"\"\n        # 重置默认配置名称\n        self.reset_default_config_name()\n        # 初始化服务\n        super().init_service(service_name, service_type)\n\n    def get_default_config_name(self) -> Optional[str]:\n        \"\"\"\n        获取默认服务配置的名称\n\n        :return: 优先从所有下载器中查找配置了默认的下载器，如果没有配置，则获取第一个下载器名称\n        \"\"\"\n        # 优先查找默认配置\n        if self._default_config_name:\n            return self._default_config_name\n\n        configs = ServiceConfigHelper.get_downloader_configs()\n        for conf in configs:\n            if conf.default:\n                self._default_config_name = conf.name\n                return self._default_config_name\n        # 如果没有默认配置，返回第一个配置的名称\n        first_conf = next(iter(configs), None)\n        self._default_config_name = first_conf.name if first_conf else None\n        return self._default_config_name\n\n    def get_configs(self) -> Dict[str, DownloaderConf]:\n        \"\"\"\n        获取已启用的下载器的配置字典\n\n        :return: 返回下载器配置字典\n        \"\"\"\n        configs = ServiceConfigHelper.get_downloader_configs()\n        if not self._service_name:\n            return {}\n        return {conf.name: conf for conf in configs if conf.type == self._service_name and conf.enabled}\n\n    def reset_default_config_name(self):\n        \"\"\"\n        重置默认配置名称\n        \"\"\"\n        self._default_config_name = None\n    \n    def normalize_path(self, path: Path, downloader: Optional[str]) -> str:\n        \"\"\"\n        根据下载器配置和路径映射，规范化下载路径\n\n        :param path: 存储路径\n        :param downloader: 下载器名称\n        :return: 规范化后发送给下载器的路径\n        \"\"\"\n        dir = path.as_posix()\n        conf = self.get_config(downloader)\n        if conf and conf.path_mapping:\n            for (storage_path, download_path) in conf.path_mapping:\n                storage_path = Path(storage_path.strip()).as_posix()\n                download_path = Path(download_path.strip()).as_posix()\n                if dir.startswith(storage_path):\n                    dir = dir.replace(storage_path, download_path, 1)\n                    break\n        # 去掉存储协议前缀 if any, 下载器无法识别\n        for s in StorageSchema:\n            prefix = f\"{s.value}:\"\n            if dir.startswith(prefix):\n                return dir[len(prefix):]\n        return dir\n\n\nclass _MediaServerBase(ServiceBase[TService, MediaServerConf]):\n    \"\"\"\n    媒体服务器基类\n    \"\"\"\n    CONFIG_WATCH = {SystemConfigKey.MediaServers.value}\n\n    def get_configs(self) -> Dict[str, MediaServerConf]:\n        \"\"\"\n        获取已启用的媒体服务器的配置字典\n\n        :return: 返回媒体服务器配置字典\n        \"\"\"\n        configs = ServiceConfigHelper.get_mediaserver_configs()\n        if not self._service_name:\n            return {}\n        return {conf.name: conf for conf in configs if conf.type == self._service_name and conf.enabled}\n"
  },
  {
    "path": "app/modules/bangumi/__init__.py",
    "content": "from typing import List, Optional, Tuple, Union\n\nfrom app import schemas\nfrom app.core.config import settings\nfrom app.core.context import MediaInfo\nfrom app.core.meta import MetaBase\nfrom app.log import logger\nfrom app.modules import _ModuleBase\nfrom app.modules.bangumi.bangumi import BangumiApi\nfrom app.schemas.types import ModuleType, MediaRecognizeType\nfrom app.utils.http import RequestUtils\n\n\nclass BangumiModule(_ModuleBase):\n    bangumiapi: BangumiApi = None\n\n    def init_module(self) -> None:\n        self.bangumiapi = BangumiApi()\n\n    def stop(self):\n        self.bangumiapi.close()\n\n    def test(self) -> Tuple[bool, str]:\n        \"\"\"\n        测试模块连接性\n        \"\"\"\n        ret = RequestUtils().get_res(\"https://api.bgm.tv/\")\n        if ret and ret.status_code == 200:\n            return True, \"\"\n        elif ret:\n            return False, f\"无法连接Bangumi，错误码：{ret.status_code}\"\n        return False, \"Bangumi网络连接失败\"\n\n    def init_setting(self) -> Tuple[str, Union[str, bool]]:\n        pass\n\n    @staticmethod\n    def get_name() -> str:\n        return \"Bangumi\"\n\n    @staticmethod\n    def get_type() -> ModuleType:\n        \"\"\"\n        获取模块类型\n        \"\"\"\n        return ModuleType.MediaRecognize\n\n    @staticmethod\n    def get_subtype() -> MediaRecognizeType:\n        \"\"\"\n        获取模块子类型\n        \"\"\"\n        return MediaRecognizeType.Bangumi\n\n    @staticmethod\n    def get_priority() -> int:\n        \"\"\"\n        获取模块优先级，数字越小优先级越高，只有同一接口下优先级才生效\n        \"\"\"\n        return 3\n\n    def recognize_media(self, bangumiid: int = None,\n                        **kwargs) -> Optional[MediaInfo]:\n        \"\"\"\n        识别媒体信息\n        :param bangumiid: 识别的Bangumi ID\n        :return: 识别的媒体信息，包括剧集信息\n        \"\"\"\n        if not bangumiid:\n            return None\n\n        # 直接查询详情\n        info = self.bangumi_info(bangumiid=bangumiid)\n        if info:\n            # 赋值TMDB信息并返回\n            mediainfo = MediaInfo(bangumi_info=info)\n            logger.info(f\"{bangumiid} Bangumi识别结果：{mediainfo.type.value} \"\n                        f\"{mediainfo.title_year}\")\n            return mediainfo\n        else:\n            logger.info(f\"{bangumiid} 未匹配到Bangumi媒体信息\")\n\n        return None\n\n    async def async_recognize_media(self, bangumiid: int = None,\n                                    **kwargs) -> Optional[MediaInfo]:\n        \"\"\"\n        识别媒体信息（异步版本）\n        :param bangumiid: 识别的Bangumi ID\n        :return: 识别的媒体信息，包括剧集信息\n        \"\"\"\n        if not bangumiid:\n            return None\n\n        # 直接查询详情\n        info = await self.async_bangumi_info(bangumiid=bangumiid)\n        if info:\n            # 赋值TMDB信息并返回\n            mediainfo = MediaInfo(bangumi_info=info)\n            logger.info(f\"{bangumiid} Bangumi识别结果：{mediainfo.type.value} \"\n                        f\"{mediainfo.title_year}\")\n            return mediainfo\n        else:\n            logger.info(f\"{bangumiid} 未匹配到Bangumi媒体信息\")\n\n        return None\n\n    def search_medias(self, meta: MetaBase) -> Optional[List[MediaInfo]]:\n        \"\"\"\n        搜索媒体信息\n        :param meta:  识别的元数据\n        :reutrn: 媒体信息\n        \"\"\"\n        if settings.SEARCH_SOURCE and \"bangumi\" not in settings.SEARCH_SOURCE:\n            return None\n        if not meta.name:\n            return []\n        infos = self.bangumiapi.search(meta.name)\n        if infos:\n            return [MediaInfo(bangumi_info=info) for info in infos\n                    if meta.name.lower() in str(info.get(\"name\")).lower()\n                    or meta.name.lower() in str(info.get(\"name_cn\")).lower()]\n        return []\n\n    async def async_search_medias(self, meta: MetaBase) -> Optional[List[MediaInfo]]:\n        \"\"\"\n        搜索媒体信息（异步版本）\n        :param meta:  识别的元数据\n        :reutrn: 媒体信息\n        \"\"\"\n        if settings.SEARCH_SOURCE and \"bangumi\" not in settings.SEARCH_SOURCE:\n            return None\n        if not meta.name:\n            return []\n        infos = await self.bangumiapi.async_search(meta.name)\n        if infos:\n            return [MediaInfo(bangumi_info=info) for info in infos\n                    if meta.name.lower() in str(info.get(\"name\")).lower()\n                    or meta.name.lower() in str(info.get(\"name_cn\")).lower()]\n        return []\n\n    def bangumi_info(self, bangumiid: int) -> Optional[dict]:\n        \"\"\"\n        获取Bangumi信息\n        :param bangumiid: BangumiID\n        :return: Bangumi信息\n        \"\"\"\n        if not bangumiid:\n            return None\n        logger.info(f\"开始获取Bangumi信息：{bangumiid} ...\")\n        return self.bangumiapi.detail(bangumiid)\n\n    async def async_bangumi_info(self, bangumiid: int) -> Optional[dict]:\n        \"\"\"\n        获取Bangumi信息（异步版本）\n        :param bangumiid: BangumiID\n        :return: Bangumi信息\n        \"\"\"\n        if not bangumiid:\n            return None\n        logger.info(f\"开始获取Bangumi信息：{bangumiid} ...\")\n        return await self.bangumiapi.async_detail(bangumiid)\n\n    def bangumi_calendar(self) -> Optional[List[MediaInfo]]:\n        \"\"\"\n        获取Bangumi每日放送\n        \"\"\"\n        infos = self.bangumiapi.calendar()\n        if infos:\n            return [MediaInfo(bangumi_info=info) for info in infos]\n        return []\n\n    async def async_bangumi_calendar(self) -> Optional[List[MediaInfo]]:\n        \"\"\"\n        获取Bangumi每日放送（异步版本）\n        \"\"\"\n        infos = await self.bangumiapi.async_calendar()\n        if infos:\n            return [MediaInfo(bangumi_info=info) for info in infos]\n        return []\n\n    def bangumi_credits(self, bangumiid: int) -> List[schemas.MediaPerson]:\n        \"\"\"\n        根据TMDBID查询电影演职员表\n        :param bangumiid:  BangumiID\n        \"\"\"\n        persons = self.bangumiapi.credits(bangumiid)\n        if persons:\n            return [schemas.MediaPerson(source='bangumi', **person) for person in persons]\n        return []\n\n    async def async_bangumi_credits(self, bangumiid: int) -> List[schemas.MediaPerson]:\n        \"\"\"\n        根据TMDBID查询电影演职员表（异步版本）\n        :param bangumiid:  BangumiID\n        \"\"\"\n        persons = await self.bangumiapi.async_credits(bangumiid)\n        if persons:\n            return [schemas.MediaPerson(source='bangumi', **person) for person in persons]\n        return []\n\n    def bangumi_recommend(self, bangumiid: int) -> List[MediaInfo]:\n        \"\"\"\n        根据BangumiID查询推荐电影\n        :param bangumiid:  BangumiID\n        \"\"\"\n        subjects = self.bangumiapi.subjects(bangumiid)\n        if subjects:\n            return [MediaInfo(bangumi_info=subject) for subject in subjects]\n        return []\n\n    async def async_bangumi_recommend(self, bangumiid: int) -> List[MediaInfo]:\n        \"\"\"\n        根据BangumiID查询推荐电影（异步版本）\n        :param bangumiid:  BangumiID\n        \"\"\"\n        subjects = await self.bangumiapi.async_subjects(bangumiid)\n        if subjects:\n            return [MediaInfo(bangumi_info=subject) for subject in subjects]\n        return []\n\n    def bangumi_person_detail(self, person_id: int) -> Optional[schemas.MediaPerson]:\n        \"\"\"\n        获取人物详细信息\n        :param person_id:  豆瓣人物ID\n        \"\"\"\n        personinfo = self.bangumiapi.person_detail(person_id)\n        if personinfo:\n            return schemas.MediaPerson(source='bangumi', **{\n                \"id\": personinfo.get(\"id\"),\n                \"name\": personinfo.get(\"name\"),\n                \"images\": personinfo.get(\"images\"),\n                \"biography\": personinfo.get(\"summary\"),\n                \"birthday\": personinfo.get(\"birth_day\"),\n                \"gender\": personinfo.get(\"gender\")\n            })\n        return None\n\n    async def async_bangumi_person_detail(self, person_id: int) -> Optional[schemas.MediaPerson]:\n        \"\"\"\n        获取人物详细信息（异步版本）\n        :param person_id:  豆瓣人物ID\n        \"\"\"\n        personinfo = await self.bangumiapi.async_person_detail(person_id)\n        if personinfo:\n            return schemas.MediaPerson(source='bangumi', **{\n                \"id\": personinfo.get(\"id\"),\n                \"name\": personinfo.get(\"name\"),\n                \"images\": personinfo.get(\"images\"),\n                \"biography\": personinfo.get(\"summary\"),\n                \"birthday\": personinfo.get(\"birth_day\"),\n                \"gender\": personinfo.get(\"gender\")\n            })\n        return None\n\n    def bangumi_person_credits(self, person_id: int) -> List[MediaInfo]:\n        \"\"\"\n        根据TMDBID查询人物参演作品\n        :param person_id:  人物ID\n        \"\"\"\n        credits_info = self.bangumiapi.person_credits(person_id=person_id)\n        if credits_info:\n            return [MediaInfo(bangumi_info=credit) for credit in credits_info]\n        return []\n\n    async def async_bangumi_person_credits(self, person_id: int) -> List[MediaInfo]:\n        \"\"\"\n        根据TMDBID查询人物参演作品（异步版本）\n        :param person_id:  人物ID\n        \"\"\"\n        credits_info = await self.bangumiapi.async_person_credits(person_id=person_id)\n        if credits_info:\n            return [MediaInfo(bangumi_info=credit) for credit in credits_info]\n        return []\n\n    def bangumi_discover(self, **kwargs) -> Optional[List[MediaInfo]]:\n        \"\"\"\n        发现Bangumi番剧\n        \"\"\"\n        infos = self.bangumiapi.discover(**kwargs)\n        if infos:\n            return [MediaInfo(bangumi_info=info) for info in infos]\n        return []\n\n    async def async_bangumi_discover(self, **kwargs) -> Optional[List[MediaInfo]]:\n        \"\"\"\n        发现Bangumi番剧（异步版本）\n        \"\"\"\n        infos = await self.bangumiapi.async_discover(**kwargs)\n        if infos:\n            return [MediaInfo(bangumi_info=info) for info in infos]\n        return []\n\n    def clear_cache(self):\n        \"\"\"\n        清除缓存\n        \"\"\"\n        logger.info(f\"开始清除{self.get_name()}缓存 ...\")\n        self.bangumiapi.clear_cache()\n        logger.info(f\"{self.get_name()}缓存清除完成\")\n"
  },
  {
    "path": "app/modules/bangumi/bangumi.py",
    "content": "from datetime import datetime\nfrom typing import Optional\n\nimport requests\n\nfrom app.core.cache import cached\nfrom app.core.config import settings\nfrom app.utils.http import RequestUtils, AsyncRequestUtils\n\n\nclass BangumiApi(object):\n    \"\"\"\n    https://bangumi.github.io/api/\n    \"\"\"\n\n    _urls = {\n        \"discover\": \"v0/subjects\",\n        \"search\": \"search/subjects/%s?type=2\",\n        \"calendar\": \"calendar\",\n        \"detail\": \"v0/subjects/%s\",\n        \"credits\": \"v0/subjects/%s/persons\",\n        \"subjects\": \"v0/subjects/%s/subjects\",\n        \"characters\": \"v0/subjects/%s/characters\",\n        \"person_detail\": \"v0/persons/%s\",\n        \"person_credits\": \"v0/persons/%s/subjects\",\n    }\n    _base_url = \"https://api.bgm.tv/\"\n\n    def __init__(self):\n        self._session = requests.Session()\n        self._req = RequestUtils(ua=settings.NORMAL_USER_AGENT, session=self._session)\n        self._async_req = AsyncRequestUtils(ua=settings.NORMAL_USER_AGENT)\n\n    @cached(maxsize=settings.CONF.bangumi, ttl=settings.CONF.meta, shared_key=\"get\")\n    def __invoke(self, url, key: Optional[str] = None, **kwargs):\n        req_url = self._base_url + url\n        params = {}\n        if kwargs:\n            params.update(kwargs)\n        resp = self._req.get_res(url=req_url, params=params)\n        try:\n            if not resp:\n                return None\n            result = resp.json()\n            return result.get(key) if key else result\n        except Exception as e:\n            print(e)\n            return None\n\n    @cached(maxsize=settings.CONF.bangumi, ttl=settings.CONF.meta, shared_key=\"get\")\n    async def __async_invoke(self, url, key: Optional[str] = None, **kwargs):\n        req_url = self._base_url + url\n        params = {}\n        if kwargs:\n            params.update(kwargs)\n        resp = await self._async_req.get_res(url=req_url, params=params)\n        try:\n            if not resp:\n                return None\n            result = resp.json()\n            return result.get(key) if key else result\n        except Exception as e:\n            print(e)\n            return None\n\n    def search(self, name):\n        \"\"\"\n        搜索媒体信息\n        \"\"\"\n        result = self.__invoke(\"search/subject/%s\" % name)\n        if result:\n            return result.get(\"list\")\n        return []\n\n    async def async_search(self, name):\n        \"\"\"\n        搜索媒体信息（异步版本）\n        \"\"\"\n        result = await self.__async_invoke(\"search/subject/%s\" % name)\n        if result:\n            return result.get(\"list\")\n        return []\n\n    def calendar(self):\n        \"\"\"\n        获取每日放送，返回items\n        \"\"\"\n        \"\"\"\n        [\n          {\n            \"weekday\": {\n              \"en\": \"Mon\",\n              \"cn\": \"星期一\",\n              \"ja\": \"月耀日\",\n              \"id\": 1\n            },\n            \"items\": [\n              {\n                \"id\": 350235,\n                \"url\": \"http://bgm.tv/subject/350235\",\n                \"type\": 2,\n                \"name\": \"月が導く異世界道中 第二幕\",\n                \"name_cn\": \"月光下的异世界之旅 第二幕\",\n                \"summary\": \"\",\n                \"air_date\": \"2024-01-08\",\n                \"air_weekday\": 1,\n                \"rating\": {\n                  \"total\": 257,\n                  \"count\": {\n                    \"1\": 1,\n                    \"2\": 1,\n                    \"3\": 4,\n                    \"4\": 15,\n                    \"5\": 51,\n                    \"6\": 111,\n                    \"7\": 49,\n                    \"8\": 13,\n                    \"9\": 5,\n                    \"10\": 7\n                  },\n                  \"score\": 6.1\n                },\n                \"rank\": 6125,\n                \"images\": {\n                  \"large\": \"http://lain.bgm.tv/pic/cover/l/3c/a5/350235_A0USf.jpg\",\n                  \"common\": \"http://lain.bgm.tv/pic/cover/c/3c/a5/350235_A0USf.jpg\",\n                  \"medium\": \"http://lain.bgm.tv/pic/cover/m/3c/a5/350235_A0USf.jpg\",\n                  \"small\": \"http://lain.bgm.tv/pic/cover/s/3c/a5/350235_A0USf.jpg\",\n                  \"grid\": \"http://lain.bgm.tv/pic/cover/g/3c/a5/350235_A0USf.jpg\"\n                },\n                \"collection\": {\n                  \"doing\": 920\n                }\n              },\n              {\n                \"id\": 358561,\n                \"url\": \"http://bgm.tv/subject/358561\",\n                \"type\": 2,\n                \"name\": \"大宇宙时代\",\n                \"name_cn\": \"大宇宙时代\",\n                \"summary\": \"\",\n                \"air_date\": \"2024-01-22\",\n                \"air_weekday\": 1,\n                \"rating\": {\n                  \"total\": 2,\n                  \"count\": {\n                    \"1\": 0,\n                    \"2\": 0,\n                    \"3\": 0,\n                    \"4\": 0,\n                    \"5\": 1,\n                    \"6\": 1,\n                    \"7\": 0,\n                    \"8\": 0,\n                    \"9\": 0,\n                    \"10\": 0\n                  },\n                  \"score\": 5.5\n                },\n                \"images\": {\n                  \"large\": \"http://lain.bgm.tv/pic/cover/l/71/66/358561_UzsLu.jpg\",\n                  \"common\": \"http://lain.bgm.tv/pic/cover/c/71/66/358561_UzsLu.jpg\",\n                  \"medium\": \"http://lain.bgm.tv/pic/cover/m/71/66/358561_UzsLu.jpg\",\n                  \"small\": \"http://lain.bgm.tv/pic/cover/s/71/66/358561_UzsLu.jpg\",\n                  \"grid\": \"http://lain.bgm.tv/pic/cover/g/71/66/358561_UzsLu.jpg\"\n                },\n                \"collection\": {\n                  \"doing\": 9\n                }\n              }\n            ]\n          }\n        ]\n        \"\"\"\n        ret_list = []\n        result = self.__invoke(self._urls[\"calendar\"], _ts=datetime.strftime(datetime.now(), '%Y%m%d'))\n        if result:\n            for item in result:\n                ret_list.extend(item.get(\"items\") or [])\n        return ret_list\n\n    async def async_calendar(self):\n        \"\"\"\n        获取每日放送，返回items（异步版本）\n        \"\"\"\n        ret_list = []\n        result = await self.__async_invoke(self._urls[\"calendar\"], _ts=datetime.strftime(datetime.now(), '%Y%m%d'))\n        if result:\n            for item in result:\n                ret_list.extend(item.get(\"items\") or [])\n        return ret_list\n\n    def detail(self, bid: int):\n        \"\"\"\n        获取番剧详情\n        \"\"\"\n        return self.__invoke(self._urls[\"detail\"] % bid, _ts=datetime.strftime(datetime.now(), '%Y%m%d'))\n\n    async def async_detail(self, bid: int):\n        \"\"\"\n        获取番剧详情（异步版本）\n        \"\"\"\n        return await self.__async_invoke(self._urls[\"detail\"] % bid, _ts=datetime.strftime(datetime.now(), '%Y%m%d'))\n\n    def credits(self, bid: int):\n        \"\"\"\n        获取番剧人物\n        \"\"\"\n        ret_list = []\n        result = self.__invoke(self._urls[\"characters\"] % bid, _ts=datetime.strftime(datetime.now(), '%Y%m%d'))\n        if result:\n            for item in result:\n                character_id = item.get(\"id\")\n                actors = item.get(\"actors\")\n                if character_id and actors and actors[0]:\n                    actor_info = actors[0]\n                    actor_info.update({'career': [item.get('name')]})\n                    ret_list.append(actor_info)\n        return ret_list\n\n    async def async_credits(self, bid: int):\n        \"\"\"\n        获取番剧人物（异步版本）\n        \"\"\"\n        ret_list = []\n        result = await self.__async_invoke(self._urls[\"characters\"] % bid,\n                                           _ts=datetime.strftime(datetime.now(), '%Y%m%d'))\n        if result:\n            for item in result:\n                character_id = item.get(\"id\")\n                actors = item.get(\"actors\")\n                if character_id and actors and actors[0]:\n                    actor_info = actors[0]\n                    actor_info.update({'career': [item.get('name')]})\n                    ret_list.append(actor_info)\n        return ret_list\n\n    def subjects(self, bid: int):\n        \"\"\"\n        获取关联条目信息\n        \"\"\"\n        return self.__invoke(self._urls[\"subjects\"] % bid, _ts=datetime.strftime(datetime.now(), '%Y%m%d'))\n\n    async def async_subjects(self, bid: int):\n        \"\"\"\n        获取关联条目信息（异步版本）\n        \"\"\"\n        return await self.__async_invoke(self._urls[\"subjects\"] % bid, _ts=datetime.strftime(datetime.now(), '%Y%m%d'))\n\n    def person_detail(self, person_id: int):\n        \"\"\"\n        获取人物详细信息\n        \"\"\"\n        return self.__invoke(self._urls[\"person_detail\"] % person_id, _ts=datetime.strftime(datetime.now(), '%Y%m%d'))\n\n    async def async_person_detail(self, person_id: int):\n        \"\"\"\n        获取人物详细信息（异步版本）\n        \"\"\"\n        return await self.__async_invoke(self._urls[\"person_detail\"] % person_id,\n                                         _ts=datetime.strftime(datetime.now(), '%Y%m%d'))\n\n    def person_credits(self, person_id: int):\n        \"\"\"\n        获取人物参演作品\n        \"\"\"\n        ret_list = []\n        result = self.__invoke(self._urls[\"person_credits\"] % person_id,\n                               _ts=datetime.strftime(datetime.now(), '%Y%m%d'))\n        if result:\n            for item in result:\n                ret_list.append(item)\n        return ret_list\n\n    async def async_person_credits(self, person_id: int):\n        \"\"\"\n        获取人物参演作品（异步版本）\n        \"\"\"\n        ret_list = []\n        result = await self.__async_invoke(self._urls[\"person_credits\"] % person_id,\n                                           _ts=datetime.strftime(datetime.now(), '%Y%m%d'))\n        if result:\n            for item in result:\n                ret_list.append(item)\n        return ret_list\n\n    def discover(self, **kwargs):\n        \"\"\"\n        发现\n        \"\"\"\n        return self.__invoke(self._urls[\"discover\"],\n                             key=\"data\",\n                             _ts=datetime.strftime(datetime.now(), '%Y%m%d'), **kwargs)\n\n    async def async_discover(self, **kwargs):\n        \"\"\"\n        发现（异步版本）\n        \"\"\"\n        return await self.__async_invoke(self._urls[\"discover\"],\n                                         key=\"data\",\n                                         _ts=datetime.strftime(datetime.now(), '%Y%m%d'), **kwargs)\n\n    def clear_cache(self):\n        \"\"\"\n        清除缓存\n        \"\"\"\n        self.__invoke.cache_clear()\n\n    def close(self):\n        if self._session:\n            self._session.close()\n"
  },
  {
    "path": "app/modules/discord/__init__.py",
    "content": "import json\nfrom typing import Optional, Union, List, Tuple, Any\n\nfrom app.core.context import MediaInfo, Context\nfrom app.log import logger\nfrom app.modules import _ModuleBase, _MessageBase\nfrom app.schemas import MessageChannel, CommingMessage, Notification\nfrom app.schemas.types import ModuleType\n\ntry:\n    from app.modules.discord.discord import Discord\nexcept Exception as err:  # ImportError or other load issues\n    Discord = None\n    logger.error(f\"Discord 模块未加载，缺少依赖或初始化错误：{err}\")\n\n\nclass DiscordModule(_ModuleBase, _MessageBase[Discord]):\n\n    def init_module(self) -> None:\n        \"\"\"\n        初始化模块\n        \"\"\"\n        if not Discord:\n            logger.error(\"Discord 依赖未就绪（需要安装 discord.py==2.6.4），模块未启动\")\n            return\n        self.stop()\n        super().init_service(service_name=Discord.__name__.lower(),\n                             service_type=Discord)\n        self._channel = MessageChannel.Discord\n\n    @staticmethod\n    def get_name() -> str:\n        return \"Discord\"\n\n    @staticmethod\n    def get_type() -> ModuleType:\n        \"\"\"\n        获取模块类型\n        \"\"\"\n        return ModuleType.Notification\n\n    @staticmethod\n    def get_subtype() -> MessageChannel:\n        \"\"\"\n        获取模块子类型\n        \"\"\"\n        return MessageChannel.Discord\n\n    @staticmethod\n    def get_priority() -> int:\n        \"\"\"\n        获取模块优先级，数字越小优先级越高，只有同一接口下优先级才生效\n        \"\"\"\n        return 4\n\n    def stop(self):\n        \"\"\"\n        停止模块\n        \"\"\"\n        for client in self.get_instances().values():\n            client.stop()\n\n    def test(self) -> Optional[Tuple[bool, str]]:\n        \"\"\"\n        测试模块连接性\n        \"\"\"\n        if not self.get_instances():\n            return None\n        for name, client in self.get_instances().items():\n            state = client.get_state()\n            if not state:\n                return False, f\"Discord {name} Bot 未就绪\"\n        return True, \"\"\n\n    def init_setting(self) -> Tuple[str, Union[str, bool]]:\n        pass\n\n    def message_parser(self, source: str, body: Any, form: Any, args: Any) -> Optional[CommingMessage]:\n        \"\"\"\n        解析消息内容，返回字典，注意以下约定值：\n        userid: 用户ID\n        username: 用户名\n        text: 内容\n        :param source: 消息来源\n        :param body: 请求体\n        :param form: 表单\n        :param args: 参数\n        :return: 渠道、消息体\n        \"\"\"\n        client_config = self.get_config(source)\n        if not client_config:\n            return None\n        try:\n            msg_json: dict = json.loads(body)\n        except Exception as e:\n            logger.debug(f\"解析 Discord 消息失败：{str(e)}\")\n            return None\n\n        if not msg_json:\n            return None\n\n        msg_type = msg_json.get(\"type\")\n        userid = msg_json.get(\"userid\")\n        username = msg_json.get(\"username\")\n\n        if msg_type == \"interaction\":\n            callback_data = msg_json.get(\"callback_data\")\n            message_id = msg_json.get(\"message_id\")\n            chat_id = msg_json.get(\"chat_id\")\n            if callback_data and userid:\n                logger.info(f\"收到来自 {client_config.name} 的 Discord 按钮回调：\"\n                            f\"userid={userid}, username={username}, callback_data={callback_data}\")\n                return CommingMessage(\n                    channel=MessageChannel.Discord,\n                    source=client_config.name,\n                    userid=userid,\n                    username=username,\n                    text=f\"CALLBACK:{callback_data}\",\n                    is_callback=True,\n                    callback_data=callback_data,\n                    message_id=message_id,\n                    chat_id=str(chat_id) if chat_id else None\n                )\n            return None\n\n        if msg_type == \"message\":\n            text = msg_json.get(\"text\")\n            chat_id = msg_json.get(\"chat_id\")\n            if text and userid:\n                logger.info(f\"收到来自 {client_config.name} 的 Discord 消息：\"\n                            f\"userid={userid}, username={username}, text={text}\")\n                return CommingMessage(channel=MessageChannel.Discord, source=client_config.name,\n                                      userid=userid, username=username, text=text,\n                                      chat_id=str(chat_id) if chat_id else None)\n        return None\n\n    def post_message(self, message: Notification, **kwargs) -> None:\n        \"\"\"\n        发送通知消息\n        :param message: 消息通知对象\n        \"\"\"\n        # DEBUG: Log entry and configs\n        configs = self.get_configs()\n        logger.debug(f\"[Discord] post_message 被调用，message.source={message.source}, \"\n                     f\"message.userid={message.userid}, message.channel={message.channel}\")\n        logger.debug(f\"[Discord] 当前配置数量: {len(configs)}, 配置名称: {list(configs.keys())}\")\n        logger.debug(f\"[Discord] 当前实例数量: {len(self.get_instances())}, 实例名称: {list(self.get_instances().keys())}\")\n\n        if not configs:\n            logger.warning(\"[Discord] get_configs() 返回空，没有可用的 Discord 配置\")\n            return\n\n        for conf in configs.values():\n            logger.debug(f\"[Discord] 检查配置: name={conf.name}, type={conf.type}, enabled={conf.enabled}\")\n            if not self.check_message(message, conf.name):\n                logger.debug(f\"[Discord] check_message 返回 False，跳过配置: {conf.name}\")\n                continue\n            logger.debug(f\"[Discord] check_message 通过，准备发送到: {conf.name}\")\n            targets = message.targets\n            userid = message.userid\n            if not userid and targets is not None:\n                userid = targets.get('discord_userid')\n                if not userid:\n                    logger.warn(\"用户没有指定 Discord 用户ID，消息无法发送\")\n                    return\n            client: Discord = self.get_instance(conf.name)\n            logger.debug(f\"[Discord] get_instance('{conf.name}') 返回: {client is not None}\")\n            if client:\n                logger.debug(f\"[Discord] 调用 client.send_msg, userid={userid}, title={message.title[:50] if message.title else None}...\")\n                result = client.send_msg(title=message.title, text=message.text,\n                                image=message.image, userid=userid, link=message.link,\n                                buttons=message.buttons,\n                                original_message_id=message.original_message_id,\n                                original_chat_id=message.original_chat_id,\n                                mtype=message.mtype)\n                logger.debug(f\"[Discord] send_msg 返回结果: {result}\")\n            else:\n                logger.warning(f\"[Discord] 未找到配置 '{conf.name}' 对应的 Discord 客户端实例\")\n\n    def post_medias_message(self, message: Notification, medias: List[MediaInfo]) -> None:\n        \"\"\"\n        发送媒体信息选择列表\n        :param message: 消息体\n        :param medias: 媒体信息\n        :return: 成功或失败\n        \"\"\"\n        for conf in self.get_configs().values():\n            if not self.check_message(message, conf.name):\n                continue\n            client: Discord = self.get_instance(conf.name)\n            if client:\n                client.send_medias_msg(title=message.title, medias=medias, userid=message.userid,\n                                       buttons=message.buttons,\n                                       original_message_id=message.original_message_id,\n                                       original_chat_id=message.original_chat_id)\n\n    def post_torrents_message(self, message: Notification, torrents: List[Context]) -> None:\n        \"\"\"\n        发送种子信息选择列表\n        :param message: 消息体\n        :param torrents: 种子信息\n        :return: 成功或失败\n        \"\"\"\n        for conf in self.get_configs().values():\n            if not self.check_message(message, conf.name):\n                continue\n            client: Discord = self.get_instance(conf.name)\n            if client:\n                client.send_torrents_msg(title=message.title, torrents=torrents,\n                                         userid=message.userid, buttons=message.buttons,\n                                         original_message_id=message.original_message_id,\n                                         original_chat_id=message.original_chat_id)\n\n    def delete_message(self, channel: MessageChannel, source: str,\n                       message_id: str, chat_id: Optional[str] = None) -> bool:\n        \"\"\"\n        删除消息\n        :param channel: 消息渠道\n        :param source: 指定的消息源\n        :param message_id: 消息ID（Slack中为时间戳）\n        :param chat_id: 聊天ID（频道ID）\n        :return: 删除是否成功\n        \"\"\"\n        success = False\n        for conf in self.get_configs().values():\n            if channel != self._channel:\n                break\n            if source != conf.name:\n                continue\n            client: Discord = self.get_instance(conf.name)\n            if client:\n                result = client.delete_msg(message_id=message_id, chat_id=chat_id)\n                if result:\n                    success = True\n        return success\n"
  },
  {
    "path": "app/modules/discord/discord.py",
    "content": "import asyncio\nimport re\nimport threading\nfrom typing import Optional, List, Dict, Any, Tuple, Union\nfrom urllib.parse import quote\n\nimport discord\nfrom discord import app_commands\nimport httpx\n\nfrom app.core.config import settings\nfrom app.core.context import MediaInfo, Context\nfrom app.core.metainfo import MetaInfo\nfrom app.log import logger\nfrom app.schemas.types import NotificationType\nfrom app.utils.string import StringUtils\n\n# Discord embed 字段解析白名单\n# 只有这些消息类型会使用复杂的字段解析逻辑\nPARSE_FIELD_TYPES = {\n    NotificationType.Download,      # 资源下载\n    NotificationType.Organize,      # 整理入库\n    NotificationType.Subscribe,     # 订阅\n    NotificationType.Manual,        # 手动处理\n}\n\n\nclass Discord:\n    \"\"\"\n    Discord Bot 通知与交互实现（基于 discord.py 2.6.4）\n    \"\"\"\n\n    def __init__(self, DISCORD_BOT_TOKEN: Optional[str] = None,\n                 DISCORD_GUILD_ID: Optional[Union[str, int]] = None,\n                 DISCORD_CHANNEL_ID: Optional[Union[str, int]] = None,\n                 **kwargs):\n        logger.debug(f\"[Discord] 初始化 Discord 实例: name={kwargs.get('name')}, \"\n                     f\"GUILD_ID={DISCORD_GUILD_ID}, CHANNEL_ID={DISCORD_CHANNEL_ID}, \"\n                     f\"TOKEN={'已配置' if DISCORD_BOT_TOKEN else '未配置'}\")\n        if not DISCORD_BOT_TOKEN:\n            logger.error(\"Discord Bot Token 未配置！\")\n            return\n\n        self._token = DISCORD_BOT_TOKEN\n        self._guild_id = self._to_int(DISCORD_GUILD_ID)\n        self._channel_id = self._to_int(DISCORD_CHANNEL_ID)\n        logger.debug(f\"[Discord] 解析后的 ID: _guild_id={self._guild_id}, _channel_id={self._channel_id}\")\n        base_ds_url = f\"http://127.0.0.1:{settings.PORT}/api/v1/message/\"\n        self._ds_url = f\"{base_ds_url}?token={settings.API_TOKEN}\"\n        if kwargs.get(\"name\"):\n            # URL encode the source name to handle special characters in config names\n            encoded_name = quote(kwargs.get('name'), safe='')\n            self._ds_url = f\"{self._ds_url}&source={encoded_name}\"\n        logger.debug(f\"[Discord] 消息回调 URL: {self._ds_url}\")\n\n        intents = discord.Intents.default()\n        intents.message_content = True\n        intents.messages = True\n        intents.guilds = True\n\n        self._client: Optional[discord.Client] = discord.Client(\n            intents=intents,\n            proxy=settings.PROXY_HOST\n        )\n        self._tree: Optional[app_commands.CommandTree] = None\n        self._loop: asyncio.AbstractEventLoop = asyncio.new_event_loop()\n        self._thread: Optional[threading.Thread] = None\n        self._ready_event = threading.Event()\n        self._user_dm_cache: Dict[str, discord.DMChannel] = {}\n        self._user_chat_mapping: Dict[str, str] = {}  # userid -> chat_id mapping for reply targeting\n        self._broadcast_channel = None\n        self._bot_user_id: Optional[int] = None\n\n        self._register_events()\n        self._start()\n\n    @staticmethod\n    def _to_int(val: Optional[Union[str, int]]) -> Optional[int]:\n        try:\n            return int(val) if val is not None and str(val).strip() else None\n        except ValueError:\n            return None\n\n    def _register_events(self):\n        @self._client.event\n        async def on_ready():\n            self._bot_user_id = self._client.user.id if self._client.user else None\n            self._ready_event.set()\n            logger.info(f\"Discord Bot 已登录：{self._client.user}\")\n\n        @self._client.event\n        async def on_message(message: discord.Message):\n            if message.author.bot:\n                return\n            if not self._should_process_message(message):\n                return\n\n            # Update user-chat mapping for reply targeting\n            self._update_user_chat_mapping(str(message.author.id), str(message.channel.id))\n\n            cleaned_text = self._clean_bot_mention(message.content or \"\")\n            username = message.author.display_name or message.author.global_name or message.author.name\n            payload = {\n                \"type\": \"message\",\n                \"userid\": str(message.author.id),\n                \"username\": username,\n                \"user_tag\": str(message.author),\n                \"text\": cleaned_text,\n                \"message_id\": str(message.id),\n                \"chat_id\": str(message.channel.id),\n                \"channel_type\": \"dm\" if isinstance(message.channel, discord.DMChannel) else \"guild\"\n            }\n            await self._post_to_ds(payload)\n\n        @self._client.event\n        async def on_interaction(interaction: discord.Interaction):\n            if interaction.type == discord.InteractionType.component:\n                data = interaction.data or {}\n                callback_data = data.get(\"custom_id\")\n                if not callback_data:\n                    return\n                try:\n                    await interaction.response.defer(ephemeral=True)\n                except Exception as e:\n                    logger.error(f\"处理 Discord 交互响应失败：{e}\")\n\n                # Update user-chat mapping for reply targeting\n                if interaction.user and interaction.channel:\n                    self._update_user_chat_mapping(str(interaction.user.id), str(interaction.channel.id))\n\n                username = (interaction.user.display_name or interaction.user.global_name or interaction.user.name) \\\n                    if interaction.user else None\n                payload = {\n                    \"type\": \"interaction\",\n                    \"userid\": str(interaction.user.id) if interaction.user else None,\n                    \"username\": username,\n                    \"user_tag\": str(interaction.user) if interaction.user else None,\n                    \"callback_data\": callback_data,\n                    \"message_id\": str(interaction.message.id) if interaction.message else None,\n                    \"chat_id\": str(interaction.channel.id) if interaction.channel else None\n                }\n                await self._post_to_ds(payload)\n\n    def _start(self):\n        if self._thread:\n            return\n\n        def runner():\n            asyncio.set_event_loop(self._loop)\n            try:\n                self._loop.create_task(self._client.start(self._token))\n                self._loop.run_forever()\n            except Exception as err:\n                logger.error(f\"Discord Bot 启动失败：{err}\")\n            finally:\n                try:\n                    self._loop.run_until_complete(self._client.close())\n                except Exception as err:\n                    logger.debug(f\"Discord Bot 关闭失败：{err}\")\n\n        self._thread = threading.Thread(target=runner, daemon=True)\n        self._thread.start()\n\n    def stop(self):\n        if not self._client or not self._loop or not self._thread:\n            return\n        try:\n            asyncio.run_coroutine_threadsafe(self._client.close(), self._loop).result(timeout=10)\n        except Exception as err:\n            logger.error(f\"关闭 Discord Bot 失败：{err}\")\n        finally:\n            try:\n                self._loop.call_soon_threadsafe(self._loop.stop)\n            except Exception as err:\n                logger.error(f\"停止 Discord 事件循环失败：{err}\")\n            self._ready_event.clear()\n\n    def get_state(self) -> bool:\n        return self._ready_event.is_set() and self._client is not None\n\n    def send_msg(self, title: str, text: Optional[str] = None, image: Optional[str] = None,\n                 userid: Optional[str] = None, link: Optional[str] = None,\n                 buttons: Optional[List[List[dict]]] = None,\n                 original_message_id: Optional[Union[int, str]] = None,\n                 original_chat_id: Optional[str] = None,\n                 mtype: Optional['NotificationType'] = None) -> Optional[bool]:\n        logger.debug(f\"[Discord] send_msg 被调用: userid={userid}, title={title[:50] if title else None}...\")\n        logger.debug(f\"[Discord] get_state() = {self.get_state()}, \"\n                     f\"_ready_event.is_set() = {self._ready_event.is_set()}, \"\n                     f\"_client = {self._client is not None}\")\n        if not self.get_state():\n            logger.warning(\"[Discord] get_state() 返回 False，Bot 未就绪，无法发送消息\")\n            return False\n        if not title and not text:\n            logger.warn(\"标题和内容不能同时为空\")\n            return False\n\n        try:\n            logger.debug(f\"[Discord] 准备异步发送消息...\")\n            future = asyncio.run_coroutine_threadsafe(\n                self._send_message(title=title, text=text, image=image, userid=userid,\n                                   link=link, buttons=buttons,\n                                   original_message_id=original_message_id,\n                                   original_chat_id=original_chat_id,\n                                   mtype=mtype),\n                self._loop)\n            result = future.result(timeout=30)\n            logger.debug(f\"[Discord] 异步发送完成，结果: {result}\")\n            return result\n        except Exception as err:\n            logger.error(f\"发送 Discord 消息失败：{err}\")\n            return False\n\n    def send_medias_msg(self, medias: List[MediaInfo], userid: Optional[str] = None, title: Optional[str] = None,\n                        buttons: Optional[List[List[dict]]] = None,\n                        original_message_id: Optional[Union[int, str]] = None,\n                        original_chat_id: Optional[str] = None) -> Optional[bool]:\n        if not self.get_state() or not medias:\n            return False\n        title = title or \"媒体列表\"\n        try:\n            future = asyncio.run_coroutine_threadsafe(\n                self._send_list_message(\n                    embeds=self._build_media_embeds(medias, title),\n                    userid=userid,\n                    buttons=self._build_default_buttons(len(medias)) if not buttons else buttons,\n                    fallback_buttons=buttons,\n                    original_message_id=original_message_id,\n                    original_chat_id=original_chat_id\n                ),\n                self._loop\n            )\n            return future.result(timeout=30)\n        except Exception as err:\n            logger.error(f\"发送 Discord 媒体列表失败：{err}\")\n            return False\n\n    def send_torrents_msg(self, torrents: List[Context], userid: Optional[str] = None, title: Optional[str] = None,\n                          buttons: Optional[List[List[dict]]] = None,\n                          original_message_id: Optional[Union[int, str]] = None,\n                          original_chat_id: Optional[str] = None) -> Optional[bool]:\n        if not self.get_state() or not torrents:\n            return False\n        title = title or \"种子列表\"\n        try:\n            future = asyncio.run_coroutine_threadsafe(\n                self._send_list_message(\n                    embeds=self._build_torrent_embeds(torrents, title),\n                    userid=userid,\n                    buttons=self._build_default_buttons(len(torrents)) if not buttons else buttons,\n                    fallback_buttons=buttons,\n                    original_message_id=original_message_id,\n                    original_chat_id=original_chat_id\n                ),\n                self._loop\n            )\n            return future.result(timeout=30)\n        except Exception as err:\n            logger.error(f\"发送 Discord 种子列表失败：{err}\")\n            return False\n\n    def delete_msg(self, message_id: Union[str, int], chat_id: Optional[str] = None) -> Optional[bool]:\n        if not self.get_state():\n            return False\n        try:\n            future = asyncio.run_coroutine_threadsafe(\n                self._delete_message(message_id=message_id, chat_id=chat_id),\n                self._loop\n            )\n            return future.result(timeout=15)\n        except Exception as err:\n            logger.error(f\"删除 Discord 消息失败：{err}\")\n            return False\n\n    async def _send_message(self, title: str, text: Optional[str], image: Optional[str],\n                            userid: Optional[str], link: Optional[str],\n                            buttons: Optional[List[List[dict]]],\n                            original_message_id: Optional[Union[int, str]],\n                            original_chat_id: Optional[str],\n                            mtype: Optional['NotificationType'] = None) -> bool:\n        logger.debug(f\"[Discord] _send_message: userid={userid}, original_chat_id={original_chat_id}\")\n        channel = await self._resolve_channel(userid=userid, chat_id=original_chat_id)\n        logger.debug(f\"[Discord] _resolve_channel 返回: {channel}, type={type(channel)}\")\n        if not channel:\n            logger.error(\"未找到可用的 Discord 频道或私聊\")\n            return False\n\n        embed = self._build_embed(title=title, text=text, image=image, link=link, mtype=mtype)\n        view = self._build_view(buttons=buttons, link=link)\n        content = None\n\n        if original_message_id and original_chat_id:\n            logger.debug(f\"[Discord] 编辑现有消息: message_id={original_message_id}\")\n            return await self._edit_message(chat_id=original_chat_id, message_id=original_message_id,\n                                            content=content, embed=embed, view=view)\n\n        logger.debug(f\"[Discord] 发送新消息到频道: {channel}\")\n        try:\n            await channel.send(content=content, embed=embed, view=view)\n            logger.debug(\"[Discord] 消息发送成功\")\n            return True\n        except Exception as e:\n            logger.error(f\"[Discord] 发送消息到频道失败: {e}\")\n            return False\n\n    async def _send_list_message(self, embeds: List[discord.Embed],\n                                 userid: Optional[str],\n                                 buttons: Optional[List[List[dict]]],\n                                 fallback_buttons: Optional[List[List[dict]]],\n                                 original_message_id: Optional[Union[int, str]],\n                                 original_chat_id: Optional[str]) -> bool:\n        channel = await self._resolve_channel(userid=userid, chat_id=original_chat_id)\n        if not channel:\n            logger.error(\"未找到可用的 Discord 频道或私聊\")\n            return False\n\n        view = self._build_view(buttons=buttons if buttons else fallback_buttons)\n        embeds = embeds[:10] if embeds else []  # Discord 单条消息最多 10 个 embed\n\n        if original_message_id and original_chat_id:\n            return await self._edit_message(chat_id=original_chat_id, message_id=original_message_id,\n                                            content=None, embed=None, view=view, embeds=embeds)\n\n        await channel.send(embed=embeds[0] if len(embeds) == 1 else None,\n                           embeds=embeds if len(embeds) > 1 else None,\n                           view=view)\n        return True\n\n    async def _edit_message(self, chat_id: Union[str, int], message_id: Union[str, int],\n                            content: Optional[str], embed: Optional[discord.Embed],\n                            view: Optional[discord.ui.View], embeds: Optional[List[discord.Embed]] = None) -> bool:\n        channel = await self._resolve_channel(chat_id=str(chat_id))\n        if not channel:\n            logger.error(f\"未找到要编辑的 Discord 频道：{chat_id}\")\n            return False\n        try:\n            message = await channel.fetch_message(int(message_id))\n            kwargs: Dict[str, Any] = {\"content\": content, \"view\": view}\n            if embeds:\n                if len(embeds) == 1:\n                    kwargs[\"embed\"] = embeds[0]\n                else:\n                    kwargs[\"embeds\"] = embeds\n            elif embed:\n                kwargs[\"embed\"] = embed\n            await message.edit(**kwargs)\n            return True\n        except Exception as err:\n            logger.error(f\"编辑 Discord 消息失败：{err}\")\n            return False\n\n    async def _delete_message(self, message_id: Union[str, int], chat_id: Optional[str]) -> bool:\n        channel = await self._resolve_channel(chat_id=chat_id)\n        if not channel:\n            logger.error(\"删除 Discord 消息时未找到频道\")\n            return False\n        try:\n            message = await channel.fetch_message(int(message_id))\n            await message.delete()\n            return True\n        except Exception as err:\n            logger.error(f\"删除 Discord 消息失败：{err}\")\n            return False\n\n    @staticmethod\n    def _build_embed(title: str, text: Optional[str], image: Optional[str],\n                     link: Optional[str], mtype: Optional['NotificationType'] = None) -> discord.Embed:\n        fields: List[Dict[str, str]] = []\n        desc_lines: List[str] = []\n        should_parse_fields = mtype in PARSE_FIELD_TYPES if mtype else False\n        def _collect_spans(s: str, left: str, right: str) -> List[Tuple[int, int]]:\n            spans: List[Tuple[int, int]] = []\n            start = 0\n            while True:\n                l_idx = s.find(left, start)\n                if l_idx == -1:\n                    break\n                r_idx = s.find(right, l_idx + 1)\n                if r_idx == -1:\n                    break\n                spans.append((l_idx, r_idx))\n                start = r_idx + 1\n            return spans\n\n        def _find_colon_index(s: str, m: re.Match) -> Optional[int]:\n            segment = s[m.start():m.end()]\n            for i, ch in enumerate(segment):\n                if ch in (\":\", \"：\"):\n                    return m.start() + i\n            return None\n\n        if text:\n            # 处理上游未反序列化的 \"\\n\" 等转义换行，避免被当成普通字符\n            if \"\\\\n\" in text or \"\\\\r\" in text:\n                text = text.replace(\"\\\\r\\\\n\", \"\\n\").replace(\"\\\\n\", \"\\n\").replace(\"\\\\r\", \"\\n\")\n            if not should_parse_fields:\n                desc_lines.append(text.strip())\n            else:\n                # 匹配形如 \"字段：值\" 的片段，字段名不允许包含常见分隔符；\n                # 下一个字段需以顿号/逗号/分号等分隔开，且不能是 URL 协议开头，避免值里出现 URL 的\":\" 被误拆\n                # 字段名允许 emoji 等 Unicode 字符，但排除空白/分隔符/冒号\n                name_re = r\"[^\\s:：，,。；;、]+\"\n                pair_pattern = re.compile(\n                    rf\"({name_re})[：:](.*?)(?=(?:[，,。；;、]+\\s*(?!https?://|ftp://|ftps://|magnet:){name_re}[：:])|$)\",\n                    re.IGNORECASE,\n                )\n                for line in text.splitlines():\n                    line = line.strip()\n                    if not line:\n                        continue\n                    matches = list(pair_pattern.finditer(line))\n                    if matches:\n                        book_spans = _collect_spans(line, \"《\", \"》\") + _collect_spans(line, \"【\", \"】\")\n                        if book_spans:\n                            has_book_colon = False\n                            for m in matches:\n                                colon_idx = _find_colon_index(line, m)\n                                if colon_idx is not None and any(l < colon_idx < r for l, r in book_spans):\n                                    has_book_colon = True\n                                    break\n                            if has_book_colon:\n                                desc_lines.append(line)\n                                continue\n                        # 若整行只是 URL/时间等自然包含\":\"的内容，则不当作字段\n                        url_like_names = {\"http\", \"https\", \"ftp\", \"ftps\", \"magnet\"}\n                        if all(m.group(1).lower() in url_like_names or m.group(1).isdigit() for m in matches):\n                            desc_lines.append(line)\n                            continue\n                        last_end = 0\n                        for m in matches:\n                            # 追加匹配前的非空文本到描述\n                            prefix = line[last_end:m.start()].strip(\" ，,;；。、\")\n                            # 仅当前缀不全是分隔符/空白时才记录\n                            if prefix and prefix.strip(\" ，,;；。、\"):\n                                desc_lines.append(prefix)\n                            name = m.group(1).strip()\n                            value = m.group(2).strip(\" ，,;；。、\\t\") or \"-\"\n                            if name:\n                                fields.append({\"name\": name, \"value\": value, \"inline\": False})\n                            last_end = m.end()\n                        # 匹配末尾后的文本\n                        suffix = line[last_end:].strip(\" ，,;；。、\")\n                        if suffix and suffix.strip(\" ，,;；。、\"):\n                            desc_lines.append(suffix)\n                    else:\n                        desc_lines.append(line)\n        description = \"\\n\".join(desc_lines).strip()\n        if not description and not fields and text:\n            description = text.strip()\n        embed = discord.Embed(\n            title=title,\n            url=link or \"https://github.com/jxxghp/MoviePilot\",\n            description=description if description else None,\n            color=0xE67E22\n        )\n        for field in fields:\n            embed.add_field(name=field[\"name\"], value=field[\"value\"], inline=False)\n        if image:\n            embed.set_image(url=image)\n        return embed\n\n    @staticmethod\n    def _build_media_embeds(medias: List[MediaInfo], title: str) -> List[discord.Embed]:\n        embeds: List[discord.Embed] = []\n        for index, media in enumerate(medias[:10], start=1):\n            overview = media.get_overview_string(80)\n            desc_parts = [\n                f\"{media.type.value} | {media.vote_star}\" if media.vote_star else media.type.value,\n                overview\n            ]\n            embed = discord.Embed(\n                title=f\"{index}. {media.title_year}\",\n                url=media.detail_link or discord.Embed.Empty,\n                description=\"\\n\".join([p for p in desc_parts if p]),\n                color=0x5865F2\n            )\n            if media.get_poster_image():\n                embed.set_thumbnail(url=media.get_poster_image())\n            embeds.append(embed)\n        if embeds:\n            embeds[0].set_author(name=title)\n        return embeds\n\n    @staticmethod\n    def _build_torrent_embeds(torrents: List[Context], title: str) -> List[discord.Embed]:\n        embeds: List[discord.Embed] = []\n        for index, context in enumerate(torrents[:10], start=1):\n            torrent = context.torrent_info\n            meta = MetaInfo(torrent.title, torrent.description)\n            title_text = f\"{meta.season_episode} {meta.resource_term} {meta.video_term} {meta.release_group}\"\n            title_text = re.sub(r\"\\s+\", \" \", title_text).strip()\n            detail = [\n                f\"{torrent.site_name} | {StringUtils.str_filesize(torrent.size)} | {torrent.volume_factor} | {torrent.seeders}↑\",\n                meta.resource_term,\n                meta.video_term\n            ]\n            embed = discord.Embed(\n                title=f\"{index}. {title_text or torrent.title}\",\n                url=torrent.page_url or discord.Embed.Empty,\n                description=\"\\n\".join([d for d in detail if d]),\n                color=0x00A86B\n            )\n            poster = getattr(torrent, \"poster\", None)\n            if poster:\n                embed.set_thumbnail(url=poster)\n            embeds.append(embed)\n        if embeds:\n            embeds[0].set_author(name=title)\n        return embeds\n\n    @staticmethod\n    def _build_default_buttons(count: int) -> List[List[dict]]:\n        buttons: List[List[dict]] = []\n        max_rows = 5\n        max_per_row = 5\n        capped = min(count, max_rows * max_per_row)\n        for idx in range(1, capped + 1):\n            row_idx = (idx - 1) // max_per_row\n            if len(buttons) <= row_idx:\n                buttons.append([])\n            buttons[row_idx].append({\"text\": f\"选择 {idx}\", \"callback_data\": str(idx)})\n        if count > capped:\n            logger.warn(f\"按钮数量超过 Discord 限制，仅展示前 {capped} 个\")\n        return buttons\n\n    @staticmethod\n    def _build_view(buttons: Optional[List[List[dict]]], link: Optional[str] = None) -> Optional[discord.ui.View]:\n        has_buttons = buttons and any(buttons)\n        if not has_buttons and not link:\n            return None\n\n        view = discord.ui.View(timeout=None)\n        if buttons:\n            for row_index, button_row in enumerate(buttons[:5]):\n                for button in button_row[:5]:\n                    if \"url\" in button:\n                        btn = discord.ui.Button(label=button.get(\"text\", \"链接\"),\n                                                url=button[\"url\"],\n                                                style=discord.ButtonStyle.link)\n                    else:\n                        custom_id = (button.get(\"callback_data\") or button.get(\"text\") or f\"btn-{row_index}\")[:99]\n                        btn = discord.ui.Button(label=button.get(\"text\", \"选择\")[:80],\n                                                custom_id=custom_id,\n                                                style=discord.ButtonStyle.primary)\n                    view.add_item(btn)\n        elif link:\n            view.add_item(discord.ui.Button(label=\"查看详情\", url=link, style=discord.ButtonStyle.link))\n        return view\n\n    async def _resolve_channel(self, userid: Optional[str] = None, chat_id: Optional[str] = None):\n        \"\"\"\n        Resolve the channel to send messages to.\n        Priority order:\n        1. `chat_id` (original channel where user sent the message) - for contextual replies\n        2. `userid` mapping (channel where user last sent a message) - for contextual replies\n        3. Configured `_channel_id` (broadcast channel) - for system notifications\n        4. Any available text channel in configured guild - fallback\n        5. `userid` (DM) - for private conversations as a final fallback\n        \"\"\"\n        logger.debug(f\"[Discord] _resolve_channel: userid={userid}, chat_id={chat_id}, \"\n                     f\"_channel_id={self._channel_id}, _guild_id={self._guild_id}\")\n\n        # Priority 1: Use explicit chat_id (reply to the same channel where user sent message)\n        if chat_id:\n            logger.debug(f\"[Discord] 尝试通过 chat_id={chat_id} 获取原始频道\")\n            channel = self._client.get_channel(int(chat_id))\n            if channel:\n                logger.debug(f\"[Discord] 通过 get_channel 找到频道: {channel}\")\n                return channel\n            try:\n                channel = await self._client.fetch_channel(int(chat_id))\n                logger.debug(f\"[Discord] 通过 fetch_channel 找到频道: {channel}\")\n                return channel\n            except Exception as err:\n                logger.warn(f\"通过 chat_id 获取 Discord 频道失败：{err}\")\n\n        # Priority 2: Use user-chat mapping (reply to where the user last sent a message)\n        if userid:\n            mapped_chat_id = self._get_user_chat_id(str(userid))\n            if mapped_chat_id:\n                logger.debug(f\"[Discord] 从用户映射获取 chat_id={mapped_chat_id}\")\n                channel = self._client.get_channel(int(mapped_chat_id))\n                if channel:\n                    logger.debug(f\"[Discord] 通过映射找到频道: {channel}\")\n                    return channel\n                try:\n                    channel = await self._client.fetch_channel(int(mapped_chat_id))\n                    logger.debug(f\"[Discord] 通过 fetch_channel 找到映射频道: {channel}\")\n                    return channel\n                except Exception as err:\n                    logger.warn(f\"通过映射的 chat_id 获取 Discord 频道失败：{err}\")\n\n        # Priority 3: Use configured broadcast channel (for system notifications)\n        if self._broadcast_channel:\n            logger.debug(f\"[Discord] 使用缓存的广播频道: {self._broadcast_channel}\")\n            return self._broadcast_channel\n        if self._channel_id:\n            logger.debug(f\"[Discord] 尝试通过配置的 _channel_id={self._channel_id} 获取频道\")\n            channel = self._client.get_channel(self._channel_id)\n            if not channel:\n                try:\n                    channel = await self._client.fetch_channel(self._channel_id)\n                except Exception as err:\n                    logger.warn(f\"通过配置的频道ID获取 Discord 频道失败：{err}\")\n                    channel = None\n            self._broadcast_channel = channel\n            if channel:\n                logger.debug(f\"[Discord] 通过配置的频道ID找到频道: {channel}\")\n                return channel\n\n        # Priority 4: Find any available text channel in guild (fallback)\n        logger.debug(f\"[Discord] 尝试在 Guild 中寻找可用频道\")\n        target_guilds = []\n        if self._guild_id:\n            guild = self._client.get_guild(self._guild_id)\n            if guild:\n                target_guilds.append(guild)\n        else:\n            target_guilds = list(self._client.guilds)\n        logger.debug(f\"[Discord] 目标 Guilds 数量: {len(target_guilds)}\")\n\n        for guild in target_guilds:\n            for channel in guild.text_channels:\n                if guild.me and channel.permissions_for(guild.me).send_messages:\n                    logger.debug(f\"[Discord] 在 Guild 中找到可用频道: {channel}\")\n                    self._broadcast_channel = channel\n                    return channel\n\n        # Priority 5: Fallback to DM (only if no channel available)\n        if userid:\n            logger.debug(f\"[Discord] 回退到私聊: userid={userid}\")\n            dm = await self._get_dm_channel(str(userid))\n            if dm:\n                logger.debug(f\"[Discord] 获取到私聊频道: {dm}\")\n                return dm\n            else:\n                logger.debug(f\"[Discord] 无法获取用户 {userid} 的私聊频道\")\n\n        return None\n\n    async def _get_dm_channel(self, userid: str) -> Optional[discord.DMChannel]:\n        logger.debug(f\"[Discord] _get_dm_channel: userid={userid}\")\n        if userid in self._user_dm_cache:\n            logger.debug(f\"[Discord] 从缓存获取私聊频道: {self._user_dm_cache.get(userid)}\")\n            return self._user_dm_cache.get(userid)\n        try:\n            logger.debug(f\"[Discord] 尝试获取/创建用户 {userid} 的私聊频道\")\n            user_obj = self._client.get_user(int(userid))\n            logger.debug(f\"[Discord] get_user 结果: {user_obj}\")\n            if not user_obj:\n                user_obj = await self._client.fetch_user(int(userid))\n                logger.debug(f\"[Discord] fetch_user 结果: {user_obj}\")\n            if not user_obj:\n                logger.debug(f\"[Discord] 无法找到用户 {userid}\")\n                return None\n            dm = user_obj.dm_channel\n            logger.debug(f\"[Discord] 用户现有 dm_channel: {dm}\")\n            if not dm:\n                dm = await user_obj.create_dm()\n                logger.debug(f\"[Discord] 创建新的 dm_channel: {dm}\")\n            if dm:\n                self._user_dm_cache[userid] = dm\n            return dm\n        except Exception as err:\n            logger.error(f\"获取 Discord 私聊失败：{err}\")\n            return None\n\n    def _update_user_chat_mapping(self, userid: str, chat_id: str) -> None:\n        \"\"\"\n        Update user-chat mapping for reply targeting.\n        This ensures replies go to the same channel where the user sent the message.\n        :param userid: User ID\n        :param chat_id: Channel/Chat ID where the user sent the message\n        \"\"\"\n        if userid and chat_id:\n            self._user_chat_mapping[userid] = chat_id\n            logger.debug(f\"[Discord] 更新用户频道映射: userid={userid} -> chat_id={chat_id}\")\n\n    def _get_user_chat_id(self, userid: str) -> Optional[str]:\n        \"\"\"\n        Get the chat ID where the user last sent a message.\n        :param userid: User ID\n        :return: Chat ID or None if not found\n        \"\"\"\n        return self._user_chat_mapping.get(userid)\n\n    def _should_process_message(self, message: discord.Message) -> bool:\n        if isinstance(message.channel, discord.DMChannel):\n            return True\n        content = message.content or \"\"\n        # 仅处理 @Bot 或斜杠命令\n        if self._client.user and self._client.user.mentioned_in(message):\n            return True\n        if content.startswith(\"/\"):\n            return True\n        return False\n\n    def _clean_bot_mention(self, content: str) -> str:\n        if not content:\n            return \"\"\n        if self._bot_user_id:\n            mention_pattern = rf\"<@!?{self._bot_user_id}>\"\n            content = re.sub(mention_pattern, \"\", content).strip()\n        return content\n\n    async def _post_to_ds(self, payload: Dict[str, Any]) -> None:\n        try:\n            proxy = None\n            if settings.PROXY:\n                proxy = settings.PROXY.get(\"https\") or settings.PROXY.get(\"http\")\n            async with httpx.AsyncClient(timeout=10, verify=False, proxy=proxy) as client:\n                await client.post(self._ds_url, json=payload)\n        except Exception as err:\n            logger.error(f\"转发 Discord 消息失败：{err}\")\n"
  },
  {
    "path": "app/modules/douban/__init__.py",
    "content": "import re\nfrom typing import List, Optional, Tuple, Union\n\nimport cn2an\nimport zhconv\n\nfrom app import schemas\nfrom app.core.config import settings\nfrom app.core.context import MediaInfo\nfrom app.core.meta import MetaBase\nfrom app.core.metainfo import MetaInfo\nfrom app.log import logger\nfrom app.modules import _ModuleBase\nfrom app.modules.douban.apiv2 import DoubanApi\nfrom app.modules.douban.douban_cache import DoubanCache\nfrom app.modules.douban.scraper import DoubanScraper\nfrom app.schemas import MediaPerson, APIRateLimitException\nfrom app.schemas.types import MediaType, ModuleType, MediaRecognizeType\nfrom app.utils.common import retry\nfrom app.utils.http import RequestUtils\nfrom app.utils.limit import rate_limit_exponential\n\n\nclass DoubanModule(_ModuleBase):\n    doubanapi: DoubanApi = None\n    scraper: DoubanScraper = None\n    cache: DoubanCache = None\n\n    def init_module(self) -> None:\n        self.doubanapi = DoubanApi()\n        self.scraper = DoubanScraper()\n        self.cache = DoubanCache()\n\n    def stop(self):\n        self.doubanapi.close()\n\n    def test(self) -> Tuple[bool, str]:\n        \"\"\"\n        测试模块连接性\n        \"\"\"\n        ret = RequestUtils().get_res(\"https://movie.douban.com/\")\n        if ret is None:\n            return False, \"豆瓣网络连接失败\"\n        return True, \"\"\n\n    def init_setting(self) -> Tuple[str, Union[str, bool]]:\n        pass\n\n    @staticmethod\n    def get_name() -> str:\n        return \"豆瓣\"\n\n    @staticmethod\n    def get_type() -> ModuleType:\n        \"\"\"\n        获取模块类型\n        \"\"\"\n        return ModuleType.MediaRecognize\n\n    @staticmethod\n    def get_subtype() -> MediaRecognizeType:\n        \"\"\"\n        获取模块子类型\n        \"\"\"\n        return MediaRecognizeType.Douban\n\n    @staticmethod\n    def get_priority() -> int:\n        \"\"\"\n        获取模块优先级，数字越小优先级越高，只有同一接口下优先级才生效\n        \"\"\"\n        return 2\n\n    def _recognize_media_core(self, meta: MetaBase = None,\n                              mtype: MediaType = None,\n                              doubanid: Optional[str] = None,\n                              cache: Optional[bool] = True,\n                              douban_info_func=None,\n                              match_doubaninfo_func=None,\n                              **kwargs) -> Optional[MediaInfo]:\n        \"\"\"\n        识别媒体信息的核心逻辑\n        :param meta:     识别的元数据\n        :param mtype:    识别的媒体类型，与doubanid配套\n        :param doubanid: 豆瓣ID\n        :param cache:    是否使用缓存\n        :param douban_info_func: 获取豆瓣信息的函数\n        :param match_doubaninfo_func: 匹配豆瓣信息的函数\n        :return: 识别的媒体信息，包括剧集信息\n        \"\"\"\n        if not doubanid and not meta:\n            return None\n\n        if meta and not doubanid \\\n                and settings.RECOGNIZE_SOURCE != \"douban\":\n            return None\n\n        if not meta:\n            # 未提供元数据时，直接查询豆瓣信息，不使用缓存\n            cache_info = {}\n        elif not meta.name:\n            logger.error(\"识别媒体信息时未提供元数据名称\")\n            return None\n        else:\n            # 读取缓存\n            if mtype:\n                meta.type = mtype\n            if doubanid:\n                meta.doubanid = doubanid\n            cache_info = self.cache.get(meta)\n\n        # 识别豆瓣信息\n        if not cache_info or not cache:\n            # 缓存没有或者强制不使用缓存\n            if doubanid:\n                # 直接查询详情\n                info = douban_info_func(doubanid=doubanid, mtype=mtype or meta.type)\n            elif meta:\n                info = {}\n                # 简体名称\n                zh_name = zhconv.convert(meta.cn_name, \"zh-hans\") if meta.cn_name else None\n                # 使用中英文名分别识别，去重去空，但要保持顺序\n                names = list(dict.fromkeys([k for k in [meta.cn_name, zh_name, meta.en_name] if k]))\n                for name in names:\n                    if meta.begin_season:\n                        logger.info(f\"正在识别 {name} 第{meta.begin_season}季 ...\")\n                    else:\n                        logger.info(f\"正在识别 {name} ...\")\n                    # 匹配豆瓣信息\n                    match_info = match_doubaninfo_func(name=name,\n                                                       mtype=mtype or meta.type,\n                                                       year=meta.year,\n                                                       season=meta.begin_season)\n                    if match_info:\n                        # 匹配到豆瓣信息\n                        info = douban_info_func(\n                            doubanid=match_info.get(\"id\"),\n                            mtype=mtype or meta.type\n                        )\n                        if info:\n                            break\n            else:\n                logger.error(\"识别媒体信息时未提供元数据或豆瓣ID\")\n                return None\n\n            # 保存到缓存\n            if meta and cache:\n                self.cache.update(meta, info)\n        else:\n            # 使用缓存信息\n            if cache_info.get(\"title\"):\n                logger.info(f\"{meta.name} 使用豆瓣识别缓存：{cache_info.get('title')}\")\n                info = douban_info_func(mtype=cache_info.get(\"type\"),\n                                        doubanid=cache_info.get(\"id\"))\n            else:\n                logger.info(f\"{meta.name} 使用豆瓣识别缓存：无法识别\")\n                info = None\n\n        if info:\n            # 赋值TMDB信息并返回\n            mediainfo = MediaInfo(douban_info=info)\n            if meta:\n                logger.info(f\"{meta.name} 豆瓣识别结果：{mediainfo.type.value} \"\n                            f\"{mediainfo.title_year} \"\n                            f\"{mediainfo.douban_id}\")\n            else:\n                logger.info(f\"{doubanid} 豆瓣识别结果：{mediainfo.type.value} \"\n                            f\"{mediainfo.title_year}\")\n            return mediainfo\n        else:\n            logger.info(f\"{meta.name if meta else doubanid} 未匹配到豆瓣媒体信息\")\n\n        return None\n\n    async def _async_recognize_media_core(self, meta: MetaBase = None,\n                                          mtype: MediaType = None,\n                                          doubanid: Optional[str] = None,\n                                          cache: Optional[bool] = True,\n                                          async_douban_info_func=None,\n                                          async_match_doubaninfo_func=None,\n                                          **kwargs) -> Optional[MediaInfo]:\n        \"\"\"\n        识别媒体信息的核心逻辑（异步版本）\n        :param meta:     识别的元数据\n        :param mtype:    识别的媒体类型，与doubanid配套\n        :param doubanid: 豆瓣ID\n        :param cache:    是否使用缓存\n        :param async_douban_info_func: 获取豆瓣信息的异步函数\n        :param async_match_doubaninfo_func: 匹配豆瓣信息的异步函数\n        :return: 识别的媒体信息，包括剧集信息\n        \"\"\"\n        if not doubanid and not meta:\n            return None\n\n        if meta and not doubanid \\\n                and settings.RECOGNIZE_SOURCE != \"douban\":\n            return None\n\n        if not meta:\n            # 未提供元数据时，直接查询豆瓣信息，不使用缓存\n            cache_info = {}\n        elif not meta.name:\n            logger.error(\"识别媒体信息时未提供元数据名称\")\n            return None\n        else:\n            # 读取缓存\n            if mtype:\n                meta.type = mtype\n            if doubanid:\n                meta.doubanid = doubanid\n            cache_info = self.cache.get(meta)\n\n        # 识别豆瓣信息\n        if not cache_info or not cache:\n            # 缓存没有或者强制不使用缓存\n            if doubanid:\n                # 直接查询详情\n                info = await async_douban_info_func(doubanid=doubanid, mtype=mtype or meta.type)\n            elif meta:\n                info = {}\n                # 简体名称\n                zh_name = zhconv.convert(meta.cn_name, \"zh-hans\") if meta.cn_name else None\n                # 使用中英文名分别识别，去重去空，但要保持顺序\n                names = list(dict.fromkeys([k for k in [meta.cn_name, zh_name, meta.en_name] if k]))\n                for name in names:\n                    if meta.begin_season:\n                        logger.info(f\"正在识别 {name} 第{meta.begin_season}季 ...\")\n                    else:\n                        logger.info(f\"正在识别 {name} ...\")\n                    # 匹配豆瓣信息\n                    match_info = await async_match_doubaninfo_func(name=name,\n                                                                   mtype=mtype or meta.type,\n                                                                   year=meta.year,\n                                                                   season=meta.begin_season)\n                    if match_info:\n                        # 匹配到豆瓣信息\n                        info = await async_douban_info_func(\n                            doubanid=match_info.get(\"id\"),\n                            mtype=mtype or meta.type\n                        )\n                        if info:\n                            break\n            else:\n                logger.error(\"识别媒体信息时未提供元数据或豆瓣ID\")\n                return None\n\n            # 保存到缓存\n            if meta and cache:\n                self.cache.update(meta, info)\n        else:\n            # 使用缓存信息\n            if cache_info.get(\"title\"):\n                logger.info(f\"{meta.name} 使用豆瓣识别缓存：{cache_info.get('title')}\")\n                info = await async_douban_info_func(mtype=cache_info.get(\"type\"),\n                                                    doubanid=cache_info.get(\"id\"))\n            else:\n                logger.info(f\"{meta.name} 使用豆瓣识别缓存：无法识别\")\n                info = None\n\n        if info:\n            # 赋值TMDB信息并返回\n            mediainfo = MediaInfo(douban_info=info)\n            if meta:\n                logger.info(f\"{meta.name} 豆瓣识别结果：{mediainfo.type.value} \"\n                            f\"{mediainfo.title_year} \"\n                            f\"{mediainfo.douban_id}\")\n            else:\n                logger.info(f\"{doubanid} 豆瓣识别结果：{mediainfo.type.value} \"\n                            f\"{mediainfo.title_year}\")\n            return mediainfo\n        else:\n            logger.info(f\"{meta.name if meta else doubanid} 未匹配到豆瓣媒体信息\")\n\n        return None\n\n    def recognize_media(self, meta: MetaBase = None,\n                        mtype: MediaType = None,\n                        doubanid: Optional[str] = None,\n                        cache: Optional[bool] = True,\n                        **kwargs) -> Optional[MediaInfo]:\n        \"\"\"\n        识别媒体信息\n        :param meta:     识别的元数据\n        :param mtype:    识别的媒体类型，与doubanid配套\n        :param doubanid: 豆瓣ID\n        :param cache:    是否使用缓存\n        :return: 识别的媒体信息，包括剧集信息\n        \"\"\"\n        return self._recognize_media_core(\n            meta=meta,\n            mtype=mtype,\n            doubanid=doubanid,\n            cache=cache,\n            douban_info_func=self.douban_info,\n            match_doubaninfo_func=self.match_doubaninfo,\n            **kwargs\n        )\n\n    async def async_recognize_media(self, meta: MetaBase = None,\n                                    mtype: MediaType = None,\n                                    doubanid: Optional[str] = None,\n                                    cache: Optional[bool] = True,\n                                    **kwargs) -> Optional[MediaInfo]:\n        \"\"\"\n        识别媒体信息（异步版本）\n        :param meta:     识别的元数据\n        :param mtype:    识别的媒体类型，与doubanid配套\n        :param doubanid: 豆瓣ID\n        :param cache:    是否使用缓存\n        :return: 识别的媒体信息，包括剧集信息\n        \"\"\"\n        return await self._async_recognize_media_core(\n            meta=meta,\n            mtype=mtype,\n            doubanid=doubanid,\n            cache=cache,\n            async_douban_info_func=self.async_douban_info,\n            async_match_doubaninfo_func=self.async_match_doubaninfo,\n            **kwargs\n        )\n\n    @rate_limit_exponential(source=\"douban_info\")\n    def douban_info(self, doubanid: str, mtype: MediaType = None, raise_exception: bool = True) -> Optional[dict]:\n        \"\"\"\n        获取豆瓣信息\n        :param doubanid: 豆瓣ID\n        :param mtype:    媒体类型\n        :param raise_exception: 触发速率限制时是否抛出异常\n        :return: 豆瓣信息\n        \"\"\"\n        \"\"\"\n        {\n          \"rating\": {\n            \"count\": 287365,\n            \"max\": 10,\n            \"star_count\": 3.5,\n            \"value\": 6.6\n          },\n          \"lineticket_url\": \"\",\n          \"controversy_reason\": \"\",\n          \"pubdate\": [\n            \"2021-10-29(中国大陆)\"\n          ],\n          \"last_episode_number\": null,\n          \"interest_control_info\": null,\n          \"pic\": {\n            \"large\": \"https://img9.doubanio.com/view/photo/m_ratio_poster/public/p2707553644.webp\",\n            \"normal\": \"https://img9.doubanio.com/view/photo/s_ratio_poster/public/p2707553644.webp\"\n          },\n          \"vendor_count\": 6,\n          \"body_bg_color\": \"f4f5f9\",\n          \"is_tv\": false,\n          \"head_info\": null,\n          \"album_no_interact\": false,\n          \"ticket_price_info\": \"\",\n          \"webisode_count\": 0,\n          \"year\": \"2021\",\n          \"card_subtitle\": \"2021 / 英国 美国 / 动作 惊悚 冒险 / 凯瑞·福永 / 丹尼尔·克雷格 蕾雅·赛杜\",\n          \"forum_info\": null,\n          \"webisode\": null,\n          \"id\": \"20276229\",\n          \"gallery_topic_count\": 0,\n          \"languages\": [\n            \"英语\",\n            \"法语\",\n            \"意大利语\",\n            \"俄语\",\n            \"西班牙语\"\n          ],\n          \"genres\": [\n            \"动作\",\n            \"惊悚\",\n            \"冒险\"\n          ],\n          \"review_count\": 926,\n          \"title\": \"007：无暇赴死\",\n          \"intro\": \"世界局势波诡云谲，再度出山的邦德（丹尼尔·克雷格 饰）面临有史以来空前的危机，传奇特工007的故事在本片中达到高潮。新老角色集结亮相，蕾雅·赛杜回归，二度饰演邦女郎玛德琳。系列最恐怖反派萨芬（拉米·马雷克 饰）重磅登场，毫不留情地展示了自己狠辣的一面，不仅揭开了玛德琳身上隐藏的秘密，还酝酿着危及数百万人性命的阴谋，幽灵党的身影也似乎再次浮出水面。半路杀出的新00号特工（拉什纳·林奇 饰）与神秘女子（安娜·德·阿玛斯 饰）看似与邦德同阵作战，但其真实目的依然成谜。关乎邦德生死的新仇旧怨接踵而至，暗潮汹涌之下他能否拯救世界？\",\n          \"interest_cmt_earlier_tip_title\": \"发布于上映前\",\n          \"has_linewatch\": true,\n          \"ugc_tabs\": [\n            {\n              \"source\": \"reviews\",\n              \"type\": \"review\",\n              \"title\": \"影评\"\n            },\n            {\n              \"source\": \"forum_topics\",\n              \"type\": \"forum\",\n              \"title\": \"讨论\"\n            }\n          ],\n          \"forum_topic_count\": 857,\n          \"ticket_promo_text\": \"\",\n          \"webview_info\": {},\n          \"is_released\": true,\n          \"actors\": [\n            {\n              \"name\": \"丹尼尔·克雷格\",\n              \"roles\": [\n                \"演员\",\n                \"制片人\",\n                \"配音\"\n              ],\n              \"title\": \"丹尼尔·克雷格（同名）英国,英格兰,柴郡,切斯特影视演员\",\n              \"url\": \"https://movie.douban.com/celebrity/1025175/\",\n              \"user\": null,\n              \"character\": \"饰 詹姆斯·邦德 James Bond 007\",\n              \"uri\": \"douban://douban.com/celebrity/1025175?subject_id=27230907\",\n              \"avatar\": {\n                \"large\": \"https://qnmob3.doubanio.com/view/celebrity/raw/public/p42588.jpg?imageView2/2/q/80/w/600/h/3000/format/webp\",\n                \"normal\": \"https://qnmob3.doubanio.com/view/celebrity/raw/public/p42588.jpg?imageView2/2/q/80/w/200/h/300/format/webp\"\n              },\n              \"sharing_url\": \"https://www.douban.com/doubanapp/dispatch?uri=/celebrity/1025175/\",\n              \"type\": \"celebrity\",\n              \"id\": \"1025175\",\n              \"latin_name\": \"Daniel Craig\"\n            }\n          ],\n          \"interest\": null,\n          \"vendor_icons\": [\n            \"https://img9.doubanio.com/f/frodo/fbc90f355fc45d5d2056e0d88c697f9414b56b44/pics/vendors/tencent.png\",\n            \"https://img2.doubanio.com/f/frodo/8286b9b5240f35c7e59e1b1768cd2ccf0467cde5/pics/vendors/migu_video.png\",\n            \"https://img9.doubanio.com/f/frodo/88a62f5e0cf9981c910e60f4421c3e66aac2c9bc/pics/vendors/bilibili.png\"\n          ],\n          \"episodes_count\": 0,\n          \"color_scheme\": {\n            \"is_dark\": true,\n            \"primary_color_light\": \"868ca5\",\n            \"_base_color\": [\n              0.6333333333333333,\n              0.18867924528301885,\n              0.20784313725490197\n            ],\n            \"secondary_color\": \"f4f5f9\",\n            \"_avg_color\": [\n              0.059523809523809625,\n              0.09790209790209795,\n              0.5607843137254902\n            ],\n            \"primary_color_dark\": \"676c7f\"\n          },\n          \"type\": \"movie\",\n          \"null_rating_reason\": \"\",\n          \"linewatches\": [\n            {\n              \"url\": \"http://v.youku.com/v_show/id_XNTIwMzM2NDg5Mg==.html?tpa=dW5pb25faWQ9MzAwMDA4XzEwMDAwMl8wMl8wMQ&refer=esfhz_operation.xuka.xj_00003036_000000_FNZfau_19010900\",\n              \"source\": {\n                \"literal\": \"youku\",\n                \"pic\": \"https://img1.doubanio.com/img/files/file-1432869267.png\",\n                \"name\": \"优酷视频\"\n              },\n              \"source_uri\": \"youku://play?vid=XNTIwMzM2NDg5Mg==&source=douban&refer=esfhz_operation.xuka.xj_00003036_000000_FNZfau_19010900\",\n              \"free\": false\n            },\n          ],\n          \"info_url\": \"https://www.douban.com/doubanapp//h5/movie/20276229/desc\",\n          \"tags\": [],\n          \"durations\": [\n            \"163分钟\"\n          ],\n          \"comment_count\": 97204,\n          \"cover\": {\n            \"description\": \"\",\n            \"author\": {\n              \"loc\": {\n                \"id\": \"108288\",\n                \"name\": \"北京\",\n                \"uid\": \"beijing\"\n              },\n              \"kind\": \"user\",\n              \"name\": \"雨落下\",\n              \"reg_time\": \"2020-08-11 16:22:48\",\n              \"url\": \"https://www.douban.com/people/221011676/\",\n              \"uri\": \"douban://douban.com/user/221011676\",\n              \"id\": \"221011676\",\n              \"avatar_side_icon_type\": 3,\n              \"avatar_side_icon_id\": \"234\",\n              \"avatar\": \"https://img2.doubanio.com/icon/up221011676-2.jpg\",\n              \"is_club\": false,\n              \"type\": \"user\",\n              \"avatar_side_icon\": \"https://img2.doubanio.com/view/files/raw/file-1683625971.png\",\n              \"uid\": \"221011676\"\n            },\n            \"url\": \"https://movie.douban.com/photos/photo/2707553644/\",\n            \"image\": {\n              \"large\": {\n                \"url\": \"https://img9.doubanio.com/view/photo/l/public/p2707553644.webp\",\n                \"width\": 1082,\n                \"height\": 1600,\n                \"size\": 0\n              },\n              \"raw\": null,\n              \"small\": {\n                \"url\": \"https://img9.doubanio.com/view/photo/s/public/p2707553644.webp\",\n                \"width\": 405,\n                \"height\": 600,\n                \"size\": 0\n              },\n              \"normal\": {\n                \"url\": \"https://img9.doubanio.com/view/photo/m/public/p2707553644.webp\",\n                \"width\": 405,\n                \"height\": 600,\n                \"size\": 0\n              },\n              \"is_animated\": false\n            },\n            \"uri\": \"douban://douban.com/photo/2707553644\",\n            \"create_time\": \"2021-10-26 15:05:01\",\n            \"position\": 0,\n            \"owner_uri\": \"douban://douban.com/movie/20276229\",\n            \"type\": \"photo\",\n            \"id\": \"2707553644\",\n            \"sharing_url\": \"https://www.douban.com/doubanapp/dispatch?uri=/photo/2707553644/\"\n          },\n          \"cover_url\": \"https://img9.doubanio.com/view/photo/m_ratio_poster/public/p2707553644.webp\",\n          \"restrictive_icon_url\": \"\",\n          \"header_bg_color\": \"676c7f\",\n          \"is_douban_intro\": false,\n          \"ticket_vendor_icons\": [\n            \"https://img9.doubanio.com/view/dale-online/dale_ad/public/0589a62f2f2d7c2.jpg\"\n          ],\n          \"honor_infos\": [],\n          \"sharing_url\": \"https://movie.douban.com/subject/20276229/\",\n          \"subject_collections\": [],\n          \"wechat_timeline_share\": \"screenshot\",\n          \"countries\": [\n            \"英国\",\n            \"美国\"\n          ],\n          \"url\": \"https://movie.douban.com/subject/20276229/\",\n          \"release_date\": null,\n          \"original_title\": \"No Time to Die\",\n          \"uri\": \"douban://douban.com/movie/20276229\",\n          \"pre_playable_date\": null,\n          \"episodes_info\": \"\",\n          \"subtype\": \"movie\",\n          \"directors\": [\n            {\n              \"name\": \"凯瑞·福永\",\n              \"roles\": [\n                \"导演\",\n                \"制片人\",\n                \"编剧\",\n                \"摄影\",\n                \"演员\"\n              ],\n              \"title\": \"凯瑞·福永（同名）美国,加利福尼亚州,奥克兰影视演员\",\n              \"url\": \"https://movie.douban.com/celebrity/1009531/\",\n              \"user\": null,\n              \"character\": \"导演\",\n              \"uri\": \"douban://douban.com/celebrity/1009531?subject_id=27215222\",\n              \"avatar\": {\n                \"large\": \"https://qnmob3.doubanio.com/view/celebrity/raw/public/p1392285899.57.jpg?imageView2/2/q/80/w/600/h/3000/format/webp\",\n                \"normal\": \"https://qnmob3.doubanio.com/view/celebrity/raw/public/p1392285899.57.jpg?imageView2/2/q/80/w/200/h/300/format/webp\"\n              },\n              \"sharing_url\": \"https://www.douban.com/doubanapp/dispatch?uri=/celebrity/1009531/\",\n              \"type\": \"celebrity\",\n              \"id\": \"1009531\",\n              \"latin_name\": \"Cary Fukunaga\"\n            }\n          ],\n          \"is_show\": false,\n          \"in_blacklist\": false,\n          \"pre_release_desc\": \"\",\n          \"video\": null,\n          \"aka\": [\n            \"007：生死有时(港)\",\n            \"007：生死交战(台)\",\n            \"007：间不容死\",\n            \"邦德25\",\n            \"007：没空去死(豆友译名)\",\n            \"James Bond 25\",\n            \"Never Dream of Dying\",\n            \"Shatterhand\"\n          ],\n          \"is_restrictive\": false,\n          \"trailer\": {\n            \"sharing_url\": \"https://www.douban.com/doubanapp/dispatch?uri=/movie/20276229/trailer%3Ftrailer_id%3D282585%26trailer_type%3DA\",\n            \"video_url\": \"https://vt1.doubanio.com/202310011325/3b1f5827e91dde7826dc20930380dfc2/view/movie/M/402820585.mp4\",\n            \"title\": \"中国预告片：终极决战版 (中文字幕)\",\n            \"uri\": \"douban://douban.com/movie/20276229/trailer?trailer_id=282585&trailer_type=A\",\n            \"cover_url\": \"https://img1.doubanio.com/img/trailer/medium/2712944408.jpg\",\n            \"term_num\": 0,\n            \"n_comments\": 21,\n            \"create_time\": \"2021-11-01\",\n            \"subject_title\": \"007：无暇赴死\",\n            \"file_size\": 10520074,\n            \"runtime\": \"00:42\",\n            \"type\": \"A\",\n            \"id\": \"282585\",\n            \"desc\": \"\"\n          },\n          \"interest_cmt_earlier_tip_desc\": \"该短评的发布时间早于公开上映时间，作者可能通过其他渠道提前观看，请谨慎参考。其评分将不计入总评分。\"\n        }\n        \"\"\"\n\n        def __douban_tv():\n            \"\"\"\n            获取豆瓣剧集信息\n            \"\"\"\n            info = self.doubanapi.tv_detail(doubanid)\n            if info:\n                if \"subject_ip_rate_limit\" in info.get(\"msg\", \"\"):\n                    msg = f\"触发豆瓣IP速率限制，错误信息：{info} ...\"\n                    logger.warn(msg)\n                    raise APIRateLimitException(msg)\n                celebrities = self.doubanapi.tv_celebrities(doubanid)\n                if celebrities:\n                    info[\"directors\"] = celebrities.get(\"directors\")\n                    info[\"actors\"] = celebrities.get(\"actors\")\n            return info\n\n        def __douban_movie():\n            \"\"\"\n            获取豆瓣电影信息\n            \"\"\"\n            info = self.doubanapi.movie_detail(doubanid)\n            if info:\n                if \"subject_ip_rate_limit\" in info.get(\"msg\", \"\"):\n                    msg = f\"触发豆瓣IP速率限制，错误信息：{info} ...\"\n                    logger.warn(msg)\n                    raise APIRateLimitException(msg)\n                celebrities = self.doubanapi.movie_celebrities(doubanid)\n                if celebrities:\n                    info[\"directors\"] = celebrities.get(\"directors\")\n                    info[\"actors\"] = celebrities.get(\"actors\")\n            return info\n\n        if not doubanid:\n            return None\n        logger.info(f\"开始获取豆瓣信息：{doubanid} ...\")\n        if mtype == MediaType.TV:\n            return __douban_tv()\n        elif mtype == MediaType.MOVIE:\n            return __douban_movie()\n        else:\n            return __douban_movie() or __douban_tv()\n\n    @rate_limit_exponential(source=\"douban_info\")\n    async def async_douban_info(self, doubanid: str, mtype: MediaType = None,\n                                raise_exception: bool = True) -> Optional[dict]:\n        \"\"\"\n        获取豆瓣信息（异步版本）\n        :param doubanid: 豆瓣ID\n        :param mtype:    媒体类型\n        :param raise_exception: 触发速率限制时是否抛出异常\n        :return: 豆瓣信息\n        \"\"\"\n\n        async def __async_douban_tv():\n            \"\"\"\n            获取豆瓣剧集信息（异步版本）\n            \"\"\"\n            info = await self.doubanapi.async_tv_detail(doubanid)\n            if info:\n                if \"subject_ip_rate_limit\" in info.get(\"msg\", \"\"):\n                    msg = f\"触发豆瓣IP速率限制，错误信息：{info} ...\"\n                    logger.warn(msg)\n                    raise APIRateLimitException(msg)\n                celebrities = await self.doubanapi.async_tv_celebrities(doubanid)\n                if celebrities:\n                    info[\"directors\"] = celebrities.get(\"directors\")\n                    info[\"actors\"] = celebrities.get(\"actors\")\n            return info\n\n        async def __async_douban_movie():\n            \"\"\"\n            获取豆瓣电影信息（异步版本）\n            \"\"\"\n            info = await self.doubanapi.async_movie_detail(doubanid)\n            if info:\n                if \"subject_ip_rate_limit\" in info.get(\"msg\", \"\"):\n                    msg = f\"触发豆瓣IP速率限制，错误信息：{info} ...\"\n                    logger.warn(msg)\n                    raise APIRateLimitException(msg)\n                celebrities = await self.doubanapi.async_movie_celebrities(doubanid)\n                if celebrities:\n                    info[\"directors\"] = celebrities.get(\"directors\")\n                    info[\"actors\"] = celebrities.get(\"actors\")\n            return info\n\n        if not doubanid:\n            return None\n        logger.info(f\"开始获取豆瓣信息：{doubanid} ...\")\n        if mtype == MediaType.TV:\n            return await __async_douban_tv()\n        elif mtype == MediaType.MOVIE:\n            return await __async_douban_movie()\n        else:\n            movie_result = await __async_douban_movie()\n            if movie_result:\n                return movie_result\n            return await __async_douban_tv()\n\n    def douban_discover(self, mtype: MediaType, sort: str, tags: str,\n                        page: int = 1, count: int = 30) -> Optional[List[MediaInfo]]:\n        \"\"\"\n        发现豆瓣电影、剧集\n        :param mtype:  媒体类型\n        :param sort:  排序方式\n        :param tags:  标签\n        :param page:  页码\n        :param count:  数量\n        :return: 媒体信息列表\n        \"\"\"\n        logger.info(f\"开始发现豆瓣 {mtype.value} ...\")\n        if mtype == MediaType.MOVIE:\n            infos = self.doubanapi.movie_recommend(start=(page - 1) * count, count=count,\n                                                   sort=sort, tags=tags)\n        else:\n            infos = self.doubanapi.tv_recommend(start=(page - 1) * count, count=count,\n                                                sort=sort, tags=tags)\n        if infos and infos.get(\"items\"):\n            medias = [MediaInfo(douban_info=info) for info in infos.get(\"items\")]\n            return [media for media in medias if media.poster_path\n                    and \"movie_large.jpg\" not in media.poster_path\n                    and \"tv_normal.png\" not in media.poster_path\n                    and \"movie_large.jpg\" not in media.poster_path\n                    and \"tv_normal.jpg\" not in media.poster_path\n                    and \"tv_large.jpg\" not in media.poster_path]\n        return []\n\n    async def async_douban_discover(self, mtype: MediaType, sort: str, tags: str,\n                                    page: int = 1, count: int = 30) -> Optional[List[MediaInfo]]:\n        \"\"\"\n        发现豆瓣电影、剧集（异步版本）\n        :param mtype:  媒体类型\n        :param sort:  排序方式\n        :param tags:  标签\n        :param page:  页码\n        :param count:  数量\n        :return: 媒体信息列表\n        \"\"\"\n        logger.info(f\"开始发现豆瓣 {mtype.value} ...\")\n        if mtype == MediaType.MOVIE:\n            infos = await self.doubanapi.async_movie_recommend(start=(page - 1) * count, count=count,\n                                                               sort=sort, tags=tags)\n        else:\n            infos = await self.doubanapi.async_tv_recommend(start=(page - 1) * count, count=count,\n                                                            sort=sort, tags=tags)\n        if infos and infos.get(\"items\"):\n            medias = [MediaInfo(douban_info=info) for info in infos.get(\"items\")]\n            return [media for media in medias if media.poster_path\n                    and \"movie_large.jpg\" not in media.poster_path\n                    and \"tv_normal.png\" not in media.poster_path\n                    and \"movie_large.jpg\" not in media.poster_path\n                    and \"tv_normal.jpg\" not in media.poster_path\n                    and \"tv_large.jpg\" not in media.poster_path]\n        return []\n\n    def movie_showing(self, page: int = 1, count: int = 30) -> List[MediaInfo]:\n        \"\"\"\n        获取正在上映的电影\n        \"\"\"\n        infos = self.doubanapi.movie_showing(start=(page - 1) * count,\n                                             count=count)\n        if infos and infos.get(\"subject_collection_items\"):\n            return [MediaInfo(douban_info=info) for info in infos.get(\"subject_collection_items\")]\n        return []\n\n    async def async_movie_showing(self, page: int = 1, count: int = 30) -> List[MediaInfo]:\n        \"\"\"\n        获取正在上映的电影（异步版本）\n        \"\"\"\n        infos = await self.doubanapi.async_movie_showing(start=(page - 1) * count,\n                                                         count=count)\n        if infos and infos.get(\"subject_collection_items\"):\n            return [MediaInfo(douban_info=info) for info in infos.get(\"subject_collection_items\")]\n        return []\n\n    def tv_weekly_chinese(self, page: int = 1, count: int = 30) -> List[MediaInfo]:\n        \"\"\"\n        获取豆瓣本周口碑国产剧\n        \"\"\"\n        infos = self.doubanapi.tv_chinese_best_weekly(start=(page - 1) * count,\n                                                      count=count)\n        if infos:\n            return [MediaInfo(douban_info=info) for info in infos.get(\"subject_collection_items\")]\n        return []\n\n    async def async_tv_weekly_chinese(self, page: int = 1, count: int = 30) -> List[MediaInfo]:\n        \"\"\"\n        获取豆瓣本周口碑国产剧（异步版本）\n        \"\"\"\n        infos = await self.doubanapi.async_tv_chinese_best_weekly(start=(page - 1) * count,\n                                                                  count=count)\n        if infos:\n            return [MediaInfo(douban_info=info) for info in infos.get(\"subject_collection_items\")]\n        return []\n\n    def tv_weekly_global(self, page: int = 1, count: int = 30) -> List[MediaInfo]:\n        \"\"\"\n        获取豆瓣本周口碑外国剧\n        \"\"\"\n        infos = self.doubanapi.tv_global_best_weekly(start=(page - 1) * count,\n                                                     count=count)\n        if infos and infos.get(\"subject_collection_items\"):\n            return [MediaInfo(douban_info=info) for info in infos.get(\"subject_collection_items\")]\n        return []\n\n    async def async_tv_weekly_global(self, page: int = 1, count: int = 30) -> List[MediaInfo]:\n        \"\"\"\n        获取豆瓣本周口碑外国剧（异步版本）\n        \"\"\"\n        infos = await self.doubanapi.async_tv_global_best_weekly(start=(page - 1) * count,\n                                                                 count=count)\n        if infos and infos.get(\"subject_collection_items\"):\n            return [MediaInfo(douban_info=info) for info in infos.get(\"subject_collection_items\")]\n        return []\n\n    def tv_animation(self, page: int = 1, count: int = 30) -> List[MediaInfo]:\n        \"\"\"\n        获取豆瓣动画剧\n        \"\"\"\n        infos = self.doubanapi.tv_animation(start=(page - 1) * count,\n                                            count=count)\n        if infos and infos.get(\"subject_collection_items\"):\n            return [MediaInfo(douban_info=info) for info in infos.get(\"subject_collection_items\")]\n        return []\n\n    async def async_tv_animation(self, page: int = 1, count: int = 30) -> List[MediaInfo]:\n        \"\"\"\n        获取豆瓣动画剧（异步版本）\n        \"\"\"\n        infos = await self.doubanapi.async_tv_animation(start=(page - 1) * count,\n                                                        count=count)\n        if infos and infos.get(\"subject_collection_items\"):\n            return [MediaInfo(douban_info=info) for info in infos.get(\"subject_collection_items\")]\n        return []\n\n    def movie_hot(self, page: int = 1, count: int = 30) -> List[MediaInfo]:\n        \"\"\"\n        获取豆瓣热门电影\n        \"\"\"\n        infos = self.doubanapi.movie_hot_gaia(start=(page - 1) * count,\n                                              count=count)\n        if infos and infos.get(\"subject_collection_items\"):\n            return [MediaInfo(douban_info=info) for info in infos.get(\"subject_collection_items\")]\n        return []\n\n    async def async_movie_hot(self, page: int = 1, count: int = 30) -> List[MediaInfo]:\n        \"\"\"\n        获取豆瓣热门电影（异步版本）\n        \"\"\"\n        infos = await self.doubanapi.async_movie_hot_gaia(start=(page - 1) * count,\n                                                          count=count)\n        if infos and infos.get(\"subject_collection_items\"):\n            return [MediaInfo(douban_info=info) for info in infos.get(\"subject_collection_items\")]\n        return []\n\n    def tv_hot(self, page: int = 1, count: int = 30) -> List[MediaInfo]:\n        \"\"\"\n        获取豆瓣热门剧集\n        \"\"\"\n        infos = self.doubanapi.tv_hot(start=(page - 1) * count,\n                                      count=count)\n        if infos and infos.get(\"subject_collection_items\"):\n            return [MediaInfo(douban_info=info) for info in infos.get(\"subject_collection_items\")]\n        return []\n\n    async def async_tv_hot(self, page: int = 1, count: int = 30) -> List[MediaInfo]:\n        \"\"\"\n        获取豆瓣热门剧集（异步版本）\n        \"\"\"\n        infos = await self.doubanapi.async_tv_hot(start=(page - 1) * count,\n                                                  count=count)\n        if infos and infos.get(\"subject_collection_items\"):\n            return [MediaInfo(douban_info=info) for info in infos.get(\"subject_collection_items\")]\n        return []\n\n    def search_medias(self, meta: MetaBase) -> Optional[List[MediaInfo]]:\n        \"\"\"\n        搜索媒体信息\n        :param meta:  识别的元数据\n        :reutrn: 媒体信息\n        \"\"\"\n        if settings.SEARCH_SOURCE and \"douban\" not in settings.SEARCH_SOURCE:\n            return None\n        if not meta.name:\n            return []\n        result = self.doubanapi.search(meta.name)\n        if not result or not result.get(\"items\"):\n            return []\n        # 返回数据\n        ret_medias = []\n        for item_obj in result.get(\"items\"):\n            if meta.type and meta.type != MediaType.UNKNOWN and meta.type.value != item_obj.get(\"type_name\"):\n                continue\n            if item_obj.get(\"type_name\") not in (MediaType.TV.value, MediaType.MOVIE.value):\n                continue\n            if meta.name not in item_obj.get(\"target\", {}).get(\"title\"):\n                continue\n            ret_medias.append(MediaInfo(douban_info=item_obj.get(\"target\")))\n        # 将搜索词中的季写入标题中\n        if ret_medias and meta.begin_season:\n            # 小写数据转大写\n            season_str = cn2an.an2cn(meta.begin_season, \"low\")\n            for media in ret_medias:\n                if media.type == MediaType.TV:\n                    media.title = f\"{media.title} 第{season_str}季\"\n                    media.season = meta.begin_season\n        return ret_medias\n\n    async def async_search_medias(self, meta: MetaBase) -> Optional[List[MediaInfo]]:\n        \"\"\"\n        搜索媒体信息（异步版本）\n        :param meta:  识别的元数据\n        :reutrn: 媒体信息\n        \"\"\"\n        if settings.SEARCH_SOURCE and \"douban\" not in settings.SEARCH_SOURCE:\n            return None\n        if not meta.name:\n            return []\n        result = await self.doubanapi.async_search(meta.name)\n        if not result or not result.get(\"items\"):\n            return []\n        # 返回数据\n        ret_medias = []\n        for item_obj in result.get(\"items\"):\n            if meta.type and meta.type != MediaType.UNKNOWN and meta.type.value != item_obj.get(\"type_name\"):\n                continue\n            if item_obj.get(\"type_name\") not in (MediaType.TV.value, MediaType.MOVIE.value):\n                continue\n            if meta.name not in item_obj.get(\"target\", {}).get(\"title\"):\n                continue\n            ret_medias.append(MediaInfo(douban_info=item_obj.get(\"target\")))\n        # 将搜索词中的季写入标题中\n        if ret_medias and meta.begin_season:\n            # 小写数据转大写\n            season_str = cn2an.an2cn(meta.begin_season, \"low\")\n            for media in ret_medias:\n                if media.type == MediaType.TV:\n                    media.title = f\"{media.title} 第{season_str}季\"\n                    media.season = meta.begin_season\n        return ret_medias\n\n    def search_persons(self, name: str) -> Optional[List[MediaPerson]]:\n        \"\"\"\n        搜索人物信息\n        \"\"\"\n        if settings.SEARCH_SOURCE and \"douban\" not in settings.SEARCH_SOURCE:\n            return None\n        if not name:\n            return []\n        result = self.doubanapi.person_search(keyword=name)\n        if result and result.get('items'):\n            return [MediaPerson(source='douban', **{\n                'id': item.get('target_id'),\n                'name': item.get('target', {}).get('title'),\n                'url': item.get('target', {}).get('url'),\n                'images': item.get('target', {}).get('cover', {}),\n                'avatar': (item.get('target', {}).get('cover_img', {}).get('url')\n                           or '').replace(\"/l/public/\", \"/s/public/\"),\n            }) for item in result.get('items') if name in item.get('target', {}).get('title')]\n        return []\n\n    async def async_search_persons(self, name: str) -> Optional[List[MediaPerson]]:\n        \"\"\"\n        搜索人物信息（异步版本）\n        \"\"\"\n        if settings.SEARCH_SOURCE and \"douban\" not in settings.SEARCH_SOURCE:\n            return None\n        if not name:\n            return []\n        result = await self.doubanapi.async_person_search(keyword=name)\n        if result and result.get('items'):\n            return [MediaPerson(source='douban', **{\n                'id': item.get('target_id'),\n                'name': item.get('target', {}).get('title'),\n                'url': item.get('target', {}).get('url'),\n                'images': item.get('target', {}).get('cover', {}),\n                'avatar': (item.get('target', {}).get('cover_img', {}).get('url')\n                           or '').replace(\"/l/public/\", \"/s/public/\"),\n            }) for item in result.get('items') if name in item.get('target', {}).get('title')]\n        return []\n\n    @staticmethod\n    def _process_imdbid_result(result: dict, imdbid: str) -> Optional[dict]:\n        \"\"\"\n        处理IMDBID查询结果\n        :param result: IMDBID查询返回的结果\n        :param imdbid: IMDB ID\n        :return: 处理后的结果，None表示无结果\n        \"\"\"\n        if result:\n            doubanid = result.get(\"id\")\n            if doubanid:\n                if not str(doubanid).isdigit():\n                    doubanid = re.search(r\"\\d+\", doubanid).group(0)\n                    result[\"id\"] = doubanid\n                logger.info(f\"{imdbid} 查询到豆瓣信息：{result.get('title')}\")\n                return result\n            return None\n        return None\n\n    @staticmethod\n    def _process_search_results(result: dict, name: str, mtype: MediaType = None,\n                                year: str = None, season: int = None) -> dict:\n        \"\"\"\n        处理搜索结果并进行匹配\n        :param result: 搜索返回的结果\n        :param name: 搜索名称\n        :param mtype: 媒体类型\n        :param year: 年份\n        :param season: 季号\n        :return: 匹配到的豆瓣信息\n        \"\"\"\n        if not result:\n            logger.warn(f\"未找到 {name} 的豆瓣信息\")\n            return {}\n\n        # 触发rate limit检查\n        if \"search_access_rate_limit\" in result.values():\n            msg = f\"触发豆瓣API速率限制，错误信息：{result} ...\"\n            logger.warn(msg)\n            raise APIRateLimitException(msg)\n\n        if not result.get(\"items\"):\n            logger.warn(f\"未找到 {name} 的豆瓣信息\")\n            return {}\n\n        for item_obj in result.get(\"items\"):\n            type_name = item_obj.get(\"type_name\")\n            if type_name not in [MediaType.TV.value, MediaType.MOVIE.value]:\n                continue\n            if mtype and mtype.value != type_name:\n                continue\n            if mtype and mtype == MediaType.TV and not season:\n                season = 1\n            item = item_obj.get(\"target\")\n            title = item.get(\"title\")\n            if not title:\n                continue\n            meta = MetaInfo(title)\n            if type_name == MediaType.TV.value:\n                meta.type = MediaType.TV\n                meta.begin_season = meta.begin_season or 1\n            if meta.name == name \\\n                    and ((not season and not meta.begin_season) or meta.begin_season == season) \\\n                    and (not year or item.get('year') == year):\n                logger.info(f\"{name} 匹配到豆瓣信息：{item.get('id')} {item.get('title')}\")\n                return item\n        return {}\n\n    @retry(Exception, 5, 3, 3, logger=logger)\n    @rate_limit_exponential(source=\"match_doubaninfo\")\n    def match_doubaninfo(self, name: str, imdbid: str = None,\n                         mtype: MediaType = None, year: str = None, season: int = None,\n                         raise_exception: bool = False) -> dict:\n        \"\"\"\n        搜索和匹配豆瓣信息\n        :param name:  名称\n        :param imdbid:  IMDB ID\n        :param mtype:  类型\n        :param year:  年份\n        :param season:  季号\n        :param raise_exception: 触发速率限制时是否抛出异常\n        \"\"\"\n        if imdbid:\n            # 优先使用IMDBID查询\n            logger.info(f\"开始使用IMDBID {imdbid} 查询豆瓣信息 ...\")\n            result = self.doubanapi.imdbid(imdbid)\n            processed_result = self._process_imdbid_result(result, imdbid)\n            if processed_result:\n                return processed_result\n\n        # 搜索\n        logger.info(f\"开始使用名称 {name} 匹配豆瓣信息 ...\")\n        result = self.doubanapi.search(f\"{name} {year or ''}\".strip())\n        return self._process_search_results(result, name, mtype, year, season)\n\n    @retry(Exception, 5, 3, 3, logger=logger)\n    @rate_limit_exponential(source=\"match_doubaninfo\")\n    async def async_match_doubaninfo(self, name: str, imdbid: str = None,\n                                     mtype: MediaType = None, year: str = None, season: int = None,\n                                     raise_exception: bool = False) -> dict:\n        \"\"\"\n        搜索和匹配豆瓣信息（异步版本）\n        :param name:  名称\n        :param imdbid:  IMDB ID\n        :param mtype:  类型\n        :param year:  年份\n        :param season:  季号\n        :param raise_exception: 触发速率限制时是否抛出异常\n        \"\"\"\n        if imdbid:\n            # 优先使用IMDBID查询\n            logger.info(f\"开始使用IMDBID {imdbid} 查询豆瓣信息 ...\")\n            result = await self.doubanapi.async_imdbid(imdbid)\n            processed_result = self._process_imdbid_result(result, imdbid)\n            if processed_result:\n                return processed_result\n\n        # 搜索\n        logger.info(f\"开始使用名称 {name} 匹配豆瓣信息 ...\")\n        result = await self.doubanapi.async_search(f\"{name} {year or ''}\".strip())\n        return self._process_search_results(result, name, mtype, year, season)\n\n    def movie_top250(self, page: int = 1, count: int = 30) -> List[MediaInfo]:\n        \"\"\"\n        获取豆瓣电影TOP250\n        \"\"\"\n        infos = self.doubanapi.movie_top250(start=(page - 1) * count,\n                                            count=count)\n        if infos and infos.get(\"subject_collection_items\"):\n            return [MediaInfo(douban_info=info) for info in infos.get(\"subject_collection_items\")]\n        return []\n\n    async def async_movie_top250(self, page: int = 1, count: int = 30) -> List[MediaInfo]:\n        \"\"\"\n        获取豆瓣电影TOP250（异步版本）\n        \"\"\"\n        infos = await self.doubanapi.async_movie_top250(start=(page - 1) * count,\n                                                        count=count)\n        if infos and infos.get(\"subject_collection_items\"):\n            return [MediaInfo(douban_info=info) for info in infos.get(\"subject_collection_items\")]\n        return []\n\n    def metadata_nfo(self, mediainfo: MediaInfo, season: int = None, **kwargs) -> Optional[str]:\n        \"\"\"\n        获取NFO文件内容文本\n        :param mediainfo: 媒体信息\n        :param season: 季号\n        \"\"\"\n        if settings.SCRAP_SOURCE != \"douban\":\n            return None\n        return self.scraper.get_metadata_nfo(mediainfo=mediainfo, season=season)\n\n    def metadata_img(self, mediainfo: MediaInfo, season: int = None, episode: int = None) -> Optional[dict]:\n        \"\"\"\n        获取图片名称和url\n        :param mediainfo: 媒体信息\n        :param season: 季号\n        :param episode: 集号\n        \"\"\"\n        if settings.SCRAP_SOURCE != \"douban\":\n            return None\n        return self.scraper.get_metadata_img(mediainfo=mediainfo, season=season, episode=episode)\n\n    @staticmethod\n    def _validate_douban_obtain_images_params(mediainfo: MediaInfo) -> Optional[MediaInfo]:\n        \"\"\"\n        验证豆瓣 obtain_images 参数\n        :param mediainfo: 媒体信息\n        :return: None 表示不处理，MediaInfo 表示继续处理\n        \"\"\"\n        if settings.RECOGNIZE_SOURCE != \"douban\":\n            return None\n        if not mediainfo.douban_id:\n            return None\n        if mediainfo.backdrop_path:\n            # 没有图片缺失\n            return mediainfo\n        return None\n\n    @staticmethod\n    def _process_douban_images(mediainfo: MediaInfo, info: dict) -> MediaInfo:\n        \"\"\"\n        处理豆瓣图片数据\n        :param mediainfo: 媒体信息\n        :param info: 图片信息\n        :return: 更新后的媒体信息\n        \"\"\"\n        if not info:\n            return mediainfo\n        images = info.get(\"photos\")\n        # 背景图\n        if images:\n            backdrop = images[0].get(\"image\", {}).get(\"large\") or {}\n            if backdrop:\n                mediainfo.backdrop_path = backdrop.get(\"url\")\n        return mediainfo\n\n    def obtain_images(self, mediainfo: MediaInfo) -> Optional[MediaInfo]:\n        \"\"\"\n        补充抓取媒体信息图片\n        :param mediainfo:  识别的媒体信息\n        :return: 更新后的媒体信息\n        \"\"\"\n        # 验证参数\n        result = self._validate_douban_obtain_images_params(mediainfo)\n        if result is not None:\n            return result\n\n        # 调用图片接口\n        if mediainfo.type == MediaType.MOVIE:\n            info = self.doubanapi.movie_photos(mediainfo.douban_id)\n        else:\n            info = self.doubanapi.tv_photos(mediainfo.douban_id)\n\n        # 处理图片数据\n        return self._process_douban_images(mediainfo, info)\n\n    async def async_obtain_images(self, mediainfo: MediaInfo) -> Optional[MediaInfo]:\n        \"\"\"\n        补充抓取媒体信息图片（异步版本）\n        :param mediainfo:  识别的媒体信息\n        :return: 更新后的媒体信息\n        \"\"\"\n        # 验证参数\n        result = self._validate_douban_obtain_images_params(mediainfo)\n        if result is not None:\n            return result\n\n        # 调用图片接口\n        if mediainfo.type == MediaType.MOVIE:\n            info = await self.doubanapi.async_movie_photos(mediainfo.douban_id)\n        else:\n            info = await self.doubanapi.async_tv_photos(mediainfo.douban_id)\n\n        # 处理图片数据\n        return self._process_douban_images(mediainfo, info)\n\n    def clear_cache(self):\n        \"\"\"\n        清除缓存\n        \"\"\"\n        logger.info(\"开始清除豆瓣缓存 ...\")\n        self.doubanapi.clear_cache()\n        self.cache.clear()\n        logger.info(\"豆瓣缓存清除完成\")\n\n    def douban_movie_credits(self, doubanid: str) -> List[schemas.MediaPerson]:\n        \"\"\"\n        根据豆瓣ID查询电影演职员表\n        :param doubanid:  豆瓣ID\n        \"\"\"\n        result = self.doubanapi.movie_celebrities(subject_id=doubanid)\n        return self._process_celebrity_data(result)\n\n    def douban_tv_credits(self, doubanid: str) -> List[schemas.MediaPerson]:\n        \"\"\"\n        根据豆瓣ID查询电视剧演职员表\n        :param doubanid:  豆瓣ID\n        \"\"\"\n        result = self.doubanapi.tv_celebrities(subject_id=doubanid)\n        return self._process_celebrity_data(result)\n\n    def douban_movie_recommend(self, doubanid: str) -> List[MediaInfo]:\n        \"\"\"\n        根据豆瓣ID查询推荐电影\n        :param doubanid:  豆瓣ID\n        \"\"\"\n        recommend = self.doubanapi.movie_recommendations(subject_id=doubanid)\n        if recommend:\n            return [MediaInfo(douban_info=info) for info in recommend]\n        return []\n\n    def douban_tv_recommend(self, doubanid: str) -> List[MediaInfo]:\n        \"\"\"\n        根据豆瓣ID查询推荐电视剧\n        :param doubanid:  豆瓣ID\n        \"\"\"\n        recommend = self.doubanapi.tv_recommendations(subject_id=doubanid)\n        if recommend:\n            return [MediaInfo(douban_info=info) for info in recommend]\n        return []\n\n    def douban_person_detail(self, person_id: int) -> schemas.MediaPerson:\n        \"\"\"\n        获取人物详细信息\n        :param person_id:  豆瓣人物ID\n        \"\"\"\n        detail = self.doubanapi.person_detail(person_id)\n        if detail:\n            also_known_as = []\n            infos = detail.get(\"extra\", {}).get(\"info\")\n            if infos:\n                also_known_as = [\"：\".join(info) for info in infos]\n            image = detail.get(\"cover_img\", {}).get(\"url\")\n            if image:\n                image = image.replace(\"/l/public/\", \"/s/public/\")\n            return schemas.MediaPerson(source='douban', **{\n                \"id\": detail.get(\"id\"),\n                \"name\": detail.get(\"title\"),\n                \"avatar\": image,\n                \"biography\": detail.get(\"extra\", {}).get(\"short_info\"),\n                \"also_known_as\": also_known_as,\n            })\n        return schemas.MediaPerson(source='douban')\n\n    def douban_person_credits(self, person_id: int, page: int = 1) -> List[MediaInfo]:\n        \"\"\"\n        根据TMDBID查询人物参演作品\n        :param person_id:  人物ID\n        :param page:  页码\n        \"\"\"\n        # 获取人物参演作品集\n        personinfo = self.doubanapi.person_detail(person_id)\n        if not personinfo:\n            return []\n        collection_id = None\n        for module in personinfo.get(\"modules\"):\n            if module.get(\"type\") == \"work_collections\":\n                collection_id = module.get(\"payload\", {}).get(\"id\")\n        # 查询作品集内容\n        if collection_id:\n            collections = self.doubanapi.person_work(subject_id=collection_id, start=(page - 1) * 20, count=20)\n            if collections:\n                works = collections.get(\"works\")\n                return [MediaInfo(douban_info=work.get(\"subject\")) for work in works]\n        return []\n\n    @staticmethod\n    def _process_celebrity_data(result: dict) -> List[schemas.MediaPerson]:\n        \"\"\"\n        处理演职员表数据的公共方法\n        :param result: API返回的演职员表数据\n        :return: 处理后的演员列表\n        \"\"\"\n        if not result:\n            return []\n        ret_list = result.get(\"actors\") or []\n        if ret_list:\n            # 更新豆瓣演员信息中的ID，从URI中提取'douban://douban.com/celebrity/1316132?subject_id=27503705' subject_id\n            for doubaninfo in ret_list:\n                doubaninfo['id'] = doubaninfo.get('uri', '').split('?subject_id=')[-1]\n            return [schemas.MediaPerson(source='douban', **doubaninfo) for doubaninfo in ret_list]\n        return []\n\n    async def async_douban_movie_credits(self, doubanid: str) -> List[schemas.MediaPerson]:\n        \"\"\"\n        根据豆瓣ID查询电影演职员表（异步版本）\n        :param doubanid:  豆瓣ID\n        \"\"\"\n        result = await self.doubanapi.async_movie_celebrities(subject_id=doubanid)\n        return self._process_celebrity_data(result)\n\n    async def async_douban_tv_credits(self, doubanid: str) -> List[schemas.MediaPerson]:\n        \"\"\"\n        根据豆瓣ID查询电视剧演职员表（异步版本）\n        :param doubanid:  豆瓣ID\n        \"\"\"\n        result = await self.doubanapi.async_tv_celebrities(subject_id=doubanid)\n        return self._process_celebrity_data(result)\n\n    async def async_douban_movie_recommend(self, doubanid: str) -> List[MediaInfo]:\n        \"\"\"\n        根据豆瓣ID查询推荐电影（异步版本）\n        :param doubanid:  豆瓣ID\n        \"\"\"\n        recommend = await self.doubanapi.async_movie_recommendations(subject_id=doubanid)\n        if recommend:\n            return [MediaInfo(douban_info=info) for info in recommend]\n        return []\n\n    async def async_douban_tv_recommend(self, doubanid: str) -> List[MediaInfo]:\n        \"\"\"\n        根据豆瓣ID查询推荐电视剧（异步版本）\n        :param doubanid:  豆瓣ID\n        \"\"\"\n        recommend = await self.doubanapi.async_tv_recommendations(subject_id=doubanid)\n        if recommend:\n            return [MediaInfo(douban_info=info) for info in recommend]\n        return []\n\n    async def async_douban_person_detail(self, person_id: int) -> schemas.MediaPerson:\n        \"\"\"\n        获取人物详细信息（异步版本）\n        :param person_id:  豆瓣人物ID\n        \"\"\"\n        detail = await self.doubanapi.async_person_detail(person_id)\n        if detail:\n            also_known_as = []\n            infos = detail.get(\"extra\", {}).get(\"info\")\n            if infos:\n                also_known_as = [\"：\".join(info) for info in infos]\n            image = detail.get(\"cover_img\", {}).get(\"url\")\n            if image:\n                image = image.replace(\"/l/public/\", \"/s/public/\")\n            return schemas.MediaPerson(source='douban', **{\n                \"id\": detail.get(\"id\"),\n                \"name\": detail.get(\"title\"),\n                \"avatar\": image,\n                \"biography\": detail.get(\"extra\", {}).get(\"short_info\"),\n                \"also_known_as\": also_known_as,\n            })\n        return schemas.MediaPerson(source='douban')\n\n    async def async_douban_person_credits(self, person_id: int, page: int = 1) -> List[MediaInfo]:\n        \"\"\"\n        根据豆瓣ID查询人物参演作品（异步版本）\n        :param person_id:  人物ID\n        :param page:  页码\n        \"\"\"\n        # 获取人物参演作品集\n        personinfo = await self.doubanapi.async_person_detail(person_id)\n        if not personinfo:\n            return []\n        collection_id = None\n        for module in personinfo.get(\"modules\"):\n            if module.get(\"type\") == \"work_collections\":\n                collection_id = module.get(\"payload\", {}).get(\"id\")\n        # 查询作品集内容\n        if collection_id:\n            collections = await self.doubanapi.async_person_work(subject_id=collection_id, start=(page - 1) * 20,\n                                                                 count=20)\n            if collections:\n                works = collections.get(\"works\")\n                return [MediaInfo(douban_info=work.get(\"subject\")) for work in works]\n        return []\n"
  },
  {
    "path": "app/modules/douban/apiv2.py",
    "content": "# -*- coding: utf-8 -*-\nimport base64\nimport hashlib\nimport hmac\nfrom datetime import datetime\nfrom random import choice\nfrom typing import Optional, Union\nfrom urllib import parse\n\nimport httpx\nimport requests\n\nfrom app.core.cache import cached\nfrom app.core.config import settings\nfrom app.utils.http import RequestUtils, AsyncRequestUtils\nfrom app.utils.singleton import WeakSingleton\n\n\nclass DoubanApi(metaclass=WeakSingleton):\n    _urls = {\n        # 搜索类\n        # sort=U:近期热门 T:标记最多 S:评分最高 R:最新上映\n        # 聚合搜索\n        \"search\": \"/search/weixin\",\n        \"search_agg\": \"/search\",\n        \"search_subject\": \"/search/subjects\",\n        \"imdbid\": \"/movie/imdb/%s\",\n\n        # 电影探索\n        # sort=U:综合排序 T:近期热度 S:高分优先 R:首播时间\n        \"movie_recommend\": \"/movie/recommend\",\n        # 电视剧探索\n        \"tv_recommend\": \"/tv/recommend\",\n        # 搜索\n        \"movie_tag\": \"/movie/tag\",\n        \"tv_tag\": \"/tv/tag\",\n        \"movie_search\": \"/search/movie\",\n        \"tv_search\": \"/search/movie\",\n        \"book_search\": \"/search/book\",\n        \"group_search\": \"/search/group\",\n\n        # 各类主题合集\n        # 正在上映\n        \"movie_showing\": \"/subject_collection/movie_showing/items\",\n        # 热门电影\n        \"movie_hot_gaia\": \"/subject_collection/movie_hot_gaia/items\",\n        # 即将上映\n        \"movie_soon\": \"/subject_collection/movie_soon/items\",\n        # TOP250\n        \"movie_top250\": \"/subject_collection/movie_top250/items\",\n        # 高分经典科幻片榜\n        \"movie_scifi\": \"/subject_collection/movie_scifi/items\",\n        # 高分经典喜剧片榜\n        \"movie_comedy\": \"/subject_collection/movie_comedy/items\",\n        # 高分经典动作片榜\n        \"movie_action\": \"/subject_collection/movie_action/items\",\n        # 高分经典爱情片榜\n        \"movie_love\": \"/subject_collection/movie_love/items\",\n\n        # 热门剧集\n        \"tv_hot\": \"/subject_collection/tv_hot/items\",\n        # 国产剧\n        \"tv_domestic\": \"/subject_collection/tv_domestic/items\",\n        # 美剧\n        \"tv_american\": \"/subject_collection/tv_american/items\",\n        # 本剧\n        \"tv_japanese\": \"/subject_collection/tv_japanese/items\",\n        # 韩剧\n        \"tv_korean\": \"/subject_collection/tv_korean/items\",\n        # 动画\n        \"tv_animation\": \"/subject_collection/tv_animation/items\",\n        # 综艺\n        \"tv_variety_show\": \"/subject_collection/tv_variety_show/items\",\n        # 华语口碑周榜\n        \"tv_chinese_best_weekly\": \"/subject_collection/tv_chinese_best_weekly/items\",\n        # 全球口碑周榜\n        \"tv_global_best_weekly\": \"/subject_collection/tv_global_best_weekly/items\",\n\n        # 执门综艺\n        \"show_hot\": \"/subject_collection/show_hot/items\",\n        # 国内综艺\n        \"show_domestic\": \"/subject_collection/show_domestic/items\",\n        # 国外综艺\n        \"show_foreign\": \"/subject_collection/show_foreign/items\",\n\n        \"book_bestseller\": \"/subject_collection/book_bestseller/items\",\n        \"book_top250\": \"/subject_collection/book_top250/items\",\n        # 虚构类热门榜\n        \"book_fiction_hot_weekly\": \"/subject_collection/book_fiction_hot_weekly/items\",\n        # 非虚构类热门\n        \"book_nonfiction_hot_weekly\": \"/subject_collection/book_nonfiction_hot_weekly/items\",\n\n        # 音乐\n        \"music_single\": \"/subject_collection/music_single/items\",\n\n        # rank list\n        \"movie_rank_list\": \"/movie/rank_list\",\n        \"movie_year_ranks\": \"/movie/year_ranks\",\n        \"book_rank_list\": \"/book/rank_list\",\n        \"tv_rank_list\": \"/tv/rank_list\",\n\n        # movie info\n        \"movie_detail\": \"/movie/\",\n        \"movie_rating\": \"/movie/%s/rating\",\n        \"movie_photos\": \"/movie/%s/photos\",\n        \"movie_trailers\": \"/movie/%s/trailers\",\n        \"movie_interests\": \"/movie/%s/interests\",\n        \"movie_reviews\": \"/movie/%s/reviews\",\n        \"movie_recommendations\": \"/movie/%s/recommendations\",\n        \"movie_celebrities\": \"/movie/%s/celebrities\",\n\n        # tv info\n        \"tv_detail\": \"/tv/\",\n        \"tv_rating\": \"/tv/%s/rating\",\n        \"tv_photos\": \"/tv/%s/photos\",\n        \"tv_trailers\": \"/tv/%s/trailers\",\n        \"tv_interests\": \"/tv/%s/interests\",\n        \"tv_reviews\": \"/tv/%s/reviews\",\n        \"tv_recommendations\": \"/tv/%s/recommendations\",\n        \"tv_celebrities\": \"/tv/%s/celebrities\",\n\n        # book info\n        \"book_detail\": \"/book/\",\n        \"book_rating\": \"/book/%s/rating\",\n        \"book_interests\": \"/book/%s/interests\",\n        \"book_reviews\": \"/book/%s/reviews\",\n        \"book_recommendations\": \"/book/%s/recommendations\",\n\n        # music info\n        \"music_detail\": \"/music/\",\n        \"music_rating\": \"/music/%s/rating\",\n        \"music_interests\": \"/music/%s/interests\",\n        \"music_reviews\": \"/music/%s/reviews\",\n        \"music_recommendations\": \"/music/%s/recommendations\",\n\n        # doulist\n        \"doulist\": \"/doulist/\",\n        \"doulist_items\": \"/doulist/%s/items\",\n\n        # person\n        \"person_detail\": \"/elessar/subject/\",\n        \"person_work\": \"/elessar/work_collections/%s/works\",\n    }\n\n    _user_agents = [\n        \"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\"\n        \"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\",\n        \"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\",\n        \"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\"]\n    _api_secret_key = \"bf7dddc7c9cfe6f7\"\n    _api_key = \"0dad551ec0f84ed02907ff5c42e8ec70\"\n    _api_key2 = \"0ab215a8b1977939201640fa14c66bab\"\n    _base_url = \"https://frodo.douban.com/api/v2\"\n    _api_url = \"https://api.douban.com/v2\"\n\n    def __init__(self):\n        self._session = requests.Session()\n\n    @classmethod\n    def __sign(cls, url: str, ts: str, method='GET') -> str:\n        \"\"\"\n        签名\n        \"\"\"\n        url_path = parse.urlparse(url).path\n        raw_sign = '&'.join([method.upper(), parse.quote(url_path, safe=''), ts])\n        return base64.b64encode(\n            hmac.new(\n                cls._api_secret_key.encode(),\n                raw_sign.encode(),\n                hashlib.sha1\n            ).digest()\n        ).decode()\n\n    def __invoke_recommend(self, url: str, **kwargs) -> dict:\n        \"\"\"\n        推荐/发现类API\n        \"\"\"\n        return self.__invoke(url, **kwargs)\n\n    async def __async_invoke_recommend(self, url: str, **kwargs) -> dict:\n        \"\"\"\n        推荐/发现类API（异步版本）\n        \"\"\"\n        return await self.__async_invoke(url, **kwargs)\n\n    def __invoke_search(self, url: str, **kwargs) -> dict:\n        \"\"\"\n        搜索类API\n        \"\"\"\n        return self.__invoke(url, **kwargs)\n\n    async def __async_invoke_search(self, url: str, **kwargs) -> dict:\n        \"\"\"\n        搜索类API（异步版本）\n        \"\"\"\n        return await self.__async_invoke(url, **kwargs)\n\n    def _prepare_get_request(self, url: str, **kwargs) -> tuple[str, dict]:\n        \"\"\"\n        准备GET请求的URL和参数\n        \"\"\"\n        req_url = self._base_url + url\n\n        params: dict = {'apiKey': self._api_key}\n        if kwargs:\n            params.update(kwargs)\n\n        ts = params.pop(\n            '_ts',\n            datetime.strftime(datetime.now(), '%Y%m%d')\n        )\n        params.update({\n            'os_rom': 'android',\n            'apiKey': self._api_key,\n            '_ts': ts,\n            '_sig': self.__sign(url=req_url, ts=ts)\n        })\n        return req_url, params\n\n    @staticmethod\n    def _handle_response(resp: Union[requests.Response, httpx.Response]) -> dict:\n        \"\"\"\n        处理HTTP响应\n        \"\"\"\n        return resp.json() if resp is not None else None\n\n    @cached(maxsize=settings.CONF.douban, ttl=settings.CONF.meta, skip_none=True, shared_key=\"get\")\n    def __invoke(self, url: str, **kwargs) -> dict:\n        \"\"\"\n        GET请求\n        \"\"\"\n        req_url, params = self._prepare_get_request(url, **kwargs)\n        resp = RequestUtils(\n            ua=choice(self._user_agents),\n            session=self._session\n        ).get_res(url=req_url, params=params)\n        return self._handle_response(resp)\n\n    @cached(maxsize=settings.CONF.douban, ttl=settings.CONF.meta, skip_none=True, shared_key=\"get\")\n    async def __async_invoke(self, url: str, **kwargs) -> dict:\n        \"\"\"\n        GET请求（异步版本）\n        \"\"\"\n        req_url, params = self._prepare_get_request(url, **kwargs)\n        resp = await AsyncRequestUtils(\n            ua=choice(self._user_agents)\n        ).get_res(url=req_url, params=params)\n        return self._handle_response(resp)\n\n    def _prepare_post_request(self, url: str, **kwargs) -> tuple[str, dict]:\n        \"\"\"\n        准备POST请求的URL和参数\n        \"\"\"\n        req_url = self._api_url + url\n        params = {'apikey': self._api_key2}\n        if kwargs:\n            params.update(kwargs)\n        if '_ts' in params:\n            params.pop('_ts')\n        return req_url, params\n\n    @cached(maxsize=settings.CONF.douban, ttl=settings.CONF.meta, skip_none=True, shared_key=\"post\")\n    def __post(self, url: str, **kwargs) -> dict:\n        \"\"\"\n        POST请求\n        esponse = requests.post(\n            url=\"https://api.douban.com/v2/movie/imdb/tt29139455\",\n            headers={\n                \"Content-Type\": \"application/x-www-form-urlencoded; charset=utf-8\",\n                \"Cookie\": \"bid=J9zb1zA5sJc\",\n            },\n            data={\n                \"apikey\": \"0ab215a8b1977939201640fa14c66bab\",\n            }\n        )\n        \"\"\"\n        req_url, params = self._prepare_post_request(url, **kwargs)\n        resp = RequestUtils(\n            ua=settings.NORMAL_USER_AGENT,\n            session=self._session,\n        ).post_res(url=req_url, data=params)\n        return self._handle_response(resp)\n\n    @cached(maxsize=settings.CONF.douban, ttl=settings.CONF.meta, skip_none=True, shared_key=\"post\")\n    async def __async_post(self, url: str, **kwargs) -> dict:\n        \"\"\"\n        POST请求（异步版本）\n        \"\"\"\n        req_url, params = self._prepare_post_request(url, **kwargs)\n        resp = await AsyncRequestUtils(\n            ua=settings.NORMAL_USER_AGENT\n        ).post_res(url=req_url, data=params)\n        return self._handle_response(resp)\n\n    def imdbid(self, imdbid: str,\n               ts=datetime.strftime(datetime.now(), '%Y%m%d')):\n        \"\"\"\n        IMDBID搜索\n        \"\"\"\n        return self.__post(self._urls[\"imdbid\"] % imdbid, _ts=ts)\n\n    async def async_imdbid(self, imdbid: str,\n                           ts=datetime.strftime(datetime.now(), '%Y%m%d')):\n        \"\"\"\n        IMDBID搜索（异步版本）\n        \"\"\"\n        return await self.__async_post(self._urls[\"imdbid\"] % imdbid, _ts=ts)\n\n    def search(self, keyword: str, start: Optional[int] = 0, count: Optional[int] = 20,\n               ts=datetime.strftime(datetime.now(), '%Y%m%d')) -> dict:\n        \"\"\"\n        关键字搜索\n        \"\"\"\n        return self.__invoke_search(self._urls[\"search\"], q=keyword,\n                                    start=start, count=count, _ts=ts)\n\n    async def async_search(self, keyword: str, start: Optional[int] = 0, count: Optional[int] = 20,\n                           ts=datetime.strftime(datetime.now(), '%Y%m%d')) -> dict:\n        \"\"\"\n        关键字搜索（异步版本）\n        \"\"\"\n        return await self.__async_invoke_search(self._urls[\"search\"], q=keyword,\n                                                start=start, count=count, _ts=ts)\n\n    def movie_search(self, keyword: str, start: Optional[int] = 0, count: Optional[int] = 20,\n                     ts=datetime.strftime(datetime.now(), '%Y%m%d')):\n        \"\"\"\n        电影搜索\n        \"\"\"\n        return self.__invoke_search(self._urls[\"movie_search\"], q=keyword,\n                                    start=start, count=count, _ts=ts)\n\n    async def async_movie_search(self, keyword: str, start: Optional[int] = 0, count: Optional[int] = 20,\n                                 ts=datetime.strftime(datetime.now(), '%Y%m%d')):\n        \"\"\"\n        电影搜索（异步版本）\n        \"\"\"\n        return await self.__async_invoke_search(self._urls[\"movie_search\"], q=keyword,\n                                                start=start, count=count, _ts=ts)\n\n    def tv_search(self, keyword: str, start: Optional[int] = 0, count: Optional[int] = 20,\n                  ts=datetime.strftime(datetime.now(), '%Y%m%d')):\n        \"\"\"\n        电视搜索\n        \"\"\"\n        return self.__invoke_search(self._urls[\"tv_search\"], q=keyword,\n                                    start=start, count=count, _ts=ts)\n\n    async def async_tv_search(self, keyword: str, start: Optional[int] = 0, count: Optional[int] = 20,\n                              ts=datetime.strftime(datetime.now(), '%Y%m%d')):\n        \"\"\"\n        电视搜索（异步版本）\n        \"\"\"\n        return await self.__async_invoke_search(self._urls[\"tv_search\"], q=keyword,\n                                                start=start, count=count, _ts=ts)\n\n    def book_search(self, keyword: str, start: Optional[int] = 0, count: Optional[int] = 20,\n                    ts=datetime.strftime(datetime.now(), '%Y%m%d')):\n        \"\"\"\n        书籍搜索\n        \"\"\"\n        return self.__invoke_search(self._urls[\"book_search\"], q=keyword,\n                                    start=start, count=count, _ts=ts)\n\n    async def async_book_search(self, keyword: str, start: Optional[int] = 0, count: Optional[int] = 20,\n                                ts=datetime.strftime(datetime.now(), '%Y%m%d')):\n        \"\"\"\n        书籍搜索（异步版本）\n        \"\"\"\n        return await self.__async_invoke_search(self._urls[\"book_search\"], q=keyword,\n                                                start=start, count=count, _ts=ts)\n\n    def group_search(self, keyword: str, start: Optional[int] = 0, count: Optional[int] = 20,\n                     ts=datetime.strftime(datetime.now(), '%Y%m%d')):\n        \"\"\"\n        小组搜索\n        \"\"\"\n        return self.__invoke_search(self._urls[\"group_search\"], q=keyword,\n                                    start=start, count=count, _ts=ts)\n\n    async def async_group_search(self, keyword: str, start: Optional[int] = 0, count: Optional[int] = 20,\n                                 ts=datetime.strftime(datetime.now(), '%Y%m%d')):\n        \"\"\"\n        小组搜索（异步版本）\n        \"\"\"\n        return await self.__async_invoke_search(self._urls[\"group_search\"], q=keyword,\n                                                start=start, count=count, _ts=ts)\n\n    def person_search(self, keyword: str, start: Optional[int] = 0, count: Optional[int] = 20,\n                      ts=datetime.strftime(datetime.now(), '%Y%m%d')):\n        \"\"\"\n        人物搜索\n        \"\"\"\n        return self.__invoke_search(self._urls[\"search_subject\"], type=\"person\", q=keyword,\n                                    start=start, count=count, _ts=ts)\n\n    async def async_person_search(self, keyword: str, start: Optional[int] = 0, count: Optional[int] = 20,\n                                  ts=datetime.strftime(datetime.now(), '%Y%m%d')):\n        \"\"\"\n        人物搜索（异步版本）\n        \"\"\"\n        return await self.__async_invoke_search(self._urls[\"search_subject\"], type=\"person\", q=keyword,\n                                                start=start, count=count, _ts=ts)\n\n    def movie_showing(self, start: Optional[int] = 0, count: Optional[int] = 20,\n                      ts=datetime.strftime(datetime.now(), '%Y%m%d')):\n        \"\"\"\n        正在热映\n        \"\"\"\n        return self.__invoke_recommend(self._urls[\"movie_showing\"],\n                                       start=start, count=count, _ts=ts)\n\n    async def async_movie_showing(self, start: Optional[int] = 0, count: Optional[int] = 20,\n                                  ts=datetime.strftime(datetime.now(), '%Y%m%d')):\n        \"\"\"\n        正在热映（异步版本）\n        \"\"\"\n        return await self.__async_invoke_recommend(self._urls[\"movie_showing\"],\n                                                   start=start, count=count, _ts=ts)\n\n    def movie_soon(self, start: Optional[int] = 0, count: Optional[int] = 20,\n                   ts=datetime.strftime(datetime.now(), '%Y%m%d')):\n        \"\"\"\n        即将上映\n        \"\"\"\n        return self.__invoke_recommend(self._urls[\"movie_soon\"],\n                                       start=start, count=count, _ts=ts)\n\n    async def async_movie_soon(self, start: Optional[int] = 0, count: Optional[int] = 20,\n                               ts=datetime.strftime(datetime.now(), '%Y%m%d')):\n        \"\"\"\n        即将上映（异步版本）\n        \"\"\"\n        return await self.__async_invoke_recommend(self._urls[\"movie_soon\"],\n                                                   start=start, count=count, _ts=ts)\n\n    def movie_hot_gaia(self, start: Optional[int] = 0, count: Optional[int] = 20,\n                       ts=datetime.strftime(datetime.now(), '%Y%m%d')):\n        \"\"\"\n        热门电影\n        \"\"\"\n        return self.__invoke_recommend(self._urls[\"movie_hot_gaia\"],\n                                       start=start, count=count, _ts=ts)\n\n    async def async_movie_hot_gaia(self, start: Optional[int] = 0, count: Optional[int] = 20,\n                                   ts=datetime.strftime(datetime.now(), '%Y%m%d')):\n        \"\"\"\n        热门电影（异步版本）\n        \"\"\"\n        return await self.__async_invoke_recommend(self._urls[\"movie_hot_gaia\"],\n                                                   start=start, count=count, _ts=ts)\n\n    def tv_hot(self, start: Optional[int] = 0, count: Optional[int] = 20,\n               ts=datetime.strftime(datetime.now(), '%Y%m%d')):\n        \"\"\"\n        热门剧集\n        \"\"\"\n        return self.__invoke_recommend(self._urls[\"tv_hot\"],\n                                       start=start, count=count, _ts=ts)\n\n    async def async_tv_hot(self, start: Optional[int] = 0, count: Optional[int] = 20,\n                           ts=datetime.strftime(datetime.now(), '%Y%m%d')):\n        \"\"\"\n        热门剧集（异步版本）\n        \"\"\"\n        return await self.__async_invoke_recommend(self._urls[\"tv_hot\"],\n                                                   start=start, count=count, _ts=ts)\n\n    def tv_animation(self, start: Optional[int] = 0, count: Optional[int] = 20,\n                     ts=datetime.strftime(datetime.now(), '%Y%m%d')):\n        \"\"\"\n        动画\n        \"\"\"\n        return self.__invoke_recommend(self._urls[\"tv_animation\"],\n                                       start=start, count=count, _ts=ts)\n\n    async def async_tv_animation(self, start: Optional[int] = 0, count: Optional[int] = 20,\n                                 ts=datetime.strftime(datetime.now(), '%Y%m%d')):\n        \"\"\"\n        动画（异步版本）\n        \"\"\"\n        return await self.__async_invoke_recommend(self._urls[\"tv_animation\"],\n                                                   start=start, count=count, _ts=ts)\n\n    def tv_variety_show(self, start: Optional[int] = 0, count: Optional[int] = 20,\n                        ts=datetime.strftime(datetime.now(), '%Y%m%d')):\n        \"\"\"\n        综艺\n        \"\"\"\n        return self.__invoke_recommend(self._urls[\"tv_variety_show\"],\n                                       start=start, count=count, _ts=ts)\n\n    async def async_tv_variety_show(self, start: Optional[int] = 0, count: Optional[int] = 20,\n                                    ts=datetime.strftime(datetime.now(), '%Y%m%d')):\n        \"\"\"\n        综艺（异步版本）\n        \"\"\"\n        return await self.__async_invoke_recommend(self._urls[\"tv_variety_show\"],\n                                                   start=start, count=count, _ts=ts)\n\n    def tv_rank_list(self, start: Optional[int] = 0, count: Optional[int] = 20,\n                     ts=datetime.strftime(datetime.now(), '%Y%m%d')):\n        \"\"\"\n        电视剧排行榜\n        \"\"\"\n        return self.__invoke_recommend(self._urls[\"tv_rank_list\"],\n                                       start=start, count=count, _ts=ts)\n\n    async def async_tv_rank_list(self, start: Optional[int] = 0, count: Optional[int] = 20,\n                                 ts=datetime.strftime(datetime.now(), '%Y%m%d')):\n        \"\"\"\n        电视剧排行榜（异步版本）\n        \"\"\"\n        return await self.__async_invoke_recommend(self._urls[\"tv_rank_list\"],\n                                                   start=start, count=count, _ts=ts)\n\n    def show_hot(self, start: Optional[int] = 0, count: Optional[int] = 20,\n                 ts=datetime.strftime(datetime.now(), '%Y%m%d')):\n        \"\"\"\n        综艺热门\n        \"\"\"\n        return self.__invoke_recommend(self._urls[\"show_hot\"],\n                                       start=start, count=count, _ts=ts)\n\n    async def async_show_hot(self, start: Optional[int] = 0, count: Optional[int] = 20,\n                             ts=datetime.strftime(datetime.now(), '%Y%m%d')):\n        \"\"\"\n        综艺热门（异步版本）\n        \"\"\"\n        return await self.__async_invoke_recommend(self._urls[\"show_hot\"],\n                                                   start=start, count=count, _ts=ts)\n\n    def movie_detail(self, subject_id: str):\n        \"\"\"\n        电影详情\n        \"\"\"\n        return self.__invoke_search(self._urls[\"movie_detail\"] + subject_id)\n\n    async def async_movie_detail(self, subject_id: str):\n        \"\"\"\n        电影详情（异步版本）\n        \"\"\"\n        return await self.__async_invoke_search(self._urls[\"movie_detail\"] + subject_id)\n\n    def movie_celebrities(self, subject_id: str):\n        \"\"\"\n        电影演职员\n        \"\"\"\n        return self.__invoke_search(self._urls[\"movie_celebrities\"] % subject_id)\n\n    async def async_movie_celebrities(self, subject_id: str):\n        \"\"\"\n        电影演职员（异步版本）\n        \"\"\"\n        return await self.__async_invoke_search(self._urls[\"movie_celebrities\"] % subject_id)\n\n    def tv_detail(self, subject_id: str):\n        \"\"\"\n        电视剧详情\n        \"\"\"\n        return self.__invoke_search(self._urls[\"tv_detail\"] + subject_id)\n\n    async def async_tv_detail(self, subject_id: str):\n        \"\"\"\n        电视剧详情（异步版本）\n        \"\"\"\n        return await self.__async_invoke_search(self._urls[\"tv_detail\"] + subject_id)\n\n    def tv_celebrities(self, subject_id: str):\n        \"\"\"\n        电视剧演职员\n        \"\"\"\n        return self.__invoke_search(self._urls[\"tv_celebrities\"] % subject_id)\n\n    async def async_tv_celebrities(self, subject_id: str):\n        \"\"\"\n        电视剧演职员（异步版本）\n        \"\"\"\n        return await self.__async_invoke_search(self._urls[\"tv_celebrities\"] % subject_id)\n\n    def book_detail(self, subject_id: str):\n        \"\"\"\n        书籍详情\n        \"\"\"\n        return self.__invoke_search(self._urls[\"book_detail\"] + subject_id)\n\n    async def async_book_detail(self, subject_id: str):\n        \"\"\"\n        书籍详情（异步版本）\n        \"\"\"\n        return await self.__async_invoke_search(self._urls[\"book_detail\"] + subject_id)\n\n    def movie_top250(self, start: Optional[int] = 0, count: Optional[int] = 20,\n                     ts=datetime.strftime(datetime.now(), '%Y%m%d')):\n        \"\"\"\n        电影TOP250\n        \"\"\"\n        return self.__invoke_recommend(self._urls[\"movie_top250\"],\n                                       start=start, count=count, _ts=ts)\n\n    async def async_movie_top250(self, start: Optional[int] = 0, count: Optional[int] = 20,\n                                 ts=datetime.strftime(datetime.now(), '%Y%m%d')):\n        \"\"\"\n        电影TOP250（异步版本）\n        \"\"\"\n        return await self.__async_invoke_recommend(self._urls[\"movie_top250\"],\n                                                   start=start, count=count, _ts=ts)\n\n    def movie_recommend(self, tags='', sort='R', start: Optional[int] = 0, count: Optional[int] = 20,\n                        ts=datetime.strftime(datetime.now(), '%Y%m%d')):\n        \"\"\"\n        电影探索\n        \"\"\"\n        return self.__invoke_recommend(self._urls[\"movie_recommend\"], tags=tags, sort=sort,\n                                       start=start, count=count, _ts=ts)\n\n    async def async_movie_recommend(self, tags='', sort='R', start: Optional[int] = 0, count: Optional[int] = 20,\n                                    ts=datetime.strftime(datetime.now(), '%Y%m%d')):\n        \"\"\"\n        电影探索（异步版本）\n        \"\"\"\n        return await self.__async_invoke_recommend(self._urls[\"movie_recommend\"], tags=tags, sort=sort,\n                                                   start=start, count=count, _ts=ts)\n\n    def tv_recommend(self, tags='', sort='R', start: Optional[int] = 0, count: Optional[int] = 20,\n                     ts=datetime.strftime(datetime.now(), '%Y%m%d')):\n        \"\"\"\n        电视剧探索\n        \"\"\"\n        return self.__invoke_recommend(self._urls[\"tv_recommend\"], tags=tags, sort=sort,\n                                       start=start, count=count, _ts=ts)\n\n    async def async_tv_recommend(self, tags='', sort='R', start: Optional[int] = 0, count: Optional[int] = 20,\n                                 ts=datetime.strftime(datetime.now(), '%Y%m%d')):\n        \"\"\"\n        电视剧探索（异步版本）\n        \"\"\"\n        return await self.__async_invoke_recommend(self._urls[\"tv_recommend\"], tags=tags, sort=sort,\n                                                   start=start, count=count, _ts=ts)\n\n    def tv_chinese_best_weekly(self, start: Optional[int] = 0, count: Optional[int] = 20,\n                               ts=datetime.strftime(datetime.now(), '%Y%m%d')):\n        \"\"\"\n        华语口碑周榜\n        \"\"\"\n        return self.__invoke_recommend(self._urls[\"tv_chinese_best_weekly\"],\n                                       start=start, count=count, _ts=ts)\n\n    async def async_tv_chinese_best_weekly(self, start: Optional[int] = 0, count: Optional[int] = 20,\n                                           ts=datetime.strftime(datetime.now(), '%Y%m%d')):\n        \"\"\"\n        华语口碑周榜（异步版本）\n        \"\"\"\n        return await self.__async_invoke_recommend(self._urls[\"tv_chinese_best_weekly\"],\n                                                   start=start, count=count, _ts=ts)\n\n    def tv_global_best_weekly(self, start: Optional[int] = 0, count: Optional[int] = 20,\n                              ts=datetime.strftime(datetime.now(), '%Y%m%d')):\n        \"\"\"\n        全球口碑周榜\n        \"\"\"\n        return self.__invoke_recommend(self._urls[\"tv_global_best_weekly\"],\n                                       start=start, count=count, _ts=ts)\n\n    async def async_tv_global_best_weekly(self, start: Optional[int] = 0, count: Optional[int] = 20,\n                                          ts=datetime.strftime(datetime.now(), '%Y%m%d')):\n        \"\"\"\n        全球口碑周榜（异步版本）\n        \"\"\"\n        return await self.__async_invoke_recommend(self._urls[\"tv_global_best_weekly\"],\n                                                   start=start, count=count, _ts=ts)\n\n    def doulist_detail(self, subject_id: str):\n        \"\"\"\n        豆列详情\n        :param subject_id: 豆列id\n        \"\"\"\n        return self.__invoke_search(self._urls[\"doulist\"] + subject_id)\n\n    async def async_doulist_detail(self, subject_id: str):\n        \"\"\"\n        豆列详情（异步版本）\n        :param subject_id: 豆列id\n        \"\"\"\n        return await self.__async_invoke_search(self._urls[\"doulist\"] + subject_id)\n\n    def doulist_items(self, subject_id: str, start: Optional[int] = 0, count: Optional[int] = 20,\n                      ts=datetime.strftime(datetime.now(), '%Y%m%d')):\n        \"\"\"\n        豆列列表\n        :param subject_id: 豆列id\n        :param start: 开始\n        :param count: 数量\n        :param ts: 时间戳\n        \"\"\"\n        return self.__invoke_search(self._urls[\"doulist_items\"] % subject_id,\n                                    start=start, count=count, _ts=ts)\n\n    async def async_doulist_items(self, subject_id: str, start: Optional[int] = 0, count: Optional[int] = 20,\n                                  ts=datetime.strftime(datetime.now(), '%Y%m%d')):\n        \"\"\"\n        豆列列表（异步版本）\n        :param subject_id: 豆列id\n        :param start: 开始\n        :param count: 数量\n        :param ts: 时间戳\n        \"\"\"\n        return await self.__async_invoke_search(self._urls[\"doulist_items\"] % subject_id,\n                                                start=start, count=count, _ts=ts)\n\n    def movie_recommendations(self, subject_id: str, start: Optional[int] = 0, count: Optional[int] = 20,\n                              ts=datetime.strftime(datetime.now(), '%Y%m%d')):\n        \"\"\"\n        电影推荐\n        :param subject_id: 电影id\n        :param start: 开始\n        :param count: 数量\n        :param ts: 时间戳\n        \"\"\"\n        return self.__invoke_recommend(self._urls[\"movie_recommendations\"] % subject_id,\n                                       start=start, count=count, _ts=ts)\n\n    async def async_movie_recommendations(self, subject_id: str, start: Optional[int] = 0, count: Optional[int] = 20,\n                                          ts=datetime.strftime(datetime.now(), '%Y%m%d')):\n        \"\"\"\n        电影推荐（异步版本）\n        :param subject_id: 电影id\n        :param start: 开始\n        :param count: 数量\n        :param ts: 时间戳\n        \"\"\"\n        return await self.__async_invoke_recommend(self._urls[\"movie_recommendations\"] % subject_id,\n                                                   start=start, count=count, _ts=ts)\n\n    def tv_recommendations(self, subject_id: str, start: Optional[int] = 0, count: Optional[int] = 20,\n                           ts=datetime.strftime(datetime.now(), '%Y%m%d')):\n        \"\"\"\n        电视剧推荐\n        :param subject_id: 电视剧id\n        :param start: 开始\n        :param count: 数量\n        :param ts: 时间戳\n        \"\"\"\n        return self.__invoke_recommend(self._urls[\"tv_recommendations\"] % subject_id,\n                                       start=start, count=count, _ts=ts)\n\n    async def async_tv_recommendations(self, subject_id: str, start: Optional[int] = 0, count: Optional[int] = 20,\n                                       ts=datetime.strftime(datetime.now(), '%Y%m%d')):\n        \"\"\"\n        电视剧推荐（异步版本）\n        :param subject_id: 电视剧id\n        :param start: 开始\n        :param count: 数量\n        :param ts: 时间戳\n        \"\"\"\n        return await self.__async_invoke_recommend(self._urls[\"tv_recommendations\"] % subject_id,\n                                                   start=start, count=count, _ts=ts)\n\n    def movie_photos(self, subject_id: str, start: Optional[int] = 0, count: Optional[int] = 20,\n                     ts=datetime.strftime(datetime.now(), '%Y%m%d')):\n        \"\"\"\n        电影剧照\n        :param subject_id: 电影id\n        :param start: 开始\n        :param count: 数量\n        :param ts: 时间戳\n        \"\"\"\n        return self.__invoke_search(self._urls[\"movie_photos\"] % subject_id,\n                                    start=start, count=count, _ts=ts)\n\n    async def async_movie_photos(self, subject_id: str, start: Optional[int] = 0, count: Optional[int] = 20,\n                                 ts=datetime.strftime(datetime.now(), '%Y%m%d')):\n        \"\"\"\n        电影剧照（异步版本）\n        :param subject_id: 电影id\n        :param start: 开始\n        :param count: 数量\n        :param ts: 时间戳\n        \"\"\"\n        return await self.__async_invoke_search(self._urls[\"movie_photos\"] % subject_id,\n                                                start=start, count=count, _ts=ts)\n\n    def tv_photos(self, subject_id: str, start: Optional[int] = 0, count: Optional[int] = 20,\n                  ts=datetime.strftime(datetime.now(), '%Y%m%d')):\n        \"\"\"\n        电视剧剧照\n        :param subject_id: 电视剧id\n        :param start: 开始\n        :param count: 数量\n        :param ts: 时间戳\n        \"\"\"\n        return self.__invoke_search(self._urls[\"tv_photos\"] % subject_id,\n                                    start=start, count=count, _ts=ts)\n\n    async def async_tv_photos(self, subject_id: str, start: Optional[int] = 0, count: Optional[int] = 20,\n                              ts=datetime.strftime(datetime.now(), '%Y%m%d')):\n        \"\"\"\n        电视剧剧照（异步版本）\n        :param subject_id: 电视剧id\n        :param start: 开始\n        :param count: 数量\n        :param ts: 时间戳\n        \"\"\"\n        return await self.__async_invoke_search(self._urls[\"tv_photos\"] % subject_id,\n                                                start=start, count=count, _ts=ts)\n\n    def person_detail(self, subject_id: int):\n        \"\"\"\n        用户详情\n        :param subject_id: 人物 id\n        :return:\n        \"\"\"\n        return self.__invoke_search(self._urls[\"person_detail\"] + str(subject_id))\n\n    async def async_person_detail(self, subject_id: int):\n        \"\"\"\n        用户详情（异步版本）\n        :param subject_id: 人物 id\n        :return:\n        \"\"\"\n        return await self.__async_invoke_search(self._urls[\"person_detail\"] + str(subject_id))\n\n    def person_work(self, subject_id: int, start: Optional[int] = 0, count: Optional[int] = 20,\n                    sort_by: Optional[str] = \"time\",\n                    collection_title: Optional[str] = \"影视\",\n                    ts=datetime.strftime(datetime.now(), '%Y%m%d')):\n        \"\"\"\n        用户作品集\n        :param subject_id: work_collection id\n        :param start: 开始页\n        :param count: 数量\n        :param sort_by: collection or time or vote\n        :param collection_title: 影视 or 图书 or 音乐\n        :param ts: 时间戳\n        :return:\n        \"\"\"\n        return self.__invoke_search(self._urls[\"person_work\"] % subject_id, sortby=sort_by,\n                                    collection_title=collection_title,\n                                    start=start, count=count, _ts=ts)\n\n    async def async_person_work(self, subject_id: int, start: Optional[int] = 0, count: Optional[int] = 20,\n                                sort_by: Optional[str] = \"time\",\n                                collection_title: Optional[str] = \"影视\",\n                                ts=datetime.strftime(datetime.now(), '%Y%m%d')):\n        \"\"\"\n        用户作品集（异步版本）\n        :param subject_id: work_collection id\n        :param start: 开始页\n        :param count: 数量\n        :param sort_by: collection or time or vote\n        :param collection_title: 影视 or 图书 or 音乐\n        :param ts: 时间戳\n        :return:\n        \"\"\"\n        return await self.__async_invoke_search(self._urls[\"person_work\"] % subject_id, sortby=sort_by,\n                                                collection_title=collection_title,\n                                                start=start, count=count, _ts=ts)\n\n    def clear_cache(self):\n        \"\"\"\n        清空LRU缓存\n        \"\"\"\n        self.__invoke.cache_clear()\n        self.__post.cache_clear()\n\n    def close(self):\n        if self._session:\n            self._session.close()\n"
  },
  {
    "path": "app/modules/douban/douban_cache.py",
    "content": "import pickle\nimport traceback\nfrom pathlib import Path\nfrom threading import RLock\nfrom typing import Optional\n\nfrom app.core.cache import TTLCache\nfrom app.core.config import settings\nfrom app.core.meta import MetaBase\nfrom app.core.metainfo import MetaInfo\nfrom app.log import logger\nfrom app.schemas.types import MediaType\nfrom app.utils.singleton import WeakSingleton\n\nlock = RLock()\n\n\nclass DoubanCache(metaclass=WeakSingleton):\n    \"\"\"\n    豆瓣缓存数据\n    {\n        \"id\": '',\n        \"title\": '',\n        \"year\": '',\n        \"type\": MediaType\n    }\n    \"\"\"\n    # TMDB缓存过期\n    _douban_cache_expire: bool = True\n\n    def __init__(self):\n        self.maxsize = settings.CONF.douban\n        self.ttl = settings.CONF.meta\n        self.region = \"__douban_cache__\"\n        self._meta_filepath = settings.TEMP_PATH / self.region\n        # 初始化缓存\n        self._cache = TTLCache(region=self.region, maxsize=self.maxsize, ttl=self.ttl)\n        # 非Redis加载本地缓存数据\n        if not self._cache.is_redis():\n            for key, value in self.__load(self._meta_filepath).items():\n                self._cache.set(key, value)\n\n    def clear(self):\n        \"\"\"\n        清空所有豆瓣缓存\n        \"\"\"\n        with lock:\n            self._cache.clear()\n\n    @staticmethod\n    def __get_key(meta: MetaBase) -> str:\n        \"\"\"\n        获取缓存KEY\n        \"\"\"\n        return f\"[{meta.type.value if meta.type else '未知'}]\" \\\n               f\"{meta.doubanid or meta.name}-{meta.year}-{meta.begin_season}\"\n\n    def get(self, meta: MetaBase):\n        \"\"\"\n        根据KEY值获取缓存值\n        \"\"\"\n        key = self.__get_key(meta)\n        with lock:\n            return self._cache.get(key) or {}\n\n    def delete(self, key: str) -> dict:\n        \"\"\"\n        删除缓存信息\n        @param key: 缓存key\n        @return: 被删除的缓存内容\n        \"\"\"\n        with lock:\n            redis_data = self._cache.get(key)\n            if redis_data:\n                self._cache.delete(key)\n                return redis_data\n            return {}\n\n    def modify(self, key: str, title: str) -> dict:\n        \"\"\"\n        修改缓存信息\n        @param key: 缓存key\n        @param title: 标题\n        @return: 被修改后缓存内容\n        \"\"\"\n        with lock:\n            redis_data = self._cache.get(key)\n            if redis_data:\n                redis_data[\"title\"] = title\n                self._cache.set(key, redis_data)\n                return redis_data\n            return {}\n\n    @staticmethod\n    def __load(path: Path) -> dict:\n        \"\"\"\n        从文件中加载缓存\n        \"\"\"\n        try:\n            if path.exists():\n                with open(path, 'rb') as f:\n                    data = pickle.load(f)\n                return data\n        except Exception as e:\n            logger.error(f\"加载缓存失败: {str(e)} - {traceback.format_exc()}\")\n        return {}\n\n    def update(self, meta: MetaBase, info: dict) -> None:\n        \"\"\"\n        新增或更新缓存条目\n        \"\"\"\n        if info:\n            # 缓存标题\n            cache_title = info.get(\"title\")\n            # 缓存年份\n            cache_year = info.get('year')\n            # 类型\n            if isinstance(info.get('media_type'), MediaType):\n                mtype = info.get('media_type')\n            elif info.get(\"type\"):\n                mtype = MediaType.MOVIE if info.get(\"type\") == \"movie\" else MediaType.TV\n            else:\n                meta = MetaInfo(cache_title)\n                if meta.begin_season:\n                    mtype = MediaType.TV\n                else:\n                    mtype = MediaType.MOVIE\n            # 海报\n            poster_path = info.get(\"pic\", {}).get(\"large\")\n            if not poster_path and info.get(\"cover_url\"):\n                poster_path = info.get(\"cover_url\")\n            if not poster_path and info.get(\"cover\"):\n                poster_path = info.get(\"cover\").get(\"url\")\n\n            with lock:\n                self._cache.set(self.__get_key(meta), {\n                    \"id\": info.get(\"id\"),\n                    \"type\": mtype,\n                    \"year\": cache_year,\n                    \"title\": cache_title,\n                    \"poster_path\": poster_path\n                })\n\n        elif info is not None:\n            # None时不缓存，此时代表网络错误，允许重复请求\n            with lock:\n                self._cache.set(self.__get_key(meta), {\n                    \"id\": 0\n                })\n\n    def save(self, force: Optional[bool] = False) -> None:\n        \"\"\"\n        保存缓存数据到文件\n        \"\"\"\n        # Redis不需要保存到本地文件\n        if self._cache.is_redis():\n            return\n\n        # 本地文件\n        meta_data = self.__load(self._meta_filepath)\n        # 当前缓存数据（去除无法识别）\n        new_meta_data = {k: v for k, v in self._cache.items() if v.get(\"id\")}\n\n        if not force \\\n                and meta_data.keys() == new_meta_data.keys():\n            return\n        # 写入本地\n        with open(self._meta_filepath, 'wb') as f:\n            pickle.dump(new_meta_data, f, pickle.HIGHEST_PROTOCOL)  # noqa\n\n    def __del__(self):\n        self.save()\n"
  },
  {
    "path": "app/modules/douban/scraper.py",
    "content": "from pathlib import Path\nfrom typing import Optional\nfrom xml.dom import minidom\n\nfrom app.core.context import MediaInfo\nfrom app.schemas.types import MediaType\nfrom app.utils.dom import DomUtils\n\n\nclass DoubanScraper:\n    _force_nfo = False\n    _force_img = False\n\n    def get_metadata_nfo(self, mediainfo: MediaInfo, season: Optional[int] = None) -> Optional[str]:\n        \"\"\"\n        获取NFO文件内容文本\n        :param mediainfo: 媒体信息\n        :param season: 季号\n        \"\"\"\n        if mediainfo.type == MediaType.MOVIE:\n            # 电影元数据文件\n            doc = self.__gen_movie_nfo_file(mediainfo=mediainfo)\n        else:\n            if season is not None:\n                # 季元数据文件\n                doc = self.__gen_tv_season_nfo_file(mediainfo=mediainfo, season=season)\n            else:\n                # 电视剧元数据文件\n                doc = self.__gen_tv_nfo_file(mediainfo=mediainfo)\n        if doc:\n            return doc.toprettyxml(indent=\"  \", encoding=\"utf-8\") # noqa\n\n        return None\n\n    @staticmethod\n    def get_metadata_img(mediainfo: MediaInfo, season: Optional[int] = None, episode: Optional[int] = None) -> Optional[dict]:\n        \"\"\"\n        获取图片内容\n        :param mediainfo: 媒体信息\n        :param season: 季号\n        :param episode: 集号\n        \"\"\"\n        ret_dict = {}\n        if season is not None:\n            # 豆瓣无季图片\n            return {}\n        if episode:\n            # 豆瓣无集图片\n            return {}\n        if mediainfo.poster_path:\n            ret_dict[f\"poster{Path(mediainfo.poster_path).suffix}\"] = mediainfo.poster_path\n        if mediainfo.backdrop_path:\n            ret_dict[f\"backdrop{Path(mediainfo.backdrop_path).suffix}\"] = mediainfo.backdrop_path\n        return ret_dict\n\n    @staticmethod\n    def __gen_common_nfo(mediainfo: MediaInfo, doc: minidom.Document, root: minidom.Node):\n        # 简介\n        xplot = DomUtils.add_node(doc, root, \"plot\")\n        xplot.appendChild(doc.createCDATASection(mediainfo.overview or \"\"))\n        xoutline = DomUtils.add_node(doc, root, \"outline\")\n        xoutline.appendChild(doc.createCDATASection(mediainfo.overview or \"\"))\n        # 导演\n        for director in mediainfo.directors:\n            DomUtils.add_node(doc, root, \"director\", director.get(\"name\") or \"\")\n        # 演员\n        for actor in mediainfo.actors:\n            xactor = DomUtils.add_node(doc, root, \"actor\")\n            DomUtils.add_node(doc, xactor, \"name\", actor.get(\"name\") or \"\")\n            DomUtils.add_node(doc, xactor, \"type\", \"Actor\")\n            DomUtils.add_node(doc, xactor, \"role\", actor.get(\"character\") or actor.get(\"role\") or \"\")\n            DomUtils.add_node(doc, xactor, \"thumb\", actor.get('avatar', {}).get('normal'))\n            DomUtils.add_node(doc, xactor, \"profile\", actor.get('url'))\n        # 评分\n        DomUtils.add_node(doc, root, \"rating\", mediainfo.vote_average or \"0\")\n\n        return doc\n\n    def __gen_movie_nfo_file(self, mediainfo: MediaInfo) -> minidom.Document:\n        \"\"\"\n        生成电影的NFO描述文件\n        :param mediainfo: 豆瓣信息\n        \"\"\"\n        # 开始生成XML\n        doc = minidom.Document()\n        root = DomUtils.add_node(doc, doc, \"movie\")\n        # 公共部分\n        doc = self.__gen_common_nfo(mediainfo=mediainfo,\n                                    doc=doc,\n                                    root=root)\n        # 标题\n        DomUtils.add_node(doc, root, \"title\", mediainfo.title or \"\")\n        # 年份\n        DomUtils.add_node(doc, root, \"year\", mediainfo.year or \"\")\n\n        return doc\n\n    def __gen_tv_nfo_file(self, mediainfo: MediaInfo) -> minidom.Document:\n        \"\"\"\n        生成电视剧的NFO描述文件\n        :param mediainfo: 媒体信息\n        \"\"\"\n        # 开始生成XML\n        doc = minidom.Document()\n        root = DomUtils.add_node(doc, doc, \"tvshow\")\n        # 公共部分\n        doc = self.__gen_common_nfo(mediainfo=mediainfo,\n                                    doc=doc,\n                                    root=root)\n        # 标题\n        DomUtils.add_node(doc, root, \"title\", mediainfo.title or \"\")\n        # 年份\n        DomUtils.add_node(doc, root, \"year\", mediainfo.year or \"\")\n        DomUtils.add_node(doc, root, \"season\", \"-1\")\n        DomUtils.add_node(doc, root, \"episode\", \"-1\")\n\n        return doc\n\n    @staticmethod\n    def __gen_tv_season_nfo_file(mediainfo: MediaInfo,\n                                 season: int) -> minidom.Document:\n        \"\"\"\n        生成电视剧季的NFO描述文件\n        :param mediainfo: 媒体信息\n        :param season: 季号\n        \"\"\"\n        doc = minidom.Document()\n        root = DomUtils.add_node(doc, doc, \"season\")\n        # 简介\n        xplot = DomUtils.add_node(doc, root, \"plot\")\n        xplot.appendChild(doc.createCDATASection(mediainfo.overview or \"\"))\n        xoutline = DomUtils.add_node(doc, root, \"outline\")\n        xoutline.appendChild(doc.createCDATASection(mediainfo.overview or \"\"))\n        # 标题\n        DomUtils.add_node(doc, root, \"title\", \"季 %s\" % season)\n        # 发行日期\n        DomUtils.add_node(doc, root, \"premiered\", mediainfo.release_date or \"\")\n        DomUtils.add_node(doc, root, \"releasedate\", mediainfo.release_date or \"\")\n        # 发行年份\n        DomUtils.add_node(doc, root, \"year\", mediainfo.release_date[:4] if mediainfo.release_date else \"\")\n        # seasonnumber\n        DomUtils.add_node(doc, root, \"seasonnumber\", str(season))\n\n        return doc\n"
  },
  {
    "path": "app/modules/emby/__init__.py",
    "content": "from typing import Any, Generator, List, Optional, Tuple, Union\n\nfrom app import schemas\nfrom app.core.context import MediaInfo\nfrom app.core.event import eventmanager\nfrom app.log import logger\nfrom app.modules import _MediaServerBase, _ModuleBase\nfrom app.modules.emby.emby import Emby\nfrom app.schemas.types import MediaType, ModuleType, ChainEventType, MediaServerType\n\n\nclass EmbyModule(_ModuleBase, _MediaServerBase[Emby]):\n\n    def init_module(self) -> None:\n        \"\"\"\n        初始化模块\n        \"\"\"\n        super().init_service(service_name=Emby.__name__.lower(),\n                             service_type=lambda conf: Emby(**conf.config, sync_libraries=conf.sync_libraries))\n\n    @staticmethod\n    def get_name() -> str:\n        return \"Emby\"\n\n    @staticmethod\n    def get_type() -> ModuleType:\n        \"\"\"\n        获取模块类型\n        \"\"\"\n        return ModuleType.MediaServer\n\n    @staticmethod\n    def get_subtype() -> MediaServerType:\n        \"\"\"\n        获取模块子类型\n        \"\"\"\n        return MediaServerType.Emby\n\n    @staticmethod\n    def get_priority() -> int:\n        \"\"\"\n        获取模块优先级，数字越小优先级越高，只有同一接口下优先级才生效\n        \"\"\"\n        return 1\n\n    def stop(self):\n        pass\n\n    def test(self) -> Optional[Tuple[bool, str]]:\n        \"\"\"\n        测试模块连接性\n        \"\"\"\n        if not self.get_instances():\n            return None\n        for name, server in self.get_instances().items():\n            if server.is_inactive():\n                server.reconnect()\n            if not server.get_user():\n                return False, f\"无法连接Emby服务器：{name}\"\n        return True, \"\"\n\n    def init_setting(self) -> Tuple[str, Union[str, bool]]:\n        pass\n\n    def scheduler_job(self) -> None:\n        \"\"\"\n        定时任务，每10分钟调用一次\n        \"\"\"\n        # 定时重连\n        for name, server in self.get_instances().items():\n            if server.is_inactive():\n                logger.info(f\"Emby服务器 {name} 连接断开，尝试重连 ...\")\n                server.reconnect()\n\n    def user_authenticate(self, credentials: schemas.AuthCredentials, service_name: Optional[str] = None) \\\n            -> Optional[schemas.AuthCredentials]:\n        \"\"\"\n        使用Emby用户辅助完成用户认证\n        :param credentials: 认证数据\n        :param service_name: 指定要认证的媒体服务器名称，若为 None 则认证所有服务\n        :return: 认证数据\n        \"\"\"\n        # Emby认证\n        if not credentials or credentials.grant_type != \"password\":\n            return None\n        # 确定要认证的服务器列表\n        if service_name:\n            # 如果指定了服务名，获取该服务实例\n            servers = [(service_name, server)] if (server := self.get_instance(service_name)) else []\n        else:\n            # 如果没有指定服务名，遍历所有服务\n            servers = self.get_instances().items()\n        # 遍历要认证的服务器\n        for name, server in servers:\n            # 触发认证拦截事件\n            intercept_event = eventmanager.send_event(\n                etype=ChainEventType.AuthIntercept,\n                data=schemas.AuthInterceptCredentials(username=credentials.username, channel=self.get_name(),\n                                                      service=name, status=\"triggered\")\n            )\n            if intercept_event and intercept_event.event_data:\n                intercept_data: schemas.AuthInterceptCredentials = intercept_event.event_data\n                if intercept_data.cancel:\n                    continue\n            token = server.authenticate(credentials.username, credentials.password)\n            if token:\n                credentials.channel = self.get_name()\n                credentials.service = name\n                credentials.token = token\n                return credentials\n        return None\n\n    def webhook_parser(self, body: Any, form: Any, args: Any) -> Optional[schemas.WebhookEventInfo]:\n        \"\"\"\n        解析Webhook报文体\n        :param body:  请求体\n        :param form:  请求表单\n        :param args:  请求参数\n        :return: 字典，解析为消息时需要包含：title、text、image\n        \"\"\"\n        source = args.get(\"source\")\n        if source:\n            server: Emby = self.get_instance(source)\n            if not server:\n                return None\n            result = server.get_webhook_message(form, args)\n            if result:\n                result.server_name = source\n            return result\n\n        for server in self.get_instances().values():\n            if server:\n                result = server.get_webhook_message(form, args)\n                if result:\n                    return result\n        return None\n\n    def media_exists(self, mediainfo: MediaInfo, itemid: Optional[str] = None,\n                     server: Optional[str] = None) -> Optional[schemas.ExistMediaInfo]:\n        \"\"\"\n        判断媒体文件是否存在\n        :param mediainfo:  识别的媒体信息\n        :param itemid:  媒体服务器ItemID\n        :param server:  媒体服务器名称\n        :return: 如不存在返回None，存在时返回信息，包括每季已存在所有集{type: movie/tv, seasons: {season: [episodes]}}\n        \"\"\"\n        if server:\n            servers = [(server, self.get_instance(server))]\n        else:\n            servers = self.get_instances().items()\n        for name, s in servers:\n            if not s:\n                continue\n            if mediainfo.type == MediaType.MOVIE:\n                if itemid:\n                    movie = s.get_iteminfo(itemid)\n                    if movie:\n                        logger.info(f\"媒体库 {name} 中找到了 {movie}\")\n                        return schemas.ExistMediaInfo(\n                            type=MediaType.MOVIE,\n                            server_type=\"emby\",\n                            server=name,\n                            itemid=movie.item_id\n                        )\n                movies = s.get_movies(title=mediainfo.title,\n                                      year=mediainfo.year,\n                                      tmdb_id=mediainfo.tmdb_id)\n                if not movies:\n                    logger.info(f\"{mediainfo.title_year} 没有在媒体库 {name} 中\")\n                    continue\n                else:\n                    logger.info(f\"媒体库 {name} 中找到了 {movies}\")\n                    return schemas.ExistMediaInfo(\n                        type=MediaType.MOVIE,\n                        server_type=\"emby\",\n                        server=name,\n                        itemid=movies[0].item_id\n                    )\n            else:\n                itemid, tvs = s.get_tv_episodes(title=mediainfo.title,\n                                                year=mediainfo.year,\n                                                tmdb_id=mediainfo.tmdb_id,\n                                                item_id=itemid)\n                if not tvs:\n                    logger.info(f\"{mediainfo.title_year} 没有在媒体库 {name} 中\")\n                    continue\n                else:\n                    logger.info(f\"{mediainfo.title_year} 在媒体库 {name} 中找到了这些季集：{tvs}\")\n                    return schemas.ExistMediaInfo(\n                        type=MediaType.TV,\n                        seasons=tvs,\n                        server_type=\"emby\",\n                        server=name,\n                        itemid=itemid\n                    )\n        return None\n\n    def media_statistic(self, server: Optional[str] = None) -> Optional[List[schemas.Statistic]]:\n        \"\"\"\n        媒体数量统计\n        \"\"\"\n        if server:\n            server_obj: Emby = self.get_instance(server)\n            if not server_obj:\n                return None\n            servers = [server_obj]\n        else:\n            servers = self.get_instances().values()\n        media_statistics = []\n        for s in servers:\n            media_statistic = s.get_medias_count()\n            if not media_statistic:\n                continue\n            media_statistic.user_count = s.get_user_count()\n            media_statistics.append(media_statistic)\n        return media_statistics\n\n    def mediaserver_librarys(self, server: str,\n                             username: Optional[str] = None,\n                             hidden: Optional[bool] = False) -> Optional[List[schemas.MediaServerLibrary]]:\n        \"\"\"\n        媒体库列表\n        \"\"\"\n        server_obj: Emby = self.get_instance(server)\n        if server_obj:\n            return server_obj.get_librarys(username=username, hidden=hidden)\n        return None\n\n    def mediaserver_items(self, server: str, library_id: Union[str, int], start_index: Optional[int] = 0,\n                          limit: Optional[int] = -1) -> Optional[Generator]:\n        \"\"\"\n        获取媒体服务器项目列表，支持分页和不分页逻辑，默认不分页获取所有数据\n\n        :param server: 媒体服务器名称\n        :param library_id: 媒体库ID，用于标识要获取的媒体库\n        :param start_index: 起始索引，用于分页获取数据。默认为 0，即从第一个项目开始获取\n        :param limit: 每次请求的最大项目数，用于分页。如果为 None 或 -1，则表示一次性获取所有数据，默认为 -1\n\n        :return: 返回一个生成器对象，用于逐步获取媒体服务器中的项目\n        \"\"\"\n        server_obj: Emby = self.get_instance(server)\n        if server_obj:\n            return server_obj.get_items(library_id, start_index, limit)\n        return None\n\n    def mediaserver_iteminfo(self, server: str, item_id: str) -> Optional[schemas.MediaServerItem]:\n        \"\"\"\n        媒体库项目详情\n        \"\"\"\n        server_obj: Emby = self.get_instance(server)\n        if server_obj:\n            return server_obj.get_iteminfo(item_id)\n        return None\n\n    def mediaserver_tv_episodes(self, server: str,\n                                item_id: Union[str, int]) -> Optional[List[schemas.MediaServerSeasonInfo]]:\n        \"\"\"\n        获取剧集信息\n        \"\"\"\n        server_obj: Emby = self.get_instance(server)\n        if not server_obj:\n            return None\n        _, seasoninfo = server_obj.get_tv_episodes(item_id=item_id)\n        if not seasoninfo:\n            return []\n        return [schemas.MediaServerSeasonInfo(\n            season=season,\n            episodes=episodes\n        ) for season, episodes in seasoninfo.items()]\n\n    def mediaserver_playing(self, server: str, count: Optional[int] = 20,\n                            username: Optional[str] = None) -> List[schemas.MediaServerPlayItem]:\n        \"\"\"\n        获取媒体服务器正在播放信息\n        \"\"\"\n        server_obj: Emby = self.get_instance(server)\n        if not server_obj:\n            return []\n        return server_obj.get_resume(num=count, username=username)\n\n    def mediaserver_play_url(self, server: str, item_id: Union[str, int]) -> Optional[str]:\n        \"\"\"\n        获取媒体库播放地址\n        \"\"\"\n        server_obj: Emby = self.get_instance(server)\n        if not server_obj:\n            return None\n        return server_obj.get_play_url(item_id)\n\n    def mediaserver_latest(self, server: Optional[str] = None, count: Optional[int] = 20,\n                           username: Optional[str] = None) -> List[schemas.MediaServerPlayItem]:\n        \"\"\"\n        获取媒体服务器最新入库条目\n        \"\"\"\n        server_obj: Emby = self.get_instance(server)\n        if not server_obj:\n            return []\n        return server_obj.get_latest(num=count, username=username)\n\n    def mediaserver_latest_images(self,\n                                  server: Optional[str] = None,\n                                  count: Optional[int] = 10,\n                                  username: Optional[str] = None,\n                                  remote: Optional[bool] = False\n                                  ) -> List[str]:\n        \"\"\"\n        获取媒体服务器最新入库条目的图片\n\n        :param server: 媒体服务器名称\n        :param count: 获取数量\n        :param username: 用户名\n        :param remote: True为外网链接, False为内网链接\n        :return: 图片链接列表\n        \"\"\"\n        server_obj: Emby = self.get_instance(server)\n        if not server_obj:\n            return []\n\n        links = []\n        items: List[schemas.MediaServerPlayItem] = self.mediaserver_latest(server=server, count=count,\n                                                                           username=username)\n        for item in items:\n            if item.BackdropImageTags:\n                image_url = server_obj.get_backdrop_url(item_id=item.id,\n                                                        image_tag=item.BackdropImageTags[0],\n                                                        remote=remote)\n                if image_url:\n                    links.append(image_url)\n        return links\n"
  },
  {
    "path": "app/modules/emby/emby.py",
    "content": "import json\nimport re\nimport traceback\nfrom datetime import datetime\nfrom pathlib import Path\nfrom typing import List, Optional, Union, Dict, Generator, Tuple, Any\n\nfrom requests import Response\n\nfrom app import schemas\nfrom app.core.config import settings\nfrom app.log import logger\nfrom app.schemas import MediaServerItem\nfrom app.schemas.types import MediaType\nfrom app.utils.http import RequestUtils\nfrom app.utils.url import UrlUtils\n\n\nclass Emby:\n    _host: Optional[str] = None\n    _playhost: Optional[str] = None\n    _apikey: Optional[str] = None\n    _sync_libraries: List[str] = []\n    user: Optional[Union[str, int]] = None\n    _username: Optional[str] = None\n\n    def __init__(self, host: Optional[str] = None, apikey: Optional[str] = None, play_host: Optional[str] = None,\n                 username: Optional[str] = None, sync_libraries: list = None, **kwargs):\n        if not host or not apikey:\n            logger.error(\"Emby服务器配置不完整！\")\n            return\n        self._host = host\n        if self._host:\n            self._host = UrlUtils.standardize_base_url(self._host)\n        self._playhost = play_host\n        if self._playhost:\n            self._playhost = UrlUtils.standardize_base_url(self._playhost)\n        self._apikey = apikey\n        self._username = username\n        self.user = self.get_user(username or settings.SUPERUSER)\n        self.folders = self.get_emby_folders()\n        self.serverid = self.get_server_id()\n        self._sync_libraries = sync_libraries or []\n\n    def is_inactive(self) -> bool:\n        \"\"\"\n        判断是否需要重连\n        \"\"\"\n        if not self._host or not self._apikey:\n            return False\n        return True if not self.user else False\n\n    def reconnect(self):\n        \"\"\"\n        重连\n        \"\"\"\n        self.user = self.get_user()\n        self.folders = self.get_emby_folders()\n\n    def get_emby_folders(self) -> List[dict]:\n        \"\"\"\n        获取Emby媒体库路径列表\n        \"\"\"\n        if not self._host or not self._apikey:\n            return []\n        url = f\"{self._host}emby/Library/SelectableMediaFolders\"\n        params = {\n            'api_key': self._apikey\n        }\n        try:\n            res = RequestUtils().get_res(url, params)\n            if res:\n                return res.json()\n            else:\n                logger.error(f\"Library/SelectableMediaFolders 未获取到返回数据\")\n                return []\n        except Exception as e:\n            logger.error(f\"连接Library/SelectableMediaFolders 出错：\" + str(e))\n            return []\n\n    def get_emby_virtual_folders(self) -> List[dict]:\n        \"\"\"\n        获取Emby媒体库所有路径列表（包含共享路径）\n        \"\"\"\n        if not self._host or not self._apikey:\n            return []\n        url = f\"{self._host}emby/Library/VirtualFolders/Query\"\n        params = {\n            'api_key': self._apikey\n        }\n        try:\n            res = RequestUtils().get_res(url, params)\n            if res:\n                library_items = res.json().get(\"Items\")\n                librarys = []\n                for library_item in library_items:\n                    library_id = library_item.get('ItemId')\n                    library_name = library_item.get('Name')\n                    pathInfos = library_item.get('LibraryOptions', {}).get('PathInfos')\n                    library_paths = []\n                    for path in pathInfos:\n                        if path.get('NetworkPath'):\n                            library_paths.append(path.get('NetworkPath'))\n                        else:\n                            library_paths.append(path.get('Path'))\n\n                    if library_name and library_paths:\n                        librarys.append({\n                            'Id': library_id,\n                            'Name': library_name,\n                            'Path': library_paths\n                        })\n                return librarys\n            else:\n                logger.error(f\"Library/VirtualFolders/Query 未获取到返回数据\")\n                return []\n        except Exception as e:\n            logger.error(f\"连接Library/VirtualFolders/Query 出错：\" + str(e))\n            return []\n\n    def __get_emby_librarys(self, username: Optional[str] = None) -> List[dict]:\n        \"\"\"\n        获取Emby媒体库列表\n        \"\"\"\n        if not self._host or not self._apikey:\n            return []\n        if username:\n            user = self.get_user(username)\n        else:\n            user = self.user\n        url = f\"{self._host}emby/Users/{user}/Views\"\n        params = {\"api_key\": self._apikey}\n        try:\n            res = RequestUtils().get_res(url, params)\n            if res:\n                return res.json().get(\"Items\")\n            else:\n                logger.error(f\"User/Views 未获取到返回数据\")\n                return []\n        except Exception as e:\n            logger.error(f\"连接User/Views 出错：\" + str(e))\n            return []\n\n    def get_librarys(self, username: Optional[str] = None, hidden: Optional[bool] = False) -> List[\n        schemas.MediaServerLibrary]:\n        \"\"\"\n        获取媒体服务器所有媒体库列表\n        \"\"\"\n        if not self._host or not self._apikey:\n            return []\n        libraries = []\n        for library in self.__get_emby_librarys(username) or []:\n            if hidden and self._sync_libraries and \"all\" not in self._sync_libraries \\\n                    and library.get(\"Id\") not in self._sync_libraries:\n                continue\n            if library.get(\"CollectionType\") == \"movies\":\n                library_type = MediaType.MOVIE.value\n            elif library.get(\"CollectionType\") == \"tvshows\":\n                library_type = MediaType.TV.value\n            else:\n                library_type = MediaType.UNKNOWN.value\n            image = self.__get_local_image_by_id(library.get(\"Id\"))\n            libraries.append(\n                schemas.MediaServerLibrary(\n                    server=\"emby\",\n                    id=library.get(\"Id\"),\n                    name=library.get(\"Name\"),\n                    path=library.get(\"Path\"),\n                    type=library_type,\n                    image=image,\n                    link=f'{self._playhost or self._host}web/index.html'\n                         f'#!/videos?serverId={self.serverid}&parentId={library.get(\"Id\")}',\n                    server_type=\"emby\"\n                )\n            )\n        return libraries\n\n    def get_user(self, user_name: Optional[str] = None) -> Optional[Union[str, int]]:\n        \"\"\"\n        获得管理员用户\n        \"\"\"\n        if not self._host or not self._apikey:\n            return None\n        url = f\"{self._host}Users\"\n        params = {\n            \"api_key\": self._apikey\n        }\n        try:\n            res = RequestUtils().get_res(url, params)\n            if res:\n                users = res.json()\n                # 先查询是否有与当前用户名称匹配的\n                if user_name:\n                    for user in users:\n                        if user.get(\"Name\") == user_name:\n                            return user.get(\"Id\")\n                # 查询管理员\n                for user in users:\n                    if user.get(\"Policy\", {}).get(\"IsAdministrator\"):\n                        return user.get(\"Id\")\n            else:\n                logger.error(f\"Users 未获取到返回数据\")\n        except Exception as e:\n            logger.error(f\"连接Users出错：\" + str(e))\n        return None\n\n    def authenticate(self, username: str, password: str) -> Optional[str]:\n        \"\"\"\n        用户认证\n        :param username: 用户名\n        :param password: 密码\n        :return: 认证token\n        \"\"\"\n        if not self._host or not self._apikey:\n            return None\n        url = f\"{self._host}emby/Users/AuthenticateByName\"\n        try:\n            res = RequestUtils(headers={\n                'X-Emby-Authorization': f'MediaBrowser Client=\"MoviePilot\", '\n                                        f'Device=\"requests\", '\n                                        f'DeviceId=\"1\", '\n                                        f'Version=\"1.0.0\", '\n                                        f'Token=\"{self._apikey}\"',\n                'Content-Type': 'application/json',\n                \"Accept\": \"application/json\"\n            }).post_res(\n                url=url,\n                data=json.dumps({\n                    \"Username\": username,\n                    \"Pw\": password\n                })\n            )\n            if res:\n                auth_token = res.json().get(\"AccessToken\")\n                if auth_token:\n                    logger.info(f\"用户 {username} Emby认证成功\")\n                    return auth_token\n            else:\n                logger.error(f\"Users/AuthenticateByName 未获取到返回数据\")\n        except Exception as e:\n            logger.error(f\"连接Users/AuthenticateByName出错：\" + str(e))\n        return None\n\n    def get_server_id(self) -> Optional[str]:\n        \"\"\"\n        获得服务器信息\n        \"\"\"\n        if not self._host or not self._apikey:\n            return None\n        url = f\"{self._host}System/Info\"\n        params = {\n            'api_key': self._apikey\n        }\n        try:\n            res = RequestUtils().get_res(url, params)\n            if res:\n                return res.json().get(\"Id\")\n            else:\n                logger.error(f\"System/Info 未获取到返回数据\")\n        except Exception as e:\n\n            logger.error(f\"连接System/Info出错：\" + str(e))\n        return None\n\n    def get_user_count(self) -> int:\n        \"\"\"\n        获得用户数量\n        \"\"\"\n        if not self._host or not self._apikey:\n            return 0\n        url = f\"{self._host}emby/Users/Query\"\n        params = {\n            'api_key': self._apikey\n        }\n        try:\n            res = RequestUtils().get_res(url, params)\n            if res:\n                return res.json().get(\"TotalRecordCount\")\n            else:\n                logger.error(f\"Users/Query 未获取到返回数据\")\n                return 0\n        except Exception as e:\n            logger.error(f\"连接Users/Query出错：\" + str(e))\n            return 0\n\n    def get_medias_count(self) -> schemas.Statistic:\n        \"\"\"\n        获得电影、电视剧、动漫媒体数量\n        :return: MovieCount SeriesCount SongCount\n        \"\"\"\n        if not self._host or not self._apikey:\n            return schemas.Statistic()\n        url = f\"{self._host}emby/Items/Counts\"\n        params = {\n            'api_key': self._apikey\n        }\n        try:\n            res = RequestUtils().get_res(url, params)\n            if res:\n                result = res.json()\n                return schemas.Statistic(\n                    movie_count=result.get(\"MovieCount\") or 0,\n                    tv_count=result.get(\"SeriesCount\") or 0,\n                    episode_count=result.get(\"EpisodeCount\") or 0\n                )\n            else:\n                logger.error(f\"Items/Counts 未获取到返回数据\")\n                return schemas.Statistic()\n        except Exception as e:\n            logger.error(f\"连接Items/Counts出错：\" + str(e))\n            return schemas.Statistic()\n\n    def __get_emby_series_id_by_name(self, name: str, year: str) -> Optional[str]:\n        \"\"\"\n        根据名称查询Emby中剧集的SeriesId\n        :param name: 标题\n        :param year: 年份\n        :return: None 表示连不通，\"\"表示未找到，找到返回ID\n        \"\"\"\n        if not self._host or not self._apikey:\n            return None\n        url = f\"{self._host}emby/Items\"\n        params = {\n            \"IncludeItemTypes\": \"Series\",\n            \"Fields\": \"ProductionYear\",\n            \"StartIndex\": 0,\n            \"Recursive\": \"true\",\n            \"SearchTerm\": name,\n            \"Limit\": 10,\n            \"IncludeSearchTypes\": \"false\",\n            \"api_key\": self._apikey\n        }\n        try:\n            res = RequestUtils().get_res(url, params)\n            if res:\n                res_items = res.json().get(\"Items\")\n                if res_items:\n                    for res_item in res_items:\n                        if res_item.get('Name') == name and (\n                                not year or str(res_item.get('ProductionYear')) == str(year)):\n                            return res_item.get('Id')\n        except Exception as e:\n            logger.error(f\"连接Items出错：\" + str(e))\n            return None\n        return \"\"\n\n    def get_movies(self,\n                   title: str,\n                   year: Optional[str] = None,\n                   tmdb_id: Optional[int] = None) -> Optional[List[schemas.MediaServerItem]]:\n        \"\"\"\n        根据标题和年份，检查电影是否在Emby中存在，存在则返回列表\n        :param title: 标题\n        :param year: 年份，可以为空，为空时不按年份过滤\n        :param tmdb_id: TMDB ID\n        :return: 含title、year属性的字典列表\n        \"\"\"\n        if not self._host or not self._apikey:\n            return None\n        url = f\"{self._host}emby/Items\"\n        params = {\n            \"IncludeItemTypes\": \"Movie\",\n            \"Fields\": \"ProviderIds,OriginalTitle,ProductionYear,Path,UserDataPlayCount,UserDataLastPlayedDate,ParentId\",\n            \"StartIndex\": 0,\n            \"Recursive\": \"true\",\n            \"SearchTerm\": title,\n            \"Limit\": 10,\n            \"IncludeSearchTypes\": \"false\",\n            \"api_key\": self._apikey\n        }\n        try:\n            res = RequestUtils().get_res(url, params)\n            if res:\n                res_items = res.json().get(\"Items\")\n                if res_items:\n                    ret_movies = []\n                    for item in res_items:\n                        if not item:\n                            continue\n                        mediaserver_item = self.__format_item_info(item)\n                        if mediaserver_item:\n                            if (not tmdb_id or mediaserver_item.tmdbid == tmdb_id) and \\\n                                    mediaserver_item.title == title and \\\n                                    (not year or str(mediaserver_item.year) == str(year)):\n                                ret_movies.append(mediaserver_item)\n                    return ret_movies\n        except Exception as e:\n            logger.error(f\"连接Items出错：\" + str(e))\n            return None\n        return []\n\n    def get_tv_episodes(self,\n                        item_id: Optional[str] = None,\n                        title: Optional[str] = None,\n                        year: Optional[str] = None,\n                        tmdb_id: Optional[int] = None,\n                        season: Optional[int] = None\n                        ) -> Tuple[Optional[str], Optional[Dict[int, List[int]]]]:\n        \"\"\"\n        根据标题和年份和季，返回Emby中的剧集列表\n        :param item_id: Emby中的ID\n        :param title: 标题\n        :param year: 年份\n        :param tmdb_id: TMDBID\n        :param season: 季\n        :return: 每一季的已有集数\n        \"\"\"\n        if not self._host or not self._apikey:\n            return None, None\n        # 电视剧\n        if not item_id:\n            item_id = self.__get_emby_series_id_by_name(title, year)\n            if item_id is None:\n                return None, None\n            if not item_id:\n                return None, {}\n        # 验证tmdbid是否相同\n        item_info = self.get_iteminfo(item_id)\n        if item_info:\n            if tmdb_id and item_info.tmdbid:\n                if str(tmdb_id) != str(item_info.tmdbid):\n                    return None, {}\n        # 查集的信息\n        if season is None:\n            season = None\n        try:\n            url = f\"{self._host}emby/Shows/{item_id}/Episodes\"\n            params = {\n                \"Season\": season,\n                \"IsMissing\": \"false\",\n                \"api_key\": self._apikey\n            }\n            res_json = RequestUtils().get_res(url, params)\n            if res_json:\n                tv_item = res_json.json()\n                res_items = tv_item.get(\"Items\")\n                season_episodes = {}\n                for res_item in res_items:\n                    season_index = res_item.get(\"ParentIndexNumber\")\n                    if season_index is None:\n                        continue\n                    if season is not None and season != season_index:\n                        continue\n                    episode_index = res_item.get(\"IndexNumber\")\n                    if episode_index is None:\n                        continue\n                    if season_index not in season_episodes:\n                        season_episodes[season_index] = []\n                    season_episodes[season_index].append(episode_index)\n                # 返回\n                return item_id, season_episodes\n        except Exception as e:\n            logger.error(f\"连接Shows/Id/Episodes出错：\" + str(e))\n            return None, None\n        return None, {}\n\n    def get_remote_image_by_id(self, item_id: str, image_type: str) -> Optional[str]:\n        \"\"\"\n        根据ItemId从Emby查询TMDB的图片地址\n        :param item_id: 在Emby中的ID\n        :param image_type: 图片的类弄地，poster或者backdrop等\n        :return: 图片对应在TMDB中的URL\n        \"\"\"\n        if not self._host or not self._apikey:\n            return None\n        url = f\"{self._host}emby/Items/{item_id}/RemoteImages\"\n        params = {\n            \"api_key\": self._apikey\n        }\n        try:\n            res = RequestUtils(timeout=10).get_res(url, params)\n            if res:\n                images = res.json().get(\"Images\")\n                if images:\n                    for image in images:\n                        if image.get(\"ProviderName\") == \"TheMovieDb\" and image.get(\"Type\") == image_type:\n                            return image.get(\"Url\")\n            # 数据为空\n            logger.info(f\"Items/RemoteImages 未获取到返回数据，采用本地图片\")\n            return self.generate_external_image_link(item_id, image_type)\n        except Exception as e:\n            logger.error(f\"连接Items/Id/RemoteImages出错：\" + str(e))\n        return None\n\n    def generate_external_image_link(self, item_id: str, image_type: str) -> Optional[str]:\n        \"\"\"\n        根据ItemId和imageType查询本地对应图片\n        :param item_id: 在Emby中的ID\n        :param image_type: 图片类型，如Backdrop、Primary\n        :return: 图片对应在外网播放器中的URL\n        \"\"\"\n        if not self._playhost:\n            logger.error(\"Emby外网播放地址未能获取或为空\")\n            return None\n\n        url = f\"{self._playhost}Items/{item_id}/Images/{image_type}\"\n        try:\n            res = RequestUtils().get_res(url)\n            if res and res.status_code != 404:\n                logger.info(f\"影片图片链接:{res.url}\")\n                return res.url\n            else:\n                logger.info(\"Items/Id/Images 未获取到返回数据或无该影片{}图片\".format(image_type))\n                return None\n        except Exception as e:\n            logger.error(f\"连接Items/Id/Images出错：\" + str(e))\n            return None\n\n    def __refresh_emby_library_by_id(self, item_id: str) -> bool:\n        \"\"\"\n        通知Emby刷新一个项目的媒体库\n        \"\"\"\n        if not self._host or not self._apikey:\n            return False\n        url = f\"{self._host}emby/Items/{item_id}/Refresh\"\n        params = {\n            \"Recursive\": \"true\",\n            \"api_key\": self._apikey\n        }\n        try:\n            res = RequestUtils().post_res(url, params=params)\n            if res:\n                return True\n            else:\n                logger.info(f\"刷新媒体库对象 {item_id} 失败，无法连接Emby！\")\n        except Exception as e:\n            logger.error(f\"连接Items/Id/Refresh出错：\" + str(e))\n            return False\n        return False\n\n    def refresh_root_library(self) -> bool:\n        \"\"\"\n        通知Emby刷新整个媒体库\n        \"\"\"\n        if not self._host or not self._apikey:\n            return False\n        url = f\"{self._host}emby/Library/Refresh\"\n        params = {\n            \"api_key\": self._apikey\n        }\n        try:\n            res = RequestUtils().post_res(url, params=params)\n            if res:\n                return True\n            else:\n                logger.info(f\"刷新媒体库失败，无法连接Emby！\")\n        except Exception as e:\n            logger.error(f\"连接Library/Refresh出错：\" + str(e))\n            return False\n        return False\n\n    def refresh_library_by_items(self, items: List[schemas.RefreshMediaItem]) -> Optional[bool]:\n        \"\"\"\n        按类型、名称、年份来刷新媒体库\n        :param items: 已识别的需要刷新媒体库的媒体信息列表\n        \"\"\"\n        if not items:\n            return False\n        # 收集要刷新的媒体库信息\n        logger.info(f\"开始刷新Emby媒体库...\")\n        library_ids = []\n        for item in items:\n            library_id = self.__get_emby_library_id_by_item(item)\n            if library_id and library_id not in library_ids:\n                library_ids.append(library_id)\n        # 开始刷新媒体库\n        if \"/\" in library_ids:\n            return self.refresh_root_library()\n        for library_id in library_ids:\n            if library_id != \"/\":\n                return self.__refresh_emby_library_by_id(library_id)\n        logger.info(f\"Emby媒体库刷新完成\")\n        return True\n\n    def __get_emby_library_id_by_item(self, item: schemas.RefreshMediaItem) -> Optional[str]:\n        \"\"\"\n        根据媒体信息查询在哪个媒体库，返回要刷新的位置的ID\n        :param item: {title, year, type, category, target_path}\n        \"\"\"\n        if not item.title or not item.year or not item.type:\n            return None\n        if item.type != MediaType.MOVIE.value:\n            item_id = self.__get_emby_series_id_by_name(item.title, item.year)\n            if item_id:\n                # 存在电视剧，则直接刷新这个电视剧就行\n                return item_id\n        else:\n            if self.get_movies(item.title, item.year):\n                # 已存在，不用刷新\n                return None\n        # 查找需要刷新的媒体库ID\n        item_path = Path(item.target_path)\n        # 匹配子目录\n        for folder in self.folders:\n            for subfolder in folder.get(\"SubFolders\"):\n                try:\n                    # 匹配子目录\n                    subfolder_path = Path(subfolder.get(\"Path\"))\n                    if item_path.is_relative_to(subfolder_path):\n                        return folder.get(\"Id\")\n                except Exception as err:\n                    logger.debug(f\"匹配子目录出错：{err} - {traceback.format_exc()}\")\n        # 如果找不到，只要路径中有分类目录名就命中\n        for folder in self.folders:\n            for subfolder in folder.get(\"SubFolders\"):\n                if subfolder.get(\"Path\") and re.search(r\"[/\\\\]%s\" % item.category,\n                                                       subfolder.get(\"Path\")):\n                    return folder.get(\"Id\")\n        # 刷新根目录\n        return \"/\"\n\n    @staticmethod\n    def __format_item_info(item) -> Optional[schemas.MediaServerItem]:\n        \"\"\"\n        格式化item\n        \"\"\"\n        try:\n            user_data = item.get(\"UserData\", {})\n            if not user_data:\n                user_state = None\n            else:\n                resume = item.get(\"UserData\", {}).get(\"PlaybackPositionTicks\") and item.get(\"UserData\", {}).get(\n                    \"PlaybackPositionTicks\") > 0\n                last_played_date = item.get(\"UserData\", {}).get(\"LastPlayedDate\")\n                if last_played_date is not None and \".\" in last_played_date:\n                    last_played_date = last_played_date.split(\".\")[0]\n                user_state = schemas.MediaServerItemUserState(\n                    played=item.get(\"UserData\", {}).get(\"Played\"),\n                    resume=resume,\n                    last_played_date=datetime.strptime(last_played_date, \"%Y-%m-%dT%H:%M:%S\").strftime(\n                        \"%Y-%m-%d %H:%M:%S\") if last_played_date else None,\n                    play_count=item.get(\"UserData\", {}).get(\"PlayCount\"),\n                    percentage=item.get(\"UserData\", {}).get(\"PlayedPercentage\"),\n                )\n            tmdbid = item.get(\"ProviderIds\", {}).get(\"Tmdb\")\n            return schemas.MediaServerItem(\n                server=\"emby\",\n                library=item.get(\"ParentId\"),\n                item_id=item.get(\"Id\"),\n                item_type=item.get(\"Type\"),\n                title=item.get(\"Name\"),\n                original_title=item.get(\"OriginalTitle\"),\n                year=item.get(\"ProductionYear\"),\n                tmdbid=int(tmdbid) if tmdbid else None,\n                imdbid=item.get(\"ProviderIds\", {}).get(\"Imdb\"),\n                tvdbid=item.get(\"ProviderIds\", {}).get(\"Tvdb\"),\n                path=item.get(\"Path\"),\n                user_state=user_state\n\n            )\n        except Exception as e:\n            logger.error(e)\n        return None\n\n    def get_iteminfo(self, itemid: str) -> Optional[schemas.MediaServerItem]:\n        \"\"\"\n        获取单个项目详情\n        \"\"\"\n        if not itemid:\n            return None\n        if not self._host or not self._apikey:\n            return None\n        url = f\"{self._host}emby/Users/{self.user}/Items/{itemid}\"\n        params = {\n            \"api_key\": self._apikey\n        }\n        try:\n            res = RequestUtils().get_res(url, params)\n            if res and res.status_code == 200:\n                iteminfo = self.__format_item_info(res.json())\n                return iteminfo\n        except Exception as e:\n            logger.error(f\"连接/Users/{self.user}/Items/{itemid}出错：\" + str(e))\n        return None\n\n    def get_items(self, parent: Union[str, int], start_index: Optional[int] = 0,\n                  limit: Optional[int] = -1) -> Generator[MediaServerItem | None | Any, Any, None]:\n        \"\"\"\n        获取媒体服务器项目列表，支持分页和不分页逻辑，默认不分页获取所有数据\n\n        :param parent: 媒体库ID，用于标识要获取的媒体库\n        :param start_index: 起始索引，用于分页获取数据。默认为 0，即从第一个项目开始获取\n        :param limit: 每次请求的最大项目数，用于分页。如果为 None 或 -1，则表示一次性获取所有数据，默认为 -1\n\n        :return: 返回一个生成器对象，用于逐步获取媒体服务器中的项目\n        \"\"\"\n        if not parent or not self._host or not self._apikey:\n            return None\n        url = f\"{self._host}emby/Users/{self.user}/Items\"\n        params = {\n            \"ParentId\": parent,\n            \"api_key\": self._apikey,\n            \"Fields\": \"ProviderIds,OriginalTitle,ProductionYear,Path,UserDataPlayCount,UserDataLastPlayedDate,ParentId\"\n        }\n        if limit is not None and limit != -1:\n            params.update({\n                \"StartIndex\": start_index,\n                \"Limit\": limit\n            })\n        try:\n            res = RequestUtils().get_res(url, params)\n            if not res or res.status_code != 200:\n                return None\n            items = res.json().get(\"Items\") or []\n            for item in items:\n                if not item:\n                    continue\n                if \"Folder\" in item.get(\"Type\"):\n                    for items in self.get_items(parent=item.get('Id')):\n                        yield items\n                elif item.get(\"Type\") in [\"Movie\", \"Series\"]:\n                    yield self.__format_item_info(item)\n        except Exception as e:\n            logger.error(f\"连接Users/Items出错：\" + str(e))\n        return None\n\n    def get_webhook_message(self, form: any, args: dict) -> Optional[schemas.WebhookEventInfo]:\n        \"\"\"\n        解析Emby Webhook报文\n        电影：\n        {\n          \"Title\": \"admin 在 Microsoft Edge Windows 上停止播放 蜘蛛侠：纵横宇宙\",\n          \"Date\": \"2023-08-19T00:49:07.8523469Z\",\n          \"Event\": \"playback.stop\",\n          \"User\": {\n            \"Name\": \"admin\",\n            \"Id\": \"e6a9dd89fd954d689870e7e0e3e72947\"\n          },\n          \"Item\": {\n            \"Name\": \"蜘蛛侠：纵横宇宙\",\n            \"OriginalTitle\": \"Spider-Man: Across the Spider-Verse\",\n            \"ServerId\": \"f40a5bd0c6b64051bdbed00580fa1118\",\n            \"Id\": \"240270\",\n            \"DateCreated\": \"2023-06-21T21:01:27.0000000Z\",\n            \"Container\": \"mp4\",\n            \"SortName\": \"蜘蛛侠：纵横宇宙\",\n            \"PremiereDate\": \"2023-05-30T16:00:00.0000000Z\",\n            \"ExternalUrls\": [\n              {\n                \"Name\": \"IMDb\",\n                \"Url\": \"https://www.imdb.com/title/tt9362722\"\n              },\n              {\n                \"Name\": \"TheMovieDb\",\n                \"Url\": \"https://www.themoviedb.org/movie/569094\"\n              },\n              {\n                \"Name\": \"Trakt\",\n                \"Url\": \"https://trakt.tv/search/tmdb/569094?id_type=movie\"\n              }\n            ],\n            \"Path\": \"\\\\\\\\10.10.10.10\\\\Video\\\\电影\\\\动画电影\\\\蜘蛛侠：纵横宇宙 (2023)\\\\蜘蛛侠：纵横宇宙 (2023).mp4\",\n            \"OfficialRating\": \"PG\",\n            \"Overview\": \"讲述了新生代蜘蛛侠迈尔斯（沙梅克·摩尔 Shameik Moore 配音）携手蜘蛛格温（海莉·斯坦菲尔德 Hailee Steinfeld 配音），穿越多元宇宙踏上更宏大的冒险征程的故事。面临每个蜘蛛侠都会失去至亲的宿命，迈尔斯誓言打破命运魔咒，找到属于自己的英雄之路。而这个决定和蜘蛛侠2099（奥斯卡·伊萨克 Oscar Is aac 配音）所领军的蜘蛛联盟产生了极大冲突，一场以一敌百的蜘蛛侠大内战即将拉响！\",\n            \"Taglines\": [],\n            \"Genres\": [\n              \"动作\",\n              \"冒险\",\n              \"动画\",\n              \"科幻\"\n            ],\n            \"CommunityRating\": 8.7,\n            \"RunTimeTicks\": 80439590000,\n            \"Size\": 3170164641,\n            \"FileName\": \"蜘蛛侠：纵横宇宙 (2023).mp4\",\n            \"Bitrate\": 3152840,\n            \"PlayAccess\": \"Full\",\n            \"ProductionYear\": 2023,\n            \"RemoteTrailers\": [\n              {\n                \"Url\": \"https://www.youtube.com/watch?v=BbXJ3_AQE_o\"\n              },\n              {\n                \"Url\": \"https://www.youtube.com/watch?v=cqGjhVJWtEg\"\n              },\n              {\n                \"Url\": \"https://www.youtube.com/watch?v=shW9i6k8cB0\"\n              },\n              {\n                \"Url\": \"https://www.youtube.com/watch?v=Etv-L2JKCWk\"\n              },\n              {\n                \"Url\": \"https://www.youtube.com/watch?v=yFrxzaBLDQM\"\n              }\n            ],\n            \"ProviderIds\": {\n              \"Tmdb\": \"569094\",\n              \"Imdb\": \"tt9362722\"\n            },\n            \"IsFolder\": false,\n            \"ParentId\": \"240253\",\n            \"Type\": \"Movie\",\n            \"Studios\": [\n              {\n                \"Name\": \"Columbia Pictures\",\n                \"Id\": 1252\n              },\n              {\n                \"Name\": \"Sony Pictures Animation\",\n                \"Id\": 1814\n              },\n              {\n                \"Name\": \"Lord Miller\",\n                \"Id\": 240307\n              },\n              {\n                \"Name\": \"Pascal Pictures\",\n                \"Id\": 60101\n              },\n              {\n                \"Name\": \"Arad Productions\",\n                \"Id\": 67372\n              }\n            ],\n            \"GenreItems\": [\n              {\n                \"Name\": \"动作\",\n                \"Id\": 767\n              },\n              {\n                \"Name\": \"冒险\",\n                \"Id\": 818\n              },\n              {\n                \"Name\": \"动画\",\n                \"Id\": 1382\n              },\n              {\n                \"Name\": \"科幻\",\n                \"Id\": 709\n              }\n            ],\n            \"TagItems\": [],\n            \"PrimaryImageAspectRatio\": 0.7012622720897616,\n            \"ImageTags\": {\n              \"Primary\": \"c080830ff3c964a775dd0b011b675a29\",\n              \"Art\": \"a418b990ca0df95838884b5951883ad5\",\n              \"Logo\": \"1782310274c108e85d02d2b0b1c7249c\",\n              \"Thumb\": \"29d499a96b7da07cd1cf37edb58507a8\",\n              \"Banner\": \"bec236365d57f7f646d8fda16fce2ecb\",\n              \"Disc\": \"3e32d87be8655f52bcf43bd34ee94c2b\"\n            },\n            \"BackdropImageTags\": [\n              \"13acab1246c95a6fbdee22cf65edf3f0\"\n            ],\n            \"MediaType\": \"Video\",\n            \"Width\": 1920,\n            \"Height\": 820\n          },\n          \"Server\": {\n            \"Name\": \"PN41\",\n            \"Id\": \"f40a5bd0c6b64051bdbed00580fa1118\",\n            \"Version\": \"4.7.13.0\"\n          },\n          \"Session\": {\n            \"RemoteEndPoint\": \"10.10.10.253\",\n            \"Client\": \"Emby Web\",\n            \"DeviceName\": \"Microsoft Edge Windows\",\n            \"DeviceId\": \"30239450-1748-4855-9799-de3544fc2744\",\n            \"ApplicationVersion\": \"4.7.13.0\",\n            \"Id\": \"c336b028b893558b333d1a49238b7db1\"\n          },\n          \"PlaybackInfo\": {\n            \"PlayedToCompletion\": false,\n            \"PositionTicks\": 17431791950,\n            \"PlaylistIndex\": 0,\n            \"PlaylistLength\": 1\n          }\n        }\n\n        电视剧：\n        {\n          \"Title\": \"admin 在 Microsoft Edge Windows 上开始播放 长风渡 - S1, Ep11 - 第 11 集\",\n          \"Date\": \"2023-08-19T00:52:20.5200050Z\",\n          \"Event\": \"playback.start\",\n          \"User\": {\n            \"Name\": \"admin\",\n            \"Id\": \"e6a9dd89fd954d689870e7e0e3e72947\"\n          },\n          \"Item\": {\n            \"Name\": \"第 11 集\",\n            \"ServerId\": \"f40a5bd0c6b64051bdbed00580fa1118\",\n            \"Id\": \"240252\",\n            \"DateCreated\": \"2023-06-21T10:51:06.0000000Z\",\n            \"Container\": \"mp4\",\n            \"SortName\": \"第 11 集\",\n            \"PremiereDate\": \"2023-06-20T16:00:00.0000000Z\",\n            \"ExternalUrls\": [\n              {\n                \"Name\": \"Trakt\",\n                \"Url\": \"https://trakt.tv/search/tmdb/4533239?id_type=episode\"\n              }\n            ],\n            \"Path\": \"\\\\\\\\10.10.10.10\\\\Video\\\\电视剧\\\\国产剧\\\\长风渡 (2023)\\\\Season 1\\\\长风渡 - S01E11 - 第 11 集.mp4\",\n            \"Taglines\": [],\n            \"Genres\": [],\n            \"RunTimeTicks\": 28021450000,\n            \"Size\": 707122056,\n            \"FileName\": \"长风渡 - S01E11 - 第 11 集.mp4\",\n            \"Bitrate\": 2018802,\n            \"PlayAccess\": \"Full\",\n            \"ProductionYear\": 2023,\n            \"IndexNumber\": 11,\n            \"ParentIndexNumber\": 1,\n            \"RemoteTrailers\": [],\n            \"ProviderIds\": {\n              \"Tmdb\": \"4533239\"\n            },\n            \"IsFolder\": false,\n            \"ParentId\": \"240203\",\n            \"Type\": \"Episode\",\n            \"Studios\": [],\n            \"GenreItems\": [],\n            \"TagItems\": [],\n            \"ParentLogoItemId\": \"240202\",\n            \"ParentBackdropItemId\": \"240202\",\n            \"ParentBackdropImageTags\": [\n              \"7dd568c67721c1f184b281001ced2f8e\"\n            ],\n            \"SeriesName\": \"长风渡\",\n            \"SeriesId\": \"240202\",\n            \"SeasonId\": \"240203\",\n            \"PrimaryImageAspectRatio\": 2.4,\n            \"SeriesPrimaryImageTag\": \"e91c822173e9bcbf7a0efa7d1c16f6bd\",\n            \"SeasonName\": \"季 1\",\n            \"ImageTags\": {\n              \"Primary\": \"d6bf1d76150cd86fdff746e4353569ee\"\n            },\n            \"BackdropImageTags\": [],\n            \"ParentLogoImageTag\": \"51cf6b2661c3c9cef3796abafd6a1694\",\n            \"MediaType\": \"Video\",\n            \"Width\": 1920,\n            \"Height\": 800\n          },\n          \"Server\": {\n            \"Name\": \"PN41\",\n            \"Id\": \"f40a5bd0c6b64051bdbed00580fa1118\",\n            \"Version\": \"4.7.13.0\"\n          },\n          \"Session\": {\n            \"RemoteEndPoint\": \"10.10.10.253\",\n            \"Client\": \"Emby Web\",\n            \"DeviceName\": \"Microsoft Edge Windows\",\n            \"DeviceId\": \"30239450-1748-4855-9799-de3544fc2744\",\n            \"ApplicationVersion\": \"4.7.13.0\",\n            \"Id\": \"c336b028b893558b333d1a49238b7db1\"\n          },\n          \"PlaybackInfo\": {\n            \"PositionTicks\": 14256663550,\n            \"PlaylistIndex\": 10,\n            \"PlaylistLength\": 40\n          }\n        }\n        \"\"\"\n        if not form and not args:\n            return None\n        try:\n            if form and form.get(\"data\"):\n                result = form.get(\"data\")\n            else:\n                result = json.dumps(dict(args))\n            message = json.loads(result)\n        except Exception as e:\n            logger.debug(f\"解析emby webhook报文出错：\" + str(e))\n            return None\n        eventType = message.get('Event')\n        if not eventType:\n            return None\n        logger.debug(f\"接收到emby webhook：{message}\")\n        eventItem = schemas.WebhookEventInfo(event=eventType, channel=\"emby\")\n        if message.get('Item'):\n            eventItem.media_type = message.get('Item', {}).get('Type')\n            if message.get('Item', {}).get('Type') == 'Episode' \\\n                    or message.get('Item', {}).get('Type') == 'Series' \\\n                    or message.get('Item', {}).get('Type') == 'Season':\n                eventItem.item_type = \"TV\"\n                if message.get('Item', {}).get('SeriesName') \\\n                        and message.get('Item', {}).get('ParentIndexNumber') \\\n                        and message.get('Item', {}).get('IndexNumber'):\n                    eventItem.item_name = \"%s %s%s %s\" % (\n                        message.get('Item', {}).get('SeriesName'),\n                        \"S\" + str(message.get('Item', {}).get('ParentIndexNumber')),\n                        \"E\" + str(message.get('Item', {}).get('IndexNumber')),\n                        message.get('Item', {}).get('Name'))\n                elif message.get('Item', {}).get('SeriesName'):\n                    eventItem.item_name = \"%s %s\" % (\n                        message.get('Item', {}).get('SeriesName'),\n                        message.get('Item', {}).get('Name'))\n                else:\n                    eventItem.item_name = message.get('Item', {}).get('Name')\n                eventItem.item_id = message.get('Item', {}).get('SeriesId')\n                eventItem.season_id = message.get('Item', {}).get('ParentIndexNumber')\n                eventItem.episode_id = message.get('Item', {}).get('IndexNumber')\n            elif message.get('Item', {}).get('Type') == 'Audio':\n                eventItem.item_type = \"AUD\"\n                album = message.get('Item', {}).get('Album')\n                file_name = message.get('Item', {}).get('FileName')\n                eventItem.item_name = album\n                eventItem.overview = file_name\n                eventItem.item_id = message.get('Item', {}).get('AlbumId')\n            else:\n                eventItem.item_type = \"MOV\"\n                eventItem.item_name = \"%s %s\" % (\n                    message.get('Item', {}).get('Name'), \"(\" + str(message.get('Item', {}).get('ProductionYear')) + \")\")\n                eventItem.item_id = message.get('Item', {}).get('Id')\n\n            eventItem.item_path = message.get('Item', {}).get('Path')\n            eventItem.tmdb_id = message.get('Item', {}).get('ProviderIds', {}).get('Tmdb')\n            if message.get('Item', {}).get('Overview') and len(message.get('Item', {}).get('Overview')) > 100:\n                eventItem.overview = str(message.get('Item', {}).get('Overview'))[:100] + \"...\"\n            else:\n                eventItem.overview = message.get('Item', {}).get('Overview')\n            eventItem.percentage = message.get('TranscodingInfo', {}).get('CompletionPercentage')\n            if not eventItem.percentage:\n                if message.get('PlaybackInfo', {}).get('PositionTicks') and message.get('Item', {}).get('RunTimeTicks'):\n                    eventItem.percentage = message.get('PlaybackInfo', {}).get('PositionTicks') / \\\n                                           message.get('Item', {}).get('RunTimeTicks') * 100\n        if message.get('Session'):\n            eventItem.ip = message.get('Session').get('RemoteEndPoint')\n            eventItem.device_name = message.get('Session').get('DeviceName')\n            eventItem.client = message.get('Session').get('Client')\n        if message.get(\"User\"):\n            eventItem.user_name = message.get(\"User\").get('Name')\n        if message.get(\"item_isvirtual\"):\n            eventItem.item_isvirtual = message.get(\"item_isvirtual\")\n            eventItem.item_type = message.get(\"item_type\")\n            eventItem.item_name = message.get(\"item_name\")\n            eventItem.item_path = message.get(\"item_path\")\n            eventItem.tmdb_id = message.get(\"tmdb_id\")\n            eventItem.season_id = message.get(\"season_id\")\n            eventItem.episode_id = message.get(\"episode_id\")\n\n        # 获取消息图片\n        if eventItem.item_id:\n            # 根据返回的item_id去调用媒体服务器获取\n            eventItem.image_url = self.get_remote_image_by_id(item_id=eventItem.item_id,\n                                                              image_type=\"Backdrop\")\n\n        eventItem.json_object = message\n\n        return eventItem\n\n    def get_data(self, url: str) -> Optional[Response]:\n        \"\"\"\n        自定义URL从媒体服务器获取数据，其中[HOST]、[APIKEY]、[USER]会被替换成实际的值\n        :param url: 请求地址\n        \"\"\"\n        if not self._host or not self._apikey:\n            return None\n        url = url.replace(\"[HOST]\", self._host or '') \\\n            .replace(\"[APIKEY]\", self._apikey or '') \\\n            .replace(\"[USER]\", self.user or '')\n        try:\n            return RequestUtils(content_type=\"application/json\").get_res(url=url)\n        except Exception as e:\n            logger.error(f\"连接Emby出错：\" + str(e))\n            return None\n\n    def post_data(self, url: str, data: Optional[str] = None, headers: dict = None) -> Optional[Response]:\n        \"\"\"\n        自定义URL从媒体服务器获取数据，其中[HOST]、[APIKEY]、[USER]会被替换成实际的值\n        :param url: 请求地址\n        :param data: 请求数据\n        :param headers: 请求头\n        \"\"\"\n        if not self._host or not self._apikey:\n            return None\n        url = url.replace(\"[HOST]\", self._host or '') \\\n            .replace(\"[APIKEY]\", self._apikey or '') \\\n            .replace(\"[USER]\", self.user or '')\n        try:\n            return RequestUtils(\n                headers=headers,\n            ).post_res(url=url, data=data)\n        except Exception as e:\n            logger.error(f\"连接Emby出错：\" + str(e))\n            return None\n\n    def get_play_url(self, item_id: str) -> str:\n        \"\"\"\n        拼装媒体播放链接\n        :param item_id: 媒体的的ID\n        \"\"\"\n        return f\"{self._playhost or self._host}web/index.html#!\" \\\n               f\"/item?id={item_id}&context=home&serverId={self.serverid}\"\n\n    def get_backdrop_url(self, item_id: str, image_tag: str, remote: Optional[bool] = False) -> str:\n        \"\"\"\n        获取Emby的Backdrop图片地址\n        :param: item_id: 在Emby中的ID\n        :param: image_tag: 图片的tag\n        :param: remote 是否远程使用，TG微信等客户端调用应为True\n        \"\"\"\n        if not self._host or not self._apikey:\n            return \"\"\n        if not image_tag or not item_id:\n            return \"\"\n        if remote:\n            host_url = self._playhost or self._host\n        else:\n            host_url = self._host\n        return f\"{host_url}Items/{item_id}/\" \\\n               f\"Images/Backdrop?tag={image_tag}&api_key={self._apikey}\"\n\n    def __get_local_image_by_id(self, item_id: str) -> str:\n        \"\"\"\n        根据ItemId从媒体服务器查询本地图片地址\n        :param: item_id: 在Emby中的ID\n        :param: remote 是否远程使用，TG微信等客户端调用应为True\n        :param: inner 是否NT内部调用，为True是会使用NT中转\n        \"\"\"\n        if not self._host or not self._apikey:\n            return \"\"\n        return \"%sItems/%s/Images/Primary\" % (self._host, item_id)\n\n    def get_resume(self, num: Optional[int] = 12, username: Optional[str] = None) -> Optional[\n        List[schemas.MediaServerPlayItem]]:\n        \"\"\"\n        获得继续观看\n        \"\"\"\n        if not self._host or not self._apikey:\n            return None\n        if username:\n            user = self.get_user(username)\n        else:\n            user = self.user\n        url = f\"{self._host}Users/{user}/Items/Resume\"\n        params = {\n            \"Limit\": 100,\n            \"MediaTypes\": \"Video\",\n            \"Fields\": \"ProductionYear,Path\",\n            \"api_key\": self._apikey,\n        }\n        try:\n            res = RequestUtils().get_res(url, params)\n            if res:\n                result = res.json().get(\"Items\") or []\n                ret_resume = []\n                # 用户媒体库文件夹列表（排除黑名单）\n                library_folders = self.get_user_library_folders()\n                for item in result:\n                    if len(ret_resume) == num:\n                        break\n                    if item.get(\"Type\") not in [\"Movie\", \"Episode\"]:\n                        continue\n                    item_path = item.get(\"Path\")\n                    if item_path and library_folders and not any(\n                            str(item_path).startswith(folder) for folder in library_folders):\n                        continue\n                    item_type = MediaType.MOVIE.value if item.get(\"Type\") == \"Movie\" else MediaType.TV.value\n                    link = self.get_play_url(item.get(\"Id\"))\n                    if item_type == MediaType.MOVIE.value:\n                        title = item.get(\"Name\")\n                        subtitle = str(item.get(\"ProductionYear\")) if item.get(\"ProductionYear\") else None\n                    else:\n                        title = f'{item.get(\"SeriesName\")}'\n                        subtitle = f'S{item.get(\"ParentIndexNumber\")}:{item.get(\"IndexNumber\")} - {item.get(\"Name\")}'\n                    if item_type == MediaType.MOVIE.value:\n                        if item.get(\"BackdropImageTags\"):\n                            image = self.get_backdrop_url(item_id=item.get(\"Id\"),\n                                                          image_tag=item.get(\"BackdropImageTags\")[0])\n                        else:\n                            image = self.__get_local_image_by_id(item.get(\"Id\"))\n                    else:\n                        image = self.get_backdrop_url(item_id=item.get(\"SeriesId\"),\n                                                      image_tag=item.get(\"SeriesPrimaryImageTag\"))\n                        if not image:\n                            image = self.__get_local_image_by_id(item.get(\"SeriesId\"))\n                    ret_resume.append(schemas.MediaServerPlayItem(\n                        id=item.get(\"Id\"),\n                        title=title,\n                        subtitle=subtitle,\n                        type=item_type,\n                        image=image,\n                        link=link,\n                        percent=item.get(\"UserData\", {}).get(\"PlayedPercentage\"),\n                        server_type='emby'\n                    ))\n                return ret_resume\n            else:\n                logger.error(f\"Users/Items/Resume 未获取到返回数据\")\n        except Exception as e:\n            logger.error(f\"连接Users/Items/Resume出错：\" + str(e))\n        return []\n\n    def get_latest(self, num: Optional[int] = 20, username: Optional[str] = None) -> Optional[\n        List[schemas.MediaServerPlayItem]]:\n        \"\"\"\n        获得最近更新\n        \"\"\"\n        if not self._host or not self._apikey:\n            return None\n        if username:\n            user = self.get_user(username)\n        else:\n            user = self.user\n        url = f\"{self._host}Users/{user}/Items/Latest\"\n        params = {\n            \"Limit\": 100,\n            \"MediaTypes\": \"Video\",\n            \"Fields\": \"ProductionYear,Path,BackdropImageTags\",\n            \"api_key\": self._apikey\n        }\n        try:\n            res = RequestUtils().get_res(url, params)\n            if res:\n                result = res.json() or []\n                ret_latest = []\n                # 用户媒体库文件夹列表（排除黑名单）\n                library_folders = self.get_user_library_folders()\n                for item in result:\n                    if len(ret_latest) == num:\n                        break\n                    if item.get(\"Type\") not in [\"Movie\", \"Series\"]:\n                        continue\n                    item_path = item.get(\"Path\")\n                    if item_path and library_folders and not any(\n                            str(item_path).startswith(folder) for folder in library_folders):\n                        continue\n                    item_type = MediaType.MOVIE.value if item.get(\"Type\") == \"Movie\" else MediaType.TV.value\n                    link = self.get_play_url(item.get(\"Id\"))\n                    image = self.__get_local_image_by_id(item_id=item.get(\"Id\"))\n                    ret_latest.append(schemas.MediaServerPlayItem(\n                        id=item.get(\"Id\"),\n                        title=item.get(\"Name\"),\n                        subtitle=str(item.get(\"ProductionYear\")) if item.get(\"ProductionYear\") else None,\n                        type=item_type,\n                        image=image,\n                        link=link,\n                        BackdropImageTags=item.get(\"BackdropImageTags\"),\n                        server_type='emby'\n                    ))\n                return ret_latest\n            else:\n                logger.error(f\"Users/Items/Latest 未获取到返回数据\")\n        except Exception as e:\n            logger.error(f\"连接Users/Items/Latest出错：\" + str(e))\n        return []\n\n    def get_user_library_folders(self):\n        \"\"\"\n        获取Emby媒体库文件夹列表（排除黑名单）\n        \"\"\"\n        if not self._host or not self._apikey:\n            return []\n        library_folders = []\n        for library in self.get_emby_virtual_folders() or []:\n            if self._sync_libraries and library.get(\"Id\") not in self._sync_libraries:\n                continue\n            library_folders += [folder for folder in library.get(\"Path\")]\n        return library_folders\n"
  },
  {
    "path": "app/modules/fanart/__init__.py",
    "content": "import re\nfrom typing import Optional, Tuple, Union\n\nfrom app.core.cache import cached\nfrom app.core.context import MediaInfo, settings\nfrom app.log import logger\nfrom app.modules import _ModuleBase\nfrom app.schemas.types import MediaType, ModuleType, OtherModulesType\nfrom app.utils.http import RequestUtils\n\n\nclass FanartModule(_ModuleBase):\n    \"\"\"\n    {\n        \"name\": \"The Wheel of Time\",\n        \"thetvdb_id\": \"355730\",\n        \"tvposter\": [\n            {\n                \"id\": \"174068\",\n                \"url\": \"http://assets.fanart.tv/fanart/tv/355730/tvposter/the-wheel-of-time-64b009de9548d.jpg\",\n                \"lang\": \"en\",\n                \"likes\": \"3\"\n            },\n            {\n                \"id\": \"176424\",\n                \"url\": \"http://assets.fanart.tv/fanart/tv/355730/tvposter/the-wheel-of-time-64de44fe42073.jpg\",\n                \"lang\": \"00\",\n                \"likes\": \"3\"\n            },\n            {\n                \"id\": \"176407\",\n                \"url\": \"http://assets.fanart.tv/fanart/tv/355730/tvposter/the-wheel-of-time-64dde63c7c941.jpg\",\n                \"lang\": \"en\",\n                \"likes\": \"0\"\n            },\n            {\n                \"id\": \"177321\",\n                \"url\": \"http://assets.fanart.tv/fanart/tv/355730/tvposter/the-wheel-of-time-64eda10599c3d.jpg\",\n                \"lang\": \"cz\",\n                \"likes\": \"0\"\n            },\n            {\n                \"id\": \"155050\",\n                \"url\": \"http://assets.fanart.tv/fanart/tv/355730/tvposter/the-wheel-of-time-6313adbd1fd58.jpg\",\n                \"lang\": \"pl\",\n                \"likes\": \"0\"\n            },\n            {\n                \"id\": \"140198\",\n                \"url\": \"http://assets.fanart.tv/fanart/tv/355730/tvposter/the-wheel-of-time-61a0d7b11952e.jpg\",\n                \"lang\": \"en\",\n                \"likes\": \"0\"\n            },\n            {\n                \"id\": \"140034\",\n                \"url\": \"http://assets.fanart.tv/fanart/tv/355730/tvposter/the-wheel-of-time-619e65b73871d.jpg\",\n                \"lang\": \"en\",\n                \"likes\": \"0\"\n            }\n        ],\n        \"hdtvlogo\": [\n            {\n                \"id\": \"139835\",\n                \"url\": \"http://assets.fanart.tv/fanart/tv/355730/hdtvlogo/the-wheel-of-time-6197d9392faba.png\",\n                \"lang\": \"en\",\n                \"likes\": \"3\"\n            },\n            {\n                \"id\": \"140039\",\n                \"url\": \"http://assets.fanart.tv/fanart/tv/355730/hdtvlogo/the-wheel-of-time-619e87941a128.png\",\n                \"lang\": \"pt\",\n                \"likes\": \"3\"\n            },\n            {\n                \"id\": \"140092\",\n                \"url\": \"http://assets.fanart.tv/fanart/tv/355730/hdtvlogo/the-wheel-of-time-619fa2347bada.png\",\n                \"lang\": \"en\",\n                \"likes\": \"3\"\n            },\n            {\n                \"id\": \"164312\",\n                \"url\": \"http://assets.fanart.tv/fanart/tv/355730/hdtvlogo/the-wheel-of-time-63c8185cb8824.png\",\n                \"lang\": \"hu\",\n                \"likes\": \"1\"\n            },\n            {\n                \"id\": \"139827\",\n                \"url\": \"http://assets.fanart.tv/fanart/tv/355730/hdtvlogo/the-wheel-of-time-6197539658a9e.png\",\n                \"lang\": \"en\",\n                \"likes\": \"1\"\n            },\n            {\n                \"id\": \"177214\",\n                \"url\": \"http://assets.fanart.tv/fanart/tv/355730/hdtvlogo/the-wheel-of-time-64ebae44c23a6.png\",\n                \"lang\": \"cz\",\n                \"likes\": \"0\"\n            },\n            {\n                \"id\": \"177215\",\n                \"url\": \"http://assets.fanart.tv/fanart/tv/355730/hdtvlogo/the-wheel-of-time-64ebae472deef.png\",\n                \"lang\": \"cz\",\n                \"likes\": \"0\"\n            },\n            {\n                \"id\": \"156163\",\n                \"url\": \"http://assets.fanart.tv/fanart/tv/355730/hdtvlogo/the-wheel-of-time-63316bef1ff9d.png\",\n                \"lang\": \"cz\",\n                \"likes\": \"0\"\n            },\n            {\n                \"id\": \"155051\",\n                \"url\": \"http://assets.fanart.tv/fanart/tv/355730/hdtvlogo/the-wheel-of-time-6313add04ca92.png\",\n                \"lang\": \"pl\",\n                \"likes\": \"0\"\n            },\n            {\n                \"id\": \"152668\",\n                \"url\": \"http://assets.fanart.tv/fanart/tv/355730/hdtvlogo/the-wheel-of-time-62ced3775a40a.png\",\n                \"lang\": \"pl\",\n                \"likes\": \"0\"\n            },\n            {\n                \"id\": \"142266\",\n                \"url\": \"http://assets.fanart.tv/fanart/tv/355730/hdtvlogo/the-wheel-of-time-61ccd93eeac2b.png\",\n                \"lang\": \"de\",\n                \"likes\": \"0\"\n            }\n        ],\n        \"hdclearart\": [\n            {\n                \"id\": \"164313\",\n                \"url\": \"http://assets.fanart.tv/fanart/tv/355730/hdclearart/the-wheel-of-time-63c81871c982c.png\",\n                \"lang\": \"en\",\n                \"likes\": \"3\"\n            },\n            {\n                \"id\": \"140284\",\n                \"url\": \"http://assets.fanart.tv/fanart/tv/355730/hdclearart/the-wheel-of-time-61a2128ed1df2.png\",\n                \"lang\": \"pt\",\n                \"likes\": \"3\"\n            },\n            {\n                \"id\": \"139828\",\n                \"url\": \"http://assets.fanart.tv/fanart/tv/355730/hdclearart/the-wheel-of-time-61975401e894c.png\",\n                \"lang\": \"en\",\n                \"likes\": \"1\"\n            },\n            {\n                \"id\": \"164314\",\n                \"url\": \"http://assets.fanart.tv/fanart/tv/355730/hdclearart/the-wheel-of-time-63c8188488a5f.png\",\n                \"lang\": \"hu\",\n                \"likes\": \"1\"\n            },\n            {\n                \"id\": \"177322\",\n                \"url\": \"http://assets.fanart.tv/fanart/tv/355730/hdclearart/the-wheel-of-time-64eda135933b6.png\",\n                \"lang\": \"cz\",\n                \"likes\": \"0\"\n            },\n            {\n                \"id\": \"142267\",\n                \"url\": \"http://assets.fanart.tv/fanart/tv/355730/hdclearart/the-wheel-of-time-61ccda9918c5c.png\",\n                \"lang\": \"de\",\n                \"likes\": \"0\"\n            }\n        ],\n        \"seasonposter\": [\n            {\n                \"id\": \"140199\",\n                \"url\": \"http://assets.fanart.tv/fanart/tv/355730/seasonposter/the-wheel-of-time-61a0d7c2976de.jpg\",\n                \"lang\": \"en\",\n                \"likes\": \"1\",\n                \"season\": \"1\"\n            },\n            {\n                \"id\": \"176395\",\n                \"url\": \"http://assets.fanart.tv/fanart/tv/355730/seasonposter/the-wheel-of-time-64dd80b3d79a9.jpg\",\n                \"lang\": \"en\",\n                \"likes\": \"0\",\n                \"season\": \"1\"\n            },\n            {\n                \"id\": \"140035\",\n                \"url\": \"http://assets.fanart.tv/fanart/tv/355730/seasonposter/the-wheel-of-time-619e65c4d5357.jpg\",\n                \"lang\": \"en\",\n                \"likes\": \"0\",\n                \"season\": \"1\"\n            }\n        ],\n        \"tvthumb\": [\n            {\n                \"id\": \"140242\",\n                \"url\": \"http://assets.fanart.tv/fanart/tv/355730/tvthumb/the-wheel-of-time-61a1813035506.jpg\",\n                \"lang\": \"en\",\n                \"likes\": \"1\"\n            },\n            {\n                \"id\": \"177323\",\n                \"url\": \"http://assets.fanart.tv/fanart/tv/355730/tvthumb/the-wheel-of-time-64eda15b6dce6.jpg\",\n                \"lang\": \"cz\",\n                \"likes\": \"0\"\n            },\n            {\n                \"id\": \"176399\",\n                \"url\": \"http://assets.fanart.tv/fanart/tv/355730/tvthumb/the-wheel-of-time-64dd85c9b618c.jpg\",\n                \"lang\": \"en\",\n                \"likes\": \"0\"\n            },\n            {\n                \"id\": \"152669\",\n                \"url\": \"http://assets.fanart.tv/fanart/tv/355730/tvthumb/the-wheel-of-time-62ced53d16574.jpg\",\n                \"lang\": \"pl\",\n                \"likes\": \"0\"\n            },\n            {\n                \"id\": \"141983\",\n                \"url\": \"http://assets.fanart.tv/fanart/tv/355730/tvthumb/the-wheel-of-time-61c6d04a6d701.jpg\",\n                \"lang\": \"en\",\n                \"likes\": \"0\"\n            }\n        ],\n        \"showbackground\": [\n            {\n                \"id\": \"177324\",\n                \"url\": \"http://assets.fanart.tv/fanart/tv/355730/showbackground/the-wheel-of-time-64eda1833ccb1.jpg\",\n                \"lang\": \"\",\n                \"likes\": \"0\",\n                \"season\": \"all\"\n            },\n            {\n                \"id\": \"141986\",\n                \"url\": \"http://assets.fanart.tv/fanart/tv/355730/showbackground/the-wheel-of-time-61c6d08f7c7e2.jpg\",\n                \"lang\": \"\",\n                \"likes\": \"0\",\n                \"season\": \"all\"\n            },\n            {\n                \"id\": \"139868\",\n                \"url\": \"http://assets.fanart.tv/fanart/tv/355730/showbackground/the-wheel-of-time-6198ce358b98a.jpg\",\n                \"lang\": \"\",\n                \"likes\": \"0\",\n                \"season\": \"all\"\n            }\n        ],\n        \"seasonthumb\": [\n            {\n                \"id\": \"176396\",\n                \"url\": \"http://assets.fanart.tv/fanart/tv/355730/seasonthumb/the-wheel-of-time-64dd80c8593f9.jpg\",\n                \"lang\": \"en\",\n                \"likes\": \"0\",\n                \"season\": \"1\"\n            },\n            {\n                \"id\": \"176400\",\n                \"url\": \"http://assets.fanart.tv/fanart/tv/355730/seasonthumb/the-wheel-of-time-64dd85da7c5e9.jpg\",\n                \"lang\": \"en\",\n                \"likes\": \"0\",\n                \"season\": \"0\"\n            }\n        ],\n        \"tvbanner\": [\n            {\n                \"id\": \"176397\",\n                \"url\": \"http://assets.fanart.tv/fanart/tv/355730/tvbanner/the-wheel-of-time-64dd80da9a255.jpg\",\n                \"lang\": \"en\",\n                \"likes\": \"0\"\n            },\n            {\n                \"id\": \"176401\",\n                \"url\": \"http://assets.fanart.tv/fanart/tv/355730/tvbanner/the-wheel-of-time-64dd85e8904ea.jpg\",\n                \"lang\": \"en\",\n                \"likes\": \"0\"\n            },\n            {\n                \"id\": \"141988\",\n                \"url\": \"http://assets.fanart.tv/fanart/tv/355730/tvbanner/the-wheel-of-time-61c6d34bceb5f.jpg\",\n                \"lang\": \"en\",\n                \"likes\": \"0\"\n            },\n            {\n                \"id\": \"141984\",\n                \"url\": \"http://assets.fanart.tv/fanart/tv/355730/tvbanner/the-wheel-of-time-61c6d06c1c21c.jpg\",\n                \"lang\": \"en\",\n                \"likes\": \"0\"\n            }\n        ],\n        \"seasonbanner\": [\n            {\n                \"id\": \"176398\",\n                \"url\": \"http://assets.fanart.tv/fanart/tv/355730/seasonbanner/the-wheel-of-time-64dd80e7dbd9f.jpg\",\n                \"lang\": \"en\",\n                \"likes\": \"0\",\n                \"season\": \"1\"\n            },\n            {\n                \"id\": \"176402\",\n                \"url\": \"http://assets.fanart.tv/fanart/tv/355730/seasonbanner/the-wheel-of-time-64dd85fb4f1b1.jpg\",\n                \"lang\": \"en\",\n                \"likes\": \"0\",\n                \"season\": \"0\"\n            }\n        ]\n    }\n    \"\"\"\n\n    # 代理\n    _proxies: dict = settings.PROXY\n\n    # Fanart Api\n    _movie_url: str = f'https://webservice.fanart.tv/v3/movies/%s?api_key={settings.FANART_API_KEY}'\n    _tv_url: str = f'https://webservice.fanart.tv/v3/tv/%s?api_key={settings.FANART_API_KEY}'\n\n    def init_module(self) -> None:\n        pass\n\n    def stop(self):\n        pass\n\n    def test(self) -> Tuple[bool, str]:\n        \"\"\"\n        测试模块连接性\n        \"\"\"\n        ret = RequestUtils().get_res(\"https://webservice.fanart.tv\")\n        if ret and ret.status_code == 200:\n            return True, \"\"\n        elif ret:\n            return False, f\"无法连接fanart，错误码：{ret.status_code}\"\n        return False, \"fanart网络连接失败\"\n\n    def init_setting(self) -> Tuple[str, Union[str, bool]]:\n        return \"FANART_API_KEY\", True\n\n    @staticmethod\n    def get_name() -> str:\n        return \"Fanart\"\n\n    @staticmethod\n    def get_type() -> ModuleType:\n        \"\"\"\n        获取模块类型\n        \"\"\"\n        return ModuleType.Other\n\n    @staticmethod\n    def get_subtype() -> OtherModulesType:\n        \"\"\"\n        获取模块子类型\n        \"\"\"\n        return OtherModulesType.Fanart\n\n    @staticmethod\n    def get_priority() -> int:\n        \"\"\"\n        获取模块优先级，数字越小优先级越高，只有同一接口下优先级才生效\n        \"\"\"\n        return 0\n\n    def obtain_images(self, mediainfo: MediaInfo) -> Optional[MediaInfo]:\n        \"\"\"\n        获取图片\n        :param mediainfo:  识别的媒体信息\n        :return: 更新后的媒体信息\n        \"\"\"\n        if not settings.FANART_ENABLE:\n            return None\n        if not mediainfo.tmdb_id and not mediainfo.tvdb_id:\n            return None\n        if mediainfo.type == MediaType.MOVIE:\n            result = self.__request_fanart(mediainfo.type, mediainfo.tmdb_id)\n        else:\n            if mediainfo.tvdb_id:\n                result = self.__request_fanart(mediainfo.type, mediainfo.tvdb_id)\n            else:\n                logger.info(f\"{mediainfo.title_year} 没有tvdbid，无法获取fanart图片\")\n                return None\n        if not result or result.get('status') == 'error':\n            logger.warn(f\"没有获取到 {mediainfo.title_year} 的fanart图片数据\")\n            return None\n        # 获取所有图片\n        for name, images in result.items():\n            if not images:\n                continue\n            if not isinstance(images, list):\n                continue\n\n            # 图片属性xx_path\n            image_name = self.__name(name)\n            if image_name.startswith(\"season\"):\n                # 季图片，图片格式seasonxx-xxxx/season-specials-xxxx\n                for image_obj in images:\n                    image_season = image_obj.get('season')\n                    if image_season is not None:\n                        # 包括poster,thumb,banner\n                        if image_season == '0':\n                            season_image = f\"season-specials-{image_name[6:]}\"\n                        else:\n                            season_image = f\"season{str(image_season).rjust(2, '0')}-{image_name[6:]}\"\n                        # 设置图片，没有图片才设置\n                        if not mediainfo.get_image(season_image):\n                            mediainfo.set_image(season_image, image_obj.get('url'))\n            else:\n\n                # 其他图片，优先环境变量指定语言，再like最多\n                def __pick_best_image(_images):\n                    lang_env = settings.FANART_LANG\n                    if lang_env:\n                        langs = [lang.strip() for lang in lang_env.split(\",\") if lang.strip()]\n                        for lang in langs:\n                            lang_images = [img for img in _images if img.get('lang') == lang]\n                            if lang_images:\n                                lang_images.sort(key=lambda x: int(x.get('likes', 0)), reverse=True)\n                                return lang_images[0]\n                    # 没设置或没找到，按原逻辑 zh、en、like最多\n                    zh_images = [img for img in _images if img.get('lang') == 'zh']\n                    if zh_images:\n                        zh_images.sort(key=lambda x: int(x.get('likes', 0)), reverse=True)\n                        return zh_images[0]\n                    en_images = [img for img in _images if img.get('lang') == 'en']\n                    if en_images:\n                        en_images.sort(key=lambda x: int(x.get('likes', 0)), reverse=True)\n                        return en_images[0]\n                    _images.sort(key=lambda x: int(x.get('likes', 0)), reverse=True)\n                    return _images[0]\n\n                image_obj = __pick_best_image(images)\n                # 设置图片，没有图片才设置\n                if not mediainfo.get_image(image_name):\n                    mediainfo.set_image(image_name, image_obj.get('url'))\n\n        return mediainfo\n\n    @staticmethod\n    def __name(fanart_name: str) -> str:\n        \"\"\"\n        转换Fanart图片的名字\n        \"\"\"\n        words_to_remove = r'tv|movie|hdmovie|hdtv|show|hd'\n        pattern = re.compile(words_to_remove, re.IGNORECASE)\n        result = re.sub(pattern, '', fanart_name)\n        return result\n\n    @classmethod\n    @cached(maxsize=settings.CONF.fanart, ttl=settings.CONF.meta, shared_key=\"get\")\n    def __request_fanart(cls, media_type: MediaType, queryid: Union[str, int]) -> Optional[dict]:\n        if media_type == MediaType.MOVIE:\n            image_url = cls._movie_url % queryid\n        else:\n            image_url = cls._tv_url % queryid\n        try:\n            ret = RequestUtils(proxies=cls._proxies, timeout=10).get_res(image_url, raise_exception=True)\n            if ret:\n                return ret.json()\n            else:\n                logger.debug(f\"未能获取到 {queryid} 的Fanart图片\")\n                return {}\n        except Exception as err:\n            logger.error(f\"获取{queryid}的Fanart图片失败：{str(err)}\")\n            return None\n\n    def clear_cache(self):\n        \"\"\"\n        清除缓存\n        \"\"\"\n        logger.info(f\"开始清除{self.get_name()}缓存 ...\")\n        self.__request_fanart.cache_clear()\n        logger.info(f\"{self.get_name()}缓存清除完成\")\n"
  },
  {
    "path": "app/modules/filemanager/__init__.py",
    "content": "from pathlib import Path\nfrom typing import Optional, List, Tuple, Union, Dict, Callable\n\nfrom app.chain.tmdb import TmdbChain\nfrom app.core.config import settings\nfrom app.core.context import MediaInfo\nfrom app.core.meta import MetaBase\nfrom app.core.metainfo import MetaInfo\nfrom app.helper.directory import DirectoryHelper\nfrom app.helper.message import MessageHelper\nfrom app.helper.module import ModuleHelper\nfrom app.log import logger\nfrom app.modules import _ModuleBase\nfrom app.modules.filemanager.storages import StorageBase\nfrom app.modules.filemanager.transhandler import TransHandler\nfrom app.schemas import TransferInfo, ExistMediaInfo, TmdbEpisode, TransferDirectoryConf, FileItem, StorageUsage\nfrom app.schemas.types import MediaType, ModuleType, OtherModulesType\nfrom app.utils.system import SystemUtils\n\n\nclass FileManagerModule(_ModuleBase):\n    \"\"\"\n    文件整理模块\n    \"\"\"\n\n    _storage_schemas = []\n    _support_storages = []\n\n    def __init__(self):\n        super().__init__()\n        self.directoryhelper = DirectoryHelper()\n        self.messagehelper = MessageHelper()\n\n    def init_module(self) -> None:\n        # 加载模块\n        self._storage_schemas = ModuleHelper.load('app.modules.filemanager.storages',\n                                                  filter_func=lambda _, obj: hasattr(obj, 'schema') and obj.schema)\n        # 获取存储类型\n        self._support_storages = [storage.schema.value for storage in self._storage_schemas if storage.schema]\n\n    @staticmethod\n    def get_name() -> str:\n        return \"文件整理\"\n\n    @staticmethod\n    def get_type() -> ModuleType:\n        \"\"\"\n        获取模块类型\n        \"\"\"\n        return ModuleType.Other\n\n    @staticmethod\n    def get_subtype() -> OtherModulesType:\n        \"\"\"\n        获取模块子类型\n        \"\"\"\n        return OtherModulesType.FileManager\n\n    @staticmethod\n    def get_priority() -> int:\n        \"\"\"\n        获取模块优先级，数字越小优先级越高，只有同一接口下优先级才生效\n        \"\"\"\n        return 4\n\n    def stop(self):\n        pass\n\n    def test(self) -> Tuple[bool, str]:\n        \"\"\"\n        测试模块连接性\n        \"\"\"\n        # 检查目录\n        dirs = self.directoryhelper.get_dirs()\n        if not dirs:\n            return False, \"未设置任何目录\"\n        for d in dirs:\n            # 下载目录\n            download_path = d.download_path\n            if not download_path:\n                return False, f\"{d.name} 的下载目录未设置\"\n            if d.storage == \"local\" and not Path(download_path).exists():\n                return False, f\"{d.name} 的下载目录 {download_path} 不存在\"\n            # 仅在启用整理时检查媒体库目录\n            library_path = d.library_path\n            if d.transfer_type:\n                if not library_path:\n                    return False, f\"{d.name} 的媒体库目录未设置\"\n                if d.library_storage == \"local\" and not Path(library_path).exists():\n                    return False, f\"{d.name} 的媒体库目录 {library_path} 不存在\"\n                # 硬链接\n                if d.transfer_type == \"link\" \\\n                        and d.storage == \"local\" \\\n                        and d.library_storage == \"local\" \\\n                        and not SystemUtils.is_same_disk(Path(download_path), Path(library_path)):\n                    return False, f\"{d.name} 的下载目录 {download_path} 与媒体库目录 {library_path} 不在同一磁盘，无法硬链接\"\n            # 存储\n            storage_oper = self.__get_storage_oper(d.storage)\n            if storage_oper:\n                if not storage_oper.check():\n                    return False, f\"{d.name} 的存储测试不通过\"\n                if d.transfer_type and d.transfer_type not in storage_oper.support_transtype():\n                    return False, f\"{d.name} 的存储不支持 {d.transfer_type} 整理方式\"\n\n        return True, \"\"\n\n    def __get_storage_oper(self, _storage: str, _func: Optional[str] = None) -> Optional[StorageBase]:\n        \"\"\"\n        获取存储操作对象\n        \"\"\"\n        for storage_schema in self._storage_schemas:\n            if storage_schema.schema \\\n                    and storage_schema.schema.value == _storage \\\n                    and (not _func or hasattr(storage_schema, _func)):\n                return storage_schema()\n        return None\n\n    def init_setting(self) -> Tuple[str, Union[str, bool]]:\n        pass\n\n    def support_transtype(self, storage: str) -> Optional[dict]:\n        \"\"\"\n        支持的整理方式\n        \"\"\"\n        if storage not in self._support_storages:\n            return None\n        storage_oper = self.__get_storage_oper(storage)\n        if not storage_oper:\n            logger.error(f\"不支持 {storage} 的整理方式获取\")\n            return None\n        return storage_oper.support_transtype()\n\n    @staticmethod\n    def recommend_name(meta: MetaBase, mediainfo: MediaInfo) -> Optional[str]:\n        \"\"\"\n        获取重命名后的名称\n        :param meta: 元数据\n        :param mediainfo: 媒体信息\n        :return: 重命名后的名称（含目录）\n        \"\"\"\n        handler = TransHandler()\n        # 重命名格式\n        rename_format = settings.RENAME_FORMAT(mediainfo.type)\n        # 获取集信息\n        episodes_info: Optional[List[TmdbEpisode]] = None\n        if mediainfo.type == MediaType.TV:\n            # 判断注意season为0的情况\n            season_num = mediainfo.season\n            if season_num is None and meta.season_seq:\n                if meta.season_seq.isdigit():\n                    season_num = int(meta.season_seq)\n            # 默认值1\n            if season_num is None:\n                season_num = 1\n            episodes_info = TmdbChain().tmdb_episodes(\n                tmdbid=mediainfo.tmdb_id,\n                season=season_num,\n                episode_group=mediainfo.episode_group,\n            )\n        # 获取重命名后的名称\n        path = handler.get_rename_path(\n            template_string=rename_format,\n            rename_dict=handler.get_naming_dict(meta=meta,\n                                                mediainfo=mediainfo,\n                                                episodes_info=episodes_info,\n                                                file_ext=Path(meta.title).suffix)\n        )\n        return path.as_posix() if path else \"\"\n\n    def save_config(self, storage: str, conf: Dict) -> None:\n        \"\"\"\n        保存存储配置\n        \"\"\"\n        storage_oper = self.__get_storage_oper(storage)\n        if not storage_oper:\n            logger.error(f\"不支持 {storage} 的配置保存\")\n            return\n        storage_oper.set_config(conf)\n\n    def reset_config(self, storage: str) -> None:\n        \"\"\"\n        重置存储配置\n        \"\"\"\n        storage_oper = self.__get_storage_oper(storage)\n        if not storage_oper:\n            logger.error(f\"不支持 {storage} 的重置存储配置\")\n            return\n        storage_oper.reset_config()\n\n    def generate_qrcode(self, storage: str) -> Optional[Tuple[dict, str]]:\n        \"\"\"\n        生成二维码\n        \"\"\"\n        storage_oper = self.__get_storage_oper(storage, \"generate_qrcode\")\n        if not storage_oper:\n            logger.error(f\"不支持 {storage} 的二维码生成\")\n            return None\n        return storage_oper.generate_qrcode()\n\n    def generate_auth_url(self, storage: str) -> Optional[Tuple[dict, str]]:\n        \"\"\"\n        生成 OAuth2 授权 URL\n        \"\"\"\n        storage_oper = self.__get_storage_oper(storage, \"generate_auth_url\")\n        if not storage_oper:\n            logger.error(f\"不支持 {storage} 的 OAuth2 授权\")\n            return {}, f\"不支持 {storage} 的 OAuth2 授权\"\n        return storage_oper.generate_auth_url()\n\n    def check_login(self, storage: str, **kwargs) -> Optional[Dict[str, str]]:\n        \"\"\"\n        登录确认\n        \"\"\"\n        storage_oper = self.__get_storage_oper(storage, \"check_login\")\n        if not storage_oper:\n            logger.error(f\"不支持 {storage} 的登录确认\")\n            return None\n        return storage_oper.check_login(**kwargs)\n\n    def list_files(self, fileitem: FileItem, recursion: Optional[bool] = False) -> Optional[List[FileItem]]:\n        \"\"\"\n        浏览文件\n        :param fileitem: 源文件\n        :param recursion: 是否递归，此时只浏览文件\n        :return: 文件项列表\n        \"\"\"\n        if fileitem.storage not in self._support_storages:\n            return None\n        storage_oper = self.__get_storage_oper(fileitem.storage)\n        if not storage_oper:\n            logger.error(f\"不支持 {fileitem.storage} 的文件浏览\")\n            return None\n\n        def __get_files(_item: FileItem, _r: Optional[bool] = False):\n            \"\"\"\n            递归处理\n            \"\"\"\n            _items = storage_oper.list(_item)\n            if _items:\n                if _r:\n                    for t in _items:\n                        if t.type == \"dir\":\n                            __get_files(t, _r)\n                        else:\n                            result.append(t)\n                else:\n                    result.extend(_items)\n\n        # 返回结果\n        result = []\n        __get_files(fileitem, recursion)\n\n        return result\n\n    def any_files(self, fileitem: FileItem, extensions: list = None) -> Optional[bool]:\n        \"\"\"\n        查询当前目录下是否存在指定扩展名任意文件\n        \"\"\"\n        if fileitem.storage not in self._support_storages:\n            return None\n        storage_oper = self.__get_storage_oper(fileitem.storage)\n        if not storage_oper:\n            logger.error(f\"不支持 {fileitem.storage} 的文件浏览\")\n            return None\n\n        def __any_file(_item: FileItem):\n            \"\"\"\n            递归处理\n            \"\"\"\n            _items = storage_oper.list(_item)\n            if _items:\n                if not extensions:\n                    return True\n                for t in _items:\n                    if (t.type == \"file\"\n                            and t.extension\n                            and f\".{t.extension.lower()}\" in extensions):\n                        return True\n                    elif t.type == \"dir\":\n                        if __any_file(t):\n                            return True\n            return False\n\n        # 返回结果\n        return __any_file(fileitem)\n\n    def create_folder(self, fileitem: FileItem, name: str) -> Optional[FileItem]:\n        \"\"\"\n        创建目录\n        :param fileitem: 源文件\n        :param name: 目录名\n        :return: 创建的目录\n        \"\"\"\n        if fileitem.storage not in self._support_storages:\n            return None\n        storage_oper = self.__get_storage_oper(fileitem.storage)\n        if not storage_oper:\n            logger.error(f\"不支持 {fileitem.storage} 的目录创建\")\n            return None\n        return storage_oper.create_folder(fileitem, name)\n\n    def delete_file(self, fileitem: FileItem) -> Optional[bool]:\n        \"\"\"\n        删除文件或目录\n        \"\"\"\n        if fileitem.storage not in self._support_storages:\n            return None\n        storage_oper = self.__get_storage_oper(fileitem.storage)\n        if not storage_oper:\n            logger.error(f\"不支持 {fileitem.storage} 的删除处理\")\n            return False\n        return storage_oper.delete(fileitem)\n\n    def rename_file(self, fileitem: FileItem, name: str) -> Optional[bool]:\n        \"\"\"\n        重命名文件或目录\n        \"\"\"\n        if fileitem.storage not in self._support_storages:\n            return None\n        storage_oper = self.__get_storage_oper(fileitem.storage)\n        if not storage_oper:\n            logger.error(f\"不支持 {fileitem.storage} 的重命名处理\")\n            return False\n        return storage_oper.rename(fileitem, name)\n\n    def download_file(self, fileitem: FileItem, path: Path = None) -> Optional[Path]:\n        \"\"\"\n        下载文件\n        \"\"\"\n        if fileitem.storage not in self._support_storages:\n            return None\n        storage_oper = self.__get_storage_oper(fileitem.storage)\n        if not storage_oper:\n            logger.error(f\"不支持 {fileitem.storage} 的下载处理\")\n            return None\n        return storage_oper.download(fileitem, path=path)\n\n    def upload_file(self, fileitem: FileItem, path: Path, new_name: Optional[str] = None) -> Optional[FileItem]:\n        \"\"\"\n        上传文件\n        \"\"\"\n        if fileitem.storage not in self._support_storages:\n            return None\n        storage_oper = self.__get_storage_oper(fileitem.storage)\n        if not storage_oper:\n            logger.error(f\"不支持 {fileitem.storage} 的上传处理\")\n            return None\n        return storage_oper.upload(fileitem, path, new_name)\n\n    def get_file_item(self, storage: str, path: Path) -> Optional[FileItem]:\n        \"\"\"\n        根据路径获取文件项\n        \"\"\"\n        if storage not in self._support_storages:\n            return None\n        storage_oper = self.__get_storage_oper(storage)\n        if not storage_oper:\n            logger.error(f\"不支持 {storage} 的文件获取\")\n            return None\n        return storage_oper.get_item(path)\n\n    def get_parent_item(self, fileitem: FileItem) -> Optional[FileItem]:\n        \"\"\"\n        获取上级目录项\n        \"\"\"\n        if fileitem.storage not in self._support_storages:\n            return None\n        storage_oper = self.__get_storage_oper(fileitem.storage)\n        if not storage_oper:\n            logger.error(f\"不支持 {fileitem.storage} 的文件获取\")\n            return None\n        return storage_oper.get_parent(fileitem)\n\n    def snapshot_storage(self, storage: str, path: Path,\n                         last_snapshot_time: float = None, max_depth: int = 5) -> Optional[Dict[str, Dict]]:\n        \"\"\"\n        快照存储\n        :param storage: 存储类型\n        :param path: 路径\n        :param last_snapshot_time: 上次快照时间，用于增量快照\n        :param max_depth: 最大递归深度，避免过深遍历\n        \"\"\"\n        if storage not in self._support_storages:\n            return None\n        storage_oper = self.__get_storage_oper(storage)\n        if not storage_oper:\n            logger.error(f\"不支持 {storage} 的快照处理\")\n            return None\n        return storage_oper.snapshot(path, last_snapshot_time=last_snapshot_time, max_depth=max_depth)\n\n    def storage_usage(self, storage: str) -> Optional[StorageUsage]:\n        \"\"\"\n        存储使用情况\n        \"\"\"\n        if storage not in self._support_storages:\n            return None\n        storage_oper = self.__get_storage_oper(storage)\n        if not storage_oper:\n            logger.error(f\"不支持 {storage} 的存储使用情况\")\n            return None\n        return storage_oper.usage()\n\n    def transfer(self, fileitem: FileItem, meta: MetaBase, mediainfo: MediaInfo,\n                 target_directory: TransferDirectoryConf = None,\n                 target_storage: Optional[str] = None, target_path: Path = None,\n                 transfer_type: Optional[str] = None, scrape: Optional[bool] = None,\n                 library_type_folder: Optional[bool] = None, library_category_folder: Optional[bool] = None,\n                 episodes_info: List[TmdbEpisode] = None,\n                 source_oper: Callable = None, target_oper: Callable = None) -> TransferInfo:\n        \"\"\"\n        文件整理\n        :param fileitem:  文件信息\n        :param meta: 预识别的元数据\n        :param mediainfo:  识别的媒体信息\n        :param target_directory:  目标目录配置\n        :param target_storage:  目标存储\n        :param target_path:  目标路径\n        :param transfer_type:  转移模式\n        :param scrape: 是否刮削元数据\n        :param library_type_folder: 是否按媒体类型创建目录\n        :param library_category_folder: 是否按媒体类别创建目录\n        :param episodes_info: 当前季的全部集信息\n        :param source_oper: 源存储操作对象\n        :param target_oper: 目标存储操作对象\n        :return: {path, target_path, message}\n        \"\"\"\n        handler = TransHandler()\n        # 检查目录路径\n        if fileitem.storage == \"local\" and not Path(fileitem.path).exists():\n            return TransferInfo(success=False,\n                                fileitem=fileitem,\n                                message=f\"{fileitem.path} 不存在\")\n        # 目标路径不能是文件\n        if target_path and target_path.is_file():\n            logger.error(f\"整理目标路径 {target_path} 是一个文件\")\n            return TransferInfo(success=False,\n                                fileitem=fileitem,\n                                message=f\"{target_path} 不是有效目录\")\n        # 获取目标路径\n        if target_directory:\n            # 目标媒体库目录未设置\n            if not target_directory.library_path:\n                logger.error(f\"目标媒体库目录未设置，无法整理文件，源路径：{fileitem.path}\")\n                return TransferInfo(success=False,\n                                    fileitem=fileitem,\n                                    message=\"目标媒体库目录未设置\")\n            # 整理方式\n            if not transfer_type:\n                transfer_type = target_directory.transfer_type\n            # 目标存储\n            if not target_storage:\n                target_storage = target_directory.library_storage\n            # 是否需要重命名\n            need_rename = target_directory.renaming\n            # 是否需要通知\n            need_notify = target_directory.notify\n            # 覆盖模式\n            overwrite_mode = target_directory.overwrite_mode\n            # 是否需要刮削\n            need_scrape = target_directory.scraping if scrape is None else scrape\n            # 拼装媒体库一、二级子目录\n            target_path = handler.get_dest_dir(mediainfo=mediainfo, target_dir=target_directory,\n                                               need_type_folder=library_type_folder,\n                                               need_category_folder=library_category_folder)\n        elif target_path:\n            need_scrape = scrape or False\n            need_rename = True\n            need_notify = False\n            overwrite_mode = \"never\"\n            # 手动整理的场景，有自定义目标路径\n            target_path = handler.get_dest_path(mediainfo=mediainfo, target_path=target_path,\n                                                need_type_folder=library_type_folder,\n                                                need_category_folder=library_category_folder)\n        else:\n            # 未找到有效的媒体库目录\n            logger.error(\n                f\"{mediainfo.type.value if mediainfo.type else '未知类型'} {mediainfo.title_year} 未找到有效的媒体库目录，无法整理文件，源路径：{fileitem.path}\")\n            return TransferInfo(success=False,\n                                fileitem=fileitem,\n                                message=\"未找到有效的媒体库目录\")\n        # 整理方式\n        if not transfer_type:\n            logger.error(f\"{target_directory.name} 未设置整理方式\")\n            return TransferInfo(success=False,\n                                fileitem=fileitem,\n                                message=f\"{target_directory.name} 未设置整理方式\")\n\n        # 源操作对象\n        if not source_oper:\n            source_oper = self.__get_storage_oper(fileitem.storage)\n        if not source_oper:\n            return TransferInfo(success=False,\n                                message=f\"不支持的存储类型：{fileitem.storage}\",\n                                fileitem=fileitem,\n                                fail_list=[fileitem.path],\n                                transfer_type=transfer_type,\n                                need_notify=need_notify\n                                )\n        # 目的操作对象\n        if not target_oper:\n            if not target_storage:\n                target_storage = fileitem.storage\n            target_oper = self.__get_storage_oper(target_storage)\n        if not target_oper:\n            return TransferInfo(success=False,\n                                message=f\"不支持的存储类型：{target_storage}\",\n                                fileitem=fileitem,\n                                fail_list=[fileitem.path],\n                                transfer_type=transfer_type,\n                                need_notify=need_notify)\n\n        # 整理\n        logger.info(f\"获取整理目标路径：【{target_storage}】{target_path}\")\n        return handler.transfer_media(fileitem=fileitem,\n                                      in_meta=meta,\n                                      mediainfo=mediainfo,\n                                      target_storage=target_storage,\n                                      target_path=target_path,\n                                      transfer_type=transfer_type,\n                                      need_scrape=need_scrape,\n                                      need_rename=need_rename,\n                                      need_notify=need_notify,\n                                      overwrite_mode=overwrite_mode,\n                                      episodes_info=episodes_info,\n                                      source_oper=source_oper,\n                                      target_oper=target_oper)\n\n    def media_files(self, mediainfo: MediaInfo) -> List[FileItem]:\n        \"\"\"\n        获取对应媒体的媒体库文件列表\n        :param mediainfo: 媒体信息\n        \"\"\"\n        handler = TransHandler()\n        ret_fileitems = []\n        # 检查本地媒体库\n        dest_dirs = DirectoryHelper().get_library_dirs()\n        # 检查每一个媒体库目录\n        for dest_dir in dest_dirs:\n            # 存储\n            storage_oper = self.__get_storage_oper(dest_dir.library_storage)\n            if not storage_oper:\n                continue\n            # 媒体分类路径\n            dir_path = handler.get_dest_dir(mediainfo=mediainfo, target_dir=dest_dir)\n            # 重命名格式\n            rename_format = settings.RENAME_FORMAT(mediainfo.type)\n            # 元数据补上常用属性，尽可能确保重命名后的路径不出现空白\n            meta = MetaInfo(mediainfo.title)\n            if meta.type == MediaType.UNKNOWN and mediainfo.type is not None:\n                meta.type = mediainfo.type\n            if meta.year is None:\n                meta.year = mediainfo.year\n            if meta.begin_season is None:\n                meta.begin_season = 1\n            if meta.begin_episode is None:\n                meta.begin_episode = 1\n            # 获取路径（重命名路径）\n            target_path = handler.get_rename_path(\n                path=dir_path,\n                template_string=rename_format,\n                rename_dict=handler.get_naming_dict(meta=meta,\n                                                    mediainfo=mediainfo)\n            )\n            # 获取重命名后的媒体文件根路径\n            media_path = DirectoryHelper.get_media_root_path(\n                rename_format, rename_path=target_path\n            )\n            if not media_path:\n                # 忽略\n                continue\n            if dir_path != media_path and dir_path.is_relative_to(media_path):\n                # 兜底检查，避免不必要的扫盘\n                logger.warn(f\"{media_path} 是媒体库目录 {dir_path} 的父目录，忽略获取媒体文件列表，请检查重命名格式！\")\n                continue\n            # 检索媒体文件\n            fileitem = storage_oper.get_item(media_path)\n            if not fileitem:\n                continue\n            try:\n                media_files = self.list_files(fileitem, True)\n            except Exception as e:\n                logger.debug(f\"获取媒体文件列表失败：{str(e)}\")\n                continue\n            if media_files:\n                for media_file in media_files:\n                    if f\".{media_file.extension.lower()}\" in settings.RMT_MEDIAEXT:\n                        if media_file not in ret_fileitems:\n                            ret_fileitems.append(media_file)\n        return ret_fileitems\n\n    def media_exists(self, mediainfo: MediaInfo, **kwargs) -> Optional[ExistMediaInfo]:\n        \"\"\"\n        判断媒体文件是否存在于文件系统（网盘或本地文件），只支持标准媒体库结构\n        :param mediainfo:  识别的媒体信息\n        :return: 如不存在返回None，存在时返回信息，包括每季已存在所有集{type: movie/tv, seasons: {season: [episodes]}}\n        \"\"\"\n        if not settings.LOCAL_EXISTS_SEARCH:\n            return None\n\n        logger.debug(f\"正在本地媒体库中查找 {mediainfo.title_year}...\")\n\n        # 检查媒体库\n        fileitems = self.media_files(mediainfo)\n        if not fileitems:\n            logger.debug(f\"{mediainfo.title_year} 不在本地媒体库中\")\n            return None\n\n        if mediainfo.type == MediaType.MOVIE:\n            # 电影存在任何文件为存在\n            logger.info(f\"{mediainfo.title_year} 在本地文件系统中找到了\")\n            return ExistMediaInfo(type=MediaType.MOVIE)\n        else:\n            # 电视剧检索集数\n            seasons: Dict[int, list] = {}\n            for fileitem in fileitems:\n                file_meta = MetaInfo(fileitem.basename)\n                season_index = file_meta.begin_season or 1\n                episode_index = file_meta.begin_episode\n                if not episode_index:\n                    continue\n                if season_index not in seasons:\n                    seasons[season_index] = []\n                if episode_index not in seasons[season_index]:\n                    seasons[season_index].append(episode_index)\n            # 返回剧集情况\n            logger.info(f\"{mediainfo.title_year} 在本地文件系统中找到了这些季集：{seasons}\")\n            return ExistMediaInfo(type=MediaType.TV, seasons=seasons)\n"
  },
  {
    "path": "app/modules/filemanager/storages/__init__.py",
    "content": "from abc import ABCMeta, abstractmethod\nfrom pathlib import Path\nfrom typing import Optional, List, Dict, Tuple, Callable, Union\n\nfrom tqdm import tqdm\n\nfrom app import schemas\nfrom app.helper.progress import ProgressHelper\nfrom app.helper.storage import StorageHelper\nfrom app.log import logger\nfrom app.utils.crypto import HashUtils\n\n\ndef transfer_process(path: str) -> Callable[[int | float], None]:\n    \"\"\"\n    传输进度回调\n    \"\"\"\n    pbar = tqdm(total=100, desc=\"进度\", unit=\"%\")\n    progress = ProgressHelper(HashUtils.md5(path))\n    progress.start()\n\n    def update_progress(percent: Union[int, float]) -> None:\n        \"\"\"\n        更新进度百分比\n        \"\"\"\n        percent_value = round(percent, 2) if isinstance(percent, float) else percent\n        pbar.n = percent_value\n        # 更新进度\n        pbar.refresh()\n        progress.update(value=percent_value, text=f\"{path} 进度：{percent_value}%\")\n        # 完成时结束\n        if percent_value >= 100:\n            progress.end()\n            pbar.close()\n\n    return update_progress\n\n\nclass StorageBase(metaclass=ABCMeta):\n    \"\"\"\n    存储基类\n    \"\"\"\n    schema = None\n    transtype = {}\n    snapshot_check_folder_modtime = True\n\n    def __init__(self):\n        self.storagehelper = StorageHelper()\n\n    @abstractmethod\n    def init_storage(self):\n        \"\"\"\n        初始化\n        \"\"\"\n        pass\n\n    def generate_qrcode(self, *args, **kwargs) -> Optional[Tuple[dict, str]]:\n        pass\n\n    def generate_auth_url(self, *args, **kwargs) -> Optional[Tuple[dict, str]]:\n        \"\"\"\n        生成 OAuth2 授权 URL\n        \"\"\"\n        return {}, \"此存储不支持 OAuth2 授权\"\n\n    def check_login(self, *args, **kwargs) -> Optional[Dict[str, str]]:\n        pass\n\n    def get_config(self) -> Optional[schemas.StorageConf]:\n        \"\"\"\n        获取配置\n        \"\"\"\n        return self.storagehelper.get_storage(self.schema.value)\n\n    def get_conf(self) -> dict:\n        \"\"\"\n        获取配置\n        \"\"\"\n        conf = self.get_config()\n        return conf.config if conf else {}\n\n    def set_config(self, conf: dict):\n        \"\"\"\n        设置配置\n        \"\"\"\n        self.storagehelper.set_storage(self.schema.value, conf)\n        self.init_storage()\n\n    def support_transtype(self) -> dict:\n        \"\"\"\n        支持的整理方式\n        \"\"\"\n        return self.transtype\n\n    def is_support_transtype(self, transtype: str) -> bool:\n        \"\"\"\n        是否支持整理方式\n        \"\"\"\n        return transtype in self.transtype\n\n    def reset_config(self):\n        \"\"\"\n        重置置配置\n        \"\"\"\n        self.storagehelper.reset_storage(self.schema.value)\n        self.init_storage()\n\n    @abstractmethod\n    def check(self) -> bool:\n        \"\"\"\n        检查存储是否可用\n        \"\"\"\n        pass\n\n    @abstractmethod\n    def list(self, fileitem: schemas.FileItem) -> List[schemas.FileItem]:\n        \"\"\"\n        浏览文件\n        \"\"\"\n        pass\n\n    @abstractmethod\n    def create_folder(self, fileitem: schemas.FileItem, name: str) -> Optional[schemas.FileItem]:\n        \"\"\"\n        创建目录\n        :param fileitem: 父目录\n        :param name: 目录名\n        \"\"\"\n        pass\n\n    @abstractmethod\n    def get_folder(self, path: Path) -> Optional[schemas.FileItem]:\n        \"\"\"\n        获取目录，如目录不存在则创建\n        \"\"\"\n        pass\n\n    @abstractmethod\n    def get_item(self, path: Path) -> Optional[schemas.FileItem]:\n        \"\"\"\n        获取文件或目录，不存在返回None\n        \"\"\"\n        pass\n\n    def get_parent(self, fileitem: schemas.FileItem) -> Optional[schemas.FileItem]:\n        \"\"\"\n        获取父目录\n        \"\"\"\n        return self.get_item(Path(fileitem.path).parent)\n\n    @abstractmethod\n    def delete(self, fileitem: schemas.FileItem) -> bool:\n        \"\"\"\n        删除文件\n        \"\"\"\n        pass\n\n    @abstractmethod\n    def rename(self, fileitem: schemas.FileItem, name: str) -> bool:\n        \"\"\"\n        重命名文件\n        \"\"\"\n        pass\n\n    @abstractmethod\n    def download(self, fileitem: schemas.FileItem, path: Path = None) -> Path:\n        \"\"\"\n        下载文件，保存到本地，返回本地临时文件地址\n        :param fileitem: 文件项\n        :param path: 文件保存路径\n        \"\"\"\n        pass\n\n    @abstractmethod\n    def upload(self, fileitem: schemas.FileItem, path: Path,\n               new_name: Optional[str] = None) -> Optional[schemas.FileItem]:\n        \"\"\"\n        上传文件\n        :param fileitem: 上传目录项\n        :param path: 本地文件路径\n        :param new_name: 上传后文件名\n        \"\"\"\n        pass\n\n    @abstractmethod\n    def detail(self, fileitem: schemas.FileItem) -> Optional[schemas.FileItem]:\n        \"\"\"\n        获取文件详情\n        \"\"\"\n        pass\n\n    @abstractmethod\n    def copy(self, fileitem: schemas.FileItem, path: Path, new_name: str) -> bool:\n        \"\"\"\n        复制文件\n        :param fileitem: 文件项\n        :param path: 目标目录\n        :param new_name: 新文件名\n        \"\"\"\n        pass\n\n    @abstractmethod\n    def move(self, fileitem: schemas.FileItem, path: Path, new_name: str) -> bool:\n        \"\"\"\n        移动文件\n        :param fileitem: 文件项\n        :param path: 目标目录\n        :param new_name: 新文件名\n        \"\"\"\n        pass\n\n    @abstractmethod\n    def link(self, fileitem: schemas.FileItem, target_file: Path) -> bool:\n        \"\"\"\n        硬链接文件\n        \"\"\"\n        pass\n\n    @abstractmethod\n    def softlink(self, fileitem: schemas.FileItem, target_file: Path) -> bool:\n        \"\"\"\n        软链接文件\n        \"\"\"\n        pass\n\n    @abstractmethod\n    def usage(self) -> Optional[schemas.StorageUsage]:\n        \"\"\"\n        存储使用情况\n        \"\"\"\n        pass\n\n    def snapshot(self, path: Path, last_snapshot_time: float = None, max_depth: int = 5) -> Dict[str, Dict]:\n        \"\"\"\n        快照文件系统，输出所有层级文件信息（不含目录）\n        :param path: 路径\n        :param last_snapshot_time: 上次快照时间，用于增量快照\n        :param max_depth: 最大递归深度，避免过深遍历\n        \"\"\"\n        files_info = {}\n\n        def __snapshot_file(_fileitm: schemas.FileItem, current_depth: int = 0):\n            \"\"\"\n            递归获取文件信息\n            \"\"\"\n            try:\n                if _fileitm.type == \"dir\":\n                    # 检查递归深度限制\n                    if current_depth >= max_depth:\n                        return\n\n                    # 增量检查：如果目录修改时间早于上次快照，跳过\n                    if (self.snapshot_check_folder_modtime and\n                            last_snapshot_time and\n                            _fileitm.modify_time and\n                            _fileitm.modify_time <= last_snapshot_time):\n                        return\n\n                    # 遍历子文件\n                    sub_files = self.list(_fileitm)\n                    for sub_file in sub_files:\n                        __snapshot_file(sub_file, current_depth + 1)\n                else:\n                    # 记录文件的完整信息用于比对（始终包含所有文件，由 compare_snapshots 负责检测变化）\n                    files_info[_fileitm.path] = {\n                        'size': _fileitm.size or 0,\n                        'modify_time': getattr(_fileitm, 'modify_time', 0),\n                        'type': _fileitm.type\n                    }\n\n            except Exception as e:\n                logger.debug(f\"Snapshot error for {_fileitm.path}: {e}\")\n\n        fileitem = self.get_item(path)\n        if not fileitem:\n            return {}\n\n        __snapshot_file(fileitem)\n\n        return files_info\n"
  },
  {
    "path": "app/modules/filemanager/storages/alipan.py",
    "content": "import base64\nimport hashlib\nimport secrets\nimport threading\nimport time\nfrom pathlib import Path\nfrom typing import List, Optional, Tuple, Union\n\nimport requests\n\nfrom app import schemas\nfrom app.core.config import settings, global_vars\nfrom app.log import logger\nfrom app.modules.filemanager import StorageBase\nfrom app.modules.filemanager.storages import transfer_process\nfrom app.schemas.types import StorageSchema\nfrom app.utils.http import RequestUtils\nfrom app.utils.singleton import WeakSingleton\nfrom app.utils.string import StringUtils\n\nlock = threading.Lock()\n\n\nclass NoCheckInException(Exception):\n    pass\n\n\nclass SessionInvalidException(Exception):\n    pass\n\n\nclass AliPan(StorageBase, metaclass=WeakSingleton):\n    \"\"\"\n    阿里云盘相关操作\n    \"\"\"\n\n    # 存储类型\n    schema = StorageSchema.Alipan\n\n    # 支持的整理方式\n    transtype = {\"move\": \"移动\", \"copy\": \"复制\"}\n\n    # 基础url\n    base_url = \"https://openapi.alipan.com\"\n\n    # 阿里云盘目录时间不随子文件变更而更新，默认关闭目录修改时间检查\n    snapshot_check_folder_modtime = settings.ALIPAN_SNAPSHOT_CHECK_FOLDER_MODTIME\n\n    # 文件块大小，默认10MB\n    chunk_size = 10 * 1024 * 1024\n\n    def __init__(self):\n        super().__init__()\n        self._auth_state = {}\n        self.session = requests.Session()\n        self._init_session()\n\n    def _init_session(self):\n        \"\"\"\n        初始化带速率限制的会话\n        \"\"\"\n        self.session.headers.update({\"Content-Type\": \"application/json\"})\n\n    def _check_session(self):\n        \"\"\"\n        检查会话是否过期\n        \"\"\"\n        if not self.access_token:\n            raise NoCheckInException(\"【阿里云盘】请先扫码登录！\")\n\n    @property\n    def _default_drive_id(self) -> str:\n        \"\"\"\n        获取默认存储桶ID\n        \"\"\"\n        conf = self.get_conf()\n        drive_id = (\n            conf.get(\"resource_drive_id\")\n            or conf.get(\"backup_drive_id\")\n            or conf.get(\"default_drive_id\")\n        )\n        if not drive_id:\n            raise NoCheckInException(\"【阿里云盘】请先扫码登录！\")\n        return drive_id\n\n    @property\n    def access_token(self) -> Optional[str]:\n        \"\"\"\n        访问token\n        \"\"\"\n        with lock:\n            tokens = self.get_conf()\n            refresh_token = tokens.get(\"refresh_token\")\n            expires_in = tokens.get(\"expires_in\", 0)\n            refresh_time = tokens.get(\"refresh_time\", 0)\n            if expires_in and refresh_time + expires_in < int(time.time()):\n                tokens = self.__refresh_access_token(refresh_token)\n                if tokens:\n                    self.set_config({\"refresh_time\": int(time.time()), **tokens})\n            access_token = tokens.get(\"access_token\")\n            if access_token:\n                self.session.headers.update({\"Authorization\": f\"Bearer {access_token}\"})\n            return access_token\n\n    def generate_qrcode(self) -> Tuple[dict, str]:\n        \"\"\"\n        实现PKCE规范的设备授权二维码生成\n        \"\"\"\n\n        # 生成PKCE参数\n        code_verifier = secrets.token_urlsafe(96)[:128]\n        # 请求设备码\n        resp = self.session.post(\n            f\"{self.base_url}/oauth/authorize/qrcode\",\n            json={\n                \"client_id\": settings.ALIPAN_APP_ID,\n                \"scopes\": [\n                    \"user:base\",\n                    \"file:all:read\",\n                    \"file:all:write\",\n                    \"file:share:write\",\n                ],\n                \"code_challenge\": code_verifier,\n                \"code_challenge_method\": \"plain\",\n            },\n        )\n        if resp is None:\n            return {}, \"网络错误\"\n        result = resp.json()\n        if result.get(\"code\"):\n            return {}, result.get(\"message\")\n        # 持久化验证参数\n        self._auth_state = {\"sid\": result.get(\"sid\"), \"code_verifier\": code_verifier}\n        # 生成二维码内容\n        return {\"codeUrl\": result.get(\"qrCodeUrl\")}, \"\"\n\n    def check_login(self) -> Optional[Tuple[dict, str]]:\n        \"\"\"\n        改进的带PKCE校验的登录状态检查\n        \"\"\"\n\n        _status_text = {\n            \"WaitLogin\": \"等待登录\",\n            \"ScanSuccess\": \"扫码成功\",\n            \"LoginSuccess\": \"登录成功\",\n            \"QRCodeExpired\": \"二维码过期\",\n        }\n\n        if not self._auth_state:\n            return {}, \"生成二维码失败\"\n        try:\n            resp = self.session.get(\n                f\"{self.base_url}/oauth/qrcode/{self._auth_state['sid']}/status\"\n            )\n            if resp is None:\n                return {}, \"网络错误\"\n            result = resp.json()\n            # 扫码结果\n            status = result.get(\"status\")\n            if status == \"LoginSuccess\":\n                authCode = result.get(\"authCode\")\n                self._auth_state[\"authCode\"] = authCode\n                tokens = self.__get_access_token()\n                if tokens:\n                    self.set_config({\"refresh_time\": int(time.time()), **tokens})\n                    self.__get_drive_id()\n            return {\"status\": status, \"tip\": _status_text.get(status, \"未知错误\")}, \"\"\n        except Exception as e:\n            return {}, str(e)\n\n    def __get_access_token(self) -> dict:\n        \"\"\"\n        确认登录后，获取相关token\n        \"\"\"\n        if not self._auth_state:\n            raise SessionInvalidException(\"【阿里云盘】请先生成二维码\")\n        resp = self.session.post(\n            f\"{self.base_url}/oauth/access_token\",\n            json={\n                \"client_id\": settings.ALIPAN_APP_ID,\n                \"grant_type\": \"authorization_code\",\n                \"code\": self._auth_state[\"authCode\"],\n                \"code_verifier\": self._auth_state[\"code_verifier\"],\n            },\n        )\n        if resp is None:\n            raise SessionInvalidException(\"【阿里云盘】获取 access_token 失败\")\n        result = resp.json()\n        if result.get(\"code\"):\n            raise Exception(\n                f\"【阿里云盘】{result.get('code')} - {result.get('message')}！\"\n            )\n        return result\n\n    def __refresh_access_token(self, refresh_token: str) -> Optional[dict]:\n        \"\"\"\n        刷新access_token\n        \"\"\"\n        if not refresh_token:\n            raise SessionInvalidException(\"【阿里云盘】会话失效，请重新扫码登录！\")\n        resp = self.session.post(\n            f\"{self.base_url}/oauth/access_token\",\n            json={\n                \"client_id\": settings.ALIPAN_APP_ID,\n                \"grant_type\": \"refresh_token\",\n                \"refresh_token\": refresh_token,\n            },\n        )\n        if resp is None:\n            logger.error(\n                f\"【阿里云盘】刷新 access_token 失败：refresh_token={refresh_token}\"\n            )\n            return None\n        result = resp.json()\n        if result.get(\"code\"):\n            logger.warn(\n                f\"【阿里云盘】刷新 access_token 失败：{result.get('code')} - {result.get('message')}！\"\n            )\n        return result\n\n    def __get_drive_id(self):\n        \"\"\"\n        获取默认存储桶ID\n        \"\"\"\n        resp = self.session.post(f\"{self.base_url}/adrive/v1.0/user/getDriveInfo\")\n        if resp is None:\n            logger.error(\"获取默认存储桶ID失败\")\n            return None\n        result = resp.json()\n        if result.get(\"code\"):\n            logger.warn(\n                f\"获取默认存储ID失败：{result.get('code')} - {result.get('message')}！\"\n            )\n            return None\n        # 保存用户参数\n        \"\"\"\n        user_id\tstring\t是\t用户ID，具有唯一性\n        name\tstring\t是\t昵称\n        avatar\tstring\t是\t头像地址\n        default_drive_id\tstring\t是\t默认drive\n        resource_drive_id\tstring\t否\t资源库。用户选择了授权才会返回\n        backup_drive_id\tstring\t否\t备份盘。用户选择了授权才会返回\n        \"\"\"\n        conf = self.get_conf()\n        conf.update(result)\n        self.set_config(conf)\n        return None\n\n    def _request_api(\n        self, method: str, endpoint: str, result_key: Optional[str] = None, **kwargs\n    ) -> Optional[Union[dict, list]]:\n        \"\"\"\n        带错误处理和速率限制的API请求\n        \"\"\"\n        # 检查会话\n        self._check_session()\n\n        # 错误日志控制\n        no_error_log = kwargs.pop(\"no_error_log\", False)\n\n        try:\n            resp = self.session.request(method, f\"{self.base_url}{endpoint}\", **kwargs)\n        except requests.exceptions.RequestException as e:\n            logger.error(f\"【阿里云盘】{method} 请求 {endpoint} 网络错误: {str(e)}\")\n            return None\n\n        if resp is None:\n            logger.warn(f\"【阿里云盘】{method} 请求 {endpoint} 失败！\")\n            return None\n\n        # 处理速率限制\n        if resp.status_code == 429:\n            reset_time = int(resp.headers.get(\"X-RateLimit-Reset\", 60))\n            time.sleep(reset_time + 5)\n            return self._request_api(method, endpoint, result_key, **kwargs)\n\n        # 返回数据\n        ret_data = resp.json()\n        if ret_data.get(\"code\"):\n            if not no_error_log:\n                logger.warn(\n                    f\"【阿里云盘】{method} {endpoint} 返回：{ret_data.get('code')} {ret_data.get('message')}\"\n                )\n\n        if result_key:\n            return ret_data.get(result_key)\n        return ret_data\n\n    def __get_fileitem(self, fileinfo: dict, parent: str = \"/\") -> schemas.FileItem:\n        \"\"\"\n        获取文件信息\n        \"\"\"\n        if not fileinfo:\n            return schemas.FileItem()\n        if not parent.endswith(\"/\"):\n            parent += \"/\"\n        if fileinfo.get(\"type\") == \"folder\":\n            return schemas.FileItem(\n                storage=self.schema.value,\n                fileid=fileinfo.get(\"file_id\"),\n                parent_fileid=fileinfo.get(\"parent_file_id\"),\n                type=\"dir\",\n                path=f\"{parent}{fileinfo.get('name')}\" + \"/\",\n                name=fileinfo.get(\"name\"),\n                basename=fileinfo.get(\"name\"),\n                size=fileinfo.get(\"size\"),\n                modify_time=StringUtils.str_to_timestamp(fileinfo.get(\"updated_at\")),\n                drive_id=fileinfo.get(\"drive_id\"),\n            )\n        else:\n            return schemas.FileItem(\n                storage=self.schema.value,\n                fileid=fileinfo.get(\"file_id\"),\n                parent_fileid=fileinfo.get(\"parent_file_id\"),\n                type=\"file\",\n                path=f\"{parent}{fileinfo.get('name')}\",\n                name=fileinfo.get(\"name\"),\n                basename=Path(fileinfo.get(\"name\")).stem,\n                size=fileinfo.get(\"size\"),\n                extension=fileinfo.get(\"file_extension\"),\n                modify_time=StringUtils.str_to_timestamp(fileinfo.get(\"updated_at\")),\n                thumbnail=fileinfo.get(\"thumbnail\"),\n                drive_id=fileinfo.get(\"drive_id\"),\n            )\n\n    @staticmethod\n    def _calc_sha1(filepath: Path, size: Optional[int] = None) -> str:\n        \"\"\"\n        计算文件SHA1（符合阿里云盘规范）\n        size: 前多少字节\n        \"\"\"\n        sha1 = hashlib.sha1()\n        with open(filepath, \"rb\") as f:\n            if size:\n                chunk = f.read(size)\n                sha1.update(chunk)\n            else:\n                while chunk := f.read(8192):\n                    sha1.update(chunk)\n        return sha1.hexdigest()\n\n    def init_storage(self):\n        pass\n\n    def list(self, fileitem: schemas.FileItem) -> List[schemas.FileItem]:\n        \"\"\"\n        目录遍历实现\n        \"\"\"\n        if fileitem.type == \"file\":\n            item = self.detail(fileitem)\n            if item:\n                return [item]\n            return []\n\n        if fileitem.path == \"/\":\n            parent_file_id = \"root\"\n            drive_id = self._default_drive_id\n        else:\n            parent_file_id = fileitem.fileid\n            drive_id = fileitem.drive_id\n\n        items = []\n        next_marker = None\n\n        while True:\n            resp = self._request_api(\n                \"POST\",\n                \"/adrive/v1.0/openFile/list\",\n                json={\n                    \"drive_id\": drive_id,\n                    \"limit\": 100,\n                    \"marker\": next_marker,\n                    \"parent_file_id\": parent_file_id,\n                },\n            )\n            if resp is None:\n                raise FileNotFoundError(f\"【阿里云盘】{fileitem.path} 检索出错！\")\n            if not resp:\n                break\n            next_marker = resp.get(\"next_marker\")\n            for item in resp.get(\"items\", []):\n                items.append(self.__get_fileitem(item, parent=str(fileitem.path)))\n            if len(resp.get(\"items\")) < 100:\n                break\n        return items\n\n    def _delay_get_item(self, path: Path) -> Optional[schemas.FileItem]:\n        \"\"\"\n        自动延迟重试 get_item 模块\n        \"\"\"\n        for _ in range(2):\n            time.sleep(2)\n            fileitem = self.get_item(path)\n            if fileitem:\n                return fileitem\n        return None\n\n    def create_folder(\n        self, parent_item: schemas.FileItem, name: str\n    ) -> Optional[schemas.FileItem]:\n        \"\"\"\n        创建目录\n        \"\"\"\n        resp = self._request_api(\n            \"POST\",\n            \"/adrive/v1.0/openFile/create\",\n            json={\n                \"drive_id\": parent_item.drive_id,\n                \"parent_file_id\": parent_item.fileid or \"root\",\n                \"name\": name,\n                \"type\": \"folder\",\n            },\n        )\n        if not resp:\n            return None\n        if resp.get(\"code\"):\n            logger.warn(f\"【阿里云盘】创建目录失败: {resp.get('message')}\")\n            return None\n        # 缓存新目录\n        new_path = Path(parent_item.path) / name\n        return self._delay_get_item(new_path)\n\n    @staticmethod\n    def _calculate_pre_hash(file_path: Path):\n        \"\"\"\n        计算文件前1KB的SHA1作为pre_hash\n        \"\"\"\n        sha1 = hashlib.sha1()\n        with open(file_path, \"rb\") as f:\n            data = f.read(1024)\n            sha1.update(data)\n        return sha1.hexdigest()\n\n    def _calculate_proof_code(self, file_path: Path):\n        \"\"\"\n        计算秒传所需的proof_code\n        \"\"\"\n        file_size = file_path.stat().st_size\n        if file_size == 0:\n            return \"\"\n\n        # Step 1-3: 计算access_token的MD5并取前16位\n        md5 = hashlib.md5(self.access_token.encode()).hexdigest()\n        hex_str = md5[:16]\n\n        # Step 4: 转换为无符号int64\n        try:\n            tmp_int = int(hex_str, 16)\n        except ValueError:\n            raise ValueError(\n                \"【阿里云盘】Invalid hex string for proof code calculation\"\n            )\n\n        # Step 5-7: 计算读取范围\n        index = tmp_int % file_size\n        start = index\n        end = index + 8\n        if end > file_size:\n            end = file_size\n\n        # Step 8: 读取文件范围数据并编码\n        with open(file_path, \"rb\") as f:\n            f.seek(start)\n            chunk = f.read(end - start)\n\n        return base64.b64encode(chunk).decode()\n\n    @staticmethod\n    def _calculate_content_hash(file_path: Path):\n        \"\"\"\n        计算整个文件的SHA1作为content_hash\n        \"\"\"\n        sha1 = hashlib.sha1()\n        with open(file_path, \"rb\") as f:\n            while True:\n                chunk = f.read(8192)\n                if not chunk:\n                    break\n                sha1.update(chunk)\n        return sha1.hexdigest()\n\n    def _create_file(\n        self,\n        drive_id: str,\n        parent_file_id: str,\n        file_name: str,\n        file_path: Path,\n        check_name_mode=\"refuse\",\n        chunk_size: int = 1 * 1024 * 1024 * 1024,\n    ):\n        \"\"\"\n        创建文件请求，尝试秒传\n        \"\"\"\n        file_size = file_path.stat().st_size\n        pre_hash = self._calculate_pre_hash(file_path)\n        num_parts = (file_size + chunk_size - 1) // chunk_size\n\n        # 构建分片信息\n        part_info_list = [{\"part_number\": i + 1} for i in range(num_parts)]\n\n        # 确定是否能秒传\n        data = {\n            \"drive_id\": drive_id,\n            \"parent_file_id\": parent_file_id,\n            \"name\": file_name,\n            \"type\": \"file\",\n            \"check_name_mode\": check_name_mode,\n            \"size\": file_size,\n            \"pre_hash\": pre_hash,\n            \"part_info_list\": part_info_list,\n        }\n        resp = self._request_api(\"POST\", \"/adrive/v1.0/openFile/create\", json=data)\n        if not resp:\n            raise Exception(\"【阿里云盘】创建文件失败！\")\n        if resp.get(\"code\") == \"PreHashMatched\":\n            # 可以秒传\n            proof_code = self._calculate_proof_code(file_path)\n            content_hash = self._calculate_content_hash(file_path)\n            data.pop(\"pre_hash\")\n            data.update(\n                {\n                    \"proof_code\": proof_code,\n                    \"proof_version\": \"v1\",\n                    \"content_hash\": content_hash,\n                    \"content_hash_name\": \"sha1\",\n                }\n            )\n            resp = self._request_api(\"POST\", \"/adrive/v1.0/openFile/create\", json=data)\n            if not resp:\n                raise Exception(\"【阿里云盘】创建文件失败！\")\n            if resp.get(\"code\"):\n                raise Exception(resp.get(\"message\"))\n        return resp\n\n    def _refresh_upload_urls(\n        self, drive_id: str, file_id: str, upload_id: str, part_numbers: List[int]\n    ):\n        \"\"\"\n        刷新分片上传地址\n        \"\"\"\n        data = {\n            \"drive_id\": drive_id,\n            \"file_id\": file_id,\n            \"upload_id\": upload_id,\n            \"part_info_list\": [{\"part_number\": num} for num in part_numbers],\n        }\n        resp = self._request_api(\n            \"POST\", \"/adrive/v1.0/openFile/getUploadUrl\", json=data\n        )\n        if not resp:\n            raise Exception(\"【阿里云盘】刷新分片上传地址失败！\")\n        if resp.get(\"code\"):\n            raise Exception(resp.get(\"message\"))\n        return resp.get(\"part_info_list\", [])\n\n    @staticmethod\n    def _upload_part(upload_url: str, data: bytes):\n        \"\"\"\n        上传单个分片\n        \"\"\"\n        return requests.put(upload_url, data=data, timeout=60.0)\n\n    def _list_uploaded_parts(self, drive_id: str, file_id: str, upload_id: str) -> dict:\n        \"\"\"\n        获取已上传分片列表\n        \"\"\"\n        data = {\"drive_id\": drive_id, \"file_id\": file_id, \"upload_id\": upload_id}\n        resp = self._request_api(\n            \"POST\", \"/adrive/v1.0/openFile/listUploadedParts\", json=data\n        )\n        if not resp:\n            raise Exception(\"【阿里云盘】获取已上传分片失败！\")\n        if resp.get(\"code\"):\n            raise Exception(resp.get(\"message\"))\n        return resp\n\n    def _complete_upload(self, drive_id: str, file_id: str, upload_id: str):\n        \"\"\"标记上传完成\"\"\"\n        data = {\"drive_id\": drive_id, \"file_id\": file_id, \"upload_id\": upload_id}\n        resp = self._request_api(\"POST\", \"/adrive/v1.0/openFile/complete\", json=data)\n        if not resp:\n            raise Exception(\"【阿里云盘】完成上传失败！\")\n        if resp.get(\"code\"):\n            raise Exception(resp.get(\"message\"))\n        return resp\n\n    def upload(\n        self,\n        target_dir: schemas.FileItem,\n        local_path: Path,\n        new_name: Optional[str] = None,\n    ) -> Optional[schemas.FileItem]:\n        \"\"\"\n        文件上传：分片、支持秒传\n        \"\"\"\n        target_name = new_name or local_path.name\n        target_path = Path(target_dir.path) / target_name\n        file_size = local_path.stat().st_size\n\n        # 1. 创建文件并检查秒传\n        chunk_size = 10 * 1024 * 1024  # 分片大小 10M\n        create_res = self._create_file(\n            drive_id=target_dir.drive_id,\n            parent_file_id=target_dir.fileid,\n            file_name=target_name,\n            file_path=local_path,\n            chunk_size=chunk_size,\n        )\n        if create_res.get(\"rapid_upload\", False):\n            logger.info(f\"【阿里云盘】{target_name} 秒传完成！\")\n            return self._delay_get_item(target_path)\n\n        if create_res.get(\"exist\", False):\n            logger.info(f\"【阿里云盘】{target_name} 已存在\")\n            return self.get_item(target_path)\n\n        # 2. 准备分片上传参数\n        file_id = create_res.get(\"file_id\")\n        if not file_id:\n            logger.warn(f\"【阿里云盘】创建 {target_name} 文件失败！\")\n            return None\n        upload_id = create_res.get(\"upload_id\")\n        part_info_list = create_res.get(\"part_info_list\")\n        uploaded_parts = set()\n\n        # 3. 获取已上传分片\n        uploaded_info = self._list_uploaded_parts(\n            drive_id=target_dir.drive_id, file_id=file_id, upload_id=upload_id\n        )\n        for part in uploaded_info.get(\"uploaded_parts\", []):\n            uploaded_parts.add(part[\"part_number\"])\n\n        # 4. 初始化进度条\n        logger.info(\n            f\"【阿里云盘】开始上传: {local_path} -> {target_path}，分片数：{len(part_info_list)}\"\n        )\n        progress_callback = transfer_process(local_path.as_posix())\n\n        # 5. 分片上传循环\n        uploaded_size = 0\n        with open(local_path, \"rb\") as f:\n            for part_info in part_info_list:\n                if global_vars.is_transfer_stopped(local_path.as_posix()):\n                    logger.info(f\"【阿里云盘】{target_name} 上传已取消！\")\n                    return None\n\n                # 计算分片参数\n                part_num = part_info[\"part_number\"]\n                start = (part_num - 1) * chunk_size\n                end = min(start + chunk_size, file_size)\n                current_chunk_size = end - start\n\n                # 更新进度条（已存在的分片）\n                if part_num in uploaded_parts:\n                    uploaded_size += current_chunk_size\n                    progress_callback((uploaded_size * 100) / file_size)\n                    continue\n\n                # 准备分片数据\n                f.seek(start)\n                data = f.read(current_chunk_size)\n\n                # 上传分片（带重试逻辑）\n                success = False\n                for attempt in range(3):  # 最大重试次数\n                    try:\n                        # 获取当前上传地址（可能刷新）\n                        if attempt > 0:\n                            new_urls = self._refresh_upload_urls(\n                                drive_id=target_dir.drive_id,\n                                file_id=file_id,\n                                upload_id=upload_id,\n                                part_numbers=[part_num],\n                            )\n                            upload_url = new_urls[0][\"upload_url\"]\n                        else:\n                            upload_url = part_info[\"upload_url\"]\n                        # 执行上传\n                        logger.info(\n                            f\"【阿里云盘】开始 第{attempt + 1}次 上传 {target_name} 分片 {part_num} ...\"\n                        )\n                        response = self._upload_part(upload_url=upload_url, data=data)\n                        if response is None:\n                            continue\n                        if response.status_code == 200:\n                            success = True\n                            break\n                        else:\n                            logger.warn(\n                                f\"【阿里云盘】{target_name} 分片 {part_num} 第 {attempt + 1} 次上传失败：{response.text}！\"\n                            )\n                    except Exception as e:\n                        logger.warn(\n                            f\"【阿里云盘】{target_name} 分片 {part_num} 上传异常: {str(e)}！\"\n                        )\n\n                # 处理上传结果\n                if success:\n                    uploaded_parts.add(part_num)\n                    uploaded_size += current_chunk_size\n                    progress_callback((uploaded_size * 100) / file_size)\n                else:\n                    raise Exception(\n                        f\"【阿里云盘】{target_name} 分片 {part_num} 上传失败！\"\n                    )\n\n        # 6. 关闭进度条\n        progress_callback(100)\n\n        # 7. 完成上传\n        result = self._complete_upload(\n            drive_id=target_dir.drive_id, file_id=file_id, upload_id=upload_id\n        )\n        if not result:\n            raise Exception(\"【阿里云盘】完成上传失败！\")\n        if result.get(\"code\"):\n            logger.warn(\n                f\"【阿里云盘】{target_name} 上传失败：{result.get('message')}！\"\n            )\n        return self.__get_fileitem(result, parent=target_dir.path)\n\n    def download(self, fileitem: schemas.FileItem, path: Path = None) -> Optional[Path]:\n        \"\"\"\n        带实时进度显示的下载\n        \"\"\"\n        download_info = self._request_api(\n            \"POST\",\n            \"/adrive/v1.0/openFile/getDownloadUrl\",\n            json={\n                \"drive_id\": fileitem.drive_id,\n                \"file_id\": fileitem.fileid,\n            },\n        )\n        if not download_info:\n            logger.error(f\"【阿里云盘】获取下载链接失败: {fileitem.name}\")\n            return None\n\n        download_url = download_info.get(\"url\")\n        if not download_url:\n            logger.error(f\"【阿里云盘】下载链接为空: {fileitem.name}\")\n            return None\n\n        local_path = (path or settings.TEMP_PATH) / fileitem.name\n\n        # 获取文件大小\n        file_size = fileitem.size\n\n        # 初始化进度条\n        logger.info(f\"【阿里云盘】开始下载: {fileitem.name} -> {local_path}\")\n        progress_callback = transfer_process(Path(fileitem.path).as_posix())\n\n        try:\n            # 构建请求头，包含必要的认证信息\n            headers = {\n                \"User-Agent\": settings.NORMAL_USER_AGENT,\n                \"Referer\": \"https://www.aliyundrive.com/\",\n                \"Accept\": \"*/*\",\n                \"Accept-Language\": \"zh-CN,zh;q=0.9,en;q=0.8\",\n                \"Accept-Encoding\": \"gzip, deflate, br\",\n                \"Connection\": \"keep-alive\",\n                \"Sec-Fetch-Dest\": \"empty\",\n                \"Sec-Fetch-Mode\": \"cors\",\n                \"Sec-Fetch-Site\": \"cross-site\",\n            }\n\n            # 如果有access_token，添加到请求头\n            if self.access_token:\n                headers[\"Authorization\"] = f\"Bearer {self.access_token}\"\n\n            request_utils = RequestUtils(headers=headers)\n            with request_utils.get_stream(download_url, raise_exception=True) as r:\n                r.raise_for_status()\n                downloaded_size = 0\n                with open(local_path, \"wb\") as f:\n                    for chunk in r.iter_content(chunk_size=self.chunk_size):\n                        if global_vars.is_transfer_stopped(fileitem.path):\n                            logger.info(f\"【阿里云盘】{fileitem.path} 下载已取消！\")\n                            return None\n                        if chunk:\n                            f.write(chunk)\n                            # 更新进度\n                            downloaded_size += len(chunk)\n                            if file_size:\n                                progress = (downloaded_size * 100) / file_size\n                                progress_callback(progress)\n\n                # 完成下载\n                progress_callback(100)\n                logger.info(f\"【阿里云盘】下载完成: {fileitem.name}\")\n                return local_path\n        except Exception as e:\n            logger.error(f\"【阿里云盘】下载失败: {fileitem.name} - {str(e)}\")\n            if local_path.exists():\n                local_path.unlink()\n            return None\n\n    def check(self) -> bool:\n        return self.access_token is not None\n\n    def delete(self, fileitem: schemas.FileItem) -> bool:\n        \"\"\"\n        删除文件/目录\n        \"\"\"\n        try:\n            self._request_api(\n                \"POST\",\n                \"/adrive/v1.0/openFile/recyclebin/trash\",\n                json={\"drive_id\": fileitem.drive_id, \"file_id\": fileitem.fileid},\n            )\n            return True\n        except requests.exceptions.HTTPError:\n            return False\n\n    def rename(self, fileitem: schemas.FileItem, name: str) -> bool:\n        \"\"\"\n        重命名文件/目录\n        \"\"\"\n        resp = self._request_api(\n            \"POST\",\n            \"/adrive/v1.0/openFile/update\",\n            json={\n                \"drive_id\": fileitem.drive_id,\n                \"file_id\": fileitem.fileid,\n                \"name\": name,\n            },\n        )\n        if not resp:\n            return False\n        if resp.get(\"code\"):\n            logger.warn(f\"【阿里云盘】重命名失败: {resp.get('message')}\")\n            return False\n        return True\n\n    def get_item(self, path: Path, drive_id: str = None) -> Optional[schemas.FileItem]:\n        \"\"\"\n        获取指定路径的文件/目录项\n        \"\"\"\n        try:\n            resp = self._request_api(\n                \"POST\",\n                \"/adrive/v1.0/openFile/get_by_path\",\n                json={\n                    \"drive_id\": drive_id or self._default_drive_id,\n                    \"file_path\": path.as_posix(),\n                },\n                no_error_log=True,\n            )\n            if not resp:\n                return None\n            if resp.get(\"code\"):\n                logger.debug(f\"【阿里云盘】获取文件信息失败: {resp.get('message')}\")\n                return None\n            return self.__get_fileitem(resp, parent=str(path.parent))\n        except Exception as e:\n            logger.debug(f\"【阿里云盘】获取文件信息失败: {str(e)}\")\n            return None\n\n    def get_folder(self, path: Path) -> Optional[schemas.FileItem]:\n        \"\"\"\n        获取指定路径的文件夹，如不存在则创建\n        \"\"\"\n\n        def __find_dir(\n            _fileitem: schemas.FileItem, _name: str\n        ) -> Optional[schemas.FileItem]:\n            \"\"\"\n            查找下级目录中匹配名称的目录\n            \"\"\"\n            for sub_folder in self.list(_fileitem):\n                if sub_folder.type != \"dir\":\n                    continue\n                if sub_folder.name == _name:\n                    return sub_folder\n            return None\n\n        # 是否已存在\n        folder = self.get_item(path)\n        if folder:\n            return folder\n        # 逐级查找和创建目录\n        fileitem = schemas.FileItem(\n            storage=self.schema.value, path=\"/\", drive_id=self._default_drive_id\n        )\n        for part in path.parts[1:]:\n            dir_file = __find_dir(fileitem, part)\n            if dir_file:\n                fileitem = dir_file\n            else:\n                dir_file = self.create_folder(fileitem, part)\n                if not dir_file:\n                    logger.warn(f\"【阿里云盘】创建目录 {fileitem.path}{part} 失败！\")\n                    return None\n                fileitem = dir_file\n        return fileitem\n\n    def detail(self, fileitem: schemas.FileItem) -> Optional[schemas.FileItem]:\n        \"\"\"\n        获取文件/目录详细信息\n        \"\"\"\n        return self.get_item(Path(fileitem.path))\n\n    def copy(self, fileitem: schemas.FileItem, path: Path, new_name: str) -> bool:\n        \"\"\"\n        复制文件到指定路径\n        :param fileitem: 要复制的文件项\n        :param path: 目标目录路径\n        :param new_name: 新文件名\n        \"\"\"\n        dest_fileitem = self.get_item(path, drive_id=fileitem.drive_id)\n        if not dest_fileitem or dest_fileitem.type != \"dir\":\n            logger.warn(f\"【阿里云盘】目标路径 {path} 不存在或不是目录！\")\n            return False\n        resp = self._request_api(\n            \"POST\",\n            \"/adrive/v1.0/openFile/copy\",\n            json={\n                \"drive_id\": fileitem.drive_id,\n                \"file_id\": fileitem.fileid,\n                \"to_drive_id\": fileitem.drive_id,\n                \"to_parent_file_id\": dest_fileitem.fileid,\n            },\n        )\n        if not resp:\n            return False\n        if resp.get(\"code\"):\n            logger.warn(f\"【阿里云盘】复制文件失败: {resp.get('message')}\")\n            return False\n        # 重命名\n        new_path = Path(path) / fileitem.name\n        new_file = self._delay_get_item(new_path)\n        self.rename(new_file, new_name)\n        return True\n\n    def move(self, fileitem: schemas.FileItem, path: Path, new_name: str) -> bool:\n        \"\"\"\n        移动文件到指定路径\n        :param fileitem: 要移动的文件项\n        :param path: 目标目录路径\n        :param new_name: 新文件名\n        \"\"\"\n        src_fid = fileitem.fileid\n        target_fileitem = self.get_item(path, drive_id=fileitem.drive_id)\n        if not target_fileitem or target_fileitem.type != \"dir\":\n            logger.warn(f\"【阿里云盘】目标路径 {path} 不存在或不是目录！\")\n            return False\n\n        resp = self._request_api(\n            \"POST\",\n            \"/adrive/v1.0/openFile/move\",\n            json={\n                \"drive_id\": fileitem.drive_id,\n                \"file_id\": src_fid,\n                \"to_parent_file_id\": target_fileitem.fileid,\n                \"new_name\": new_name,\n            },\n        )\n        if not resp:\n            return False\n        if resp.get(\"code\"):\n            logger.warn(f\"【阿里云盘】移动文件失败: {resp.get('message')}\")\n            return False\n        return True\n\n    def link(self, fileitem: schemas.FileItem, target_file: Path) -> bool:\n        pass\n\n    def softlink(self, fileitem: schemas.FileItem, target_file: Path) -> bool:\n        pass\n\n    def usage(self) -> Optional[schemas.StorageUsage]:\n        \"\"\"\n        获取带有企业级配额信息的存储使用情况\n        \"\"\"\n        try:\n            resp = self._request_api(\"POST\", \"/adrive/v1.0/user/getSpaceInfo\")\n            if not resp:\n                return None\n            space = resp.get(\"personal_space_info\") or {}\n            total_size = space.get(\"total_size\") or 0\n            used_size = space.get(\"used_size\") or 0\n            return schemas.StorageUsage(\n                total=total_size, available=total_size - used_size\n            )\n        except NoCheckInException:\n            return None\n        except SessionInvalidException:\n            return None\n"
  },
  {
    "path": "app/modules/filemanager/storages/alist.py",
    "content": "import json\nimport time\nfrom datetime import datetime\nfrom pathlib import Path\nfrom typing import Optional, List\n\nfrom app import schemas\nfrom app.core.cache import cached\nfrom app.core.config import settings, global_vars\nfrom app.log import logger\nfrom app.modules.filemanager.storages import StorageBase, transfer_process\nfrom app.schemas.exception import OperationInterrupted\nfrom app.schemas.types import StorageSchema\nfrom app.utils.http import RequestUtils\nfrom app.utils.singleton import WeakSingleton\nfrom app.utils.url import UrlUtils\n\n\nclass Alist(StorageBase, metaclass=WeakSingleton):\n    \"\"\"\n    Openlist相关操作\n\n    API 文档：https://fox.oplist.org/\n    \"\"\"\n\n    # 存储类型\n    schema = StorageSchema.Alist\n\n    # 支持的整理方式\n    transtype = {\n        \"copy\": \"复制\",\n        \"move\": \"移动\",\n    }\n\n    # 快照检查目录修改时间\n    snapshot_check_folder_modtime = settings.OPENLIST_SNAPSHOT_CHECK_FOLDER_MODTIME\n\n    def __init__(self):\n        super().__init__()\n\n    def init_storage(self):\n        \"\"\"\n        初始化\n        \"\"\"\n        self.__generate_token.cache_clear()  # noqa\n\n    def _delay_get_item(\n        self, path: Path, /, refresh: bool = False\n    ) -> Optional[schemas.FileItem]:\n        \"\"\"\n        自动延迟重试 get_item 模块\n\n        :param path: 文件路径\n        :param refresh: 是否刷新\n        :return: 文件项\n        \"\"\"\n        for _ in range(2):\n            time.sleep(2)\n            fileitem = self.get_item(path=path, refresh=refresh)\n            if fileitem:\n                return fileitem\n        return None\n\n    @property\n    def __get_base_url(self) -> str:\n        \"\"\"\n        获取基础URL\n        \"\"\"\n        url = self.get_conf().get(\"url\")\n        if url is None:\n            return \"\"\n        return UrlUtils.standardize_base_url(self.get_conf().get(\"url\"))\n\n    def __get_api_url(self, path: str) -> str:\n        \"\"\"\n        获取API URL\n\n        :param path: API路径\n        :return: API URL\n        \"\"\"\n        return UrlUtils.adapt_request_url(self.__get_base_url, path)\n\n    @property\n    def __get_valuable_toke(self) -> str:\n        \"\"\"\n        获取一个可用的token\n        如果设置永久令牌则返回永久令牌\n        否则使用账号密码生成临时令牌\n        \"\"\"\n        return self.__generate_token()\n\n    @cached(maxsize=1, ttl=60 * 60 * 24 * 2 - 60 * 5, skip_empty=True)\n    def __generate_token(self) -> str:\n        \"\"\"\n        如果设置永久令牌则返回永久令牌，否则使用账号密码生成一个临时 token\n        缓存2天，提前5分钟更新\n        \"\"\"\n        conf = self.get_conf()\n        token = conf.get(\"token\")\n        if token:\n            return str(token)\n        resp = RequestUtils(headers={\"Content-Type\": \"application/json\"}).post_res(\n            self.__get_api_url(\"/api/auth/login\"),\n            data=json.dumps(\n                {\n                    \"username\": conf.get(\"username\"),\n                    \"password\": conf.get(\"password\"),\n                }\n            ),\n        )\n        \"\"\"\n        {\n            \"username\": \"{{alist_username}}\",\n            \"password\": \"{{alist_password}}\"\n        }\n        ======================================\n        {\n            \"code\": 200,\n            \"message\": \"success\",\n            \"data\": {\n                \"token\": \"abcd\"\n            }\n        }\n        \"\"\"\n\n        if resp is None:\n            logger.warning(\"【OpenList】请求登录失败，无法连接alist服务\")\n            return \"\"\n\n        if resp.status_code != 200:\n            logger.warning(\n                f\"【OpenList】更新令牌请求发送失败，状态码：{resp.status_code}\"\n            )\n            return \"\"\n\n        result = resp.json()\n\n        if result[\"code\"] != 200:\n            logger.critical(f\"【OpenList】更新令牌，错误信息：{result['message']}\")\n            return \"\"\n\n        logger.debug(\"【OpenList】AList获取令牌成功\")\n        return result[\"data\"][\"token\"]\n\n    def __get_header_with_token(self) -> dict:\n        \"\"\"\n        获取带有token的header\n        \"\"\"\n        return {\"Authorization\": self.__get_valuable_toke}\n\n    def check(self) -> bool:\n        \"\"\"\n        检查存储是否可用\n        \"\"\"\n        return True if self.__generate_token() else False\n\n    def list(\n        self,\n        fileitem: schemas.FileItem,\n        password: Optional[str] = \"\",\n        page: int = 1,\n        per_page: int = 0,\n        refresh: bool = False,\n    ) -> List[schemas.FileItem]:\n        \"\"\"\n        浏览文件\n        :param fileitem: 文件项\n        :param password: 路径密码\n        :param page: 页码\n        :param per_page: 每页数量\n        :param refresh: 是否刷新\n        :return: 文件列表\n        \"\"\"\n        if fileitem.type == \"file\":\n            item = self.get_item(Path(fileitem.path))\n            if item:\n                return [item]\n            return []\n        resp = RequestUtils(headers=self.__get_header_with_token()).post_res(\n            self.__get_api_url(\"/api/fs/list\"),\n            json={\n                \"path\": fileitem.path,\n                \"password\": password,\n                \"page\": page,\n                \"per_page\": per_page,\n                \"refresh\": refresh,\n            },\n        )\n        \"\"\"\n        {\n            \"path\": \"/t\",\n            \"password\": \"\",\n            \"page\": 1,\n            \"per_page\": 0,\n            \"refresh\": false\n        }\n        ======================================\n        {\n            \"code\": 200,\n            \"message\": \"success\",\n            \"data\": {\n                \"content\": [\n                {\n                    \"name\": \"Alist V3.md\",\n                    \"size\": 1592,\n                    \"is_dir\": false,\n                    \"modified\": \"2024-05-17T13:47:55.4174917+08:00\",\n                    \"created\": \"2024-05-17T13:47:47.5725906+08:00\",\n                    \"sign\": \"\",\n                    \"thumb\": \"\",\n                    \"type\": 4,\n                    \"hashinfo\": \"null\",\n                    \"hash_info\": null\n                }\n                ],\n                \"total\": 1,\n                \"readme\": \"\",\n                \"header\": \"\",\n                \"write\": true,\n                \"provider\": \"Local\"\n            }\n        }\n        \"\"\"\n\n        if resp is None:\n            logger.warn(\n                f\"【OpenList】请求获取目录 {fileitem.path} 的文件列表失败，无法连接alist服务\"\n            )\n            return []\n        if resp.status_code != 200:\n            logger.warn(\n                f\"【OpenList】请求获取目录 {fileitem.path} 的文件列表失败，状态码：{resp.status_code}\"\n            )\n            return []\n\n        result = resp.json()\n\n        if result[\"code\"] != 200:\n            logger.warn(\n                f\"【OpenList】获取目录 {fileitem.path} 的文件列表失败，错误信息：{result['message']}\"\n            )\n            return []\n\n        return [\n            schemas.FileItem(\n                storage=self.schema.value,\n                type=\"dir\" if item[\"is_dir\"] else \"file\",\n                path=(Path(fileitem.path) / item[\"name\"]).as_posix()\n                + (\"/\" if item[\"is_dir\"] else \"\"),\n                name=item[\"name\"],\n                basename=Path(item[\"name\"]).stem,\n                extension=Path(item[\"name\"]).suffix[1:] if not item[\"is_dir\"] else None,\n                size=item[\"size\"] if not item[\"is_dir\"] else None,\n                modify_time=self.__parse_timestamp(item[\"modified\"]),\n                thumbnail=item[\"thumb\"],\n            )\n            for item in result[\"data\"][\"content\"] or []\n        ]\n\n    def create_folder(\n        self, fileitem: schemas.FileItem, name: str\n    ) -> Optional[schemas.FileItem]:\n        \"\"\"\n        创建目录\n        :param fileitem: 父目录\n        :param name: 目录名\n        :return: 目录项\n        \"\"\"\n        path = Path(fileitem.path) / name\n        resp = RequestUtils(headers=self.__get_header_with_token()).post_res(\n            self.__get_api_url(\"/api/fs/mkdir\"),\n            json={\"path\": path.as_posix()},\n        )\n        \"\"\"\n        {\n            \"path\": \"/tt\"\n        }\n        ======================================\n        {\n            \"code\": 200,\n            \"message\": \"success\",\n            \"data\": null\n        }\n        \"\"\"\n        if resp is None:\n            logger.warn(f\"【OpenList】请求创建目录 {path} 失败，无法连接alist服务\")\n            return None\n        if resp.status_code != 200:\n            logger.warn(\n                f\"【OpenList】请求创建目录 {path} 失败，状态码：{resp.status_code}\"\n            )\n            return None\n\n        result = resp.json()\n        if result[\"code\"] != 200:\n            logger.warn(\n                f\"【OpenList】创建目录 {path} 失败，错误信息：{result['message']}\"\n            )\n            return None\n\n        return self._delay_get_item(path, refresh=True)\n\n    def get_folder(self, path: Path) -> Optional[schemas.FileItem]:\n        \"\"\"\n        获取目录，如目录不存在则创建\n\n        :param path: 目录路径\n        :return: 目录项\n        \"\"\"\n        folder = self.get_item(path)\n        if folder:\n            return folder\n        if not folder:\n            folder = self.create_folder(\n                schemas.FileItem(\n                    storage=self.schema.value,\n                    type=\"dir\",\n                    path=path.parent.as_posix(),\n                    name=path.name,\n                    basename=path.stem,\n                ),\n                path.name,\n            )\n        return folder\n\n    def get_item(\n        self,\n        path: Path,\n        password: Optional[str] = \"\",\n        page: int = 1,\n        per_page: int = 0,\n        refresh: bool = False,\n    ) -> Optional[schemas.FileItem]:\n        \"\"\"\n        获取文件或目录，不存在返回None\n        :param path: 文件路径\n        :param password: 路径密码\n        :param page: 页码\n        :param per_page: 每页数量\n        :param refresh: 是否刷新\n        :return: 文件项\n        \"\"\"\n        resp = RequestUtils(headers=self.__get_header_with_token()).post_res(\n            self.__get_api_url(\"/api/fs/get\"),\n            json={\n                \"path\": path.as_posix(),\n                \"password\": password,\n                \"page\": page,\n                \"per_page\": per_page,\n                \"refresh\": refresh,\n            },\n        )\n        \"\"\"\n        {\n            \"path\": \"/t\",\n            \"password\": \"\",\n            \"page\": 1,\n            \"per_page\": 0,\n            \"refresh\": false\n        }\n        ======================================\n        {\n            \"code\": 200,\n            \"message\": \"success\",\n            \"data\": {\n                \"name\": \"Alist V3.md\",\n                \"size\": 2618,\n                \"is_dir\": false,\n                \"modified\": \"2024-05-17T16:05:36.4651534+08:00\",\n                \"created\": \"2024-05-17T16:05:29.2001008+08:00\",\n                \"sign\": \"\",\n                \"thumb\": \"\",\n                \"type\": 4,\n                \"hashinfo\": \"null\",\n                \"hash_info\": null,\n                \"raw_url\": \"http://127.0.0.1:5244/p/local/Alist%20V3.md\",\n                \"readme\": \"\",\n                \"header\": \"\",\n                \"provider\": \"Local\",\n                \"related\": null\n            }\n        }\n        \"\"\"\n        if resp is None:\n            logger.warn(f\"【OpenList】请求获取文件 {path} 失败，无法连接alist服务\")\n            return None\n        if resp.status_code != 200:\n            logger.warn(\n                f\"【OpenList】请求获取文件 {path} 失败，状态码：{resp.status_code}\"\n            )\n            return None\n\n        result = resp.json()\n        if result[\"code\"] != 200:\n            logger.debug(\n                f\"【OpenList】获取文件 {path} 失败，错误信息：{result['message']}\"\n            )\n            return None\n\n        return schemas.FileItem(\n            storage=self.schema.value,\n            type=\"dir\" if result[\"data\"][\"is_dir\"] else \"file\",\n            path=path.as_posix() + (\"/\" if result[\"data\"][\"is_dir\"] else \"\"),\n            name=result[\"data\"][\"name\"],\n            basename=Path(result[\"data\"][\"name\"]).stem,\n            extension=Path(result[\"data\"][\"name\"]).suffix[1:],\n            size=result[\"data\"][\"size\"],\n            modify_time=self.__parse_timestamp(result[\"data\"][\"modified\"]),\n            thumbnail=result[\"data\"][\"thumb\"],\n        )\n\n    def get_parent(self, fileitem: schemas.FileItem) -> Optional[schemas.FileItem]:\n        \"\"\"\n        获取父目录\n\n        :param fileitem: 文件项\n        :return: 父目录项\n        \"\"\"\n        return self.get_folder(Path(fileitem.path).parent)\n\n    def __is_empty_dir(self, fileitem: schemas.FileItem) -> bool:\n        \"\"\"\n        判断目录是否为空\n\n        :param fileitem: 文件项\n        :return: 是否为空目录\n        \"\"\"\n        if fileitem.type != \"dir\":\n            return False\n        # 获取目录内容\n        items = self.list(fileitem)\n        return len(items) == 0\n\n    def delete(self, fileitem: schemas.FileItem) -> bool:\n        \"\"\"\n        删除文件或目录，空目录用专用API\n\n        :param fileitem: 文件项\n        :return: 是否删除成功\n        \"\"\"\n        # 如果是空目录，优先用 remove_empty_directory\n        if fileitem.type == \"dir\" and self.__is_empty_dir(fileitem):\n            resp = RequestUtils(headers=self.__get_header_with_token()).post_res(\n                self.__get_api_url(\"/api/fs/remove_empty_directory\"),\n                json={\n                    \"src_dir\": fileitem.path,\n                },\n            )\n            if resp is None:\n                logger.warn(\n                    f\"【OpenList】请求删除空目录 {fileitem.path} 失败，无法连接alist服务\"\n                )\n                return False\n            if resp.status_code != 200:\n                logger.warn(\n                    f\"【OpenList】请求删除空目录 {fileitem.path} 失败，状态码：{resp.status_code}\"\n                )\n                return False\n            result = resp.json()\n            if result[\"code\"] != 200:\n                logger.warn(\n                    f\"【OpenList】删除空目录 {fileitem.path} 失败，错误信息：{result['message']}\"\n                )\n                return False\n            return True\n        # 其它情况（文件或非空目录）\n        resp = RequestUtils(headers=self.__get_header_with_token()).post_res(\n            self.__get_api_url(\"/api/fs/remove\"),\n            json={\n                \"dir\": Path(fileitem.path).parent.as_posix(),\n                \"names\": [fileitem.name],\n            },\n        )\n        if resp is None:\n            logger.warn(\n                f\"【OpenList】请求删除文件 {fileitem.path} 失败，无法连接alist服务\"\n            )\n            return False\n        if resp.status_code != 200:\n            logger.warn(\n                f\"【OpenList】请求删除文件 {fileitem.path} 失败，状态码：{resp.status_code}\"\n            )\n            return False\n        result = resp.json()\n        if result[\"code\"] != 200:\n            logger.warn(\n                f\"【OpenList】删除文件 {fileitem.path} 失败，错误信息：{result['message']}\"\n            )\n            return False\n        return True\n\n    def rename(self, fileitem: schemas.FileItem, name: str) -> bool:\n        \"\"\"\n        重命名文件\n\n        :param fileitem: 文件项\n        :param name: 新文件名\n        :return: 是否重命名成功\n        \"\"\"\n        resp = RequestUtils(headers=self.__get_header_with_token()).post_res(\n            self.__get_api_url(\"/api/fs/rename\"),\n            json={\n                \"name\": name,\n                \"path\": fileitem.path,\n            },\n        )\n        \"\"\"\n        {\n            \"name\": \"test3\",\n            \"path\": \"/阿里云盘/test2\"\n        }\n        ======================================\n        {\n            \"code\": 200,\n            \"message\": \"success\",\n            \"data\": null\n        }\n        \"\"\"\n        if not resp:\n            logger.warn(\n                f\"【OpenList】请求重命名文件 {fileitem.path} 失败，无法连接alist服务\"\n            )\n            return False\n        if resp.status_code != 200:\n            logger.warn(\n                f\"【OpenList】请求重命名文件 {fileitem.path} 失败，状态码：{resp.status_code}\"\n            )\n            return False\n\n        result = resp.json()\n        if result[\"code\"] != 200:\n            logger.warn(\n                f\"【OpenList】重命名文件 {fileitem.path} 失败，错误信息：{result['message']}\"\n            )\n            return False\n\n        return True\n\n    def download(\n        self,\n        fileitem: schemas.FileItem,\n        path: Path = None,\n        password: Optional[str] = \"\",\n    ) -> Optional[Path]:\n        \"\"\"\n        下载文件，保存到本地，返回本地临时文件地址\n        :param fileitem: 文件项\n        :param path: 文件保存路径\n        :param password: 文件密码\n        :return: 本地临时文件地址\n        \"\"\"\n        resp = RequestUtils(headers=self.__get_header_with_token()).post_res(\n            self.__get_api_url(\"/api/fs/get\"),\n            json={\n                \"path\": fileitem.path,\n                \"password\": password,\n                \"page\": 1,\n                \"per_page\": 0,\n                \"refresh\": False,\n            },\n        )\n        \"\"\"\n        {\n            \"code\": 200,\n            \"message\": \"success\",\n            \"data\": {\n                \"name\": \"[ANi]輝夜姬想讓人告白～天才們的戀愛頭腦戰～[01][1080P][Baha][WEB-DL].mp4\",\n                \"size\": 924933111,\n                \"is_dir\": false,\n                \"modified\": \"1970-01-01T00:00:00Z\",\n                \"created\": \"1970-01-01T00:00:00Z\",\n                \"sign\": \"1v0xkMQz_uG8fkEOQ7-l58OnbB-g4GkdBlUBcrsApCQ=:0\",\n                \"thumb\": \"\",\n                \"type\": 2,\n                \"hashinfo\": \"null\",\n                \"hash_info\": null,\n                \"raw_url\": \"xxxxxx\",\n                \"readme\": \"\",\n                \"header\": \"\",\n                \"provider\": \"UrlTree\",\n                \"related\": null\n            }\n        }\n        \"\"\"\n        if not resp:\n            logger.warn(f\"【OpenList】请求获取文件 {path} 失败，无法连接alist服务\")\n            return None\n        if resp.status_code != 200:\n            logger.warn(\n                f\"【OpenList】请求获取文件 {path} 失败，状态码：{resp.status_code}\"\n            )\n            return None\n\n        result = resp.json()\n        if result[\"code\"] != 200:\n            logger.warn(\n                f\"【OpenList】获取文件 {path} 失败，错误信息：{result['message']}\"\n            )\n            return None\n\n        if result[\"data\"][\"raw_url\"]:\n            download_url = result[\"data\"][\"raw_url\"]\n        else:\n            download_url = UrlUtils.adapt_request_url(\n                self.__get_base_url, f\"/d{fileitem.path}\"\n            )\n            if result[\"data\"][\"sign\"]:\n                download_url = download_url + \"?sign=\" + result[\"data\"][\"sign\"]\n\n        if not path:\n            local_path = settings.TEMP_PATH / fileitem.name\n        else:\n            local_path = path / fileitem.name\n\n        request_utils = RequestUtils(headers=self.__get_header_with_token())\n        try:\n            with request_utils.get_stream(download_url, raise_exception=True) as r:\n                r.raise_for_status()\n                with open(local_path, \"wb\") as f:\n                    for chunk in r.iter_content(chunk_size=8192):\n                        if global_vars.is_transfer_stopped(fileitem.path):\n                            logger.info(f\"【OpenList】{fileitem.path} 下载已取消！\")\n                            return None\n                        f.write(chunk)\n        except Exception as e:\n            logger.error(f\"【OpenList】下载文件 {fileitem.path} 失败：{e}\")\n            if local_path.exists():\n                return local_path\n\n        return local_path\n\n    def upload(\n        self,\n        fileitem: schemas.FileItem,\n        path: Path,\n        new_name: Optional[str] = None,\n        task: bool = False,\n    ) -> Optional[schemas.FileItem]:\n        \"\"\"\n        上传文件（带进度）\n        :param fileitem: 上传目录项\n        :param path: 本地文件路径\n        :param new_name: 上传后文件名\n        :param task: 是否为任务，默认为False避免未完成上传时对文件进行操作\n        :return: 上传后的文件项\n        \"\"\"\n        try:\n            # 获取文件大小\n            target_name = new_name or path.name\n            target_path = Path(fileitem.path) / target_name\n\n            # 初始化进度回调\n            progress_callback = transfer_process(path.as_posix())\n\n            # 准备上传请求\n            encoded_path = UrlUtils.quote(target_path.as_posix())\n            headers = self.__get_header_with_token()\n            headers.setdefault(\"Content-Type\", \"application/octet-stream\")\n            headers.setdefault(\"As-Task\", str(task).lower())\n            headers.setdefault(\"File-Path\", encoded_path)\n\n            # 创建自定义的文件流，支持进度回调\n            class ProgressFileReader:\n                def __init__(self, file_path: Path, callback):\n                    self.file = open(file_path, \"rb\")\n                    self.callback = callback\n                    self.uploaded_size = 0\n                    self.file_size = file_path.stat().st_size\n\n                def __len__(self) -> int:\n                    return self.file_size\n\n                def read(self, size=-1):\n                    if global_vars.is_transfer_stopped(path.as_posix()):\n                        logger.info(f\"【OpenList】{path} 上传已取消！\")\n                        raise OperationInterrupted(f\"Upload cancelled: {path}\")\n                    chunk = self.file.read(size)\n                    if chunk:\n                        self.uploaded_size += len(chunk)\n                        if self.callback:\n                            percent = (self.uploaded_size * 100) / self.file_size\n                            self.callback(percent)\n                    return chunk\n\n                def close(self):\n                    self.file.close()\n\n            # 使用自定义文件流上传\n            progress_reader = ProgressFileReader(path, progress_callback)\n            try:\n                resp = RequestUtils(headers=headers, timeout=6000).put_res(\n                    self.__get_api_url(\"/api/fs/put\"),\n                    data=progress_reader,\n                )\n            except OperationInterrupted:\n                return None\n            finally:\n                progress_reader.close()\n\n            if resp is None:\n                logger.warn(f\"【OpenList】请求上传文件 {path} 失败\")\n                return None\n            if resp.status_code != 200:\n                logger.warn(\n                    f\"【OpenList】请求上传文件 {path} 失败，状态码：{resp.status_code}\"\n                )\n                return None\n\n            # 完成上传\n            progress_callback(100)\n\n            # 获取上传后的文件项\n            new_item = self._delay_get_item(target_path, refresh=True)\n            if new_item and new_name and new_name != path.name:\n                if self.rename(new_item, new_name):\n                    return self._delay_get_item(\n                        Path(new_item.path).with_name(new_name), refresh=True\n                    )\n\n            return new_item\n\n        except Exception as e:\n            logger.error(f\"【OpenList】上传文件 {path} 失败：{e}\")\n            return None\n\n    def detail(self, fileitem: schemas.FileItem) -> Optional[schemas.FileItem]:\n        \"\"\"\n        获取文件详情\n        \"\"\"\n        return self.get_item(Path(fileitem.path))\n\n    def copy(self, fileitem: schemas.FileItem, path: Path, new_name: str) -> bool:\n        \"\"\"\n        复制文件\n        :param fileitem: 文件项\n        :param path: 目标目录\n        :param new_name: 新文件名\n        :return: 是否复制成功\n        \"\"\"\n        resp = RequestUtils(headers=self.__get_header_with_token()).post_res(\n            self.__get_api_url(\"/api/fs/copy\"),\n            json={\n                \"src_dir\": Path(fileitem.path).parent.as_posix(),\n                \"dst_dir\": path.as_posix(),\n                \"names\": [fileitem.name],\n            },\n        )\n        \"\"\"\n        {\n            \"src_dir\": \"string\",\n            \"dst_dir\": \"string\",\n            \"names\": [\n                \"string\"\n            ]\n        }\n        ======================================\n        {\n            \"code\": 200,\n            \"message\": \"success\",\n            \"data\": null\n        }\n        \"\"\"\n        if resp is None:\n            logger.warn(\n                f\"【OpenList】请求复制文件 {fileitem.path} 失败，无法连接alist服务\"\n            )\n            return False\n        if resp.status_code != 200:\n            logger.warn(\n                f\"【OpenList】请求复制文件 {fileitem.path} 失败，状态码：{resp.status_code}\"\n            )\n            return False\n\n        result = resp.json()\n        if result[\"code\"] != 200:\n            logger.warn(\n                f\"【OpenList】复制文件 {fileitem.path} 失败，错误信息：{result['message']}\"\n            )\n            return False\n        # 重命名\n        if fileitem.name != new_name:\n            new_item = self._delay_get_item(path / fileitem.name, refresh=True)\n            if new_item:\n                self.rename(new_item, new_name)\n        return True\n\n    def move(self, fileitem: schemas.FileItem, path: Path, new_name: str) -> bool:\n        \"\"\"\n        移动文件\n        :param fileitem: 文件项\n        :param path: 目标目录\n        :param new_name: 新文件名\n        :return: 是否移动成功\n        \"\"\"\n        # 先重命名\n        if fileitem.name != new_name:\n            self.rename(fileitem, new_name)\n        resp = RequestUtils(headers=self.__get_header_with_token()).post_res(\n            self.__get_api_url(\"/api/fs/move\"),\n            json={\n                \"src_dir\": Path(fileitem.path).parent.as_posix(),\n                \"dst_dir\": path.as_posix(),\n                \"names\": [new_name],\n            },\n        )\n        \"\"\"\n        {\n            \"src_dir\": \"string\",\n            \"dst_dir\": \"string\",\n            \"names\": [\n                \"string\"\n            ]\n        }\n        ======================================\n        {\n            \"code\": 200,\n            \"message\": \"success\",\n            \"data\": null\n        }\n        \"\"\"\n        if resp is None:\n            logger.warn(\n                f\"【OpenList】请求移动文件 {fileitem.path} 失败，无法连接alist服务\"\n            )\n            return False\n        if resp.status_code != 200:\n            logger.warn(\n                f\"【OpenList】请求移动文件 {fileitem.path} 失败，状态码：{resp.status_code}\"\n            )\n            return False\n\n        result = resp.json()\n        if result[\"code\"] != 200:\n            logger.warn(\n                f\"【OpenList】移动文件 {fileitem.path} 失败，错误信息：{result['message']}\"\n            )\n            return False\n        return True\n\n    def link(self, fileitem: schemas.FileItem, target_file: Path) -> bool:\n        \"\"\"\n        硬链接文件\n        \"\"\"\n        pass\n\n    def softlink(self, fileitem: schemas.FileItem, target_file: Path) -> bool:\n        \"\"\"\n        软链接文件\n        \"\"\"\n        pass\n\n    def usage(self) -> Optional[schemas.StorageUsage]:\n        \"\"\"\n        存储使用情况\n        \"\"\"\n        pass\n\n    @staticmethod\n    def __parse_timestamp(time_str: str) -> float:\n        \"\"\"\n        直接使用 ISO 8601 格式解析时间\n        \"\"\"\n        return datetime.fromisoformat(time_str).timestamp()\n"
  },
  {
    "path": "app/modules/filemanager/storages/local.py",
    "content": "import shutil\nfrom pathlib import Path\nfrom typing import Optional, List\n\nfrom app import schemas\nfrom app.core.config import global_vars\nfrom app.helper.directory import DirectoryHelper\nfrom app.log import logger\nfrom app.modules.filemanager.storages import StorageBase, transfer_process\nfrom app.schemas.types import StorageSchema\nfrom app.utils.system import SystemUtils\n\n\nclass LocalStorage(StorageBase):\n    \"\"\"\n    本地文件操作\n    \"\"\"\n\n    # 存储类型\n    schema = StorageSchema.Local\n    # 支持的整理方式\n    transtype = {\n        \"copy\": \"复制\",\n        \"move\": \"移动\",\n        \"link\": \"硬链接\",\n        \"softlink\": \"软链接\"\n    }\n\n    # 文件块大小，默认10MB\n    chunk_size = 10 * 1024 * 1024\n\n    def init_storage(self):\n        \"\"\"\n        初始化\n        \"\"\"\n        pass\n\n    def check(self) -> bool:\n        \"\"\"\n        检查存储是否可用\n        \"\"\"\n        return True\n\n    def __get_fileitem(self, path: Path) -> schemas.FileItem:\n        \"\"\"\n        获取文件项\n        \"\"\"\n        return schemas.FileItem(\n            storage=self.schema.value,\n            type=\"file\",\n            path=path.as_posix(),\n            name=path.name,\n            basename=path.stem,\n            extension=path.suffix[1:],\n            size=path.stat().st_size,\n            modify_time=path.stat().st_mtime,\n        )\n\n    def __get_diritem(self, path: Path) -> schemas.FileItem:\n        \"\"\"\n        获取目录项\n        \"\"\"\n        return schemas.FileItem(\n            storage=self.schema.value,\n            type=\"dir\",\n            path=path.as_posix() + \"/\",\n            name=path.name,\n            basename=path.stem,\n            modify_time=path.stat().st_mtime,\n        )\n\n    def list(self, fileitem: schemas.FileItem) -> List[schemas.FileItem]:\n        \"\"\"\n        浏览文件\n        \"\"\"\n        # 返回结果\n        ret_items = []\n        path = fileitem.path\n        if not fileitem.path or fileitem.path == \"/\":\n            if SystemUtils.is_windows():\n                partitions = SystemUtils.get_windows_drives() or [\"C:/\"]\n                for partition in partitions:\n                    ret_items.append(schemas.FileItem(\n                        storage=self.schema.value,\n                        type=\"dir\",\n                        path=partition + \"/\",\n                        name=partition,\n                        basename=partition\n                    ))\n                return ret_items\n            else:\n                path = \"/\"\n        else:\n            if SystemUtils.is_windows():\n                path = path.lstrip(\"/\")\n            elif not path.startswith(\"/\"):\n                path = \"/\" + path\n\n        # 遍历目录\n        path_obj = Path(path)\n        if not path_obj.exists():\n            logger.warn(f\"【本地】目录不存在：{path}\")\n            return []\n\n        # 如果是文件\n        if path_obj.is_file():\n            ret_items.append(self.__get_fileitem(path_obj))\n            return ret_items\n\n        # 扁历所有目录\n        for item in SystemUtils.list_sub_directory(path_obj):\n            ret_items.append(self.__get_diritem(item))\n\n        # 遍历所有文件，不含子目录\n        for item in SystemUtils.list_sub_file(path_obj):\n            ret_items.append(self.__get_fileitem(item))\n        return ret_items\n\n    def create_folder(self, fileitem: schemas.FileItem, name: str) -> Optional[schemas.FileItem]:\n        \"\"\"\n        创建目录\n        :param fileitem: 父目录\n        :param name: 目录名\n        \"\"\"\n        if not fileitem.path:\n            return None\n        path_obj = Path(fileitem.path) / name\n        if not path_obj.exists():\n            path_obj.mkdir(parents=True, exist_ok=True)\n        return self.__get_diritem(path_obj)\n\n    def get_folder(self, path: Path) -> Optional[schemas.FileItem]:\n        \"\"\"\n        获取目录\n        \"\"\"\n        if not path.exists():\n            path.mkdir(parents=True, exist_ok=True)\n        return self.__get_diritem(path)\n\n    def get_item(self, path: Path) -> Optional[schemas.FileItem]:\n        \"\"\"\n        获取文件或目录，不存在返回None\n        \"\"\"\n        if not path.exists():\n            return None\n        if path.is_file():\n            return self.__get_fileitem(path)\n        return self.__get_diritem(path)\n\n    def detail(self, fileitem: schemas.FileItem) -> Optional[schemas.FileItem]:\n        \"\"\"\n        获取文件详情\n        \"\"\"\n        path_obj = Path(fileitem.path)\n        if not path_obj.exists():\n            return None\n        return self.__get_fileitem(path_obj)\n\n    def delete(self, fileitem: schemas.FileItem) -> bool:\n        \"\"\"\n        删除文件\n        \"\"\"\n        if not fileitem.path:\n            return False\n        path_obj = Path(fileitem.path)\n        if not path_obj.exists():\n            return True\n        try:\n            if path_obj.is_file():\n                path_obj.unlink()\n            else:\n                shutil.rmtree(path_obj, ignore_errors=True)\n        except Exception as e:\n            logger.error(f\"【本地】删除文件失败：{e}\")\n            return False\n        return True\n\n    def rename(self, fileitem: schemas.FileItem, name: str) -> bool:\n        \"\"\"\n        重命名文件\n        \"\"\"\n        path_obj = Path(fileitem.path)\n        if not path_obj.exists():\n            return False\n        try:\n            path_obj.rename(path_obj.parent / name)\n        except Exception as e:\n            logger.error(f\"【本地】重命名文件失败：{e}\")\n            return False\n        return True\n\n    def download(self, fileitem: schemas.FileItem, path: Path = None) -> Optional[Path]:\n        \"\"\"\n        下载文件\n        \"\"\"\n        return Path(fileitem.path)\n\n    def _copy_with_progress(self, src: Path, dest: Path):\n        \"\"\"\n        分块复制文件并回调进度\n        \"\"\"\n        total_size = src.stat().st_size\n        copied_size = 0\n        progress_callback = transfer_process(src.as_posix())\n        try:\n            with open(src, \"rb\") as fsrc, open(dest, \"wb\") as fdst:\n                while True:\n                    if global_vars.is_transfer_stopped(src.as_posix()):\n                        logger.info(f\"【本地】{src} 复制已取消！\")\n                        return False\n                    buf = fsrc.read(self.chunk_size)\n                    if not buf:\n                        break\n                    fdst.write(buf)\n                    copied_size += len(buf)\n                    # 更新进度\n                    if progress_callback:\n                        percent = copied_size / total_size * 100\n                        progress_callback(percent)\n            # 保留文件时间戳、权限等信息\n            shutil.copystat(src, dest)\n            return True\n        except Exception as e:\n            logger.error(f\"【本地】复制文件 {src} 失败：{e}\")\n            return False\n        finally:\n            progress_callback(100)\n\n    def upload(\n            self,\n            fileitem: schemas.FileItem,\n            path: Path,\n            new_name: Optional[str] = None\n    ) -> Optional[schemas.FileItem]:\n        \"\"\"\n        上传文件（带进度）\n        \"\"\"\n        try:\n            dir_path = Path(fileitem.path)\n            target_path = dir_path / (new_name or path.name)\n            if self._copy_with_progress(path, target_path):\n                # 上传删除源文件\n                path.unlink()\n                return self.get_item(target_path)\n        except Exception as err:\n            logger.error(f\"【本地】移动文件失败：{err}\")\n        return None\n\n    @staticmethod\n    def __should_show_progress(src: Path, dest: Path):\n        \"\"\"\n        是否显示进度条\n        \"\"\"\n        src_isnetwork = SystemUtils.is_network_filesystem(src)\n        dest_isnetwork = SystemUtils.is_network_filesystem(dest)\n        if src_isnetwork and dest_isnetwork and SystemUtils.is_same_disk(src, dest):\n            return True\n        return False\n\n    def copy(\n            self,\n            fileitem: schemas.FileItem,\n            path: Path,\n            new_name: str\n    ) -> bool:\n        \"\"\"\n        复制文件（带进度）\n        \"\"\"\n        try:\n            src = Path(fileitem.path)\n            dest = path / new_name\n            if self.__should_show_progress(src, dest):\n                if self._copy_with_progress(src, dest):\n                    return True\n            else:\n                code, message = SystemUtils.copy(src, dest)\n                if code == 0:\n                    return True\n                else:\n                    logger.error(f\"【本地】复制文件失败：{message}\")\n        except Exception as err:\n            logger.error(f\"【本地】复制文件失败：{err}\")\n        return False\n\n    def move(\n            self,\n            fileitem: schemas.FileItem,\n            path: Path,\n            new_name: str\n    ) -> bool:\n        \"\"\"\n        移动文件（带进度）\n        \"\"\"\n        try:\n            src = Path(fileitem.path)\n            dest = path / new_name\n            if src == dest:\n                # 目标和源文件相同，直接返回成功，不做任何操作\n                return True\n            if self.__should_show_progress(src, dest):\n                if self._copy_with_progress(src, dest):\n                    # 复制成功删除源文件\n                    src.unlink()\n                    return True\n            else:\n                code, message = SystemUtils.move(src, dest)\n                if code == 0:\n                    return True\n                else:\n                    logger.error(f\"【本地】移动文件失败：{message}\")\n        except Exception as err:\n            logger.error(f\"【本地】移动文件失败：{err}\")\n        return False\n\n    def link(self, fileitem: schemas.FileItem, target_file: Path) -> bool:\n        \"\"\"\n        硬链接文件\n        \"\"\"\n        file_path = Path(fileitem.path)\n        code, message = SystemUtils.link(file_path, target_file)\n        if code != 0:\n            logger.error(f\"【本地】硬链接文件失败：{message}\")\n            return False\n        return True\n\n    def softlink(self, fileitem: schemas.FileItem, target_file: Path) -> bool:\n        \"\"\"\n        软链接文件\n        \"\"\"\n        file_path = Path(fileitem.path)\n        code, message = SystemUtils.softlink(file_path, target_file)\n        if code != 0:\n            logger.error(f\"【本地】软链接文件失败：{message}\")\n            return False\n        return True\n\n    def usage(self) -> Optional[schemas.StorageUsage]:\n        \"\"\"\n        存储使用情况\n        \"\"\"\n        directory_helper = DirectoryHelper()\n        total_storage, free_storage = SystemUtils.space_usage(\n            [Path(d.download_path) for d in directory_helper.get_local_download_dirs() if d.download_path] +\n            [Path(d.library_path) for d in directory_helper.get_local_library_dirs() if d.library_path]\n        )\n        return schemas.StorageUsage(\n            total=total_storage,\n            available=free_storage\n        )\n"
  },
  {
    "path": "app/modules/filemanager/storages/rclone.py",
    "content": "import json\nimport subprocess\nfrom pathlib import Path\nfrom typing import Optional, List\n\nfrom app import schemas\nfrom app.core.config import settings\nfrom app.log import logger\nfrom app.modules.filemanager.storages import StorageBase, transfer_process\nfrom app.schemas.types import StorageSchema\nfrom app.utils.string import StringUtils\nfrom app.utils.system import SystemUtils\n\n\nclass Rclone(StorageBase):\n    \"\"\"\n    rclone相关操作\n    \"\"\"\n\n    # 存储类型\n    schema = StorageSchema.Rclone\n\n    # 支持的整理方式\n    transtype = {\n        \"move\": \"移动\",\n        \"copy\": \"复制\"\n    }\n\n    snapshot_check_folder_modtime = settings.RCLONE_SNAPSHOT_CHECK_FOLDER_MODTIME\n\n    def init_storage(self):\n        \"\"\"\n        初始化\n        \"\"\"\n        pass\n\n    def set_config(self, conf: dict):\n        \"\"\"\n        设置配置\n        \"\"\"\n        super().set_config(conf)\n        filepath = conf.get(\"filepath\")\n        if not filepath:\n            logger.warn(\"【rclone】保存配置失败：未设置配置文件路径\")\n        logger.info(f\"【rclone】配置写入文件：{filepath}\")\n        path = Path(filepath)\n        if not path.parent.exists():\n            path.parent.mkdir(parents=True, exist_ok=True)\n        path.write_text(conf.get('content'), encoding='utf-8')\n\n    @staticmethod\n    def __get_hidden_shell():\n        if SystemUtils.is_windows():\n            st = subprocess.STARTUPINFO()\n            st.dwFlags = subprocess.STARTF_USESHOWWINDOW\n            st.wShowWindow = subprocess.SW_HIDE\n            return st\n        else:\n            return None\n\n    @staticmethod\n    def __parse_rclone_progress(line: str) -> Optional[float]:\n        \"\"\"\n        解析rclone进度输出\n        \"\"\"\n        if not line:\n            return None\n        \n        line = line.strip()\n        \n        # 检查是否包含百分比\n        if '%' not in line:\n            return None\n            \n        try:\n            # 尝试多种进度输出格式\n            if 'ETA' in line:\n                # 格式: \"Transferred: 1.234M / 5.678M, 22%, 1.234MB/s, ETA 2m3s\"\n                percent_str = line.split('%')[0].split()[-1]\n                return float(percent_str)\n            elif 'Transferred:' in line and '100%' in line:\n                # 传输完成\n                return 100.0\n            else:\n                # 其他包含百分比的格式\n                parts = line.split()\n                for part in parts:\n                    if '%' in part:\n                        percent_str = part.replace('%', '')\n                        return float(percent_str)\n        except (ValueError, IndexError):\n            pass\n            \n        return None\n\n    def __get_rcloneitem(self, item: dict, parent: Optional[str] = \"/\") -> schemas.FileItem:\n        \"\"\"\n        获取rclone文件项\n        \"\"\"\n        if not item:\n            return schemas.FileItem()\n        if item.get(\"IsDir\"):\n            return schemas.FileItem(\n                storage=self.schema.value,\n                type=\"dir\",\n                path=f\"{parent}{item.get('Name')}\" + \"/\",\n                name=item.get(\"Name\"),\n                basename=item.get(\"Name\"),\n                modify_time=StringUtils.str_to_timestamp(item.get(\"ModTime\"))\n            )\n        else:\n            return schemas.FileItem(\n                storage=self.schema.value,\n                type=\"file\",\n                path=f\"{parent}{item.get('Name')}\",\n                name=item.get(\"Name\"),\n                basename=Path(item.get(\"Name\")).stem,\n                extension=Path(item.get(\"Name\")).suffix[1:],\n                size=item.get(\"Size\"),\n                modify_time=StringUtils.str_to_timestamp(item.get(\"ModTime\"))\n            )\n\n    def check(self) -> bool:\n        \"\"\"\n        检查存储是否可用\n        \"\"\"\n        try:\n            retcode = subprocess.run(\n                ['rclone', 'lsf', 'MP:'],\n                startupinfo=self.__get_hidden_shell()\n            ).returncode\n            if retcode == 0:\n                return True\n        except Exception as err:\n            logger.error(f\"【rclone】存储检查失败：{err}\")\n        return False\n\n    def list(self, fileitem: schemas.FileItem) -> List[schemas.FileItem]:\n        \"\"\"\n        浏览文件\n        \"\"\"\n        if fileitem.type == \"file\":\n            return [fileitem]\n        try:\n            ret = subprocess.run(\n                [\n                    'rclone', 'lsjson',\n                    f'MP:{fileitem.path}'\n                ],\n                capture_output=True,\n                startupinfo=self.__get_hidden_shell()\n            )\n            if ret.returncode == 0:\n                items = json.loads(ret.stdout)\n                return [self.__get_rcloneitem(item, parent=fileitem.path) for item in items]\n        except Exception as err:\n            logger.error(f\"【rclone】浏览文件失败：{err}\")\n        return []\n\n    def create_folder(self, fileitem: schemas.FileItem, name: str) -> Optional[schemas.FileItem]:\n        \"\"\"\n        创建目录\n        :param fileitem: 父目录\n        :param name: 目录名\n        \"\"\"\n        try:\n            retcode = subprocess.run(\n                [\n                    'rclone', 'mkdir',\n                    f'MP:{Path(fileitem.path) / name}'\n                ],\n                startupinfo=self.__get_hidden_shell()\n            ).returncode\n            if retcode == 0:\n                return self.get_item(Path(fileitem.path) / name)\n        except Exception as err:\n            logger.error(f\"【rclone】创建目录失败：{err}\")\n        return None\n\n    def get_folder(self, path: Path) -> Optional[schemas.FileItem]:\n        \"\"\"\n        根据文件路程获取目录，不存在则创建\n        \"\"\"\n\n        def __find_dir(_fileitem: schemas.FileItem, _name: str) -> Optional[schemas.FileItem]:\n            \"\"\"\n            查找下级目录中匹配名称的目录\n            \"\"\"\n            for sub_folder in self.list(_fileitem):\n                if sub_folder.type != \"dir\":\n                    continue\n                if sub_folder.name == _name:\n                    return sub_folder\n            return None\n\n        # 是否已存在\n        folder = self.get_item(path)\n        if folder:\n            return folder\n        # 逐级查找和创建目录\n        fileitem = schemas.FileItem(storage=self.schema.value, path=\"/\")\n        for part in path.parts[1:]:\n            dir_file = __find_dir(fileitem, part)\n            if dir_file:\n                fileitem = dir_file\n            else:\n                dir_file = self.create_folder(fileitem, part)\n                if not dir_file:\n                    logger.warn(f\"【rclone】创建目录 {fileitem.path}{part} 失败！\")\n                    return None\n                fileitem = dir_file\n        return fileitem\n\n    def get_item(self, path: Path) -> Optional[schemas.FileItem]:\n        \"\"\"\n        获取文件或目录，不存在返回None\n        \"\"\"\n        try:\n            ret = subprocess.run(\n                [\n                    'rclone', 'lsjson',\n                    f'MP:{path.parent}'\n                ],\n                capture_output=True,\n                startupinfo=self.__get_hidden_shell()\n            )\n            if ret.returncode == 0:\n                items = json.loads(ret.stdout)\n                for item in items:\n                    if item.get(\"Name\") == path.name:\n                        return self.__get_rcloneitem(item, parent=str(path.parent) + \"/\")\n            return None\n        except Exception as err:\n            logger.debug(f\"【rclone】获取文件项失败：{err}\")\n        return None\n\n    def delete(self, fileitem: schemas.FileItem) -> bool:\n        \"\"\"\n        删除文件\n        \"\"\"\n        try:\n            retcode = subprocess.run(\n                [\n                    'rclone', 'deletefile',\n                    f'MP:{fileitem.path}'\n                ],\n                startupinfo=self.__get_hidden_shell()\n            ).returncode\n            if retcode == 0:\n                return True\n        except Exception as err:\n            logger.error(f\"【rclone】删除文件失败：{err}\")\n        return False\n\n    def rename(self, fileitem: schemas.FileItem, name: str) -> bool:\n        \"\"\"\n        重命名文件\n        \"\"\"\n        try:\n            retcode = subprocess.run(\n                [\n                    'rclone', 'moveto',\n                    f'MP:{fileitem.path}',\n                    f'MP:{Path(fileitem.path).parent / name}'\n                ],\n                startupinfo=self.__get_hidden_shell()\n            ).returncode\n            if retcode == 0:\n                return True\n        except Exception as err:\n            logger.error(f\"【rclone】重命名文件失败：{err}\")\n        return False\n\n    def download(self, fileitem: schemas.FileItem, path: Path = None) -> Optional[Path]:\n        \"\"\"\n        带实时进度显示的下载\n        \"\"\"\n        local_path = (path or settings.TEMP_PATH) / fileitem.name\n        \n        # 初始化进度条\n        logger.info(f\"【rclone】开始下载: {fileitem.name} -> {local_path}\")\n        progress_callback = transfer_process(Path(fileitem.path).as_posix())\n        \n        try:\n            # 使用rclone的进度显示功能\n            process = subprocess.Popen(\n                [\n                    'rclone', 'copyto',\n                    '--progress',  # 启用进度显示\n                    '--stats', '1s',  # 每秒更新一次统计信息\n                    f'MP:{fileitem.path}',\n                    f'{local_path}'\n                ],\n                stdout=subprocess.PIPE,\n                stderr=subprocess.STDOUT,\n                startupinfo=self.__get_hidden_shell(),\n                universal_newlines=True,\n                bufsize=1\n            )\n            \n            # 监控进度输出\n            last_progress = 0\n            for line in process.stdout:\n                if line:\n                    # 解析rclone的进度输出\n                    progress = self.__parse_rclone_progress(line)\n                    if progress is not None and progress > last_progress:\n                        progress_callback(progress)\n                        last_progress = progress\n                        if progress >= 100:\n                            break\n            \n            # 等待进程完成\n            retcode = process.wait()\n            if retcode == 0:\n                logger.info(f\"【rclone】下载完成: {fileitem.name}\")\n                return local_path\n            else:\n                logger.error(f\"【rclone】下载失败: {fileitem.name}\")\n                return None\n                \n        except Exception as err:\n            logger.error(f\"【rclone】下载失败: {fileitem.name} - {err}\")\n            # 删除可能部分下载的文件\n            if local_path.exists():\n                local_path.unlink()\n            return None\n\n    def upload(self, fileitem: schemas.FileItem, path: Path,\n               new_name: Optional[str] = None) -> Optional[schemas.FileItem]:\n        \"\"\"\n        带实时进度显示的上传\n        :param fileitem: 上传目录项\n        :param path: 本地文件路径\n        :param new_name: 上传后文件名\n        \"\"\"\n        target_name = new_name or path.name\n        new_path = Path(fileitem.path) / target_name\n        \n        # 初始化进度条\n        logger.info(f\"【rclone】开始上传: {path} -> {new_path}\")\n        progress_callback = transfer_process(path.as_posix())\n        \n        try:\n            # 使用rclone的进度显示功能\n            process = subprocess.Popen(\n                [\n                    'rclone', 'copyto',\n                    '--progress',  # 启用进度显示\n                    '--stats', '1s',  # 每秒更新一次统计信息\n                    path.as_posix(),\n                    f'MP:{new_path}'\n                ],\n                stdout=subprocess.PIPE,\n                stderr=subprocess.STDOUT,\n                startupinfo=self.__get_hidden_shell(),\n                universal_newlines=True,\n                bufsize=1\n            )\n            \n            # 监控进度输出\n            last_progress = 0\n            for line in process.stdout:\n                if line:\n                    # 解析rclone的进度输出\n                    progress = self.__parse_rclone_progress(line)\n                    if progress is not None and progress > last_progress:\n                        progress_callback(progress)\n                        last_progress = progress\n                        if progress >= 100:\n                            break\n            \n            # 等待进程完成\n            retcode = process.wait()\n            if retcode == 0:\n                logger.info(f\"【rclone】上传完成: {target_name}\")\n                return self.get_item(new_path)\n            else:\n                logger.error(f\"【rclone】上传失败: {target_name}\")\n                return None\n                \n        except Exception as err:\n            logger.error(f\"【rclone】上传失败: {target_name} - {err}\")\n            return None\n\n    def detail(self, fileitem: schemas.FileItem) -> Optional[schemas.FileItem]:\n        \"\"\"\n        获取文件详情\n        \"\"\"\n        try:\n            ret = subprocess.run(\n                [\n                    'rclone', 'lsjson',\n                    f'MP:{fileitem.path}'\n                ],\n                capture_output=True,\n                startupinfo=self.__get_hidden_shell()\n            )\n            if ret.returncode == 0:\n                items = json.loads(ret.stdout)\n                return self.__get_rcloneitem(items[0])\n        except Exception as err:\n            logger.error(f\"【rclone】获取文件详情失败：{err}\")\n        return None\n\n    def move(self, fileitem: schemas.FileItem, path: Path, new_name: str) -> bool:\n        \"\"\"\n        移动文件\n        :param fileitem: 文件项\n        :param path: 目标目录\n        :param new_name: 新文件名\n        \"\"\"\n        target_path = path / new_name\n        \n        # 初始化进度条\n        logger.info(f\"【rclone】开始移动: {fileitem.path} -> {target_path}\")\n        progress_callback = transfer_process(Path(fileitem.path).as_posix())\n        \n        try:\n            # 使用rclone的进度显示功能\n            process = subprocess.Popen(\n                [\n                    'rclone', 'moveto',\n                    '--progress',  # 启用进度显示\n                    '--stats', '1s',  # 每秒更新一次统计信息\n                    f'MP:{fileitem.path}',\n                    f'MP:{target_path}'\n                ],\n                stdout=subprocess.PIPE,\n                stderr=subprocess.STDOUT,\n                startupinfo=self.__get_hidden_shell(),\n                universal_newlines=True,\n                bufsize=1\n            )\n            \n            # 监控进度输出\n            last_progress = 0\n            for line in process.stdout:\n                if line:\n                    # 解析rclone的进度输出\n                    progress = self.__parse_rclone_progress(line)\n                    if progress is not None and progress > last_progress:\n                        progress_callback(progress)\n                        last_progress = progress\n                        if progress >= 100:\n                            break\n            \n            # 等待进程完成\n            retcode = process.wait()\n            if retcode == 0:\n                logger.info(f\"【rclone】移动完成: {fileitem.name}\")\n                return True\n            else:\n                logger.error(f\"【rclone】移动失败: {fileitem.name}\")\n                return False\n                \n        except Exception as err:\n            logger.error(f\"【rclone】移动失败: {fileitem.name} - {err}\")\n            return False\n\n    def copy(self, fileitem: schemas.FileItem, path: Path, new_name: str) -> bool:\n        \"\"\"\n        复制文件\n        :param fileitem: 文件项\n        :param path: 目标目录\n        :param new_name: 新文件名\n        \"\"\"\n        target_path = path / new_name\n        \n        # 初始化进度条\n        logger.info(f\"【rclone】开始复制: {fileitem.path} -> {target_path}\")\n        progress_callback = transfer_process(Path(fileitem.path).as_posix())\n        \n        try:\n            # 使用rclone的进度显示功能\n            process = subprocess.Popen(\n                [\n                    'rclone', 'copyto',\n                    '--progress',  # 启用进度显示\n                    '--stats', '1s',  # 每秒更新一次统计信息\n                    f'MP:{fileitem.path}',\n                    f'MP:{target_path}'\n                ],\n                stdout=subprocess.PIPE,\n                stderr=subprocess.STDOUT,\n                startupinfo=self.__get_hidden_shell(),\n                universal_newlines=True,\n                bufsize=1\n            )\n            \n            # 监控进度输出\n            last_progress = 0\n            for line in process.stdout:\n                if line:\n                    # 解析rclone的进度输出\n                    progress = self.__parse_rclone_progress(line)\n                    if progress is not None and progress > last_progress:\n                        progress_callback(progress)\n                        last_progress = progress\n                        if progress >= 100:\n                            break\n            \n            # 等待进程完成\n            retcode = process.wait()\n            if retcode == 0:\n                logger.info(f\"【rclone】复制完成: {fileitem.name}\")\n                return True\n            else:\n                logger.error(f\"【rclone】复制失败: {fileitem.name}\")\n                return False\n                \n        except Exception as err:\n            logger.error(f\"【rclone】复制失败: {fileitem.name} - {err}\")\n            return False\n\n    def link(self, fileitem: schemas.FileItem, target_file: Path) -> bool:\n        pass\n\n    def softlink(self, fileitem: schemas.FileItem, target_file: Path) -> bool:\n        pass\n\n    def usage(self) -> Optional[schemas.StorageUsage]:\n        \"\"\"\n        存储使用情况\n        \"\"\"\n        conf = self.get_config()\n        if not conf:\n            return None\n        file_path = conf.config.get(\"filepath\")\n        if not file_path or not Path(file_path).exists():\n            return None\n        # 读取rclone文件，检查是否有[MP]节点配置\n        with open(file_path, \"r\", encoding=\"utf-8\") as f:\n            lines = f.readlines()\n            if not lines:\n                return None\n            if not any(\"[MP]\" in line.strip() for line in lines):\n                return None\n        try:\n            ret = subprocess.run(\n                [\n                    'rclone', 'about',\n                    'MP:/', '--json'\n                ],\n                capture_output=True,\n                startupinfo=self.__get_hidden_shell()\n            )\n            if ret.returncode == 0:\n                items = json.loads(ret.stdout)\n                return schemas.StorageUsage(\n                    total=items.get(\"total\"),\n                    available=items.get(\"free\")\n                )\n        except Exception as err:\n            logger.error(f\"【rclone】获取存储使用情况失败：{err}\")\n        return None\n"
  },
  {
    "path": "app/modules/filemanager/storages/smb.py",
    "content": "import threading\nimport time\nfrom pathlib import Path\nfrom typing import List, Optional, Union\n\nimport smbclient\nfrom smbclient import ClientConfig, register_session, reset_connection_cache\nfrom smbprotocol.exceptions import (\n    SMBException,\n    SMBResponseException,\n    SMBAuthenticationError,\n)\n\nfrom app import schemas\nfrom app.core.config import settings, global_vars\nfrom app.log import logger\nfrom app.modules.filemanager import StorageBase\nfrom app.modules.filemanager.storages import transfer_process\nfrom app.schemas.types import StorageSchema\nfrom app.utils.singleton import WeakSingleton\n\nlock = threading.Lock()\n\n\nclass SMBConnectionError(Exception):\n    \"\"\"\n    SMB 连接错误\n    \"\"\"\n\n    pass\n\n\nclass SMB(StorageBase, metaclass=WeakSingleton):\n    \"\"\"\n    SMB网络挂载存储相关操作 - 使用 smbclient 高级接口\n    \"\"\"\n\n    # 存储类型\n    schema = StorageSchema.SMB\n\n    # 支持的整理方式\n    transtype = {\n        \"move\": \"移动\",\n        \"copy\": \"复制\",\n        \"link\": \"硬链接\",\n    }\n\n    # 文件块大小，默认10MB\n    chunk_size = 10 * 1024 * 1024\n\n    def __init__(self):\n        super().__init__()\n        self._connected = False\n        self._server_path = None\n        self._host = None\n        self._username = None\n        self._password = None\n\n        self._init_connection()\n\n    def _init_connection(self):\n        \"\"\"\n        初始化SMB连接配置\n        \"\"\"\n        try:\n            conf = self.get_conf()\n            if not conf:\n                return\n\n            self._host = conf.get(\"host\")\n            self._username = conf.get(\"username\")\n            self._password = conf.get(\"password\")\n            domain = conf.get(\"domain\", \"\")\n            share = conf.get(\"share\", \"\")\n            port = conf.get(\"port\", 445)\n\n            if not all([self._host, share]):\n                logger.error(\"【SMB】缺少必要的连接参数：host 和 share\")\n                return\n\n            # 构建服务器路径\n            self._server_path = f\"\\\\\\\\{self._host}\\\\{share}\"\n\n            # 配置全局客户端设置\n            ClientConfig(\n                username=self._username,\n                password=self._password,\n                domain=domain if domain else None,\n                connection_timeout=60,\n                port=port,\n                auth_protocol=\"negotiate\",  # 使用协商认证\n                require_secure_negotiate=False,  # 匿名访问时可能需要关闭安全协商\n            )\n\n            # 注册会话以启用连接池\n            register_session(\n                self._host,\n                username=self._username,\n                password=self._password,\n                port=port,\n                encrypt=False,  # 根据需要启用加密\n                connection_timeout=60,\n            )\n\n            # 测试连接\n            self._test_connection()\n\n            self._connected = True\n            # 判断是否为匿名访问\n            if self._is_anonymous_access():\n                logger.info(f\"【SMB】匿名连接成功：{self._server_path}\")\n            else:\n                logger.info(\n                    f\"【SMB】认证连接成功：{self._server_path} (用户：{self._username})\"\n                )\n\n        except Exception as e:\n            logger.error(f\"【SMB】连接初始化失败：{e}\")\n            self._connected = False\n\n    def _test_connection(self):\n        \"\"\"\n        测试SMB连接\n        \"\"\"\n        try:\n            # 尝试列出根目录来测试连接\n            smbclient.listdir(self._server_path)\n        except SMBAuthenticationError as e:\n            raise SMBConnectionError(f\"SMB认证失败：{e}\")\n        except SMBResponseException as e:\n            raise SMBConnectionError(f\"SMB响应错误：{e}\")\n        except SMBException as e:\n            raise SMBConnectionError(f\"SMB连接错误：{e}\")\n        except Exception as e:\n            raise SMBConnectionError(f\"连接测试失败：{e}\")\n\n    def _is_anonymous_access(self) -> bool:\n        \"\"\"\n        检查是否为匿名访问\n        \"\"\"\n        return not self._username and not self._password\n\n    def _check_connection(self):\n        \"\"\"\n        检查SMB连接状态\n        \"\"\"\n        if not self._connected or not self._server_path:\n            raise SMBConnectionError(\"【SMB】连接未建立或已断开，请检查配置！\")\n\n    def _normalize_path(self, path: Union[str, Path]) -> str:\n        \"\"\"\n        标准化路径格式为SMB路径\n        \"\"\"\n        path_str = str(path)\n\n        # 处理根路径\n        if path_str in [\"/\", \"\\\\\"]:\n            return self._server_path\n\n        # 去除前导斜杠\n        if path_str.startswith(\"/\"):\n            path_str = path_str[1:]\n\n        # 构建完整的SMB路径\n        if path_str:\n            return f\"{self._server_path}\\\\{path_str.replace('/', '\\\\')}\"\n        else:\n            return self._server_path\n\n    def _create_fileitem(\n        self, stat_result, file_path: str, name: str\n    ) -> schemas.FileItem:\n        \"\"\"\n        创建文件项\n        \"\"\"\n        try:\n            # 检查是否为目录\n            is_directory = smbclient.path.isdir(file_path)\n\n            # 处理路径\n            relative_path = file_path.replace(self._server_path, \"\").replace(\"\\\\\", \"/\")\n            if not relative_path.startswith(\"/\"):\n                relative_path = \"/\" + relative_path\n\n            if is_directory and not relative_path.endswith(\"/\"):\n                relative_path += \"/\"\n\n            # 获取时间戳\n            try:\n                modify_time = int(stat_result.st_mtime)\n            except (AttributeError, TypeError):\n                modify_time = int(time.time())\n\n            if is_directory:\n                return schemas.FileItem(\n                    storage=self.schema.value,\n                    type=\"dir\",\n                    path=relative_path,\n                    name=name,\n                    basename=name,\n                    modify_time=modify_time,\n                )\n            else:\n                return schemas.FileItem(\n                    storage=self.schema.value,\n                    type=\"file\",\n                    path=relative_path,\n                    name=name,\n                    basename=Path(name).stem,\n                    extension=Path(name).suffix[1:] if Path(name).suffix else None,\n                    size=getattr(stat_result, \"st_size\", 0),\n                    modify_time=modify_time,\n                )\n        except Exception as e:\n            logger.error(f\"【SMB】创建文件项失败：{e}\")\n            # 返回基本的文件项信息\n            return schemas.FileItem(\n                storage=self.schema.value,\n                type=\"file\",\n                path=file_path.replace(self._server_path, \"\").replace(\"\\\\\", \"/\"),\n                name=name,\n                basename=Path(name).stem,\n                modify_time=int(time.time()),\n            )\n\n    def init_storage(self):\n        \"\"\"\n        初始化存储\n        \"\"\"\n        # 重置连接缓存\n        reset_connection_cache()\n        self._init_connection()\n\n    def check(self) -> bool:\n        \"\"\"\n        检查存储是否可用\n        \"\"\"\n        if not self._connected:\n            return False\n\n        try:\n            self._test_connection()\n            return True\n        except Exception as e:\n            logger.debug(f\"【SMB】连接检查失败：{e}\")\n            self._connected = False\n            return False\n\n    def list(self, fileitem: schemas.FileItem) -> List[schemas.FileItem]:\n        \"\"\"\n        浏览文件\n        \"\"\"\n        try:\n            self._check_connection()\n\n            if fileitem.type == \"file\":\n                item = self.detail(fileitem)\n                if item:\n                    return [item]\n                return []\n\n            # 构建SMB路径\n            smb_path = self._normalize_path(fileitem.path.rstrip(\"/\"))\n\n            # 列出目录内容\n            try:\n                entries = smbclient.listdir(smb_path)\n            except SMBResponseException as e:\n                logger.error(f\"【SMB】列出目录失败: {smb_path} - {e}\")\n                return []\n            except SMBException as e:\n                logger.error(f\"【SMB】列出目录失败: {smb_path} - {e}\")\n                return []\n\n            items = []\n            for entry in entries:\n                if entry in [\".\", \"..\"]:\n                    continue\n\n                entry_path = f\"{smb_path}\\\\{entry}\"\n                try:\n                    stat_result = smbclient.stat(entry_path)\n                    item = self._create_fileitem(stat_result, entry_path, entry)\n                    items.append(item)\n                except Exception as e:\n                    logger.debug(f\"【SMB】获取文件信息失败: {entry_path} - {e}\")\n                    continue\n\n            return items\n        except Exception as e:\n            logger.error(f\"【SMB】列出文件失败: {e}\")\n            return []\n\n    def create_folder(\n        self, fileitem: schemas.FileItem, name: str\n    ) -> Optional[schemas.FileItem]:\n        \"\"\"\n        创建目录\n        \"\"\"\n        try:\n            self._check_connection()\n\n            parent_path = self._normalize_path(fileitem.path.rstrip(\"/\"))\n            new_path = f\"{parent_path}\\\\{name}\"\n\n            # 创建目录\n            smbclient.mkdir(new_path)\n\n            # 返回创建的目录信息\n            return schemas.FileItem(\n                storage=self.schema.value,\n                type=\"dir\",\n                path=f\"{fileitem.path.rstrip('/')}/{name}/\",\n                name=name,\n                basename=name,\n                modify_time=int(time.time()),\n            )\n        except Exception as e:\n            logger.error(f\"【SMB】创建目录失败: {e}\")\n            return None\n\n    def get_folder(self, path: Path) -> Optional[schemas.FileItem]:\n        \"\"\"\n        获取目录，如目录不存在则创建\n        \"\"\"\n        # 检查目录是否存在\n        folder = self.get_item(path)\n        if folder:\n            return folder\n\n        # 逐级创建目录\n        parts = path.parts\n        current_path = Path(\"/\")\n\n        for part in parts[1:]:  # 跳过根目录\n            current_path = current_path / part\n            folder = self.get_item(current_path)\n            if not folder:\n                parent_folder = self.get_item(current_path.parent)\n                if not parent_folder:\n                    logger.error(f\"【SMB】父目录不存在: {current_path.parent}\")\n                    return None\n                folder = self.create_folder(parent_folder, part)\n                if not folder:\n                    return None\n\n        return folder\n\n    def get_item(self, path: Path) -> Optional[schemas.FileItem]:\n        \"\"\"\n        获取文件或目录，不存在返回None\n        \"\"\"\n        try:\n            self._check_connection()\n\n            # 处理根目录\n            if str(path) == \"/\":\n                return schemas.FileItem(\n                    storage=self.schema.value,\n                    type=\"dir\",\n                    path=\"/\",\n                    name=\"\",\n                    basename=\"\",\n                    modify_time=int(time.time()),\n                )\n\n            smb_path = self._normalize_path(str(path).rstrip(\"/\"))\n\n            # 检查路径是否存在\n            if not smbclient.path.exists(smb_path):\n                return None\n\n            stat_result = smbclient.stat(smb_path)\n            file_name = Path(path).name\n\n            return self._create_fileitem(stat_result, smb_path, file_name)\n\n        except Exception as e:\n            logger.debug(f\"【SMB】获取文件项失败: {e}\")\n            return None\n\n    def detail(self, fileitem: schemas.FileItem) -> Optional[schemas.FileItem]:\n        \"\"\"\n        获取文件详情\n        \"\"\"\n        return self.get_item(Path(fileitem.path))\n\n    def delete(self, fileitem: schemas.FileItem) -> bool:\n        \"\"\"\n        删除文件或目录\n        \"\"\"\n        try:\n            self._check_connection()\n\n            smb_path = self._normalize_path(fileitem.path.rstrip(\"/\"))\n            logger.info(f\"【SMB】开始删除: {fileitem.path} (类型: {fileitem.type})\")\n\n            # 先检查路径是否存在\n            if not smbclient.path.exists(smb_path):\n                logger.warn(f\"【SMB】路径不存在，跳过删除: {fileitem.path}\")\n                return True\n\n            if fileitem.type == \"dir\":\n                # 递归删除目录及其内容\n                logger.debug(f\"【SMB】递归删除目录: {smb_path}\")\n                self._recursive_delete(smb_path)\n            else:\n                # 删除文件\n                logger.debug(f\"【SMB】删除文件: {smb_path}\")\n                smbclient.remove(smb_path)\n\n            logger.info(f\"【SMB】删除成功: {fileitem.path}\")\n            return True\n        except SMBConnectionError as e:\n            logger.error(f\"【SMB】删除失败 - 连接错误: {fileitem.path} - {e}\")\n            return False\n        except SMBResponseException as e:\n            logger.error(f\"【SMB】删除失败 - SMB响应错误: {fileitem.path} - {e}\")\n            return False\n        except SMBException as e:\n            logger.error(f\"【SMB】删除失败 - SMB错误: {fileitem.path} - {e}\")\n            return False\n        except Exception as e:\n            logger.error(f\"【SMB】删除失败 - 未知错误: {fileitem.path} - {e}\")\n            return False\n\n    def _recursive_delete(self, smb_path: str):\n        \"\"\"\n        递归删除目录及其所有内容\n        \"\"\"\n        try:\n            # 检查路径是否存在\n            if not smbclient.path.exists(smb_path):\n                logger.debug(f\"【SMB】路径不存在，跳过删除: {smb_path}\")\n                return\n\n            # 如果是文件，直接删除\n            if smbclient.path.isfile(smb_path):\n                logger.debug(f\"【SMB】删除文件: {smb_path}\")\n                smbclient.remove(smb_path)\n                return\n\n            # 如果是目录，先删除其内容\n            if smbclient.path.isdir(smb_path):\n                logger.debug(f\"【SMB】开始删除目录内容: {smb_path}\")\n                try:\n                    # 列出目录内容\n                    entries = smbclient.listdir(smb_path)\n                    logger.debug(f\"【SMB】目录 {smb_path} 包含 {len(entries)} 个项目\")\n\n                    for entry in entries:\n                        if entry in [\".\", \"..\"]:\n                            continue\n                        entry_path = f\"{smb_path}\\\\{entry}\"\n                        logger.debug(f\"【SMB】递归删除子项: {entry_path}\")\n                        # 递归删除子项\n                        self._recursive_delete(entry_path)\n\n                    # 删除空目录\n                    logger.debug(f\"【SMB】删除空目录: {smb_path}\")\n                    smbclient.rmdir(smb_path)\n                    logger.debug(f\"【SMB】目录删除成功: {smb_path}\")\n\n                except SMBResponseException as e:\n                    # 如果目录不为空，尝试强制删除\n                    logger.warn(f\"【SMB】目录不为空，尝试强制删除: {smb_path} - {e}\")\n                    # 使用remove方法尝试删除（某些SMB服务器支持）\n                    try:\n                        smbclient.remove(smb_path)\n                        logger.info(f\"【SMB】强制删除目录成功: {smb_path}\")\n                    except Exception as remove_error:\n                        # 如果还是失败，记录错误并抛出异常\n                        logger.error(\n                            f\"【SMB】无法删除非空目录: {smb_path} - {remove_error}\"\n                        )\n                        raise SMBConnectionError(\n                            f\"无法删除非空目录 {smb_path}: {remove_error}\"\n                        )\n                except SMBException as e:\n                    logger.error(f\"【SMB】SMB操作失败: {smb_path} - {e}\")\n                    raise SMBConnectionError(f\"SMB操作失败 {smb_path}: {e}\")\n\n        except SMBConnectionError:\n            # 重新抛出SMB连接错误\n            raise\n        except Exception as e:\n            logger.error(f\"【SMB】递归删除失败: {smb_path} - {e}\")\n            raise SMBConnectionError(f\"递归删除失败 {smb_path}: {e}\")\n\n    def rename(self, fileitem: schemas.FileItem, name: str) -> bool:\n        \"\"\"\n        重命名文件\n        \"\"\"\n        try:\n            self._check_connection()\n\n            old_path = self._normalize_path(fileitem.path.rstrip(\"/\"))\n            parent_path = Path(fileitem.path).parent\n            new_path = self._normalize_path(str(parent_path / name))\n\n            # 重命名\n            smbclient.rename(old_path, new_path)\n\n            logger.info(f\"【SMB】重命名成功: {fileitem.path} -> {name}\")\n            return True\n        except Exception as e:\n            logger.error(f\"【SMB】重命名失败: {e}\")\n            return False\n\n    def download(self, fileitem: schemas.FileItem, path: Path = None) -> Optional[Path]:\n        \"\"\"\n        带实时进度显示的下载\n        \"\"\"\n        local_path = (path or settings.TEMP_PATH) / fileitem.name\n        smb_path = self._normalize_path(fileitem.path)\n        try:\n            self._check_connection()\n\n            # 确保本地目录存在\n            local_path.parent.mkdir(parents=True, exist_ok=True)\n\n            # 获取文件大小\n            file_size = fileitem.size\n\n            # 初始化进度条\n            logger.info(f\"【SMB】开始下载: {fileitem.name} -> {local_path}\")\n            progress_callback = transfer_process(Path(fileitem.path).as_posix())\n\n            # 使用更高效的文件传输方式\n            with smbclient.open_file(smb_path, mode=\"rb\") as src_file:\n                with open(local_path, \"wb\") as dst_file:\n                    downloaded_size = 0\n                    while True:\n                        if global_vars.is_transfer_stopped(fileitem.path):\n                            logger.info(f\"【SMB】{fileitem.path} 下载已取消！\")\n                            return None\n                        chunk = src_file.read(self.chunk_size)\n                        if not chunk:\n                            break\n                        dst_file.write(chunk)\n                        downloaded_size += len(chunk)\n                        # 更新进度\n                        if file_size:\n                            progress = (downloaded_size * 100) / file_size\n                            progress_callback(progress)\n\n            # 完成下载\n            progress_callback(100)\n            logger.info(f\"【SMB】下载完成: {fileitem.name}\")\n            return local_path\n\n        except Exception as e:\n            logger.error(f\"【SMB】下载失败: {fileitem.name} - {e}\")\n            # 删除可能部分下载的文件\n            if local_path.exists():\n                local_path.unlink()\n            return None\n\n    def upload(\n        self, fileitem: schemas.FileItem, path: Path, new_name: Optional[str] = None\n    ) -> Optional[schemas.FileItem]:\n        \"\"\"\n        带实时进度显示的上传\n        \"\"\"\n        target_name = new_name or path.name\n        target_path = Path(fileitem.path) / target_name\n        smb_path = self._normalize_path(str(target_path))\n\n        try:\n            self._check_connection()\n\n            # 获取文件大小\n            file_size = path.stat().st_size\n\n            # 初始化进度条\n            logger.info(f\"【SMB】开始上传: {path} -> {target_path}\")\n            progress_callback = transfer_process(path.as_posix())\n\n            # 使用更高效的文件传输方式\n            with open(path, \"rb\") as src_file:\n                with smbclient.open_file(smb_path, mode=\"wb\") as dst_file:\n                    uploaded_size = 0\n                    while True:\n                        if global_vars.is_transfer_stopped(path.as_posix()):\n                            logger.info(f\"【SMB】{path} 上传已取消！\")\n                            return None\n                        chunk = src_file.read(self.chunk_size)\n                        if not chunk:\n                            break\n                        dst_file.write(chunk)\n                        uploaded_size += len(chunk)\n                        # 更新进度\n                        if file_size:\n                            progress = (uploaded_size * 100) / file_size\n                            progress_callback(progress)\n\n            # 完成上传\n            progress_callback(100)\n            logger.info(f\"【SMB】上传完成: {target_name}\")\n\n            # 返回上传后的文件信息\n            return self.get_item(target_path)\n\n        except Exception as e:\n            logger.error(f\"【SMB】上传失败: {target_name} - {e}\")\n            return None\n\n    def copy(self, fileitem: schemas.FileItem, path: Path, new_name: str) -> bool:\n        \"\"\"\n        复制文件\n        \"\"\"\n        try:\n            # 下载到临时文件\n            temp_file = self.download(fileitem)\n            if not temp_file:\n                return False\n\n            # 获取目标目录\n            target_folder = self.get_item(path)\n            if not target_folder:\n                return False\n\n            # 上传到目标位置\n            result = self.upload(target_folder, temp_file, new_name)\n\n            # 删除临时文件\n            if temp_file.exists():\n                temp_file.unlink()\n\n            return result is not None\n        except Exception as e:\n            logger.error(f\"【SMB】复制失败: {e}\")\n            return False\n\n    def move(self, fileitem: schemas.FileItem, path: Path, new_name: str) -> bool:\n        \"\"\"\n        移动文件\n        \"\"\"\n        try:\n            # 先复制\n            if not self.copy(fileitem, path, new_name):\n                return False\n\n            # 再删除原文件\n            if not self.delete(fileitem):\n                logger.warn(f\"【SMB】删除原文件失败: {fileitem.path}\")\n                return False\n\n            return True\n        except Exception as e:\n            logger.error(f\"【SMB】移动失败: {e}\")\n            return False\n\n    def link(self, fileitem: schemas.FileItem, target_file: Path) -> bool:\n        \"\"\"\n        硬链接文件\n        Samba服务器需要开启 unix extensions 支持\n        \"\"\"\n        try:\n            self._check_connection()\n            src_path = self._normalize_path(fileitem.path)\n            dst_path = self._normalize_path(target_file)\n\n            # 检查源文件是否存在\n            if not smbclient.path.exists(src_path):\n                raise FileNotFoundError(f\"源文件不存在: {src_path}\")\n\n            # 确保目标路径的父目录存在\n            dst_parent = \"\\\\\".join(dst_path.rsplit(\"\\\\\", 1)[:-1])\n            if dst_parent and not smbclient.path.exists(dst_parent):\n                logger.info(f\"【SMB】创建目标目录: {dst_parent}\")\n                smbclient.makedirs(dst_parent, exist_ok=True)\n\n            # 尝试创建硬链接\n            smbclient.link(src_path, dst_path)\n            logger.info(f\"【SMB】硬链接创建成功: {src_path} -> {dst_path}\")\n            return True\n\n        except SMBResponseException as e:\n            # SMB协议错误，可能不支持硬链接\n            logger.error(f\"【SMB】创建硬链接失败(当前Samba服务器可能不支持硬链接): {e}\")\n            return False\n        except Exception as e:\n            logger.error(f\"【SMB】创建硬链接失败: {e}\")\n            return False\n\n    def softlink(self, fileitem: schemas.FileItem, target_file: Path) -> bool:\n        pass\n\n    def usage(self) -> Optional[schemas.StorageUsage]:\n        \"\"\"\n        存储使用情况\n        \"\"\"\n        try:\n            self._check_connection()\n            volume_stat = smbclient.stat_volume(self._server_path)\n            return schemas.StorageUsage(\n                total=volume_stat.total_size,\n                available=volume_stat.caller_available_size,\n            )\n\n        except Exception as e:\n            logger.error(f\"【SMB】获取存储使用情况失败: {e}\")\n            return None\n\n    def __del__(self):\n        \"\"\"\n        析构函数，清理连接\n        \"\"\"\n        try:\n            if self._connected:\n                reset_connection_cache()\n        except Exception as e:\n            logger.debug(f\"【SMB】清理连接失败: {e}\")\n"
  },
  {
    "path": "app/modules/filemanager/storages/u115.py",
    "content": "import base64\nimport secrets\nimport time\nfrom pathlib import Path\nfrom threading import Lock\nfrom typing import List, Optional, Tuple, Union\nfrom hashlib import sha256\n\nimport oss2\nimport httpx\nfrom oss2 import SizedFileAdapter, determine_part_size\nfrom oss2.models import PartInfo\nfrom cryptography.hazmat.primitives import hashes\n\nfrom app import schemas\nfrom app.core.config import settings, global_vars\nfrom app.log import logger\nfrom app.modules.filemanager import StorageBase\nfrom app.modules.filemanager.storages import transfer_process\nfrom app.schemas.types import StorageSchema\nfrom app.utils.singleton import WeakSingleton\nfrom app.utils.string import StringUtils\nfrom app.utils.limit import QpsRateLimiter, RateStats\n\n\nlock = Lock()\n\n\nclass NoCheckInException(Exception):\n    pass\n\n\nclass U115Pan(StorageBase, metaclass=WeakSingleton):\n    \"\"\"\n    115相关操作\n    \"\"\"\n\n    # 存储类型\n    schema = StorageSchema.U115\n\n    # 支持的整理方式\n    transtype = {\"move\": \"移动\", \"copy\": \"复制\"}\n    # 基础url\n    base_url = \"https://proapi.115.com\"\n\n    # 文件块大小，默认10MB\n    chunk_size = 10 * 1024 * 1024\n\n    # 下载接口单独限流\n    download_endpoint = \"/open/ufile/downurl\"\n    # 风控触发后休眠时间（秒）\n    limit_sleep_seconds = 3600\n\n    def __init__(self):\n        super().__init__()\n        self._auth_state = {}\n        self.session = httpx.Client(follow_redirects=True, timeout=20.0)\n        self._init_session()\n        # 接口限流\n        self._download_limiter = QpsRateLimiter(1)\n        self._api_limiter = QpsRateLimiter(3)\n        self._limit_until = 0.0\n        self._limit_lock = Lock()\n        # 总体 QPS/QPM/QPH 统计\n        self._rate_stats = RateStats(source=\"115\")\n\n    def _init_session(self):\n        \"\"\"\n        初始化带速率限制的会话\n        \"\"\"\n        self.session.headers.update(\n            {\n                \"User-Agent\": \"W115Storage/2.0\",\n                \"Accept-Encoding\": \"gzip, deflate\",\n                \"Content-Type\": \"application/x-www-form-urlencoded\",\n            }\n        )\n\n    def _check_session(self):\n        \"\"\"\n        检查会话是否过期\n        \"\"\"\n        if not self.access_token:\n            raise NoCheckInException(\"【115】请先扫码登录！\")\n\n    @property\n    def access_token(self) -> Optional[str]:\n        \"\"\"\n        访问token\n        \"\"\"\n        with lock:\n            tokens = self.get_conf()\n            refresh_token = tokens.get(\"refresh_token\")\n            if not refresh_token:\n                return None\n            expires_in = tokens.get(\"expires_in\", 0)\n            refresh_time = tokens.get(\"refresh_time\", 0)\n            if expires_in and refresh_time + expires_in < int(time.time()):\n                tokens = self.__refresh_access_token(refresh_token)\n                if tokens:\n                    self.set_config({\"refresh_time\": int(time.time()), **tokens})\n                else:\n                    return None\n            access_token = tokens.get(\"access_token\")\n            if access_token:\n                self.session.headers.update({\"Authorization\": f\"Bearer {access_token}\"})\n            return access_token\n\n    def generate_auth_url(self) -> Tuple[dict, str]:\n        \"\"\"\n        生成 OAuth2 授权 URL\n        \"\"\"\n        try:\n            resp = self.session.get(f\"{settings.U115_AUTH_SERVER}/u115/auth_url\")\n            if resp is None:\n                return {}, \"无法连接到授权服务器\"\n\n            result = resp.json()\n            if not result.get(\"success\"):\n                return {}, result.get(\"message\", \"获取授权URL失败\")\n\n            data = result.get(\"data\", {})\n            auth_url = data.get(\"auth_url\")\n            state = data.get(\"state\")\n\n            if not auth_url or not state:\n                return {}, \"授权服务器返回数据不完整\"\n\n            self._auth_state = {\"state\": state}\n\n            return {\"authUrl\": auth_url, \"state\": state}, \"\"\n        except Exception as e:\n            logger.error(f\"【115】获取授权 URL 失败: {str(e)}\")\n            return {}, f\"获取授权 URL 失败: {str(e)}\"\n\n    def generate_qrcode(self) -> Tuple[dict, str]:\n        \"\"\"\n        实现PKCE规范的设备授权二维码生成\n        \"\"\"\n        # 生成PKCE参数\n        code_verifier = secrets.token_urlsafe(96)[:128]\n        code_challenge = base64.b64encode(\n            sha256(code_verifier.encode(\"utf-8\")).digest()\n        ).decode(\"utf-8\")\n        # 请求设备码\n        resp = self.session.post(\n            \"https://passportapi.115.com/open/authDeviceCode\",\n            data={\n                \"client_id\": settings.U115_APP_ID,\n                \"code_challenge\": code_challenge,\n                \"code_challenge_method\": \"sha256\",\n            },\n        )\n        if resp is None:\n            return {}, \"网络错误\"\n        result = resp.json()\n        if result.get(\"code\") != 0:\n            return {}, result.get(\"message\")\n        # 持久化验证参数\n        self._auth_state = {\n            \"code_verifier\": code_verifier,\n            \"uid\": result[\"data\"][\"uid\"],\n            \"time\": result[\"data\"][\"time\"],\n            \"sign\": result[\"data\"][\"sign\"],\n        }\n\n        # 生成二维码内容\n        return {\"codeContent\": result[\"data\"][\"qrcode\"]}, \"\"\n\n    def check_login(self) -> Optional[Tuple[dict, str]]:\n        \"\"\"\n        检查授权状态\n        \"\"\"\n        if self._auth_state and self._auth_state.get(\"state\"):\n            return self.__check_oauth_login()\n\n        if not self._auth_state:\n            return {}, \"生成二维码失败\"\n        try:\n            resp = self.session.get(\n                \"https://qrcodeapi.115.com/get/status/\",\n                params={\n                    \"uid\": self._auth_state[\"uid\"],\n                    \"time\": self._auth_state[\"time\"],\n                    \"sign\": self._auth_state[\"sign\"],\n                },\n            )\n            if resp is None:\n                return {}, \"网络错误\"\n            result = resp.json()\n            if result.get(\"code\") != 0 or not result.get(\"data\"):\n                return {}, result.get(\"message\")\n            if result[\"data\"][\"status\"] == 2:\n                tokens = self.__get_access_token()\n                self.set_config({\"refresh_time\": int(time.time()), **tokens})\n            return {\n                \"status\": result[\"data\"][\"status\"],\n                \"tip\": result[\"data\"][\"msg\"],\n            }, \"\"\n        except Exception as e:\n            return {}, str(e)\n\n    def __check_oauth_login(self) -> Tuple[dict, str]:\n        \"\"\"\n        检查 OAuth2 授权状态\n        \"\"\"\n        state = self._auth_state.get(\"state\")\n        if not state:\n            return {}, \"state为空\"\n\n        try:\n            resp = self.session.get(\n                f\"{settings.U115_AUTH_SERVER}/u115/token\", params={\"state\": state}\n            )\n            if resp is None:\n                return {}, \"无法连接到授权服务器\"\n\n            result = resp.json()\n            status = result.get(\"status\", \"pending\")\n\n            if status == \"completed\":\n                data = result.get(\"data\", {})\n                if data:\n                    self.set_config(\n                        {\n                            \"refresh_time\": int(time.time()),\n                            \"access_token\": data.get(\"access_token\"),\n                            \"refresh_token\": data.get(\"refresh_token\"),\n                            \"expires_in\": data.get(\"expires_in\"),\n                        }\n                    )\n                    self._auth_state = {}\n                    return {\"status\": 2, \"tip\": \"授权成功\"}, \"\"\n                return {}, \"授权服务器返回数据不完整\"\n            elif status == \"expired\":\n                self._auth_state = {}\n                return {\"status\": -1, \"tip\": result.get(\"message\", \"授权已过期\")}, \"\"\n            else:\n                return {\"status\": 0, \"tip\": \"等待用户授权\"}, \"\"\n        except Exception as e:\n            logger.error(f\"【115】检查授权状态失败: {str(e)}\")\n            return {}, f\"检查授权状态失败: {str(e)}\"\n\n    def __get_access_token(self) -> dict:\n        \"\"\"\n        确认登录后，获取相关token\n        \"\"\"\n        if not self._auth_state:\n            raise Exception(\"【115】请先生成二维码\")\n        resp = self.session.post(\n            \"https://passportapi.115.com/open/deviceCodeToToken\",\n            data={\n                \"uid\": self._auth_state[\"uid\"],\n                \"code_verifier\": self._auth_state[\"code_verifier\"],\n            },\n        )\n        if resp is None:\n            raise Exception(\"获取 access_token 失败\")\n        result = resp.json()\n        if result.get(\"code\") != 0:\n            raise Exception(result.get(\"message\"))\n        return result[\"data\"]\n\n    def __refresh_access_token(self, refresh_token: str) -> Optional[dict]:\n        \"\"\"\n        刷新access_token\n        \"\"\"\n        resp = self.session.post(\n            \"https://passportapi.115.com/open/refreshToken\",\n            data={\"refresh_token\": refresh_token},\n        )\n        if resp is None:\n            logger.error(\n                f\"【115】刷新 access_token 失败：refresh_token={refresh_token}\"\n            )\n            return None\n        result = resp.json()\n        if result.get(\"code\") != 0:\n            logger.warn(\n                f\"【115】刷新 access_token 失败：{result.get('code')} - {result.get('message')}！\"\n            )\n            return None\n        return result.get(\"data\")\n\n    def _request_api(\n        self, method: str, endpoint: str, result_key: Optional[str] = None, **kwargs\n    ) -> Optional[Union[dict, list]]:\n        \"\"\"\n        带错误处理和速率限制的API请求\n        \"\"\"\n        # 检查会话\n        self._check_session()\n\n        # 错误日志标志\n        no_error_log = kwargs.pop(\"no_error_log\", False)\n        # 重试次数\n        retry_times = kwargs.pop(\"retry_limit\", 3)\n\n        # 按接口类型限流\n        if endpoint == self.download_endpoint:\n            self._download_limiter.acquire()\n        else:\n            self._api_limiter.acquire()\n        self._rate_stats.record()\n\n        # 风控冷却期间阻止所有接口调用，统一等待\n        with self._limit_lock:\n            wait_until = self._limit_until\n        if wait_until > time.time():\n            wait_secs = wait_until - time.time()\n            logger.info(\n                f\"【115】风控冷却中，本请求等待 {wait_secs:.0f} 秒后再调用接口...\"\n            )\n            time.sleep(wait_secs)\n\n        try:\n            resp = self.session.request(method, f\"{self.base_url}{endpoint}\", **kwargs)\n        except httpx.RequestError as e:\n            logger.error(f\"【115】{method} 请求 {endpoint} 网络错误: {str(e)}\")\n            return None\n\n        if resp is None:\n            logger.warn(f\"【115】{method} 请求 {endpoint} 失败！\")\n            return None\n\n        kwargs[\"retry_limit\"] = retry_times\n\n        if resp.status_code == 429:\n            self._rate_stats.log_stats(\"warning\")\n            if retry_times <= 0:\n                logger.error(\n                    f\"【115】{method} 请求 {endpoint} 触发限流(429)，重试次数用尽！\"\n                )\n                return None\n            with self._limit_lock:\n                self._limit_until = max(\n                    self._limit_until,\n                    time.time() + self.limit_sleep_seconds,\n                )\n            logger.warning(\n                f\"【115】触发限流(429)，全体接口进入风控冷却 {self.limit_sleep_seconds} 秒，随后重试...\"\n            )\n            time.sleep(self.limit_sleep_seconds)\n            kwargs[\"retry_limit\"] = retry_times - 1\n            kwargs[\"no_error_log\"] = no_error_log\n            return self._request_api(method, endpoint, result_key, **kwargs)\n\n        # 处理请求错误\n        try:\n            resp.raise_for_status()\n        except httpx.HTTPStatusError as e:\n            if retry_times <= 0:\n                logger.error(\n                    f\"【115】{method} 请求 {endpoint} 错误 {e}，重试次数用尽！\"\n                )\n                return None\n            kwargs[\"retry_limit\"] = retry_times - 1\n            kwargs[\"no_error_log\"] = no_error_log\n            sleep_duration = 2 ** (5 - retry_times + 1)\n            logger.info(\n                f\"【115】{method} 请求 {endpoint} 错误 {e}，等待 {sleep_duration} 秒后重试...\"\n            )\n            time.sleep(sleep_duration)\n            return self._request_api(method, endpoint, result_key, **kwargs)\n\n        # 返回数据\n        ret_data = resp.json()\n        if ret_data.get(\"code\") not in (0, 20004):\n            error_msg = ret_data.get(\"message\", \"\")\n            if not no_error_log:\n                logger.warn(f\"【115】{method} 请求 {endpoint} 出错：{error_msg}\")\n            if \"已达到当前访问上限\" in error_msg:\n                self._rate_stats.log_stats(\"warning\")\n                if retry_times <= 0:\n                    logger.error(\n                        f\"【115】{method} 请求 {endpoint} 触发风控(访问上限)，重试次数用尽！\"\n                    )\n                    return None\n                with self._limit_lock:\n                    self._limit_until = max(\n                        self._limit_until,\n                        time.time() + self.limit_sleep_seconds,\n                    )\n                logger.warning(\n                    f\"【115】触发风控(访问上限)，全体接口进入风控冷却 {self.limit_sleep_seconds} 秒，随后重试...\"\n                )\n                time.sleep(self.limit_sleep_seconds)\n                kwargs[\"retry_limit\"] = retry_times - 1\n                kwargs[\"no_error_log\"] = no_error_log\n                return self._request_api(method, endpoint, result_key, **kwargs)\n            return None\n\n        if result_key:\n            return ret_data.get(result_key)\n        return ret_data\n\n    @staticmethod\n    def _calc_sha1(filepath: Path, size: Optional[int] = None) -> str:\n        \"\"\"\n        计算文件SHA1（符合115规范）\n        size: 前多少字节\n        \"\"\"\n        sha1 = hashes.Hash(hashes.SHA1())\n        with open(filepath, \"rb\") as f:\n            if size:\n                chunk = f.read(size)\n                sha1.update(chunk)\n            else:\n                while chunk := f.read(8192):\n                    sha1.update(chunk)\n        return sha1.finalize().hex()\n\n    def init_storage(self):\n        pass\n\n    def list(self, fileitem: schemas.FileItem) -> List[schemas.FileItem]:\n        \"\"\"\n        目录遍历实现\n        \"\"\"\n\n        if fileitem.type == \"file\":\n            item = self.detail(fileitem)\n            if item:\n                return [item]\n            return []\n        if fileitem.path == \"/\":\n            cid = \"0\"\n        else:\n            cid = fileitem.fileid\n            if not cid:\n                _fileitem = self.get_item(Path(fileitem.path))\n                if not _fileitem:\n                    logger.warn(f\"【115】获取目录 {fileitem.path} 失败！\")\n                    return []\n                cid = _fileitem.fileid\n\n        items = []\n        offset = 0\n\n        while True:\n            resp = self._request_api(\n                \"GET\",\n                \"/open/ufile/files\",\n                \"data\",\n                params={\n                    \"cid\": int(cid),\n                    \"limit\": 1000,\n                    \"offset\": offset,\n                    \"cur\": True,\n                    \"show_dir\": 1,\n                },\n            )\n            if resp is None:\n                raise FileNotFoundError(f\"【115】{fileitem.path} 检索出错！\")\n            if not resp:\n                break\n            for item in resp:\n                parent_path = Path(fileitem.path)  # noqa\n                item_name = item[\"fn\"]\n                full_path = parent_path / item_name\n                items.append(\n                    schemas.FileItem(\n                        storage=self.schema.value,\n                        fileid=str(item[\"fid\"]),\n                        parent_fileid=cid,\n                        name=item[\"fn\"],\n                        basename=Path(item[\"fn\"]).stem,\n                        extension=item[\"ico\"] if item[\"fc\"] == \"1\" else None,\n                        type=\"dir\" if item[\"fc\"] == \"0\" else \"file\",\n                        path=full_path.as_posix() + (\"/\" if item[\"fc\"] == \"0\" else \"\"),\n                        size=item[\"fs\"] if item[\"fc\"] == \"1\" else None,\n                        modify_time=item[\"upt\"],\n                        pickcode=item[\"pc\"],\n                    )\n                )\n\n            if len(resp) < 1000:\n                break\n            offset += len(resp)\n\n        return items\n\n    def create_folder(\n        self, parent_item: schemas.FileItem, name: str\n    ) -> Optional[schemas.FileItem]:\n        \"\"\"\n        创建目录\n        \"\"\"\n        new_path = Path(parent_item.path) / name\n        resp = self._request_api(\n            \"POST\",\n            \"/open/folder/add\",\n            data={\n                \"pid\": 0 if parent_item.path == \"/\" else int(parent_item.fileid or 0),\n                \"file_name\": name,\n            },\n        )\n        if not resp:\n            return None\n        if not resp.get(\"state\"):\n            if resp.get(\"code\") == 20004:\n                # 目录已存在\n                return self.get_item(new_path)\n            logger.warn(f\"【115】创建目录失败: {resp.get('error')}\")\n            return None\n        return schemas.FileItem(\n            storage=self.schema.value,\n            fileid=str(resp[\"data\"][\"file_id\"]),\n            path=new_path.as_posix() + \"/\",\n            name=name,\n            basename=name,\n            type=\"dir\",\n            modify_time=int(time.time()),\n        )\n\n    def upload(\n        self,\n        target_dir: schemas.FileItem,\n        local_path: Path,\n        new_name: Optional[str] = None,\n    ) -> Optional[schemas.FileItem]:\n        \"\"\"\n        实现带秒传、断点续传和二次认证的文件上传\n        \"\"\"\n\n        def encode_callback(cb: str) -> str:\n            return oss2.utils.b64encode_as_string(cb)\n\n        target_name = new_name or local_path.name\n        target_path = Path(target_dir.path) / target_name\n        # 计算文件特征值\n        file_size = local_path.stat().st_size\n        file_sha1 = self._calc_sha1(local_path)\n        file_preid = self._calc_sha1(local_path, 128 * 1024 * 1024)\n\n        # 获取目标目录CID\n        target_cid = target_dir.fileid\n        target_param = f\"U_1_{target_cid}\"\n\n        # Step 1: 初始化上传\n        init_data = {\n            \"file_name\": target_name,\n            \"file_size\": file_size,\n            \"target\": target_param,\n            \"fileid\": file_sha1,\n            \"preid\": file_preid,\n        }\n        init_resp = self._request_api(\"POST\", \"/open/upload/init\", data=init_data)\n        if not init_resp:\n            return None\n        if not init_resp.get(\"state\"):\n            logger.warn(f\"【115】初始化上传失败: {init_resp.get('error')}\")\n            return None\n        # 结果\n        init_result = init_resp.get(\"data\")\n        logger.debug(f\"【115】上传 Step 1 初始化结果: {init_result}\")\n        # 回调信息\n        bucket_name = init_result.get(\"bucket\")\n        object_name = init_result.get(\"object\")\n        callback = init_result.get(\"callback\")\n        # 二次认证信息\n        sign_check = init_result.get(\"sign_check\")\n        pick_code = init_result.get(\"pick_code\")\n        sign_key = init_result.get(\"sign_key\")\n\n        # Step 2: 处理二次认证\n        if init_result.get(\"code\") in [700, 701] and sign_check:\n            sign_checks = sign_check.split(\"-\")\n            start = int(sign_checks[0])\n            end = int(sign_checks[1])\n            # 计算指定区间的SHA1\n            # sign_check （用下划线隔开,截取上传文内容的sha1）(单位是byte): \"2392148-2392298\"\n            with open(local_path, \"rb\") as f:\n                # 取2392148-2392298之间的内容(包含2392148、2392298)的sha1\n                f.seek(start)\n                chunk = f.read(end - start + 1)\n                sha1 = hashes.Hash(hashes.SHA1())\n                sha1.update(chunk)\n                sign_val = sha1.finalize().hex().upper()\n            # 重新初始化请求\n            # sign_key，sign_val(根据sign_check计算的值大写的sha1值)\n            init_data.update(\n                {\"pick_code\": pick_code, \"sign_key\": sign_key, \"sign_val\": sign_val}\n            )\n            init_resp = self._request_api(\"POST\", \"/open/upload/init\", data=init_data)\n            if not init_resp:\n                return None\n            if not init_resp.get(\"state\"):\n                logger.warn(f\"【115】上传二次认证失败: {init_resp.get('error')}\")\n                return None\n            # 二次认证结果\n            init_result = init_resp.get(\"data\")\n            logger.debug(f\"【115】上传 Step 2 二次认证结果: {init_result}\")\n            if not pick_code:\n                pick_code = init_result.get(\"pick_code\")\n            if not bucket_name:\n                bucket_name = init_result.get(\"bucket\")\n            if not object_name:\n                object_name = init_result.get(\"object\")\n            if not callback:\n                callback = init_result.get(\"callback\")\n\n        # Step 3: 秒传\n        if init_result.get(\"status\") == 2:\n            logger.info(f\"【115】{target_name} 秒传成功\")\n            file_id = init_result.get(\"file_id\", None)\n            if file_id:\n                logger.debug(f\"【115】{target_name} 使用秒传返回ID获取文件信息\")\n                time.sleep(2)\n                info_resp = self._request_api(\n                    \"GET\",\n                    \"/open/folder/get_info\",\n                    \"data\",\n                    params={\"file_id\": int(file_id)},\n                )\n                if info_resp:\n                    return schemas.FileItem(\n                        storage=self.schema.value,\n                        fileid=str(info_resp[\"file_id\"]),\n                        path=target_path.as_posix()\n                        + (\"/\" if info_resp[\"file_category\"] == \"0\" else \"\"),\n                        type=\"file\" if info_resp[\"file_category\"] == \"1\" else \"dir\",\n                        name=info_resp[\"file_name\"],\n                        basename=Path(info_resp[\"file_name\"]).stem,\n                        extension=Path(info_resp[\"file_name\"]).suffix[1:]\n                        if info_resp[\"file_category\"] == \"1\"\n                        else None,\n                        pickcode=info_resp[\"pick_code\"],\n                        size=StringUtils.num_filesize(info_resp[\"size\"])\n                        if info_resp[\"file_category\"] == \"1\"\n                        else None,\n                        modify_time=info_resp[\"utime\"],\n                    )\n            return self.get_item(target_path)\n\n        # Step 4: 获取上传凭证\n        token_resp = self._request_api(\"GET\", \"/open/upload/get_token\", \"data\")\n        if not token_resp:\n            logger.warn(\"【115】获取上传凭证失败\")\n            return None\n        logger.debug(f\"【115】上传 Step 4 获取上传凭证结果: {token_resp}\")\n        # 上传凭证\n        endpoint = token_resp.get(\"endpoint\")\n        AccessKeyId = token_resp.get(\"AccessKeyId\")\n        AccessKeySecret = token_resp.get(\"AccessKeySecret\")\n        SecurityToken = token_resp.get(\"SecurityToken\")\n\n        # Step 5: 断点续传\n        resume_resp = self._request_api(\n            \"POST\",\n            \"/open/upload/resume\",\n            \"data\",\n            data={\n                \"file_size\": file_size,\n                \"target\": target_param,\n                \"fileid\": file_sha1,\n                \"pick_code\": pick_code,\n            },\n        )\n        if resume_resp:\n            logger.debug(f\"【115】上传 Step 5 断点续传结果: {resume_resp}\")\n            if resume_resp.get(\"callback\"):\n                callback = resume_resp[\"callback\"]\n\n        # Step 6: 对象存储上传\n        auth = oss2.StsAuth(\n            access_key_id=AccessKeyId,\n            access_key_secret=AccessKeySecret,\n            security_token=SecurityToken,\n        )\n        bucket = oss2.Bucket(auth, endpoint, bucket_name)  # noqa\n        # determine_part_size方法用于确定分片大小，设置分片大小为 10M\n        part_size = determine_part_size(file_size, preferred_size=10 * 1024 * 1024)\n\n        # 初始化进度条\n        logger.info(\n            f\"【115】开始上传: {local_path} -> {target_path}，分片大小：{StringUtils.str_filesize(part_size)}\"\n        )\n        progress_callback = transfer_process(local_path.as_posix())\n\n        # 初始化分片\n        upload_id = bucket.init_multipart_upload(\n            object_name, params={\"encoding-type\": \"url\", \"sequential\": \"\"}\n        ).upload_id\n        parts = []\n        # 逐个上传分片\n        with open(local_path, \"rb\") as fileobj:\n            part_number = 1\n            offset = 0\n            while offset < file_size:\n                if global_vars.is_transfer_stopped(local_path.as_posix()):\n                    logger.info(f\"【115】{local_path} 上传已取消！\")\n                    return None\n                num_to_upload = min(part_size, file_size - offset)\n                # 调用SizedFileAdapter(fileobj, size)方法会生成一个新的文件对象，重新计算起始追加位置。\n                logger.info(\n                    f\"【115】开始上传 {target_name} 分片 {part_number}: {offset} -> {offset + num_to_upload}\"\n                )\n                result = bucket.upload_part(\n                    object_name,\n                    upload_id,\n                    part_number,\n                    data=SizedFileAdapter(fileobj, num_to_upload),\n                )\n                parts.append(PartInfo(part_number, result.etag))\n                logger.info(f\"【115】{target_name} 分片 {part_number} 上传完成\")\n                offset += num_to_upload\n                part_number += 1\n                # 更新进度\n                progress = (offset * 100) / file_size\n                progress_callback(progress)\n\n        # 完成上传\n        progress_callback(100)\n\n        # 请求头\n        headers = {\n            \"X-oss-callback\": encode_callback(callback[\"callback\"]),\n            \"x-oss-callback-var\": encode_callback(callback[\"callback_var\"]),\n            \"x-oss-forbid-overwrite\": \"false\",\n        }\n        try:\n            result = bucket.complete_multipart_upload(\n                object_name, upload_id, parts, headers=headers\n            )\n            if result.status == 200:\n                logger.debug(\n                    f\"【115】上传 Step 6 回调结果：{result.resp.response.json()}\"\n                )\n                logger.info(f\"【115】{target_name} 上传成功\")\n            else:\n                logger.warn(f\"【115】{target_name} 上传失败，错误码: {result.status}\")\n                return None\n        except oss2.exceptions.OssError as e:\n            if e.code == \"FileAlreadyExists\":\n                logger.warn(f\"【115】{target_name} 已存在\")\n            else:\n                logger.error(\n                    f\"【115】{target_name} 上传失败: {e.status}, 错误码: {e.code}, 详情: {e.message}\"\n                )\n                return None\n        # 返回结果\n        return self.get_item(target_path)\n\n    def download(self, fileitem: schemas.FileItem, path: Path = None) -> Optional[Path]:\n        \"\"\"\n        带实时进度显示的下载\n        \"\"\"\n        detail = self.get_item(Path(fileitem.path))\n        if not detail:\n            logger.error(f\"【115】获取文件详情失败: {fileitem.name}\")\n            return None\n\n        download_info = self._request_api(\n            \"POST\", \"/open/ufile/downurl\", \"data\", data={\"pick_code\": detail.pickcode}\n        )\n        if not download_info:\n            logger.error(f\"【115】获取下载链接失败: {fileitem.name}\")\n            return None\n\n        download_url = list(download_info.values())[0].get(\"url\", {}).get(\"url\")\n        if not download_url:\n            logger.error(f\"【115】下载链接为空: {fileitem.name}\")\n            return None\n\n        local_path = (path or settings.TEMP_PATH) / fileitem.name\n\n        # 获取文件大小\n        file_size = detail.size\n\n        # 初始化进度条\n        logger.info(f\"【115】开始下载: {fileitem.name} -> {local_path}\")\n        progress_callback = transfer_process(Path(fileitem.path).as_posix())\n\n        try:\n            with self.session.stream(\"GET\", download_url) as r:\n                r.raise_for_status()\n                downloaded_size = 0\n\n                with open(local_path, \"wb\") as f:\n                    for chunk in r.iter_bytes(chunk_size=self.chunk_size):\n                        if global_vars.is_transfer_stopped(fileitem.path):\n                            logger.info(f\"【115】{fileitem.path} 下载已取消！\")\n                            r.close()\n                            return None\n                        f.write(chunk)\n                        downloaded_size += len(chunk)\n                        if file_size:\n                            progress = (downloaded_size * 100) / file_size\n                            progress_callback(progress)\n\n                # 完成下载\n                progress_callback(100)\n                logger.info(f\"【115】下载完成: {fileitem.name}\")\n        except httpx.RequestError as e:\n            logger.error(f\"【115】下载网络错误: {fileitem.name} - {str(e)}\")\n            # 删除可能部分下载的文件\n            if local_path.exists():\n                local_path.unlink()\n            return None\n        except Exception as e:\n            logger.error(f\"【115】下载失败: {fileitem.name} - {str(e)}\")\n            # 删除可能部分下载的文件\n            if local_path.exists():\n                local_path.unlink()\n            return None\n\n        return local_path\n\n    def check(self) -> bool:\n        return self.access_token is not None\n\n    def delete(self, fileitem: schemas.FileItem) -> bool:\n        \"\"\"\n        删除文件/目录\n        \"\"\"\n        try:\n            self._request_api(\n                \"POST\", \"/open/ufile/delete\", data={\"file_ids\": int(fileitem.fileid)}\n            )\n            return True\n        except httpx.HTTPError:\n            return False\n\n    def rename(self, fileitem: schemas.FileItem, name: str) -> bool:\n        \"\"\"\n        重命名文件/目录\n        \"\"\"\n        resp = self._request_api(\n            \"POST\",\n            \"/open/ufile/update\",\n            data={\"file_id\": int(fileitem.fileid), \"file_name\": name},\n        )\n        if not resp:\n            return False\n        if resp[\"state\"]:\n            return True\n        return False\n\n    def get_item(self, path: Path) -> Optional[schemas.FileItem]:\n        \"\"\"\n        获取指定路径的文件/目录项\n        \"\"\"\n        try:\n            resp = self._request_api(\n                \"POST\",\n                \"/open/folder/get_info\",\n                \"data\",\n                data={\"path\": path.as_posix()},\n                no_error_log=True,\n            )\n            if not resp:\n                return None\n            return schemas.FileItem(\n                storage=self.schema.value,\n                fileid=str(resp[\"file_id\"]),\n                path=path.as_posix() + (\"/\" if resp[\"file_category\"] == \"0\" else \"\"),\n                type=\"file\" if resp[\"file_category\"] == \"1\" else \"dir\",\n                name=resp[\"file_name\"],\n                basename=Path(resp[\"file_name\"]).stem,\n                extension=Path(resp[\"file_name\"]).suffix[1:]\n                if resp[\"file_category\"] == \"1\"\n                else None,\n                pickcode=resp[\"pick_code\"],\n                size=resp[\"size_byte\"] if resp[\"file_category\"] == \"1\" else None,\n                modify_time=resp[\"utime\"],\n            )\n        except Exception as e:\n            logger.debug(f\"【115】获取文件信息失败: {str(e)}\")\n            return None\n\n    def get_folder(self, path: Path) -> Optional[schemas.FileItem]:\n        \"\"\"\n        获取指定路径的文件夹，如不存在则创建\n        \"\"\"\n\n        def __find_dir(\n            _fileitem: schemas.FileItem, _name: str\n        ) -> Optional[schemas.FileItem]:\n            \"\"\"\n            查找下级目录中匹配名称的目录\n            \"\"\"\n            for sub_folder in self.list(_fileitem):\n                if sub_folder.type != \"dir\":\n                    continue\n                if sub_folder.name == _name:\n                    return sub_folder\n            return None\n\n        # 是否已存在\n        folder = self.get_item(path)\n        if folder:\n            return folder\n        # 逐级查找和创建目录\n        fileitem = schemas.FileItem(storage=self.schema.value, path=\"/\")\n        for part in path.parts[1:]:\n            dir_file = __find_dir(fileitem, part)\n            if dir_file:\n                fileitem = dir_file\n            else:\n                dir_file = self.create_folder(fileitem, part)\n                if not dir_file:\n                    logger.warn(f\"【115】创建目录 {fileitem.path}{part} 失败！\")\n                    return None\n                fileitem = dir_file\n        return fileitem\n\n    def detail(self, fileitem: schemas.FileItem) -> Optional[schemas.FileItem]:\n        \"\"\"\n        获取文件/目录详细信息\n        \"\"\"\n        return self.get_item(Path(fileitem.path))\n\n    def copy(self, fileitem: schemas.FileItem, path: Path, new_name: str) -> bool:\n        \"\"\"\n        复制\n        \"\"\"\n        if fileitem.fileid is None:\n            fileitem = self.get_item(Path(fileitem.path))\n            if not fileitem:\n                logger.warn(f\"【115】获取文件 {fileitem.path} 失败！\")\n                return False\n        dest_fileitem = self.get_item(path)\n        if not dest_fileitem or dest_fileitem.type != \"dir\":\n            logger.warn(f\"【115】目标路径 {path} 不是一个有效的目录！\")\n            return False\n\n        resp = self._request_api(\n            \"POST\",\n            \"/open/ufile/copy\",\n            data={\n                \"file_id\": int(fileitem.fileid),\n                \"pid\": int(dest_fileitem.fileid),\n            },\n        )\n        if not resp:\n            return False\n        if resp[\"state\"]:\n            new_path = Path(path) / fileitem.name\n            new_item = self.get_item(new_path)\n            if not new_item:\n                return False\n            if self.rename(new_item, new_name):\n                return True\n        return False\n\n    def move(self, fileitem: schemas.FileItem, path: Path, new_name: str) -> bool:\n        \"\"\"\n        移动\n        \"\"\"\n        if fileitem.fileid is None:\n            fileitem = self.get_item(Path(fileitem.path))\n            if not fileitem:\n                logger.warn(f\"【115】获取文件 {fileitem.path} 失败！\")\n                return False\n        dest_fileitem = self.get_item(path)\n        if not dest_fileitem or dest_fileitem.type != \"dir\":\n            logger.warn(f\"【115】目标路径 {path} 不是一个有效的目录！\")\n            return False\n        resp = self._request_api(\n            \"POST\",\n            \"/open/ufile/move\",\n            data={\n                \"file_ids\": int(fileitem.fileid),\n                \"to_cid\": int(dest_fileitem.fileid),\n            },\n        )\n        if not resp:\n            return False\n        if resp[\"state\"]:\n            new_path = Path(path) / fileitem.name\n            new_file = self.get_item(new_path)\n            if not new_file:\n                return False\n            if self.rename(new_file, new_name):\n                return True\n        return False\n\n    def link(self, fileitem: schemas.FileItem, target_file: Path) -> bool:\n        pass\n\n    def softlink(self, fileitem: schemas.FileItem, target_file: Path) -> bool:\n        pass\n\n    def usage(self) -> Optional[schemas.StorageUsage]:\n        \"\"\"\n        存储使用情况\n        \"\"\"\n        try:\n            resp = self._request_api(\"GET\", \"/open/user/info\", \"data\")\n            if not resp:\n                return None\n            space = resp[\"rt_space_info\"]\n            return schemas.StorageUsage(\n                total=space[\"all_total\"][\"size\"], available=space[\"all_remain\"][\"size\"]\n            )\n        except NoCheckInException:\n            return None\n"
  },
  {
    "path": "app/modules/filemanager/transhandler.py",
    "content": "import re\nfrom pathlib import Path\nfrom typing import Optional, List, Tuple\n\nfrom jinja2 import Template\n\nfrom app.core.config import settings\nfrom app.core.context import MediaInfo\nfrom app.core.event import eventmanager\nfrom app.core.meta import MetaBase\nfrom app.core.metainfo import MetaInfoPath\nfrom app.helper.directory import DirectoryHelper\nfrom app.helper.message import TemplateHelper\nfrom app.log import logger\nfrom app.modules.filemanager.storages import StorageBase\nfrom app.schemas import TransferInfo, TmdbEpisode, TransferDirectoryConf, FileItem, TransferInterceptEventData, \\\n    TransferRenameEventData\nfrom app.schemas.types import MediaType, ChainEventType\nfrom app.utils.system import SystemUtils\n\n\nclass TransHandler:\n    \"\"\"\n    文件转移整理类\n    \"\"\"\n\n    def __init__(self):\n        pass\n\n    @staticmethod\n    def __update_result(result: TransferInfo, **kwargs):\n        \"\"\"\n        更新结果\n        \"\"\"\n        # 设置值\n        for key, value in kwargs.items():\n            if hasattr(result, key):\n                current_value = getattr(result, key)\n                if current_value is None:\n                    current_value = value\n                elif isinstance(current_value, list):\n                    if isinstance(value, list):\n                        current_value.extend(value)\n                    else:\n                        current_value.append(value)\n                elif isinstance(current_value, dict):\n                    if isinstance(value, dict):\n                        current_value.update(value)\n                    else:\n                        current_value[key] = value\n                elif isinstance(current_value, bool):\n                    current_value = value\n                elif isinstance(current_value, int):\n                    current_value += (value or 0)\n                else:\n                    current_value = value\n                setattr(result, key, current_value)\n\n    def transfer_media(self,\n                       fileitem: FileItem,\n                       in_meta: MetaBase,\n                       mediainfo: MediaInfo,\n                       target_storage: str,\n                       target_path: Path,\n                       transfer_type: str,\n                       source_oper: StorageBase,\n                       target_oper: StorageBase,\n                       need_scrape: Optional[bool] = False,\n                       need_rename: Optional[bool] = True,\n                       need_notify: Optional[bool] = True,\n                       overwrite_mode: Optional[str] = None,\n                       episodes_info: List[TmdbEpisode] = None\n                       ) -> TransferInfo:\n        \"\"\"\n        识别并整理一个文件或者一个目录下的所有文件\n        :param fileitem: 整理的文件对象，可能是一个文件也可以是一个目录\n        :param in_meta：预识别元数据\n        :param mediainfo: 媒体信息\n        :param target_storage: 目标存储\n        :param target_path: 目标路径\n        :param transfer_type: 文件整理方式\n        :param source_oper: 源存储操作对象\n        :param target_oper: 目标存储操作对象\n        :param need_scrape: 是否需要刮削\n        :param need_rename: 是否需要重命名\n        :param need_notify: 是否需要通知\n        :param overwrite_mode: 覆盖模式\n        :param episodes_info: 当前季的全部集信息\n        :return: TransferInfo、错误信息\n        \"\"\"\n\n        def __is_subtitle_file(_fileitem: FileItem) -> bool:\n            \"\"\"\n            判断是否为字幕文件\n            :param _fileitem: 文件项\n            :return: True/False\n            \"\"\"\n            if not _fileitem.extension:\n                return False\n            if f\".{_fileitem.extension.lower()}\" in settings.RMT_SUBEXT:\n                return True\n            return False\n\n        def __is_extra_file(_fileitem: FileItem) -> bool:\n            \"\"\"\n            判断是否为附加文件\n            :param _fileitem: 文件项\n            :return: True/False\n            \"\"\"\n            if not _fileitem.extension:\n                return False\n            if f\".{_fileitem.extension.lower()}\" in (settings.RMT_SUBEXT + settings.RMT_AUDIOEXT):\n                return True\n            return False\n\n        # 整理结果\n        result = TransferInfo()\n\n        try:\n\n            # 重命名格式\n            rename_format = settings.RENAME_FORMAT(mediainfo.type)\n\n            # 判断是否为文件夹\n            if fileitem.type == \"dir\":\n                # 整理整个目录，一般为蓝光原盘\n                if need_rename:\n                    new_path = self.get_rename_path(\n                        path=target_path,\n                        template_string=rename_format,\n                        rename_dict=self.get_naming_dict(meta=in_meta,\n                                                         mediainfo=mediainfo)\n                    )\n                    new_path = DirectoryHelper.get_media_root_path(\n                        rename_format, rename_path=new_path\n                    )\n                    if not new_path:\n                        self.__update_result(\n                            result=result,\n                            success=False,\n                            message=\"重命名格式无效\",\n                            fileitem=fileitem,\n                            transfer_type=transfer_type,\n                            need_notify=need_notify,\n                        )\n                        return result\n                else:\n                    new_path = target_path / fileitem.name\n                # 原盘大小只计算STREAM目录内的文件大小\n                if stream_fileitem := source_oper.get_item(\n                        Path(fileitem.path) / \"BDMV\" / \"STREAM\"\n                ):\n                    fileitem.size = sum(\n                        file.size for file in source_oper.list(stream_fileitem) or []\n                    )\n                # 整理目录\n                new_diritem, errmsg = self.__transfer_dir(fileitem=fileitem,\n                                                          mediainfo=mediainfo,\n                                                          source_oper=source_oper,\n                                                          target_oper=target_oper,\n                                                          target_storage=target_storage,\n                                                          target_path=new_path,\n                                                          transfer_type=transfer_type,\n                                                          result=result)\n                if not new_diritem:\n                    logger.error(f\"文件夹 {fileitem.path} 整理失败：{errmsg}\")\n                    self.__update_result(result=result,\n                                         success=False,\n                                         message=errmsg,\n                                         fileitem=fileitem,\n                                         transfer_type=transfer_type,\n                                         need_notify=need_notify)\n                    return result\n\n                logger.info(f\"文件夹 {fileitem.path} 整理成功\")\n                # 返回整理后的路径\n                self.__update_result(result=result,\n                                     success=True,\n                                     fileitem=fileitem,\n                                     target_item=new_diritem,\n                                     target_diritem=new_diritem,\n                                     need_scrape=need_scrape,\n                                     need_notify=need_notify,\n                                     transfer_type=transfer_type)\n                return result\n            else:\n                # 整理单个文件\n                if mediainfo.type == MediaType.TV:\n                    # 电视剧\n                    if in_meta.begin_episode is None:\n                        logger.warn(f\"文件 {fileitem.path} 整理失败：未识别到文件集数\")\n                        self.__update_result(result=result,\n                                             success=False,\n                                             message=\"未识别到文件集数\",\n                                             fileitem=fileitem,\n                                             fail_list=[fileitem.path],\n                                             transfer_type=transfer_type,\n                                             need_notify=need_notify)\n                        return result\n\n                    # 文件结束季为空\n                    in_meta.end_season = None\n                    # 文件总季数为1\n                    if in_meta.total_season:\n                        in_meta.total_season = 1\n                    # 文件不可能超过2集\n                    if in_meta.total_episode > 2:\n                        in_meta.total_episode = 1\n                        in_meta.end_episode = None\n\n                # 目的文件名\n                if need_rename:\n                    new_file = self.get_rename_path(\n                        path=target_path,\n                        template_string=rename_format,\n                        rename_dict=self.get_naming_dict(\n                            meta=in_meta,\n                            mediainfo=mediainfo,\n                            episodes_info=episodes_info,\n                            file_ext=f\".{fileitem.extension}\"\n                        )\n                    )\n\n                    # 针对字幕文件，文件名中补充额外标识信息\n                    if __is_subtitle_file(fileitem):\n                        new_file = self.__rename_subtitles(fileitem, new_file)\n\n                    # 文件目录\n                    folder_path = DirectoryHelper.get_media_root_path(\n                        rename_format, rename_path=new_file\n                    )\n                    if not folder_path:\n                        self.__update_result(\n                            result=result,\n                            success=False,\n                            message=\"重命名格式无效\",\n                            fileitem=fileitem,\n                            fail_list=[fileitem.path],\n                            transfer_type=transfer_type,\n                            need_notify=need_notify,\n                        )\n                        return result\n                else:\n                    new_file = target_path / fileitem.name\n                    folder_path = target_path\n\n                # 目标目录\n                target_diritem = target_oper.get_folder(folder_path)\n                if not target_diritem:\n                    logger.error(f\"目标目录 {folder_path} 获取失败\")\n                    self.__update_result(result=result,\n                                         success=False,\n                                         message=f\"目标目录 {folder_path} 获取失败\",\n                                         fileitem=fileitem,\n                                         fail_list=[fileitem.path],\n                                         transfer_type=transfer_type,\n                                         need_notify=need_notify)\n                    return result\n\n                # 判断是否要覆盖，附加文件强制覆盖\n                overflag = False\n                if not __is_extra_file(fileitem):\n                    # 目标文件\n                    target_item = target_oper.get_item(new_file)\n                    if target_item:\n                        # 目标文件已存在\n                        target_file = new_file\n                        if target_storage == \"local\" and new_file.is_symlink():\n                            target_file = new_file.readlink()\n                            if not target_file.exists():\n                                overflag = True\n                        if not overflag:\n                            # 目标文件已存在\n                            logger.info(\n                                f\"目的文件系统中已经存在同名文件 {target_file}，当前整理覆盖模式设置为 {overwrite_mode}\")\n                            if overwrite_mode == 'always':\n                                # 总是覆盖同名文件\n                                overflag = True\n                            elif overwrite_mode == 'size':\n                                # 存在时大覆盖小\n                                if target_item.size < fileitem.size:\n                                    logger.info(f\"目标文件文件大小更小，将覆盖：{new_file}\")\n                                    overflag = True\n                                else:\n                                    self.__update_result(result=result,\n                                                         success=False,\n                                                         message=f\"媒体库存在同名文件，且质量更好\",\n                                                         fileitem=fileitem,\n                                                         target_item=target_item,\n                                                         target_diritem=target_diritem,\n                                                         fail_list=[fileitem.path],\n                                                         transfer_type=transfer_type,\n                                                         need_notify=need_notify)\n                                    return result\n                            elif overwrite_mode == 'never':\n                                # 存在不覆盖\n                                self.__update_result(result=result,\n                                                     success=False,\n                                                     message=f\"媒体库存在同名文件，当前覆盖模式为不覆盖\",\n                                                     fileitem=fileitem,\n                                                     target_item=target_item,\n                                                     target_diritem=target_diritem,\n                                                     fail_list=[fileitem.path],\n                                                     transfer_type=transfer_type,\n                                                     need_notify=need_notify)\n                                return result\n                            elif overwrite_mode == 'latest':\n                                # 仅保留最新版本\n                                logger.info(f\"当前整理覆盖模式设置为仅保留最新版本，将覆盖：{new_file}\")\n                                overflag = True\n                    else:\n                        if overwrite_mode == 'latest':\n                            # 文件不存在，但仅保留最新版本\n                            logger.info(\n                                f\"当前整理覆盖模式设置为 {overwrite_mode}，仅保留最新版本，正在删除已有版本文件 ...\")\n                            self.__delete_version_files(target_oper, new_file)\n                else:\n                    # 附加文件 总是需要覆盖\n                    overflag = True\n\n                # 整理文件\n                new_item, err_msg = self.__transfer_file(fileitem=fileitem,\n                                                         mediainfo=mediainfo,\n                                                         target_storage=target_storage,\n                                                         target_file=new_file,\n                                                         transfer_type=transfer_type,\n                                                         over_flag=overflag,\n                                                         source_oper=source_oper,\n                                                         target_oper=target_oper,\n                                                         result=result)\n                if not new_item:\n                    logger.error(f\"文件 {fileitem.path} 整理失败：{err_msg}\")\n                    self.__update_result(result=result,\n                                         success=False,\n                                         message=err_msg,\n                                         fileitem=fileitem,\n                                         fail_list=[fileitem.path],\n                                         transfer_type=transfer_type,\n                                         need_notify=need_notify)\n                    return result\n\n                logger.info(f\"文件 {fileitem.path} 整理成功\")\n                self.__update_result(result=result,\n                                     success=True,\n                                     fileitem=fileitem,\n                                     target_item=new_item,\n                                     target_diritem=target_diritem,\n                                     need_scrape=need_scrape,\n                                     transfer_type=transfer_type,\n                                     need_notify=need_notify)\n                return result\n        except Exception as e:\n            logger.error(f\"媒体整理出错：{e}\")\n            return TransferInfo(success=False, message=str(e))\n\n    @staticmethod\n    def __transfer_command(fileitem: FileItem, target_storage: str,\n                           source_oper: StorageBase, target_oper: StorageBase,\n                           target_file: Path, transfer_type: str,\n                           ) -> Tuple[Optional[FileItem], str]:\n        \"\"\"\n        处理单个文件\n        :param fileitem: 源文件\n        :param target_storage: 目标存储\n        :param source_oper: 源存储操作对象\n        :param target_oper: 目标存储操作对象\n        :param target_file: 目标文件路径\n        :param transfer_type: 整理方式\n        \"\"\"\n\n        def __get_targetitem(_path: Path) -> FileItem:\n            \"\"\"\n            获取文件信息\n            \"\"\"\n            return FileItem(\n                storage=target_storage,\n                path=_path.as_posix(),\n                name=_path.name,\n                basename=_path.stem,\n                type=\"file\",\n                size=_path.stat().st_size,\n                extension=_path.suffix.lstrip('.'),\n                modify_time=_path.stat().st_mtime\n            )\n\n        if (fileitem.storage != target_storage\n                and fileitem.storage != \"local\" and target_storage != \"local\"):\n            return None, f\"不支持 {fileitem.storage} 到 {target_storage} 的文件整理\"\n\n        if fileitem.storage == \"local\" and target_storage == \"local\":\n            # 创建目录\n            if not target_file.parent.exists():\n                target_file.parent.mkdir(parents=True, exist_ok=True)\n            # 本地到本地\n            if transfer_type == \"copy\":\n                state = source_oper.copy(fileitem, target_file.parent, target_file.name)\n            elif transfer_type == \"move\":\n                state = source_oper.move(fileitem, target_file.parent, target_file.name)\n            elif transfer_type == \"link\":\n                state = source_oper.link(fileitem, target_file)\n            elif transfer_type == \"softlink\":\n                state = source_oper.softlink(fileitem, target_file)\n            else:\n                return None, f\"不支持的整理方式：{transfer_type}\"\n            if state:\n                return __get_targetitem(target_file), \"\"\n            else:\n                return None, f\"{fileitem.path} {transfer_type} 失败\"\n        elif fileitem.storage == \"local\" and target_storage != \"local\":\n            # 本地到网盘\n            filepath = Path(fileitem.path)\n            if not filepath.exists():\n                return None, f\"文件 {filepath} 不存在\"\n            if transfer_type == \"copy\":\n                # 复制\n                # 根据目的路径创建文件夹\n                target_fileitem = target_oper.get_folder(target_file.parent)\n                if target_fileitem:\n                    # 上传文件\n                    new_item = target_oper.upload(target_fileitem, filepath, target_file.name)\n                    if new_item:\n                        return new_item, \"\"\n                    else:\n                        return None, f\"{fileitem.path} 上传 {target_storage} 失败\"\n                else:\n                    return None, f\"【{target_storage}】{target_file.parent} 目录获取失败\"\n            elif transfer_type == \"move\":\n                # 移动\n                # 根据目的路径获取文件夹\n                target_fileitem = target_oper.get_folder(target_file.parent)\n                if target_fileitem:\n                    # 上传文件\n                    new_item = target_oper.upload(target_fileitem, filepath, target_file.name)\n                    if new_item:\n                        # 删除源文件\n                        source_oper.delete(fileitem)\n                        return new_item, \"\"\n                    else:\n                        return None, f\"{fileitem.path} 上传 {target_storage} 失败\"\n                else:\n                    return None, f\"【{target_storage}】{target_file.parent} 目录获取失败\"\n        elif fileitem.storage != \"local\" and target_storage == \"local\":\n            # 网盘到本地\n            if target_file.exists():\n                logger.warn(f\"文件已存在：{target_file}\")\n                return __get_targetitem(target_file), \"\"\n            # 网盘到本地\n            if transfer_type in [\"copy\", \"move\"]:\n                # 下载\n                tmp_file = source_oper.download(fileitem=fileitem, path=target_file.parent)\n                if tmp_file:\n                    # 创建目录\n                    if not target_file.parent.exists():\n                        target_file.parent.mkdir(parents=True, exist_ok=True)\n                    # 将tmp_file移动后target_file\n                    SystemUtils.move(tmp_file, target_file)\n                    if transfer_type == \"move\":\n                        # 删除源文件\n                        source_oper.delete(fileitem)\n                    return __get_targetitem(target_file), \"\"\n                else:\n                    return None, f\"{fileitem.path} {fileitem.storage} 下载失败\"\n        elif fileitem.storage == target_storage:\n            # 同一网盘\n            if not source_oper.is_support_transtype(transfer_type):\n                return None, f\"存储 {fileitem.storage} 不支持 {transfer_type} 整理方式\"\n\n            if transfer_type == \"copy\":\n                # 复制文件到新目录\n                target_fileitem = target_oper.get_folder(target_file.parent)\n                if target_fileitem:\n                    if source_oper.copy(fileitem, Path(target_fileitem.path), target_file.name):\n                        return target_oper.get_item(target_file), \"\"\n                    else:\n                        return None, f\"【{target_storage}】{fileitem.path} 复制文件失败\"\n                else:\n                    return None, f\"【{target_storage}】{target_file.parent} 目录获取失败\"\n            elif transfer_type == \"move\":\n                # 移动文件到新目录\n                target_fileitem = target_oper.get_folder(target_file.parent)\n                if target_fileitem:\n                    if source_oper.move(fileitem, Path(target_fileitem.path), target_file.name):\n                        return target_oper.get_item(target_file), \"\"\n                    else:\n                        return None, f\"【{target_storage}】{fileitem.path} 移动文件失败\"\n                else:\n                    return None, f\"【{target_storage}】{target_file.parent} 目录获取失败\"\n            elif transfer_type == \"link\":\n                if source_oper.link(fileitem, target_file):\n                    return target_oper.get_item(target_file), \"\"\n                else:\n                    return None, f\"【{target_storage}】{fileitem.path} 创建硬链接失败\"\n            else:\n                return None, f\"不支持的整理方式：{transfer_type}\"\n\n        return None, \"未知错误\"\n\n    @staticmethod\n    def __rename_subtitles(sub_item: FileItem, new_file: Path) -> Path:\n        \"\"\"\n        重命名字幕文件，补充附加信息\n        \"\"\"\n        # 字幕正则式\n        _zhcn_sub_re = r\"([.\\[(\\s](((zh[-_])?(cn|ch[si]|sg|sc))|zho?\" \\\n                       r\"|chinese|(cn|ch[si]|sg|zho?)[-_&]?(cn|ch[si]|sg|zho?|eng|jap|ja|jpn)\" \\\n                       r\"|eng[-_&]?(cn|ch[si]|sg|zho?)|(jap|ja|jpn)[-_&]?(cn|ch[si]|sg|zho?)\" \\\n                       r\"|简[体中]?)[.\\])\\s])\" \\\n                       r\"|([\\u4e00-\\u9fa5]{0,3}[中双][\\u4e00-\\u9fa5]{0,2}[字文语][\\u4e00-\\u9fa5]{0,3})\" \\\n                       r\"|简体|简中|JPSC|sc_jp\" \\\n                       r\"|(?<![a-z0-9])gb(?![a-z0-9])\"\n        _zhtw_sub_re = r\"([.\\[(\\s](((zh[-_])?(hk|tw|cht|tc))\" \\\n                       r\"|cht[-_&]?(cht|eng|jap|ja|jpn)\" \\\n                       r\"|eng[-_&]?cht|(jap|ja|jpn)[-_&]?cht\" \\\n                       r\"|繁[体中]?)[.\\])\\s])\" \\\n                       r\"|繁体中[文字]|中[文字]繁体|繁体|JPTC|tc_jp\" \\\n                       r\"|(?<![a-z0-9])big5(?![a-z0-9])\"\n        _ja_sub_re = r\"([.\\[(\\s](ja-jp|jap|ja|jpn\" \\\n                     r\"|(jap|ja|jpn)[-_&]?eng|eng[-_&]?(jap|ja|jpn))[.\\])\\s])\" \\\n                     r\"|日本語|日語\"\n        _eng_sub_re = r\"[.\\[(\\s]eng[.\\])\\s]\"\n\n        # 原文件后缀\n        file_ext = f\".{sub_item.extension}\"\n        # 新文件后缀\n        new_file_type = \"\"\n\n        # 识别字幕语言\n        if re.search(_zhcn_sub_re, sub_item.name, re.I):\n            new_file_type = \".chi.zh-cn\"\n        elif re.search(_zhtw_sub_re, sub_item.name, re.I):\n            new_file_type = \".zh-tw\"\n        elif re.search(_ja_sub_re, sub_item.name, re.I):\n            new_file_type = \".ja\"\n        elif re.search(_eng_sub_re, sub_item.name, re.I):\n            new_file_type = \".eng\"\n\n        # 添加默认字幕标识\n        if ((settings.DEFAULT_SUB == \"zh-cn\" and new_file_type == \".chi.zh-cn\")\n                or (settings.DEFAULT_SUB == \"zh-tw\" and new_file_type == \".zh-tw\")\n                or (settings.DEFAULT_SUB == \"ja\" and new_file_type == \".ja\")\n                or (settings.DEFAULT_SUB == \"eng\" and new_file_type == \".eng\")):\n            new_sub_tag = \".default\" + new_file_type\n        else:\n            new_sub_tag = new_file_type\n\n        return new_file.with_name(new_file.stem + new_sub_tag + file_ext)\n\n    def __transfer_dir(self, fileitem: FileItem, mediainfo: MediaInfo,\n                       source_oper: StorageBase, target_oper: StorageBase,\n                       transfer_type: str, target_storage: str, target_path: Path,\n                       result: TransferInfo) -> Tuple[Optional[FileItem], str]:\n        \"\"\"\n        整理整个文件夹\n        :param fileitem: 源文件\n        :param mediainfo: 媒体信息\n        :param source_oper: 源存储操作对象\n        :param target_oper: 目标存储操作对象\n        :param transfer_type: 整理方式\n        :param target_storage: 目标存储\n        :param target_path: 目标路径\n        \"\"\"\n        logger.info(f\"正在整理目录：{fileitem.path} 到 {target_path}\")\n        target_item = target_oper.get_folder(target_path)\n        if not target_item:\n            return None, f\"获取目标目录失败：{target_path}\"\n        event_data = TransferInterceptEventData(\n            fileitem=fileitem,\n            mediainfo=mediainfo,\n            target_storage=target_storage,\n            target_path=target_path,\n            transfer_type=transfer_type\n        )\n        event = eventmanager.send_event(ChainEventType.TransferIntercept, event_data)\n        if event and event.event_data:\n            event_data = event.event_data\n            # 如果事件被取消，跳过文件整理\n            if event_data.cancel:\n                logger.debug(\n                    f\"Transfer dir canceled by event: {event_data.source},\"\n                    f\"Reason: {event_data.reason}\")\n                return None, event_data.reason\n        # 处理所有文件\n        state, errmsg = self.__transfer_dir_files(fileitem=fileitem,\n                                                  target_storage=target_storage,\n                                                  source_oper=source_oper,\n                                                  target_oper=target_oper,\n                                                  target_path=target_path,\n                                                  transfer_type=transfer_type,\n                                                  result=result)\n        if state:\n            return target_item, errmsg\n        else:\n            return None, errmsg\n\n    def __transfer_dir_files(self, fileitem: FileItem, target_storage: str,\n                             source_oper: StorageBase, target_oper: StorageBase,\n                             transfer_type: str, target_path: Path,\n                             result: TransferInfo) -> Tuple[bool, str]:\n        \"\"\"\n        按目录结构整理目录下所有文件\n        :param fileitem: 源文件\n        :param target_storage: 目标存储\n        :param source_oper: 源存储操作对象\n        :param target_oper: 目标存储操作对象\n        :param target_path: 目标路径\n        :param transfer_type: 整理方式\n        \"\"\"\n        file_list: List[FileItem] = source_oper.list(fileitem)\n        # 整理文件\n        for item in file_list:\n            if item.type == \"dir\":\n                # 递归整理目录\n                new_path = target_path / item.name\n                state, errmsg = self.__transfer_dir_files(fileitem=item,\n                                                          target_storage=target_storage,\n                                                          source_oper=source_oper,\n                                                          target_oper=target_oper,\n                                                          transfer_type=transfer_type,\n                                                          target_path=new_path,\n                                                          result=result)\n                if not state:\n                    return False, errmsg\n            else:\n                # 整理文件\n                new_file = target_path / item.name\n                new_item, errmsg = self.__transfer_command(fileitem=item,\n                                                           target_storage=target_storage,\n                                                           source_oper=source_oper,\n                                                           target_oper=target_oper,\n                                                           target_file=new_file,\n                                                           transfer_type=transfer_type)\n                if not new_item:\n                    return False, errmsg\n                self.__update_result(\n                    result=result,\n                    file_list=[item.path],\n                    file_list_new=[new_item.path],\n                )\n        # 返回成功\n        return True, \"\"\n\n    def __transfer_file(self, fileitem: FileItem, mediainfo: MediaInfo,\n                        source_oper: StorageBase, target_oper: StorageBase,\n                        target_storage: str, target_file: Path,\n                        transfer_type: str, result: TransferInfo,\n                        over_flag: Optional[bool] = False) -> Tuple[Optional[FileItem], str]:\n        \"\"\"\n        整理一个文件，同时处理其他相关文件\n        :param fileitem: 原文件\n        :param mediainfo: 媒体信息\n        :param source_oper: 源存储操作对象\n        :param target_oper: 目标存储操作对象\n        :param target_storage: 目标存储\n        :param target_file: 新文件\n        :param transfer_type: 整理方式\n        :param over_flag: 是否覆盖，为True时会先删除再整理\n        :param source_oper: 源存储操作对象\n        :param target_oper: 目标存储操作对象\n        \"\"\"\n        logger.info(f\"正在整理文件：【{fileitem.storage}】{fileitem.path} 到 【{target_storage}】{target_file}，\"\n                    f\"操作类型：{transfer_type}\")\n        event_data = TransferInterceptEventData(\n            fileitem=fileitem,\n            mediainfo=mediainfo,\n            target_storage=target_storage,\n            target_path=target_file,\n            transfer_type=transfer_type,\n            options={\n                \"over_flag\": over_flag\n            }\n        )\n        event = eventmanager.send_event(ChainEventType.TransferIntercept, event_data)\n        if event and event.event_data:\n            event_data = event.event_data\n            # 如果事件被取消，跳过文件整理\n            if event_data.cancel:\n                logger.debug(\n                    f\"Transfer file canceled by event: {event_data.source},\"\n                    f\"Reason: {event_data.reason}\")\n                return None, event_data.reason\n        if target_storage == \"local\" and (target_file.exists() or target_file.is_symlink()):\n            if not over_flag:\n                logger.warn(f\"文件已存在：{target_file}\")\n                return None, f\"{target_file} 已存在\"\n            else:\n                logger.info(f\"正在删除已存在的文件：{target_file}\")\n                target_file.unlink()\n        else:\n            exists_item = target_oper.get_item(target_file)\n            if exists_item:\n                if not over_flag:\n                    logger.warn(f\"文件已存在：【{target_storage}】{target_file}\")\n                    return None, f\"【{target_storage}】{target_file} 已存在\"\n                else:\n                    logger.info(f\"正在删除已存在的文件：【{target_storage}】{target_file}\")\n                    target_oper.delete(exists_item)\n        # 执行文件整理命令\n        new_item, errmsg = self.__transfer_command(fileitem=fileitem,\n                                                   target_storage=target_storage,\n                                                   source_oper=source_oper,\n                                                   target_oper=target_oper,\n                                                   target_file=target_file,\n                                                   transfer_type=transfer_type)\n        if new_item:\n            self.__update_result(\n                result=result,\n                file_list=[fileitem.path],\n                file_list_new=[new_item.path],\n                file_count=1,\n                total_size=fileitem.size,\n            )\n            return new_item, errmsg\n\n        return None, errmsg\n\n    @staticmethod\n    def get_dest_path(mediainfo: MediaInfo, target_path: Path,\n                      need_type_folder: Optional[bool] = False, need_category_folder: Optional[bool] = False):\n        \"\"\"\n        获取目标路径\n        \"\"\"\n        if need_type_folder and mediainfo.type:\n            target_path = target_path / mediainfo.type.value\n        if need_category_folder and mediainfo.category:\n            target_path = target_path / mediainfo.category\n        return target_path\n\n    @staticmethod\n    def get_dest_dir(mediainfo: MediaInfo, target_dir: TransferDirectoryConf,\n                     need_type_folder: Optional[bool] = None, need_category_folder: Optional[bool] = None) -> Path:\n        \"\"\"\n        根据设置并装媒体库目录\n        :param mediainfo: 媒体信息\n        :param target_dir: 媒体库根目录\n        :param need_type_folder: 是否需要按媒体类型创建目录\n        :param need_category_folder: 是否需要按媒体类别创建目录\n        \"\"\"\n        if need_type_folder is None:\n            need_type_folder = target_dir.library_type_folder\n        if need_category_folder is None:\n            need_category_folder = target_dir.library_category_folder\n        if not target_dir.media_type and need_type_folder and mediainfo.type:\n            # 一级自动分类\n            library_dir = Path(target_dir.library_path) / mediainfo.type.value\n        elif target_dir.media_type and need_type_folder:\n            # 一级手动分类\n            library_dir = Path(target_dir.library_path) / target_dir.media_type\n        else:\n            library_dir = Path(target_dir.library_path)\n        if not target_dir.media_category and need_category_folder and mediainfo.category:\n            # 二级自动分类\n            library_dir = library_dir / mediainfo.category\n        elif target_dir.media_category and need_category_folder:\n            # 二级手动分类\n            library_dir = library_dir / target_dir.media_category\n\n        return library_dir\n\n    @staticmethod\n    def get_naming_dict(meta: MetaBase, mediainfo: MediaInfo, file_ext: Optional[str] = None,\n                        episodes_info: List[TmdbEpisode] = None) -> dict:\n        \"\"\"\n        根据媒体信息，返回Format字典\n        :param meta: 文件元数据\n        :param mediainfo: 识别的媒体信息\n        :param file_ext: 文件扩展名\n        :param episodes_info: 当前季的全部集信息\n        \"\"\"\n        return TemplateHelper().builder.build(meta=meta, mediainfo=mediainfo,\n                                              file_extension=file_ext, episodes_info=episodes_info)\n\n    @staticmethod\n    def __delete_version_files(storage_oper: StorageBase, path: Path) -> bool:\n        \"\"\"\n        删除目录下的所有版本文件\n        :param storage_oper: 存储操作对象\n        :param path: 目录路径\n        \"\"\"\n        # 存储\n        if not storage_oper:\n            return False\n        # 识别文件中的季集信息\n        meta = MetaInfoPath(path)\n        season = meta.season\n        episode = meta.episode\n        logger.warn(f\"正在删除目标目录中其它版本的文件：{path.parent}\")\n        # 获取父目录\n        parent_item = storage_oper.get_item(path.parent)\n        if not parent_item:\n            logger.warn(f\"目录 {path.parent} 不存在\")\n            return False\n        # 检索媒体文件\n        media_files = storage_oper.list(parent_item)\n        if not media_files:\n            logger.info(f\"目录 {path.parent} 中没有文件\")\n            return False\n        # 删除文件\n        for media_file in media_files:\n            media_path = Path(media_file.path)\n            if media_path == path:\n                continue\n            if media_file.type != \"file\":\n                continue\n            # 当前只有视频文件需要保留最新版本，其余格式无需处理，以避免误删 (issue 5449)\n            if f\".{media_file.extension.lower()}\" not in settings.RMT_MEDIAEXT:\n                continue\n            # 识别文件中的季集信息\n            filemeta = MetaInfoPath(media_path)\n            # 相同季集的文件才删除\n            if filemeta.season != season or filemeta.episode != episode:\n                continue\n            logger.info(f\"正在删除文件：{media_file.name}\")\n            storage_oper.delete(media_file)\n        return True\n\n    @staticmethod\n    def get_rename_path(template_string: str, rename_dict: dict, path: Path = None) -> Path:\n        \"\"\"\n        生成重命名后的完整路径，支持智能重命名事件\n        :param template_string: Jinja2 模板字符串\n        :param rename_dict: 渲染上下文，用于替换模板中的变量\n        :param path: 可选的基础路径，如果提供，将在其基础上拼接生成的路径\n        :return: 生成的完整路径\n        \"\"\"\n        # 创建jinja2模板对象\n        template = Template(template_string)\n        # 渲染生成的字符串\n        render_str = template.render(rename_dict)\n\n        logger.debug(f\"Initial render string: {render_str}\")\n        # 发送智能重命名事件\n        event_data = TransferRenameEventData(\n            template_string=template_string,\n            rename_dict=rename_dict,\n            render_str=render_str,\n            path=path\n        )\n        event = eventmanager.send_event(ChainEventType.TransferRename, event_data)\n        # 检查事件返回的结果\n        if event and event.event_data:\n            event_data: TransferRenameEventData = event.event_data\n            if event_data.updated and event_data.updated_str:\n                logger.debug(f\"Render string updated by event: \"\n                             f\"{render_str} -> {event_data.updated_str} (source: {event_data.source})\")\n                render_str = event_data.updated_str\n\n        # 目的路径\n        if path:\n            return path / render_str\n        else:\n            return Path(render_str)\n"
  },
  {
    "path": "app/modules/filter/RuleParser.py",
    "content": "import threading\n\nfrom pyparsing import Forward, Literal, Word, alphas, infixNotation, opAssoc, alphanums, Combine, nums, ParseResults\n\n\nclass RuleParser:\n\n    _lock = threading.Lock()\n    _thread_local = threading.local()\n\n    def __init__(self):\n        \"\"\"\n        定义语法规则\n        \"\"\"\n        with self._lock:\n            if not hasattr(self._thread_local, 'initialized'):\n                # 表达式\n                expr: Forward = Forward()\n                # 原子\n                atom: Combine = Combine(Word(alphas, alphanums) | (Word(nums) + Word(alphas, alphanums)))\n                # 逻辑非操作符\n                operator_not: Literal = Literal('!').setParseAction(lambda t: 'not')\n                # 逻辑或操作符\n                operator_or: Literal = Literal('|').setParseAction(lambda t: 'or')\n                # 逻辑与操作符\n                operator_and: Literal = Literal('&').setParseAction(lambda t: 'and')\n                # 定义表达式的语法规则\n                expr <<= (operator_not + expr) | atom | ('(' + expr + ')')\n\n                # 运算符优先级\n                self.expr = infixNotation(expr,\n                                          [(operator_not, 1, opAssoc.RIGHT),\n                                           (operator_and, 2, opAssoc.LEFT),\n                                           (operator_or, 2, opAssoc.LEFT)])\n\n                self._thread_local.expr = self.expr\n                self._thread_local.initialized = True\n            else:\n                self.expr = self._thread_local.expr\n\n    def parse(self, expression: str) -> ParseResults:\n        \"\"\"\n        解析给定的表达式。\n\n        参数:\n        expression -- 要解析的表达式\n\n        返回:\n        解析结果\n        \"\"\"\n        return self.expr.parseString(expression)\n\n\nif __name__ == '__main__':\n    # 测试代码\n    expression_str = \"\"\"\n     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\n    \"\"\"\n    for exp in expression_str.split('>'):\n        parsed_expr = RuleParser().parse(exp.strip())\n        print(parsed_expr.asList())\n"
  },
  {
    "path": "app/modules/filter/__init__.py",
    "content": "import re\nfrom typing import List, Tuple, Union, Dict, Optional\n\nfrom app.core.context import TorrentInfo, MediaInfo\nfrom app.core.metainfo import MetaInfo\nfrom app.helper.rule import RuleHelper\nfrom app.log import logger\nfrom app.modules import _ModuleBase\nfrom app.modules.filter.RuleParser import RuleParser\nfrom app.schemas.types import ModuleType, OtherModulesType, SystemConfigKey\nfrom app.utils.string import StringUtils\n\n\nclass FilterModule(_ModuleBase):\n    CONFIG_WATCH = {SystemConfigKey.CustomFilterRules.value}\n    # 规则解析器\n    parser: RuleParser = None\n    # 媒体信息\n    media: MediaInfo = None\n\n    # 内置规则集\n    rule_set: Dict[str, dict] = {\n        # 蓝光原盘\n        \"BLU\": {\n            \"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)'],\n            \"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(?<!WEB-|HDTV)RIP\\b)']\n        },\n        # 4K\n        \"4K\": {\n            \"include\": [r'4k|2160p|x2160'],\n            \"exclude\": []\n        },\n        # 1080P\n        \"1080P\": {\n            \"include\": [r'1080[pi]|x1080'],\n            \"exclude\": []\n        },\n        # 720P\n        \"720P\": {\n            \"include\": [r'720[pi]|x720'],\n            \"exclude\": []\n        },\n        # 中字\n        \"CNSUB\": {\n            \"include\": [\n                r'[中国國繁简](/|\\s|\\\\|\\|)?[繁简英粤]|[英简繁](/|\\s|\\\\|\\|)?[中繁简]'\n                r'|繁體|简体|[中国國][字配]|国语|國語|中文|中字|简日|繁日|简繁|繁体'\n                r'|([\\s,.-\\[])(chs|cht)(|[\\s,.-\\]])'\n                r'|(?<![a-z0-9])(gb|big5)(?![a-z0-9])'],\n            \"exclude\": [],\n            \"tmdb\": {\n                \"original_language\": \"zh,cn\"\n            }\n        },\n        # 官种\n        \"GZ\": {\n            \"include\": [r'官方', r'官种', r'官组'],\n            \"match\": [\"labels\"]\n        },\n        # 特效字幕\n        \"SPECSUB\": {\n            \"include\": [r'特效'],\n            \"exclude\": []\n        },\n        # BluRay\n        \"BLURAY\": {\n            \"include\": [r'Blu-?Ray'],\n            \"exclude\": []\n        },\n        # UHD\n        \"UHD\": {\n            \"include\": [r'UHD|UltraHD'],\n            \"exclude\": []\n        },\n        # H265\n        \"H265\": {\n            \"include\": [r'[Hx].?265|HEVC'],\n            \"exclude\": []\n        },\n        # H264\n        \"H264\": {\n            \"include\": [r'[Hx].?264|AVC'],\n            \"exclude\": []\n        },\n        # 杜比视界\n        \"DOLBY\": {\n            \"include\": [r\"Dolby[\\s.]+Vision|DOVI|[\\s.]+DV[\\s.]+|杜比视界\"],\n            \"exclude\": []\n        },\n        # 杜比全景声\n        \"ATMOS\": {\n            \"include\": [r\"Dolby[\\s.+]+Atmos|Atmos|杜比全景[声聲]\"],\n            \"exclude\": []\n        },\n        # HDR\n        \"HDR\": {\n            \"include\": [r\"[\\s.]+HDR[\\s.]+|HDR10|HDR10\\+\"],\n            \"exclude\": []\n        },\n        # SDR\n        \"SDR\": {\n            \"include\": [r\"[\\s.]+SDR[\\s.]+\"],\n            \"exclude\": []\n        },\n        # 重编码\n        \"REMUX\": {\n            \"include\": [r'REMUX'],\n            \"exclude\": []\n        },\n        # WEB-DL\n        \"WEBDL\": {\n            \"include\": [r'WEB-?DL|WEB-?RIP'],\n            \"exclude\": []\n        },\n        # 免费\n        \"FREE\": {\n            \"downloadvolumefactor\": 0\n        },\n        # 国语配音\n        \"CNVOI\": {\n            \"include\": [r'[国國][语語]配音|[国國]配|[国國][语語]'],\n            \"exclude\": [],\n            \"tmdb\": {\n                \"original_language\": \"zh\"\n            }\n        },\n        # 粤语配音\n        \"HKVOI\": {\n            \"include\": [r'粤语配音|粤语'],\n            \"exclude\": []\n        },\n        # 60FPS\n        \"60FPS\": {\n            \"include\": [r'60fps|60帧'],\n            \"exclude\": []\n        },\n        # 3D\n        \"3D\": {\n            \"include\": [r'3D'],\n            \"exclude\": []\n        },\n    }\n\n    def __init__(self):\n        super().__init__()\n        self.rulehelper = RuleHelper()\n\n    def init_module(self) -> None:\n        self.parser = RuleParser()\n        self.__init_custom_rules()\n\n    def __init_custom_rules(self):\n        \"\"\"\n        加载用户自定义规则，如跟内置规则冲突，以用户自定义规则为准\n        \"\"\"\n        custom_rules = self.rulehelper.get_custom_rules()\n        for rule in custom_rules:\n            logger.info(f\"加载自定义规则 {rule.id} - {rule.name}\")\n            self.rule_set[rule.id] = rule.model_dump()\n\n    @staticmethod\n    def get_name() -> str:\n        return \"过滤器\"\n\n    @staticmethod\n    def get_type() -> ModuleType:\n        \"\"\"\n        获取模块类型\n        \"\"\"\n        return ModuleType.Other\n\n    @staticmethod\n    def get_subtype() -> OtherModulesType:\n        \"\"\"\n        获取模块子类型\n        \"\"\"\n        return OtherModulesType.Filter\n\n    @staticmethod\n    def get_priority() -> int:\n        \"\"\"\n        获取模块优先级，数字越小优先级越高，只有同一接口下优先级才生效\n        \"\"\"\n        return 4\n\n    def stop(self):\n        pass\n\n    def test(self):\n        pass\n\n    def init_setting(self) -> Tuple[str, Union[str, bool]]:\n        pass\n\n    def filter_torrents(self, rule_groups: List[str],\n                        torrent_list: List[TorrentInfo],\n                        mediainfo: MediaInfo = None) -> List[TorrentInfo]:\n        \"\"\"\n        过滤种子资源\n        :param rule_groups:  过滤规则组名称列表\n        :param torrent_list:  资源列表\n        :param mediainfo:  媒体信息\n        :return: 过滤后的资源列表，添加资源优先级\n        \"\"\"\n        if not rule_groups:\n            return torrent_list\n        self.media = mediainfo\n        # 查询规则表详情\n        groups = self.rulehelper.get_rule_group_by_media(media=mediainfo, group_names=rule_groups)\n        if groups:\n            for group in groups:\n                # 过滤种子\n                torrent_list = self.__filter_torrents(\n                    rule_string=group.rule_string,\n                    rule_name=group.name,\n                    torrent_list=torrent_list\n                    )\n        return torrent_list\n\n    def __filter_torrents(self, rule_string: str, rule_name: str,\n                          torrent_list: List[TorrentInfo]) -> List[TorrentInfo]:\n        \"\"\"\n        过滤种子\n        \"\"\"\n        # 返回种子列表\n        ret_torrents = []\n        for torrent in torrent_list:\n            # 能命中优先级的才返回\n            if not self.__get_order(torrent, rule_string):\n                logger.debug(f\"种子 {torrent.site_name} - {torrent.title} {torrent.description or ''} \"\n                             f\"不匹配 {rule_name} 过滤规则\")\n                continue\n            ret_torrents.append(torrent)\n\n        return ret_torrents\n\n    def __get_order(self, torrent: TorrentInfo, rule_str: str) -> Optional[TorrentInfo]:\n        \"\"\"\n        获取种子匹配的规则优先级，值越大越优先，未匹配时返回None\n        \"\"\"\n        # 多级规则\n        rule_groups = rule_str.split('>')\n        # 优先级\n        res_order = 100\n        # 是否匹配\n        matched = False\n\n        for rule_group in rule_groups:\n            # 解析规则组\n            parsed_group = self.parser.parse(rule_group.strip())\n            if self.__match_group(torrent, parsed_group.as_list()[0]):\n                # 出现匹配时中断\n                matched = True\n                logger.debug(f\"种子 {torrent.site_name} - {torrent.title} 优先级为 {100 - res_order + 1}\")\n                torrent.pri_order = res_order\n                break\n            # 优先级降低，继续匹配\n            res_order -= 1\n\n        return None if not matched else torrent\n\n    def __match_group(self, torrent: TorrentInfo, rule_group: Union[list, str]) -> Optional[bool]:\n        \"\"\"\n        判断种子是否匹配规则组\n        \"\"\"\n        if not isinstance(rule_group, list):\n            # 不是列表，说明是规则名称\n            return self.__match_rule(torrent, rule_group)\n        elif isinstance(rule_group, list) and len(rule_group) == 1:\n            # 只有一个规则项\n            return self.__match_group(torrent, rule_group[0])\n        elif rule_group[0] == \"not\":\n            # 非操作\n            return not self.__match_group(torrent, rule_group[1:])\n        elif rule_group[1] == \"and\":\n            # 与操作\n            return self.__match_group(torrent, rule_group[0]) and self.__match_group(torrent, rule_group[2:])\n        elif rule_group[1] == \"or\":\n            # 或操作\n            return self.__match_group(torrent, rule_group[0]) or self.__match_group(torrent, rule_group[2:])\n\n    def __match_rule(self, torrent: TorrentInfo, rule_name: str) -> bool:\n        \"\"\"\n        判断种子是否匹配规则项\n        \"\"\"\n        if not self.rule_set.get(rule_name):\n            # 规则不存在\n            logger.debug(f\"规则 {rule_name} 不存在\")\n            return False\n        # TMDB规则\n        tmdb = self.rule_set[rule_name].get(\"tmdb\")\n        # 符合TMDB规则的直接返回True，即不过滤\n        if tmdb and self.__match_tmdb(tmdb):\n            logger.debug(f\"种子 {torrent.site_name} - {torrent.title} 符合 {rule_name} 的TMDB规则，匹配成功\")\n            return True\n        # 匹配项：标题、副标题、标签\n        content = f\"{torrent.title} {torrent.description} {' '.join(torrent.labels or [])}\"\n        # 只匹配指定关键字\n        match_content = []\n        matchs = self.rule_set[rule_name].get(\"match\") or []\n        if matchs:\n            for match in matchs:\n                if not hasattr(torrent, match):\n                    continue\n                match_value = getattr(torrent, match)\n                if not match_value:\n                    continue\n                if isinstance(match_value, list):\n                    match_content.extend(match_value)\n                else:\n                    match_content.append(match_value)\n        if match_content:\n            content = \" \".join(match_content)\n        # 包含规则项\n        includes = self.rule_set[rule_name].get(\"include\") or []\n        if not isinstance(includes, list):\n            includes = [includes]\n        # 排除规则项\n        excludes = self.rule_set[rule_name].get(\"exclude\") or []\n        if not isinstance(excludes, list):\n            excludes = [excludes]\n        # 大小范围规则项\n        size_range = self.rule_set[rule_name].get(\"size_range\")\n        # 做种人数规则项\n        seeders = self.rule_set[rule_name].get(\"seeders\")\n        # FREE规则\n        downloadvolumefactor = self.rule_set[rule_name].get(\"downloadvolumefactor\")\n        # 发布时间规则\n        pubdate: str = self.rule_set[rule_name].get(\"publish_time\")\n        if includes and not any(re.search(r\"%s\" % include, content, re.IGNORECASE) for include in includes):\n            # 未发现任何包含项\n            logger.debug(f\"种子 {torrent.site_name} - {torrent.title} 不包含任何项 {includes}\")\n            return False\n        for exclude in excludes:\n            if re.search(r\"%s\" % exclude, content, re.IGNORECASE):\n                # 发现排除项\n                logger.debug(f\"种子 {torrent.site_name} - {torrent.title} 包含 {exclude}\")\n                return False\n        if size_range:\n            if not self.__match_size(torrent, size_range):\n                # 大小范围不匹配\n                logger.debug(f\"种子 {torrent.site_name} - {torrent.title} 大小 \"\n                             f\"{StringUtils.str_filesize(torrent.size)} 不在范围 {size_range}MB\")\n                return False\n        if seeders:\n            if torrent.seeders < int(seeders):\n                # 做种人数不匹配\n                logger.debug(f\"种子 {torrent.site_name} - {torrent.title} 做种人数 {torrent.seeders} 小于 {seeders}\")\n                return False\n        if downloadvolumefactor is not None:\n            if torrent.downloadvolumefactor != downloadvolumefactor:\n                # FREE规则不匹配\n                logger.debug(\n                    f\"种子 {torrent.site_name} - {torrent.title} FREE值 {torrent.downloadvolumefactor} 不是 {downloadvolumefactor}\")\n                return False\n        if pubdate:\n            # 种子发布时间\n            pub_minutes = torrent.pub_minutes()\n            # 发布时间规则\n            pub_times = [float(t) for t in pubdate.split(\"-\")]\n            if len(pub_times) == 1:\n                # 发布时间小于规则\n                if pub_minutes < pub_times[0]:\n                    logger.debug(f\"种子 {torrent.site_name} - {torrent.title} 发布时间 {pub_minutes} 小于 {pub_times[0]}\")\n                    return False\n            else:\n                # 区间\n                if not (pub_times[0] <= pub_minutes <= pub_times[1]):\n                    logger.debug(f\"种子 {torrent.site_name} - {torrent.title} 发布时间 {pub_minutes} 不在 {pub_times[0]}-{pub_times[1]} 时间区间\")\n                    return False\n\n        return True\n\n    def __match_tmdb(self, tmdb: dict) -> bool:\n        \"\"\"\n        判断种子是否匹配TMDB规则\n        \"\"\"\n\n        def __get_media_value(key: str):\n            try:\n                return getattr(self.media, key)\n            except ValueError:\n                return \"\"\n\n        if not self.media:\n            return False\n\n        for attr, value in tmdb.items():\n            if not value:\n                continue\n            # 获取media信息的值\n            info_value = __get_media_value(attr)\n            if not info_value:\n                # 没有该值，不匹配\n                return False\n            elif attr == \"production_countries\":\n                # 国家信息\n                info_values = [str(val.get(\"iso_3166_1\")).upper() for val in info_value]\n            else:\n                # media信息转化为数组\n                if isinstance(info_value, list):\n                    info_values = [str(val).upper() for val in info_value]\n                else:\n                    info_values = [str(info_value).upper()]\n            # 过滤值转化为数组\n            if value.find(\",\") != -1:\n                values = [str(val).upper() for val in value.split(\",\") if val]\n            else:\n                values = [str(value).upper()]\n            # 没有交集为不匹配\n            if not set(values).intersection(set(info_values)):\n                return False\n\n        return True\n\n    @staticmethod\n    def __match_size(torrent: TorrentInfo, size_range: str) -> bool:\n        \"\"\"\n        判断种子是否匹配大小范围（MB），剧集拆分为每集大小\n        \"\"\"\n        if not size_range:\n            return True\n        # 集数\n        meta = MetaInfo(title=torrent.title, subtitle=torrent.description)\n        episode_count = meta.total_episode or 1\n        # 每集大小\n        torrent_size = torrent.size / episode_count\n        # 大小范围\n        size_range = size_range.strip()\n        if size_range.find(\"-\") != -1:\n            # 区间\n            size_min, size_max = size_range.split(\"-\")\n            size_min = float(size_min.strip()) * 1024 * 1024\n            size_max = float(size_max.strip()) * 1024 * 1024\n            if size_min <= torrent_size <= size_max:\n                return True\n        elif size_range.startswith(\">\"):\n            # 大于\n            size_min = float(size_range[1:].strip()) * 1024 * 1024\n            if torrent_size >= size_min:\n                return True\n        elif size_range.startswith(\"<\"):\n            # 小于\n            size_max = float(size_range[1:].strip()) * 1024 * 1024\n            if torrent_size <= size_max:\n                return True\n        return False\n"
  },
  {
    "path": "app/modules/indexer/__init__.py",
    "content": "from datetime import datetime\nfrom typing import List, Optional, Tuple, Union\n\nfrom app.core.context import TorrentInfo\nfrom app.db.site_oper import SiteOper\nfrom app.helper.module import ModuleHelper\nfrom app.helper.sites import SitesHelper  # noqa\nfrom app.log import logger\nfrom app.modules import _ModuleBase\nfrom app.modules.indexer.parser import SiteParserBase\nfrom app.modules.indexer.spider import SiteSpider\nfrom app.modules.indexer.spider.haidan import HaiDanSpider\nfrom app.modules.indexer.spider.hddolby import HddolbySpider\nfrom app.modules.indexer.spider.mtorrent import MTorrentSpider\nfrom app.modules.indexer.spider.rousi import RousiSpider\nfrom app.modules.indexer.spider.tnode import TNodeSpider\nfrom app.modules.indexer.spider.torrentleech import TorrentLeech\nfrom app.modules.indexer.spider.yema import YemaSpider\nfrom app.schemas import SiteUserData\nfrom app.schemas.types import MediaType, ModuleType, OtherModulesType\nfrom app.utils.string import StringUtils\n\n\nclass IndexerModule(_ModuleBase):\n    \"\"\"\n    索引模块\n    \"\"\"\n\n    _site_schemas = []\n\n    def init_module(self) -> None:\n        # 加载模块\n        self._site_schemas = ModuleHelper.load(\n            'app.modules.indexer.parser',\n            filter_func=lambda _, obj: hasattr(obj, 'schema') and getattr(obj, 'schema') is not None)\n        pass\n\n    @staticmethod\n    def get_name() -> str:\n        return \"站点索引\"\n\n    @staticmethod\n    def get_type() -> ModuleType:\n        \"\"\"\n        获取模块类型\n        \"\"\"\n        return ModuleType.Indexer\n\n    @staticmethod\n    def get_subtype() -> OtherModulesType:\n        \"\"\"\n        获取模块子类型\n        \"\"\"\n        return OtherModulesType.Indexer\n\n    @staticmethod\n    def get_priority() -> int:\n        \"\"\"\n        获取模块优先级，数字越小优先级越高，只有同一接口下优先级才生效\n        \"\"\"\n        return 0\n\n    def stop(self):\n        pass\n\n    def test(self) -> Tuple[bool, str]:\n        \"\"\"\n        测试模块连接性\n        \"\"\"\n        sites = SitesHelper().get_indexers()\n        if not sites:\n            return False, \"未配置站点或未通过用户认证\"\n        return True, \"\"\n\n    def init_setting(self) -> Tuple[str, Union[str, bool]]:\n        pass\n\n    @staticmethod\n    def __search_check(site: dict, search_word: Optional[str] = None) -> bool:\n        \"\"\"\n        检查是否可以执行搜索\n        \"\"\"\n        # 可能为关键字或ttxxxx\n        if search_word \\\n                and site.get('language') == \"en\" \\\n                and StringUtils.is_chinese(search_word):\n            # 不支持中文\n            logger.warn(f\"{site.get('name')} 不支持中文搜索\")\n            return False\n\n        # 站点流控\n        state, msg = SitesHelper().check(StringUtils.get_url_domain(site.get(\"domain\")))\n        if state:\n            logger.warn(msg)\n            return False\n\n        return True\n\n    @staticmethod\n    def __clear_search_text(text: Optional[str]) -> Optional[str]:\n        \"\"\"\n        清理搜索文本\n        :param text: 需要清理的文本\n        :return: 清理后的文本\n        \"\"\"\n        if not text:\n            return text\n        # 去除特殊字符和多余空格\n        return StringUtils.clear(text, replace_word=\" \", allow_space=True)\n\n    @staticmethod\n    def __indexer_statistic(site: dict, error_flag: bool = False, seconds: int = 0) -> None:\n        \"\"\"\n        索引器统计\n        \"\"\"\n        domain = StringUtils.get_url_domain(site.get(\"domain\"))\n        if error_flag:\n            SiteOper().fail(domain)\n        else:\n            SiteOper().success(domain=domain, seconds=seconds)\n\n    @staticmethod\n    async def __async_indexer_statistic(site: dict, error_flag: bool = False, seconds: int = 0) -> None:\n        \"\"\"\n        异步索引器统计\n        \"\"\"\n        domain = StringUtils.get_url_domain(site.get(\"domain\"))\n        if error_flag:\n            await SiteOper().async_fail(domain)\n        else:\n            await SiteOper().async_success(domain=domain, seconds=seconds)\n\n    @staticmethod\n    def __parse_result(site: dict, result_array: list, seconds: int) -> TorrentInfo:\n        \"\"\"\n        解析搜索结果为 TorrentInfo 对象\n        \"\"\"\n        if not result_array or len(result_array) == 0:\n            logger.warn(f\"{site.get('name')} 未搜索到数据，耗时 {seconds} 秒\")\n            return []\n        logger.info(\n            f\"{site.get('name')} 搜索完成，耗时 {seconds} 秒，返回数据：{len(result_array)}\")\n        return [TorrentInfo(site=site.get(\"id\"),\n                            site_name=site.get(\"name\"),\n                            site_cookie=site.get(\"cookie\"),\n                            site_ua=site.get(\"ua\"),\n                            site_proxy=site.get(\"proxy\"),\n                            site_order=site.get(\"pri\"),\n                            site_downloader=site.get(\"downloader\"),\n                            **result) for result in result_array]\n\n    def search_torrents(self, site: dict,\n                        keyword: str = None,\n                        mtype: MediaType = None,\n                        cat: Optional[str] = None,\n                        page: Optional[int] = 0) -> List[TorrentInfo]:\n        \"\"\"\n        搜索一个站点\n        :param site:  站点\n        :param keyword:  搜索关键词\n        :param mtype:  媒体类型\n        :param cat:  分类\n        :param page:  页码\n        :return: 资源列表\n        \"\"\"\n\n        # 索引结果\n        result = []\n        # 开始计时\n        start_time = datetime.now()\n        # 错误标志\n        error_flag = False\n\n        # 检查是否可以执行搜索\n        if not self.__search_check(site, keyword):\n            return []\n\n        # 去除搜索关键字中的特殊字符\n        search_word = self.__clear_search_text(keyword)\n\n        # 开始搜索\n        try:\n            if site.get('parser') == \"TNodeSpider\":\n                error_flag, result = TNodeSpider(site).search(\n                    keyword=search_word,\n                    page=page\n                )\n            elif site.get('parser') == \"TorrentLeech\":\n                error_flag, result = TorrentLeech(site).search(\n                    keyword=search_word,\n                    page=page\n                )\n            elif site.get('parser') == \"mTorrent\":\n                error_flag, result = MTorrentSpider(site).search(\n                    keyword=search_word,\n                    mtype=mtype,\n                    page=page\n                )\n            elif site.get('parser') == \"Yema\":\n                error_flag, result = YemaSpider(site).search(\n                    keyword=search_word,\n                    mtype=mtype,\n                    page=page\n                )\n            elif site.get('parser') == \"Haidan\":\n                error_flag, result = HaiDanSpider(site).search(\n                    keyword=search_word,\n                    mtype=mtype\n                )\n            elif site.get('parser') == \"HDDolby\":\n                error_flag, result = HddolbySpider(site).search(\n                    keyword=search_word,\n                    mtype=mtype,\n                    page=page\n                )\n            elif site.get('parser') == \"RousiPro\":\n                error_flag, result = RousiSpider(site).search(\n                    keyword=search_word,\n                    mtype=mtype,\n                    cat=cat,\n                    page=page\n                )\n            else:\n                error_flag, result = self.__spider_search(\n                    search_word=search_word,\n                    indexer=site,\n                    mtype=mtype,\n                    cat=cat,\n                    page=page\n                )\n        except Exception as err:\n            logger.error(f\"{site.get('name')} 搜索出错：{str(err)}\")\n\n        # 索引花费的时间\n        seconds = (datetime.now() - start_time).seconds\n\n        # 统计索引情况\n        self.__indexer_statistic(site=site, error_flag=error_flag, seconds=seconds)\n\n        # 返回结果\n        return self.__parse_result(\n            site=site,\n            result_array=result,\n            seconds=seconds\n        )\n\n    async def async_search_torrents(self, site: dict,\n                                    keyword: str = None,\n                                    mtype: MediaType = None,\n                                    cat: Optional[str] = None,\n                                    page: Optional[int] = 0) -> List[TorrentInfo]:\n        \"\"\"\n        异步搜索一个站点\n        :param site:  站点\n        :param keyword:  搜索关键词\n        :param mtype:  媒体类型\n        :param cat:  分类\n        :param page:  页码\n        :return: 资源列表\n        \"\"\"\n\n        # 索引结果\n        result = []\n        # 开始计时\n        start_time = datetime.now()\n        # 错误标志\n        error_flag = False\n\n        # 检查是否可以执行搜索\n        if not self.__search_check(site, keyword):\n            return []\n\n        # 去除搜索关键字中的特殊字符\n        search_word = self.__clear_search_text(keyword)\n\n        # 开始搜索\n        try:\n            if site.get('parser') == \"TNodeSpider\":\n                error_flag, result = await TNodeSpider(site).async_search(\n                    keyword=search_word,\n                    page=page\n                )\n            elif site.get('parser') == \"TorrentLeech\":\n                error_flag, result = await TorrentLeech(site).async_search(\n                    keyword=search_word,\n                    page=page\n                )\n            elif site.get('parser') == \"mTorrent\":\n                error_flag, result = await MTorrentSpider(site).async_search(\n                    keyword=search_word,\n                    mtype=mtype,\n                    page=page\n                )\n            elif site.get('parser') == \"Yema\":\n                error_flag, result = await YemaSpider(site).async_search(\n                    keyword=search_word,\n                    mtype=mtype,\n                    page=page\n                )\n            elif site.get('parser') == \"Haidan\":\n                error_flag, result = await HaiDanSpider(site).async_search(\n                    keyword=search_word,\n                    mtype=mtype\n                )\n            elif site.get('parser') == \"HDDolby\":\n                error_flag, result = await HddolbySpider(site).async_search(\n                    keyword=search_word,\n                    mtype=mtype,\n                    page=page\n                )\n            elif site.get('parser') == \"RousiPro\":\n                error_flag, result = await RousiSpider(site).async_search(\n                    keyword=search_word,\n                    mtype=mtype,\n                    cat=cat,\n                    page=page\n                )\n            else:\n                error_flag, result = await self.__async_spider_search(\n                    search_word=search_word,\n                    indexer=site,\n                    mtype=mtype,\n                    cat=cat,\n                    page=page\n                )\n        except Exception as err:\n            logger.error(f\"{site.get('name')} 搜索出错：{str(err)}\")\n\n        # 索引花费的时间\n        seconds = (datetime.now() - start_time).seconds\n\n        # 统计索引情况\n        await self.__async_indexer_statistic(site=site, error_flag=error_flag, seconds=seconds)\n\n        # 返回结果\n        return self.__parse_result(\n            site=site,\n            result_array=result,\n            seconds=seconds\n        )\n\n    @staticmethod\n    def __spider_search(indexer: dict,\n                        search_word: Optional[str] = None,\n                        mtype: MediaType = None,\n                        cat: Optional[str] = None,\n                        page: Optional[int] = 0) -> Tuple[bool, List[dict]]:\n        \"\"\"\n        根据关键字搜索单个站点\n        :param: indexer: 站点配置\n        :param: search_word: 关键字\n        :param: cat: 分类\n        :param: page: 页码\n        :param: mtype: 媒体类型\n        :param: timeout: 超时时间\n        :return: 是否发生错误, 种子列表\n        \"\"\"\n        _spider = SiteSpider(indexer=indexer,\n                             keyword=search_word,\n                             mtype=mtype,\n                             cat=cat,\n                             page=page)\n\n        try:\n            return _spider.is_error, _spider.get_torrents()\n        finally:\n            del _spider\n\n    @staticmethod\n    async def __async_spider_search(indexer: dict,\n                                    search_word: Optional[str] = None,\n                                    mtype: MediaType = None,\n                                    cat: Optional[str] = None,\n                                    page: Optional[int] = 0) -> Tuple[bool, List[dict]]:\n        \"\"\"\n        异步根据关键字搜索单个站点\n        :param: indexer: 站点配置\n        :param: search_word: 关键字\n        :param: cat: 分类\n        :param: page: 页码\n        :param: mtype: 媒体类型\n        :param: timeout: 超时时间\n        :return: 是否发生错误, 种子列表\n        \"\"\"\n        _spider = SiteSpider(indexer=indexer,\n                             keyword=search_word,\n                             mtype=mtype,\n                             cat=cat,\n                             page=page)\n\n        try:\n            result = await _spider.async_get_torrents()\n            return _spider.is_error, result\n        finally:\n            del _spider\n\n    def refresh_torrents(self, site: dict,\n                         keyword: Optional[str] = None,\n                         cat: Optional[str] = None,\n                         page: Optional[int] = 0) -> Optional[List[TorrentInfo]]:\n        \"\"\"\n        获取站点最新一页的种子，多个站点需要多线程处理\n        :param site:  站点\n        :param keyword:  关键字\n        :param cat:  分类\n        :param page:  页码\n        :reutrn: 种子资源列表\n        \"\"\"\n        return self.search_torrents(site=site, keyword=keyword, cat=cat, page=page)\n\n    async def async_refresh_torrents(self, site: dict,\n                                     keyword: Optional[str] = None,\n                                     cat: Optional[str] = None,\n                                     page: Optional[int] = 0) -> Optional[List[TorrentInfo]]:\n        \"\"\"\n        异步获取站点最新一页的种子，多个站点需要多线程处理\n        :param site:  站点\n        :param keyword:  关键字\n        :param cat:  分类\n        :param page:  页码\n        :reutrn: 种子资源列表\n        \"\"\"\n        return await self.async_search_torrents(site=site, keyword=keyword, cat=cat, page=page)\n\n    def refresh_userdata(self, site: dict) -> Optional[SiteUserData]:\n        \"\"\"\n        刷新站点的用户数据\n        :param site:  站点\n        :return: 用户数据\n        \"\"\"\n\n        def __get_site_obj() -> Optional[SiteParserBase]:\n            \"\"\"\n            获取站点解析器\n            \"\"\"\n            for site_schema in self._site_schemas:\n                if site_schema.schema and site_schema.schema.value == site.get(\"schema\"):\n                    return site_schema(\n                        site_name=site.get(\"name\"),\n                        url=site.get(\"url\"),\n                        site_cookie=site.get(\"cookie\"),\n                        apikey=site.get(\"apikey\"),\n                        token=site.get(\"token\"),\n                        ua=site.get(\"ua\"),\n                        proxy=site.get(\"proxy\"))\n            return None\n\n        site_obj = __get_site_obj()\n        if not site_obj:\n            if not site.get(\"public\"):\n                logger.warn(f\"站点  {site.get('name')} 未找到站点解析器，schema：{site.get('schema')}\")\n            return None\n\n        # 获取用户数据\n        try:\n            logger.info(f\"站点 {site.get('name')} 开始以 {site.get('schema')} 模型解析数据...\")\n            site_obj.parse()\n            logger.debug(f\"站点 {site.get('name')} 数据解析完成\")\n            return SiteUserData(\n                domain=StringUtils.get_url_domain(site.get(\"url\")),\n                userid=site_obj.userid,\n                username=site_obj.username,\n                user_level=site_obj.user_level,\n                join_at=site_obj.join_at,\n                upload=site_obj.upload,\n                download=site_obj.download,\n                ratio=site_obj.ratio,\n                bonus=site_obj.bonus,\n                seeding=site_obj.seeding,\n                seeding_size=site_obj.seeding_size,\n                seeding_info=site_obj.seeding_info.copy() if site_obj.seeding_info else [],\n                leeching=site_obj.leeching,\n                leeching_size=site_obj.leeching_size,\n                message_unread=site_obj.message_unread,\n                message_unread_contents=site_obj.message_unread_contents.copy() if site_obj.message_unread_contents else [],\n                updated_day=datetime.now().strftime('%Y-%m-%d'),\n                err_msg=site_obj.err_msg\n            )\n        finally:\n            site_obj.clear()\n"
  },
  {
    "path": "app/modules/indexer/parser/__init__.py",
    "content": "# -*- coding: utf-8 -*-\nimport json\nimport re\nfrom abc import ABCMeta, abstractmethod\nfrom enum import Enum\nfrom typing import Optional\nfrom urllib.parse import urljoin, urlsplit\n\nfrom requests import Session\n\nfrom app.core.config import settings\nfrom app.helper.cloudflare import under_challenge\nfrom app.log import logger\nfrom app.utils.http import RequestUtils\nfrom app.utils.site import SiteUtils\n\n\n# 站点框架\nclass SiteSchema(Enum):\n    DiscuzX = \"DiscuzX\"\n    Gazelle = \"Gazelle\"\n    Ipt = \"IPTorrents\"\n    NexusPhp = \"NexusPhp\"\n    NexusProject = \"NexusProject\"\n    NexusRabbit = \"NexusRabbit\"\n    NexusHhanclub = \"NexusHhanclub\"\n    NexusAudiences = \"NexusAudiences\"\n    SmallHorse = \"Small Horse\"\n    Unit3d = \"Unit3d\"\n    TorrentLeech = \"TorrentLeech\"\n    FileList = \"FileList\"\n    TNode = \"TNode\"\n    MTorrent = \"MTorrent\"\n    Yema = \"Yema\"\n    HDDolby = \"HDDolby\"\n    Zhixing = \"Zhixing\"\n    Bitpt = \"Bitpt\"\n    RousiPro = \"RousiPro\"\n\n\nclass SiteParserBase(metaclass=ABCMeta):\n    # 站点模版\n    schema = None\n    # 请求模式 cookie/apikey\n    request_mode = \"cookie\"\n\n    def __init__(self, site_name: str,\n                 url: str,\n                 site_cookie: str,\n                 apikey: str,\n                 token: str,\n                 session: Session = None,\n                 ua: Optional[str] = None,\n                 emulate: bool = False,\n                 proxy: bool = None):\n        super().__init__()\n\n        # 站点信息\n        self.apikey = apikey\n        self.token = token\n        self._site_name = site_name\n        self._site_url = url\n        __split_url = urlsplit(url)\n        self._site_domain = __split_url.netloc\n        self._base_url = f\"{__split_url.scheme}://{__split_url.netloc}\"\n        self._site_cookie = site_cookie\n        self._session = session if session else None\n        self._ua = ua\n        self._emulate = emulate\n        self._proxy = proxy\n        self._index_html = \"\"\n        # 用户信息\n        self.username = None\n        self.userid = None\n        self.user_level = None\n        self.join_at = None\n        self.bonus = 0.0\n\n        # 流量信息\n        self.upload = 0\n        self.download = 0\n        self.ratio = 0\n\n        # 做种信息\n        self.seeding = 0\n        self.leeching = 0\n        self.seeding_size = 0\n        self.leeching_size = 0\n        self.uploaded = 0\n        self.completed = 0\n        self.incomplete = 0\n        self.uploaded_size = 0\n        self.completed_size = 0\n        self.incomplete_size = 0\n        # 做种人数, 种子大小\n        self.seeding_info = []\n\n        # 未读消息\n        self.message_unread = 0\n        self.message_unread_contents = []\n        self.message_read_force = False\n\n        # 全局附加请求头\n        self._addition_headers = None\n\n        # 用户基础信息页面\n        self._user_basic_page = None\n        # 用户基础信息参数\n        self._user_basic_params = None\n        # 用户基础信息请求头\n        self._user_basic_headers = None\n\n        # 用户详情信息页面\n        self._user_detail_page = \"userdetails.php?id=\"\n        # 用户详情信息参数\n        self._user_detail_params = None\n        # 用户详情信息请求头\n        self._user_detail_headers = None\n\n        # 用户流量信息页面\n        self._user_traffic_page = \"index.php\"\n        # 用户流量信息参数\n        self._user_traffic_params = None\n        # 用户流量信息请求头\n        self._user_traffic_headers = None\n\n        # 用户未读消息页面\n        self._user_mail_unread_page = \"messages.php?action=viewmailbox&box=1&unread=yes\"\n        # 系统未读消息页面\n        self._sys_mail_unread_page = \"messages.php?action=viewmailbox&box=-2&unread=yes\"\n        # 未读消息数参数\n        self._mail_unread_params = None\n        # 未读消息数请求头\n        self._mail_unread_headers = None\n        # 未读消息内容参数\n        self._mail_content_params = None\n        # 未读消息内容请求头\n        self._mail_content_headers = None\n\n        # 用户做种信息页面\n        self._torrent_seeding_page = \"getusertorrentlistajax.php?userid=\"\n        # 用户做种信息参数\n        self._torrent_seeding_params = None\n        # 用户做种信息请求头\n        self._torrent_seeding_headers = None\n\n        # 错误信息\n        self.err_msg = None\n\n    def site_schema(self) -> SiteSchema:\n        \"\"\"\n        站点解析模型\n        :return: 站点解析模型\n        \"\"\"\n        return self.schema\n\n    def parse(self):\n        \"\"\"\n        解析站点信息\n        :return:\n        \"\"\"\n        try:\n            # Cookie模式时，获取站点首页html\n            if self.request_mode == \"apikey\":\n                if not self.apikey and not self.token:\n                    logger.warn(f\"{self._site_name} 未设置cookie 或 apikey/token，跳过后续操作\")\n                    return\n                self._index_html = {}\n            else:\n                # 检查是否已经登录\n                self._index_html = self._get_page_content(url=self._site_url)\n                if not self._parse_logged_in(self._index_html):\n                    return\n            # 解析站点页面\n            self._parse_site_page(self._index_html)\n            # 解析用户基础信息\n            if self._user_basic_page:\n                self._parse_user_base_info(\n                    self._get_page_content(\n                        url=urljoin(self._base_url, self._user_basic_page),\n                        params=self._user_basic_params,\n                        headers=self._user_basic_headers\n                    )\n                )\n            else:\n                self._parse_user_base_info(self._index_html)\n            # 解析用户详细信息\n            if self._user_detail_page:\n                self._parse_user_detail_info(\n                    self._get_page_content(\n                        url=urljoin(self._base_url, self._user_detail_page),\n                        params=self._user_detail_params,\n                        headers=self._user_detail_headers\n                    )\n                )\n            # 解析用户未读消息\n            if settings.SITE_MESSAGE:\n                self._pase_unread_msgs()\n            # 解析用户上传、下载、分享率等信息\n            if self._user_traffic_page:\n                self._parse_user_traffic_info(\n                    self._get_page_content(\n                        url=urljoin(self._base_url, self._user_traffic_page),\n                        params=self._user_traffic_params,\n                        headers=self._user_traffic_headers\n                    )\n                )\n            # 解析用户做种信息\n            self._parse_seeding_pages()\n        finally:\n            # 关闭连接\n            self.close()\n\n    def _pase_unread_msgs(self):\n        \"\"\"\n        解析所有未读消息标题和内容\n        :return:\n        \"\"\"\n        unread_msg_links = []\n        if self.message_unread > 0 or self.message_read_force:\n            links = {self._user_mail_unread_page, self._sys_mail_unread_page}\n            for link in links:\n                if not link:\n                    continue\n                msg_links = []\n                next_page = self._parse_message_unread_links(\n                    self._get_page_content(\n                        url=urljoin(self._base_url, link),\n                        params=self._mail_unread_params,\n                        headers=self._mail_unread_headers\n                    ),\n                    msg_links)\n                while next_page:\n                    next_page = self._parse_message_unread_links(\n                        self._get_page_content(\n                            url=urljoin(self._base_url, next_page),\n                            params=self._mail_unread_params,\n                            headers=self._mail_unread_headers\n                        ),\n                        msg_links\n                    )\n                unread_msg_links.extend(msg_links)\n        # 重新更新未读消息数（99999表示有消息但数量未知）\n        if unread_msg_links and not self.message_unread:\n            self.message_unread = len(unread_msg_links)\n        # 解析未读消息内容\n        for msg_link in unread_msg_links:\n            logger.debug(f\"{self._site_name} 信息链接 {msg_link}\")\n            head, date, content = self._parse_message_content(\n                self._get_page_content(\n                    urljoin(self._base_url, msg_link),\n                    params=self._mail_content_params,\n                    headers=self._mail_content_headers\n                )\n            )\n            logger.debug(f\"{self._site_name} 标题 {head} 时间 {date} 内容 {content}\")\n            self.message_unread_contents.append((head, date, content))\n\n    def _parse_seeding_pages(self):\n        \"\"\"\n        解析做种页面\n        \"\"\"\n        if self._torrent_seeding_page:\n            # 第一页\n            next_page = self._parse_user_torrent_seeding_info(\n                self._get_page_content(\n                    url=urljoin(self._base_url, self._torrent_seeding_page),\n                    params=self._torrent_seeding_params,\n                    headers=self._torrent_seeding_headers\n                )\n            )\n\n            # 其他页处理\n            while next_page is not None and next_page is not False:\n                next_page = self._parse_user_torrent_seeding_info(\n                    self._get_page_content(\n                        url=urljoin(urljoin(self._base_url, self._torrent_seeding_page), next_page),\n                        params=self._torrent_seeding_params,\n                        headers=self._torrent_seeding_headers\n                    ),\n                    multi_page=True)\n\n    @staticmethod\n    def _prepare_html_text(html_text):\n        \"\"\"\n        处理掉HTML中的干扰部分\n        \"\"\"\n        return re.sub(r\"#\\d+\", \"\", re.sub(r\"\\d+px\", \"\", html_text))\n\n    @abstractmethod\n    def _parse_message_unread_links(self, html_text: str, msg_links: list) -> Optional[str]:\n        \"\"\"\n        获取未阅读消息链接\n        :param html_text:\n        :return:\n        \"\"\"\n        pass\n\n    def _get_page_content(self, url: str, params: dict = None, headers: dict = None):\n        \"\"\"\n        获取页面内容\n        :param url: 网页地址\n        :param params: post参数\n        :param headers: 额外的请求头\n        :return:\n        \"\"\"\n        req_headers = None\n        proxies = settings.PROXY if self._proxy else None\n        if self._ua or headers or self._addition_headers:\n\n            if self.request_mode == \"apikey\":\n                req_headers = {}\n            else:\n                req_headers = {\n                    \"User-Agent\": f\"{self._ua}\"\n                }\n\n            if headers:\n                req_headers.update(headers)\n            else:\n                req_headers.update({\n                    \"Content-Type\": \"application/x-www-form-urlencoded; charset=UTF-8\",\n                })\n\n            if self._addition_headers:\n                req_headers.update(self._addition_headers)\n\n        if self.request_mode == \"apikey\":\n            # 使用apikey请求，通过请求头传递\n            cookie = None\n            session = None\n        else:\n            # 使用cookie请求\n            cookie = self._site_cookie\n            session = self._session\n\n        if params:\n            if req_headers.get(\"Content-Type\") == \"application/json\":\n                res = RequestUtils(cookies=cookie,\n                                   session=session,\n                                   timeout=60,\n                                   proxies=proxies,\n                                   headers=req_headers).post_res(url=url, json=params)\n            else:\n                res = RequestUtils(cookies=cookie,\n                                   session=session,\n                                   timeout=60,\n                                   proxies=proxies,\n                                   headers=req_headers).post_res(url=url, data=params)\n        else:\n            res = RequestUtils(cookies=cookie,\n                               session=session,\n                               timeout=60,\n                               proxies=proxies,\n                               headers=req_headers).get_res(url=url)\n        if res is not None and res.status_code in (200, 500, 403):\n            if req_headers and \"application/json\" in str(req_headers.get(\"Accept\")):\n                try:\n                    return json.dumps(res.json())\n                except (json.JSONDecodeError, ValueError) as e:\n                    logger.error(f\"{self._site_name} API响应JSON解析失败: {e}\")\n                    return \"\"\n            else:\n                # 如果cloudflare 有防护，尝试使用浏览器仿真\n                if under_challenge(res.text):\n                    logger.warn(\n                        f\"{self._site_name} 检测到Cloudflare，请更新Cookie和UA\")\n                    return \"\"\n                return RequestUtils.get_decoded_html_content(res,\n                                                             settings.ENCODING_DETECTION_PERFORMANCE_MODE,\n                                                             settings.ENCODING_DETECTION_MIN_CONFIDENCE)\n\n        return \"\"\n\n    @abstractmethod\n    def _parse_site_page(self, html_text: str):\n        \"\"\"\n        解析站点相关信息页面\n        :param html_text:\n        :return:\n        \"\"\"\n        pass\n\n    @abstractmethod\n    def _parse_user_base_info(self, html_text: str):\n        \"\"\"\n        解析用户基础信息\n        :param html_text:\n        :return:\n        \"\"\"\n        pass\n\n    def _parse_logged_in(self, html_text):\n        \"\"\"\n        解析用户是否已经登陆\n        :param html_text:\n        :return: True/False\n        \"\"\"\n        logged_in = SiteUtils.is_logged_in(html_text)\n        if not logged_in:\n            self.err_msg = \"未检测到已登陆，请检查cookies是否过期\"\n            logger.warn(f\"{self._site_name} 未登录，跳过后续操作\")\n\n        return logged_in\n\n    @abstractmethod\n    def _parse_user_traffic_info(self, html_text: str):\n        \"\"\"\n        解析用户的上传，下载，分享率等信息\n        :param html_text:\n        :return:\n        \"\"\"\n        pass\n\n    @abstractmethod\n    def _parse_user_torrent_seeding_info(self, html_text: str, multi_page: bool = False) -> Optional[str]:\n        \"\"\"\n        解析用户的做种相关信息\n        :param html_text:\n        :param multi_page: 是否多页数据\n        :return: 下页地址\n        \"\"\"\n        pass\n\n    @abstractmethod\n    def _parse_user_detail_info(self, html_text: str):\n        \"\"\"\n        解析用户的详细信息\n        加入时间/等级/魔力值等\n        :param html_text:\n        :return:\n        \"\"\"\n        pass\n\n    @abstractmethod\n    def _parse_message_content(self, html_text):\n        \"\"\"\n        解析短消息内容\n        :param html_text:\n        :return:  head: message, date: time, content: message content\n        \"\"\"\n        pass\n\n    def close(self):\n        \"\"\"\n        关闭会话\n        \"\"\"\n        if self._session:\n            self._session.close()\n            self._session = None\n\n    def clear(self):\n        \"\"\"\n        清除当前解析器的所有信息\n        \"\"\"\n        self._index_html = \"\"\n        self.seeding_info.clear()\n        self.message_unread_contents.clear()\n\n    def to_dict(self):\n        \"\"\"\n        转化为字典\n        \"\"\"\n        attributes = [\n            attr for attr in dir(self)\n            if not callable(getattr(self, attr)) and not attr.startswith(\"_\")\n        ]\n        return {\n            attr: getattr(self, attr).value\n            if isinstance(getattr(self, attr), SiteSchema)\n            else getattr(self, attr) for attr in attributes\n        }\n"
  },
  {
    "path": "app/modules/indexer/parser/bitpt.py",
    "content": "#\n# 极速之星 https://bitpt.cn/\n# author: ThedoRap\n# time: 2025-10-02\n#\n# -*- coding: utf-8 -*-\nimport re\nfrom typing import Optional, Tuple\nfrom urllib.parse import urljoin, urlencode\n\nfrom bs4 import BeautifulSoup\nfrom app.modules.indexer.parser import SiteParserBase, SiteSchema\nfrom app.utils.string import StringUtils\n\nclass BitptSiteUserInfo(SiteParserBase):\n    schema = SiteSchema.Bitpt\n\n    def _parse_site_page(self, html_text: str):\n        self._user_basic_page = \"userdetails.php?uid={uid}\"\n        self._user_detail_page = None\n        self._user_basic_params = {}\n        self._user_traffic_page = None\n        self._sys_mail_unread_page = None\n        self._user_mail_unread_page = None\n        self._mail_unread_params = {}\n        self._torrent_seeding_base = \"browse.php\"\n        self._torrent_seeding_params = {\"t\": \"myseed\", \"st\": \"2\", \"d\": \"desc\"}\n        self._torrent_seeding_headers = {}\n        self._addition_headers = {}\n\n    def _parse_logged_in(self, html_text):\n        soup = BeautifulSoup(html_text, 'html.parser')\n        return bool(soup.find(id='userinfotop'))\n\n    def _parse_user_base_info(self, html_text: str):\n        if not html_text:\n            return None\n        soup = BeautifulSoup(html_text, 'html.parser')\n        table = soup.find('table', class_='frmtable')\n        if not table:\n            return\n\n        rows = table.find_all('tr')\n        info_dict = {}\n        for row in rows:\n            cells = row.find_all('td')\n            if len(cells) == 2:\n                key = cells[0].text.strip()\n                value = cells[1].text.strip()\n                info_dict[key] = value\n\n        self.userid = info_dict.get('UID')\n        self.username = info_dict.get('用户名').split('\\xa0')[0] if '用户名' in info_dict else None\n        self.user_level = info_dict.get('用户级别') if '用户级别' in info_dict else None\n        self.join_at = StringUtils.unify_datetime_str(info_dict.get('注册时间')) if '注册时间' in info_dict else None\n\n        self.upload = StringUtils.num_filesize(info_dict.get('上传流量')) if '上传流量' in info_dict else 0\n        self.download = StringUtils.num_filesize(info_dict.get('下载流量')) if '下载流量' in info_dict else 0\n        self.ratio = float(info_dict.get('共享率')) if '共享率' in info_dict else 0\n        bonus_str = info_dict.get('星辰', '')\n        self.bonus = float(re.search(r'累计([\\d\\.]+)', bonus_str).group(1)) if re.search(r'累计([\\d\\.]+)', bonus_str) else 0\n        self.message_unread = 0\n\n        if hasattr(self, '_torrent_seeding_base') and self._torrent_seeding_base:\n            self.seeding = 0\n            self.seeding_size = 0\n        else:\n            seeding_info = soup.find('div', style=\"margin:0 auto;width:90%;font-size:14px;margin-top:10px;margin-bottom:10px;text-align:center;\")\n            if seeding_info:\n                seeding_link = seeding_info.find_all('a')[1].text if len(seeding_info.find_all('a')) > 1 else ''\n                match = re.search(r'当前上传的种子\\((\\d+)个, 共([\\d\\.]+ [KMGT]B)\\)', seeding_link)\n                if match:\n                    self.seeding = int(match.group(1))\n                    self.seeding_size = StringUtils.num_filesize(match.group(2))\n                else:\n                    self.seeding = 0\n                    self.seeding_size = 0\n\n    def _parse_user_traffic_info(self, html_text: str):\n        pass\n\n    def _parse_user_detail_info(self, html_text: str):\n        pass\n\n    def _parse_user_torrent_seeding_page_info(self, html_text: str) -> Tuple[int, int]:\n        if not html_text:\n            return 0, 0\n        soup = BeautifulSoup(html_text, 'html.parser')\n        torrent_table = soup.find('table', class_='torrenttable')\n        if not torrent_table:\n            return 0, 0\n        rows = torrent_table.find_all('tr')\n        if len(rows) <= 1:\n            return 0, 0\n        torrents = [row for row in rows[1:] if 'btr' in row.get('class', [])]\n        page_seeding = 0\n        page_seeding_size = 0\n        for torrent in torrents:\n            size_td = torrent.find('td', class_='r')\n            if size_td:\n                size_a = size_td.find('a')\n                size_text = size_a.text.strip() if size_a else size_td.text.strip()\n                if size_text:\n                    page_seeding += 1\n                    page_seeding_size += StringUtils.num_filesize(size_text)\n        return page_seeding, page_seeding_size\n\n    def _parse_message_unread_links(self, html_text: str, msg_links: list) -> Optional[str]:\n        pass\n\n    def _parse_message_content(self, html_text) -> Tuple[Optional[str], Optional[str], Optional[str]]:\n        pass\n\n    def _parse_user_torrent_seeding_info(self, html_text: str):\n        pass\n\n    def parse(self):\n        super().parse()\n        if self._index_html:\n            soup = BeautifulSoup(self._index_html, 'html.parser')\n            user_link = soup.find('a', href=re.compile(r'userdetails\\.php\\?uid=\\d+'))\n            if user_link:\n                uid_match = re.search(r'uid=(\\d+)', user_link['href'])\n                if uid_match:\n                    self.userid = uid_match.group(1)\n\n        if self.userid and self._user_basic_page:\n            basic_url = self._user_basic_page.format(uid=self.userid)\n            basic_html = self._get_page_content(url=urljoin(self._base_url, basic_url))\n            self._parse_user_base_info(basic_html)\n\n        if hasattr(self, '_torrent_seeding_base') and self._torrent_seeding_base:\n            seeding_base_url = urljoin(self._base_url, self._torrent_seeding_base)\n            params = self._torrent_seeding_params.copy()\n            page_num = 1\n            while True:\n                params['p'] = page_num\n                query_string = urlencode(params)\n                full_url = f\"{seeding_base_url}?{query_string}\"\n                seeding_html = self._get_page_content(url=full_url)\n                page_seeding, page_seeding_size = self._parse_user_torrent_seeding_page_info(seeding_html)\n                self.seeding += page_seeding\n                self.seeding_size += page_seeding_size\n                if page_seeding == 0:\n                    break\n                page_num += 1\n\n        # 🔑 最终对外统一转字符串\n        self.userid = str(self.userid or \"\")\n        self.username = str(self.username or \"\")\n        self.user_level = str(self.user_level or \"\")\n        self.join_at = str(self.join_at or \"\")\n\n        self.upload = str(self.upload or 0)\n        self.download = str(self.download or 0)\n        self.ratio = str(self.ratio or 0)\n        self.bonus = str(self.bonus or 0.0)\n        self.message_unread = str(self.message_unread or 0)\n\n        self.seeding = str(self.seeding or 0)\n        self.seeding_size = str(self.seeding_size or 0)"
  },
  {
    "path": "app/modules/indexer/parser/discuz.py",
    "content": "# -*- coding: utf-8 -*-\nimport re\nfrom typing import Optional\n\nfrom lxml import etree\n\nfrom app.modules.indexer.parser import SiteParserBase, SiteSchema\nfrom app.utils.string import StringUtils\n\n\nclass DiscuzUserInfo(SiteParserBase):\n    schema = SiteSchema.DiscuzX\n\n    def _parse_user_base_info(self, html_text: str):\n        html_text = self._prepare_html_text(html_text)\n        html = etree.HTML(html_text)\n        try:\n            user_info = html.xpath('//a[contains(@href, \"&uid=\")]')\n            if user_info:\n                user_id_match = re.search(r\"&uid=(\\d+)\", user_info[0].attrib['href'])\n                if user_id_match and user_id_match.group().strip():\n                    self.userid = user_id_match.group(1)\n                    self._torrent_seeding_page = f\"forum.php?&mod=torrents&cat_5up=on\"\n                    self._user_detail_page = user_info[0].attrib['href']\n                    self.username = user_info[0].text.strip()\n        finally:\n            if html is not None:\n                del html\n\n    def _parse_site_page(self, html_text: str):\n        pass\n\n    def _parse_user_detail_info(self, html_text: str):\n        \"\"\"\n        解析用户额外信息，加入时间，等级\n        :param html_text:\n        :return:\n        \"\"\"\n        html = etree.HTML(html_text)\n        try:\n            if not StringUtils.is_valid_html_element(html):\n                return None\n\n            # 用户等级\n            user_levels_text = html.xpath('//a[contains(@href, \"usergroup\")]/text()')\n            if user_levels_text:\n                self.user_level = user_levels_text[-1].strip()\n\n            # 加入日期\n            join_at_text = html.xpath('//li[em[text()=\"注册时间\"]]/text()')\n            if join_at_text:\n                self.join_at = StringUtils.unify_datetime_str(join_at_text[0].strip())\n\n            # 分享率\n            ratio_text = html.xpath('//li[contains(.//text(), \"分享率\")]//text()')\n            if ratio_text:\n                ratio_match = re.search(r\"\\(([\\d,.]+)\\)\", ratio_text[0])\n                if ratio_match and ratio_match.group(1).strip():\n                    self.bonus = StringUtils.str_float(ratio_match.group(1))\n\n            # 积分\n            bouns_text = html.xpath('//li[em[text()=\"积分\"]]/text()')\n            if bouns_text:\n                self.bonus = StringUtils.str_float(bouns_text[0].strip())\n\n            # 上传\n            upload_text = html.xpath('//li[em[contains(text(),\"上传量\")]]/text()')\n            if upload_text:\n                self.upload = StringUtils.num_filesize(upload_text[0].strip().split('/')[-1])\n\n            # 下载\n            download_text = html.xpath('//li[em[contains(text(),\"下载量\")]]/text()')\n            if download_text:\n                self.download = StringUtils.num_filesize(download_text[0].strip().split('/')[-1])\n        finally:\n            if html is not None:\n                del html\n\n    def _parse_user_torrent_seeding_info(self, html_text: str, multi_page: bool = False) -> Optional[str]:\n        \"\"\"\n        做种相关信息\n        :param html_text:\n        :param multi_page: 是否多页数据\n        :return: 下页地址\n        \"\"\"\n        html = etree.HTML(html_text)\n        try:\n            if not StringUtils.is_valid_html_element(html):\n                return None\n\n            size_col = 3\n            seeders_col = 4\n            # 搜索size列\n            if html.xpath('//tr[position()=1]/td[.//img[@class=\"size\"] and .//img[@alt=\"size\"]]'):\n                size_col = len(html.xpath('//tr[position()=1]/td[.//img[@class=\"size\"] '\n                                          'and .//img[@alt=\"size\"]]/preceding-sibling::td')) + 1\n            # 搜索seeders列\n            if html.xpath('//tr[position()=1]/td[.//img[@class=\"seeders\"] and .//img[@alt=\"seeders\"]]'):\n                seeders_col = len(html.xpath('//tr[position()=1]/td[.//img[@class=\"seeders\"] '\n                                             'and .//img[@alt=\"seeders\"]]/preceding-sibling::td')) + 1\n\n            page_seeding = 0\n            page_seeding_size = 0\n            page_seeding_info = []\n            seeding_sizes = html.xpath(f'//tr[position()>1]/td[{size_col}]')\n            seeding_seeders = html.xpath(f'//tr[position()>1]/td[{seeders_col}]//text()')\n            if seeding_sizes and seeding_seeders:\n                page_seeding = len(seeding_sizes)\n\n                for i in range(0, len(seeding_sizes)):\n                    size = StringUtils.num_filesize(seeding_sizes[i].xpath(\"string(.)\").strip())\n                    seeders = StringUtils.str_int(seeding_seeders[i])\n\n                    page_seeding_size += size\n                    page_seeding_info.append([seeders, size])\n\n            self.seeding += page_seeding\n            self.seeding_size += page_seeding_size\n            self.seeding_info.extend(page_seeding_info)\n\n            # 是否存在下页数据\n            next_page = None\n            next_page_text = html.xpath('//a[contains(.//text(), \"下一页\") or contains(.//text(), \"下一頁\")]/@href')\n            if next_page_text:\n                next_page = next_page_text[-1].strip()\n        finally:\n            if html is not None:\n                del html\n\n        return next_page\n\n    def _parse_user_traffic_info(self, html_text: str):\n        pass\n\n    def _parse_message_unread_links(self, html_text: str, msg_links: list) -> Optional[str]:\n        return None\n\n    def _parse_message_content(self, html_text):\n        return None, None, None\n"
  },
  {
    "path": "app/modules/indexer/parser/file_list.py",
    "content": "# -*- coding: utf-8 -*-\nimport re\nfrom typing import Optional\n\nfrom lxml import etree\n\nfrom app.modules.indexer.parser import SiteParserBase, SiteSchema\nfrom app.utils.string import StringUtils\n\n\nclass FileListSiteUserInfo(SiteParserBase):\n    schema = SiteSchema.FileList\n\n    def _parse_site_page(self, html_text: str):\n        html_text = self._prepare_html_text(html_text)\n\n        user_detail = re.search(r\"userdetails.php\\?id=(\\d+)\", html_text)\n        if user_detail and user_detail.group().strip():\n            self._user_detail_page = user_detail.group().strip().lstrip('/')\n            self.userid = user_detail.group(1)\n\n        self._torrent_seeding_page = f\"snatchlist.php?id={self.userid}&action=torrents&type=seeding\"\n\n    def _parse_user_base_info(self, html_text: str):\n        html_text = self._prepare_html_text(html_text)\n        html = etree.HTML(html_text)\n        try:\n            ret = html.xpath(f'//a[contains(@href, \"userdetails\") and contains(@href, \"{self.userid}\")]//text()')\n            if ret:\n                self.username = str(ret[0])\n        finally:\n            if html is not None:\n                del html\n\n    def _parse_user_traffic_info(self, html_text: str):\n        \"\"\"\n        上传/下载/分享率 [做种数/魔力值]\n        :param html_text:\n        :return:\n        \"\"\"\n        return\n\n    def _parse_user_detail_info(self, html_text: str):\n        html_text = self._prepare_html_text(html_text)\n        html = etree.HTML(html_text)\n        try:\n            upload_html = html.xpath('//table//tr/td[text()=\"Uploaded\"]/following-sibling::td//text()')\n            if upload_html:\n                self.upload = StringUtils.num_filesize(upload_html[0])\n            download_html = html.xpath('//table//tr/td[text()=\"Downloaded\"]/following-sibling::td//text()')\n            if download_html:\n                self.download = StringUtils.num_filesize(download_html[0])\n\n            ratio_html = html.xpath('//table//tr/td[text()=\"Share ratio\"]/following-sibling::td//text()')\n            if ratio_html:\n                share_ratio = StringUtils.str_float(ratio_html[0])\n            else:\n                share_ratio = 0\n            self.ratio = 0 if self.download == 0 else share_ratio\n\n            seed_html = html.xpath('//table//tr/td[text()=\"Seed bonus\"]/following-sibling::td//text()')\n            if seed_html:\n                self.seeding = StringUtils.str_int(seed_html[1])\n                self.seeding_size = StringUtils.num_filesize(seed_html[3])\n\n            user_level_html = html.xpath('//table//tr/td[text()=\"Class\"]/following-sibling::td//text()')\n            if user_level_html:\n                self.user_level = user_level_html[0].strip()\n\n            join_at_html = html.xpath('//table//tr/td[contains(text(), \"Join\")]/following-sibling::td//text()')\n            if join_at_html:\n                join_at = (join_at_html[0].split(\"(\"))[0].strip()\n                self.join_at = StringUtils.unify_datetime_str(join_at)\n\n            bonus_html = html.xpath('//a[contains(@href, \"shop.php\")]')\n            if bonus_html:\n                self.bonus = StringUtils.str_float(bonus_html[0].xpath(\"string(.)\").strip())\n        finally:\n            if html is not None:\n                del html\n\n    def _parse_user_torrent_seeding_info(self, html_text: str, multi_page: Optional[bool] = False) -> Optional[str]:\n        \"\"\"\n        做种相关信息\n        :param html_text:\n        :param multi_page: 是否多页数据\n        :return: 下页地址\n        \"\"\"\n        html = etree.HTML(html_text)\n        try:\n            if not StringUtils.is_valid_html_element(html):\n                return None\n\n            size_col = 6\n            seeders_col = 7\n\n            page_seeding_size = 0\n            page_seeding_info = []\n            seeding_sizes = html.xpath(f'//table/tr[position()>1]/td[{size_col}]')\n            seeding_seeders = html.xpath(f'//table/tr[position()>1]/td[{seeders_col}]')\n            if seeding_sizes and seeding_seeders:\n                for i in range(0, len(seeding_sizes)):\n                    size = StringUtils.num_filesize(seeding_sizes[i].xpath(\"string(.)\").strip())\n                    seeders = StringUtils.str_int(seeding_seeders[i].xpath(\"string(.)\").strip())\n\n                    page_seeding_size += size\n                    page_seeding_info.append([seeders, size])\n\n            self.seeding_info.extend(page_seeding_info)\n\n            # 是否存在下页数据\n            next_page = None\n        finally:\n            if html is not None:\n                del html\n\n        return next_page\n\n    def _parse_message_unread_links(self, html_text: str, msg_links: list) -> Optional[str]:\n        return None\n\n    def _parse_message_content(self, html_text):\n        return None, None, None\n"
  },
  {
    "path": "app/modules/indexer/parser/gazelle.py",
    "content": "# -*- coding: utf-8 -*-\nimport re\nfrom typing import Optional\n\nfrom lxml import etree\n\nfrom app.modules.indexer.parser import SiteParserBase, SiteSchema\nfrom app.utils.string import StringUtils\n\n\nclass GazelleSiteUserInfo(SiteParserBase):\n    schema = SiteSchema.Gazelle\n\n    def _parse_user_base_info(self, html_text: str):\n        html_text = self._prepare_html_text(html_text)\n        html = etree.HTML(html_text)\n        try:\n            tmps = html.xpath('//a[contains(@href, \"user.php?id=\") or contains(@href, \"user?id=\")]')\n            if tmps:\n                user_id_match = re.search(r\"user(?:\\.php)?\\?id=(\\d+)\", tmps[0].attrib['href'])\n                if user_id_match and user_id_match.group().strip():\n                    self.userid = user_id_match.group(1)\n                    self._torrent_seeding_page = f\"torrents.php?type=seeding&userid={self.userid}\"\n                    self._user_detail_page = f\"user.php?id={self.userid}\"\n                    self.username = tmps[0].text.strip()\n\n            tmps = html.xpath('//*[@id=\"header-uploaded-value\"]/@data-value')\n            if tmps:\n                self.upload = StringUtils.num_filesize(tmps[0])\n            else:\n                tmps = html.xpath('//li[@id=\"stats_seeding\"]/span/text()')\n                if tmps:\n                    self.upload = StringUtils.num_filesize(tmps[0])\n\n            tmps = html.xpath('//*[@id=\"header-downloaded-value\"]/@data-value')\n            if tmps:\n                self.download = StringUtils.num_filesize(tmps[0])\n            else:\n                tmps = html.xpath('//li[@id=\"stats_leeching\"]/span/text()')\n                if tmps:\n                    self.download = StringUtils.num_filesize(tmps[0])\n\n            self.ratio = 0.0 if self.download <= 0.0 else round(self.upload / self.download, 3)\n\n            tmps = html.xpath('//a[contains(@href, \"bonus\")]/@data-tooltip')\n            if tmps:\n                bonus_match = re.search(r\"([\\d,.]+)\", tmps[0])\n                if bonus_match and bonus_match.group(1).strip():\n                    self.bonus = StringUtils.str_float(bonus_match.group(1))\n            else:\n                tmps = html.xpath('//a[contains(@href, \"bonus\")]')\n                if tmps:\n                    bonus_text = tmps[0].xpath(\"string(.)\")\n                    bonus_match = re.search(r\"([\\d,.]+)\", bonus_text)\n                    if bonus_match and bonus_match.group(1).strip():\n                        self.bonus = StringUtils.str_float(bonus_match.group(1))\n        finally:\n            if html is not None:\n                del html\n\n    def _parse_site_page(self, html_text: str):\n        pass\n\n    def _parse_user_detail_info(self, html_text: str):\n        \"\"\"\n        解析用户额外信息，加入时间，等级\n        :param html_text:\n        :return:\n        \"\"\"\n        html = etree.HTML(html_text)\n        try:\n            if not StringUtils.is_valid_html_element(html):\n                return None\n\n            # 用户等级\n            user_levels_text = html.xpath('//*[@id=\"class-value\"]/@data-value')\n            if user_levels_text:\n                self.user_level = user_levels_text[0].strip()\n            else:\n                user_levels_text = html.xpath('//li[contains(text(), \"用户等级\")]/text()')\n                if user_levels_text:\n                    self.user_level = user_levels_text[0].split(':')[1].strip()\n\n            # 加入日期\n            join_at_text = html.xpath('//*[@id=\"join-date-value\"]/@data-value')\n            if join_at_text:\n                self.join_at = StringUtils.unify_datetime_str(join_at_text[0].strip())\n            else:\n                join_at_text = html.xpath(\n                    '//div[contains(@class, \"box_userinfo_stats\")]//li[contains(text(), \"加入时间\")]/span/text()')\n                if join_at_text:\n                    self.join_at = StringUtils.unify_datetime_str(join_at_text[0].strip())\n        finally:\n            if html is not None:\n                del html\n\n    def _parse_user_torrent_seeding_info(self, html_text: str, multi_page: Optional[bool] = False) -> Optional[str]:\n        \"\"\"\n        做种相关信息\n        :param html_text:\n        :param multi_page: 是否多页数据\n        :return: 下页地址\n        \"\"\"\n        html = etree.HTML(html_text)\n        try:\n            if not StringUtils.is_valid_html_element(html):\n                return None\n\n            size_col = 3\n            # 搜索size列\n            if html.xpath('//table[contains(@id, \"torrent\")]//tr[1]/td'):\n                size_col = len(html.xpath('//table[contains(@id, \"torrent\")]//tr[1]/td')) - 3\n            # 搜索seeders列\n            seeders_col = size_col + 2\n\n            page_seeding = 0\n            page_seeding_size = 0\n            page_seeding_info = []\n            seeding_sizes = html.xpath(f'//table[contains(@id, \"torrent\")]//tr[position()>1]/td[{size_col}]')\n            seeding_seeders = html.xpath(f'//table[contains(@id, \"torrent\")]//tr[position()>1]/td[{seeders_col}]/text()')\n            if seeding_sizes and seeding_seeders:\n                page_seeding = len(seeding_sizes)\n\n                for i in range(0, len(seeding_sizes)):\n                    size = StringUtils.num_filesize(seeding_sizes[i].xpath(\"string(.)\").strip())\n                    seeders = int(seeding_seeders[i])\n\n                    page_seeding_size += size\n                    page_seeding_info.append([seeders, size])\n\n            if multi_page:\n                self.seeding += page_seeding\n                self.seeding_size += page_seeding_size\n                self.seeding_info.extend(page_seeding_info)\n            else:\n                if not self.seeding:\n                    self.seeding = page_seeding\n                if not self.seeding_size:\n                    self.seeding_size = page_seeding_size\n                if not self.seeding_info:\n                    self.seeding_info = page_seeding_info\n\n            # 是否存在下页数据\n            next_page = None\n            next_page_text = html.xpath('//a[contains(.//text(), \"Next\") or contains(.//text(), \"下一页\") or contains(@title, \"下一页\") or contains(@title, \"Next\")]/@href')\n            if next_page_text:\n                next_page = next_page_text[-1].strip()\n        finally:\n            if html is not None:\n                del html\n\n        return next_page\n\n    def _parse_user_traffic_info(self, html_text: str):\n        pass\n\n    def _parse_message_unread_links(self, html_text: str, msg_links: list) -> Optional[str]:\n        return None\n\n    def _parse_message_content(self, html_text):\n        return None, None, None\n"
  },
  {
    "path": "app/modules/indexer/parser/hddolby.py",
    "content": "# -*- coding: utf-8 -*-\nimport json\nfrom typing import Optional, Tuple\n\nfrom app.modules.indexer.parser import SiteParserBase, SiteSchema\nfrom app.utils.string import StringUtils\n\n\nclass HDDolbySiteUserInfo(SiteParserBase):\n    schema = SiteSchema.HDDolby\n    request_mode = \"apikey\"\n\n    # 用户级别字典\n    HDDolby_sysRoleList = {\n        \"0\": \"Peasant\",\n        \"1\": \"User\",\n        \"2\": \"Power User\",\n        \"3\": \"Elite User\",\n        \"4\": \"Crazy User\",\n        \"5\": \"Insane User\",\n        \"6\": \"Veteran User\",\n        \"7\": \"Extreme User\",\n        \"8\": \"Ultimate User\",\n        \"9\": \"Nexus Master\",\n        \"10\": \"VIP\",\n        \"11\": \"Retiree\",\n        \"12\": \"Helper\",\n        \"13\": \"Seeder\",\n        \"14\": \"Transferrer\",\n        \"15\": \"Uploader\",\n        \"16\": \"Torrent Manager\",\n        \"17\": \"Forum Moderator\",\n        \"18\": \"Coder\",\n        \"19\": \"Moderator\",\n        \"20\": \"Administrator\",\n        \"21\": \"Sysop\",\n        \"22\": \"Staff Leader\",\n    }\n\n    def _parse_site_page(self, html_text: str):\n        \"\"\"\n        获取站点页面地址\n        \"\"\"\n        # 更换api地址\n        self._base_url = f\"https://api.{StringUtils.get_url_domain(self._base_url)}\"\n        self._user_traffic_page = None\n        self._user_detail_page = None\n        self._user_basic_page = \"api/v1/user/data\"\n        self._user_basic_params = {}\n        self._user_basic_headers = {\n            \"Content-Type\": \"application/json\",\n            \"Accept\": \"application/json, text/plain, */*\"\n        }\n        self._sys_mail_unread_page = None\n        self._user_mail_unread_page = None\n        self._mail_unread_params = {}\n        self._torrent_seeding_page = \"api/v1/user/peers\"\n        self._torrent_seeding_params = {}\n        self._torrent_seeding_headers = {\n            \"Content-Type\": \"application/json\",\n            \"Accept\": \"application/json, text/plain, */*\"\n        }\n        self._addition_headers = {\n            \"x-api-key\": self.apikey,\n        }\n\n    def _parse_logged_in(self, html_text):\n        \"\"\"\n        判断是否登录成功, 通过判断是否存在用户信息\n        暂时跳过检测，待后续优化\n        :param html_text:\n        :return:\n        \"\"\"\n        return True\n\n    def _parse_user_base_info(self, html_text: str):\n        \"\"\"\n        解析用户基本信息，这里把_parse_user_traffic_info和_parse_user_detail_info合并到这里\n        \"\"\"\n        if not html_text:\n            return None\n        detail = json.loads(html_text)\n        if not detail or detail.get(\"status\") != 0:\n            return\n        user_infos = detail.get(\"data\")\n        \"\"\"\n        {\n            \"id\": \"1\",\n            \"added\": \"2019-03-03 15:30:36\",\n            \"last_access\": \"2025-02-18 19:48:04\",\n            \"class\": \"22\",\n            \"uploaded\": \"852071699418375\",\n            \"downloaded\": \"1885536536176\",\n            \"seedbonus\": \"99774808.0\",\n            \"sebonus\": \"3739023.7\",\n            \"unread_messages\": \"0\",\n        }\n        \"\"\"\n        if not user_infos:\n            return\n        user_info = user_infos[0]\n        self.userid = user_info.get(\"id\")\n        self.username = user_info.get(\"username\")\n        self.user_level = self.HDDolby_sysRoleList.get(user_info.get(\"class\") or \"1\")\n        self.join_at = user_info.get(\"added\")\n        self.upload = int(user_info.get(\"uploaded\") or '0')\n        self.download = int(user_info.get(\"downloaded\") or '0')\n        self.ratio = round(self.upload / self.download, 2) if self.download else 0\n        self.bonus = float(user_info.get(\"seedbonus\") or \"0\")\n        self.message_unread = int(user_info.get(\"unread_messages\") or '0')\n\n    def _parse_user_traffic_info(self, html_text: str):\n        \"\"\"\n        解析用户流量信息\n        \"\"\"\n        pass\n\n    def _parse_user_detail_info(self, html_text: str):\n        \"\"\"\n        解析用户详细信息\n        \"\"\"\n        pass\n\n    def _parse_user_torrent_seeding_info(self, html_text: str, multi_page: Optional[bool] = False) -> Optional[str]:\n        \"\"\"\n        解析用户做种信息\n        \"\"\"\n        if not html_text:\n            return None\n        seeding_info = json.loads(html_text)\n        if not seeding_info or seeding_info.get(\"status\") != 0:\n            return None\n        torrents = seeding_info.get(\"data\", [])\n        page_seeding_size = 0\n        page_seeding_info = []\n        for info in torrents:\n            size = info.get(\"size\")\n            seeder = info.get(\"seeders\") or 1\n            page_seeding_size += size\n            page_seeding_info.append([seeder, size])\n        self.seeding += len(torrents)\n        self.seeding_size += page_seeding_size\n        self.seeding_info.extend(page_seeding_info)\n\n        return None\n\n    def _parse_message_unread_links(self, html_text: str, msg_links: list) -> Optional[str]:\n        \"\"\"\n        解析未读消息链接，这里直接读出详情\n        \"\"\"\n        pass\n\n    def _parse_message_content(self, html_text) -> Tuple[Optional[str], Optional[str], Optional[str]]:\n        \"\"\"\n        解析消息内容\n        \"\"\"\n        pass\n"
  },
  {
    "path": "app/modules/indexer/parser/ipt_project.py",
    "content": "# -*- coding: utf-8 -*-\nimport re\nfrom typing import Optional\n\nfrom lxml import etree\n\nfrom app.modules.indexer.parser import SiteParserBase, SiteSchema\nfrom app.utils.string import StringUtils\n\n\nclass IptSiteUserInfo(SiteParserBase):\n    schema = SiteSchema.Ipt\n\n    def _parse_user_base_info(self, html_text: str):\n        html_text = self._prepare_html_text(html_text)\n        html = etree.HTML(html_text)\n        try:\n            tmps = html.xpath('//a[contains(@href, \"/u/\")]//text()')\n            tmps_id = html.xpath('//a[contains(@href, \"/u/\")]/@href')\n            if tmps:\n                self.username = str(tmps[-1])\n            if tmps_id:\n                user_id_match = re.search(r\"/u/(\\d+)\", tmps_id[0])\n                if user_id_match and user_id_match.group().strip():\n                    self.userid = user_id_match.group(1)\n                    self._user_detail_page = f\"user.php?u={self.userid}\"\n                    self._torrent_seeding_page = f\"peers?u={self.userid}\"\n\n            tmps = html.xpath('//div[@class = \"stats\"]/div/div')\n            if tmps:\n                self.upload = StringUtils.num_filesize(str(tmps[0].xpath('span/text()')[1]).strip())\n                self.download = StringUtils.num_filesize(str(tmps[0].xpath('span/text()')[2]).strip())\n                self.seeding = StringUtils.str_int(tmps[0].xpath('a')[2].xpath('text()')[0])\n                self.leeching = StringUtils.str_int(tmps[0].xpath('a')[2].xpath('text()')[1])\n                self.ratio = StringUtils.str_float(str(tmps[0].xpath('span/text()')[0]).strip().replace('-', '0'))\n                self.bonus = StringUtils.str_float(tmps[0].xpath('a')[3].xpath('text()')[0])\n        finally:\n            if html is not None:\n                del html\n\n    def _parse_site_page(self, html_text: str):\n        pass\n\n    def _parse_user_detail_info(self, html_text: str):\n        html = etree.HTML(html_text)\n        try:\n            if not StringUtils.is_valid_html_element(html):\n                return\n\n            user_levels_text = html.xpath('//tr/th[text()=\"Class\"]/following-sibling::td[1]/text()')\n            if user_levels_text:\n                self.user_level = user_levels_text[0].strip()\n\n            # 加入日期\n            join_at_text = html.xpath('//tr/th[text()=\"Join date\"]/following-sibling::td[1]/text()')\n            if join_at_text:\n                self.join_at = StringUtils.unify_datetime_str(join_at_text[0].split(' (')[0])\n        finally:\n            if html is not None:\n                del html\n\n    def _parse_user_torrent_seeding_info(self, html_text: str, multi_page: bool = False) -> Optional[str]:\n        html = etree.HTML(html_text)\n        try:\n            if not StringUtils.is_valid_html_element(html):\n                return None\n            # seeding start\n            seeding_end_pos = 3\n            if html.xpath('//tr/td[text() = \"Leechers\"]'):\n                seeding_end_pos = len(html.xpath('//tr/td[text() = \"Leechers\"]/../preceding-sibling::tr')) + 1\n                seeding_end_pos = seeding_end_pos - 3\n\n            page_seeding = 0\n            page_seeding_size = 0\n            seeding_torrents = html.xpath('//tr/td[text() = \"Seeders\"]/../following-sibling::tr/td[position()=6]/text()')\n            if seeding_torrents:\n                page_seeding = seeding_end_pos\n                for per_size in seeding_torrents[:seeding_end_pos]:\n                    if '(' in per_size and ')' in per_size:\n                        per_size = per_size.split('(')[-1]\n                        per_size = per_size.split(')')[0]\n\n                    page_seeding_size += StringUtils.num_filesize(per_size)\n\n            self.seeding = page_seeding\n            self.seeding_size = page_seeding_size\n        finally:\n            if html is not None:\n                del html\n\n    def _parse_user_traffic_info(self, html_text: str):\n        pass\n\n    def _parse_message_unread_links(self, html_text: str, msg_links: list) -> Optional[str]:\n        return None\n\n    def _parse_message_content(self, html_text):\n        return None, None, None\n"
  },
  {
    "path": "app/modules/indexer/parser/mtorrent.py",
    "content": "# -*- coding: utf-8 -*-\nimport json\nfrom typing import Optional, Tuple\nfrom urllib.parse import urljoin\n\nfrom app.log import logger\nfrom app.modules.indexer.parser import SiteParserBase, SiteSchema\nfrom app.utils.string import StringUtils\n\n\nclass MTorrentSiteUserInfo(SiteParserBase):\n    schema = SiteSchema.MTorrent\n    request_mode = \"apikey\"\n\n    # 用户级别字典\n    MTeam_sysRoleList = {\n        \"1\": \"User\",\n        \"2\": \"Power User\",\n        \"3\": \"Elite User\",\n        \"4\": \"Crazy User\",\n        \"5\": \"Insane User\",\n        \"6\": \"Veteran User\",\n        \"7\": \"Extreme User\",\n        \"8\": \"Ultimate User\",\n        \"9\": \"Nexus Master\",\n        \"10\": \"VIP\",\n        \"11\": \"Retiree\",\n        \"12\": \"Uploader\",\n        \"13\": \"Moderator\",\n        \"14\": \"Administrator\",\n        \"15\": \"Sysop\",\n        \"16\": \"Staff\",\n        \"17\": \"Offer memberStaff\",\n        \"18\": \"Bet memberStaff\",\n    }\n\n    def _parse_site_page(self, html_text: str):\n        \"\"\"\n        获取站点页面地址\n        \"\"\"\n        # 更换api地址\n        self._base_url = f\"https://api.{StringUtils.get_url_domain(self._base_url)}\"\n        self._user_traffic_page = None\n        self._user_detail_page = None\n        self._user_basic_page = \"api/member/profile\"\n        self._user_basic_params = {\n            \"uid\": self.userid\n        }\n        self._sys_mail_unread_page = None\n        self._user_mail_unread_page = \"api/msg/search\"\n        self._mail_unread_params = {\n            \"keyword\": \"\",\n            \"box\": \"-2\",\n            \"type\": \"pageNumber\",\n            \"pageSize\": 100\n        }\n        self._torrent_seeding_page = \"api/member/getUserTorrentList\"\n        self._torrent_seeding_headers = {\n            \"Content-Type\": \"application/json\",\n            \"Accept\": \"application/json, text/plain, */*\"\n        }\n        self._addition_headers = {\n            \"x-api-key\": self.apikey,\n        }\n\n    def _parse_logged_in(self, html_text):\n        \"\"\"\n        判断是否登录成功, 通过判断是否存在用户信息\n        暂时跳过检测，待后续优化\n        :param html_text:\n        :return:\n        \"\"\"\n        return True\n\n    def _parse_user_base_info(self, html_text: str):\n        \"\"\"\n        解析用户基本信息，这里把_parse_user_traffic_info和_parse_user_detail_info合并到这里\n        \"\"\"\n        if not html_text:\n            return None\n        detail = json.loads(html_text)\n        if not detail or detail.get(\"code\") != \"0\":\n            return\n        user_info = detail.get(\"data\", {})\n        self.userid = user_info.get(\"id\")\n        self.username = user_info.get(\"username\")\n        self.user_level = self.MTeam_sysRoleList.get(user_info.get(\"role\") or \"1\")\n        self.join_at = user_info.get(\"memberStatus\", {}).get(\"createdDate\")\n\n        self.upload = int(user_info.get(\"memberCount\", {}).get(\"uploaded\") or '0')\n        self.download = int(user_info.get(\"memberCount\", {}).get(\"downloaded\") or '0')\n        self.ratio = user_info.get(\"memberCount\", {}).get(\"shareRate\") or 0\n        self.bonus = user_info.get(\"memberCount\", {}).get(\"bonus\") or 0\n        self.message_read_force = True\n        self._torrent_seeding_params = {\n            \"pageNumber\": 1,\n            \"pageSize\": 200,\n            \"type\": \"SEEDING\",\n            \"userid\": self.userid\n        }\n\n    def _parse_user_traffic_info(self, html_text: str):\n        \"\"\"\n        解析用户流量信息\n        \"\"\"\n        pass\n\n    def _parse_user_detail_info(self, html_text: str):\n        \"\"\"\n        解析用户详细信息\n        \"\"\"\n        pass\n\n    def _parse_user_torrent_seeding_info(self, html_text: str, multi_page: Optional[bool] = False) -> Optional[str]:\n        \"\"\"\n        解析用户做种信息\n        \"\"\"\n        if not html_text:\n            return None\n        seeding_info = json.loads(html_text)\n        if not seeding_info or seeding_info.get(\"code\") != \"0\":\n            return None\n        torrents = seeding_info.get(\"data\", {}).get(\"data\", [])\n        page_seeding_size = 0\n        page_seeding_info = []\n        for info in torrents:\n            torrent = info.get(\"torrent\", {})\n            size = int(torrent.get(\"size\") or '0')\n            seeders = int(torrent.get(\"source\") or '0')\n            page_seeding_size += size\n            page_seeding_info.append([seeders, size])\n        self.seeding += len(torrents)\n        self.seeding_size += page_seeding_size\n        self.seeding_info.extend(page_seeding_info)\n\n        # 查询总做种数\n        seeder_count = 0\n        try:\n            result = self._get_page_content(\n                url=urljoin(self._base_url, \"api/tracker/myPeerStatus\"),\n                params={\"uid\": self.userid},\n            )\n            if result:\n                seeder_info = json.loads(result)\n                seeder_count = int(seeder_info.get(\"data\", {}).get(\"seeder\") or 0)\n        except Exception as e:\n            logger.error(f\"获取做种数失败: {str(e)}\")\n        if not seeder_count:\n            return None\n        if self.seeding >= seeder_count:\n            return None\n        # 还有下一页\n        self._torrent_seeding_params[\"pageNumber\"] += 1\n        return \"\"\n\n    def _parse_message_unread_links(self, html_text: str, msg_links: list) -> Optional[str]:\n        \"\"\"\n        解析未读消息链接，这里直接读出详情\n        \"\"\"\n        if not html_text:\n            return None\n        messages_info = json.loads(html_text)\n        if not messages_info or messages_info.get(\"code\") != \"0\":\n            return None\n        messages = messages_info.get(\"data\", {}).get(\"data\", [])\n        for message in messages:\n            if not message.get(\"unread\"):\n                continue\n            head = message.get(\"title\")\n            date = message.get(\"createdDate\")\n            content = message.get(\"context\")\n            if head and date and content:\n                self.message_unread_contents.append((head, date, content))\n                # 设置已读\n                self._get_page_content(\n                    url=urljoin(self._base_url, f\"api/msg/markRead\"),\n                    params={\"msgId\": message.get(\"id\")}\n                )\n        # 是否存在下页数据\n        return None\n\n    def _parse_message_content(self, html_text) -> Tuple[Optional[str], Optional[str], Optional[str]]:\n        \"\"\"\n        解析消息内容\n        \"\"\"\n        pass\n"
  },
  {
    "path": "app/modules/indexer/parser/nexus_audiences.py",
    "content": "# -*- coding: utf-8 -*-\nfrom urllib.parse import urljoin\n\nfrom lxml import etree\n\nfrom app.modules.indexer.parser import SiteSchema\nfrom app.modules.indexer.parser.nexus_php import NexusPhpSiteUserInfo\nfrom app.utils.string import StringUtils\n\n\nclass NexusAudiencesSiteUserInfo(NexusPhpSiteUserInfo):\n    schema = SiteSchema.NexusAudiences\n\n    def _parse_seeding_pages(self):\n        if not self._torrent_seeding_page:\n            return\n        self._torrent_seeding_headers = {\"Referer\": urljoin(self._base_url, self._user_detail_page)}\n        html_text = self._get_page_content(\n            url=urljoin(self._base_url, self._torrent_seeding_page),\n            params=self._torrent_seeding_params,\n            headers=self._torrent_seeding_headers\n        )\n        if not html_text:\n            return\n        html = etree.HTML(html_text)\n        try:\n            if not StringUtils.is_valid_html_element(html):\n                return\n            total_row = html.xpath('//table[@class=\"table table-bordered\"]//tr[td[1][normalize-space()=\"Total\"]]')\n            if not total_row:\n                return\n            seeding_count = total_row[0].xpath('./td[2]/text()')\n            seeding_size = total_row[0].xpath('./td[3]/text()')\n            self.seeding = StringUtils.str_int(seeding_count[0]) if seeding_count else 0\n            self.seeding_size = StringUtils.num_filesize(seeding_size[0].strip()) if seeding_size else 0\n        finally:\n            if html is not None:\n                del html\n"
  },
  {
    "path": "app/modules/indexer/parser/nexus_hhanclub.py",
    "content": "# -*- coding: utf-8 -*-\nimport re\n\nfrom lxml import etree\n\nfrom app.modules.indexer.parser import SiteSchema\nfrom app.modules.indexer.parser.nexus_php import NexusPhpSiteUserInfo\nfrom app.utils.string import StringUtils\n\n\nclass NexusHhanclubSiteUserInfo(NexusPhpSiteUserInfo):\n    schema = SiteSchema.NexusHhanclub\n\n    def _parse_user_traffic_info(self, html_text):\n        super()._parse_user_traffic_info(html_text)\n\n        html_text = self._prepare_html_text(html_text)\n        html = etree.HTML(html_text)\n\n        try:\n            # 上传、下载、分享率\n            upload_match = re.search(r\"[_<>/a-zA-Z-=\\\"'\\s#;]+([\\d,.\\s]+[KMGTPI]*B)\",\n                                     html.xpath('//*[@id=\"user-info-panel\"]/div[2]/div[2]/div[4]/text()')[0])\n            download_match = re.search(r\"[_<>/a-zA-Z-=\\\"'\\s#;]+([\\d,.\\s]+[KMGTPI]*B)\",\n                                       html.xpath('//*[@id=\"user-info-panel\"]/div[2]/div[2]/div[5]/text()')[0])\n            ratio_match = re.search(r\"分享率][:：_<>/a-zA-Z-=\\\"'\\s#;]+([\\d,.\\s]+)\",\n                                    html.xpath('//*[@id=\"user-info-panel\"]/div[2]/div[1]/div[1]/div/text()')[0])\n\n            # 计算分享率\n            self.upload = StringUtils.num_filesize(upload_match.group(1).strip()) if upload_match else 0\n            self.download = StringUtils.num_filesize(download_match.group(1).strip()) if download_match else 0\n            # 优先使用页面上的分享率\n            calc_ratio = 0.0 if self.download <= 0.0 else round(self.upload / self.download, 3)\n            self.ratio = StringUtils.str_float(ratio_match.group(1)) if (\n                    ratio_match and ratio_match.group(1).strip()) else calc_ratio\n        finally:\n            if html is not None:\n                del html\n\n    def _parse_user_detail_info(self, html_text: str):\n        \"\"\"\n        解析用户额外信息，加入时间，等级\n        :param html_text:\n        :return:\n        \"\"\"\n        super()._parse_user_detail_info(html_text)\n\n        html = etree.HTML(html_text)\n        try:\n            if not StringUtils.is_valid_html_element(html):\n                return\n            # 加入时间\n            join_at_text = html.xpath('//span[contains(text(), \"加入日期\")]/following-sibling::span/span/@title')\n            if join_at_text:\n                self.join_at = StringUtils.unify_datetime_str(join_at_text[0].strip())\n        finally:\n            if html is not None:\n                del html\n\n    def _get_user_level(self, html):\n        super()._get_user_level(html)\n        user_level_path = html.xpath('//b[contains(@class, \"_Name\")]/text()')\n        if user_level_path:\n            self.user_level = user_level_path[0]\n"
  },
  {
    "path": "app/modules/indexer/parser/nexus_php.py",
    "content": "# -*- coding: utf-8 -*-\nimport re\nfrom typing import Optional\n\nfrom lxml import etree\n\nfrom app.log import logger\nfrom app.modules.indexer.parser import SiteParserBase, SiteSchema\nfrom app.utils.string import StringUtils\n\n\nclass NexusPhpSiteUserInfo(SiteParserBase):\n    schema = SiteSchema.NexusPhp\n\n    def _parse_site_page(self, html_text: str):\n        html_text = self._prepare_html_text(html_text)\n\n        user_detail = re.search(r\"userdetails.php\\?id=(\\d+)\", html_text)\n        if user_detail and user_detail.group().strip():\n            self._user_detail_page = user_detail.group().strip().lstrip('/')\n            self.userid = user_detail.group(1)\n            self._torrent_seeding_page = f\"getusertorrentlistajax.php?userid={self.userid}&type=seeding\"\n        else:\n            user_detail = re.search(r\"(userdetails)\", html_text)\n            if user_detail and user_detail.group().strip():\n                self._user_detail_page = user_detail.group().strip().lstrip('/')\n                self.userid = None\n                self._torrent_seeding_page = None\n\n    def _parse_message_unread(self, html_text):\n        \"\"\"\n        解析未读短消息数量\n        :param html_text:\n        :return:\n        \"\"\"\n        html = etree.HTML(html_text)\n        try:\n            if not StringUtils.is_valid_html_element(html):\n                return\n\n            message_labels = html.xpath('//a[@href=\"messages.php\"]/..')\n            message_labels.extend(html.xpath('//a[contains(@href, \"messages.php\")]/..'))\n            if message_labels:\n                message_text = message_labels[0].xpath(\"string(.)\")\n\n                logger.debug(f\"{self._site_name} 消息原始信息 {message_text}\")\n                message_unread_match = re.findall(r\"[^Date](信息箱\\s*|\\((?![^)]*:)|你有\\xa0)(\\d+)\", message_text)\n\n                if message_unread_match and len(message_unread_match[-1]) == 2:\n                    self.message_unread = StringUtils.str_int(message_unread_match[-1][1])\n                elif message_text.isdigit():\n                    self.message_unread = StringUtils.str_int(message_text)\n        finally:\n            if html is not None:\n                del html\n\n    def _parse_user_base_info(self, html_text: str):\n        \"\"\"\n        解析用户基本信息\n        \"\"\"\n        # 合并解析，减少额外请求调用\n        self._parse_user_traffic_info(html_text)\n        self._user_traffic_page = None\n\n        self._parse_message_unread(html_text)\n\n        html = etree.HTML(html_text)\n        try:\n            if not StringUtils.is_valid_html_element(html):\n                return\n\n            ret = html.xpath(f'//a[contains(@href, \"userdetails\") and contains(@href, \"{self.userid}\")]//b//text()')\n            if ret:\n                self.username = str(ret[0])\n                return\n            ret = html.xpath(f'//a[contains(@href, \"userdetails\") and contains(@href, \"{self.userid}\")]//text()')\n            if ret:\n                self.username = str(ret[0])\n\n            ret = html.xpath('//a[contains(@href, \"userdetails\")]//strong//text()')\n        finally:\n            if html is not None:\n                del html\n\n        if ret:\n            self.username = str(ret[0])\n            return\n\n    def _parse_user_traffic_info(self, html_text):\n        \"\"\"\n        解析用户流量信息\n        \"\"\"\n        html_text = self._prepare_html_text(html_text)\n        upload_match = re.search(r\"[^总]上[传傳]量?[:：_<>/a-zA-Z-=\\\"'\\s#;]+([\\d,.\\s]+[KMGTPI]*B)\", html_text,\n                                 re.IGNORECASE)\n        self.upload = StringUtils.num_filesize(upload_match.group(1).strip()) if upload_match else 0\n        download_match = re.search(r\"[^总子影力]下[载載]量?[:：_<>/a-zA-Z-=\\\"'\\s#;]+([\\d,.\\s]+[KMGTPI]*B)\", html_text,\n                                   re.IGNORECASE)\n        self.download = StringUtils.num_filesize(download_match.group(1).strip()) if download_match else 0\n        ratio_match = re.search(r\"分享率[:：_<>/a-zA-Z-=\\\"'\\s#;]+([\\d,.\\s]+)\", html_text)\n        # 计算分享率\n        calc_ratio = 0.0 if self.download <= 0.0 else round(self.upload / self.download, 3)\n        # 优先使用页面上的分享率\n        self.ratio = StringUtils.str_float(ratio_match.group(1)) if (\n                ratio_match and ratio_match.group(1).strip()) else calc_ratio\n        leeching_match = re.search(r\"(Torrents leeching|下载中)[\\u4E00-\\u9FA5\\D\\s]+(\\d+)[\\s\\S]+<\", html_text)\n        self.leeching = StringUtils.str_int(leeching_match.group(2)) if leeching_match and leeching_match.group(\n            2).strip() else 0\n        html = etree.HTML(html_text)\n        try:\n            has_ucoin, self.bonus = self._parse_ucoin(html)\n            if has_ucoin:\n                return\n            tmps = html.xpath('//a[contains(@href,\"mybonus\")]/text()') if html else None\n            if tmps:\n                bonus_text = str(tmps[0]).strip()\n                bonus_match = re.search(r\"([\\d,.]+)\", bonus_text)\n                if bonus_match and bonus_match.group(1).strip():\n                    self.bonus = StringUtils.str_float(bonus_match.group(1))\n                    return\n            bonus_match = re.search(r\"mybonus.[\\[\\]:：<>/a-zA-Z_\\-=\\\"'\\s#;.(使用魔力值豆]+\\s*([\\d,.]+)[<()&\\s]\", html_text)\n            try:\n                if bonus_match and bonus_match.group(1).strip():\n                    self.bonus = StringUtils.str_float(bonus_match.group(1))\n                    return\n                bonus_match = re.search(r\"[魔力值|\\]][\\[\\]:：<>/a-zA-Z_\\-=\\\"'\\s#;]+\\s*([\\d,.]+|\\\"[\\d,.]+\\\")[<>()&\\s]\",\n                                        html_text,\n                                        flags=re.S)\n                if bonus_match and bonus_match.group(1).strip():\n                    self.bonus = StringUtils.str_float(bonus_match.group(1).strip('\"'))\n            except Exception as err:\n                logger.error(f\"{self._site_name} 解析魔力值出错, 错误信息: {str(err)}\")\n        finally:\n            if html is not None:\n                del html\n\n    @staticmethod\n    def _parse_ucoin(html):\n        \"\"\"\n        解析ucoin, 统一转换为铜币\n        :param html:\n        :return:\n        \"\"\"\n        if StringUtils.is_valid_html_element(html):\n            gold, silver, copper = None, None, None\n\n            golds = html.xpath('//span[@class = \"ucoin-symbol ucoin-gold\"]//text()')\n            if golds:\n                gold = StringUtils.str_float(str(golds[-1]))\n            silvers = html.xpath('//span[@class = \"ucoin-symbol ucoin-silver\"]//text()')\n            if silvers:\n                silver = StringUtils.str_float(str(silvers[-1]))\n            coppers = html.xpath('//span[@class = \"ucoin-symbol ucoin-copper\"]//text()')\n            if coppers:\n                copper = StringUtils.str_float(str(coppers[-1]))\n            if gold or silver or copper:\n                gold = gold if gold else 0\n                silver = silver if silver else 0\n                copper = copper if copper else 0\n                return True, gold * 100 * 100 + silver * 100 + copper\n        return False, 0.0\n\n    def _parse_user_torrent_seeding_info(self, html_text: str, multi_page: Optional[bool] = False) -> Optional[str]:\n        \"\"\"\n        做种相关信息\n        :param html_text:\n        :param multi_page: 是否多页数据\n        :return: 下页地址\n        \"\"\"\n        html = etree.HTML(str(html_text).replace(r'\\/', '/'))\n        try:\n            if not StringUtils.is_valid_html_element(html):\n                return None\n\n            # 首页存在扩展链接，使用扩展链接\n            seeding_url_text = html.xpath('//a[contains(@href,\"torrents.php\") '\n                                          'and contains(@href,\"seeding\")]/@href')\n            if multi_page is False and seeding_url_text and seeding_url_text[0].strip():\n                self._torrent_seeding_page = seeding_url_text[0].strip()\n                return self._torrent_seeding_page\n\n            size_col = 3\n            seeders_col = 4\n            # 搜索size列\n            size_col_xpath = '//tr[position()=1]/' \\\n                             'td[(img[@class=\"size\"] and img[@alt=\"size\"])' \\\n                             ' or (text() = \"大小\")' \\\n                             ' or (a/img[@class=\"size\" and @alt=\"size\"])]'\n            if html.xpath(size_col_xpath):\n                size_col = len(html.xpath(f'{size_col_xpath}/preceding-sibling::td')) + 1\n            # 搜索seeders列\n            seeders_col_xpath = '//tr[position()=1]/' \\\n                                'td[(img[@class=\"seeders\"] and img[@alt=\"seeders\"])' \\\n                                ' or (text() = \"在做种\")' \\\n                                ' or (a/img[@class=\"seeders\" and @alt=\"seeders\"])]'\n            if html.xpath(seeders_col_xpath):\n                seeders_col = len(html.xpath(f'{seeders_col_xpath}/preceding-sibling::td')) + 1\n\n            page_seeding = 0\n            page_seeding_size = 0\n            page_seeding_info = []\n            # 如果 table class=\"torrents\"，则增加table[@class=\"torrents\"]\n            table_class = '//table[@class=\"torrents\"]' if html.xpath('//table[@class=\"torrents\"]') else ''\n            seeding_sizes = html.xpath(f'{table_class}//tr[position()>1]/td[{size_col}]')\n            seeding_seeders = html.xpath(f'{table_class}//tr[position()>1]/td[{seeders_col}]/b/a/text()')\n            if not seeding_seeders:\n                seeding_seeders = html.xpath(f'{table_class}//tr[position()>1]/td[{seeders_col}]//text()')\n            if seeding_sizes and seeding_seeders:\n                page_seeding = len(seeding_sizes)\n\n                for i in range(0, len(seeding_sizes)):\n                    size = StringUtils.num_filesize(seeding_sizes[i].xpath(\"string(.)\").strip())\n                    seeders = StringUtils.str_int(seeding_seeders[i])\n\n                    page_seeding_size += size\n                    page_seeding_info.append([seeders, size])\n\n            self.seeding += page_seeding\n            self.seeding_size += page_seeding_size\n            self.seeding_info.extend(page_seeding_info)\n\n            # 是否存在下页数据\n            next_page = None\n            next_page_text = html.xpath(\n                '//a[contains(.//text(), \"下一页\") or contains(.//text(), \"下一頁\") or contains(.//text(), \">\")]/@href')\n\n            # 防止识别到详情页\n            while next_page_text:\n                next_page = next_page_text.pop().strip()\n                if not next_page.startswith('details.php'):\n                    break\n                next_page = None\n\n            # fix up page url\n            if next_page:\n                if self.userid not in next_page:\n                    next_page = f'{next_page}&userid={self.userid}&type=seeding'\n        finally:\n            if html is not None:\n                del html\n\n        return next_page\n\n    def _parse_user_detail_info(self, html_text: str):\n        \"\"\"\n        解析用户额外信息，加入时间，等级\n        :param html_text:\n        :return:\n        \"\"\"\n        html = etree.HTML(html_text)\n        try:\n            if not StringUtils.is_valid_html_element(html):\n                return\n\n            self._get_user_level(html)\n\n            self._fixup_traffic_info(html)\n\n            # 加入日期\n            join_at_text = html.xpath(\n                '//tr/td[text()=\"加入日期\" or text()=\"注册日期\" or *[text()=\"加入日期\"]]/following-sibling::td[1]//text()'\n                '|//div/b[text()=\"加入日期\"]/../text()')\n            if join_at_text:\n                self.join_at = StringUtils.unify_datetime_str(join_at_text[0].split(' (')[0].strip())\n\n            # 做种体积 & 做种数\n            # seeding 页面获取不到的话，此处再获取一次\n            seeding_sizes = html.xpath('//tr/td[text()=\"当前上传\"]/following-sibling::td[1]//'\n                                       'table[tr[1][td[4 and text()=\"尺寸\"]]]//tr[position()>1]/td[4]')\n            seeding_seeders = html.xpath('//tr/td[text()=\"当前上传\"]/following-sibling::td[1]//'\n                                         'table[tr[1][td[5 and text()=\"做种者\"]]]//tr[position()>1]/td[5]//text()')\n            tmp_seeding = len(seeding_sizes)\n            tmp_seeding_size = 0\n            tmp_seeding_info = []\n            for i in range(0, len(seeding_sizes)):\n                size = StringUtils.num_filesize(seeding_sizes[i].xpath(\"string(.)\").strip())\n                seeders = StringUtils.str_int(seeding_seeders[i])\n\n                tmp_seeding_size += size\n                tmp_seeding_info.append([seeders, size])\n\n            if not self.seeding_size:\n                self.seeding_size = tmp_seeding_size\n            if not self.seeding:\n                self.seeding = tmp_seeding\n            if not self.seeding_info:\n                self.seeding_info = tmp_seeding_info\n\n            seeding_sizes = html.xpath('//tr/td[text()=\"做种统计\"]/following-sibling::td[1]//text()')\n            if seeding_sizes:\n                seeding_match = re.search(r\"总做种数:\\s+(\\d+)\", seeding_sizes[0], re.IGNORECASE)\n                seeding_size_match = re.search(r\"总做种体积:\\s+([\\d,.\\s]+[KMGTPI]*B)\", seeding_sizes[0], re.IGNORECASE)\n                tmp_seeding = StringUtils.str_int(seeding_match.group(1)) if (\n                        seeding_match and seeding_match.group(1)) else 0\n                tmp_seeding_size = StringUtils.num_filesize(\n                    seeding_size_match.group(1).strip()) if seeding_size_match else 0\n            if not self.seeding_size:\n                self.seeding_size = tmp_seeding_size\n            if not self.seeding:\n                self.seeding = tmp_seeding\n\n            self._fixup_torrent_seeding_page(html)\n        finally:\n            if html is not None:\n                del html\n\n    def _fixup_torrent_seeding_page(self, html):\n        \"\"\"\n        修正种子页面链接\n        :param html:\n        :return:\n        \"\"\"\n        # 单独的种子页面\n        seeding_url_text = html.xpath('//a[contains(@href,\"getusertorrentlist.php\") '\n                                      'and contains(@href,\"seeding\")]/@href')\n        if seeding_url_text:\n            self._torrent_seeding_page = seeding_url_text[0].strip()\n        # 从JS调用种获取用户ID\n        seeding_url_text = html.xpath('//a[contains(@href, \"javascript: getusertorrentlistajax\") '\n                                      'and contains(@href,\"seeding\")]/@href')\n        csrf_text = html.xpath('//meta[@name=\"x-csrf\"]/@content')\n        if not self._torrent_seeding_page and seeding_url_text:\n            user_js = re.search(r\"javascript: getusertorrentlistajax\\(\\s*'(\\d+)\", seeding_url_text[0])\n            if user_js and user_js.group(1).strip():\n                self.userid = user_js.group(1).strip()\n                self._torrent_seeding_page = f\"getusertorrentlistajax.php?userid={self.userid}&type=seeding\"\n        elif seeding_url_text and csrf_text:\n            if csrf_text[0].strip():\n                self._torrent_seeding_page \\\n                    = f\"ajax_getusertorrentlist.php\"\n                self._torrent_seeding_params = {'userid': self.userid, 'type': 'seeding', 'csrf': csrf_text[0].strip()}\n\n        # 分类做种模式\n        # 临时屏蔽\n        # seeding_url_text = html.xpath('//tr/td[text()=\"当前做种\"]/following-sibling::td[1]'\n        #                              '/table//td/a[contains(@href,\"seeding\")]/@href')\n        # if seeding_url_text:\n        #    self._torrent_seeding_page = seeding_url_text\n\n    def _get_user_level(self, html):\n        # 等级 获取同一行等级数据，图片格式等级，取title信息，否则取文本信息\n        user_levels_text = html.xpath('//tr/td[text()=\"等級\" or text()=\"等级\" or *[text()=\"等级\"]]/'\n                                      'following-sibling::td[1]/img[1]/@title')\n        if user_levels_text:\n            self.user_level = user_levels_text[0].strip()\n            return\n\n        user_levels_text = html.xpath('//tr/td[text()=\"等級\" or text()=\"等级\"]/'\n                                      'following-sibling::td[1 and not(img)]'\n                                      '|//tr/td[text()=\"等級\" or text()=\"等级\"]/'\n                                      'following-sibling::td[1 and img[not(@title)]]')\n        if user_levels_text:\n            self.user_level = user_levels_text[0].xpath(\"string(.)\").strip()\n            return\n\n        user_levels_text = html.xpath('//tr/td[text()=\"等級\" or text()=\"等级\"]/'\n                                      'following-sibling::td[1]')\n        if user_levels_text:\n            self.user_level = user_levels_text[0].xpath(\"string(.)\").strip()\n            return\n\n        user_levels_text = html.xpath('//a[contains(@href, \"userdetails\")]/text()')\n        if not self.user_level and user_levels_text:\n            for user_level_text in user_levels_text:\n                user_level_match = re.search(r\"\\[(.*)]\", user_level_text)\n                if user_level_match and user_level_match.group(1).strip():\n                    self.user_level = user_level_match.group(1).strip()\n                    break\n\n    def _parse_message_unread_links(self, html_text: str, msg_links: list) -> Optional[str]:\n        html = etree.HTML(html_text)\n        try:\n            if not StringUtils.is_valid_html_element(html):\n                return None\n\n            message_links = html.xpath('//tr[not(./td/img[@alt=\"Read\"])]/td/a[contains(@href, \"viewmessage\")]/@href')\n            msg_links.extend(message_links)\n            # 是否存在下页数据\n            next_page = None\n            next_page_text = html.xpath('//a[contains(.//text(), \"下一页\") or contains(.//text(), \"下一頁\")]/@href')\n            if next_page_text:\n                next_page = next_page_text[-1].strip()\n        finally:\n            if html is not None:\n                del html\n\n        return next_page\n\n    def _parse_message_content(self, html_text):\n        html = etree.HTML(html_text)\n        try:\n            if not StringUtils.is_valid_html_element(html):\n                return None, None, None\n            # 标题\n            message_head_text = None\n            message_head = html.xpath('//h1/text()'\n                                      '|//div[@class=\"layui-card-header\"]/span[1]/text()')\n            if message_head:\n                message_head_text = message_head[-1].strip()\n\n            # 消息时间\n            message_date_text = None\n            message_date = html.xpath('//h1/following-sibling::table[.//tr/td[@class=\"colhead\"]]//tr[2]/td[2]'\n                                      '|//div[@class=\"layui-card-header\"]/span[2]/span[2]')\n            if message_date:\n                message_date_text = message_date[0].xpath(\"string(.)\").strip()\n\n            # 消息内容\n            message_content_text = None\n            message_content = html.xpath('//h1/following-sibling::table[.//tr/td[@class=\"colhead\"]]//tr[3]/td'\n                                         '|//div[contains(@class,\"layui-card-body\")]')\n            if message_content:\n                message_content_text = message_content[0].xpath(\"string(.)\").strip()\n        finally:\n            if html is not None:\n                del html\n\n        return message_head_text, message_date_text, message_content_text\n\n    def _fixup_traffic_info(self, html):\n        # fixup bonus\n        if not self.bonus:\n            bonus_text = html.xpath('//tr/td[text()=\"魔力值\" or text()=\"猫粮\"]/following-sibling::td[1]/text()')\n            if bonus_text:\n                self.bonus = StringUtils.str_float(bonus_text[0].strip())\n"
  },
  {
    "path": "app/modules/indexer/parser/nexus_project.py",
    "content": "# -*- coding: utf-8 -*-\nimport re\n\nfrom app.modules.indexer.parser import SiteSchema\nfrom app.modules.indexer.parser.nexus_php import NexusPhpSiteUserInfo\n\n\nclass NexusProjectSiteUserInfo(NexusPhpSiteUserInfo):\n    schema = SiteSchema.NexusProject\n\n    def _parse_site_page(self, html_text: str):\n        html_text = self._prepare_html_text(html_text)\n\n        user_detail = re.search(r\"userdetails.php\\?id=(\\d+)\", html_text)\n        if user_detail and user_detail.group().strip():\n            self._user_detail_page = user_detail.group().strip().lstrip('/')\n            self.userid = user_detail.group(1)\n\n        self._torrent_seeding_page = f\"viewusertorrents.php?id={self.userid}&show=seeding\"\n"
  },
  {
    "path": "app/modules/indexer/parser/nexus_rabbit.py",
    "content": "# -*- coding: utf-8 -*-\nimport re\nimport json\nfrom typing import Optional\nfrom lxml import etree\nfrom urllib.parse import urljoin\nfrom app.log import logger\nfrom app.modules.indexer.parser import SiteSchema\nfrom app.modules.indexer.parser import SiteParserBase\nfrom app.utils.string import StringUtils\n\n\nclass NexusRabbitSiteUserInfo(SiteParserBase):\n    schema = SiteSchema.NexusRabbit\n\n    def _parse_site_page(self, html_text: str):\n        html_text = self._prepare_html_text(html_text)\n\n        user_detail = re.search(r\"user.php\\?id=(\\d+)\", html_text)\n\n        if not (user_detail and user_detail.group().strip()):\n            return\n\n        self.userid = user_detail.group(1)\n        self._user_detail_page = f\"user.php?id={self.userid}\"\n\n        self._user_traffic_page = None\n\n        self._torrent_seeding_page = \"api/general\"\n        self._torrent_seeding_params = {\n            \"page\": 1,\n            \"limit\": 5000000,\n            \"action\": \"userTorrentsList\",\n            \"data\": {\"type\": \"seeding\", \"id\": int(self.userid)},\n        }\n        self._torrent_seeding_headers = {\n            \"Content-Type\": \"application/json\",\n            \"Accept\": \"application/json, text/plain, */*\",\n            \"X-Requested-With\": \"XMLHttpRequest\",  # 必须要加上这一条，不然返回的是空数据\n        }\n\n        self._user_mail_unread_page = None\n        self._sys_mail_unread_page = \"api/general\"\n        self._mail_unread_params = {\n            \"page\": 1,\n            \"limit\": 5000000,\n            \"action\": \"getMessageIn\",\n        }\n        self._mail_unread_headers = {\n            \"Content-Type\": \"application/json\",\n            \"Accept\": \"application/json, text/plain, */*\",\n            \"X-Requested-With\": \"XMLHttpRequest\",\n        }\n\n    def _parse_user_torrent_seeding_info(\n        self, html_text: str, multi_page: bool = False\n    ) -> Optional[str]:\n        \"\"\"\n        做种相关信息\n        :param html_text:\n        :param multi_page: 是否多页数据\n        :return: 下页地址\n        \"\"\"\n\n        try:\n            torrents = json.loads(html_text).get(\"data\", [])\n        except Exception as e:\n            logger.error(f\"解析做种信息失败: {str(e)}\")\n            return None\n\n        seeding_size = 0\n        seeding_info = []\n\n        for torrent in torrents:\n            seeders = int(torrent.get(\"seeders\", 0))\n            size = StringUtils.num_filesize(torrent.get(\"size\"))\n            seeding_size += size\n            seeding_info.append([seeders, size])\n\n        self.seeding = len(torrents)\n        self.seeding_size = seeding_size\n        self.seeding_info = seeding_info\n\n    def _parse_message_unread_links(\n        self, html_text: str, msg_links: list\n    ) -> str | None:\n        unread_ids = []\n        try:\n            messages = json.loads(html_text).get(\"data\", [])\n        except Exception as e:\n            logger.error(f\"解析未读消息失败: {e}\")\n            return None\n        for msg in messages:\n            msg_id, msg_unread = msg.get(\"id\"), msg.get(\"unread\")\n            if not (msg_id and msg_unread) or msg_unread == \"no\":\n                continue\n            unread_ids.append(msg_id)\n            head, date, content = msg.get(\"subject\"), msg.get(\"added\"), msg.get(\"msg\")\n            if head and date and content:\n                self.message_unread_contents.append((head, date, content))\n        self.message_unread = len(unread_ids)\n        if unread_ids:\n            self._get_page_content(\n                url=urljoin(self._base_url, \"api/general?loading=true\"),\n                params={\"action\": \"readMessage\", \"data\": {\"ids\": unread_ids}},\n                headers={\n                    \"Content-Type\": \"application/json\",\n                    \"Accept\": \"application/json, text/plain, */*\",\n                    \"X-Requested-With\": \"XMLHttpRequest\",\n                },\n            )\n        return None\n\n    def _parse_user_base_info(self, html_text: str):\n        \"\"\"只有奶糖余额才需要在 base 中获取，其它均可以在详情页拿到\"\"\"\n        html = etree.HTML(html_text)\n        try:\n            if not StringUtils.is_valid_html_element(html):\n                return\n            bonus = html.xpath(\n                '//div[contains(text(), \"奶糖余额\")]/following-sibling::div[1]/text()'\n            )\n            if bonus:\n                self.bonus = StringUtils.str_float(bonus[0].strip())\n        finally:\n            if html is not None:\n                del html\n\n    def _parse_user_detail_info(self, html_text: str):\n        html = etree.HTML(html_text)\n        try:\n            if not StringUtils.is_valid_html_element(html):\n                return\n            # 缩小一下查找范围，所有的信息都在这个 div 里\n            user_info = html.xpath('//div[contains(@class, \"layui-hares-user-info-right\")]')\n            if not user_info:\n                return\n            user_info = user_info[0]\n            # 用户名\n            if username := user_info.xpath(\n                './/span[contains(text(), \"用户名\")]/a/span/text()'\n            ):\n                self.username = username[0].strip()\n            # 等级\n            if user_level := user_info.xpath('.//span[contains(text(), \"等级\")]/b/text()'):\n                self.user_level = user_level[0].strip()\n            # 加入日期\n            if join_date := user_info.xpath('.//span[contains(text(), \"注册日期\")]/text()'):\n                join_date = join_date[0].strip().split(\"\\r\")[0].removeprefix(\"注册日期：\")\n                self.join_at = StringUtils.unify_datetime_str(join_date)\n            # 上传量\n            if upload := user_info.xpath('.//span[contains(text(), \"上传量\")]/text()'):\n                self.upload = StringUtils.num_filesize(\n                    upload[0].strip().removeprefix(\"上传量：\")\n                )\n            # 下载量\n            if download := user_info.xpath('.//span[contains(text(), \"下载量\")]/text()'):\n                self.download = StringUtils.num_filesize(\n                    download[0].strip().removeprefix(\"下载量：\")\n                )\n            # 分享率\n            if ratio := user_info.xpath('.//span[contains(text(), \"分享率\")]/em/text()'):\n                self.ratio = StringUtils.str_float(ratio[0].strip())\n        finally:\n            if html is not None:\n                del html\n\n    def _parse_message_content(self, html_text):\n        \"\"\"\n        解析短消息内容，已经在 _parse_message_unread_links 内实现，重载防止 abstractmethod 报错\n        :param html_text:\n        :return:  head: message, date: time, content: message content\n        \"\"\"\n        pass\n\n    def _parse_user_traffic_info(self, html_text: str):\n        \"\"\"\n        解析用户的上传，下载，分享率等信息，已经在 _parse_user_detail_info 内实现，重载防止 abstractmethod 报错\n        :param html_text:\n        :return:\n        \"\"\"\n        pass\n"
  },
  {
    "path": "app/modules/indexer/parser/rousi.py",
    "content": "# -*- coding: utf-8 -*-\nimport json\nfrom urllib.parse import urljoin\nfrom typing import Optional, Tuple\n\nfrom app.log import logger\nfrom app.core.config import settings\nfrom app.utils.http import RequestUtils\nfrom app.utils.string import StringUtils\nfrom app.modules.indexer.parser import SiteParserBase, SiteSchema\n\n\nclass RousiSiteUserInfo(SiteParserBase):\n    \"\"\"\n    Rousi.pro 站点解析器\n    使用 API v1 接口，通过 Passkey (Bearer Token) 进行认证\n    \"\"\"\n    schema = SiteSchema.RousiPro\n    request_mode = \"apikey\"\n\n    def _parse_site_page(self, html_text: str):\n        \"\"\"\n        配置 API 请求地址和请求头\n        使用 API v1 的 /profile 接口获取用户信息\n        \"\"\"\n        self._base_url = f\"https://{StringUtils.get_url_domain(self._site_url)}\"\n        self._user_basic_page = \"api/v1/profile?include_fields[user]=seeding_leeching_data\"\n        self._user_basic_params = {}\n        self._user_basic_headers = {\n            \"Content-Type\": \"application/json\",\n            \"Accept\": \"application/json\",\n            \"Authorization\": f\"Bearer {self.apikey}\"\n        }\n\n        # Rousi.pro API v1 在单个接口返回所有信息，无需额外页面\n        self._user_traffic_page = None\n        self._user_detail_page = None\n        self._torrent_seeding_page = None\n        self._user_mail_unread_page = None\n        self._sys_mail_unread_page = None\n\n    def _parse_logged_in(self, html_text):\n        \"\"\"\n        判断是否登录成功\n        API 认证模式下，通过 HTTP 状态码判断，此处始终返回 True\n        \"\"\"\n        return True\n\n    def _parse_user_base_info(self, html_text: str):\n        \"\"\"\n        解析用户基本信息\n        通过 API v1 接口获取用户完整信息，包括上传下载量、做种数据等\n\n        API 响应示例：\n        {\n            \"code\": 0,\n            \"message\": \"success\",\n            \"data\": {\n                \"id\": 1,\n                \"username\": \"example\",\n                \"level_text\": \"Lv.5\",\n                \"registered_at\": \"2024-01-01T00:00:00Z\",\n                \"uploaded\": 1073741824,\n                \"downloaded\": 536870912,\n                \"ratio\": 2.0,\n                \"karma\": 1000.5,\n                \"seeding_leeching_data\": {\n                    \"seeding_count\": 10,\n                    \"seeding_size\": 10737418240,\n                    \"leeching_count\": 2,\n                    \"leeching_size\": 2147483648\n                }\n            }\n        }\n        \"\"\"\n        if not html_text:\n            return\n\n        try:\n            data = json.loads(html_text)\n        except json.JSONDecodeError:\n            logger.error(f\"{self._site_name} JSON 解析失败\")\n            return\n\n        if not data or data.get(\"code\") != 0:\n            self.err_msg = data.get(\"message\", \"未知错误\")\n            logger.warn(f\"{self._site_name} API 错误: {self.err_msg}\")\n            return\n\n        user_info = data.get(\"data\")\n        if not user_info:\n            return\n\n        # 基本信息\n        self.userid = user_info.get(\"id\")\n        self.username = user_info.get(\"username\")\n        self.user_level = user_info.get(\"level_text\") or user_info.get(\"role_text\")\n\n        # 注册时间：统一格式为 YYYY-MM-DD HH:MM:SS\n        join_at = StringUtils.unify_datetime_str(user_info.get(\"registered_at\"))\n        if join_at:\n            # 确保格式为 YYYY-MM-DD HH:MM:SS (19位)\n            if len(join_at) >= 19:\n                self.join_at = join_at[:19]\n            else:\n                self.join_at = join_at\n\n        # 流量信息\n        self.upload = int(user_info.get(\"uploaded\") or 0)\n        self.download = int(user_info.get(\"downloaded\") or 0)\n        self.ratio = round(float(user_info.get(\"ratio\") or 0), 2)\n\n        # 魔力值（站点称为 karma）\n        self.bonus = float(user_info.get(\"karma\") or 0)\n\n        # 做种/下载中数据\n        sl_data = user_info.get(\"seeding_leeching_data\", {})\n        self.seeding = int(sl_data.get(\"seeding_count\") or 0)\n        self.seeding_size = int(sl_data.get(\"seeding_size\") or 0)\n        self.leeching = int(sl_data.get(\"leeching_count\") or 0)\n        self.leeching_size = int(sl_data.get(\"leeching_size\") or 0)\n\n    def _parse_user_traffic_info(self, html_text: str):\n        \"\"\"\n        解析用户流量信息\n        Rousi.pro API v1 在 _parse_user_base_info 中已完成所有解析，此方法无需实现\n        \"\"\"\n        pass\n\n    def _parse_user_detail_info(self, html_text: str):\n        \"\"\"\n        解析用户详细信息\n        Rousi.pro API v1 在 _parse_user_base_info 中已完成所有解析，此方法无需实现\n        \"\"\"\n        pass\n\n    def _parse_user_torrent_seeding_info(self, html_text: str, multi_page: Optional[bool] = False) -> Optional[str]:\n        \"\"\"\n        解析用户做种信息\n        Rousi.pro API v1 在 _parse_user_base_info 中已通过 seeding_leeching_data 获取做种数据\n\n        :param html_text: 页面内容\n        :param multi_page: 是否多页数据\n        :return: 下页地址（无下页返回 None）\n        \"\"\"\n        return None\n\n    def _parse_message_unread_links(self, html_text: str, msg_links: list) -> Optional[str]:\n        \"\"\"\n        解析未读消息链接\n        Rousi.pro API v1 暂未提供消息相关接口\n\n        :param html_text: 页面内容\n        :param msg_links: 消息链接列表\n        :return: 下页地址（无下页返回 None）\n        \"\"\"\n        return None\n\n    def _parse_message_content(self, html_text) -> Tuple[Optional[str], Optional[str], Optional[str]]:\n        \"\"\"\n        解析消息内容\n        Rousi.pro API v1 暂未提供消息相关接口\n\n        :param html_text: 页面内容\n        :return: (标题, 日期, 内容)\n        \"\"\"\n        return None, None, None\n\n    def _pase_unread_msgs(self):\n        \"\"\"\n        解析所有未读消息标题和内容\n        Rousi.pro API v1 暂未提供消息相关接口，暂时以网页接口实现\n        \n        :return:\n        \"\"\"\n        if not self.token:\n            logger.warn(f\"{self._site_name} 站点未配置 Authorization 请求头，跳过消息解析\")\n            return\n        \n        headers = {\n            \"User-Agent\": self._ua,\n            \"Accept\": \"application/json, text/plain, */*\",\n            \"Authorization\": self.token if self.token.startswith(\"Bearer \") else f\"Bearer {self.token}\"\n        }\n        \n        def __get_message_list(page: int):\n            params = {\n                \"page\": page,\n                \"page_size\": 100,\n                \"unread_only\": \"true\"\n            }\n            res = RequestUtils(\n                headers=headers,\n                timeout=60,\n                proxies=settings.PROXY if self._proxy else None\n            ).get_res(\n                url=urljoin(self._base_url, \"api/messages\"),\n                params=params\n            )\n            if not res or res.status_code != 200 or res.json().get(\"code\", -1) != 0:\n                logger.warn(f\"{self._site_name} 站点解析消息失败，状态码: {res.status_code if res else '无响应'}\")\n                return {\n                    \"messages\": [],\n                    \"total_pages\": 0\n                }\n            return res.json().get(\"data\")\n        \n        # 分页获取所有未读消息\n        page = 0\n        res = __get_message_list(page)\n        page += 1\n        messages = res.get(\"messages\", [])\n        total_pages = res.get(\"total_pages\", 0)\n        while page < total_pages:\n            res = __get_message_list(page)\n            messages.extend(res.get(\"messages\", []))\n            page += 1\n        \n        self.message_unread = len(messages)\n        for messsage in messages:\n            head = messsage.get(\"title\")\n            date = StringUtils.unify_datetime_str(messsage.get(\"created_at\"))\n            content = messsage.get(\"content\")\n            logger.debug(f\"{self._site_name} 标题 {head} 时间 {date} 内容 {content}\")\n            self.message_unread_contents.append((head, date, content))\n            \n        # 更新消息为已读\n        RequestUtils(\n            headers=headers,\n            timeout=60,\n            proxies=settings.PROXY if self._proxy else None\n        ).post_res(\n            url=urljoin(self._base_url, \"api/messages/read-all\")\n        )"
  },
  {
    "path": "app/modules/indexer/parser/small_horse.py",
    "content": "# -*- coding: utf-8 -*-\nimport re\nfrom typing import Optional\n\nfrom lxml import etree\n\nfrom app.modules.indexer.parser import SiteParserBase, SiteSchema\nfrom app.utils.string import StringUtils\n\n\nclass SmallHorseSiteUserInfo(SiteParserBase):\n    schema = SiteSchema.SmallHorse\n\n    def _parse_site_page(self, html_text: str):\n        html_text = self._prepare_html_text(html_text)\n\n        user_detail = re.search(r\"user.php\\?id=(\\d+)\", html_text)\n        if user_detail and user_detail.group().strip():\n            self._user_detail_page = user_detail.group().strip().lstrip('/')\n            self.userid = user_detail.group(1)\n            self._torrent_seeding_page = f\"torrents.php?type=seeding&userid={self.userid}\"\n        self._user_traffic_page = f\"user.php?id={self.userid}\"\n\n    def _parse_user_base_info(self, html_text: str):\n        html_text = self._prepare_html_text(html_text)\n        html = etree.HTML(html_text)\n        try:\n            ret = html.xpath('//a[contains(@href, \"user.php\")]//text()')\n            if ret:\n                self.username = str(ret[0])\n        finally:\n            if html is not None:\n                del html\n\n    def _parse_user_traffic_info(self, html_text: str):\n        \"\"\"\n        上传/下载/分享率 [做种数/魔力值]\n        :param html_text:\n        :return:\n        \"\"\"\n        html_text = self._prepare_html_text(html_text)\n        html = etree.HTML(html_text)\n        try:\n            tmps = html.xpath('//ul[@class = \"stats nobullet\"]')\n            if tmps:\n                if tmps[1].xpath(\"li\") and tmps[1].xpath(\"li\")[0].xpath(\"span//text()\"):\n                    self.join_at = StringUtils.unify_datetime_str(tmps[1].xpath(\"li\")[0].xpath(\"span//text()\")[0])\n                self.upload = StringUtils.num_filesize(str(tmps[1].xpath(\"li\")[2].xpath(\"text()\")[0]).split(\":\")[1].strip())\n                self.download = StringUtils.num_filesize(\n                    str(tmps[1].xpath(\"li\")[3].xpath(\"text()\")[0]).split(\":\")[1].strip())\n                if tmps[1].xpath(\"li\")[4].xpath(\"span//text()\"):\n                    self.ratio = StringUtils.str_float(str(tmps[1].xpath(\"li\")[4].xpath(\"span//text()\")[0]).replace('∞', '0'))\n                else:\n                    self.ratio = StringUtils.str_float(str(tmps[1].xpath(\"li\")[5].xpath(\"text()\")[0]).split(\":\")[1])\n                self.bonus = StringUtils.str_float(str(tmps[1].xpath(\"li\")[5].xpath(\"text()\")[0]).split(\":\")[1])\n                self.user_level = str(tmps[3].xpath(\"li\")[0].xpath(\"text()\")[0]).split(\":\")[1].strip()\n                self.leeching = StringUtils.str_int(\n                    (tmps[4].xpath(\"li\")[6].xpath(\"text()\")[0]).split(\":\")[1].replace(\"[\", \"\"))\n        finally:\n            if html is not None:\n                del html\n\n    def _parse_user_detail_info(self, html_text: str):\n        pass\n\n    def _parse_user_torrent_seeding_info(self, html_text: str, multi_page: Optional[bool] = False) -> Optional[str]:\n        \"\"\"\n         做种相关信息\n         :param html_text:\n         :param multi_page: 是否多页数据\n         :return: 下页地址\n         \"\"\"\n        html = etree.HTML(html_text)\n        try:\n            if not StringUtils.is_valid_html_element(html):\n                return None\n\n            size_col = 6\n            seeders_col = 8\n\n            page_seeding = 0\n            page_seeding_size = 0\n            page_seeding_info = []\n            seeding_sizes = html.xpath(f'//table[@id=\"torrent_table\"]//tr[position()>1]/td[{size_col}]')\n            seeding_seeders = html.xpath(f'//table[@id=\"torrent_table\"]//tr[position()>1]/td[{seeders_col}]')\n            if seeding_sizes and seeding_seeders:\n                page_seeding = len(seeding_sizes)\n\n                for i in range(0, len(seeding_sizes)):\n                    size = StringUtils.num_filesize(seeding_sizes[i].xpath(\"string(.)\").strip())\n                    seeders = StringUtils.str_int(seeding_seeders[i].xpath(\"string(.)\").strip())\n\n                    page_seeding_size += size\n                    page_seeding_info.append([seeders, size])\n\n            self.seeding += page_seeding\n            self.seeding_size += page_seeding_size\n            self.seeding_info.extend(page_seeding_info)\n\n            # 是否存在下页数据\n            next_page = None\n            next_pages = html.xpath('//ul[@class=\"pagination\"]/li[contains(@class,\"active\")]/following-sibling::li')\n            if next_pages and len(next_pages) > 1:\n                page_num = next_pages[0].xpath(\"string(.)\").strip()\n                if page_num.isdigit():\n                    next_page = f\"{self._torrent_seeding_page}&page={page_num}\"\n        finally:\n            if html is not None:\n                del html\n        return next_page\n\n    def _parse_message_unread_links(self, html_text: str, msg_links: list) -> Optional[str]:\n        return None\n\n    def _parse_message_content(self, html_text):\n        return None, None, None\n"
  },
  {
    "path": "app/modules/indexer/parser/tnode.py",
    "content": "# -*- coding: utf-8 -*-\nimport json\nimport re\nfrom typing import Optional\n\nfrom app.log import logger\nfrom app.modules.indexer.parser import SiteParserBase, SiteSchema\nfrom app.utils.string import StringUtils\n\n\nclass TNodeSiteUserInfo(SiteParserBase):\n    schema = SiteSchema.TNode\n\n    def _parse_site_page(self, html_text: str):\n        html_text = self._prepare_html_text(html_text)\n\n        # <meta name=\"x-csrf-token\" content=\"fd169876a7b4846f3a7a16fcd5cccf8d\">\n        csrf_token = re.search(r'<meta name=\"x-csrf-token\" content=\"(.+?)\">', html_text)\n        if csrf_token:\n            self._addition_headers = {'X-CSRF-TOKEN': csrf_token.group(1)}\n            self._user_detail_page = \"api/user/getMainInfo\"\n            self._torrent_seeding_page = \"api/user/listTorrentActivity?id=&type=seeding&page=1&size=20000\"\n\n    def _parse_logged_in(self, html_text):\n        \"\"\"\n        判断是否登录成功, 通过判断是否存在用户信息\n        暂时跳过检测，待后续优化\n        :param html_text:\n        :return:\n        \"\"\"\n        return True\n\n    def _parse_user_base_info(self, html_text: str):\n        self.username = self.userid\n\n    def _parse_user_traffic_info(self, html_text: str):\n        pass\n\n    def _parse_user_detail_info(self, html_text: str):\n        try:\n            detail = json.loads(html_text)\n        except json.JSONDecodeError:\n            return\n        if detail.get(\"status\") != 200:\n            return\n\n        user_info = detail.get(\"data\", {})\n        self.userid = user_info.get(\"id\")\n        self.username = user_info.get(\"username\")\n        self.user_level = user_info.get(\"class\", {}).get(\"name\")\n        self.join_at = user_info.get(\"regTime\", 0)\n        self.join_at = StringUtils.unify_datetime_str(str(self.join_at))\n\n        self.upload = user_info.get(\"upload\")\n        self.download = user_info.get(\"download\")\n        self.ratio = 0 if self.download <= 0 else round(self.upload / self.download, 3)\n        self.bonus = user_info.get(\"bonus\")\n\n        self.message_unread = user_info.get(\"unreadAdmin\", 0) + user_info.get(\"unreadInbox\", 0) + user_info.get(\n            \"unreadSystem\", 0)\n        pass\n\n    def _parse_user_torrent_seeding_info(self, html_text: str, multi_page: Optional[bool] = False) -> Optional[str]:\n        \"\"\"\n        解析用户做种信息\n        \"\"\"\n        try:\n            seeding_info = json.loads(html_text)\n        except json.JSONDecodeError as e:\n            logger.warning(f\"{self._site_name}: Failed to decode seeding info JSON: {e}\")\n            return None\n\n        if not isinstance(seeding_info, dict):\n            logger.warning(f\"{self._site_name}: Seeding info payload is not a dictionary\")\n            return None\n\n        if seeding_info.get(\"status\") != 200:\n            return None\n\n        torrents = seeding_info.get(\"data\", {}).get(\"torrents\", [])\n\n        page_seeding_size = 0\n        page_seeding_info = []\n        for torrent in torrents:\n            size = torrent.get(\"size\", 0)\n            seeders = torrent.get(\"seeding\", 0)\n\n            page_seeding_size += size\n            page_seeding_info.append([seeders, size])\n\n        self.seeding += len(torrents)\n        self.seeding_size += page_seeding_size\n        self.seeding_info.extend(page_seeding_info)\n\n        # 是否存在下页数据\n        next_page = None\n\n        return next_page\n\n    def _parse_message_unread_links(self, html_text: str, msg_links: list) -> Optional[str]:\n        return None\n\n    def _parse_message_content(self, html_text):\n        \"\"\"\n        系统信息 api/message/listSystem?page=1&size=20\n        收件箱信息 api/message/listInbox?page=1&size=20\n        管理员信息 api/message/listAdmin?page=1&size=20\n        :param html_text:\n        :return:\n        \"\"\"\n        return None, None, None\n"
  },
  {
    "path": "app/modules/indexer/parser/torrent_leech.py",
    "content": "# -*- coding: utf-8 -*-\nimport re\nfrom typing import Optional\n\nfrom lxml import etree\n\nfrom app.modules.indexer.parser import SiteParserBase, SiteSchema\nfrom app.utils.string import StringUtils\n\n\nclass TorrentLeechSiteUserInfo(SiteParserBase):\n    schema = SiteSchema.TorrentLeech\n\n    def _parse_site_page(self, html_text: str):\n        html_text = self._prepare_html_text(html_text)\n\n        user_detail = re.search(r\"/profile/([^/]+)/\", html_text)\n        if user_detail and user_detail.group().strip():\n            self._user_detail_page = user_detail.group().strip().lstrip('/')\n            self.userid = user_detail.group(1)\n        self._user_traffic_page = f\"profile/{self.userid}/view\"\n        self._torrent_seeding_page = f\"profile/{self.userid}/seeding\"\n\n    def _parse_user_base_info(self, html_text: str):\n        self.username = self.userid\n\n    def _parse_user_traffic_info(self, html_text: str):\n        \"\"\"\n        上传/下载/分享率 [做种数/魔力值]\n        :param html_text:\n        :return:\n        \"\"\"\n        html_text = self._prepare_html_text(html_text)\n        html = etree.HTML(html_text)\n        try:\n            upload_html = html.xpath('//div[contains(@class,\"profile-uploaded\")]//span/text()')\n            if upload_html:\n                self.upload = StringUtils.num_filesize(upload_html[0])\n            download_html = html.xpath('//div[contains(@class,\"profile-downloaded\")]//span/text()')\n            if download_html:\n                self.download = StringUtils.num_filesize(download_html[0])\n            ratio_html = html.xpath('//div[contains(@class,\"profile-ratio\")]//span/text()')\n            if ratio_html:\n                self.ratio = StringUtils.str_float(ratio_html[0].replace('∞', '0'))\n\n            user_level_html = html.xpath('//table[contains(@class, \"profileViewTable\")]'\n                                         '//tr/td[text()=\"Class\"]/following-sibling::td/text()')\n            if user_level_html:\n                self.user_level = user_level_html[0].strip()\n\n            join_at_html = html.xpath('//table[contains(@class, \"profileViewTable\")]'\n                                      '//tr/td[text()=\"Registration date\"]/following-sibling::td/text()')\n            if join_at_html:\n                self.join_at = StringUtils.unify_datetime_str(join_at_html[0].strip())\n\n            bonus_html = html.xpath('//span[contains(@class, \"total-TL-points\")]/text()')\n            if bonus_html:\n                self.bonus = StringUtils.str_float(bonus_html[0].strip())\n        finally:\n            if html is not None:\n                del html\n\n    def _parse_user_detail_info(self, html_text: str):\n        pass\n\n    def _parse_user_torrent_seeding_info(self, html_text: str, multi_page: Optional[bool] = False) -> Optional[str]:\n        \"\"\"\n        做种相关信息\n        :param html_text:\n        :param multi_page: 是否多页数据\n        :return: 下页地址\n        \"\"\"\n        html = etree.HTML(html_text)\n        try:\n            if not StringUtils.is_valid_html_element(html):\n                return None\n\n            size_col = 2\n            seeders_col = 7\n\n            page_seeding = 0\n            page_seeding_size = 0\n            page_seeding_info = []\n            seeding_sizes = html.xpath(f'//tbody/tr/td[{size_col}]')\n            seeding_seeders = html.xpath(f'//tbody/tr/td[{seeders_col}]/text()')\n            if seeding_sizes and seeding_seeders:\n                page_seeding = len(seeding_sizes)\n\n                for i in range(0, len(seeding_sizes)):\n                    size = StringUtils.num_filesize(seeding_sizes[i].xpath(\"string(.)\").strip())\n                    seeders = StringUtils.str_int(seeding_seeders[i])\n\n                    page_seeding_size += size\n                    page_seeding_info.append([seeders, size])\n\n            self.seeding += page_seeding\n            self.seeding_size += page_seeding_size\n            self.seeding_info.extend(page_seeding_info)\n\n            # 是否存在下页数据\n            next_page = None\n        finally:\n            if html is not None:\n                del html\n\n        return next_page\n\n    def _parse_message_unread_links(self, html_text: str, msg_links: list) -> Optional[str]:\n        return None\n\n    def _parse_message_content(self, html_text):\n        return None, None, None\n"
  },
  {
    "path": "app/modules/indexer/parser/unit3d.py",
    "content": "# -*- coding: utf-8 -*-\nimport re\nfrom typing import Optional\n\nfrom lxml import etree\n\nfrom app.modules.indexer.parser import SiteParserBase, SiteSchema\nfrom app.utils.string import StringUtils\n\n\nclass Unit3dSiteUserInfo(SiteParserBase):\n    schema = SiteSchema.Unit3d\n\n    def _parse_user_base_info(self, html_text: str):\n        html_text = self._prepare_html_text(html_text)\n        html = etree.HTML(html_text)\n        try:\n            tmps = html.xpath('//a[contains(@href, \"/users/\") and contains(@href, \"settings\")]/@href')\n            if tmps:\n                user_name_match = re.search(r\"/users/(.+)/settings\", tmps[0])\n                if user_name_match and user_name_match.group().strip():\n                    self.username = user_name_match.group(1)\n                    self._torrent_seeding_page = f\"/users/{self.username}/active?perPage=100&client=&seeding=include\"\n                    self._user_detail_page = f\"/users/{self.username}\"\n\n            tmps = html.xpath('//a[contains(@href, \"bonus/earnings\")]')\n            if tmps:\n                bonus_text = tmps[0].xpath(\"string(.)\")\n                bonus_match = re.search(r\"([\\d,.]+)\", bonus_text)\n                if bonus_match and bonus_match.group(1).strip():\n                    self.bonus = StringUtils.str_float(bonus_match.group(1))\n        finally:\n            if html is not None:\n                del html\n\n    def _parse_site_page(self, html_text: str):\n        pass\n\n    def _parse_user_detail_info(self, html_text: str):\n        \"\"\"\n        解析用户额外信息，加入时间，等级\n        :param html_text:\n        :return:\n        \"\"\"\n        html = etree.HTML(html_text)\n        try:\n            if not StringUtils.is_valid_html_element(html):\n                return None\n\n            # 用户等级\n            user_levels_text = html.xpath('//div[contains(@class, \"content\")]//span[contains(@class, \"badge-user\")]/text()')\n            if user_levels_text:\n                self.user_level = user_levels_text[0].strip()\n\n            # 加入日期\n            join_at_text = html.xpath('//div[contains(@class, \"content\")]//h4[contains(text(), \"注册日期\") '\n                                      'or contains(text(), \"註冊日期\") '\n                                      'or contains(text(), \"Registration date\")]/text()')\n            if join_at_text:\n                self.join_at = StringUtils.unify_datetime_str(\n                    join_at_text[0].replace('注册日期', '').replace('註冊日期', '').replace('Registration date', ''))\n        finally:\n            if html is not None:\n                del html\n\n    def _parse_user_torrent_seeding_info(self, html_text: str, multi_page: Optional[bool] = False) -> Optional[str]:\n        \"\"\"\n        做种相关信息\n        :param html_text:\n        :param multi_page: 是否多页数据\n        :return: 下页地址\n        \"\"\"\n        html = etree.HTML(html_text)\n        try:\n            if not StringUtils.is_valid_html_element(html):\n                return None\n\n            size_col = 9\n            seeders_col = 2\n            # 搜索size列\n            if html.xpath('//thead//th[contains(@class,\"size\")]'):\n                size_col = len(html.xpath('//thead//th[contains(@class,\"size\")][1]/preceding-sibling::th')) + 1\n            # 搜索seeders列\n            if html.xpath('//thead//th[contains(@class,\"seeders\")]'):\n                seeders_col = len(html.xpath('//thead//th[contains(@class,\"seeders\")]/preceding-sibling::th')) + 1\n\n            page_seeding = 0\n            page_seeding_size = 0\n            page_seeding_info = []\n            seeding_sizes = html.xpath(f'//tr[position()]/td[{size_col}]')\n            seeding_seeders = html.xpath(f'//tr[position()]/td[{seeders_col}]')\n            if seeding_sizes and seeding_seeders:\n                page_seeding = len(seeding_sizes)\n\n                for i in range(0, len(seeding_sizes)):\n                    size = StringUtils.num_filesize(seeding_sizes[i].xpath(\"string(.)\").strip())\n                    seeders = StringUtils.str_int(seeding_seeders[i].xpath(\"string(.)\").strip())\n\n                    page_seeding_size += size\n                    page_seeding_info.append([seeders, size])\n\n            self.seeding += page_seeding\n            self.seeding_size += page_seeding_size\n            self.seeding_info.extend(page_seeding_info)\n\n            # 是否存在下页数据\n            next_page = None\n            next_pages = html.xpath('//ul[@class=\"pagination\"]/li[contains(@class,\"active\")]/following-sibling::li')\n            if next_pages and len(next_pages) > 1:\n                page_num = next_pages[0].xpath(\"string(.)\").strip()\n                if page_num.isdigit():\n                    next_page = f\"{self._torrent_seeding_page}&page={page_num}\"\n        finally:\n            if html is not None:\n                del html\n\n        return next_page\n\n    def _parse_user_traffic_info(self, html_text: str):\n        html_text = self._prepare_html_text(html_text)\n        upload_match = re.search(r\"[^总]上[传傳]量?[:：_<>/a-zA-Z-=\\\"'\\s#;]+([\\d,.\\s]+[KMGTPI]*B)\", html_text,\n                                 re.IGNORECASE)\n        self.upload = StringUtils.num_filesize(upload_match.group(1).strip()) if upload_match else 0\n        download_match = re.search(r\"[^总子影力]下[载載]量?[:：_<>/a-zA-Z-=\\\"'\\s#;]+([\\d,.\\s]+[KMGTPI]*B)\", html_text,\n                                   re.IGNORECASE)\n        self.download = StringUtils.num_filesize(download_match.group(1).strip()) if download_match else 0\n        ratio_match = re.search(r\"分享率[:：_<>/a-zA-Z-=\\\"'\\s#;]+([\\d,.\\s]+)\", html_text)\n        self.ratio = StringUtils.str_float(ratio_match.group(1)) if (\n                ratio_match and ratio_match.group(1).strip()) else 0.0\n\n    def _parse_message_unread_links(self, html_text: str, msg_links: list) -> Optional[str]:\n        return None\n\n    def _parse_message_content(self, html_text):\n        return None, None, None\n"
  },
  {
    "path": "app/modules/indexer/parser/yema.py",
    "content": "# -*- coding: utf-8 -*-\nimport json\nfrom typing import Optional, Tuple\n\nfrom app.modules.indexer.parser import SiteParserBase, SiteSchema\nfrom app.utils.string import StringUtils\n\n\nclass TYemaSiteUserInfo(SiteParserBase):\n    schema = SiteSchema.Yema\n\n    def _parse_site_page(self, html_text: str):\n        \"\"\"\n        获取站点页面地址\n        \"\"\"\n        self._user_traffic_page = None\n        self._user_detail_page = None\n        self._user_basic_page = \"api/consumer/fetchSelfDetail\"\n        self._user_basic_params = {}\n        self._sys_mail_unread_page = None\n        self._user_mail_unread_page = None\n        self._mail_unread_params = {}\n        self._torrent_seeding_page = \"/api/userTorrent/fetchSeedTorrentInfo\"\n        self._torrent_seeding_params = {\n            # 虽然这个参数是无意义的，但这个 API 必须用 POST\n            \"status\": \"seeding\"\n        }\n        self._torrent_seeding_headers = {}\n        self._addition_headers = {\n            \"Content-Type\": \"application/json\",\n            \"Accept\": \"application/json, text/plain, */*\",\n        }\n\n    def _parse_logged_in(self, html_text):\n        \"\"\"\n        判断是否登录成功, 通过判断是否存在用户信息\n        暂时跳过检测，待后续优化\n        :param html_text:\n        :return:\n        \"\"\"\n        return True\n\n    def _parse_user_base_info(self, html_text: str):\n        \"\"\"\n        解析用户基本信息，这里把_parse_user_traffic_info和_parse_user_detail_info合并到这里\n        \"\"\"\n        if not html_text:\n            return None\n        detail = json.loads(html_text)\n        if not detail or not detail.get(\"success\"):\n            return\n        user_info = detail.get(\"data\", {})\n        self.userid = user_info.get(\"id\")\n        self.username = user_info.get(\"name\")\n        self.user_level = str(user_info.get(\"level\")) if user_info.get(\"level\") is not None else None\n        self.join_at = StringUtils.unify_datetime_str(user_info.get(\"registerTime\"))\n\n        self.upload = user_info.get('uploadSize')\n        # 使用 promotionDownloadSize 获取真实下载量（考虑促销因素）\n        if \"promotionDownloadSize\" in user_info:\n            self.download = user_info.get('promotionDownloadSize')\n        else:\n            self.download = user_info.get('downloadSize')\n        self.ratio = round(self.upload / (self.download or 1), 2)\n        self.bonus = user_info.get(\"bonus\")\n        self.message_unread = 0\n\n    def _parse_user_traffic_info(self, html_text: str):\n        \"\"\"\n        解析用户流量信息\n        \"\"\"\n        pass\n\n    def _parse_user_detail_info(self, html_text: str):\n        \"\"\"\n        解析用户详细信息\n        \"\"\"\n        pass\n\n    def _parse_user_torrent_seeding_info(self, html_text: str, multi_page: Optional[bool] = False) -> Optional[str]:\n        \"\"\"\n        解析用户做种信息\n        \"\"\"\n        if not html_text:\n            return None\n        seeding_info = json.loads(html_text)\n        if not seeding_info or not seeding_info.get(\"success\") or not seeding_info.get(\"data\"):\n            return None\n\n        torrents = seeding_info.get(\"data\")\n\n        self.seeding += torrents.get(\"num\")\n        self.seeding_size += torrents.get(\"fileSize\")\n\n        # 是否存在下页数据\n        next_page = None\n\n        return next_page\n\n    def _parse_message_unread_links(self, html_text: str, msg_links: list) -> Optional[str]:\n        \"\"\"\n        解析未读消息链接，这里直接读出详情\n        \"\"\"\n        pass\n\n    def _parse_message_content(self, html_text) -> Tuple[Optional[str], Optional[str], Optional[str]]:\n        \"\"\"\n        解析消息内容\n        \"\"\"\n        pass\n"
  },
  {
    "path": "app/modules/indexer/parser/zhixing.py",
    "content": "#\n# 知行 http://pt.zhixing.bjtu.edu.cn/\n# author: ThedoRap\n# time: 2025-10-02\n#\n# -*- coding: utf-8 -*-\nimport re\nfrom typing import Optional, Tuple\n\nfrom app.modules.indexer.parser import SiteParserBase, SiteSchema\nfrom app.utils.string import StringUtils\nfrom bs4 import BeautifulSoup\nfrom urllib.parse import urljoin\n\n\nclass ZhixingSiteUserInfo(SiteParserBase):\n    schema = SiteSchema.Zhixing\n\n    def _parse_site_page(self, html_text: str):\n        \"\"\"\n        获取站点页面地址\n        \"\"\"\n        self._user_basic_page = \"user/{uid}/\"\n        self._user_detail_page = None\n        self._user_basic_params = {}\n        self._user_traffic_page = None\n        self._sys_mail_unread_page = None\n        self._user_mail_unread_page = None\n        self._mail_unread_params = {}\n        self._torrent_seeding_base = \"user/{uid}/seeding\"\n        self._torrent_seeding_params = {}\n        self._torrent_seeding_headers = {}\n        self._addition_headers = {}\n\n    def _parse_logged_in(self, html_text):\n        \"\"\"\n        判断是否登录成功, 通过判断是否存在用户信息\n        \"\"\"\n        soup = BeautifulSoup(html_text, 'html.parser')\n        return bool(soup.find(id='um'))\n\n    def _parse_user_base_info(self, html_text: str):\n        \"\"\"\n        解析用户基本信息，这里把_parse_user_traffic_info和_parse_user_detail_info合并到这里\n        \"\"\"\n        if not html_text:\n            return None\n        soup = BeautifulSoup(html_text, 'html.parser')\n        details_tabs = soup.find_all('div', class_='user-details-tabs')\n        info_dict = {}\n        for tab in details_tabs:\n            for p in tab.find_all('p'):\n                text = p.text.strip()\n                if '：' in text:\n                    parts = text.split('：', 1)\n                elif ':' in text:\n                    parts = text.split(':', 1)\n                else:\n                    continue\n                if len(parts) == 2:\n                    key = parts[0].strip()\n                    value_text = parts[1].strip()\n                    value = re.split(r'\\s*\\(', value_text)[0].strip().split('查看')[0].strip()\n                    info_dict[key] = value\n\n        self._basic_info = info_dict  # Save for fallback\n\n        self.userid = info_dict.get('UID')\n        self.username = info_dict.get('用户名')\n        self.user_level = info_dict.get('用户组')\n        self.join_at = StringUtils.unify_datetime_str(info_dict.get('注册时间')) if '注册时间' in info_dict else None\n\n        def num_filesize_safe(s: str):\n            if s:\n                s = s.strip()\n                if re.match(r'^\\d+(\\.\\d+)?$', s):\n                    s += ' B'\n            return StringUtils.num_filesize(s) if s else 0\n\n        self.upload = num_filesize_safe(info_dict.get('上传流量')) if '上传流量' in info_dict else 0\n        self.download = num_filesize_safe(info_dict.get('下载流量')) if '下载流量' in info_dict else 0\n        self.ratio = float(info_dict.get('共享率')) if '共享率' in info_dict else 0\n        self.bonus = float(info_dict.get('保种积分')) if '保种积分' in info_dict else 0.0\n        self.message_unread = 0  # 暂无消息解析\n\n        # Temporarily set seeding from basic, will override or fallback later\n        self.seeding = int(info_dict.get('当前保种数量')) if '当前保种数量' in info_dict else 0\n        self.seeding_size = num_filesize_safe(info_dict.get('当前保种容量')) if '当前保种容量' in info_dict else 0\n\n    def _parse_user_traffic_info(self, html_text: str):\n        pass\n\n    def _parse_user_detail_info(self, html_text: str):\n        pass\n\n    def _parse_user_torrent_seeding_page_info(self, html_text: str) -> Tuple[int, int]:\n        \"\"\"\n        解析用户做种信息单页，返回本页数量和大小\n        \"\"\"\n        if not html_text:\n            return 0, 0\n        soup = BeautifulSoup(html_text, 'html.parser')\n        torrents = soup.find_all('tr', id=re.compile(r'^t\\d+'))\n        page_seeding = 0\n        page_seeding_size = 0\n        for torrent in torrents:\n            size_td = torrent.find('td', class_='r')\n            if size_td:\n                size_text = size_td.find('a').text if size_td.find('a') else size_td.text.strip()\n                page_seeding += 1\n                page_seeding_size += StringUtils.num_filesize(size_text)\n        return page_seeding, page_seeding_size\n\n    def _parse_message_unread_links(self, html_text: str, msg_links: list) -> Optional[str]:\n        pass\n\n    def _parse_message_content(self, html_text) -> Tuple[Optional[str], Optional[str], Optional[str]]:\n        pass\n\n    def _parse_user_torrent_seeding_info(self, html_text: str):\n        \"\"\"\n        占位，避免抽象类报错\n        \"\"\"\n        pass\n\n    def parse(self):\n        \"\"\"\n        解析站点信息\n        \"\"\"\n        super().parse()\n        # 先从首页解析userid\n        if self._index_html:\n            soup = BeautifulSoup(self._index_html, 'html.parser')\n            user_link = soup.find('a', href=re.compile(r'/user/\\d+/'))\n            if user_link:\n                uid_match = re.search(r'/user/(\\d+)/', user_link['href'])\n                if uid_match:\n                    self.userid = uid_match.group(1)\n        # 如果有userid，则格式化页面\n        if self.userid:\n            if self._user_basic_page:\n                basic_url = self._user_basic_page.format(uid=self.userid)\n                basic_html = self._get_page_content(url=urljoin(self._base_url, basic_url))\n                self._parse_user_base_info(basic_html)\n            if hasattr(self, '_torrent_seeding_base') and self._torrent_seeding_base:\n                self.seeding = 0  # Reset to sum from pages\n                self.seeding_size = 0\n                seeding_base = self._torrent_seeding_base.format(uid=self.userid)\n                seeding_base_url = urljoin(self._base_url, seeding_base)\n                page_num = 1\n                while True:\n                    seeding_url = f\"{seeding_base_url}/p{page_num}\"\n                    seeding_html = self._get_page_content(url=seeding_url)\n                    page_seeding, page_seeding_size = self._parse_user_torrent_seeding_page_info(seeding_html)\n                    self.seeding += page_seeding\n                    self.seeding_size += page_seeding_size\n                    if page_seeding == 0:\n                        break\n                    page_num += 1\n                # Fallback to basic if no seeding found from pages\n                if self.seeding == 0 and hasattr(self, '_basic_info'):\n                    def num_filesize_safe(s: str):\n                        if s:\n                            s = s.strip()\n                            if re.match(r'^\\d+(\\.\\d+)?$', s):\n                                s += ' B'\n                        return StringUtils.num_filesize(s) if s else 0\n                    self.seeding = int(self._basic_info.get('当前保种数量', 0))\n                    self.seeding_size = num_filesize_safe(self._basic_info.get('当前保种容量', ''))\n\n        # 🔑 最终对外统一转字符串，避免 join 报错\n        self.userid = str(self.userid or \"\")\n        self.username = str(self.username or \"\")\n        self.user_level = str(self.user_level or \"\")\n        self.join_at = str(self.join_at or \"\")\n\n        self.upload = str(self.upload or 0)\n        self.download = str(self.download or 0)\n        self.ratio = str(self.ratio or 0)\n        self.bonus = str(self.bonus or 0.0)\n        self.message_unread = str(self.message_unread or 0)\n\n        self.seeding = str(self.seeding or 0)\n        self.seeding_size = str(self.seeding_size or 0)"
  },
  {
    "path": "app/modules/indexer/spider/__init__.py",
    "content": "import datetime\nimport re\nimport traceback\nfrom typing import Any, Optional\nfrom typing import List\nfrom urllib.parse import quote, urlencode, urlparse, parse_qs\n\nfrom fastapi.concurrency import run_in_threadpool\nfrom jinja2 import Template\nfrom pyquery import PyQuery\n\nfrom app.core.config import settings\nfrom app.log import logger\nfrom app.schemas.types import MediaType\nfrom app.utils.http import RequestUtils, AsyncRequestUtils\nfrom app.utils.string import StringUtils\n\n\nclass SiteSpider:\n    \"\"\"\n    站点爬虫\n    \"\"\"\n\n    @property\n    def __class__(self):\n        return object\n\n    @property\n    def __dict__(self):\n        return {}\n\n    @property\n    def __dir__(self):\n        raise AttributeError(f\"Cannot read protected attribute!\")\n\n    def __init__(self,\n                 indexer: dict,\n                 keyword: Optional[str] = None,\n                 mtype: MediaType = None,\n                 cat: Optional[str] = None,\n                 page: Optional[int] = 0,\n                 referer: Optional[str] = None):\n        \"\"\"\n        设置查询参数\n        :param indexer: 索引器\n        :param keyword: 搜索关键字，如果数组则为批量搜索\n        :param mtype: 媒体类型\n        :param cat: 搜索分类\n        :param page: 页码\n        :param referer: Referer\n        \"\"\"\n        if not indexer:\n            return\n        self.keyword = keyword\n        self.cat = cat\n        self.mtype = mtype\n        self.indexerid = indexer.get('id')\n        self.indexername = indexer.get('name')\n        self.search = indexer.get('search')\n        self.batch = indexer.get('batch')\n        self.browse = indexer.get('browse')\n        self.category = indexer.get('category')\n        self.list = indexer.get('torrents').get('list', {})\n        self.fields = indexer.get('torrents').get('fields')\n        if not keyword and self.browse:\n            self.list = self.browse.get('list') or self.list\n            self.fields = self.browse.get('fields') or self.fields\n        self.domain = indexer.get('domain')\n        self.result_num = int(indexer.get('result_num') or 100)\n        self._timeout = int(indexer.get('timeout') or 15)\n        self.page = page\n        if self.domain and not str(self.domain).endswith(\"/\"):\n            self.domain = self.domain + \"/\"\n        self.ua = indexer.get('ua') or settings.USER_AGENT\n        self.proxies = settings.PROXY if indexer.get('proxy') else None\n        self.proxy_server = settings.PROXY_SERVER if indexer.get('proxy') else None\n        self.cookie = indexer.get('cookie')\n        self.referer = referer\n        # 初始化属性\n        self.is_error = False\n        self.torrents_info = {}\n        self.torrents_info_array = []\n\n    def __get_search_url(self):\n        \"\"\"\n        获取搜索URL\n        \"\"\"\n        # 种子搜索相对路径\n        paths = self.search.get('paths', [])\n        torrentspath = \"\"\n        if len(paths) == 1:\n            torrentspath = paths[0].get('path', '')\n        else:\n            for path in paths:\n                if path.get(\"type\") == \"all\" and not self.mtype:\n                    torrentspath = path.get('path')\n                    break\n                elif path.get(\"type\") == \"movie\" and self.mtype == MediaType.MOVIE:\n                    torrentspath = path.get('path')\n                    break\n                elif path.get(\"type\") == \"tv\" and self.mtype == MediaType.TV:\n                    torrentspath = path.get('path')\n                    break\n\n        # 精确搜索\n        if self.keyword:\n            if isinstance(self.keyword, list):\n                # 批量查询\n                if self.batch:\n                    delimiter = self.batch.get('delimiter') or ' '\n                    space_replace = self.batch.get('space_replace') or ' '\n                    search_word = delimiter.join([str(k).replace(' ',\n                                                                 space_replace) for k in self.keyword])\n                else:\n                    search_word = \" \".join(self.keyword)\n                # 查询模式：或\n                search_mode = \"1\"\n            else:\n                # 单个查询\n                search_word = self.keyword\n                # 查询模式与\n                search_mode = \"0\"\n\n            # 搜索URL\n            indexer_params = self.search.get(\"params\", {}).copy()\n            if indexer_params:\n                search_area = indexer_params.get('search_area')\n                # search_area非0表示支持imdbid搜索\n                if (search_area and\n                        (not self.keyword or not self.keyword.startswith('tt'))):\n                    # 支持imdbid搜索，但关键字不是imdbid时，不启用imdbid搜索\n                    indexer_params.pop('search_area')\n                # 变量字典\n                inputs_dict = {\n                    \"keyword\": search_word\n                }\n                # 查询参数，默认查询标题\n                params = {\n                    \"search_mode\": search_mode,\n                    \"search_area\": 0,\n                    \"page\": self.page or 0,\n                    \"notnewword\": 1\n                }\n                # 额外参数\n                for key, value in indexer_params.items():\n                    params.update({\n                        \"%s\" % key: str(value).format(**inputs_dict)\n                    })\n                # 分类条件\n                if self.category:\n                    if self.mtype == MediaType.TV:\n                        cats = self.category.get(\"tv\") or []\n                    elif self.mtype == MediaType.MOVIE:\n                        cats = self.category.get(\"movie\") or []\n                    else:\n                        cats = (self.category.get(\"movie\") or []) + (self.category.get(\"tv\") or [])\n                    allowed_cats = set(self.cat.split(',')) if self.cat else None\n                    for cat in cats:\n                        if allowed_cats and str(cat.get('id')) not in allowed_cats:\n                            continue\n                        if self.category.get(\"field\"):\n                            value = params.get(self.category.get(\"field\"), \"\")\n                            params.update({\n                                \"%s\" % self.category.get(\"field\"): value + self.category.get(\"delimiter\",\n                                                                                             ' ') + cat.get(\"id\")\n                            })\n                        else:\n                            params.update({\n                                \"cat%s\" % cat.get(\"id\"): 1\n                            })\n                searchurl = self.domain + torrentspath + \"?\" + urlencode(params)\n            else:\n                # 变量字典\n                inputs_dict = {\n                    \"keyword\": quote(search_word),\n                    \"page\": self.page or 0\n                }\n                # 无额外参数\n                searchurl = self.domain + str(torrentspath).format(**inputs_dict)\n\n        # 列表浏览\n        else:\n            # 变量字典\n            inputs_dict = {\n                \"page\": self.page or 0,\n                \"keyword\": \"\"\n            }\n            # 有单独浏览路径\n            if self.browse:\n                torrentspath = self.browse.get(\"path\")\n                if self.browse.get(\"start\"):\n                    start_page = int(self.browse.get(\"start\")) + int(self.page or 0)\n                    inputs_dict.update({\n                        \"page\": start_page\n                    })\n            elif self.page:\n                torrentspath = torrentspath + f\"?page={self.page}\"\n            # 搜索Url\n            searchurl = self.domain + str(torrentspath).format(**inputs_dict)\n\n        return searchurl\n\n    def get_torrents(self) -> List[dict]:\n        \"\"\"\n        开始请求\n        \"\"\"\n        if not self.search or not self.domain:\n            return []\n\n        # 获取搜索URL\n        searchurl = self.__get_search_url()\n\n        logger.info(f\"开始请求：{searchurl}\")\n\n        # requests请求\n        ret = RequestUtils(\n            ua=self.ua,\n            cookies=self.cookie,\n            timeout=self._timeout,\n            referer=self.referer,\n            proxies=self.proxies\n        ).get_res(searchurl, allow_redirects=True)\n        # 解析返回\n        return self.parse(\n            RequestUtils.get_decoded_html_content(\n                ret,\n                performance_mode=settings.ENCODING_DETECTION_PERFORMANCE_MODE,\n                confidence_threshold=settings.ENCODING_DETECTION_MIN_CONFIDENCE\n            )\n        )\n\n    async def async_get_torrents(self) -> List[dict]:\n        \"\"\"\n        异步请求\n        \"\"\"\n        if not self.search or not self.domain:\n            return []\n\n        # 获取搜索URL\n        searchurl = self.__get_search_url()\n\n        logger.info(f\"开始异步请求：{searchurl}\")\n\n        # httpx请求\n        ret = await AsyncRequestUtils(\n            ua=self.ua,\n            cookies=self.cookie,\n            timeout=self._timeout,\n            referer=self.referer,\n            proxies=self.proxies\n        ).get_res(searchurl, allow_redirects=True)\n        # 解析返回\n        return await run_in_threadpool(\n            self.parse,\n            RequestUtils.get_decoded_html_content(\n                ret,\n                performance_mode=settings.ENCODING_DETECTION_PERFORMANCE_MODE,\n                confidence_threshold=settings.ENCODING_DETECTION_MIN_CONFIDENCE\n            )\n        )\n\n    def __get_title(self, torrent: Any):\n        # title default text\n        if 'title' not in self.fields:\n            return\n        selector = self.fields.get('title', {})\n        if 'selector' in selector:\n            self.torrents_info['title'] = self._safe_query(torrent, selector)\n        elif 'text' in selector:\n            render_dict = {}\n            if \"title_default\" in self.fields:\n                title_default_selector = self.fields.get('title_default', {})\n                title_default = self._safe_query(torrent, title_default_selector)\n                render_dict.update({'title_default': title_default})\n            if \"title_optional\" in self.fields:\n                title_optional_selector = self.fields.get('title_optional', {})\n                title_optional = self._safe_query(torrent, title_optional_selector)\n                render_dict.update({'title_optional': title_optional})\n            self.torrents_info['title'] = Template(selector.get('text')).render(fields=render_dict)\n        self.torrents_info['title'] = self.__filter_text(self.torrents_info.get('title'),\n                                                         selector.get('filters'))\n\n    def __get_description(self, torrent: Any):\n        # description text\n        if 'description' not in self.fields:\n            return\n        selector = self.fields.get('description', {})\n        if \"selector\" in selector or \"selectors\" in selector:\n            # 对于selectors情况，需要特殊处理selector_config\n            desc_selector = selector.copy()\n            if \"selectors\" in selector and \"selector\" not in selector:\n                desc_selector[\"selector\"] = selector.get(\"selectors\", \"\")\n            self.torrents_info['description'] = self._safe_query(torrent, desc_selector)\n        elif \"text\" in selector:\n            render_dict = {}\n            if \"tags\" in self.fields:\n                tags_selector = self.fields.get('tags', {})\n                tag = self._safe_query(torrent, tags_selector)\n                render_dict.update({'tags': tag})\n            if \"subject\" in self.fields:\n                subject_selector = self.fields.get('subject', {})\n                subject = self._safe_query(torrent, subject_selector)\n                render_dict.update({'subject': subject})\n            if \"description_free_forever\" in self.fields:\n                description_free_forever_selector = self.fields.get(\"description_free_forever\", {})\n                description_free_forever = self._safe_query(torrent, description_free_forever_selector)\n                render_dict.update({\"description_free_forever\": description_free_forever})\n            if \"description_normal\" in self.fields:\n                description_normal_selector = self.fields.get(\"description_normal\", {})\n                description_normal = self._safe_query(torrent, description_normal_selector)\n                render_dict.update({\"description_normal\": description_normal})\n            self.torrents_info['description'] = Template(selector.get('text')).render(fields=render_dict)\n        self.torrents_info['description'] = self.__filter_text(self.torrents_info.get('description'),\n                                                               selector.get('filters'))\n\n    def __get_detail(self, torrent: Any):\n        # details page text\n        if 'details' not in self.fields:\n            return\n        selector = self.fields.get('details', {})\n        item = self._safe_query(torrent, selector)\n        detail_link = self.__filter_text(item, selector.get('filters'))\n        if detail_link:\n            if not detail_link.startswith(\"http\"):\n                if detail_link.startswith(\"//\"):\n                    self.torrents_info['page_url'] = self.domain.split(\":\")[0] + \":\" + detail_link\n                elif detail_link.startswith(\"/\"):\n                    self.torrents_info['page_url'] = self.domain + detail_link[1:]\n                else:\n                    self.torrents_info['page_url'] = self.domain + detail_link\n            else:\n                self.torrents_info['page_url'] = detail_link\n\n    def __get_download(self, torrent: Any):\n        # download link text\n        if 'download' not in self.fields:\n            return\n        selector = self.fields.get('download', {})\n        item = self._safe_query(torrent, selector)\n        download_link = self.__filter_text(item, selector.get('filters'))\n        if download_link:\n            if not download_link.startswith(\"http\") \\\n                    and not download_link.startswith(\"magnet\"):\n                _scheme, _domain = StringUtils.get_url_netloc(self.domain)\n                if _domain in download_link:\n                    if download_link.startswith(\"/\"):\n                        self.torrents_info['enclosure'] = f\"{_scheme}:{download_link}\"\n                    else:\n                        self.torrents_info['enclosure'] = f\"{_scheme}://{download_link}\"\n                else:\n                    if download_link.startswith(\"/\"):\n                        self.torrents_info['enclosure'] = f\"{self.domain}{download_link[1:]}\"\n                    else:\n                        self.torrents_info['enclosure'] = f\"{self.domain}{download_link}\"\n            else:\n                self.torrents_info['enclosure'] = download_link\n\n    def __get_imdbid(self, torrent: Any):\n        # imdbid\n        if \"imdbid\" not in self.fields:\n            return\n        selector = self.fields.get('imdbid', {})\n        item = self._safe_query(torrent, selector)\n        self.torrents_info['imdbid'] = self.__filter_text(item, selector.get('filters'))\n\n    def __get_size(self, torrent: Any):\n        # torrent size int\n        if 'size' not in self.fields:\n            return\n        selector = self.fields.get('size', {})\n        item = self._safe_query(torrent, selector)\n        if item:\n            size_val = item.replace(\"\\n\", \"\").strip()\n            size_val = self.__filter_text(size_val,\n                                          selector.get('filters'))\n            self.torrents_info['size'] = StringUtils.num_filesize(size_val)\n        else:\n            self.torrents_info['size'] = 0\n\n    def __get_leechers(self, torrent: Any):\n        # torrent leechers int\n        if 'leechers' not in self.fields:\n            return\n        selector = self.fields.get('leechers', {})\n        item = self._safe_query(torrent, selector)\n        if item:\n            peers_val = item.split(\"/\")[0]\n            peers_val = peers_val.replace(\",\", \"\")\n            peers_val = self.__filter_text(peers_val, selector.get('filters'))\n            self.torrents_info['peers'] = int(peers_val) if peers_val and peers_val.isdigit() else 0\n        else:\n            self.torrents_info['peers'] = 0\n\n    def __get_seeders(self, torrent: Any):\n        # torrent seeders int\n        if 'seeders' not in self.fields:\n            return\n        selector = self.fields.get('seeders', {})\n        item = self._safe_query(torrent, selector)\n        if item:\n            seeders_val = item.split(\"/\")[0]\n            seeders_val = seeders_val.replace(\",\", \"\")\n            seeders_val = self.__filter_text(seeders_val, selector.get('filters'))\n            self.torrents_info['seeders'] = int(seeders_val) if seeders_val and seeders_val.isdigit() else 0\n        else:\n            self.torrents_info['seeders'] = 0\n\n    def __get_grabs(self, torrent: Any):\n        # torrent grabs int\n        if 'grabs' not in self.fields:\n            return\n        selector = self.fields.get('grabs', {})\n        item = self._safe_query(torrent, selector)\n        if item:\n            grabs_val = item.split(\"/\")[0]\n            grabs_val = grabs_val.replace(\",\", \"\")\n            grabs_val = self.__filter_text(grabs_val, selector.get('filters'))\n            self.torrents_info['grabs'] = int(grabs_val) if grabs_val and grabs_val.isdigit() else 0\n        else:\n            self.torrents_info['grabs'] = 0\n\n    def __get_pubdate(self, torrent: Any):\n        # torrent pubdate yyyy-mm-dd hh:mm:ss\n        if 'date_added' not in self.fields:\n            return\n        selector = self.fields.get('date_added', {})\n        pubdate_str = self._safe_query(torrent, selector)\n        if pubdate_str:\n            pubdate_str = pubdate_str.replace('\\n', ' ').strip()\n        self.torrents_info['pubdate'] = self.__filter_text(pubdate_str, selector.get('filters'))\n        if self.torrents_info.get('pubdate'):\n            try:\n                if not isinstance(self.torrents_info['pubdate'], datetime.datetime):\n                    datetime.datetime.strptime(str(self.torrents_info['pubdate']), '%Y-%m-%d %H:%M:%S')\n            except (ValueError, TypeError):\n                self.torrents_info['pubdate'] = StringUtils.unify_datetime_str(str(self.torrents_info['pubdate']))\n\n    def __get_date_elapsed(self, torrent: Any):\n        # torrent date elapsed text\n        if 'date_elapsed' not in self.fields:\n            return\n        selector = self.fields.get('date_elapsed', {})\n        date_elapsed = self._safe_query(torrent, selector)\n        self.torrents_info['date_elapsed'] = self.__filter_text(date_elapsed, selector.get('filters'))\n\n    def __get_downloadvolumefactor(self, torrent: Any):\n        # downloadvolumefactor int\n        selector = self.fields.get('downloadvolumefactor', {})\n        if not selector:\n            return\n        self.torrents_info['downloadvolumefactor'] = 1\n        if 'case' in selector:\n            for downloadvolumefactorselector in list(selector.get('case', {}).keys()):\n                downloadvolumefactor = torrent(downloadvolumefactorselector)\n                try:\n                    if len(downloadvolumefactor) > 0:\n                        self.torrents_info['downloadvolumefactor'] = selector.get('case', {}).get(\n                            downloadvolumefactorselector)\n                        break\n                finally:\n                    downloadvolumefactor.clear()\n                    del downloadvolumefactor\n        elif \"selector\" in selector:\n            item = self._safe_query(torrent, selector)\n            if item:\n                downloadvolumefactor = re.search(r'(\\d+\\.?\\d*)', item)\n                if downloadvolumefactor:\n                    self.torrents_info['downloadvolumefactor'] = int(downloadvolumefactor.group(1))\n\n    def __get_uploadvolumefactor(self, torrent: Any):\n        # uploadvolumefactor int\n        selector = self.fields.get('uploadvolumefactor', {})\n        if not selector:\n            return\n        self.torrents_info['uploadvolumefactor'] = 1\n        if 'case' in selector:\n            for uploadvolumefactorselector in list(selector.get('case', {}).keys()):\n                uploadvolumefactor = torrent(uploadvolumefactorselector)\n                try:\n                    if len(uploadvolumefactor) > 0:\n                        self.torrents_info['uploadvolumefactor'] = selector.get('case', {}).get(\n                            uploadvolumefactorselector)\n                        break\n                finally:\n                    uploadvolumefactor.clear()\n                    del uploadvolumefactor\n        elif \"selector\" in selector:\n            item = self._safe_query(torrent, selector)\n            if item:\n                uploadvolumefactor = re.search(r'(\\d+\\.?\\d*)', item)\n                if uploadvolumefactor:\n                    self.torrents_info['uploadvolumefactor'] = int(uploadvolumefactor.group(1))\n\n    def __get_labels(self, torrent: Any):\n        # labels ['label1', 'label2']\n        if 'labels' not in self.fields:\n            return\n        selector = self.fields.get('labels', {})\n        if not selector.get('selector'):\n            self.torrents_info['labels'] = []\n            return\n\n        # labels需要特殊处理，因为它返回的是列表\n        labels = torrent(selector.get(\"selector\", \"\")).clone()\n        try:\n            self.__remove(labels, selector)\n            items = self.__attribute_or_text(labels, selector)\n            if items:\n                self.torrents_info['labels'] = [item for item in items if item]\n            else:\n                self.torrents_info['labels'] = []\n        finally:\n            labels.clear()\n            del labels\n\n    def __get_free_date(self, torrent: Any):\n        # free date yyyy-mm-dd hh:mm:ss\n        if 'freedate' not in self.fields:\n            return\n        selector = self.fields.get('freedate', {})\n        freedate = self._safe_query(torrent, selector)\n        self.torrents_info['freedate'] = self.__filter_text(freedate, selector.get('filters'))\n\n    def __get_hit_and_run(self, torrent: Any):\n        # hitandrun True/False\n        if 'hr' not in self.fields:\n            return\n        selector = self.fields.get('hr', {})\n        hit_and_run = torrent(selector.get('selector', ''))\n        try:\n            if hit_and_run:\n                self.torrents_info['hit_and_run'] = True\n            else:\n                self.torrents_info['hit_and_run'] = False\n        finally:\n            hit_and_run.clear()\n            del hit_and_run\n\n    def __get_category(self, torrent: Any):\n        # category 电影/电视剧\n        if 'category' not in self.fields:\n            return\n        selector = self.fields.get('category', {})\n        category_value = self._safe_query(torrent, selector)\n        category_value = self.__filter_text(category_value, selector.get('filters'))\n        if category_value and self.category:\n            tv_cats = [str(cat.get(\"id\")) for cat in self.category.get(\"tv\") or []]\n            movie_cats = [str(cat.get(\"id\")) for cat in self.category.get(\"movie\") or []]\n            if category_value in tv_cats \\\n                    and category_value not in movie_cats:\n                self.torrents_info['category'] = MediaType.TV.value\n            elif category_value in movie_cats:\n                self.torrents_info['category'] = MediaType.MOVIE.value\n            else:\n                self.torrents_info['category'] = MediaType.UNKNOWN.value\n        else:\n            self.torrents_info['category'] = MediaType.UNKNOWN.value\n\n    def _safe_query(self, torrent: Any, selector_config: Optional[dict]) -> Optional[str]:\n        \"\"\"\n        安全地执行PyQuery查询并自动清理资源\n        :param torrent: PyQuery对象\n        :param selector_config: 选择器配置\n        :return: 处理后的结果\n        \"\"\"\n        if not selector_config or not selector_config.get('selector'):\n            return None\n\n        query_obj = torrent(selector_config.get('selector', '')).clone()\n        try:\n            self.__remove(query_obj, selector_config)\n            items = self.__attribute_or_text(query_obj, selector_config)\n            return self.__index(items, selector_config)\n        finally:\n            query_obj.clear()\n            del query_obj\n\n    def get_info(self, torrent: Any) -> dict:\n        \"\"\"\n        解析单条种子数据\n        \"\"\"\n        # 每次调用时重新初始化，避免数据累积\n        self.torrents_info = {}\n        try:\n            # 标题\n            self.__get_title(torrent)\n            # 描述\n            self.__get_description(torrent)\n            # 详情页面\n            self.__get_detail(torrent)\n            # 下载链接\n            self.__get_download(torrent)\n            # 完成数\n            self.__get_grabs(torrent)\n            # 下载数\n            self.__get_leechers(torrent)\n            # 做种数\n            self.__get_seeders(torrent)\n            # 大小\n            self.__get_size(torrent)\n            # IMDBID\n            self.__get_imdbid(torrent)\n            # 下载系数\n            self.__get_downloadvolumefactor(torrent)\n            # 上传系数\n            self.__get_uploadvolumefactor(torrent)\n            # 发布时间\n            self.__get_pubdate(torrent)\n            # 已发布时间\n            self.__get_date_elapsed(torrent)\n            # 免费载止时间\n            self.__get_free_date(torrent)\n            # 标签\n            self.__get_labels(torrent)\n            # HR\n            self.__get_hit_and_run(torrent)\n            # 分类\n            self.__get_category(torrent)\n            # 返回当前种子信息的副本，而不是引用\n            return self.torrents_info.copy() if self.torrents_info else {}\n        except Exception as err:\n            logger.error(\"%s 搜索出现错误：%s\" % (self.indexername, str(err)))\n            return {}\n        finally:\n            self.torrents_info.clear()\n\n    @staticmethod\n    def __filter_text(text: Optional[str], filters: Optional[List[dict]]) -> str:\n        \"\"\"\n        对文件进行处理\n        \"\"\"\n        if not text or not filters or not isinstance(filters, list):\n            return text\n        if not isinstance(text, str):\n            text = str(text)\n        for filter_item in filters:\n            if not text:\n                break\n            method_name = filter_item.get(\"name\")\n            try:\n                args = filter_item.get(\"args\")\n                if method_name == \"re_search\" and isinstance(args, list):\n                    rematch = re.search(r\"%s\" % args[0], text)\n                    if rematch:\n                        text = rematch.group(args[-1])\n                elif method_name == \"split\" and isinstance(args, list):\n                    text = text.split(r\"%s\" % args[0])[args[-1]]\n                elif method_name == \"replace\" and isinstance(args, list):\n                    text = text.replace(r\"%s\" % args[0], r\"%s\" % args[-1])\n                elif method_name == \"dateparse\" and isinstance(args, str):\n                    text = text.replace(\"\\n\", \" \").strip()\n                    text = datetime.datetime.strptime(text, r\"%s\" % args)\n                elif method_name == \"strip\":\n                    text = text.strip()\n                elif method_name == \"appendleft\":\n                    text = f\"{args}{text}\"\n                elif method_name == \"querystring\":\n                    parsed_url = urlparse(str(text))\n                    query_params = parse_qs(parsed_url.query)\n                    param_value = query_params.get(args)\n                    text = param_value[0] if param_value else ''\n            except Exception as err:\n                logger.debug(f'过滤器 {method_name} 处理失败：{str(err)} - {traceback.format_exc()}')\n        return text.strip()\n\n    @staticmethod\n    def __remove(item: Any, selector: Optional[dict]):\n        \"\"\"\n        移除元素\n        \"\"\"\n        if selector and \"remove\" in selector:\n            removelist = selector.get('remove', '').split(', ')\n            for v in removelist:\n                item.remove(v)\n\n    @staticmethod\n    def __attribute_or_text(item: Any, selector: Optional[dict]) -> list:\n        if not selector:\n            return item\n        if not item:\n            return []\n        if 'attribute' in selector:\n            items = [i.attr(selector.get('attribute')) for i in item.items() if i]\n        else:\n            items = [i.text() for i in item.items() if i]\n        return items\n\n    @staticmethod\n    def __index(items: Optional[list], selector: Optional[dict]) -> Optional[str]:\n        if not items:\n            return None\n        if selector:\n            if \"contents\" in selector \\\n                    and len(items) > int(selector.get(\"contents\")):\n                item = items[0].split(\"\\n\")[selector.get(\"contents\")]\n            elif \"index\" in selector \\\n                    and len(items) > int(selector.get(\"index\")):\n                item = items[int(selector.get(\"index\"))]\n            else:\n                item = items[0]\n        else:\n            item = items[0]\n        return item\n\n    def parse(self, html_text: str) -> List[dict]:\n        \"\"\"\n        解析整个页面\n        \"\"\"\n        if not html_text:\n            self.is_error = True\n            return []\n\n        # 清空旧结果\n        self.torrents_info_array = []\n        html_doc = None\n        try:\n            # 解析站点文本对象\n            html_doc = PyQuery(html_text)\n            # 种子筛选器\n            torrents_selector = self.list.get('selector', '')\n            # 遍历种子html列表\n            for i, torn in enumerate(html_doc(torrents_selector)):\n                if i >= int(self.result_num):\n                    break\n                # 创建临时PyQuery对象进行解析\n                torrent_query = PyQuery(torn)\n                try:\n                    # 直接获取种子信息，避免深拷贝\n                    torrent_info = self.get_info(torrent_query)\n                    if torrent_info:\n                        # 浅拷贝即可，减少内存使用\n                        self.torrents_info_array.append(torrent_info)\n                finally:\n                    # 显式删除临时PyQuery对象\n                    torrent_query.clear()\n                    del torrent_query\n            # 返回数组的副本，防止被后续清理操作影响\n            return self.torrents_info_array.copy()\n        except Exception as err:\n            self.is_error = True\n            logger.warn(f\"错误：{self.indexername} {str(err)}\")\n            return []\n        finally:\n            # 清理种子缓存\n            self.torrents_info_array.clear()\n            # 清理HTML文档对象\n            if html_doc is not None:\n                html_doc.clear()\n                del html_doc\n            # 清理html_text引用\n            del html_text\n"
  },
  {
    "path": "app/modules/indexer/spider/haidan.py",
    "content": "import urllib.parse\nfrom typing import Tuple, List\n\nfrom app.core.config import settings\nfrom app.db.systemconfig_oper import SystemConfigOper\nfrom app.log import logger\nfrom app.schemas import MediaType\nfrom app.utils.http import RequestUtils, AsyncRequestUtils\nfrom app.utils.string import StringUtils\n\n\nclass HaiDanSpider:\n    \"\"\"\n    haidan.video API\n    \"\"\"\n    _indexerid = None\n    _domain = None\n    _url = None\n    _name = \"\"\n    _proxy = None\n    _cookie = None\n    _ua = None\n    _size = 100\n    _searchurl = \"%storrents.php\"\n    _detailurl = \"%sdetails.php?group_id=%s&torrent_id=%s\"\n    _timeout = 15\n\n    # 电影分类\n    _movie_category = ['401', '404', '405']\n    _tv_category = ['402', '403', '404', '405']\n\n    # 足销状态 1-普通，2-免费，3-2X，4-2X免费，5-50%，6-2X50%，7-30%\n    _dl_state = {\n        \"1\": 1,\n        \"2\": 0,\n        \"3\": 1,\n        \"4\": 0,\n        \"5\": 0.5,\n        \"6\": 0.5,\n        \"7\": 0.3\n    }\n    _up_state = {\n        \"1\": 1,\n        \"2\": 1,\n        \"3\": 2,\n        \"4\": 2,\n        \"5\": 1,\n        \"6\": 2,\n        \"7\": 1\n    }\n\n    def __init__(self, indexer: dict):\n        self.systemconfig = SystemConfigOper()\n        if indexer:\n            self._indexerid = indexer.get('id')\n            self._url = indexer.get('domain')\n            self._domain = StringUtils.get_url_domain(self._url)\n            self._searchurl = self._searchurl % self._url\n            self._name = indexer.get('name')\n            if indexer.get('proxy'):\n                self._proxy = settings.PROXY\n            self._cookie = indexer.get('cookie')\n            self._ua = indexer.get('ua')\n            self._timeout = indexer.get('timeout') or 15\n\n    def __get_params(self, keyword: str, mtype: MediaType = None) -> dict:\n        \"\"\"\n        获取请求参数\n        \"\"\"\n\n        def __dict_to_query(_params: dict):\n            \"\"\"\n            将数组转换为逗号分隔的字符串\n            \"\"\"\n            for key, value in _params.items():\n                if isinstance(value, list):\n                    _params[key] = ','.join(map(str, value))\n            return urllib.parse.urlencode(_params)\n\n        if not mtype:\n            categories = []\n        elif mtype == MediaType.TV:\n            categories = self._tv_category\n        else:\n            categories = self._movie_category\n\n        # 搜索类型\n        if keyword and keyword.startswith('tt'):\n            search_area = '4'\n        else:\n            search_area = '0'\n\n        return __dict_to_query({\n            \"isapi\": \"1\",\n            \"search_area\": search_area,  # 0-标题 1-简介（较慢）3-发种用户名 4-IMDb\n            \"search\": keyword,\n            \"search_mode\": \"0\",  # 0-与 1-或 2-精准\n            \"cat\": categories\n        })\n\n    def __parse_result(self, result: dict):\n        \"\"\"\n        解析结果\n        \"\"\"\n        torrents = []\n        data = result.get('data') or {}\n        for tid, item in data.items():\n            category_value = result.get('category')\n            if category_value in self._tv_category \\\n                    and category_value not in self._movie_category:\n                category = MediaType.TV.value\n            elif category_value in self._movie_category:\n                category = MediaType.MOVIE.value\n            else:\n                category = MediaType.UNKNOWN.value\n            torrent = {\n                'title': item.get('name'),\n                'description': item.get('small_descr'),\n                'enclosure': item.get('url'),\n                'pubdate': StringUtils.format_timestamp(item.get('added')),\n                'size': int(item.get('size') or '0'),\n                'seeders': int(item.get('seeders') or '0'),\n                'peers': int(item.get(\"leechers\") or '0'),\n                'grabs': int(item.get(\"times_completed\") or '0'),\n                'downloadvolumefactor': self.__get_downloadvolumefactor(item.get('sp_state')),\n                'uploadvolumefactor': self.__get_uploadvolumefactor(item.get('sp_state')),\n                'page_url': self._detailurl % (self._url, item.get('group_id'), tid),\n                'labels': [],\n                'category': category\n            }\n            torrents.append(torrent)\n        return torrents\n\n    def search(self, keyword: str, mtype: MediaType = None) -> Tuple[bool, List[dict]]:\n        \"\"\"\n        搜索\n        \"\"\"\n\n        # 检查cookie\n        if not self._cookie:\n            return True, []\n\n        # 获取参数\n        params_str = self.__get_params(keyword, mtype)\n\n        # 发送请求\n        res = RequestUtils(\n            cookies=self._cookie,\n            ua=self._ua,\n            proxies=self._proxy,\n            timeout=self._timeout\n        ).get_res(url=f\"{self._searchurl}?{params_str}\")\n        if res and res.status_code == 200:\n            result = res.json()\n            code = result.get('code')\n            if code != 0:\n                logger.warn(f\"{self._name} 搜索失败：{result.get('msg')}\")\n                return True, []\n            return False, self.__parse_result(result)\n        elif res is not None:\n            logger.warn(f\"{self._name} 搜索失败，错误码：{res.status_code}\")\n            return True, []\n        else:\n            logger.warn(f\"{self._name} 搜索失败，无法连接 {self._domain}\")\n            return True, []\n\n    async def async_search(self, keyword: str, mtype: MediaType = None) -> Tuple[bool, List[dict]]:\n        \"\"\"\n        异步搜索\n        \"\"\"\n        # 检查cookie\n        if not self._cookie:\n            return True, []\n\n        # 获取参数\n        params_str = self.__get_params(keyword, mtype)\n\n        # 发送请求\n        res = await AsyncRequestUtils(\n            cookies=self._cookie,\n            ua=self._ua,\n            proxies=self._proxy,\n            timeout=self._timeout\n        ).get_res(url=f\"{self._searchurl}?{params_str}\")\n\n        if res and res.status_code == 200:\n            result = res.json()\n            code = result.get('code')\n            if code != 0:\n                logger.warn(f\"{self._name} 搜索失败：{result.get('msg')}\")\n                return True, []\n            return False, self.__parse_result(result)\n        elif res is not None:\n            logger.warn(f\"{self._name} 搜索失败，错误码：{res.status_code}\")\n            return True, []\n        else:\n            logger.warn(f\"{self._name} 搜索失败，无法连接 {self._domain}\")\n            return True, []\n\n    def __get_downloadvolumefactor(self, discount: str) -> float:\n        \"\"\"\n        获取下载系数\n        \"\"\"\n        if discount:\n            return self._dl_state.get(discount, 1)\n        return 1\n\n    def __get_uploadvolumefactor(self, discount: str) -> float:\n        \"\"\"\n        获取上传系数\n        \"\"\"\n        if discount:\n            return self._up_state.get(discount, 1)\n        return 1\n"
  },
  {
    "path": "app/modules/indexer/spider/hddolby.py",
    "content": "from typing import Tuple, List, Optional\n\nfrom app.core.config import settings\nfrom app.db.systemconfig_oper import SystemConfigOper\nfrom app.log import logger\nfrom app.schemas import MediaType\nfrom app.utils.http import RequestUtils, AsyncRequestUtils\nfrom app.utils.string import StringUtils\n\n\nclass HddolbySpider:\n    \"\"\"\n    HDDolby API\n    \"\"\"\n    _indexerid = None\n    _domain = None\n    _domain_host = None\n    _name = \"\"\n    _proxy = None\n    _cookie = None\n    _ua = None\n    _apikey = None\n    _size = 40\n    _pageurl = None\n    _timeout = 15\n    _searchurl = None\n\n    # 分类\n    _movie_category = [401, 405]\n    _tv_category = [402, 403, 404, 405]\n\n    # 标签\n    _labels = {\n        \"gf\": \"官方\",\n        \"gy\": \"国语\",\n        \"yy\": \"粤语\",\n        \"ja\": \"日语\",\n        \"ko\": \"韩语\",\n        \"zz\": \"中文字幕\",\n        \"jz\": \"禁转\",\n        \"xz\": \"限转\",\n        \"diy\": \"DIY\",\n        \"sf\": \"首发\",\n        \"yq\": \"应求\",\n        \"m0\": \"零魔\",\n        \"yc\": \"原创\",\n        \"gz\": \"官字\",\n        \"db\": \"Dolby Vision\",\n        \"hdr10\": \"HDR10\",\n        \"hdrm\": \"HDR10+\",\n        \"tx\": \"特效\",\n        \"lz\": \"连载\",\n        \"wj\": \"完结\",\n        \"hdrv\": \"HDR Vivid\",\n        \"hlg\": \"HLG\",\n        \"hq\": \"高码率\",\n        \"hfr\": \"高帧率\",\n    }\n\n    def __init__(self, indexer: dict):\n        self.systemconfig = SystemConfigOper()\n        if indexer:\n            self._indexerid = indexer.get('id')\n            self._domain = indexer.get('domain')\n            self._domain_host = StringUtils.get_url_domain(self._domain)\n            self._name = indexer.get('name')\n            if indexer.get('proxy'):\n                self._proxy = settings.PROXY\n            self._cookie = indexer.get('cookie')\n            self._ua = indexer.get('ua')\n            self._apikey = indexer.get('apikey')\n            self._timeout = indexer.get('timeout') or 15\n            self._searchurl = f\"https://api.{self._domain_host}/api/v1/torrent/search\"\n            self._pageurl = f\"{self._domain}details.php?id=%s&hit=1\"\n\n    def __get_params(self, keyword: str, mtype: MediaType = None, page: Optional[int] = 0) -> dict:\n        \"\"\"\n        获取请求参数\n        \"\"\"\n        if mtype == MediaType.TV:\n            categories = self._tv_category\n        elif mtype == MediaType.MOVIE:\n            categories = self._movie_category\n        else:\n            categories = list(set(self._movie_category + self._tv_category))\n\n        # 输入参数\n        return {\n            \"keyword\": keyword,\n            \"page_number\": page,\n            \"page_size\": 100,\n            \"categories\": categories,\n            \"visible\": 1,\n        }\n\n    def __parse_result(self, results: List[dict]) -> List[dict]:\n        \"\"\"\n        解析搜索结果\n        \"\"\"\n        torrents = []\n        if not results:\n            return []\n\n        for result in results:\n            \"\"\"\n            {\n                \"id\": 120202,\n                \"promotion_time_type\": 0,\n                \"promotion_until\": \"0000-00-00 00:00:00\",\n                \"category\": 402,\n                \"medium\": 6,\n                \"codec\": 1,\n                \"standard\": 2,\n                \"team\": 10,\n                \"audiocodec\": 14,\n                \"leechers\": 0,\n                \"seeders\": 1,\n                \"name\": \"[DBY] Lost S06 2010 Complete 1080p Netflix WEB-DL AVC DDP5.1-DBTV\",\n                \"small_descr\": \"lost \",\n                \"times_completed\": 0,\n                \"size\": 33665425886,\n                \"added\": \"2025-02-18 19:47:56\",\n                \"url\": 0,\n                \"hr\": 0,\n                \"tmdb_type\": \"tv\",\n                \"tmdb_id\": 4607,\n                \"imdb_id\": null,\n                \"tags\": \"gf\"\n            }\n            \"\"\"\n            # 类别\n            category_value = result.get('category')\n            if category_value in self._tv_category:\n                category = MediaType.TV.value\n            elif category_value in self._movie_category:\n                category = MediaType.MOVIE.value\n            else:\n                category = MediaType.UNKNOWN.value\n            # 标签\n            torrentLabelIds = result.get('tags', \"\").split(\";\") or []\n            torrentLabels = []\n            for labelId in torrentLabelIds:\n                if self._labels.get(labelId) is not None:\n                    torrentLabels.append(self._labels.get(labelId))\n            # 种子信息\n            torrent = {\n                'title': result.get('name'),\n                'description': result.get('small_descr'),\n                'enclosure': self.__get_download_url(result.get('id'), result.get('downhash')),\n                'pubdate': result.get('added'),\n                'size': result.get('size'),\n                'seeders': result.get('seeders'),\n                'peers': result.get('leechers'),\n                'grabs': result.get('times_completed'),\n                'downloadvolumefactor': self.__get_downloadvolumefactor(result.get('promotion_time_type')),\n                'uploadvolumefactor': self.__get_uploadvolumefactor(result.get('promotion_time_type')),\n                'freedate': result.get('promotion_until'),\n                'page_url': self._pageurl % result.get('id'),\n                'labels': torrentLabels,\n                'category': category\n            }\n            torrents.append(torrent)\n        return torrents\n\n    def search(self, keyword: str, mtype: MediaType = None, page: Optional[int] = 0) -> Tuple[bool, List[dict]]:\n        \"\"\"\n        搜索\n        \"\"\"\n\n        # 准备参数\n        params = self.__get_params(keyword, mtype, page)\n\n        # 发送请求\n        res = RequestUtils(\n            headers={\n                \"Content-Type\": \"application/json\",\n                \"Accept\": \"application/json, text/plain, */*\",\n                \"x-api-key\": self._apikey\n            },\n            cookies=self._cookie,\n            proxies=self._proxy,\n            referer=f\"{self._domain}\",\n            timeout=self._timeout\n        ).post_res(url=self._searchurl, json=params)\n        if res and res.status_code == 200:\n            result = res.json()\n            if result.get(\"error\"):\n                logger.warn(f\"{self._name} 搜索失败，错误信息：{result.get('error').get('message')}\")\n                return True, []\n            return False, self.__parse_result(result.get('data'))\n        elif res is not None:\n            logger.warn(f\"{self._name} 搜索失败，错误码：{res.status_code}\")\n            return True, []\n        else:\n            logger.warn(f\"{self._name} 搜索失败，无法连接 {self._domain}\")\n            return True, []\n\n    async def async_search(self, keyword: str, mtype: MediaType = None, page: Optional[int] = 0) -> Tuple[bool, List[dict]]:\n        \"\"\"\n        异步搜索\n        \"\"\"\n        # 准备参数\n        params = self.__get_params(keyword, mtype, page)\n\n        # 发送请求\n        res = await AsyncRequestUtils(\n            headers={\n                \"Content-Type\": \"application/json\",\n                \"Accept\": \"application/json, text/plain, */*\",\n                \"x-api-key\": self._apikey\n            },\n            cookies=self._cookie,\n            proxies=self._proxy,\n            referer=f\"{self._domain}\",\n            timeout=self._timeout\n        ).post_res(url=self._searchurl, json=params)\n        if res and res.status_code == 200:\n            result = res.json()\n            if result.get(\"error\"):\n                logger.warn(f\"{self._name} 搜索失败，错误信息：{result.get('error').get('message')}\")\n                return True, []\n            return False, self.__parse_result(result.get('data'))\n        elif res is not None:\n            logger.warn(f\"{self._name} 搜索失败，错误码：{res.status_code}\")\n            return True, []\n        else:\n            logger.warn(f\"{self._name} 搜索失败，无法连接 {self._domain}\")\n            return True, []\n\n    @staticmethod\n    def __get_downloadvolumefactor(discount: int) -> float:\n        \"\"\"\n        获取下载系数\n        \"\"\"\n        discount_dict = {\n            2: 0,\n            5: 0.5,\n            6: 1,\n            7: 0.3\n        }\n        if discount:\n            return discount_dict.get(discount, 1)\n        return 1\n\n    @staticmethod\n    def __get_uploadvolumefactor(discount: int) -> float:\n        \"\"\"\n        获取上传系数\n        \"\"\"\n        discount_dict = {\n            3: 2,\n            4: 2,\n            6: 2\n        }\n        if discount:\n            return discount_dict.get(discount, 1)\n        return 1\n\n    def __get_download_url(self, torrent_id: int, downhash: str) -> str:\n        \"\"\"\n        获取下载链接，返回base64编码的json字符串及URL\n        \"\"\"\n        return f\"{self._domain}download.php?id={torrent_id}&downhash={downhash}\"\n"
  },
  {
    "path": "app/modules/indexer/spider/mtorrent.py",
    "content": "import base64\nimport json\nimport re\nfrom typing import Tuple, List, Optional\nfrom urllib.parse import urlparse\n\nfrom app.core.config import settings\nfrom app.db.systemconfig_oper import SystemConfigOper\nfrom app.log import logger\nfrom app.schemas import MediaType\nfrom app.utils.http import RequestUtils, AsyncRequestUtils\nfrom app.utils.string import StringUtils\n\n\nclass MTorrentSpider:\n    \"\"\"\n    mTorrent API\n    \"\"\"\n    _indexerid = None\n    _domain = None\n    _url = None\n    _name = \"\"\n    _proxy = None\n    _cookie = None\n    _ua = None\n    _size = 100\n    _searchurl = \"https://api.%s/api/torrent/search\"\n    _downloadurl = \"https://api.%s/api/torrent/genDlToken\"\n    _subtitle_list_url = \"https://api.%s/api/subtitle/list\"\n    _subtitle_genlink_url = \"https://api.%s/api/subtitle/genlink\"\n    _subtitle_download_url =\"https://api.%s/api/subtitle/dlV2?credential=%s\"\n    _pageurl = \"%sdetail/%s\"\n    _timeout = 15\n\n    # 电影分类\n    _movie_category = ['401', '419', '420', '421', '439', '405', '404']\n    _tv_category = ['403', '402', '435', '438', '404', '405']\n\n    # API KEY\n    _apikey = None\n    # JWT Token\n    _token = None\n\n    # 标签\n    _labels = {\n        \"0\": \"\",\n        \"1\": \"DIY\",\n        \"2\": \"国配\",\n        \"3\": \"DIY 国配\",\n        \"4\": \"中字\",\n        \"5\": \"DIY 中字\",\n        \"6\": \"国配 中字\",\n        \"7\": \"DIY 国配 中字\"\n    }\n\n    def __init__(self, indexer: dict):\n        self.systemconfig = SystemConfigOper()\n        if indexer:\n            self._indexerid = indexer.get('id')\n            self._url = indexer.get('domain')\n            self._domain = StringUtils.get_url_domain(self._url)\n            self._searchurl = self._searchurl % self._domain\n            self._name = indexer.get('name')\n            if indexer.get('proxy'):\n                self._proxy = settings.PROXY\n            self._cookie = indexer.get('cookie')\n            self._ua = indexer.get('ua')\n            self._apikey = indexer.get('apikey')\n            self._token = indexer.get('token')\n            self._timeout = indexer.get('timeout') or 15\n\n    def __get_params(self, keyword: str, mtype: MediaType = None, page: Optional[int] = 0) -> dict:\n        \"\"\"\n        获取请求参数\n        \"\"\"\n        if not mtype:\n            categories = []\n        elif mtype == MediaType.TV:\n            categories = self._tv_category\n        else:\n            categories = self._movie_category\n        # mtorrent搜索imdb需要输入完整imdb链接，参见 https://wiki.m-team.cc/zh-tw/imdbtosearch\n        if keyword and keyword.startswith(\"tt\"):\n            keyword = f\"https://www.imdb.com/title/{keyword}\"\n        return {\n            \"keyword\": keyword,\n            \"categories\": categories,\n            \"pageNumber\": int(page) + 1,\n            \"pageSize\": self._size,\n            \"visible\": 1\n        }\n\n    def __parse_result(self, results: List[dict]):\n        \"\"\"\n        解析搜索结果\n        \"\"\"\n        torrents = []\n        if not results:\n            return torrents\n\n        for result in results:\n            category_value = result.get('category')\n            if category_value in self._tv_category \\\n                    and category_value not in self._movie_category:\n                category = MediaType.TV.value\n            elif category_value in self._movie_category:\n                category = MediaType.MOVIE.value\n            else:\n                category = MediaType.UNKNOWN.value\n            # 处理馒头新版标签\n            labels = []\n            labels_new = result.get('labelsNew')\n            if labels_new:\n                # 新版标签本身就是list\n                labels = labels_new\n            else:\n                # 旧版标签\n                labels_value = self._labels.get(result.get('labels') or \"0\") or \"\"\n                if labels_value:\n                    labels = labels_value.split()\n            status = result.get('status', {})\n            torrent = {\n                'title': result.get('name'),\n                'description': result.get('smallDescr'),\n                'enclosure': self.__get_download_url(result.get('id')),\n                'pubdate': StringUtils.format_timestamp(result.get('createdDate')),\n                'size': int(result.get('size') or '0'),\n                'seeders': int(status.get(\"seeders\") or '0'),\n                'peers': int(status.get(\"leechers\") or '0'),\n                'grabs': int(status.get(\"timesCompleted\") or '0'),\n                'downloadvolumefactor': self.__get_downloadvolumefactor(status.get(\"discount\")),\n                'uploadvolumefactor': self.__get_uploadvolumefactor(status.get(\"discount\")),\n                'page_url': self._pageurl % (self._url, result.get('id')),\n                'imdbid': self.__find_imdbid(result.get('imdb')),\n                'labels': labels,\n                'category': category\n            }\n            if discount_end_time := status.get('discountEndTime'):\n                torrent['freedate'] = StringUtils.format_timestamp(discount_end_time)\n            # 解析全站促销时的规则(当前馒头只有下载促销)\n            if promotion_rule := status.get(\"promotionRule\"):\n                discount = promotion_rule.get(\"discount\", \"NORMAL\")\n                torrent[\"downloadvolumefactor\"] = self.__get_downloadvolumefactor(discount)\n                if end_time := promotion_rule.get(\"endTime\"):\n                    torrent[\"freedate\"] = StringUtils.format_timestamp(end_time)\n            if mall_single_free := status.get(\"mallSingleFree\"):\n                if mall_single_free.get(\"status\") == \"ONGOING\":\n                    torrent[\"downloadvolumefactor\"] = self.__get_downloadvolumefactor(\"FREE\")\n                    if end_date := mall_single_free.get(\"endDate\"):\n                        torrent[\"freedate\"] = StringUtils.format_timestamp(end_date)\n            torrents.append(torrent)\n        return torrents\n\n    def search(self, keyword: str, mtype: MediaType = None, page: Optional[int] = 0) -> Tuple[bool, List[dict]]:\n        \"\"\"\n        搜索\n        \"\"\"\n        # 检查ApiKey\n        if not self._apikey:\n            return True, []\n\n        # 获取请求参数\n        params = self.__get_params(keyword, mtype, page)\n\n        # 发送请求\n        res = RequestUtils(\n            headers={\n                \"Content-Type\": \"application/json\",\n                \"User-Agent\": f\"{self._ua}\",\n                \"x-api-key\": self._apikey\n            },\n            proxies=self._proxy,\n            referer=f\"{self._domain}browse\",\n            timeout=self._timeout\n        ).post_res(url=self._searchurl, json=params)\n        if res and res.status_code == 200:\n            results = res.json().get('data', {}).get(\"data\") or []\n            return False, self.__parse_result(results)\n        elif res is not None:\n            logger.warn(f\"{self._name} 搜索失败，错误码：{res.status_code}\")\n            return True, []\n        else:\n            logger.warn(f\"{self._name} 搜索失败，无法连接 {self._domain}\")\n            return True, []\n\n    async def async_search(self, keyword: str, mtype: MediaType = None, page: Optional[int] = 0) -> Tuple[bool, List[dict]]:\n        \"\"\"\n        搜索\n        \"\"\"\n        # 检查ApiKey\n        if not self._apikey:\n            return True, []\n\n        # 获取请求参数\n        params = self.__get_params(keyword, mtype, page)\n\n        # 发送请求\n        res = await AsyncRequestUtils(\n            headers={\n                \"Content-Type\": \"application/json\",\n                \"User-Agent\": f\"{self._ua}\",\n                \"x-api-key\": self._apikey\n            },\n            proxies=self._proxy,\n            referer=f\"{self._domain}browse\",\n            timeout=self._timeout\n        ).post_res(url=self._searchurl, json=params)\n        if res and res.status_code == 200:\n            results = res.json().get('data', {}).get(\"data\") or []\n            return False, self.__parse_result(results)\n        elif res is not None:\n            logger.warn(f\"{self._name} 搜索失败，错误码：{res.status_code}\")\n            return True, []\n        else:\n            logger.warn(f\"{self._name} 搜索失败，无法连接 {self._domain}\")\n            return True, []\n\n    @staticmethod\n    def __find_imdbid(imdb: str) -> str:\n        \"\"\"\n        从imdb链接中提取imdbid\n        \"\"\"\n        if imdb:\n            m = re.search(r\"tt\\d+\", imdb)\n            if m:\n                return m.group(0)\n        return \"\"\n\n    @staticmethod\n    def __get_downloadvolumefactor(discount: str) -> float:\n        \"\"\"\n        获取下载系数\n        \"\"\"\n        discount_dict = {\n            \"FREE\": 0,\n            \"PERCENT_50\": 0.5,\n            \"PERCENT_70\": 0.3,\n            \"_2X_FREE\": 0,\n            \"_2X_PERCENT_50\": 0.5\n        }\n        if discount:\n            return discount_dict.get(discount, 1)\n        return 1\n\n    @staticmethod\n    def __get_uploadvolumefactor(discount: str) -> float:\n        \"\"\"\n        获取上传系数\n        \"\"\"\n        uploadvolumefactor_dict = {\n            \"_2X\": 2.0,\n            \"_2X_FREE\": 2.0,\n            \"_2X_PERCENT_50\": 2.0\n        }\n        if discount:\n            return uploadvolumefactor_dict.get(discount, 1)\n        return 1\n\n    def __get_download_url(self, torrent_id: str) -> str:\n        \"\"\"\n        获取下载链接，返回base64编码的json字符串及URL\n        \"\"\"\n        url = self._downloadurl % self._domain\n        params = {\n            'method': 'post',\n            'cookie': False,\n            'params': {\n                'id': torrent_id\n            },\n            'header': {\n                'User-Agent': f'{self._ua}',\n                'Accept': 'application/json, text/plain, */*',\n                'x-api-key': self._apikey\n            },\n            'proxy': True if self._proxy else False,\n            'result': 'data'\n        }\n        # base64编码\n        base64_str = base64.b64encode(json.dumps(params).encode('utf-8')).decode('utf-8')\n        return f\"[{base64_str}]{url}\"\n\n    def get_subtitle_links(self, page_url: str) -> List[str]:\n        \"\"\"\n        获取指定页面的字幕下载链接\n\n        :param page_url: 种子详情页网址\n        :type page_url: str\n        :return: 字幕下载链接\n        :rtype: List[str]\n        \"\"\"\n        if not page_url:\n            return []\n        # 从馒头的详情页网址中提取种子id\n        torrent_id = urlparse(page_url).path.rsplit(\"/\", 1)[-1].strip()\n        if not torrent_id:\n            return []\n        return self.get_subtitle_links_by_id(torrent_id)\n\n    def get_subtitle_links_by_id(self, torrent_id: str) -> List[str]:\n        \"\"\"\n        获取指定种子的字幕下载链接\n\n        :param torrent_id: 种子ID\n        :type torrent_id: str\n        :return: 字幕下载链接\n        :rtype: List[str]\n        \"\"\"\n        results = []\n        try:\n            for subtitle_id in self.__subtitle_ids(torrent_id) or []:\n                if link := self.__subtitle_genlink(subtitle_id):\n                    results.append(link)\n        except Exception as e:\n            logger.error(f\"{self._name} 获取字幕失败：{e}\")\n        return results\n\n    def __subtitle_ids(self, torrent_id: str) -> Optional[List[str]]:\n        \"\"\"\n        获取指定种子的字幕列表\n\n        :param torrent_id: 种子ID\n        :type torrent_id: str\n        :return: 字幕ID\n        :rtype: List[str] | None\n        \"\"\"\n        url = self._subtitle_list_url % self._domain\n        # 发送请求\n        res = RequestUtils(\n            headers={\n                \"Accept\": \"application/json, text/plain, */*\",\n                \"User-Agent\": f\"{self._ua}\",\n                \"x-api-key\": self._apikey,\n            },\n            proxies=self._proxy,\n            timeout=self._timeout,\n        ).post_res(url, data={\"id\": torrent_id})\n        if res and res.status_code == 200:\n            result = res.json()\n            if int(result.get(\"code\", -1)) == 0:\n                return [item[\"id\"] for item in result.get(\"data\", []) if \"id\" in item]\n            else:\n                logger.warn(\n                    f\"{self._name} 获取字幕列表失败，返回：{result.get(\"message\", \"未知\")}\"\n                )\n                return None\n        elif res is not None:\n            logger.warn(f\"{self._name} 获取字幕列表失败，错误码：{res.status_code}\")\n            return None\n        else:\n            logger.warn(f\"{self._name} 获取字幕列表失败，无法连接 {self._domain}\")\n            return None\n\n    def __subtitle_genlink(self, subtitle_id: str) -> Optional[str]:\n        \"\"\"\n        获取字幕下载链接\n\n        :param subtitle_id: 字幕ID\n        :type subtitle_id: str\n        :return: 下载链接\n        :rtype: str | None\n        \"\"\"\n        url = self._subtitle_genlink_url % self._domain\n        # 发送请求\n        res = RequestUtils(\n            headers={\n                \"Accept\": \"application/json, text/plain, */*\",\n                \"User-Agent\": f\"{self._ua}\",\n                \"x-api-key\": self._apikey,\n            },\n            proxies=self._proxy,\n            timeout=self._timeout,\n        ).post_res(url, data={\"id\": subtitle_id})\n        if res and res.status_code == 200:\n            result = res.json()\n            if int(result.get(\"code\", -1)) == 0 and isinstance(result.get(\"data\"), str):\n                return self._subtitle_download_url % (self._domain, result[\"data\"])\n            else:\n                logger.warn(\n                    f\"{self._name} 获取字幕下载链接失败，返回：{result.get(\"message\", \"未知\")}\"\n                )\n                return None\n        elif res is not None:\n            logger.warn(f\"{self._name} 获取字幕下载链接失败，错误码：{res.status_code}\")\n            return None\n        else:\n            logger.warn(f\"{self._name} 获取字幕下载链接失败，无法连接 {self._domain}\")\n            return None\n"
  },
  {
    "path": "app/modules/indexer/spider/rousi.py",
    "content": "import base64\nimport json\nfrom typing import List, Optional, Tuple\n\nfrom app.core.config import settings\nfrom app.db.systemconfig_oper import SystemConfigOper\nfrom app.log import logger\nfrom app.schemas import MediaType\nfrom app.utils.http import RequestUtils, AsyncRequestUtils\nfrom app.utils.string import StringUtils\n\n\nclass RousiSpider:\n    \"\"\"\n    Rousi.pro API v1 Spider\n\n    使用 API v1 接口进行种子搜索\n    - 认证方式：Bearer Token (Passkey)\n    - 搜索接口：/api/v1/torrents\n    - 详情接口：/api/v1/torrents/:id\n    \"\"\"\n    _indexerid = None\n    _domain = None\n    _url = None\n    _name = \"\"\n    _proxy = None\n    _cookie = None\n    _ua = None\n    _size = 100\n    _searchurl = \"https://%s/api/v1/torrents\"\n    _downloadurl = \"https://%s/api/v1/torrents/%s\"\n    _timeout = 15\n\n    # 分类定义\n    # API 不支持多分类搜索，每次只使用一个分类\n    _movie_category = 'movie'\n    _tv_category = 'tv'\n\n    # API KEY\n    _apikey = None\n\n    def __init__(self, indexer: dict):\n        self.systemconfig = SystemConfigOper()\n        if indexer:\n            self._indexerid = indexer.get('id')\n            self._url = indexer.get('domain')\n            self._domain = StringUtils.get_url_domain(self._url)\n            self._searchurl = self._searchurl % self._domain\n            self._downloadurl = self._downloadurl % (self._domain, \"%s\")\n            self._name = indexer.get('name')\n            if indexer.get('proxy'):\n                self._proxy = settings.PROXY\n            self._cookie = indexer.get('cookie')\n            self._ua = indexer.get('ua')\n            self._apikey = indexer.get('apikey')\n            self._timeout = indexer.get('timeout') or 15\n\n    def __get_params(self, keyword: str, mtype: MediaType = None, cat: Optional[str] = None, page: Optional[int] = 0) -> dict:\n        \"\"\"\n        构建 API 请求参数\n\n        :param keyword: 搜索关键词\n        :param mtype: 媒体类型 (MOVIE/TV)\n        :param cat: 用户选择的分类 ID（逗号分隔的字符串）\n        :param page: 页码（从 0 开始，API 需要从 1 开始）\n        :return: 请求参数字典\n        \"\"\"\n        params = {\n            \"page\": int(page) + 1,\n            \"page_size\": self._size\n        }\n        if keyword:\n            params[\"keyword\"] = keyword\n\n        # API 不支持多分类搜索,只使用单个 category 参数\n        # 优先使用用户选择的分类,如果用户未选择则根据 mtype 推断\n        if cat:\n            # 用户选择了特定分类,需要将分类 ID 映射回 API 的 category name\n            category_names = self.__get_category_names_by_ids(cat)\n            if category_names:\n                # 如果用户选择了多个分类,只取第一个\n                params[\"category\"] = category_names[0]\n        elif mtype:\n            # 用户未选择分类,根据媒体类型推断\n            if mtype == MediaType.MOVIE:\n                params[\"category\"] = self._movie_category\n            elif mtype == MediaType.TV:\n                params[\"category\"] = self._tv_category\n\n        return params\n\n    def __get_category_names_by_ids(self, cat: str) -> Optional[list]:\n        \"\"\"\n        根据用户选择的分类 ID 获取 API 的 category names\n\n        :param cat: 用户选择的分类 ID（逗号分隔的多个ID，如 \"1,2,3\"）\n        :return: API 的 category names 列表（如 [\"movie\", \"tv\", \"documentary\"]）\n        \"\"\"\n        if not cat:\n            return None\n\n        # ID 到 category name 的映射\n        id_to_name = {\n            '1': 'movie',\n            '2': 'tv',\n            '3': 'documentary',\n            '4': 'animation',\n            '6': 'variety'\n        }\n\n        # 分割多个分类 ID 并映射为 category names\n        cat_ids = [c.strip() for c in cat.split(',') if c.strip()]\n        category_names = [id_to_name.get(cat_id) for cat_id in cat_ids if cat_id in id_to_name]\n\n        return category_names if category_names else None\n\n    def __process_response(self, res) -> Tuple[bool, List[dict]]:\n        \"\"\"\n        处理 API 响应\n\n        :param res: 请求响应对象\n        :return: (是否发生错误, 种子列表)\n        \"\"\"\n        if res and res.status_code == 200:\n            try:\n                data = res.json()\n                if data.get('code') == 0:\n                    results = data.get('data', {}).get('torrents', [])\n                    return False, self.__parse_result(results)\n                else:\n                    logger.warn(f\"{self._name} 搜索失败，错误信息：{data.get('message')}\")\n                    return True, []\n            except Exception as e:\n                logger.warn(f\"{self._name} 解析响应失败：{e}\")\n                return True, []\n        elif res is not None:\n            logger.warn(f\"{self._name} 搜索失败，HTTP 错误码：{res.status_code}\")\n            return True, []\n        else:\n            logger.warn(f\"{self._name} 搜索失败，无法连接 {self._domain}\")\n            return True, []\n\n    def __parse_result(self, results: List[dict]) -> List[dict]:\n        \"\"\"\n        解析搜索结果\n\n        将 API 返回的种子数据转换为 MoviePilot 标准格式\n\n        :param results: API 返回的种子列表\n        :return: 标准化的种子信息列表\n        \"\"\"\n        torrents = []\n        if not results:\n            return torrents\n\n        for result in results:\n            # 解析分类信息\n            raw_cat = result.get('category')\n            cat_val = None\n\n            category = MediaType.UNKNOWN.value\n\n            if isinstance(raw_cat, dict):\n                cat_val = raw_cat.get('slug') or raw_cat.get('name')\n            elif isinstance(raw_cat, str):\n                cat_val = raw_cat\n\n            if cat_val:\n                cat_val = str(cat_val).lower()\n                if cat_val == self._movie_category:\n                    category = MediaType.MOVIE.value\n                elif cat_val == self._tv_category:\n                    category = MediaType.TV.value\n                else:\n                    category = MediaType.UNKNOWN.value\n\n            # 解析促销信息\n            # API 后端已处理全站促销优先级，直接使用返回的 promotion 数据\n            downloadvolumefactor = 1.0\n            uploadvolumefactor = 1.0\n            freedate = None\n\n            promotion = result.get('promotion')\n            if promotion and promotion.get('is_active'):\n                downloadvolumefactor = float(promotion.get('down_multiplier', 1.0))\n                uploadvolumefactor = float(promotion.get('up_multiplier', 1.0))\n                # 促销到期时间，格式化为 YYYY-MM-DD HH:MM:SS\n                if promotion.get('until'):\n                    freedate = StringUtils.unify_datetime_str(promotion.get('until'))\n\n            torrent = {\n                'title': result.get('title'),\n                'description': result.get('subtitle'),\n                'enclosure': self.__get_download_url(result.get('id')),\n                'pubdate': StringUtils.unify_datetime_str(result.get('created_at')),\n                'size': int(result.get('size') or 0),\n                'seeders': int(result.get('seeders') or 0),\n                'peers': int(result.get('leechers') or 0),\n                'grabs': int(result.get('downloads') or 0),\n                'downloadvolumefactor': downloadvolumefactor,\n                'uploadvolumefactor': uploadvolumefactor,\n                'freedate': freedate,\n                'page_url': f\"https://{self._domain}/torrent/{result.get('uuid')}\",\n                'labels': [],\n                'category': category\n            }\n            torrents.append(torrent)\n        return torrents\n\n    def search(self, keyword: str, mtype: MediaType = None, cat: Optional[str] = None, page: Optional[int] = 0) -> Tuple[bool, List[dict]]:\n        \"\"\"\n        同步搜索种子\n\n        :param keyword: 搜索关键词\n        :param mtype: 媒体类型 (MOVIE/TV)\n        :param cat: 用户选择的分类 ID（逗号分隔）\n        :param page: 页码（从 0 开始）\n        :return: (是否发生错误, 种子列表)\n        \"\"\"\n        if not self._apikey:\n            logger.warn(f\"{self._name} 未配置 API Key (Passkey)\")\n            return True, []\n\n        params = self.__get_params(keyword, mtype, cat, page)\n        headers = {\n            \"Authorization\": f\"Bearer {self._apikey}\",\n            \"Accept\": \"application/json\"\n        }\n\n        res = RequestUtils(\n            headers=headers,\n            proxies=self._proxy,\n            timeout=self._timeout\n        ).get_res(url=self._searchurl, params=params)\n\n        return self.__process_response(res)\n\n    async def async_search(self, keyword: str, mtype: MediaType = None, cat: Optional[str] = None, page: Optional[int] = 0) -> Tuple[bool, List[dict]]:\n        \"\"\"\n        异步搜索种子\n\n        :param keyword: 搜索关键词\n        :param mtype: 媒体类型 (MOVIE/TV)\n        :param cat: 用户选择的分类 ID（逗号分隔）\n        :param page: 页码（从 0 开始）\n        :return: (是否发生错误, 种子列表)\n        \"\"\"\n        if not self._apikey:\n            logger.warn(f\"{self._name} 未配置 API Key (Passkey)\")\n            return True, []\n\n        params = self.__get_params(keyword, mtype, cat, page)\n        headers = {\n            \"Authorization\": f\"Bearer {self._apikey}\",\n            \"Accept\": \"application/json\"\n        }\n\n        res = await AsyncRequestUtils(\n            headers=headers,\n            proxies=self._proxy,\n            timeout=self._timeout\n        ).get_res(url=self._searchurl, params=params)\n\n        return self.__process_response(res)\n\n    def __get_download_url(self, torrent_id: int) -> str:\n        \"\"\"\n        构建种子下载链接\n\n        使用 base64 编码的方式告诉 MoviePilot 如何获取真实下载地址\n        MoviePilot 会先请求详情接口，然后从响应中提取 data.download_url\n\n        :param torrent_id: 种子 ID\n        :return: base64 编码的请求配置字符串 + 详情接口 URL\n        \"\"\"\n        url = self._downloadurl % torrent_id\n        # MoviePilot 会解析这个特殊格式的 URL：\n        # 1. 使用指定的 method 和 header 请求 URL\n        # 2. 从 JSON 响应中提取 result 指定的字段值作为真实下载地址\n        params = {\n            'method': 'get',\n            'header': {\n                'Authorization': f'Bearer {self._apikey}',\n                'Accept': 'application/json'\n            },\n            'result': 'data.download_url'\n        }\n        base64_str = base64.b64encode(json.dumps(params).encode('utf-8')).decode('utf-8')\n        return f\"[{base64_str}]{url}\"\n"
  },
  {
    "path": "app/modules/indexer/spider/tnode.py",
    "content": "import re\nfrom typing import Tuple, List, Optional\n\nfrom app.core.cache import cached\nfrom app.core.config import settings\nfrom app.log import logger\nfrom app.utils.http import RequestUtils, AsyncRequestUtils\nfrom app.utils.singleton import SingletonClass\nfrom app.utils.string import StringUtils\n\n\nclass TNodeSpider(metaclass=SingletonClass):\n    _size = 100\n    _timeout = 15\n    _proxy = None\n    _baseurl = \"%sapi/torrent/advancedSearch\"\n    _downloadurl = \"%sapi/torrent/download/%s\"\n    _pageurl = \"%storrent/info/%s\"\n\n    def __init__(self, indexer: dict):\n        if indexer:\n            self._indexerid = indexer.get('id')\n            self._domain = indexer.get('domain')\n            self._searchurl = self._baseurl % self._domain\n            self._name = indexer.get('name')\n            if indexer.get('proxy'):\n                self._proxy = settings.PROXY\n            self._cookie = indexer.get('cookie')\n            self._ua = indexer.get('ua')\n            self._timeout = indexer.get('timeout') or 15\n\n    @cached(region=\"indexer_spider\", maxsize=1, ttl=60 * 60 * 24, skip_empty=True, shared_key=\"get_token\")\n    def __get_token(self) -> Optional[str]:\n        if not self._domain:\n            return\n        res = RequestUtils(ua=self._ua,\n                           cookies=self._cookie,\n                           proxies=self._proxy,\n                           timeout=self._timeout).get_res(url=self._domain)\n        if res and res.status_code == 200:\n            csrf_token = re.search(r'<meta name=\"x-csrf-token\" content=\"(.+?)\">', res.text)\n            if csrf_token:\n                return csrf_token.group(1)\n        return None\n\n    @cached(region=\"indexer_spider\", maxsize=1, ttl=60 * 60 * 24, skip_empty=True, shared_key=\"get_token\")\n    async def __async_get_token(self) -> Optional[str]:\n        if not self._domain:\n            return\n        res = await AsyncRequestUtils(ua=self._ua,\n                                      cookies=self._cookie,\n                                      proxies=self._proxy,\n                                      timeout=self._timeout).get_res(url=self._domain)\n        if res and res.status_code == 200:\n            csrf_token = re.search(r'<meta name=\"x-csrf-token\" content=\"(.+?)\">', res.text)\n            if csrf_token:\n                return csrf_token.group(1)\n        return None\n\n    def __get_params(self, keyword: str = None, page: Optional[int] = 0) -> dict:\n        \"\"\"\n        获取搜索参数\n        \"\"\"\n        search_type = \"imdbid\" if (keyword and keyword.startswith('tt')) else \"title\"\n        return {\n            \"page\": int(page) + 1,\n            \"size\": self._size,\n            \"type\": search_type,\n            \"keyword\": keyword or \"\",\n            \"sorter\": \"id\",\n            \"order\": \"desc\",\n            \"tags\": [],\n            \"category\": [501, 502, 503, 504],\n            \"medium\": [],\n            \"videoCoding\": [],\n            \"audioCoding\": [],\n            \"resolution\": [],\n            \"group\": []\n        }\n\n    def __parse_result(self, results: List[dict]) -> List[dict]:\n        \"\"\"\n        解析搜索结果\n        \"\"\"\n        torrents = []\n        if not results:\n            return torrents\n\n        for result in results:\n            torrent = {\n                'title': result.get('title'),\n                'description': result.get('subtitle'),\n                'enclosure': self._downloadurl % (self._domain, result.get('id')),\n                'pubdate': StringUtils.format_timestamp(result.get('upload_time')),\n                'size': result.get('size'),\n                'seeders': result.get('seeding'),\n                'peers': result.get('leeching'),\n                'grabs': result.get('complete'),\n                'downloadvolumefactor': result.get('downloadRate'),\n                'uploadvolumefactor': result.get('uploadRate'),\n                'page_url': self._pageurl % (self._domain, result.get('id')),\n                'imdbid': result.get('imdb')\n            }\n            torrents.append(torrent)\n\n        return torrents\n\n    def search(self, keyword: str, page: Optional[int] = 0) -> Tuple[bool, List[dict]]:\n        \"\"\"\n        搜索\n        \"\"\"\n        # 获取token\n        _token = self.__get_token()\n        if not _token:\n            logger.warn(f\"{self._name} 未获取到token，无法搜索\")\n            return True, []\n\n        # 获取请求参数\n        params = self.__get_params(keyword, page)\n\n        # 发送请求\n        res = RequestUtils(\n            headers={\n                'X-CSRF-TOKEN': _token,\n                \"Content-Type\": \"application/json; charset=utf-8\",\n                \"User-Agent\": f\"{self._ua}\"\n            },\n            cookies=self._cookie,\n            proxies=self._proxy,\n            timeout=self._timeout\n        ).post_res(url=self._searchurl, json=params)\n        if res and res.status_code == 200:\n            results = res.json().get('data', {}).get(\"torrents\") or []\n            return False, self.__parse_result(results)\n        elif res is not None:\n            logger.warn(f\"{self._name} 搜索失败，错误码：{res.status_code}\")\n            return True, []\n        else:\n            logger.warn(f\"{self._name} 搜索失败，无法连接 {self._domain}\")\n            return True, []\n        \n    async def async_search(self, keyword: str, page: Optional[int] = 0) -> Tuple[bool, List[dict]]:\n        \"\"\"\n        异步搜索\n        \"\"\"\n        # 获取token\n        _token = await self.__async_get_token()\n        if not _token:\n            logger.warn(f\"{self._name} 未获取到token，无法搜索\")\n            return True, []\n\n        # 获取请求参数\n        params = self.__get_params(keyword, page)\n\n        # 发送请求\n        res = await AsyncRequestUtils(\n            headers={\n                'x-csrf-token': _token,\n                \"Content-Type\": \"application/json; charset=utf-8\",\n                \"User-Agent\": f\"{self._ua}\"\n            },\n            cookies=self._cookie,\n            proxies=self._proxy,\n            timeout=self._timeout\n        ).post_res(url=self._searchurl, json=params)\n        if res and res.status_code == 200:\n            results = res.json().get('data', {}).get(\"torrents\") or []\n            return False, self.__parse_result(results)\n        elif res is not None:\n            logger.warn(f\"{self._name} 搜索失败，错误码：{res.status_code}\")\n            return True, []\n        else:\n            logger.warn(f\"{self._name} 搜索失败，无法连接 {self._domain}\")\n            return True, []\n"
  },
  {
    "path": "app/modules/indexer/spider/torrentleech.py",
    "content": "from typing import List, Tuple, Optional\nfrom urllib.parse import quote\n\nfrom app.core.config import settings\nfrom app.log import logger\nfrom app.utils.http import RequestUtils, AsyncRequestUtils\nfrom app.utils.string import StringUtils\n\n\nclass TorrentLeech:\n    _indexer = None\n    _proxy = None\n    _size = 100\n    _searchurl = \"%storrents/browse/list/query/%s\"\n    _browseurl = \"%storrents/browse/list/page/2%s\"\n    _downloadurl = \"%sdownload/%s/%s\"\n    _pageurl = \"%storrent/%s\"\n    _timeout = 15\n\n    def __init__(self, indexer: dict):\n        self._indexer = indexer\n        if indexer.get('proxy'):\n            self._proxy = settings.PROXY\n            self._timeout = indexer.get('timeout') or 15\n\n    def __parse_result(self, results: List[dict]) -> List[dict]:\n        \"\"\"\n        解析搜索结果\n        \"\"\"\n        torrents = []\n        if not results:\n            return torrents\n\n        for result in results:\n            torrent = {\n                'title': result.get('name'),\n                'enclosure': self._downloadurl % (self._indexer.get('domain'),\n                                                  result.get('fid'),\n                                                  result.get('filename')),\n                'pubdate': StringUtils.format_timestamp(result.get('addedTimestamp')),\n                'size': result.get('size'),\n                'seeders': result.get('seeders'),\n                'peers': result.get('leechers'),\n                'grabs': result.get('completed'),\n                'downloadvolumefactor': result.get('download_multiplier'),\n                'uploadvolumefactor': 1,\n                'page_url': self._pageurl % (self._indexer.get('domain'), result.get('fid')),\n                'imdbid': result.get('imdbID')\n            }\n            torrents.append(torrent)\n        return torrents\n\n    def search(self, keyword: str, page: Optional[int] = 0) -> Tuple[bool, List[dict]]:\n        \"\"\"\n        搜索种子\n        \"\"\"\n        if StringUtils.is_chinese(keyword):\n            # 不支持中文\n            return True, []\n\n        if keyword:\n            url = self._searchurl % (self._indexer.get('domain'), quote(keyword))\n        else:\n            url = self._browseurl % (self._indexer.get('domain'), int(page) + 1)\n\n        res = RequestUtils(\n            headers={\n                \"Content-Type\": \"application/json; charset=utf-8\",\n                \"User-Agent\": f\"{self._indexer.get('ua')}\",\n            },\n            cookies=self._indexer.get('cookie'),\n            proxies=self._proxy,\n            timeout=self._timeout\n        ).get_res(url)\n        if res and res.status_code == 200:\n            results = res.json().get('torrentList') or []\n            return False, self.__parse_result(results)\n        elif res is not None:\n            logger.warn(f\"{self._indexer.get('name')} 搜索失败，错误码：{res.status_code}\")\n            return True, []\n        else:\n            logger.warn(f\"{self._indexer.get('name')} 搜索失败，无法连接 {self._indexer.get('domain')}\")\n            return True, []\n\n    async def async_search(self, keyword: str, page: Optional[int] = 0) -> Tuple[bool, List[dict]]:\n        \"\"\"\n        异步搜索种子\n        \"\"\"\n        if StringUtils.is_chinese(keyword):\n            # 不支持中文\n            return True, []\n\n        if keyword:\n            url = self._searchurl % (self._indexer.get('domain'), quote(keyword))\n        else:\n            url = self._browseurl % (self._indexer.get('domain'), int(page) + 1)\n\n        res = await AsyncRequestUtils(\n            headers={\n                \"Content-Type\": \"application/json; charset=utf-8\",\n                \"User-Agent\": f\"{self._indexer.get('ua')}\",\n            },\n            cookies=self._indexer.get('cookie'),\n            proxies=self._proxy,\n            timeout=self._timeout\n        ).get_res(url)\n        if res and res.status_code == 200:\n            results = res.json().get('torrentList') or []\n            return False, self.__parse_result(results)\n        elif res is not None:\n            logger.warn(f\"{self._indexer.get('name')} 搜索失败，错误码：{res.status_code}\")\n            return True, []\n        else:\n            logger.warn(f\"{self._indexer.get('name')} 搜索失败，无法连接 {self._indexer.get('domain')}\")\n            return True, []\n"
  },
  {
    "path": "app/modules/indexer/spider/yema.py",
    "content": "from typing import Tuple, List, Optional\n\nfrom app.core.config import settings\nfrom app.db.systemconfig_oper import SystemConfigOper\nfrom app.log import logger\nfrom app.schemas import MediaType\nfrom app.utils.http import RequestUtils, AsyncRequestUtils\nfrom app.utils.string import StringUtils\n\n\nclass YemaSpider:\n    \"\"\"\n    YemaPT API\n    \"\"\"\n    _indexerid = None\n    _domain = None\n    _name = \"\"\n    _proxy = None\n    _cookie = None\n    _ua = None\n    _size = 40\n    _searchurl = \"%sapi/torrent/fetchOpenTorrentList\"\n    _downloadurl = \"%sapi/torrent/download?id=%s\"\n    _pageurl = \"%s#/torrent/detail/%s/\"\n    _timeout = 15\n\n    # 分类\n    _movie_category = [4]\n    _tv_category = [5, 13, 14, 17, 15, 6, 16]\n\n    # 标签 https://wiki.yemapt.org/developer/constants\n    _labels = {\n        \"1\": \"禁转\",\n        \"2\": \"首发\",\n        \"3\": \"官方\",\n        \"4\": \"自制\",\n        \"5\": \"国语\",\n        \"6\": \"中字\",\n        \"7\": \"粤语\",\n        \"8\": \"英字\",\n        \"9\": \"HDR10\",\n        \"10\": \"杜比视界\",\n        \"11\": \"分集\",\n        \"12\": \"完结\",\n    }\n\n    def __init__(self, indexer: dict):\n        self.systemconfig = SystemConfigOper()\n        if indexer:\n            self._indexerid = indexer.get('id')\n            self._domain = indexer.get('domain')\n            self._searchurl = self._searchurl % self._domain\n            self._name = indexer.get('name')\n            if indexer.get('proxy'):\n                self._proxy = settings.PROXY\n            self._cookie = indexer.get('cookie')\n            self._ua = indexer.get('ua')\n            self._timeout = indexer.get('timeout') or 15\n\n    def __get_params(self, keyword: str = None, page: Optional[int] = 0) -> dict:\n        \"\"\"\n        获取搜索参数\n        \"\"\"\n        params = {\n            \"pageParam\": {\n                \"current\": page + 1,\n                \"pageSize\": self._size,\n                \"total\": self._size\n            },\n            \"sorter\": {}\n        }\n        if keyword:\n            params.update({\n                \"keyword\": keyword,\n            })\n        return params\n\n    def __parse_result(self, results: List[dict]) -> List[dict]:\n        \"\"\"\n        解析搜索结果\n        \"\"\"\n        torrents = []\n        if not results:\n            return torrents\n\n        for result in results:\n            category_value = result.get('categoryId')\n            if category_value in self._tv_category:\n                category = MediaType.TV.value\n            elif category_value in self._movie_category:\n                category = MediaType.MOVIE.value\n            else:\n                category = MediaType.UNKNOWN.value\n                pass\n\n            torrentLabelIds = result.get('tagList', []) or []\n            torrentLabels = []\n            for labelId in torrentLabelIds:\n                if self._labels.get(labelId) is not None:\n                    torrentLabels.append(self._labels.get(labelId))\n                    pass\n                pass\n            torrent = {\n                'title': result.get('showName'),\n                'description': result.get('shortDesc'),\n                'enclosure': self.__get_download_url(result.get('id')),\n                'pubdate': StringUtils.unify_datetime_str(result.get('listingTime')),\n                'size': result.get('fileSize'),\n                'seeders': result.get('seedNum'),\n                'peers': result.get('leechNum'),\n                'grabs': result.get('completedNum'),\n                'downloadvolumefactor': self.__get_downloadvolumefactor(result.get('downloadPromotion')),\n                'uploadvolumefactor': self.__get_uploadvolumefactor(result.get('uploadPromotion')),\n                'freedate': StringUtils.unify_datetime_str(result.get('downloadPromotionEndTime')),\n                'page_url': self._pageurl % (self._domain, result.get('id')),\n                'labels': torrentLabels,\n                'category': category\n            }\n            torrents.append(torrent)\n\n        return torrents\n\n    def search(self, keyword: str,\n               mtype: MediaType = None, page: Optional[int] = 0) -> Tuple[bool, List[dict]]:\n        \"\"\"\n        搜索\n        \"\"\"\n\n        res = RequestUtils(\n            headers={\n                \"Content-Type\": \"application/json\",\n                \"User-Agent\": f\"{self._ua}\",\n                \"Accept\": \"application/json, text/plain, */*\"\n            },\n            cookies=self._cookie,\n            proxies=self._proxy,\n            referer=f\"{self._domain}\",\n            timeout=self._timeout\n        ).post_res(url=self._searchurl, json=self.__get_params(keyword, page))\n        if res and res.status_code == 200:\n            results = res.json().get('data', []) or []\n            return False, self.__parse_result(results)\n        elif res is not None:\n            logger.warn(f\"{self._name} 搜索失败，错误码：{res.status_code}\")\n            return True, []\n        else:\n            logger.warn(f\"{self._name} 搜索失败，无法连接 {self._domain}\")\n            return True, []\n\n    async def async_search(self, keyword: str,\n                           mtype: MediaType = None, page: Optional[int] = 0) -> Tuple[bool, List[dict]]:\n        \"\"\"\n        异步搜索\n        \"\"\"\n        res = await AsyncRequestUtils(\n            headers={\n                \"Content-Type\": \"application/json\",\n                \"User-Agent\": f\"{self._ua}\",\n                \"Accept\": \"application/json, text/plain, */*\"\n            },\n            cookies=self._cookie,\n            proxies=self._proxy,\n            referer=f\"{self._domain}\",\n            timeout=self._timeout\n        ).post_res(url=self._searchurl, json=self.__get_params(keyword, page))\n\n        if res and res.status_code == 200:\n            results = res.json().get('data', []) or []\n            return False, self.__parse_result(results)\n        elif res is not None:\n            logger.warn(f\"{self._name} 搜索失败，错误码：{res.status_code}\")\n            return True, []\n        else:\n            logger.warn(f\"{self._name} 搜索失败，无法连接 {self._domain}\")\n            return True, []\n\n    @staticmethod\n    def __get_downloadvolumefactor(discount: str) -> float:\n        \"\"\"\n        获取下载系数\n        \"\"\"\n        discount_dict = {\n            \"free\": 0,\n            \"half\": 0.5,\n            \"none\": 1\n        }\n        if discount:\n            return discount_dict.get(discount, 1)\n        return 1\n\n    @staticmethod\n    def __get_uploadvolumefactor(discount: str) -> float:\n        \"\"\"\n        获取上传系数\n        \"\"\"\n        discount_dict = {\n            \"none\": 1,\n            \"one_half\": 1.5,\n            \"double_upload\": 2\n        }\n        if discount:\n            return discount_dict.get(discount, 1)\n        return 1\n\n    def __get_download_url(self, torrent_id: str) -> str:\n        \"\"\"\n        获取下载链接\n        \"\"\"\n        return self._downloadurl % (self._domain, torrent_id)\n"
  },
  {
    "path": "app/modules/jellyfin/__init__.py",
    "content": "from typing import Any, Generator, List, Optional, Tuple, Union\n\nfrom app import schemas\nfrom app.core.context import MediaInfo\nfrom app.core.event import eventmanager\nfrom app.log import logger\nfrom app.modules import _MediaServerBase, _ModuleBase\nfrom app.modules.jellyfin.jellyfin import Jellyfin\nfrom app.schemas import AuthCredentials, AuthInterceptCredentials\nfrom app.schemas.types import MediaType, ModuleType, ChainEventType, MediaServerType\n\n\nclass JellyfinModule(_ModuleBase, _MediaServerBase[Jellyfin]):\n\n    def init_module(self) -> None:\n        \"\"\"\n        初始化模块\n        \"\"\"\n        super().init_service(service_name=Jellyfin.__name__.lower(),\n                             service_type=lambda conf: Jellyfin(**conf.config, sync_libraries=conf.sync_libraries))\n\n    @staticmethod\n    def get_name() -> str:\n        return \"Jellyfin\"\n\n    @staticmethod\n    def get_type() -> ModuleType:\n        \"\"\"\n        获取模块类型\n        \"\"\"\n        return ModuleType.MediaServer\n\n    @staticmethod\n    def get_subtype() -> MediaServerType:\n        \"\"\"\n        获取模块子类型\n        \"\"\"\n        return MediaServerType.Jellyfin\n\n    @staticmethod\n    def get_priority() -> int:\n        \"\"\"\n        获取模块优先级，数字越小优先级越高，只有同一接口下优先级才生效\n        \"\"\"\n        return 2\n\n    def init_setting(self) -> Tuple[str, Union[str, bool]]:\n        pass\n\n    def scheduler_job(self) -> None:\n        \"\"\"\n        定时任务，每10分钟调用一次\n        \"\"\"\n        # 定时重连\n        for name, server in self.get_instances().items():\n            if server.is_inactive():\n                logger.info(f\"Jellyfin {name} 服务器连接断开，尝试重连 ...\")\n                server.reconnect()\n\n    def stop(self):\n        pass\n\n    def test(self) -> Optional[Tuple[bool, str]]:\n        \"\"\"\n        测试模块连接性\n        \"\"\"\n        if not self.get_instances():\n            return None\n        for name, server in self.get_instances().items():\n            if server.is_inactive():\n                server.reconnect()\n            if not server.get_user():\n                return False, f\"无法连接Jellyfin服务器：{name}\"\n        return True, \"\"\n\n    def user_authenticate(self, credentials: AuthCredentials, service_name: Optional[str] = None) \\\n            -> Optional[AuthCredentials]:\n        \"\"\"\n        使用Jellyfin用户辅助完成用户认证\n        :param credentials: 认证数据\n        :param service_name: 指定要认证的媒体服务器名称，若为 None 则认证所有服务\n        :return: 认证数据\n        \"\"\"\n        # Jellyfin认证\n        if not credentials or credentials.grant_type != \"password\":\n            return None\n        # 确定要认证的服务器列表\n        if service_name:\n            # 如果指定了服务名，获取该服务实例\n            servers = [(service_name, server)] if (server := self.get_instance(service_name)) else []\n        else:\n            # 如果没有指定服务名，遍历所有服务\n            servers = self.get_instances().items()\n        # 遍历要认证的服务器\n        for name, server in servers:\n            # 触发认证拦截事件\n            intercept_event = eventmanager.send_event(\n                etype=ChainEventType.AuthIntercept,\n                data=AuthInterceptCredentials(username=credentials.username, channel=self.get_name(),\n                                              service=name, status=\"triggered\")\n            )\n            if intercept_event and intercept_event.event_data:\n                intercept_data: AuthInterceptCredentials = intercept_event.event_data\n                if intercept_data.cancel:\n                    continue\n            token = server.authenticate(credentials.username, credentials.password)\n            if token:\n                credentials.channel = self.get_name()\n                credentials.service = name\n                credentials.token = token\n                return credentials\n        return None\n\n    def webhook_parser(self, body: Any, form: Any, args: Any) -> Optional[schemas.WebhookEventInfo]:\n        \"\"\"\n        解析Webhook报文体\n        :param body:  请求体\n        :param form:  请求表单\n        :param args:  请求参数\n        :return: 字典，解析为消息时需要包含：title、text、image\n        \"\"\"\n        source = args.get(\"source\")\n        if source:\n            server: Jellyfin = self.get_instance(source)\n            if not server:\n                return None\n            result = server.get_webhook_message(body)\n            if result:\n                result.server_name = source\n            return result\n\n        for server in self.get_instances().values():\n            if server:\n                result = server.get_webhook_message(body)\n                if result:\n                    return result\n        return None\n\n    def media_exists(self, mediainfo: MediaInfo, itemid: Optional[str] = None,\n                     server: Optional[str] = None) -> Optional[schemas.ExistMediaInfo]:\n        \"\"\"\n        判断媒体文件是否存在\n        :param mediainfo:  识别的媒体信息\n        :param itemid:  媒体服务器ItemID\n        :param server:  媒体服务器名称\n        :return: 如不存在返回None，存在时返回信息，包括每季已存在所有集{type: movie/tv, seasons: {season: [episodes]}}\n        \"\"\"\n        if server:\n            servers = [(server, self.get_instance(server))]\n        else:\n            servers = self.get_instances().items()\n        for name, s in servers:\n            if not s:\n                continue\n            if mediainfo.type == MediaType.MOVIE:\n                if itemid:\n                    movie = s.get_iteminfo(itemid)\n                    if movie:\n                        logger.info(f\"媒体库 {name} 中找到了 {movie}\")\n                        return schemas.ExistMediaInfo(\n                            type=MediaType.MOVIE,\n                            server_type=\"jellyfin\",\n                            server=name,\n                            itemid=movie.item_id\n                        )\n                movies = s.get_movies(title=mediainfo.title, year=mediainfo.year, tmdb_id=mediainfo.tmdb_id)\n                if not movies:\n                    logger.info(f\"{mediainfo.title_year} 没有在媒体库 {name} 中\")\n                    continue\n                else:\n                    logger.info(f\"媒体库 {name} 中找到了 {movies}\")\n                    return schemas.ExistMediaInfo(\n                        type=MediaType.MOVIE,\n                        server_type=\"jellyfin\",\n                        server=name,\n                        itemid=movies[0].item_id\n                    )\n            else:\n                itemid, tvs = s.get_tv_episodes(title=mediainfo.title,\n                                                year=mediainfo.year,\n                                                tmdb_id=mediainfo.tmdb_id,\n                                                item_id=itemid)\n                if not tvs:\n                    logger.info(f\"{mediainfo.title_year} 没有在媒体库 {name} 中\")\n                    continue\n                else:\n                    logger.info(f\"{mediainfo.title_year} 在媒体库 {name} 中找到了这些季集：{tvs}\")\n                    return schemas.ExistMediaInfo(\n                        type=MediaType.TV,\n                        seasons=tvs,\n                        server_type=\"jellyfin\",\n                        server=name,\n                        itemid=itemid\n                    )\n        return None\n\n    def media_statistic(self, server: Optional[str] = None) -> Optional[List[schemas.Statistic]]:\n        \"\"\"\n        媒体数量统计\n        \"\"\"\n        if server:\n            server_obj: Jellyfin = self.get_instance(server)\n            if not server_obj:\n                return None\n            servers = [server_obj]\n        else:\n            servers = self.get_instances().values()\n        media_statistics = []\n        for s in servers:\n            media_statistic = s.get_medias_count()\n            if not media_statistic:\n                continue\n            media_statistic.user_count = s.get_user_count()\n            media_statistics.append(media_statistic)\n        return media_statistics\n\n    def mediaserver_librarys(self, server: Optional[str] = None,\n                             username: Optional[str] = None,\n                             hidden: Optional[bool] = False) -> Optional[List[schemas.MediaServerLibrary]]:\n        \"\"\"\n        媒体库列表\n        \"\"\"\n        server_obj: Jellyfin = self.get_instance(server)\n        if server_obj:\n            return server_obj.get_librarys(username=username, hidden=hidden)\n        return None\n\n    def mediaserver_items(self, server: str, library_id: Union[str, int], start_index: Optional[int] = 0,\n                          limit: Optional[int] = -1) -> Optional[Generator]:\n        \"\"\"\n        获取媒体服务器项目列表，支持分页和不分页逻辑，默认不分页获取所有数据\n\n        :param server: 媒体服务器名称\n        :param library_id: 媒体库ID，用于标识要获取的媒体库\n        :param start_index: 起始索引，用于分页获取数据。默认为 0，即从第一个项目开始获取\n        :param limit: 每次请求的最大项目数，用于分页。如果为 None 或 -1，则表示一次性获取所有数据，默认为 -1\n\n        :return: 返回一个生成器对象，用于逐步获取媒体服务器中的项目\n        \"\"\"\n        server_obj: Jellyfin = self.get_instance(server)\n        if server_obj:\n            return server_obj.get_items(library_id, start_index, limit)\n        return None\n\n    def mediaserver_iteminfo(self, server: str, item_id: str) -> Optional[schemas.MediaServerItem]:\n        \"\"\"\n        媒体库项目详情\n        \"\"\"\n        server_obj: Jellyfin = self.get_instance(server)\n        if server_obj:\n            return server_obj.get_iteminfo(item_id)\n        return None\n\n    def mediaserver_tv_episodes(self, server: str,\n                                item_id: Union[str, int]) -> Optional[List[schemas.MediaServerSeasonInfo]]:\n        \"\"\"\n        获取剧集信息\n        \"\"\"\n        server_obj: Jellyfin = self.get_instance(server)\n        if not server_obj:\n            return None\n        _, seasoninfo = server_obj.get_tv_episodes(item_id=item_id)\n        if not seasoninfo:\n            return []\n        return [schemas.MediaServerSeasonInfo(\n            season=season,\n            episodes=episodes\n        ) for season, episodes in seasoninfo.items()]\n\n    def mediaserver_playing(self, server: str,\n                            count: Optional[int] = 20, username: Optional[str] = None) -> List[schemas.MediaServerPlayItem]:\n        \"\"\"\n        获取媒体服务器正在播放信息\n        \"\"\"\n        server_obj: Jellyfin = self.get_instance(server)\n        if not server_obj:\n            return []\n        return server_obj.get_resume(num=count, username=username)\n\n    def mediaserver_play_url(self, server: str, item_id: Union[str, int]) -> Optional[str]:\n        \"\"\"\n        获取媒体库播放地址\n        \"\"\"\n        server_obj: Jellyfin = self.get_instance(server)\n        if not server_obj:\n            return None\n        return server_obj.get_play_url(item_id)\n\n    def mediaserver_latest(self, server: Optional[str] = None, count: Optional[int] = 20,\n                           username: Optional[str] = None) -> List[schemas.MediaServerPlayItem]:\n        \"\"\"\n        获取媒体服务器最新入库条目\n        \"\"\"\n        server_obj: Jellyfin = self.get_instance(server)\n        if not server_obj:\n            return []\n        return server_obj.get_latest(num=count, username=username)\n\n    def mediaserver_latest_images(self,\n                                  server: Optional[str] = None,\n                                  count: Optional[int] = 20,\n                                  username: Optional[str] = None,\n                                  remote: Optional[bool] = False,\n                                  ) -> List[str]:\n        \"\"\"\n        获取媒体服务器最新入库条目的图片\n\n        :param server: 媒体服务器名称\n        :param count: 获取数量\n        :param username: 用户名\n        :param remote: True为外网链接, False为内网链接\n        :return: 图片链接列表\n        \"\"\"\n        server_obj: Jellyfin = self.get_instance(server)\n        if not server:\n            return []\n\n        links = []\n        items: List[schemas.MediaServerPlayItem] = self.mediaserver_latest(server=server, count=count,\n                                                                           username=username)\n        for item in items:\n            if item.BackdropImageTags:\n                image_url = server_obj.get_backdrop_url(item_id=item.id,\n                                                        image_tag=item.BackdropImageTags[0],\n                                                        remote=remote)\n                if image_url:\n                    links.append(image_url)\n        return links\n"
  },
  {
    "path": "app/modules/jellyfin/jellyfin.py",
    "content": "import json\nfrom datetime import datetime\nfrom typing import List, Union, Optional, Dict, Generator, Tuple, Any\n\nfrom requests import Response\n\nfrom app import schemas\nfrom app.core.config import settings\nfrom app.log import logger\nfrom app.schemas import MediaType\nfrom app.utils.http import RequestUtils\nfrom app.utils.url import UrlUtils\nfrom app.schemas import MediaServerItem\n\n\nclass Jellyfin:\n    _host: Optional[str] = None\n    _apikey: Optional[str] = None\n    _playhost: Optional[str] = None\n    _sync_libraries: List[str] = []\n    user: Optional[Union[str, int]] = None\n\n    def __init__(self, host: Optional[str] = None, apikey: Optional[str] = None, play_host: Optional[str] = None,\n                 sync_libraries: list = None, **kwargs):\n        if not host or not apikey:\n            logger.error(\"Jellyfin服务器配置不完整！！\")\n            return\n        self._host = host\n        if self._host:\n            self._host = UrlUtils.standardize_base_url(self._host)\n        self._playhost = play_host\n        if self._playhost:\n            self._playhost = UrlUtils.standardize_base_url(self._playhost)\n        self._apikey = apikey\n        self.user = self.get_user(settings.SUPERUSER)\n        self.serverid = self.get_server_id()\n        self._sync_libraries = sync_libraries or []\n\n    def is_inactive(self) -> bool:\n        \"\"\"\n        判断是否需要重连\n        \"\"\"\n        if not self._host or not self._apikey:\n            return False\n        return True if not self.user else False\n\n    def reconnect(self):\n        \"\"\"\n        重连\n        \"\"\"\n        self.user = self.get_user()\n        self.serverid = self.get_server_id()\n\n    def get_jellyfin_folders(self) -> List[dict]:\n        \"\"\"\n        获取Jellyfin媒体库路径列表\n        \"\"\"\n        if not self._host or not self._apikey:\n            return []\n        url = f\"{self._host}Library/SelectableMediaFolders\"\n        params = {\n            'api_key': self._apikey\n        }\n        try:\n            res = RequestUtils().get_res(url, params)\n            if res:\n                return res.json()\n            else:\n                logger.error(f\"Library/SelectableMediaFolders 未获取到返回数据\")\n                return []\n        except Exception as e:\n            logger.error(f\"连接Library/SelectableMediaFolders 出错：\" + str(e))\n            return []\n\n    def get_jellyfin_virtual_folders(self) -> List[dict]:\n        \"\"\"\n        获取Jellyfin媒体库所有路径列表（包含共享路径）\n        \"\"\"\n        if not self._host or not self._apikey:\n            return []\n\n        url = f\"{self._host}Library/VirtualFolders\"\n        params = {\n            'api_key': self._apikey\n        }\n        try:\n            res = RequestUtils().get_res(url, params)\n            if res:\n                library_items = res.json()\n                librarys = []\n                for library_item in library_items:\n                    library_id = library_item.get('ItemId')\n                    library_name = library_item.get('Name')\n                    pathInfos = library_item.get('LibraryOptions', {}).get('PathInfos')\n                    library_paths = []\n                    for path in pathInfos:\n                        if path.get('NetworkPath'):\n                            library_paths.append(path.get('NetworkPath'))\n                        else:\n                            library_paths.append(path.get('Path'))\n\n                    if library_name and library_paths:\n                        librarys.append({\n                            'Id': library_id,\n                            'Name': library_name,\n                            'Path': library_paths\n                        })\n                return librarys\n            else:\n                logger.error(f\"Library/VirtualFolders 未获取到返回数据\")\n                return []\n        except Exception as e:\n            logger.error(f\"连接Library/VirtualFolders 出错：\" + str(e))\n            return []\n\n    def __get_jellyfin_librarys(self, username: Optional[str] = None) -> List[dict]:\n        \"\"\"\n        获取Jellyfin媒体库的信息\n        \"\"\"\n        if not self._host or not self._apikey:\n            return []\n        if username:\n            user = self.get_user(username)\n        else:\n            user = self.user\n        url = f\"{self._host}Users/{user}/Views\"\n        params = {\"api_key\": self._apikey}\n        try:\n            res = RequestUtils().get_res(url, params)\n            if res:\n                return res.json().get(\"Items\")\n            else:\n                logger.error(f\"Users/Views 未获取到返回数据\")\n                return []\n        except Exception as e:\n            logger.error(f\"连接Users/Views 出错：\" + str(e))\n            return []\n\n    def get_librarys(self, username: Optional[str] = None, hidden: Optional[bool] = False) -> List[schemas.MediaServerLibrary]:\n        \"\"\"\n        获取媒体服务器所有媒体库列表\n        \"\"\"\n        if not self._host or not self._apikey:\n            return []\n        libraries = []\n        for library in self.__get_jellyfin_librarys(username) or []:\n            if hidden and self._sync_libraries and \"all\" not in self._sync_libraries \\\n                    and library.get(\"Id\") not in self._sync_libraries:\n                continue\n            if library.get(\"CollectionType\") == \"movies\":\n                library_type = MediaType.MOVIE.value\n                link = f\"{self._playhost or self._host}web/index.html#!\" \\\n                       f\"/movies.html?topParentId={library.get('Id')}\"\n            elif library.get(\"CollectionType\") == \"tvshows\":\n                library_type = MediaType.TV.value\n                link = f\"{self._playhost or self._host}web/index.html#!\" \\\n                       f\"/tv.html?topParentId={library.get('Id')}\"\n            else:\n                library_type = MediaType.UNKNOWN.value\n                link = f\"{self._playhost or self._host}web/index.html#!\" \\\n                       f\"/library.html?topParentId={library.get('Id')}\"\n            image = self.__get_local_image_by_id(library.get(\"Id\"))\n            libraries.append(\n                schemas.MediaServerLibrary(\n                    server=\"jellyfin\",\n                    id=library.get(\"Id\"),\n                    name=library.get(\"Name\"),\n                    path=library.get(\"Path\"),\n                    type=library_type,\n                    image=image,\n                    link=link,\n                    server_type=\"jellyfin\"\n                ))\n        return libraries\n\n    def get_user_count(self) -> int:\n        \"\"\"\n        获得用户数量\n        \"\"\"\n        if not self._host or not self._apikey:\n            return 0\n        url = f\"{self._host}Users\"\n        params = {\n            \"api_key\": self._apikey\n        }\n        try:\n            res = RequestUtils().get_res(url, params)\n            if res:\n                return len(res.json())\n            else:\n                logger.error(f\"Users 未获取到返回数据\")\n                return 0\n        except Exception as e:\n            logger.error(f\"连接Users出错：\" + str(e))\n            return 0\n\n    def get_user(self, user_name: Optional[str] = None) -> Optional[Union[str, int]]:\n        \"\"\"\n        获得管理员用户\n        \"\"\"\n        if not self._host or not self._apikey:\n            return None\n        url = f\"{self._host}Users\"\n        params = {\n            \"api_key\": self._apikey\n        }\n        try:\n            res = RequestUtils().get_res(url, params)\n            if res:\n                users = res.json()\n                # 先查询是否有与当前用户名称匹配的\n                if user_name:\n                    for user in users:\n                        if user.get(\"Name\") == user_name:\n                            return user.get(\"Id\")\n                # 查询管理员\n                for user in users:\n                    if user.get(\"Policy\", {}).get(\"IsAdministrator\"):\n                        return user.get(\"Id\")\n            else:\n                logger.error(f\"Users 未获取到返回数据\")\n        except Exception as e:\n            logger.error(f\"连接Users出错：\" + str(e))\n        return None\n\n    def authenticate(self, username: str, password: str) -> Optional[str]:\n        \"\"\"\n        用户认证\n        :param username: 用户名\n        :param password: 密码\n        :return: 认证成功返回token，否则返回None\n        \"\"\"\n        if not self._host or not self._apikey:\n            return None\n        url = f\"{self._host}Users/authenticatebyname\"\n        try:\n            res = RequestUtils(headers={\n                'X-Emby-Authorization': f'MediaBrowser Client=\"MoviePilot\", '\n                                        f'Device=\"requests\", '\n                                        f'DeviceId=\"1\", '\n                                        f'Version=\"1.0.0\", '\n                                        f'Token=\"{self._apikey}\"',\n                'Content-Type': 'application/json',\n                \"Accept\": \"application/json\"\n            }).post_res(\n                url=url,\n                data=json.dumps({\n                    \"Username\": username,\n                    \"Pw\": password\n                })\n            )\n            if res:\n                auth_token = res.json().get(\"AccessToken\")\n                if auth_token:\n                    logger.info(f\"用户 {username} Jellyfin认证成功\")\n                    return auth_token\n            else:\n                logger.error(f\"Users/AuthenticateByName 未获取到返回数据\")\n        except Exception as e:\n            logger.error(f\"连接Users/AuthenticateByName出错：\" + str(e))\n        return None\n\n    def get_server_id(self) -> Optional[str]:\n        \"\"\"\n        获得服务器信息\n        \"\"\"\n        if not self._host or not self._apikey:\n            return None\n        url = f\"{self._host}System/Info\"\n        params = {\n            'api_key': self._apikey\n        }\n        try:\n            res = RequestUtils().get_res(url, params)\n            if res:\n                return res.json().get(\"Id\")\n            else:\n                logger.error(f\"System/Info 未获取到返回数据\")\n        except Exception as e:\n            logger.error(f\"连接System/Info出错：\" + str(e))\n        return None\n\n    def get_medias_count(self) -> schemas.Statistic:\n        \"\"\"\n        获得电影、电视剧、动漫媒体数量\n        :return: MovieCount SeriesCount SongCount\n        \"\"\"\n        if not self._host or not self._apikey:\n            return schemas.Statistic()\n        url = f\"{self._host}Items/Counts\"\n        params = {\n            'api_key': self._apikey\n        }\n        try:\n            res = RequestUtils().get_res(url, params)\n            if res:\n                result = res.json()\n                return schemas.Statistic(\n                    movie_count=result.get(\"MovieCount\") or 0,\n                    tv_count=result.get(\"SeriesCount\") or 0,\n                    episode_count=result.get(\"EpisodeCount\") or 0\n                )\n            else:\n                logger.error(f\"Items/Counts 未获取到返回数据\")\n                return schemas.Statistic()\n        except Exception as e:\n            logger.error(f\"连接Items/Counts出错：\" + str(e))\n        return schemas.Statistic()\n\n    def __get_jellyfin_series_id_by_name(self, name: str, year: str) -> Optional[str]:\n        \"\"\"\n        根据名称查询Jellyfin中剧集的SeriesId\n        \"\"\"\n        if not self._host or not self._apikey or not self.user:\n            return None\n        url = f\"{self._host}Users/{self.user}/Items\"\n        params = {\n            \"IncludeItemTypes\": \"Series\",\n            \"Recursive\": \"true\",\n            \"searchTerm\": name,\n            \"Limit\": 10,\n            \"api_key\": self._apikey\n        }\n        try:\n            res = RequestUtils().get_res(url, params)\n            if res:\n                res_items = res.json().get(\"Items\")\n                if res_items:\n                    for res_item in res_items:\n                        if res_item.get('Name') == name and (\n                                not year or str(res_item.get('ProductionYear')) == str(year)):\n                            return res_item.get('Id')\n        except Exception as e:\n            logger.error(f\"连接Items出错：\" + str(e))\n            return None\n        return \"\"\n\n    def get_movies(self,\n                   title: str,\n                   year: Optional[str] = None,\n                   tmdb_id: Optional[int] = None) -> Optional[List[schemas.MediaServerItem]]:\n        \"\"\"\n        根据标题和年份，检查电影是否在Jellyfin中存在，存在则返回列表\n        :param title: 标题\n        :param year: 年份，为空则不过滤\n        :param tmdb_id: TMDB ID\n        :return: 含title、year属性的字典列表\n        \"\"\"\n        if not self._host or not self._apikey or not self.user:\n            return None\n        url = f\"{self._host}Users/{self.user}/Items\"\n        params = {\n            \"IncludeItemTypes\": \"Movie\",\n            \"Fields\": \"ProviderIds,OriginalTitle,ProductionYear,Path,UserDataPlayCount,UserDataLastPlayedDate,ParentId\",\n            \"StartIndex\": 0,\n            \"Recursive\": \"true\",\n            \"searchTerm\": title,\n            \"Limit\": 10,\n            \"api_key\": self._apikey\n        }\n        try:\n            res = RequestUtils().get_res(url, params)\n            if res:\n                res_items = res.json().get(\"Items\")\n                if res_items:\n                    ret_movies = []\n                    for item in res_items:\n                        if not item:\n                            continue\n                        mediaserver_item = self.__format_item_info(item)\n                        if mediaserver_item:\n                            if (not tmdb_id or mediaserver_item.tmdbid == tmdb_id) and \\\n                                    mediaserver_item.title == title and \\\n                                    (not year or str(mediaserver_item.year) == str(year)):\n                                ret_movies.append(mediaserver_item)\n                    return ret_movies\n        except Exception as e:\n            logger.error(f\"连接Items出错：\" + str(e))\n            return None\n        return []\n\n    def get_tv_episodes(self,\n                        item_id: Optional[str] = None,\n                        title: Optional[str] = None,\n                        year: Optional[str] = None,\n                        tmdb_id: Optional[int] = None,\n                        season: Optional[int] = None) -> Tuple[Optional[str], Optional[Dict[int, list]]]:\n        \"\"\"\n        根据标题和年份和季，返回Jellyfin中的剧集列表\n        :param item_id: Jellyfin中的Id\n        :param title: 标题\n        :param year: 年份\n        :param tmdb_id: TMDBID\n        :param season: 季\n        :return: 集号的列表\n        \"\"\"\n        if not self._host or not self._apikey or not self.user:\n            return None, None\n        # 查TVID\n        if not item_id:\n            item_id = self.__get_jellyfin_series_id_by_name(title, year)\n            if item_id is None:\n                return None, None\n            if not item_id:\n                return None, {}\n        # 验证tmdbid是否相同\n        item_info = self.get_iteminfo(item_id)\n        if item_info:\n            if tmdb_id and item_info.tmdbid:\n                if str(tmdb_id) != str(item_info.tmdbid):\n                    return None, {}\n        if season is None:\n            season = None\n        url = f\"{self._host}Shows/{item_id}/Episodes\"\n        params = {\n            \"season\": season,\n            \"userId\": self.user,\n            \"isMissing\": \"false\",\n            \"api_key\": self._apikey\n        }\n        try:\n            res_json = RequestUtils().get_res(url, params)\n            if res_json:\n                tv_info = res_json.json()\n                res_items = tv_info.get(\"Items\")\n                # 返回的季集信息\n                season_episodes = {}\n                for res_item in res_items:\n                    season_index = res_item.get(\"ParentIndexNumber\")\n                    if season_index is None:\n                        continue\n                    if season is not None and season != season_index:\n                        continue\n                    episode_index = res_item.get(\"IndexNumber\")\n                    if episode_index is None:\n                        continue\n                    if not season_episodes.get(season_index):\n                        season_episodes[season_index] = []\n                    season_episodes[season_index].append(episode_index)\n                return item_id, season_episodes\n        except Exception as e:\n            logger.error(f\"连接Shows/Id/Episodes出错：\" + str(e))\n            return None, None\n        return None, {}\n\n    def get_remote_image_by_id(self, item_id: str, image_type: str) -> Optional[str]:\n        \"\"\"\n        根据ItemId从Jellyfin查询TMDB图片地址\n        :param item_id: 在Jellyfin中的ID\n        :param image_type: 图片的类弄地，poster或者backdrop等\n        :return: 图片对应在TMDB中的URL\n        \"\"\"\n        if not self._host or not self._apikey:\n            return None\n        url = f\"{self._host}Items/{item_id}/RemoteImages\"\n        params = {\"api_key\": self._apikey}\n        try:\n            res = RequestUtils(timeout=10).get_res(url, params)\n            if res:\n                images = res.json().get(\"Images\")\n                for image in images:\n                    if image.get(\"ProviderName\") == \"TheMovieDb\" and image.get(\"Type\") == image_type:\n                        return image.get(\"Url\")\n                # return images[0].get(\"Url\") # 首选无则返回第一张\n            else:\n                logger.info(f\"Items/RemoteImages 未获取到返回数据，采用本地图片\")\n                return self.generate_image_link(item_id, image_type, True)\n        except Exception as e:\n            logger.error(f\"连接Items/Id/RemoteImages出错：\" + str(e))\n            return None\n        return None\n\n    def get_item_path_by_id(self, item_id: str) -> Optional[str]:\n        \"\"\"\n        根据ItemId查询所在的Path\n        :param item_id: 在Jellyfin中的ID\n        :return: Path\n        \"\"\"\n        if not self._host or not self._apikey:\n            return None\n        url = f\"{self._host}Items/{item_id}/PlaybackInfo\"\n        params = {\"api_key\": self._apikey}\n        try:\n            res = RequestUtils(timeout=10).get_res(url, params)\n            if res:\n                media_sources = res.json().get(\"MediaSources\")\n                if media_sources:\n                    return media_sources[0].get(\"Path\")\n            else:\n                logger.error(\"Items/Id/PlaybackInfo 未获取到返回数据，不设置 Path\")\n                return None\n        except Exception as e:\n            logger.error(\"连接Items/Id/PlaybackInfo出错：\" + str(e))\n            return None\n        return None\n\n    def generate_image_link(self, item_id: str, image_type: str, host_type: bool) -> Optional[str]:\n        \"\"\"\n        根据ItemId和imageType查询本地对应图片\n        :param item_id: 在Jellyfin中的ID\n        :param image_type: 图片类型，如Backdrop、Primary\n        :param host_type: True为外网链接, False为内网链接\n        :return: 图片对应在host_type的播放器中的URL\n        \"\"\"\n        if not self._playhost:\n            logger.error(\"Jellyfin外网播放地址未能获取或为空\")\n            return None\n        # 检测是否为TV\n        _parent_id = self.get_itemId_ancestors(item_id, 0, \"ParentBackdropItemId\")\n        if _parent_id:\n            item_id = _parent_id\n\n        _host = self._host\n        if host_type:\n            _host = self._playhost\n        url = f\"{_host}Items/{item_id}/Images/{image_type}\"\n        try:\n            res = RequestUtils().get_res(url)\n            if res and res.status_code != 404:\n                logger.info(f\"影片图片链接:{res.url}\")\n                return res.url\n            else:\n                logger.error(\"Items/Id/Images 未获取到返回数据或无该影片{}图片\".format(image_type))\n                return None\n        except Exception as e:\n            logger.error(f\"连接Items/Id/Images出错：\" + str(e))\n            return None\n\n    def get_itemId_ancestors(self, item_id: str, index: int, key: str) -> Optional[Union[str, list, int, dict, bool]]:\n        \"\"\"\n        获得itemId的父item\n        :param item_id: 在Jellyfin中剧集的ID (S01E02的E02的item_id)\n        :param index: 第几个json对象\n        :param key: 需要得到父item中的键值对\n        :return key对应类型的值\n        \"\"\"\n        url = f\"{self._host}Items/{item_id}/Ancestors\"\n        params = {\n            \"api_key\": self._apikey\n        }\n        try:\n            res = RequestUtils().get_res(url, params)\n            if res:\n                return res.json()[index].get(key)\n            else:\n                logger.error(f\"Items/Id/Ancestors 未获取到返回数据\")\n                return None\n        except Exception as e:\n            logger.error(f\"连接Items/Id/Ancestors出错：\" + str(e))\n            return None\n\n    def refresh_root_library(self) -> Optional[bool]:\n        \"\"\"\n        通知Jellyfin刷新整个媒体库\n        \"\"\"\n        if not self._host or not self._apikey:\n            return False\n        url = f\"{self._host}Library/Refresh\"\n        params = {\n            \"api_key\": self._apikey\n        }\n        try:\n            res = RequestUtils().post_res(url, params=params)\n            if res:\n                return True\n            else:\n                logger.info(f\"刷新媒体库失败，无法连接Jellyfin！\")\n        except Exception as e:\n            logger.error(f\"连接Library/Refresh出错：\" + str(e))\n            return False\n\n    def get_webhook_message(self, body: any) -> Optional[schemas.WebhookEventInfo]:\n        \"\"\"\n        解析Jellyfin报文\n        {\n          \"ServerId\": \"d79d3a6261614419a114595a585xxxxx\",\n          \"ServerName\": \"nyanmisaka-jellyfin1\",\n          \"ServerVersion\": \"10.8.10\",\n          \"ServerUrl\": \"http://xxxxxxxx:8098\",\n          \"NotificationType\": \"PlaybackStart\",\n          \"Timestamp\": \"2023-09-10T08:35:25.3996506+00:00\",\n          \"UtcTimestamp\": \"2023-09-10T08:35:25.3996527Z\",\n          \"Name\": \"慕灼华逃婚离开\",\n          \"Overview\": \"慕灼华假装在读书，她害怕大娘子说她不务正业。\",\n          \"Tagline\": \"\",\n          \"ItemId\": \"4b92551344f53b560fb55cd6700xxxxx\",\n          \"ItemType\": \"Episode\",\n          \"RunTimeTicks\": 27074985984,\n          \"RunTime\": \"00:45:07\",\n          \"Year\": 2023,\n          \"SeriesName\": \"灼灼风流\",\n          \"SeasonNumber\": 1,\n          \"SeasonNumber00\": \"01\",\n          \"SeasonNumber000\": \"001\",\n          \"EpisodeNumber\": 1,\n          \"EpisodeNumber00\": \"01\",\n          \"EpisodeNumber000\": \"001\",\n          \"Provider_tmdb\": \"229210\",\n          \"Video_0_Title\": \"4K HEVC SDR\",\n          \"Video_0_Type\": \"Video\",\n          \"Video_0_Codec\": \"hevc\",\n          \"Video_0_Profile\": \"Main\",\n          \"Video_0_Level\": 150,\n          \"Video_0_Height\": 2160,\n          \"Video_0_Width\": 3840,\n          \"Video_0_AspectRatio\": \"16:9\",\n          \"Video_0_Interlaced\": false,\n          \"Video_0_FrameRate\": 25,\n          \"Video_0_VideoRange\": \"SDR\",\n          \"Video_0_ColorSpace\": \"bt709\",\n          \"Video_0_ColorTransfer\": \"bt709\",\n          \"Video_0_ColorPrimaries\": \"bt709\",\n          \"Video_0_PixelFormat\": \"yuv420p\",\n          \"Video_0_RefFrames\": 1,\n          \"Audio_0_Title\": \"AAC - Stereo - Default\",\n          \"Audio_0_Type\": \"Audio\",\n          \"Audio_0_Language\": \"und\",\n          \"Audio_0_Codec\": \"aac\",\n          \"Audio_0_Channels\": 2,\n          \"Audio_0_Bitrate\": 125360,\n          \"Audio_0_SampleRate\": 48000,\n          \"Audio_0_Default\": true,\n          \"PlaybackPositionTicks\": 1000000,\n          \"PlaybackPosition\": \"00:00:00\",\n          \"MediaSourceId\": \"4b92551344f53b560fb55cd6700ebc86\",\n          \"IsPaused\": false,\n          \"IsAutomated\": false,\n          \"DeviceId\": \"TW96aWxsxxxxxjA\",\n          \"DeviceName\": \"Edge Chromium\",\n          \"ClientName\": \"Jellyfin Web\",\n          \"NotificationUsername\": \"Jeaven\",\n          \"UserId\": \"9783d2432b0d40a8a716b6aa46xxxxx\"\n        }\n        \"\"\"\n        if not body:\n            return None\n        try:\n            message = json.loads(body)\n        except Exception as e:\n            logger.debug(f\"解析Jellyfin Webhook报文出错：\" + str(e))\n            return None\n        if not message:\n            return None\n        logger.debug(f\"接收到jellyfin webhook：{message}\")\n        eventType = message.get('NotificationType')\n        if not eventType:\n            return None\n        eventItem = schemas.WebhookEventInfo(\n            event=eventType,\n            channel=\"jellyfin\"\n        )\n        eventItem.item_id = message.get('ItemId')\n        eventItem.tmdb_id = message.get('Provider_tmdb')\n        eventItem.overview = message.get('Overview')\n        eventItem.item_favorite = message.get('Favorite')\n        eventItem.save_reason = message.get('SaveReason')\n        eventItem.device_name = message.get('DeviceName')\n        eventItem.user_name = message.get('NotificationUsername')\n        eventItem.client = message.get('ClientName')\n        eventItem.media_type = message.get('ItemType')\n        if message.get(\"ItemType\") == \"Episode\" \\\n                or message.get(\"ItemType\") == \"Series\" \\\n                or message.get(\"ItemType\") == \"Season\":\n            # 剧集\n            eventItem.item_type = \"TV\"\n            eventItem.season_id = message.get('SeasonNumber')\n            eventItem.episode_id = message.get('EpisodeNumber')\n            eventItem.item_name = \"%s %s%s %s\" % (\n                message.get('SeriesName'),\n                \"S\" + str(eventItem.season_id),\n                \"E\" + str(eventItem.episode_id),\n                message.get('Name'))\n        elif message.get(\"ItemType\") == 'Audio':\n            # 音乐\n            eventItem.item_type = \"AUD\"\n            eventItem.item_name = message.get('Album')\n            eventItem.overview = message.get('Name')\n            eventItem.item_id = message.get('ItemId')\n        else:\n            # 电影\n            eventItem.item_type = \"MOV\"\n            eventItem.item_name = \"%s %s\" % (\n                message.get('Name'), \"(\" + str(message.get('Year')) + \")\")\n\n        playback_position_ticks = message.get('PlaybackPositionTicks')\n        runtime_ticks = message.get('RunTimeTicks')\n        if playback_position_ticks is not None and runtime_ticks is not None:\n            eventItem.percentage = playback_position_ticks / runtime_ticks * 100\n\n        # 获取消息图片\n        if eventItem.item_id:\n            # 根据返回的item_id去调用媒体服务器获取\n            eventItem.image_url = self.get_remote_image_by_id(\n                item_id=eventItem.item_id,\n                image_type=\"Backdrop\"\n            )\n            # jellyfin 的 webhook 不含 item_path，需要单独获取\n            eventItem.item_path = self.get_item_path_by_id(eventItem.item_id)\n\n        eventItem.json_object = message\n\n        return eventItem\n\n    @staticmethod\n    def __format_item_info(item) -> Optional[schemas.MediaServerItem]:\n        \"\"\"\n        格式化item\n        \"\"\"\n        try:\n            user_data = item.get(\"UserData\", {})\n            if not user_data:\n                user_state = None\n            else:\n                resume = item.get(\"UserData\", {}).get(\"PlaybackPositionTicks\") and item.get(\"UserData\", {}).get(\n                    \"PlaybackPositionTicks\") > 0\n                last_played_date = item.get(\"UserData\", {}).get(\"LastPlayedDate\")\n                if last_played_date is not None and \".\" in last_played_date:\n                    last_played_date = last_played_date.split(\".\")[0]\n                user_state = schemas.MediaServerItemUserState(\n                    played=item.get(\"UserData\", {}).get(\"Played\"),\n                    resume=resume,\n                    last_played_date=datetime.strptime(last_played_date, \"%Y-%m-%dT%H:%M:%S\").strftime(\n                        \"%Y-%m-%d %H:%M:%S\") if last_played_date else None,\n                    play_count=item.get(\"UserData\", {}).get(\"PlayCount\"),\n                    percentage=item.get(\"UserData\", {}).get(\"PlayedPercentage\"),\n                )\n            tmdbid = item.get(\"ProviderIds\", {}).get(\"Tmdb\")\n            return schemas.MediaServerItem(\n                server=\"jellyfin\",\n                library=item.get(\"ParentId\"),\n                item_id=item.get(\"Id\"),\n                item_type=item.get(\"Type\"),\n                title=item.get(\"Name\"),\n                original_title=item.get(\"OriginalTitle\"),\n                year=item.get(\"ProductionYear\"),\n                tmdbid=int(tmdbid) if tmdbid else None,\n                imdbid=item.get(\"ProviderIds\", {}).get(\"Imdb\"),\n                tvdbid=item.get(\"ProviderIds\", {}).get(\"Tvdb\"),\n                path=item.get(\"Path\"),\n                user_state=user_state\n\n            )\n        except Exception as e:\n            logger.error(e)\n        return None\n\n    def get_iteminfo(self, itemid: str) -> Optional[schemas.MediaServerItem]:\n        \"\"\"\n        获取单个项目详情\n        \"\"\"\n        if not itemid:\n            return None\n        if not self._host or not self._apikey:\n            return None\n        url = f\"{self._host}Users/{self.user}/Items/{itemid}\"\n        params = {\n            \"api_key\": self._apikey\n        }\n        try:\n            res = RequestUtils().get_res(url, params)\n            if res and res.status_code == 200:\n                return self.__format_item_info(res.json())\n        except Exception as e:\n            logger.error(f\"连接Users/{self.user}/Items/{itemid}：\" + str(e))\n        return None\n\n    def get_items(self, parent: Union[str, int], start_index: Optional[int] = 0, limit: Optional[int] = -1) \\\n            -> Generator[MediaServerItem | None | Any, Any, None]:\n        \"\"\"\n        获取媒体服务器项目列表，支持分页和不分页逻辑，默认不分页获取所有数据\n\n        :param parent: 媒体库ID，用于标识要获取的媒体库\n        :param start_index: 起始索引，用于分页获取数据。默认为 0，即从第一个项目开始获取\n        :param limit: 每次请求的最大项目数，用于分页。如果为 None 或 -1，则表示一次性获取所有数据，默认为 -1\n\n        :return: 返回一个生成器对象，用于逐步获取媒体服务器中的项目\n        \"\"\"\n        if not parent or not self._host or not self._apikey:\n            return None\n        url = f\"{self._host}Users/{self.user}/Items\"\n        params = {\n            \"ParentId\": parent,\n            \"api_key\": self._apikey,\n            \"Fields\": \"ProviderIds,OriginalTitle,ProductionYear,Path,UserDataPlayCount,UserDataLastPlayedDate,ParentId\",\n        }\n        if limit is not None and limit != -1:\n            params.update({\n                \"StartIndex\": start_index,\n                \"Limit\": limit\n            })\n        try:\n            res = RequestUtils().get_res(url, params)\n            if not res or res.status_code != 200:\n                return None\n            items = res.json().get(\"Items\") or []\n            for item in items:\n                if not item:\n                    continue\n                if \"Folder\" in item.get(\"Type\"):\n                    for items in self.get_items(item.get(\"Id\")):\n                        yield items\n                elif item.get(\"Type\") in [\"Movie\", \"Series\"]:\n                    yield self.__format_item_info(item)\n        except Exception as e:\n            logger.error(f\"连接Users/Items出错：\" + str(e))\n\n    def get_data(self, url: str) -> Optional[Response]:\n        \"\"\"\n        自定义URL从媒体服务器获取数据，其中[HOST]、[APIKEY]、[USER]会被替换成实际的值\n        :param url: 请求地址\n        \"\"\"\n        if not self._host or not self._apikey:\n            return None\n        url = url.replace(\"[HOST]\", self._host or '') \\\n            .replace(\"[APIKEY]\", self._apikey or '') \\\n            .replace(\"[USER]\", self.user or '')\n        try:\n            return RequestUtils(accept_type=\"application/json\").get_res(url=url)\n        except Exception as e:\n            logger.error(f\"连接Jellyfin出错：\" + str(e))\n            return None\n\n    def post_data(self, url: str, data: Optional[str] = None, headers: dict = None) -> Optional[Response]:\n        \"\"\"\n        自定义URL从媒体服务器获取数据，其中[HOST]、[APIKEY]、[USER]会被替换成实际的值\n        :param url: 请求地址\n        :param data: 请求数据\n        :param headers: 请求头\n        \"\"\"\n        if not self._host or not self._apikey:\n            return None\n        url = url.replace(\"[HOST]\", self._host or '') \\\n            .replace(\"[APIKEY]\", self._apikey or '') \\\n            .replace(\"[USER]\", self.user or '')\n        try:\n            return RequestUtils(\n                headers=headers\n            ).post_res(url=url, data=data)\n        except Exception as e:\n            logger.error(f\"连接Jellyfin出错：\" + str(e))\n            return None\n\n    def get_play_url(self, item_id: str) -> str:\n        \"\"\"\n        拼装媒体播放链接\n        :param item_id: 媒体的的ID\n        \"\"\"\n        return f\"{self._playhost or self._host}web/index.html#!\" \\\n               f\"/details?id={item_id}&serverId={self.serverid}\"\n\n    def __get_local_image_by_id(self, item_id: str) -> str:\n        \"\"\"\n        根据ItemId从媒体服务器查询有声书图片地址\n        :param: item_id: 在Jellyfin中的ID\n        :param: remote 是否远程使用，TG微信等客户端调用应为True\n        :param: inner 是否NT内部调用，为True是会使用NT中转\n        \"\"\"\n        if not self._host or not self._apikey:\n            return \"\"\n        return \"%sItems/%s/Images/Primary\" % (self._host, item_id)\n\n    def get_backdrop_url(self, item_id: str, image_tag: str, remote: Optional[bool] = False) -> str:\n        \"\"\"\n        获取Backdrop图片地址\n        :param: item_id: 在Jellyfin中的ID\n        :param: image_tag: 图片的tag\n        :param: remote 是否远程使用，TG微信等客户端调用应为True\n        \"\"\"\n        if not self._host or not self._apikey:\n            return \"\"\n        if not image_tag or not item_id:\n            return \"\"\n        if remote:\n            host_url = self._playhost or self._host\n        else:\n            host_url = self._host\n        return f\"{host_url}Items/{item_id}/\" \\\n               f\"Images/Backdrop?tag={image_tag}&api_key={self._apikey}\"\n\n    def get_resume(self, num: Optional[int] = 12, username: Optional[str] = None) -> Optional[List[schemas.MediaServerPlayItem]]:\n        \"\"\"\n        获得继续观看\n        \"\"\"\n        if not self._host or not self._apikey:\n            return None\n        if username:\n            user = self.get_user(username)\n        else:\n            user = self.user\n\n        url = f\"{self._host}Users/{user}/Items/Resume\"\n        params = {\n            \"Limit\": 100,\n            \"MediaTypes\": \"Video\",\n            \"Fields\": \"ProductionYear,Path\",\n            \"api_key\": self._apikey,\n        }\n        try:\n            res = RequestUtils().get_res(url, params)\n            if res:\n                result = res.json().get(\"Items\") or []\n                ret_resume = []\n                # 用户媒体库文件夹列表（排除黑名单）\n                library_folders = self.get_user_library_folders()\n                for item in result:\n                    if len(ret_resume) == num:\n                        break\n                    if item.get(\"Type\") not in [\"Movie\", \"Episode\"]:\n                        continue\n                    item_path = item.get(\"Path\")\n                    if item_path and library_folders and not any(\n                            str(item_path).startswith(folder) for folder in library_folders):\n                        continue\n                    item_type = MediaType.MOVIE.value if item.get(\"Type\") == \"Movie\" else MediaType.TV.value\n                    link = self.get_play_url(item.get(\"Id\"))\n                    if item.get(\"BackdropImageTags\"):\n                        image = self.get_backdrop_url(item_id=item.get(\"Id\"),\n                                                      image_tag=item.get(\"BackdropImageTags\")[0])\n                    else:\n                        image = self.__get_local_image_by_id(item.get(\"Id\"))\n                    # 小部分剧集无[xxx-S01E01-thumb.jpg]图片\n                    image_res = RequestUtils().get_res(image)\n                    if not image_res or image_res.status_code == 404:\n                        image = self.generate_image_link(item.get(\"Id\"), \"Backdrop\", False)\n                    if item_type == MediaType.MOVIE.value:\n                        title = item.get(\"Name\")\n                        subtitle = str(item.get(\"ProductionYear\")) if item.get(\"ProductionYear\") else None\n                    else:\n                        title = f'{item.get(\"SeriesName\")}'\n                        subtitle = f'S{item.get(\"ParentIndexNumber\")}:{item.get(\"IndexNumber\")} - {item.get(\"Name\")}'\n                    ret_resume.append(schemas.MediaServerPlayItem(\n                        id=item.get(\"Id\"),\n                        title=title,\n                        subtitle=subtitle,\n                        type=item_type,\n                        image=image,\n                        link=link,\n                        percent=item.get(\"UserData\", {}).get(\"PlayedPercentage\"),\n                        server_type='jellyfin',\n                    ))\n                return ret_resume\n            else:\n                logger.error(f\"Users/Items/Resume 未获取到返回数据\")\n        except Exception as e:\n            logger.error(f\"连接Users/Items/Resume出错：\" + str(e))\n        return []\n\n    def get_latest(self, num=20, username: Optional[str] = None) -> Optional[List[schemas.MediaServerPlayItem]]:\n        \"\"\"\n        获得最近更新\n        \"\"\"\n        if not self._host or not self._apikey:\n            return None\n        if username:\n            user = self.get_user(username)\n        else:\n            user = self.user\n        url = f\"{self._host}Users/{user}/Items/Latest\"\n        params = {\n            \"Limit\": 100,\n            \"MediaTypes\": \"Video\",\n            \"Fields\": \"ProductionYear,Path,BackdropImageTags\",\n            \"api_key\": self._apikey,\n        }\n        try:\n            res = RequestUtils().get_res(url, params)\n            if res:\n                result = res.json() or []\n                ret_latest = []\n                # 用户媒体库文件夹列表（排除黑名单）\n                library_folders = self.get_user_library_folders()\n                for item in result:\n                    if len(ret_latest) == num:\n                        break\n                    if item.get(\"Type\") not in [\"Movie\", \"Series\"]:\n                        continue\n                    item_path = item.get(\"Path\")\n                    if item_path and library_folders and not any(\n                            str(item_path).startswith(folder) for folder in library_folders):\n                        continue\n                    item_type = MediaType.MOVIE.value if item.get(\"Type\") == \"Movie\" else MediaType.TV.value\n                    link = self.get_play_url(item.get(\"Id\"))\n                    image = self.__get_local_image_by_id(item_id=item.get(\"Id\"))\n                    ret_latest.append(schemas.MediaServerPlayItem(\n                        id=item.get(\"Id\"),\n                        title=item.get(\"Name\"),\n                        subtitle=str(item.get(\"ProductionYear\")) if item.get(\"ProductionYear\") else None,\n                        type=item_type,\n                        image=image,\n                        link=link,\n                        BackdropImageTags=item.get(\"BackdropImageTags\"),\n                        server_type='jellyfin'\n                    ))\n                return ret_latest\n            else:\n                logger.error(f\"Users/Items/Latest 未获取到返回数据\")\n        except Exception as e:\n            logger.error(f\"连接Users/Items/Latest出错：\" + str(e))\n        return []\n\n    def get_user_library_folders(self):\n        \"\"\"\n        获取Jellyfin媒体库文件夹列表（排除黑名单）\n        \"\"\"\n        if not self._host or not self._apikey:\n            return []\n        library_folders = []\n        for library in self.get_jellyfin_virtual_folders() or []:\n            if self._sync_libraries and library.get(\"Id\") not in self._sync_libraries:\n                continue\n            library_folders += [folder for folder in library.get(\"Path\")]\n        return library_folders\n"
  },
  {
    "path": "app/modules/plex/__init__.py",
    "content": "from typing import Optional, Tuple, Union, Any, List, Generator\n\nfrom app import schemas\nfrom app.core.context import MediaInfo\nfrom app.core.event import eventmanager\nfrom app.log import logger\nfrom app.modules import _ModuleBase, _MediaServerBase\nfrom app.modules.plex.plex import Plex\nfrom app.schemas import AuthCredentials, AuthInterceptCredentials\nfrom app.schemas.types import MediaType, ModuleType, ChainEventType, MediaServerType\n\n\nclass PlexModule(_ModuleBase, _MediaServerBase[Plex]):\n\n    def init_module(self) -> None:\n        \"\"\"\n        初始化模块\n        \"\"\"\n        super().init_service(service_name=Plex.__name__.lower(),\n                             service_type=lambda conf: Plex(**conf.config, sync_libraries=conf.sync_libraries))\n\n    @staticmethod\n    def get_name() -> str:\n        return \"Plex\"\n\n    @staticmethod\n    def get_type() -> ModuleType:\n        \"\"\"\n        获取模块类型\n        \"\"\"\n        return ModuleType.MediaServer\n\n    @staticmethod\n    def get_subtype() -> MediaServerType:\n        \"\"\"\n        获取模块子类型\n        \"\"\"\n        return MediaServerType.Plex\n\n    @staticmethod\n    def get_priority() -> int:\n        \"\"\"\n        获取模块优先级，数字越小优先级越高，只有同一接口下优先级才生效\n        \"\"\"\n        return 3\n\n    def stop(self):\n        \"\"\"\n        停止模块服务\n        \"\"\"\n        for server in self.get_instances().values():\n            if server:\n                server.close()\n\n    def test(self) -> Optional[Tuple[bool, str]]:\n        \"\"\"\n        测试模块连接性\n        \"\"\"\n        if not self.get_instances():\n            return None\n        for name, server in self.get_instances().items():\n            if server.is_inactive():\n                server.reconnect()\n            if not server.get_librarys():\n                return False, f\"无法连接Plex服务器：{name}\"\n        return True, \"\"\n\n    def init_setting(self) -> Tuple[str, Union[str, bool]]:\n        pass\n\n    def scheduler_job(self) -> None:\n        \"\"\"\n        定时任务，每10分钟调用一次\n        \"\"\"\n        # 定时重连\n        for name, server in self.get_instances().items():\n            if server.is_inactive():\n                logger.info(f\"Plex {name} 服务器连接断开，尝试重连 ...\")\n                server.reconnect()\n\n    def user_authenticate(self, credentials: AuthCredentials, service_name: Optional[str] = None) \\\n            -> Optional[AuthCredentials]:\n        \"\"\"\n        使用Plex用户辅助完成用户认证\n        :param credentials: 认证数据\n        :param service_name: 指定要认证的媒体服务器名称，若为 None 则认证所有服务\n        :return: 认证数据\n        \"\"\"\n        # Plex认证\n        if not credentials or credentials.grant_type != \"password\":\n            return None\n        # 确定要认证的服务器列表\n        if service_name:\n            # 如果指定了服务名，获取该服务实例\n            servers = [(service_name, server)] if (server := self.get_instance(service_name)) else []\n        else:\n            # 如果没有指定服务名，遍历所有服务\n            servers = self.get_instances().items()\n        # 遍历要认证的服务器\n        for name, server in servers:\n            # 触发认证拦截事件\n            intercept_event = eventmanager.send_event(\n                etype=ChainEventType.AuthIntercept,\n                data=AuthInterceptCredentials(username=credentials.username, channel=self.get_name(),\n                                              service=name, status=\"triggered\")\n            )\n            if intercept_event and intercept_event.event_data:\n                intercept_data: AuthInterceptCredentials = intercept_event.event_data\n                if intercept_data.cancel:\n                    continue\n            auth_result = server.authenticate(credentials.username, credentials.password)\n            if auth_result:\n                token, username = auth_result\n                credentials.channel = self.get_name()\n                credentials.service = name\n                credentials.token = token\n                # Plex 传入可能为邮箱，这里调整为用户名返回\n                credentials.username = username\n                return credentials\n        return None\n\n    def webhook_parser(self, body: Any, form: Any, args: Any) -> Optional[schemas.WebhookEventInfo]:\n        \"\"\"\n        解析Webhook报文体\n        :param body:  请求体\n        :param form:  请求表单\n        :param args:  请求参数\n        :return: 字典，解析为消息时需要包含：title、text、image\n        \"\"\"\n        source = args.get(\"source\")\n        if source:\n            server: Plex = self.get_instance(source)\n            if not server:\n                return None\n            result = server.get_webhook_message(form)\n            if result:\n                result.server_name = source\n            return result\n\n        for server in self.get_instances().values():\n            if server:\n                result = server.get_webhook_message(form)\n                if result:\n                    return result\n        return None\n\n    def media_exists(self, mediainfo: MediaInfo, itemid: Optional[str] = None,\n                     server: Optional[str] = None) -> Optional[schemas.ExistMediaInfo]:\n        \"\"\"\n        判断媒体文件是否存在\n        :param mediainfo:  识别的媒体信息\n        :param itemid:  媒体服务器ItemID\n        :param server:  媒体服务器名称\n        :return: 如不存在返回None，存在时返回信息，包括每季已存在所有集{type: movie/tv, seasons: {season: [episodes]}}\n        \"\"\"\n        if server:\n            servers = [(server, self.get_instance(server))]\n        else:\n            servers = self.get_instances().items()\n        for name, s in servers:\n            if not s:\n                continue\n            if mediainfo.type == MediaType.MOVIE:\n                if itemid:\n                    movie = s.get_iteminfo(itemid)\n                    if movie:\n                        logger.info(f\"媒体库 {name} 中找到了 {movie}\")\n                        return schemas.ExistMediaInfo(\n                            type=MediaType.MOVIE,\n                            server_type=\"plex\",\n                            server=name,\n                            itemid=movie.item_id\n                        )\n                movies = s.get_movies(title=mediainfo.title,\n                                      original_title=mediainfo.original_title,\n                                      year=mediainfo.year,\n                                      tmdb_id=mediainfo.tmdb_id)\n                if not movies:\n                    logger.info(f\"{mediainfo.title_year} 没有在媒体库 {name} 中\")\n                    continue\n                else:\n                    logger.info(f\"媒体库 {name} 中找到了 {movies}\")\n                    return schemas.ExistMediaInfo(\n                        type=MediaType.MOVIE,\n                        server_type=\"plex\",\n                        server=name,\n                        itemid=movies[0].item_id\n                    )\n            else:\n                item_id, tvs = s.get_tv_episodes(title=mediainfo.title,\n                                                 original_title=mediainfo.original_title,\n                                                 year=mediainfo.year,\n                                                 tmdb_id=mediainfo.tmdb_id,\n                                                 item_id=itemid)\n                if not tvs:\n                    logger.info(f\"{mediainfo.title_year} 没有在媒体库 {name} 中\")\n                    continue\n                else:\n                    logger.info(f\"{mediainfo.title_year} 在媒体库 {name} 中找到了这些季集：{tvs}\")\n                    return schemas.ExistMediaInfo(\n                        type=MediaType.TV,\n                        seasons=tvs,\n                        server_type=\"plex\",\n                        server=name,\n                        itemid=item_id\n                    )\n        return None\n\n    def media_statistic(self, server: Optional[str] = None) -> Optional[List[schemas.Statistic]]:\n        \"\"\"\n        媒体数量统计\n        \"\"\"\n        if server:\n            server_obj: Plex = self.get_instance(server)\n            if not server_obj:\n                return None\n            servers = [server_obj]\n        else:\n            servers = self.get_instances().values()\n        media_statistics = []\n        for s in servers:\n            media_statistic = s.get_medias_count()\n            if not media_statistic:\n                continue\n            media_statistic.user_count = 1\n            media_statistics.append(media_statistic)\n        return media_statistics\n\n    def mediaserver_librarys(self, server: Optional[str] = None, hidden: Optional[bool] = False,\n                             **kwargs) -> Optional[List[schemas.MediaServerLibrary]]:\n        \"\"\"\n        媒体库列表\n        \"\"\"\n        server_obj: Plex = self.get_instance(server)\n        if server_obj:\n            return server_obj.get_librarys(hidden)\n        return None\n\n    def mediaserver_items(self, server: str, library_id: Union[str, int], start_index: Optional[int] = 0,\n                          limit: Optional[int] = -1) -> Optional[Generator]:\n        \"\"\"\n        获取媒体服务器项目列表，支持分页和不分页逻辑，默认不分页获取所有数据\n\n        :param server: 媒体服务器名称\n        :param library_id: 媒体库ID，用于标识要获取的媒体库\n        :param start_index: 起始索引，用于分页获取数据。默认为 0，即从第一个项目开始获取\n        :param limit: 每次请求的最大项目数，用于分页。如果为 None 或 -1，则表示一次性获取所有数据，默认为 -1\n\n        :return: 返回一个生成器对象，用于逐步获取媒体服务器中的项目\n        \"\"\"\n        server_obj: Plex = self.get_instance(server)\n        if server_obj:\n            return server_obj.get_items(library_id, start_index, limit)\n        return None\n\n    def mediaserver_iteminfo(self, server: str, item_id: str) -> Optional[schemas.MediaServerItem]:\n        \"\"\"\n        媒体库项目详情\n        \"\"\"\n        server_obj: Plex = self.get_instance(server)\n        if server_obj:\n            return server_obj.get_iteminfo(item_id)\n        return None\n\n    def mediaserver_tv_episodes(self, server: str,\n                                item_id: Union[str, int]) -> Optional[List[schemas.MediaServerSeasonInfo]]:\n        \"\"\"\n        获取剧集信息\n        \"\"\"\n        server_obj: Plex = self.get_instance(server)\n        if not server_obj:\n            return None\n        _, seasoninfo = server_obj.get_tv_episodes(item_id=item_id)\n        if not seasoninfo:\n            return []\n        return [schemas.MediaServerSeasonInfo(\n            season=season,\n            episodes=episodes\n        ) for season, episodes in seasoninfo.items()]\n\n    def mediaserver_playing(self, server: str, count: Optional[int] = 20,\n                            **kwargs) -> List[schemas.MediaServerPlayItem]:\n        \"\"\"\n        获取媒体服务器正在播放信息\n        \"\"\"\n        server_obj: Plex = self.get_instance(server)\n        if not server_obj:\n            return []\n        return server_obj.get_resume(num=count)\n\n    def mediaserver_latest(self, server: Optional[str] = None, count: Optional[int] = 20,\n                           **kwargs) -> List[schemas.MediaServerPlayItem]:\n        \"\"\"\n        获取媒体服务器最新入库条目\n        \"\"\"\n        server_obj: Plex = self.get_instance(server)\n        if not server_obj:\n            return []\n        return server_obj.get_latest(num=count)\n\n    def mediaserver_latest_images(self,\n                                  server: Optional[str] = None,\n                                  count: Optional[int] = 20,\n                                  username: Optional[str] = None,\n                                  **kwargs\n                                  ) -> List[str]:\n        \"\"\"\n        获取媒体服务器最新入库条目的图片\n\n        :param server: 媒体服务器名称\n        :param count: 获取数量\n        :param username: 用户名\n        :return: 图片链接列表\n        \"\"\"\n        server_obj: Plex = self.get_instance(server)\n        if not server_obj:\n            return []\n\n        links = []\n        items: List[schemas.MediaServerPlayItem] = self.mediaserver_latest(server=server, count=count,\n                                                                           username=username)\n        for item in items:\n            link = server_obj.get_remote_image_by_id(item_id=item.id,\n                                                     image_type=\"Backdrop\",\n                                                     plex_url=False)\n            if link:\n                links.append(link)\n        return links\n\n    def mediaserver_play_url(self, server: str, item_id: Union[str, int]) -> Optional[str]:\n        \"\"\"\n        获取媒体库播放地址\n        \"\"\"\n        server_obj: Plex = self.get_instance(server)\n        if not server_obj:\n            return None\n        return server_obj.get_play_url(item_id)\n"
  },
  {
    "path": "app/modules/plex/plex.py",
    "content": "import json\nfrom pathlib import Path\nfrom typing import List, Optional, Dict, Tuple, Generator, Any, Union\nfrom urllib.parse import quote_plus\n\nfrom plexapi import media\nfrom plexapi.myplex import MyPlexAccount\nfrom plexapi.server import PlexServer\nfrom requests import Response, Session\n\nfrom app import schemas\nfrom app.core.cache import cached\nfrom app.log import logger\nfrom app.schemas import MediaType\nfrom app.utils.http import RequestUtils\nfrom app.utils.url import UrlUtils\nfrom app.schemas import MediaServerItem\n\n\nclass Plex:\n    _plex = None\n    _session = None\n    _sync_libraries: List[str] = []\n\n    def __init__(self, host: Optional[str] = None, token: Optional[str] = None, play_host: Optional[str] = None,\n                 sync_libraries: list = None, **kwargs):\n        if not host or not token:\n            logger.error(\"Plex服务器配置不完整！\")\n            return\n        self._host = host\n        if self._host:\n            self._host = UrlUtils.standardize_base_url(self._host)\n        self._playhost = play_host\n        if self._playhost:\n            self._playhost = UrlUtils.standardize_base_url(self._playhost)\n        self._token = token\n        if self._host and self._token:\n            try:\n                self._plex = PlexServer(self._host, self._token)\n                self._libraries = self._plex.library.sections()\n            except Exception as e:\n                self._plex = None\n                logger.error(f\"Plex服务器连接失败：{str(e)}\")\n            self._session = self.__adapt_plex_session()\n        self._sync_libraries = sync_libraries or []\n\n    def is_inactive(self) -> bool:\n        \"\"\"\n        判断是否需要重连\n        \"\"\"\n        if not self._host or not self._token:\n            return False\n        return True if not self._plex else False\n\n    def reconnect(self):\n        \"\"\"\n        重连\n        \"\"\"\n        try:\n            self._plex = PlexServer(self._host, self._token)\n            self._libraries = self._plex.library.sections()\n        except Exception as e:\n            self._plex = None\n            logger.error(f\"Plex服务器连接失败：{str(e)}\")\n\n    def authenticate(self, username: str, password: str) -> Optional[Tuple[str, str]]:\n        \"\"\"\n        用户认证\n        :param username: 用户名\n        :param password: 密码\n        :return: 认证成功返回 (token, 用户名)，否则返回 None\n        \"\"\"\n        if not username or not password:\n            return None\n        try:\n            account = MyPlexAccount(username=username, password=password, remember=False)\n            if account:\n                plex = PlexServer(self._host, account.authToken)\n                if not plex:\n                    return None\n                return account.authToken, account.username\n        except Exception as e:\n            # 处理认证失败或网络错误等情况\n            logger.error(f\"Authentication failed: {e}\")\n        return None\n\n    @cached(maxsize=32, ttl=86400)\n    def __get_library_images(self, library_key: str, mtype: int) -> Optional[List[str]]:\n        \"\"\"\n        获取媒体服务器最近添加的媒体的图片列表\n        param: library_key\n        param: type type的含义: 1 电影 2 剧集 详见 plexapi/utils.py中SEARCHTYPES的定义\n        \"\"\"\n        if not self._plex:\n            return None\n        # 返回结果\n        poster_urls = {}\n        # 页码计数\n        container_start = 0\n        # 需要的总条数/每页的条数\n        total_size = 4\n        # 如果总数不足,接续获取下一页\n        while len(poster_urls) < total_size:\n            items = self._plex.fetchItems(f\"/hubs/home/recentlyAdded?type={mtype}&sectionID={library_key}\",\n                                          container_start=container_start,\n                                          container_size=8,\n                                          maxresults=8)\n            for item in items:\n                if item.type == \"episode\":\n                    # 如果是剧集的单集,则去找上级的图片\n                    if item.parentThumb is not None:\n                        poster_urls[item.parentThumb] = None\n                else:\n                    # 否则就用自己的图片\n                    if item.thumb is not None:\n                        poster_urls[item.thumb] = None\n                if len(poster_urls) == total_size:\n                    break\n            if len(items) < total_size:\n                break\n            container_start += total_size\n        return [f\"{self._host.rstrip('/') + url}?X-Plex-Token={self._token}\" for url in\n                list(poster_urls.keys())[:total_size]]\n\n    def get_librarys(self, hidden: Optional[bool] = False) -> List[schemas.MediaServerLibrary]:\n        \"\"\"\n        获取媒体服务器所有媒体库列表\n        \"\"\"\n        if not self._plex:\n            return []\n        try:\n            self._libraries = self._plex.library.sections()\n        except Exception as err:\n            logger.error(f\"获取媒体服务器所有媒体库列表出错：{str(err)}\")\n            return []\n        libraries = []\n        for library in self._libraries:\n            if hidden and self._sync_libraries and \"all\" not in self._sync_libraries \\\n                    and str(library.key) not in self._sync_libraries:\n                continue\n            if library.type == \"movie\":\n                library_type = MediaType.MOVIE.value\n                image_list = self.__get_library_images(library.key, 1)\n            elif library.type == \"show\":\n                library_type = MediaType.TV.value\n                image_list = self.__get_library_images(library.key, 2)\n            else:\n                continue\n            libraries.append(\n                schemas.MediaServerLibrary(\n                    id=library.key,\n                    name=library.title,\n                    path=library.locations,\n                    type=library_type,\n                    image_list=image_list,\n                    link=f\"{self._playhost or self._host}web/index.html#!/media/{self._plex.machineIdentifier}\"\n                         f\"/com.plexapp.plugins.library?source={library.key}&X-Plex-Token={self._token}\",\n                    server_type='plex'\n                )\n            )\n        return libraries\n\n    def get_medias_count(self) -> schemas.Statistic:\n        \"\"\"\n        获得电影、电视剧、动漫媒体数量\n        :return: movie_count tv_count episode_count\n        \"\"\"\n        if not self._plex:\n            return schemas.Statistic()\n        sections = self._plex.library.sections()\n        movie_count = tv_count = episode_count = 0\n        # 媒体库白名单\n        allow_library = [str(lib.id) for lib in self.get_librarys(hidden=True)]\n        for sec in sections:\n            if str(sec.key) not in allow_library:\n                continue\n            if sec.type == \"movie\":\n                movie_count += sec.totalSize\n            if sec.type == \"show\":\n                tv_count += sec.totalSize\n                episode_count += sec.totalViewSize(libtype=\"episode\")\n        return schemas.Statistic(\n            movie_count=movie_count,\n            tv_count=tv_count,\n            episode_count=episode_count\n        )\n\n    def get_movies(self,\n                   title: str,\n                   original_title: Optional[str] = None,\n                   year: Optional[str] = None,\n                   tmdb_id: Optional[int] = None) -> Optional[List[schemas.MediaServerItem]]:\n        \"\"\"\n        根据标题和年份，检查电影是否在Plex中存在，存在则返回列表\n        :param title: 标题\n        :param original_title: 原产地标题\n        :param year: 年份，为空则不过滤\n        :param tmdb_id: TMDB ID\n        :return: 含title、year属性的字典列表\n        \"\"\"\n        if not self._plex:\n            return None\n        ret_movies = []\n        if year:\n            movies = self._plex.library.search(title=title,\n                                               year=year,\n                                               libtype=\"movie\")\n            # 根据原标题再查一遍\n            if original_title and str(original_title) != str(title):\n                movies.extend(self._plex.library.search(title=original_title,\n                                                        year=year,\n                                                        libtype=\"movie\"))\n        else:\n            movies = self._plex.library.search(title=title,\n                                               libtype=\"movie\")\n            if original_title and str(original_title) != str(title):\n                movies.extend(self._plex.library.search(title=original_title,\n                                                        libtype=\"movie\"))\n        for item in set(movies):\n            ids = self.__get_ids(item.guids)\n            if tmdb_id and ids['tmdb_id']:\n                if str(ids['tmdb_id']) != str(tmdb_id):\n                    continue\n            path = None\n            if item.locations:\n                path = item.locations[0]\n            ret_movies.append(\n                schemas.MediaServerItem(\n                    server=\"plex\",\n                    library=item.librarySectionID,\n                    item_id=item.key,\n                    item_type=item.type,\n                    title=item.title,\n                    original_title=item.originalTitle,\n                    year=item.year,\n                    tmdbid=ids['tmdb_id'],\n                    imdbid=ids['imdb_id'],\n                    tvdbid=ids['tvdb_id'],\n                    path=path,\n                )\n            )\n        return ret_movies\n\n    def get_tv_episodes(self,\n                        item_id: Optional[str] = None,\n                        title: Optional[str] = None,\n                        original_title: Optional[str] = None,\n                        year: Optional[str] = None,\n                        tmdb_id: Optional[int] = None,\n                        season: Optional[int] = None) -> Tuple[Optional[str], Optional[Dict[int, list]]]:\n        \"\"\"\n        根据标题、年份、季查询电视剧所有集信息\n        :param item_id: 媒体ID\n        :param title: 标题\n        :param original_title: 原产地标题\n        :param year: 年份，可以为空，为空时不按年份过滤\n        :param tmdb_id: TMDB ID\n        :param season: 季号，数字\n        :return: 所有集的列表\n        \"\"\"\n        if not self._plex:\n            return None, {}\n        if item_id:\n            videos = self.__fetch_item(item_id)\n        else:\n            # 兼容年份为空的场景\n            kwargs = {\"year\": year} if year else {}\n            # 根据标题和年份模糊搜索，该结果不够准确\n            videos = self._plex.library.search(title=title,\n                                               libtype=\"show\",\n                                               **kwargs)\n            if (not videos\n                    and original_title\n                    and str(original_title) != str(title)):\n                videos = self._plex.library.search(title=original_title,\n                                                   libtype=\"show\",\n                                                   **kwargs)\n\n        if not videos:\n            return None, {}\n        if isinstance(videos, list):\n            videos = videos[0]\n        video_tmdbid = self.__get_ids(videos.guids).get('tmdb_id')\n        if tmdb_id and video_tmdbid:\n            if str(video_tmdbid) != str(tmdb_id):\n                return None, {}\n        episodes = videos.episodes()\n        season_episodes = {}\n        for episode in episodes:\n            if season is not None and episode.seasonNumber != int(season):\n                continue\n            if episode.seasonNumber not in season_episodes:\n                season_episodes[episode.seasonNumber] = []\n            season_episodes[episode.seasonNumber].append(episode.index)\n        return videos.key, season_episodes\n\n    def get_remote_image_by_id(self,\n                               item_id: str,\n                               image_type: str,\n                               depth: Optional[int] = 0,\n                               plex_url: Optional[bool] = True) -> Optional[str]:\n        \"\"\"\n        根据ItemId从Plex查询图片地址\n        :param item_id: 在Plex中的ID\n        :param image_type: 图片的类型，Poster或者Backdrop等\n        :param depth: 当前递归深度，默认为0\n        :param plex_url: 是否返回Plex的URL，默认为True（仅在配置了外网地址和Token时有效）\n        :return: 图片对应在plex服务器或TMDB中的URL\n        \"\"\"\n        if not self._plex or depth > 2 or not item_id:\n            return None\n        try:\n            image_url = None\n            ekey = item_id\n            item = self._plex.fetchItem(ekey=ekey)\n            if not item:\n                return None\n            # 如果配置了外网播放地址以及Token，则默认从Plex媒体服务器获取图片，否则返回有外网地址的图片资源\n            # Plex外网播放地址这个框里目前可以填两种地址\n            #   1. Plex的官方转发地址https://app.plex.tv, 2. 自己处理的端口转发地址\n            #   如果使用的是1的官方转发地址,那么就不能走这个逻辑，因为官方转发地址无法获取到图片\n            if (self._playhost and \"app.plex.tv\" not in self._playhost\n                    and self._token and plex_url):\n                query = {\"X-Plex-Token\": self._token}\n                if image_type == \"Poster\":\n                    if item.thumb:\n                        image_url = UrlUtils.combine_url(host=self._playhost, path=item.thumb, query=query)\n                else:\n                    # 默认使用art也就是Backdrop进行处理\n                    if item.art:\n                        image_url = UrlUtils.combine_url(host=self._playhost, path=item.art, query=query)\n                    # 这里对episode进行特殊处理，实际上episode的Backdrop是Poster\n                    # 也有个别情况，比如机智的凡人小子episode就是Poster，因此这里把episode的优先级降低，默认还是取art\n                    if not image_url and item.TYPE == \"episode\" and item.thumb:\n                        image_url = UrlUtils.combine_url(host=self._playhost, path=item.thumb, query=query)\n            else:\n                if image_type == \"Poster\":\n                    images = self._plex.fetchItems(ekey=f\"{ekey}/posters\",\n                                                   cls=media.Poster)\n                else:\n                    # 默认使用art也就是Backdrop进行处理\n                    images = self._plex.fetchItems(ekey=f\"{ekey}/arts\",\n                                                   cls=media.Art)\n                    # 这里对episode进行特殊处理，实际上episode的Backdrop是Poster\n                    # 也有个别情况，比如机智的凡人小子episode就是Poster，因此这里把episode的优先级降低，默认还是取art\n                    if not images and item.TYPE == \"episode\":\n                        images = self._plex.fetchItems(ekey=f\"{ekey}/posters\",\n                                                       cls=media.Poster)\n                for image in images:\n                    if hasattr(image, \"key\") and image.key.startswith(\"http\"):\n                        image_url = image.key\n                        break\n                    # 如果最后还是找不到，则递归父级进行查找\n                if not image_url and hasattr(item, \"parentKey\"):\n                    return self.get_remote_image_by_id(item_id=item.parentKey,\n                                                       image_type=image_type,\n                                                       depth=depth + 1)\n            return image_url\n        except Exception as e:\n            logger.error(f\"获取封面出错：\" + str(e))\n        return None\n\n    def refresh_root_library(self) -> bool:\n        \"\"\"\n        通知Plex刷新整个媒体库\n        \"\"\"\n        if not self._plex:\n            return False\n        return self._plex.library.update()\n\n    def refresh_library_by_items(self, items: List[schemas.RefreshMediaItem]) -> Optional[bool]:\n        \"\"\"\n        按路径刷新媒体库 item: target_path\n        \"\"\"\n        if not self._plex:\n            return False\n        result_dict = {}\n        for item in items:\n            file_path = item.target_path\n            lib_key, path = self.__find_librarie(file_path, self._libraries)\n            # 如果存在同一剧集的多集,key(path)相同会合并\n            if path:\n                result_dict[path.as_posix()] = lib_key\n            else:\n                result_dict[\"\"] = lib_key\n        if \"\" in result_dict:\n            # 如果有匹配失败的,刷新整个库\n            self._plex.library.update()\n        else:\n            # 否则一个一个刷新\n            for path, lib_key in result_dict.items():\n                logger.info(f\"刷新媒体库：{lib_key} - {path}\")\n                self._plex.query(f'/library/sections/{lib_key}/refresh?path={quote_plus(Path(path).parent.as_posix())}')\n                return None\n        return None\n\n    @staticmethod\n    def __find_librarie(path: Path, libraries: List[Any]) -> Tuple[str, Optional[Path]]:\n        \"\"\"\n        判断这个path属于哪个媒体库\n        多个媒体库配置的目录不应有重复和嵌套,\n        \"\"\"\n\n        def is_subpath(_path: Path, _parent: Path) -> bool:\n            \"\"\"\n            判断_path是否是_parent的子目录下\n            \"\"\"\n            _path = _path.resolve()\n            _parent = _parent.resolve()\n            return _path.parts[:len(_parent.parts)] == _parent.parts\n\n        if path is None:\n            return \"\", None\n\n        try:\n            for lib in libraries:\n                if hasattr(lib, \"locations\") and lib.locations:\n                    for location in lib.locations:\n                        if is_subpath(path, Path(location)):\n                            return lib.key, path\n        except Exception as err:\n            logger.error(f\"查找媒体库出错：{str(err)}\")\n        return \"\", None\n\n    def get_iteminfo(self, itemid: str) -> Optional[schemas.MediaServerItem]:\n        \"\"\"\n        获取单个项目详情\n        \"\"\"\n        if not self._plex:\n            return None\n        try:\n            item = self.__fetch_item(itemid)\n            return self.__build_media_server_item(item)\n        except Exception as err:\n            logger.error(f\"获取项目详情出错：{str(err)}\")\n        return None\n\n    @staticmethod\n    def __get_ids(guids: List[Any]) -> dict:\n        def parse_tmdb_id(value: str) -> tuple[bool, int]:\n            \"\"\"尝试将TMDB ID字符串转换为整数。如果成功，返回(True, int)，失败则返回(False, None)。\"\"\"\n            try:\n                int_value = int(value)\n                return True, int_value\n            except ValueError:\n                return False, None\n\n        guid_mapping = {\n            \"imdb://\": \"imdb_id\",\n            \"tmdb://\": \"tmdb_id\",\n            \"tvdb://\": \"tvdb_id\"\n        }\n        ids = {varname: None for varname in guid_mapping.values()}\n        for guid in guids:\n            guid_id = guid['id'] if isinstance(guid, dict) else guid.id\n            for prefix, varname in guid_mapping.items():\n                if guid_id.startswith(prefix):\n                    clean_id = guid_id[len(prefix):]\n                    if varname == \"tmdb_id\":\n                        # tmdb_id为int，Plex可能存在脏数据，特别处理tmdb_id\n                        success, parsed_id = parse_tmdb_id(clean_id)\n                        if success:\n                            ids[varname] = parsed_id\n                    else:\n                        ids[varname] = clean_id\n                    break\n\n        return ids\n\n    def __fetch_item(self, item_id: Union[int, str]):\n        \"\"\"\n        根据给定的item_id获取媒体项\n        :param item_id: 媒体项的ID，可以是整数或字符串，如果是字符串且表示为数字，将会被转换为整数\n        \"\"\"\n        if isinstance(item_id, str) and item_id.isdigit():\n            item_id = int(item_id)\n        return self._plex.fetchItem(item_id)\n\n    def __build_media_server_item(self, item) -> Optional[schemas.MediaServerItem]:\n        \"\"\"\n        构造MediaServerItem\n        :param item: Plex媒体项目\n        :return: MediaServerItem\n        \"\"\"\n        if not item:\n            return None\n        ids = self.__get_ids(item.guids)\n        path = item.locations[0] if item.locations else None\n        playback_position = getattr(item, \"viewOffset\", None) or 0\n        duration = getattr(item, \"duration\", None) or 0\n        percentage = (playback_position / duration * 100) if duration > 0 else None\n        played = getattr(item, \"isPlayed\", None) or False\n        play_count = getattr(item, \"viewCount\", None) or 0\n        last_played_date = getattr(item, \"lastViewedAt\", None)\n\n        user_state = schemas.MediaServerItemUserState(\n            played=played,\n            resume=playback_position > 0,\n            last_played_date=last_played_date.isoformat() if last_played_date and hasattr(last_played_date,\n                                                                                          'isoformat') else None,\n            play_count=play_count,\n            percentage=percentage,\n        )\n\n        return schemas.MediaServerItem(\n            server=\"plex\",\n            library=item.librarySectionID,\n            item_id=item.key,\n            item_type=item.type,\n            title=item.title,\n            original_title=item.originalTitle,\n            year=item.year,\n            tmdbid=ids.get(\"tmdb_id\"),\n            imdbid=ids.get(\"imdb_id\"),\n            tvdbid=ids.get(\"tvdb_id\"),\n            path=path,\n            user_state=user_state,\n        )\n\n    def get_items(self, parent: Union[str, int], start_index: Optional[int] = 0, limit: Optional[int] = -1) \\\n            -> Generator[MediaServerItem | None, Any, None]:\n        \"\"\"\n        获取媒体服务器项目列表，支持分页和不分页逻辑，默认不分页获取所有数据\n\n        :param parent: 媒体库ID，用于标识要获取的媒体库\n        :param start_index: 起始索引，用于分页获取数据。默认为 0，即从第一个项目开始获取\n        :param limit: 每次请求的最大项目数，用于分页。如果为 None 或 -1，则表示一次性获取所有数据，默认为 -1\n\n        :return: 返回一个生成器对象，用于逐步获取媒体服务器中的项目\n        \"\"\"\n        if not parent or not self._plex:\n            return None\n        try:\n            section = self._plex.library.sectionByID(int(parent))\n            if section:\n                if limit is None or limit == -1:\n                    items = section.all()\n                else:\n                    items = section.all(container_start=start_index, container_size=limit, maxresults=limit)\n                for item in items:\n                    try:\n                        if not item:\n                            continue\n                        yield self.__build_media_server_item(item)\n                    except Exception as e:\n                        logger.error(f\"处理媒体项目时出错：{str(e)}, 跳过此项目\")\n                        continue\n        except Exception as err:\n            logger.error(f\"获取媒体库列表出错：{str(err)}\")\n        return None\n\n    def get_webhook_message(self, form: any) -> Optional[schemas.WebhookEventInfo]:\n        \"\"\"\n        解析Plex报文\n        eventItem  字段的含义\n        event      事件类型\n        item_type  媒体类型 TV,MOV\n        item_name  TV:琅琊榜 S1E6 剖心明志 虎口脱险\n                   MOV:猪猪侠大冒险(2001)\n        overview   剧情描述\n        {\n          \"event\": \"media.scrobble\",\n          \"user\": false,\n          \"owner\": true,\n          \"Account\": {\n            \"id\": 31646104,\n            \"thumb\": \"https://plex.tv/users/xx\",\n            \"title\": \"播放\"\n          },\n          \"Server\": {\n            \"title\": \"Media-Server\",\n            \"uuid\": \"xxxx\"\n          },\n          \"Player\": {\n            \"local\": false,\n            \"publicAddress\": \"xx.xx.xx.xx\",\n            \"title\": \"MagicBook\",\n            \"uuid\": \"wu0uoa1ujfq90t0c5p9f7fw0\"\n          },\n          \"Metadata\": {\n            \"librarySectionType\": \"show\",\n            \"ratingKey\": \"40294\",\n            \"key\": \"/library/metadata/40294\",\n            \"parentRatingKey\": \"40291\",\n            \"grandparentRatingKey\": \"40275\",\n            \"guid\": \"plex://episode/615580a9fa828e7f1a0caabd\",\n            \"parentGuid\": \"plex://season/615580a9fa828e7f1a0caab8\",\n            \"grandparentGuid\": \"plex://show/60e81fd8d8000e002d7d2976\",\n            \"type\": \"episode\",\n            \"title\": \"The World's Strongest Senior\",\n            \"titleSort\": \"World's Strongest Senior\",\n            \"grandparentKey\": \"/library/metadata/40275\",\n            \"parentKey\": \"/library/metadata/40291\",\n            \"librarySectionTitle\": \"动漫剧集\",\n            \"librarySectionID\": 7,\n            \"librarySectionKey\": \"/library/sections/7\",\n            \"grandparentTitle\": \"范马刃牙\",\n            \"parentTitle\": \"Combat Shadow Fighting Saga / Great Prison Battle Saga\",\n            \"originalTitle\": \"Baki Hanma\",\n            \"contentRating\": \"TV-MA\",\n            \"summary\": \"The world is shaken by news\",\n            \"index\": 1,\n            \"parentIndex\": 1,\n            \"audienceRating\": 8.5,\n            \"viewCount\": 1,\n            \"lastViewedAt\": 1694320444,\n            \"year\": 2021,\n            \"thumb\": \"/library/metadata/40294/thumb/1693544504\",\n            \"art\": \"/library/metadata/40275/art/1693952979\",\n            \"parentThumb\": \"/library/metadata/40291/thumb/1691115271\",\n            \"grandparentThumb\": \"/library/metadata/40275/thumb/1693952979\",\n            \"grandparentArt\": \"/library/metadata/40275/art/1693952979\",\n            \"duration\": 1500000,\n            \"originallyAvailableAt\": \"2021-09-30\",\n            \"addedAt\": 1691115281,\n            \"updatedAt\": 1693544504,\n            \"audienceRatingImage\": \"themoviedb://image.rating\",\n            \"Guid\": [\n              {\n                \"id\": \"imdb://tt14765720\"\n              },\n              {\n                \"id\": \"tmdb://3087250\"\n              },\n              {\n                \"id\": \"tvdb://8530933\"\n              }\n            ],\n            \"Rating\": [\n              {\n                \"image\": \"themoviedb://image.rating\",\n                \"value\": 8.5,\n                \"type\": \"audience\"\n              }\n            ],\n            \"Director\": [\n              {\n                \"id\": 115144,\n                \"filter\": \"director=115144\",\n                \"tag\": \"Keiya Saito\",\n                \"tagKey\": \"5f401c8d04a86500409ea6c1\"\n              }\n            ],\n            \"Writer\": [\n              {\n                \"id\": 115135,\n                \"filter\": \"writer=115135\",\n                \"tag\": \"Tatsuhiko Urahata\",\n                \"tagKey\": \"5d7768e07a53e9001e6db1ce\",\n                \"thumb\": \"https://metadata-static.plex.tv/f/people/f6f90dc89fa87d459f85d40a09720c05.jpg\"\n              }\n            ]\n          }\n        }\n        \"\"\"\n        if not form:\n            return None\n        payload = form.get(\"payload\")\n        if not payload:\n            return None\n        try:\n            message = json.loads(payload)\n        except Exception as e:\n            logger.debug(f\"解析plex webhook出错：{str(e)}\")\n            return None\n        eventType = message.get('event')\n        if not eventType:\n            return None\n        logger.debug(f\"接收到plex webhook：{message}\")\n        eventItem = schemas.WebhookEventInfo(event=eventType, channel=\"plex\")\n        if message.get('Metadata'):\n            if message.get('Metadata', {}).get('type') == 'episode':\n                eventItem.item_type = \"TV\"\n                eventItem.item_name = \"%s %s%s %s\" % (\n                    message.get('Metadata', {}).get('grandparentTitle'),\n                    \"S\" + str(message.get('Metadata', {}).get('parentIndex')),\n                    \"E\" + str(message.get('Metadata', {}).get('index')),\n                    message.get('Metadata', {}).get('title'))\n                eventItem.item_id = message.get('Metadata', {}).get('key')\n                eventItem.season_id = message.get('Metadata', {}).get('parentIndex')\n                eventItem.episode_id = message.get('Metadata', {}).get('index')\n\n                if (message.get('Metadata', {}).get('summary')\n                        and len(message.get('Metadata', {}).get('summary')) > 100):\n                    eventItem.overview = str(message.get('Metadata', {}).get('summary'))[:100] + \"...\"\n                else:\n                    eventItem.overview = message.get('Metadata', {}).get('summary')\n            else:\n                eventItem.item_type = \"MOV\" if message.get('Metadata',\n                                                           {}).get('type') == 'movie' else \"SHOW\"\n                eventItem.item_name = \"%s %s\" % (\n                    message.get('Metadata', {}).get('title'),\n                    \"(\" + str(message.get('Metadata', {}).get('year')) + \")\")\n                eventItem.item_id = message.get('Metadata', {}).get('key')\n                if len(message.get('Metadata', {}).get('summary')) > 100:\n                    eventItem.overview = str(message.get('Metadata', {}).get('summary'))[:100] + \"...\"\n                else:\n                    eventItem.overview = message.get('Metadata', {}).get('summary')\n        if message.get('Player'):\n            eventItem.ip = message.get('Player').get('publicAddress')\n            eventItem.client = message.get('Player').get('title')\n            # 这里给个空,防止拼消息的时候出现None\n            eventItem.device_name = ' '\n        if message.get('Account'):\n            eventItem.user_name = message.get(\"Account\").get('title')\n\n        # 获取消息图片\n        if eventItem.item_id:\n            # 根据返回的item_id去调用媒体服务器获取\n            eventItem.image_url = self.get_remote_image_by_id(item_id=eventItem.item_id,\n                                                              image_type=\"Backdrop\")\n\n        eventItem.json_object = message\n\n        return eventItem\n\n    def get_plex(self):\n        \"\"\"\n        获取plex对象，以便直接操作\n        \"\"\"\n        return self._plex\n\n    def get_play_url(self, item_id: str) -> str:\n        \"\"\"\n        拼装媒体播放链接\n        :param item_id: 媒体的的ID\n        \"\"\"\n        return f'{self._playhost or self._host}web/index.html#!/server/{self._plex.machineIdentifier}/details?key={item_id}&X-Plex-Token={self._token}'\n\n    def get_resume(self, num: Optional[int] = 12) -> Optional[List[schemas.MediaServerPlayItem]]:\n        \"\"\"\n        获取继续观看的媒体\n        \"\"\"\n        if not self._plex:\n            return []\n        # 媒体库白名单\n        allow_library = \",\".join(map(str, (lib.id for lib in self.get_librarys(hidden=True))))\n        params = {\"contentDirectoryID\": allow_library}\n        items = self._plex.fetchItems(\"/hubs/continueWatching/items\",\n                                      container_start=0,\n                                      container_size=num,\n                                      maxresults=num,\n                                      params=params)\n        ret_resume = []\n        for item in items:\n            item_type = MediaType.MOVIE.value if item.TYPE == \"movie\" else MediaType.TV.value\n            if item_type == MediaType.MOVIE.value:\n                title = item.title\n                subtitle = str(item.year) if item.year else None\n            else:\n                title = item.grandparentTitle\n                subtitle = f\"S{item.parentIndex}:E{item.index} - {item.title}\"\n            link = self.get_play_url(item.key)\n            image = item.artUrl\n            ret_resume.append(schemas.MediaServerPlayItem(\n                id=item.key,\n                title=title,\n                subtitle=subtitle,\n                type=item_type,\n                image=image,\n                link=link,\n                percent=item.viewOffset / item.duration * 100 if item.viewOffset and item.duration else 0,\n                server_type='plex'\n            ))\n        return ret_resume[:num]\n\n    def get_latest(self, num: Optional[int] = 20) -> Optional[List[schemas.MediaServerPlayItem]]:\n        \"\"\"\n        获取最近添加媒体\n        \"\"\"\n        if not self._plex:\n            return None\n        # 请求参数（除黑名单）\n        allow_library = \",\".join(map(str, (lib.id for lib in self.get_librarys(hidden=True))))\n        params = {\n            \"contentDirectoryID\": allow_library,\n            \"count\": num,\n            \"excludeContinueWatching\": 1\n        }\n        ret_resume = []\n        sub_result = []\n        offset = 0\n        while True:\n            if len(ret_resume) >= num:\n                break\n            # 获取所有资料库\n            hubs = self._plex.fetchItems(\n                '/hubs/promoted',\n                container_start=offset,\n                container_size=num,\n                maxresults=num,\n                params=params\n            )\n            if len(hubs) == 0:\n                break\n\n            # 合并排序\n            for hub in hubs:\n                for item in hub.items():\n                    sub_result.append(item)\n            sub_result.sort(key=lambda x: x.addedAt, reverse=True)\n\n            for item in sub_result:\n                if len(ret_resume) >= num:\n                    break\n                item_type, title, image = \"\", \"\", \"\"\n                if item.TYPE == \"movie\":\n                    item_type = MediaType.MOVIE.value\n                    title = item.title\n                    image = item.posterUrl\n                elif item.TYPE == \"season\":\n                    item_type = MediaType.TV.value\n                    title = \"%s 第%s季\" % (item.parentTitle, item.index)\n                    image = item.posterUrl\n                elif item.TYPE == \"episode\":\n                    item_type = MediaType.TV.value\n                    title = \"%s 第%s季 第%s集\" % (item.grandparentTitle, item.parentIndex, item.index)\n                    thumb = (item.parentThumb or item.grandparentThumb or '').lstrip('/')\n                    image = (self._host + thumb + f\"?X-Plex-Token={self._token}\")\n                elif item.TYPE == \"show\":\n                    item_type = MediaType.TV.value\n                    title = \"%s 共%s季\" % (item.title, item.seasonCount)\n                    image = item.posterUrl\n                link = self.get_play_url(item.key)\n                ret_resume.append(schemas.MediaServerPlayItem(\n                    id=item.key,\n                    title=title,\n                    subtitle=str(item.year) if item.year else None,\n                    type=item_type,\n                    image=image,\n                    link=link,\n                    server_type='plex'\n                ))\n            offset += num\n        return ret_resume[:num]\n\n    def get_data(self, endpoint: str, **kwargs) -> Optional[Response]:\n        \"\"\"\n        自定义从媒体服务器获取数据\n        :param endpoint: 端点\n        :param kwargs: 其他请求参数，如headers, cookies, proxies等\n        \"\"\"\n        return self.__request(method=\"get\", endpoint=endpoint, **kwargs)\n\n    def post_data(self, endpoint: str, **kwargs) -> Optional[Response]:\n        \"\"\"\n        自定义从媒体服务器获取数据\n        :param endpoint: 端点\n        :param kwargs: 其他请求参数，如headers, cookies, proxies等\n        \"\"\"\n        return self.__request(method=\"post\", endpoint=endpoint, **kwargs)\n\n    def put_data(self, endpoint: str, **kwargs) -> Optional[Response]:\n        \"\"\"\n        自定义从媒体服务器获取数据\n        :param endpoint: 端点\n        :param kwargs: 其他请求参数，如headers, cookies, proxies等\n        \"\"\"\n        return self.__request(method=\"put\", endpoint=endpoint, **kwargs)\n\n    def __request(self, method: str, endpoint: str, **kwargs) -> Optional[Response]:\n        \"\"\"\n        自定义从媒体服务器获取数据\n        :param method: HTTP方法，如 get, post, put 等\n        :param endpoint: 端点\n        :param kwargs: 其他请求参数，如headers, cookies, proxies等\n        \"\"\"\n        if not self._session:\n            return None\n        try:\n            url = UrlUtils.adapt_request_url(host=self._host, endpoint=endpoint)\n            kwargs.setdefault(\"headers\", self.__get_request_headers())\n            kwargs.setdefault(\"raise_exception\", True)\n            request_method = getattr(RequestUtils(session=self._session), f\"{method}_res\", None)\n            if request_method:\n                return request_method(url=url, **kwargs)\n            else:\n                logger.error(f\"方法 {method} 不存在\")\n                return None\n        except Exception as e:\n            logger.error(f\"连接Plex出错：\" + str(e))\n            return None\n\n    def __get_request_headers(self) -> dict:\n        \"\"\"获取请求头\"\"\"\n        return {\n            \"X-Plex-Token\": self._token,\n            \"Accept\": \"application/json\",\n            \"Content-Type\": \"application/json\"\n        }\n\n    def __adapt_plex_session(self) -> Session:\n        \"\"\"\n        创建并配置一个针对Plex服务的requests.Session实例\n        这个会话包括特定的头部信息，用于处理所有的Plex请求\n        \"\"\"\n        # 设置请求头部，通常包括验证令牌和接受/内容类型头部\n        headers = self.__get_request_headers()\n        session = Session()\n        session.headers = headers\n        return session\n\n    def close(self):\n        if self._session:\n            self._session.close()\n"
  },
  {
    "path": "app/modules/postgresql/__init__.py",
    "content": "from typing import Tuple, Union\n\nfrom app.core.config import settings\nfrom app.db import SessionFactory\nfrom app.modules import _ModuleBase\nfrom app.schemas.types import ModuleType, OtherModulesType\nfrom sqlalchemy import text\n\n\nclass PostgreSQLModule(_ModuleBase):\n    \"\"\"\n    PostgreSQL 数据库模块\n    \"\"\"\n\n    def init_module(self) -> None:\n        pass\n\n    @staticmethod\n    def get_name() -> str:\n        return \"PostgreSQL\"\n\n    @staticmethod\n    def get_type() -> ModuleType:\n        \"\"\"\n        获取模块类型\n        \"\"\"\n        return ModuleType.Other\n\n    @staticmethod\n    def get_subtype() -> OtherModulesType:\n        \"\"\"\n        获取模块子类型\n        \"\"\"\n        return OtherModulesType.PostgreSQL\n\n    @staticmethod\n    def get_priority() -> int:\n        \"\"\"\n        获取模块优先级，数字越小优先级越高，只有同一接口下优先级才生效\n        \"\"\"\n        return 0\n\n    def init_setting(self) -> Tuple[str, Union[str, bool]]:\n        pass\n\n    def stop(self) -> None:\n        pass\n\n    def test(self):\n        \"\"\"\n        测试模块连接性\n        \"\"\"\n        if settings.DB_TYPE != \"postgresql\":\n            return None\n        # 测试数据库连接\n        db = SessionFactory()\n        try:\n            db.execute(text(\"SELECT 1\"))\n        except Exception as e:\n            return False, f\"PostgreSQL连接失败：{e}\"\n        finally:\n            db.close()\n        return True, \"\"\n"
  },
  {
    "path": "app/modules/qbittorrent/__init__.py",
    "content": "from pathlib import Path\nfrom typing import Set, Tuple, Optional, Union, List, Dict\n\nfrom qbittorrentapi import TorrentFilesList\nfrom torrentool.torrent import Torrent\n\nfrom app import schemas\nfrom app.core.cache import FileCache\nfrom app.core.config import settings\nfrom app.core.metainfo import MetaInfo\nfrom app.log import logger\nfrom app.modules import _ModuleBase, _DownloaderBase\nfrom app.modules.qbittorrent.qbittorrent import Qbittorrent\nfrom app.schemas import TransferTorrent, DownloadingTorrent\nfrom app.schemas.types import TorrentStatus, ModuleType, DownloaderType\nfrom app.utils.string import StringUtils\n\n\nclass QbittorrentModule(_ModuleBase, _DownloaderBase[Qbittorrent]):\n\n    def init_module(self) -> None:\n        \"\"\"\n        初始化模块\n        \"\"\"\n        super().init_service(service_name=Qbittorrent.__name__.lower(),\n                             service_type=Qbittorrent)\n\n    @staticmethod\n    def get_name() -> str:\n        return \"Qbittorrent\"\n\n    @staticmethod\n    def get_type() -> ModuleType:\n        \"\"\"\n        获取模块类型\n        \"\"\"\n        return ModuleType.Downloader\n\n    @staticmethod\n    def get_subtype() -> DownloaderType:\n        \"\"\"\n        获取模块子类型\n        \"\"\"\n        return DownloaderType.Qbittorrent\n\n    @staticmethod\n    def get_priority() -> int:\n        \"\"\"\n        获取模块优先级，数字越小优先级越高，只有同一接口下优先级才生效\n        \"\"\"\n        return 1\n\n    def stop(self):\n        pass\n\n    def test(self) -> Optional[Tuple[bool, str]]:\n        \"\"\"\n        测试模块连接性\n        \"\"\"\n        if not self.get_instances():\n            return None\n        for name, server in self.get_instances().items():\n            if server.is_inactive():\n                server.reconnect()\n            if not server.transfer_info():\n                return False, f\"无法连接Qbittorrent下载器：{name}\"\n        return True, \"\"\n\n    def init_setting(self) -> Tuple[str, Union[str, bool]]:\n        pass\n\n    def scheduler_job(self) -> None:\n        \"\"\"\n        定时任务，每10分钟调用一次\n        \"\"\"\n        for name, server in self.get_instances().items():\n            if server.is_inactive():\n                logger.info(f\"Qbittorrent下载器 {name} 连接断开，尝试重连 ...\")\n                server.reconnect()\n\n    def download(self, content: Union[Path, str, bytes], download_dir: Path, cookie: str,\n                 episodes: Set[int] = None, category: Optional[str] = None, label: Optional[str] = None,\n                 downloader: Optional[str] = None) -> Optional[Tuple[Optional[str], Optional[str], Optional[str], str]]:\n        \"\"\"\n        根据种子文件，选择并添加下载任务\n        :param content:  种子文件地址或者磁力链接或者种子内容\n        :param download_dir:  下载目录\n        :param cookie:  cookie\n        :param episodes:  需要下载的集数\n        :param category:  分类\n        :param label:  标签\n        :param downloader:  下载器\n        :return: 下载器名称、种子Hash、种子文件布局、错误原因\n        \"\"\"\n\n        def __get_torrent_info() -> Tuple[Optional[Torrent], Optional[bytes]]:\n            \"\"\"\n            获取种子名称\n            \"\"\"\n            torrent_info, torrent_content = None, None\n            try:\n                if isinstance(content, Path):\n                    if content.exists():\n                        torrent_content = content.read_bytes()\n                    else:\n                        # 读取缓存的种子文件\n                        torrent_content = FileCache().get(content.as_posix(), region=\"torrents\")\n                else:\n                    torrent_content = content\n\n                if torrent_content:\n                    # 检查是否为磁力链接\n                    if StringUtils.is_magnet_link(torrent_content):\n                        return None, torrent_content\n                    else:\n                        torrent_info = Torrent.from_string(torrent_content)\n\n                return torrent_info, torrent_content\n            except Exception as e:\n                logger.error(f\"获取种子名称失败：{e}\")\n                return None, None\n\n        if not content:\n            return None, None, None, \"下载内容为空\"\n\n        # 读取种子的名称\n        torrent_from_file, content = __get_torrent_info()\n        # 检查是否为磁力链接\n        is_magnet = isinstance(content, str) and content.startswith(\"magnet:\") or isinstance(content,\n                                                                                             bytes) and content.startswith(\n            b\"magnet:\")\n        if not torrent_from_file and not is_magnet:\n            return None, None, None, f\"添加种子任务失败：无法读取种子文件\"\n\n        # 获取下载器\n        server: Qbittorrent = self.get_instance(downloader)\n        if not server:\n            return None\n\n        # 生成随机Tag\n        tag = StringUtils.generate_random_str(10)\n        if label:\n            tags = label.split(',') + [tag]\n        elif settings.TORRENT_TAG:\n            tags = [tag, settings.TORRENT_TAG]\n        else:\n            tags = [tag]\n        # 如果要选择文件则先暂停\n        is_paused = True if episodes else False\n        # 添加任务\n        state = server.add_torrent(\n            content=content,\n            download_dir=self.normalize_path(download_dir, downloader),\n            is_paused=is_paused,\n            tag=tags,\n            cookie=cookie,\n            category=category,\n            ignore_category_check=False\n        )\n\n        # 获取种子内容布局: `Original: 原始, Subfolder: 创建子文件夹, NoSubfolder: 不创建子文件夹`\n        torrent_layout = server.get_content_layout()\n\n        if not state:\n            # 查询所有下载器的种子\n            torrents, error = server.get_torrents()\n            if error:\n                return None, None, None, \"无法连接qbittorrent下载器\"\n            if torrents:\n                try:\n                    for torrent in torrents:\n                        # 名称与大小相等则认为是同一个种子\n                        if torrent.get(\"name\") == getattr(torrent_from_file, 'name', '') \\\n                                and torrent.get(\"total_size\") == getattr(torrent_from_file, 'total_size', 0):\n                            torrent_hash = torrent.get(\"hash\")\n                            torrent_tags = [str(tag).strip() for tag in torrent.get(\"tags\").split(',')]\n                            logger.warn(f\"下载器中已存在该种子任务：{torrent_hash} - {torrent.get('name')}\")\n                            # 给种子打上标签\n                            if \"已整理\" in torrent_tags:\n                                server.remove_torrents_tag(ids=torrent_hash, tag=['已整理'])\n                            if settings.TORRENT_TAG and settings.TORRENT_TAG not in torrent_tags:\n                                logger.info(f\"给种子 {torrent_hash} 打上标签：{settings.TORRENT_TAG}\")\n                                server.set_torrents_tag(ids=torrent_hash, tags=[settings.TORRENT_TAG])\n                            return downloader or self.get_default_config_name(), torrent_hash, torrent_layout, f\"下载任务已存在\"\n                finally:\n                    torrents.clear()\n                    del torrents\n            return None, None, None, f\"添加种子任务失败：{content}\"\n        else:\n            # 获取种子Hash\n            torrent_hash = server.get_torrent_id_by_tag(tags=tag)\n            if not torrent_hash:\n                return None, None, None, f\"下载任务添加成功，但获取Qbittorrent任务信息失败：{content}\"\n            else:\n                if is_paused:\n                    # 种子文件\n                    torrent_files = server.get_files(torrent_hash)\n                    if not torrent_files:\n                        return downloader or self.get_default_config_name(), torrent_hash, torrent_layout, \"获取种子文件失败，下载任务可能在暂停状态\"\n\n                    # 不需要的文件ID\n                    file_ids = []\n                    # 需要的集清单\n                    sucess_epidised = []\n                    try:\n                        for torrent_file in torrent_files:\n                            file_id = torrent_file.get(\"id\")\n                            file_name = torrent_file.get(\"name\")\n                            meta_info = MetaInfo(file_name)\n                            if not meta_info.episode_list \\\n                                    or not set(meta_info.episode_list).issubset(episodes):\n                                file_ids.append(file_id)\n                            else:\n                                sucess_epidised = list(set(sucess_epidised).union(set(meta_info.episode_list)))\n                    finally:\n                        torrent_files.clear()\n                        del torrent_files\n                    if sucess_epidised and file_ids:\n                        # 选择文件\n                        server.set_files(torrent_hash=torrent_hash, file_ids=file_ids, priority=0)\n                    # 开始任务\n                    if server.is_force_resume():\n                        # 强制继续\n                        server.torrents_set_force_start(torrent_hash)\n                    else:\n                        server.start_torrents(torrent_hash)\n                    return downloader or self.get_default_config_name(), torrent_hash, torrent_layout, f\"添加下载成功，已选择集数：{sucess_epidised}\"\n                else:\n                    if server.is_force_resume():\n                        server.torrents_set_force_start(torrent_hash)\n                    return downloader or self.get_default_config_name(), torrent_hash, torrent_layout, \"添加下载成功\"\n\n    def list_torrents(self, status: TorrentStatus = None,\n                      hashs: Union[list, str] = None,\n                      downloader: Optional[str] = None\n                      ) -> Optional[List[Union[TransferTorrent, DownloadingTorrent]]]:\n        \"\"\"\n        获取下载器种子列表\n        :param status:  种子状态\n        :param hashs:  种子Hash\n        :param downloader:  下载器\n        :return: 下载器中符合状态的种子列表\n        \"\"\"\n        # 获取下载器\n        if downloader:\n            server: Qbittorrent = self.get_instance(downloader)\n            if not server:\n                return None\n            servers = {downloader: server}\n        else:\n            servers: Dict[str, Qbittorrent] = self.get_instances()\n        ret_torrents = []\n        if hashs:\n            # 按Hash获取\n            for name, server in servers.items():\n                torrents, _ = server.get_torrents(ids=hashs, tags=settings.TORRENT_TAG) or []\n                try:\n                    for torrent in torrents:\n                        content_path = torrent.get(\"content_path\")\n                        if content_path:\n                            torrent_path = Path(content_path)\n                        else:\n                            torrent_path = Path(torrent.get('save_path')) / torrent.get('name')\n                        ret_torrents.append(TransferTorrent(\n                            downloader=name,\n                            title=torrent.get('name'),\n                            path=torrent_path,\n                            hash=torrent.get('hash'),\n                            size=torrent.get('total_size'),\n                            tags=torrent.get('tags'),\n                            progress=torrent.get('progress') * 100,\n                            state=\"paused\" if torrent.get('state') in (\"paused\", \"pausedDL\") else \"downloading\",\n                        ))\n                finally:\n                    torrents.clear()\n                    del torrents\n        elif status == TorrentStatus.TRANSFER:\n            # 获取已完成且未整理的\n            for name, server in servers.items():\n                torrents = server.get_completed_torrents(tags=settings.TORRENT_TAG) or []\n                try:\n                    for torrent in torrents:\n                        tags = torrent.get(\"tags\") or []\n                        if \"已整理\" in tags:\n                            continue\n                        # 内容路径\n                        content_path = torrent.get(\"content_path\")\n                        if content_path:\n                            torrent_path = Path(content_path)\n                        else:\n                            torrent_path = torrent.get('save_path') / torrent.get('name')\n                        ret_torrents.append(TransferTorrent(\n                            downloader=name,\n                            title=torrent.get('name'),\n                            path=torrent_path,\n                            hash=torrent.get('hash'),\n                            tags=torrent.get('tags')\n                        ))\n                finally:\n                    torrents.clear()\n                    del torrents\n        elif status == TorrentStatus.DOWNLOADING:\n            # 获取正在下载的任务\n            for name, server in servers.items():\n                torrents = server.get_downloading_torrents(tags=settings.TORRENT_TAG) or []\n                try:\n                    for torrent in torrents:\n                        meta = MetaInfo(torrent.get('name'))\n                        ret_torrents.append(DownloadingTorrent(\n                            downloader=name,\n                            hash=torrent.get('hash'),\n                            title=torrent.get('name'),\n                            name=meta.name,\n                            year=meta.year,\n                            season_episode=meta.season_episode,\n                            progress=torrent.get('progress') * 100,\n                            size=torrent.get('total_size'),\n                            state=\"paused\" if torrent.get('state') in (\"paused\", \"pausedDL\") else \"downloading\",\n                            dlspeed=StringUtils.str_filesize(torrent.get('dlspeed')),\n                            upspeed=StringUtils.str_filesize(torrent.get('upspeed')),\n                            left_time=StringUtils.str_secends(\n                                (torrent.get('total_size') - torrent.get('completed')) / torrent.get(\n                                    'dlspeed')) if torrent.get(\n                                'dlspeed') > 0 else ''\n                        ))\n                finally:\n                    torrents.clear()\n                    del torrents\n        else:\n            return None\n        return ret_torrents  # noqa\n\n    def transfer_completed(self, hashs: str, downloader: Optional[str] = None) -> None:\n        \"\"\"\n        转移完成后的处理\n        :param hashs:  种子Hash\n        :param downloader:  下载器\n        \"\"\"\n        server: Qbittorrent = self.get_instance(downloader)\n        if not server:\n            return None\n        server.set_torrents_tag(ids=hashs, tags=['已整理'])\n        return None\n\n    def remove_torrents(self, hashs: Union[str, list], delete_file: Optional[bool] = True,\n                        downloader: Optional[str] = None) -> Optional[bool]:\n        \"\"\"\n        删除下载器种子\n        :param hashs:  种子Hash\n        :param delete_file:  是否删除文件\n        :param downloader:  下载器\n        :return: bool\n        \"\"\"\n        server: Qbittorrent = self.get_instance(downloader)\n        if not server:\n            return None\n        return server.delete_torrents(delete_file=delete_file, ids=hashs)\n\n    def start_torrents(self, hashs: Union[list, str],\n                       downloader: Optional[str] = None) -> Optional[bool]:\n        \"\"\"\n        开始下载\n        :param hashs:  种子Hash\n        :param downloader:  下载器\n        :return: bool\n        \"\"\"\n        server: Qbittorrent = self.get_instance(downloader)\n        if not server:\n            return None\n        return server.start_torrents(ids=hashs)\n\n    def stop_torrents(self, hashs: Union[list, str], downloader: Optional[str] = None) -> Optional[bool]:\n        \"\"\"\n        停止下载\n        :param hashs:  种子Hash\n        :param downloader:  下载器\n        :return: bool\n        \"\"\"\n        server: Qbittorrent = self.get_instance(downloader)\n        if not server:\n            return None\n        return server.stop_torrents(ids=hashs)\n\n    def torrent_files(self, tid: str, downloader: Optional[str] = None) -> Optional[TorrentFilesList]:\n        \"\"\"\n        获取种子文件列表\n        \"\"\"\n        server: Qbittorrent = self.get_instance(downloader)\n        if not server:\n            return None\n        return server.get_files(tid=tid)\n\n    def downloader_info(self, downloader: Optional[str] = None) -> Optional[List[schemas.DownloaderInfo]]:\n        \"\"\"\n        下载器信息\n        \"\"\"\n        if downloader:\n            server: Qbittorrent = self.get_instance(downloader)\n            if not server:\n                return None\n            servers = [server]\n        else:\n            servers = self.get_instances().values()\n        # 调用Qbittorrent API查询实时信息\n        ret_info = []\n        for server in servers:\n            info = server.transfer_info()\n            if not info:\n                continue\n            ret_info.append(schemas.DownloaderInfo(\n                download_speed=info.get(\"dl_info_speed\"),\n                upload_speed=info.get(\"up_info_speed\"),\n                download_size=info.get(\"dl_info_data\"),\n                upload_size=info.get(\"up_info_data\")\n            ))\n        return ret_info\n"
  },
  {
    "path": "app/modules/qbittorrent/qbittorrent.py",
    "content": "import time\nimport traceback\nfrom typing import Optional, Union, Tuple, List\n\nimport qbittorrentapi\nfrom qbittorrentapi import TorrentDictionary, TorrentFilesList\nfrom qbittorrentapi.client import Client\nfrom qbittorrentapi.transfer import TransferInfoDictionary\n\nfrom app.log import logger\nfrom app.utils.string import StringUtils\n\n\nclass Qbittorrent:\n    \"\"\"\n    qbittorrent下载器\n    \"\"\"\n    def __init__(self, host: Optional[str] = None, port: int = None,\n                 username: Optional[str] = None, password: Optional[str] = None,\n                 category: Optional[bool] = False, sequentail: Optional[bool] = False,\n                 force_resume: Optional[bool] = False, first_last_piece=False,\n                 **kwargs):\n        \"\"\"\n        若不设置参数，则创建配置文件设置的下载器\n        \"\"\"\n        self.qbc = None\n        if host and port:\n            self._host, self._port = host, port\n        elif host:\n            self._host, self._port = StringUtils.get_domain_address(address=host, prefix=True)\n        else:\n            logger.error(\"Qbittorrent配置不完整！\")\n            return\n        self._username = username\n        self._password = password\n        self._category = category\n        self._sequentail = sequentail\n        self._force_resume = force_resume\n        self._first_last_piece = first_last_piece\n        self.qbc = self.__login_qbittorrent()\n\n    def is_inactive(self) -> bool:\n        \"\"\"\n        判断是否需要重连\n        \"\"\"\n        if not self._host or not self._port:\n            return False\n        return True if not self.qbc else False\n\n    def reconnect(self):\n        \"\"\"\n        重连\n        \"\"\"\n        self.qbc = self.__login_qbittorrent()\n\n    def __login_qbittorrent(self) -> Optional[Client]:\n        \"\"\"\n        连接qbittorrent\n        :return: qbittorrent对象\n        \"\"\"\n        if not self._host or not self._port:\n            return None\n        try:\n            # 登录\n            logger.info(f\"正在连接 qbittorrent：{self._host}:{self._port}\")\n            qbt = qbittorrentapi.Client(host=self._host,\n                                        port=self._port,\n                                        username=self._username,\n                                        password=self._password,\n                                        VERIFY_WEBUI_CERTIFICATE=False,\n                                        REQUESTS_ARGS={'timeout': (15, 60)})\n            try:\n                qbt.auth_log_in()\n            except (qbittorrentapi.LoginFailed, qbittorrentapi.Forbidden403Error) as e:\n                logger.error(f\"qbittorrent 登录失败：{str(e).strip() or '请检查用户名和密码是否正确'}\")\n                return None\n            except Exception as e:\n                stack_trace = \"\".join(traceback.format_exception(None, e, e.__traceback__))[:2000]\n                logger.error(f\"qbittorrent 登录失败：{str(e)}\\n{stack_trace}\")\n                return None\n            return qbt\n        except Exception as err:\n            logger.error(f\"qbittorrent 连接出错：{str(err)}\")\n            return None\n\n    def get_torrents(self, ids: Optional[Union[str, list]] = None,\n                     status: Optional[str] = None,\n                     tags: Optional[Union[str, list]] = None) -> Tuple[List[TorrentDictionary], bool]:\n        \"\"\"\n        获取种子列表\n        return: 种子列表, 是否发生异常\n        \"\"\"\n        if not self.qbc:\n            return [], True\n        try:\n            torrents = self.qbc.torrents_info(torrent_hashes=ids,\n                                              status_filter=status)\n            if tags:\n                results = []\n                if not isinstance(tags, list):\n                    tags = tags.split(',')\n                try:\n                    for torrent in torrents:\n                        torrent_tags = [str(tag).strip() for tag in torrent.get(\"tags\").split(',')]\n                        if set(tags).issubset(set(torrent_tags)):\n                            results.append(torrent)\n                finally:\n                    torrents.clear()\n                    del torrents\n                return results, False\n            return torrents or [], False\n        except Exception as err:\n            logger.error(f\"获取种子列表出错：{str(err)}\")\n            return [], True\n\n    def get_completed_torrents(self, ids: Union[str, list] = None,\n                               tags: Union[str, list] = None) -> Optional[List[TorrentDictionary]]:\n        \"\"\"\n        获取已完成的种子\n        return: 种子列表, 如发生异常则返回None\n        \"\"\"\n        if not self.qbc:\n            return None\n        # completed会包含移动状态 改为获取seeding状态 包含活动上传, 正在做种, 及强制做种\n        torrents, error = self.get_torrents(status=\"seeding\", ids=ids, tags=tags)\n        return None if error else torrents or []\n\n    def get_downloading_torrents(self, ids: Union[str, list] = None,\n                                 tags: Union[str, list] = None) -> Optional[List[TorrentDictionary]]:\n        \"\"\"\n        获取正在下载的种子\n        return: 种子列表, 如发生异常则返回None\n        \"\"\"\n        if not self.qbc:\n            return None\n        torrents, error = self.get_torrents(ids=ids,\n                                            status=\"downloading\",\n                                            tags=tags)\n        return None if error else torrents or []\n\n    def delete_torrents_tag(self, ids: Union[str, list], tag: Union[str, list]) -> bool:\n        \"\"\"\n        删除Tag\n        :param ids: 种子Hash列表\n        :param tag: 标签内容\n        \"\"\"\n        if not self.qbc:\n            return False\n        try:\n            self.qbc.torrents_delete_tags(torrent_hashes=ids, tags=tag)\n            return True\n        except Exception as err:\n            logger.error(f\"删除种子Tag出错：{str(err)}\")\n            return False\n\n    def remove_torrents_tag(self, ids: Union[str, list], tag: Union[str, list]) -> bool:\n        \"\"\"\n        移除种子Tag\n        :param ids: 种子Hash列表\n        :param tag: 标签内容\n        \"\"\"\n        if not self.qbc:\n            return False\n        try:\n            self.qbc.torrents_remove_tags(torrent_hashes=ids, tags=tag)\n            return True\n        except Exception as err:\n            logger.error(f\"移除种子Tag出错：{str(err)}\")\n            return False\n\n    def set_torrents_tag(self, ids: Union[str, list], tags: list):\n        \"\"\"\n        设置种子状态为已整理，以及是否强制做种\n        \"\"\"\n        if not self.qbc:\n            return\n        try:\n            # 打标签\n            self.qbc.torrents_add_tags(tags=tags, torrent_hashes=ids)\n        except Exception as err:\n            logger.error(f\"设置种子Tag出错：{str(err)}\")\n\n    def is_force_resume(self) -> bool:\n        \"\"\"\n        是否支持强制作种\n        \"\"\"\n        return self._force_resume\n\n    def torrents_set_force_start(self, ids: Union[str, list]):\n        \"\"\"\n        设置强制作种\n        \"\"\"\n        if not self.qbc:\n            return\n        if not self._force_resume:\n            return\n        try:\n            self.qbc.torrents_set_force_start(enable=True, torrent_hashes=ids)\n        except Exception as err:\n            logger.error(f\"设置强制作种出错：{str(err)}\")\n\n    def __get_last_add_torrentid_by_tag(self, tags: Union[str, list],\n                                        status: Optional[str] = None) -> Optional[str]:\n        \"\"\"\n        根据种子的下载链接获取下载中或暂停的钟子的ID\n        :return: 种子ID\n        \"\"\"\n        try:\n            torrents, _ = self.get_torrents(status=status, tags=tags)\n        except Exception as err:\n            logger.error(f\"获取种子列表出错：{str(err)}\")\n            return None\n        if torrents:\n            return torrents[0].get(\"hash\")\n        else:\n            return None\n\n    def get_torrent_id_by_tag(self, tags: Union[str, list],\n                              status: Optional[str] = None) -> Optional[str]:\n        \"\"\"\n        通过标签多次尝试获取刚添加的种子ID，并移除标签\n        \"\"\"\n        torrent_id = None\n        # QB添加下载后需要时间，重试10次每次等待3秒\n        for i in range(1, 10):\n            time.sleep(3)\n            torrent_id = self.__get_last_add_torrentid_by_tag(tags=tags,\n                                                              status=status)\n            if torrent_id is None:\n                continue\n            else:\n                self.delete_torrents_tag(torrent_id, tags)\n                break\n        return torrent_id\n\n    def add_torrent(self,\n                    content: Union[str, bytes],\n                    is_paused: Optional[bool] = False,\n                    download_dir: Optional[str] = None,\n                    tag: Union[str, list] = None,\n                    category: Optional[str] = None,\n                    cookie: Optional[str] = None,\n                    **kwargs\n                    ) -> bool:\n        \"\"\"\n        添加种子\n        :param content: 种子urls或文件内容\n        :param is_paused: 添加后暂停\n        :param tag: 标签\n        :param category: 种子分类\n        :param download_dir: 下载路径\n        :param cookie: 站点Cookie用于辅助下载种子\n        :param kwargs: 可选参数，如 ignore_category_check 以及 QB相关参数\n        :return: bool\n        \"\"\"\n        if not self.qbc or not content:\n            return False\n\n        # 下载内容\n        if isinstance(content, str):\n            urls = content\n            torrent_files = None\n        else:\n            urls = None\n            torrent_files = content\n\n        # 保存目录\n        if download_dir:\n            save_path = download_dir\n        else:\n            save_path = None\n\n        # 标签\n        if tag:\n            tags = tag\n        else:\n            tags = None\n\n        # 如果忽略分类检查，则直接使用传入的分类值，否则，仅在分类存在且启用了自动管理时才传递参数\n        ignore_category_check = kwargs.pop(\"ignore_category_check\", True)\n        if ignore_category_check:\n            is_auto = self._category\n        else:\n            if category and self._category:\n                is_auto = True\n            else:\n                is_auto = False\n                category = None\n        try:\n            # 添加下载\n            qbc_ret = self.qbc.torrents_add(urls=urls,\n                                            torrent_files=torrent_files,\n                                            save_path=save_path,\n                                            is_paused=is_paused,\n                                            tags=tags,\n                                            use_auto_torrent_management=is_auto,\n                                            is_sequential_download=self._sequentail,\n                                            is_first_last_piece_priority=self._first_last_piece,\n                                            cookie=cookie,\n                                            category=category,\n                                            **kwargs)\n            return True if qbc_ret and str(qbc_ret).find(\"Ok\") != -1 else False\n        except Exception as err:\n            logger.error(f\"添加种子出错：{str(err)}\")\n            return False\n\n    def start_torrents(self, ids: Union[str, list]) -> bool:\n        \"\"\"\n        启动种子\n        \"\"\"\n        if not self.qbc:\n            return False\n        try:\n            self.qbc.torrents_resume(torrent_hashes=ids)\n            return True\n        except Exception as err:\n            logger.error(f\"启动种子出错：{str(err)}\")\n            return False\n\n    def stop_torrents(self, ids: Union[str, list]) -> bool:\n        \"\"\"\n        暂停种子\n        \"\"\"\n        if not self.qbc:\n            return False\n        try:\n            self.qbc.torrents_pause(torrent_hashes=ids)\n            return True\n        except Exception as err:\n            logger.error(f\"暂停种子出错：{str(err)}\")\n            return False\n\n    def delete_torrents(self, delete_file: bool, ids: Union[str, list]) -> bool:\n        \"\"\"\n        删除种子\n        \"\"\"\n        if not self.qbc:\n            return False\n        if not ids:\n            return False\n        try:\n            self.qbc.torrents_delete(delete_files=delete_file, torrent_hashes=ids)\n            return True\n        except Exception as err:\n            logger.error(f\"删除种子出错：{str(err)}\")\n            return False\n\n    def get_files(self, tid: str) -> Optional[TorrentFilesList]:\n        \"\"\"\n        获取种子文件清单\n        \"\"\"\n        if not self.qbc:\n            return None\n        try:\n            return self.qbc.torrents_files(torrent_hash=tid)\n        except Exception as err:\n            logger.error(f\"获取种子文件列表出错：{str(err)}\")\n            return None\n\n    def set_files(self, **kwargs) -> bool:\n        \"\"\"\n        设置下载文件的状态，priority为0为不下载，priority为1为下载\n        \"\"\"\n        if not self.qbc:\n            return False\n        if not kwargs.get(\"torrent_hash\") or not kwargs.get(\"file_ids\"):\n            return False\n        try:\n            self.qbc.torrents_file_priority(torrent_hash=kwargs.get(\"torrent_hash\"),\n                                            file_ids=kwargs.get(\"file_ids\"),\n                                            priority=kwargs.get(\"priority\"))\n            return True\n        except Exception as err:\n            logger.error(f\"设置种子文件状态出错：{str(err)}\")\n            return False\n\n    def transfer_info(self) -> Optional[TransferInfoDictionary]:\n        \"\"\"\n        获取传输信息\n        \"\"\"\n        if not self.qbc:\n            return None\n        try:\n            return self.qbc.transfer_info()\n        except Exception as err:\n            logger.error(f\"获取传输信息出错：{str(err)}\")\n            return None\n\n    def set_speed_limit(self, download_limit: float = None, upload_limit: float = None) -> bool:\n        \"\"\"\n        设置速度限制\n        :param download_limit: 下载速度限制，单位KB/s\n        :param upload_limit: 上传速度限制，单位kB/s\n        \"\"\"\n        if not self.qbc:\n            return False\n        download_limit = download_limit * 1024\n        upload_limit = upload_limit * 1024\n        try:\n            self.qbc.transfer.upload_limit = int(upload_limit)\n            self.qbc.transfer.download_limit = int(download_limit)\n            return True\n        except Exception as err:\n            logger.error(f\"设置速度限制出错：{str(err)}\")\n            return False\n\n    def get_speed_limit(self) -> Optional[Tuple[float, float]]:\n        \"\"\"\n        获取QB速度\n        :return: 返回download_limit 和upload_limit ，默认是0\n        \"\"\"\n        if not self.qbc:\n            return None\n\n        download_limit = 0\n        upload_limit = 0\n        try:\n            download_limit = self.qbc.transfer.download_limit\n            upload_limit = self.qbc.transfer.upload_limit\n        except Exception as err:\n            logger.error(f\"获取速度限制出错：{str(err)}\")\n\n        return download_limit / 1024, upload_limit / 1024\n\n    def recheck_torrents(self, ids: Union[str, list]) -> bool:\n        \"\"\"\n        重新校验种子\n        \"\"\"\n        if not self.qbc:\n            return False\n        try:\n            self.qbc.torrents_recheck(torrent_hashes=ids)\n            return True\n        except Exception as err:\n            logger.error(f\"重新校验种子出错：{str(err)}\")\n            return False\n\n    def update_tracker(self, hash_string: str, tracker_list: list) -> bool:\n        \"\"\"\n        添加tracker\n        \"\"\"\n        if not self.qbc:\n            return False\n        try:\n            self.qbc.torrents_add_trackers(torrent_hash=hash_string, urls=tracker_list)\n            return True\n        except Exception as err:\n            logger.error(f\"修改tracker出错：{str(err)}\")\n            return False\n\n    def get_content_layout(self) -> Optional[str]:\n        \"\"\"\n        获取内容布局\n        \"\"\"\n        if not self.qbc:\n            return None\n        # 获取下载器全局设置\n        application = self.qbc.application.preferences\n        # 获取种子内容布局: `Original: 原始, Subfolder: 创建子文件夹, NoSubfolder: 不创建子文件夹`\n        return application.get(\"torrent_content_layout\", \"Original\")\n"
  },
  {
    "path": "app/modules/qqbot/__init__.py",
    "content": "\"\"\"\nQQ Bot 通知模块\n基于 QQ 开放平台，支持主动消息推送和 Gateway 接收消息\n注意：用户/群需曾与机器人交互过才能收到主动消息，且每月有配额限制\n\"\"\"\n\nimport json\nfrom typing import Optional, List, Tuple, Union, Any\n\nfrom app.core.context import MediaInfo, Context\nfrom app.log import logger\nfrom app.modules import _ModuleBase, _MessageBase\nfrom app.modules.qqbot.qqbot import QQBot\nfrom app.schemas import CommingMessage, MessageChannel, Notification\nfrom app.schemas.types import ModuleType\n\n\nclass QQBotModule(_ModuleBase, _MessageBase[QQBot]):\n    \"\"\"QQ Bot 通知模块\"\"\"\n\n    def init_module(self) -> None:\n        super().init_service(service_name=QQBot.__name__.lower(), service_type=QQBot)\n        self._channel = MessageChannel.QQ\n\n    @staticmethod\n    def get_name() -> str:\n        return \"QQ\"\n\n    @staticmethod\n    def get_type() -> ModuleType:\n        return ModuleType.Notification\n\n    @staticmethod\n    def get_subtype() -> MessageChannel:\n        return MessageChannel.QQ\n\n    @staticmethod\n    def get_priority() -> int:\n        return 10\n\n    def stop(self) -> None:\n        for client in self.get_instances().values():\n            if hasattr(client, \"stop\"):\n                client.stop()\n\n    def test(self) -> Optional[Tuple[bool, str]]:\n        if not self.get_instances():\n            return None\n        for name, client in self.get_instances().items():\n            if not client.get_state():\n                return False, f\"QQ Bot {name} 未就绪\"\n        return True, \"\"\n\n    def init_setting(self) -> Tuple[str, Union[str, bool]]:\n        pass\n\n    def message_parser(\n        self, source: str, body: Any, form: Any, args: Any\n    ) -> Optional[CommingMessage]:\n        \"\"\"\n        解析 Gateway 转发的 QQ 消息\n        body 格式: {\"type\": \"C2C_MESSAGE_CREATE\"|\"GROUP_AT_MESSAGE_CREATE\", \"content\": \"...\", \"author\": {...}, \"id\": \"...\", ...}\n        \"\"\"\n        client_config = self.get_config(source)\n        if not client_config:\n            return None\n        try:\n            if isinstance(body, bytes):\n                msg_body = json.loads(body)\n            elif isinstance(body, dict):\n                msg_body = body\n            else:\n                return None\n        except (json.JSONDecodeError, TypeError) as err:\n            logger.debug(f\"解析 QQ 消息失败: {err}\")\n            return None\n\n        msg_type = msg_body.get(\"type\")\n        content = (msg_body.get(\"content\") or \"\").strip()\n        if not content:\n            return None\n\n        if msg_type == \"C2C_MESSAGE_CREATE\":\n            author = msg_body.get(\"author\", {})\n            user_openid = author.get(\"user_openid\", \"\")\n            if not user_openid:\n                return None\n            logger.info(f\"收到 QQ 私聊消息: userid={user_openid}, text={content[:50]}...\")\n            return CommingMessage(\n                channel=MessageChannel.QQ,\n                source=client_config.name,\n                userid=user_openid,\n                username=user_openid,\n                text=content,\n            )\n        elif msg_type == \"GROUP_AT_MESSAGE_CREATE\":\n            author = msg_body.get(\"author\", {})\n            member_openid = author.get(\"member_openid\", \"\")\n            group_openid = msg_body.get(\"group_openid\", \"\")\n            # 群聊用 group:group_openid 作为 userid，便于回复时识别\n            userid = f\"group:{group_openid}\" if group_openid else member_openid\n            logger.info(f\"收到 QQ 群消息: group={group_openid}, userid={member_openid}, text={content[:50]}...\")\n            return CommingMessage(\n                channel=MessageChannel.QQ,\n                source=client_config.name,\n                userid=userid,\n                username=member_openid or group_openid,\n                text=content,\n            )\n        return None\n\n    def post_message(self, message: Notification, **kwargs) -> None:\n        for conf in self.get_configs().values():\n            if not self.check_message(message, conf.name):\n                continue\n            targets = message.targets\n            userid = message.userid\n            if not userid and targets:\n                userid = targets.get(\"qq_userid\") or targets.get(\"qq_openid\")\n                if not userid:\n                    userid = targets.get(\"qq_group_openid\") or targets.get(\"qq_group\")\n                    if userid:\n                        userid = f\"group:{userid}\"\n            # 无 userid 且无默认配置时，由 client 向曾发过消息的用户/群广播\n            client: QQBot = self.get_instance(conf.name)\n            if client:\n                client.send_msg(\n                    title=message.title,\n                    text=message.text,\n                    image=message.image,\n                    link=message.link,\n                    userid=userid,\n                    targets=targets,\n                )\n\n    def post_medias_message(self, message: Notification, medias: List[MediaInfo]) -> None:\n        for conf in self.get_configs().values():\n            if not self.check_message(message, conf.name):\n                continue\n            targets = message.targets\n            userid = message.userid\n            if not userid and targets:\n                userid = targets.get(\"qq_userid\") or targets.get(\"qq_openid\")\n                if not userid:\n                    g = targets.get(\"qq_group_openid\") or targets.get(\"qq_group\")\n                    if g:\n                        userid = f\"group:{g}\"\n            client: QQBot = self.get_instance(conf.name)\n            if client:\n                client.send_medias_msg(\n                    medias=medias,\n                    userid=userid,\n                    title=message.title,\n                    link=message.link,\n                    targets=targets,\n                )\n\n    def post_torrents_message(\n        self, message: Notification, torrents: List[Context]\n    ) -> None:\n        for conf in self.get_configs().values():\n            if not self.check_message(message, conf.name):\n                continue\n            targets = message.targets\n            userid = message.userid\n            if not userid and targets:\n                userid = targets.get(\"qq_userid\") or targets.get(\"qq_openid\")\n                if not userid:\n                    g = targets.get(\"qq_group_openid\") or targets.get(\"qq_group\")\n                    if g:\n                        userid = f\"group:{g}\"\n            client: QQBot = self.get_instance(conf.name)\n            if client:\n                client.send_torrents_msg(\n                    torrents=torrents,\n                    userid=userid,\n                    title=message.title,\n                    link=message.link,\n                    targets=targets,\n                )\n"
  },
  {
    "path": "app/modules/qqbot/api.py",
    "content": "\"\"\"\nQQ Bot API - Python 实现\n参考 QQ 开放平台官方 API: https://bot.q.qq.com/wiki/develop/api/\n\"\"\"\n\nimport time\nfrom typing import Optional, Literal\n\nfrom app.log import logger\nfrom app.utils.http import RequestUtils\n\nAPI_BASE = \"https://api.sgroup.qq.com\"\nTOKEN_URL = \"https://bots.qq.com/app/getAppAccessToken\"\n\n# Token 缓存\n_cached_token: Optional[dict] = None\n\n\ndef get_access_token(app_id: str, client_secret: str) -> str:\n    \"\"\"\n    获取 AccessToken（带缓存，提前 5 分钟刷新）\n    \"\"\"\n    global _cached_token\n    now_ms = int(time.time() * 1000)\n    if _cached_token and now_ms < _cached_token[\"expires_at\"] - 5 * 60 * 1000 and _cached_token[\"app_id\"] == app_id:\n        return _cached_token[\"token\"]\n\n    if _cached_token and _cached_token[\"app_id\"] != app_id:\n        _cached_token = None\n\n    try:\n        resp = RequestUtils(timeout=30).post_res(\n            TOKEN_URL,\n            json={\"appId\": app_id, \"clientSecret\": client_secret},  # QQ API 使用 camelCase\n            headers={\"Content-Type\": \"application/json\"},\n        )\n        if not resp or not resp.json():\n            raise ValueError(\"Failed to get access_token: empty response\")\n        data = resp.json()\n        token = data.get(\"access_token\")\n        expires_in = data.get(\"expires_in\", 7200)\n        if not token:\n            raise ValueError(f\"Failed to get access_token: {data}\")\n\n        # expires_in 可能为字符串，统一转为 int\n        expires_in = int(expires_in) if expires_in is not None else 7200\n\n        _cached_token = {\n            \"token\": token,\n            \"expires_at\": now_ms + expires_in * 1000,\n            \"app_id\": app_id,\n        }\n        logger.debug(f\"QQ API: Token cached for app_id={app_id}\")\n        return token\n    except Exception as e:\n        logger.error(f\"QQ API: get_access_token failed: {e}\")\n        raise\n\n\ndef clear_token_cache() -> None:\n    \"\"\"清除 Token 缓存\"\"\"\n    global _cached_token\n    _cached_token = None\n\n\ndef _api_request(\n    access_token: str,\n    method: str,\n    path: str,\n    body: Optional[dict] = None,\n    timeout: int = 30,\n) -> dict:\n    \"\"\"通用 API 请求\"\"\"\n    url = f\"{API_BASE}{path}\"\n    headers = {\n        \"Authorization\": f\"QQBot {access_token}\",\n        \"Content-Type\": \"application/json\",\n    }\n    try:\n        if method.upper() == \"GET\":\n            resp = RequestUtils(timeout=timeout).get_res(url, headers=headers)\n        else:\n            resp = RequestUtils(timeout=timeout).post_res(\n                url, json=body or {}, headers=headers\n            )\n        if not resp:\n            raise ValueError(\"Empty response\")\n        data = resp.json()\n        status = getattr(resp, \"status_code\", 0)\n        if status and status >= 400:\n            raise ValueError(f\"API Error [{path}]: {data.get('message', data)}\")\n        return data\n    except Exception as e:\n        logger.error(f\"QQ API: {method} {path} failed: {e}\")\n        raise\n\n\ndef send_proactive_c2c_message(\n    access_token: str,\n    openid: str,\n    content: str,\n    use_markdown: bool = False,\n) -> dict:\n    \"\"\"\n    主动发送 C2C 单聊消息（不需要 msg_id）\n    注意：每月限 4 条/用户，且用户必须曾与机器人交互过\n    :param access_token: 访问令牌\n    :param openid: 用户 openid\n    :param content: 消息内容\n    :param use_markdown: 是否使用 Markdown 格式（需机器人开通 Markdown 能力）\n    \"\"\"\n    if not content or not content.strip():\n        raise ValueError(\"主动消息内容不能为空\")\n    content = content.strip()\n    body = {\"markdown\": {\"content\": content}, \"msg_type\": 2} if use_markdown else {\"content\": content, \"msg_type\": 0}\n    return _api_request(\n        access_token, \"POST\", f\"/v2/users/{openid}/messages\", body\n    )\n\n\ndef send_proactive_group_message(\n    access_token: str,\n    group_openid: str,\n    content: str,\n    use_markdown: bool = False,\n) -> dict:\n    \"\"\"\n    主动发送群聊消息（不需要 msg_id）\n    注意：每月限 4 条/群，且群必须曾与机器人交互过\n    :param access_token: 访问令牌\n    :param group_openid: 群聊 openid\n    :param content: 消息内容\n    :param use_markdown: 是否使用 Markdown 格式（需机器人开通 Markdown 能力）\n    \"\"\"\n    if not content or not content.strip():\n        raise ValueError(\"主动消息内容不能为空\")\n    content = content.strip()\n    body = {\"markdown\": {\"content\": content}, \"msg_type\": 2} if use_markdown else {\"content\": content, \"msg_type\": 0}\n    return _api_request(\n        access_token, \"POST\", f\"/v2/groups/{group_openid}/messages\", body\n    )\n\n\ndef send_c2c_message(\n    access_token: str,\n    openid: str,\n    content: str,\n    msg_id: Optional[str] = None,\n) -> dict:\n    \"\"\"被动回复 C2C 单聊消息（1 小时内最多 4 次）\"\"\"\n    body = {\"content\": content, \"msg_type\": 0, \"msg_seq\": 1}\n    if msg_id:\n        body[\"msg_id\"] = msg_id\n    return _api_request(\n        access_token, \"POST\", f\"/v2/users/{openid}/messages\", body\n    )\n\n\ndef send_group_message(\n    access_token: str,\n    group_openid: str,\n    content: str,\n    msg_id: Optional[str] = None,\n) -> dict:\n    \"\"\"被动回复群聊消息（1 小时内最多 4 次）\"\"\"\n    body = {\"content\": content, \"msg_type\": 0, \"msg_seq\": 1}\n    if msg_id:\n        body[\"msg_id\"] = msg_id\n    return _api_request(\n        access_token, \"POST\", f\"/v2/groups/{group_openid}/messages\", body\n    )\n\n\ndef get_gateway_url(access_token: str) -> str:\n    \"\"\"\n    获取 WebSocket Gateway URL\n    \"\"\"\n    data = _api_request(access_token, \"GET\", \"/gateway\")\n    url = data.get(\"url\")\n    if not url:\n        raise ValueError(\"Gateway URL not found in response\")\n    return url\n\n\ndef send_message(\n    access_token: str,\n    target: str,\n    content: str,\n    msg_type: Literal[\"c2c\", \"group\"] = \"c2c\",\n    msg_id: Optional[str] = None,\n) -> dict:\n    \"\"\"\n    统一发送接口\n    :param access_token: 访问令牌\n    :param target: openid（c2c）或 group_openid（group）\n    :param content: 消息内容\n    :param msg_type: c2c 单聊 / group 群聊\n    :param msg_id: 可选，被动回复时传入原消息 id\n    \"\"\"\n    if msg_id:\n        if msg_type == \"c2c\":\n            return send_c2c_message(access_token, target, content, msg_id)\n        return send_group_message(access_token, target, content, msg_id)\n    if msg_type == \"c2c\":\n        return send_proactive_c2c_message(access_token, target, content)\n    return send_proactive_group_message(access_token, target, content)\n"
  },
  {
    "path": "app/modules/qqbot/gateway.py",
    "content": "\"\"\"\nQQ Bot Gateway WebSocket 客户端\n连接 QQ 开放平台 Gateway，接收 C2C 和群聊消息并转发至 MP 消息链\n\"\"\"\n\nimport json\nimport threading\nimport time\nfrom typing import Callable, Optional\n\nimport websocket\n\nfrom app.log import logger\n\n# QQ Bot intents\nINTENT_GROUP_AND_C2C = 1 << 25  # 群聊和 C2C 私聊\n\n\ndef run_gateway(\n    app_id: str,\n    app_secret: str,\n    config_name: str,\n    get_token_fn: Callable[[str, str], str],\n    get_gateway_url_fn: Callable[[str], str],\n    on_message_fn: Callable[[dict], None],\n    stop_event: threading.Event,\n) -> None:\n    \"\"\"\n    在后台线程中运行 Gateway WebSocket 连接\n    :param app_id: QQ 机器人 AppID\n    :param app_secret: QQ 机器人 AppSecret\n    :param config_name: 配置名称，用于消息来源标识\n    :param get_token_fn: 获取 access_token 的函数 (app_id, app_secret) -> token\n    :param get_gateway_url_fn: 获取 gateway URL 的函数 (token) -> url\n    :param on_message_fn: 收到消息时的回调 (payload_dict) -> None\n    :param stop_event: 停止事件，set 时退出循环\n    \"\"\"\n    last_seq: Optional[int] = None\n    heartbeat_interval_ms: Optional[int] = None\n    heartbeat_timer: Optional[threading.Timer] = None\n    ws_ref: list = []  # 用于在闭包中保持 ws 引用\n\n    def send_heartbeat():\n        nonlocal heartbeat_timer\n        if stop_event.is_set():\n            return\n        try:\n            if ws_ref and ws_ref[0]:\n                payload = {\"op\": 1, \"d\": last_seq}\n                ws_ref[0].send(json.dumps(payload))\n                logger.debug(f\"[QQ Gateway:{config_name}] Heartbeat sent, seq={last_seq}\")\n        except Exception as err:\n            logger.debug(f\"[QQ Gateway:{config_name}] Heartbeat error: {err}\")\n        if heartbeat_interval_ms and not stop_event.is_set():\n            heartbeat_timer = threading.Timer(heartbeat_interval_ms / 1000.0, send_heartbeat)\n            heartbeat_timer.daemon = True\n            heartbeat_timer.start()\n\n    def on_ws_message(_, message):\n        nonlocal last_seq, heartbeat_interval_ms, heartbeat_timer\n        try:\n            payload = json.loads(message)\n        except json.JSONDecodeError as err:\n            logger.error(f\"[QQ Gateway:{config_name}] Invalid JSON: {err}\")\n            return\n\n        op = payload.get(\"op\")\n        d = payload.get(\"d\")\n        s = payload.get(\"s\")\n        t = payload.get(\"t\")\n\n        if s is not None:\n            last_seq = s\n\n        logger.debug(f\"[QQ Gateway:{config_name}] op={op} t={t}\")\n\n        if op == 10:  # Hello\n            heartbeat_interval_ms = d.get(\"heartbeat_interval\", 30000)\n            logger.info(f\"[QQ Gateway:{config_name}] Hello received, heartbeat_interval={heartbeat_interval_ms}\")\n\n            # Identify\n            identify = {\n                \"op\": 2,\n                \"d\": {\n                    \"token\": f\"QQBot {token}\",\n                    \"intents\": INTENT_GROUP_AND_C2C,\n                    \"shard\": [0, 1],\n                },\n            }\n            ws_ref[0].send(json.dumps(identify))\n            logger.info(f\"[QQ Gateway:{config_name}] Identify sent\")\n\n            # 启动心跳\n            if heartbeat_timer:\n                heartbeat_timer.cancel()\n            heartbeat_timer = threading.Timer(heartbeat_interval_ms / 1000.0, send_heartbeat)\n            heartbeat_timer.daemon = True\n            heartbeat_timer.start()\n\n        elif op == 0:  # Dispatch\n            if t == \"READY\":\n                session_id = d.get(\"session_id\", \"\")\n                logger.info(f\"[QQ Gateway:{config_name}] 连接成功 Ready, session_id={session_id}\")\n            elif t == \"RESUMED\":\n                logger.info(f\"[QQ Gateway:{config_name}] 连接成功 Session resumed\")\n            elif t == \"C2C_MESSAGE_CREATE\":\n                author = d.get(\"author\", {})\n                user_openid = author.get(\"user_openid\", \"\")\n                content = d.get(\"content\", \"\").strip()\n                msg_id = d.get(\"id\", \"\")\n                if content:\n                    on_message_fn({\n                        \"type\": \"C2C_MESSAGE_CREATE\",\n                        \"content\": content,\n                        \"author\": {\"user_openid\": user_openid},\n                        \"id\": msg_id,\n                        \"timestamp\": d.get(\"timestamp\", \"\"),\n                    })\n            elif t == \"GROUP_AT_MESSAGE_CREATE\":\n                author = d.get(\"author\", {})\n                member_openid = author.get(\"member_openid\", \"\")\n                group_openid = d.get(\"group_openid\", \"\")\n                content = d.get(\"content\", \"\").strip()\n                msg_id = d.get(\"id\", \"\")\n                if content:\n                    on_message_fn({\n                        \"type\": \"GROUP_AT_MESSAGE_CREATE\",\n                        \"content\": content,\n                        \"author\": {\"member_openid\": member_openid},\n                        \"id\": msg_id,\n                        \"group_openid\": group_openid,\n                        \"timestamp\": d.get(\"timestamp\", \"\"),\n                    })\n            # 其他事件忽略\n\n        elif op == 7:  # Reconnect\n            logger.info(f\"[QQ Gateway:{config_name}] Reconnect requested\")\n            # 当前实现不自动重连，由外层循环处理\n\n        elif op == 9:  # Invalid Session\n            logger.warning(f\"[QQ Gateway:{config_name}] Invalid session\")\n            if ws_ref and ws_ref[0]:\n                ws_ref[0].close()\n\n    def on_ws_error(_, error):\n        logger.error(f\"[QQ Gateway:{config_name}] WebSocket error: {error}\")\n\n    def on_ws_close(_, close_status_code, close_msg):\n        logger.info(f\"[QQ Gateway:{config_name}] WebSocket closed: {close_status_code} {close_msg}\")\n        if heartbeat_timer:\n            heartbeat_timer.cancel()\n\n    reconnect_delays = [1, 2, 5, 10, 30, 60]\n    attempt = 0\n\n    while not stop_event.is_set():\n        try:\n            token = get_token_fn(app_id, app_secret)\n            gateway_url = get_gateway_url_fn(token)\n            logger.info(f\"[QQ Gateway:{config_name}] Connecting to {gateway_url[:60]}...\")\n\n            ws = websocket.WebSocketApp(\n                gateway_url,\n                on_message=on_ws_message,\n                on_error=on_ws_error,\n                on_close=on_ws_close,\n            )\n            ws_ref.clear()\n            ws_ref.append(ws)\n\n            # run_forever 会阻塞，需要传入 stop_event 的检查\n            # websocket-client 的 run_forever 支持 ping_interval, ping_timeout\n            # 我们使用自定义心跳，所以不设置 ping\n            ws.run_forever(\n                ping_interval=None,\n                ping_timeout=None,\n                skip_utf8_validation=True,\n            )\n\n        except Exception as e:\n            logger.error(f\"[QQ Gateway:{config_name}] Connection error: {e}\")\n\n        if stop_event.is_set():\n            break\n\n        delay = reconnect_delays[min(attempt, len(reconnect_delays) - 1)]\n        attempt += 1\n        logger.info(f\"[QQ Gateway:{config_name}] Reconnecting in {delay}s (attempt {attempt})\")\n        for _ in range(delay * 10):\n            if stop_event.is_set():\n                break\n            time.sleep(0.1)\n\n    if heartbeat_timer:\n        heartbeat_timer.cancel()\n    logger.info(f\"[QQ Gateway:{config_name}] Gateway thread stopped\")\n"
  },
  {
    "path": "app/modules/qqbot/qqbot.py",
    "content": "\"\"\"\nQQ Bot 通知客户端\n基于 QQ 开放平台 API，支持主动消息推送和 Gateway 接收消息\n\"\"\"\n\nimport hashlib\nimport io\nimport pickle\nimport threading\nfrom typing import Optional, List, Tuple\n\nfrom PIL import Image\n\nfrom app.chain.message import MessageChain\nfrom app.core.cache import FileCache\nfrom app.core.context import MediaInfo, Context\nfrom app.core.metainfo import MetaInfo\nfrom app.log import logger\nfrom app.modules.qqbot.api import (\n    get_access_token,\n    get_gateway_url,\n    send_proactive_c2c_message,\n    send_proactive_group_message,\n)\nfrom app.modules.qqbot.gateway import run_gateway\nfrom app.utils.http import RequestUtils\nfrom app.utils.string import StringUtils\n\n# QQ Markdown 图片默认尺寸（获取失败时使用，与 OpenClaw 对齐）\n_DEFAULT_IMAGE_SIZE: Tuple[int, int] = (512, 512)\n\n\nclass QQBot:\n    \"\"\"QQ Bot 通知客户端\"\"\"\n\n    def __init__(\n        self,\n        QQ_APP_ID: Optional[str] = None,\n        QQ_APP_SECRET: Optional[str] = None,\n        QQ_OPENID: Optional[str] = None,\n        QQ_GROUP_OPENID: Optional[str] = None,\n        name: Optional[str] = None,\n        **kwargs,\n    ):\n        \"\"\"\n        初始化 QQ Bot\n        :param QQ_APP_ID: QQ 机器人 AppID\n        :param QQ_APP_SECRET: QQ 机器人 AppSecret\n        :param QQ_OPENID: 默认接收者 openid（单聊）\n        :param QQ_GROUP_OPENID: 默认群组 openid（群聊，与 QQ_OPENID 二选一）\n        :param name: 配置名称，用于消息来源标识和 Gateway 接收\n        \"\"\"\n        if not QQ_APP_ID or not QQ_APP_SECRET:\n            logger.error(\"QQ Bot 配置不完整：缺少 AppID 或 AppSecret\")\n            self._ready = False\n            return\n\n        self._app_id = QQ_APP_ID\n        self._app_secret = QQ_APP_SECRET\n        self._default_openid = QQ_OPENID\n        self._default_group_openid = QQ_GROUP_OPENID\n        self._config_name = name or \"qqbot\"\n        self._ready = True\n\n        # 曾发过消息的用户/群，用于无默认接收者时的广播 {(target_id, is_group), ...}\n        self._known_targets: set = set()\n        _safe_name = hashlib.md5(self._config_name.encode()).hexdigest()[:12]\n        self._cache_key = f\"__qqbot_known_targets_{_safe_name}__\"\n        self._filecache = FileCache()\n        self._load_known_targets()\n        # 已处理的消息 ID，用于去重（避免同一条消息重复处理）\n        self._processed_msg_ids: set = set()\n        self._max_processed_ids = 1000\n\n        # Gateway 后台线程\n        self._gateway_stop = threading.Event()\n        self._gateway_thread = None\n        self._start_gateway()\n\n        logger.info(\"QQ Bot 客户端初始化完成\")\n\n    def _load_known_targets(self) -> None:\n        \"\"\"从缓存加载曾互动的用户/群\"\"\"\n        try:\n            content = self._filecache.get(self._cache_key)\n            if content:\n                data = pickle.loads(content)\n                if isinstance(data, (list, set)):\n                    self._known_targets = set(tuple(x) for x in data)\n        except Exception as e:\n            logger.debug(f\"QQ Bot 加载 known_targets 失败: {e}\")\n\n    def _save_known_targets(self) -> None:\n        \"\"\"持久化曾互动的用户/群到缓存\"\"\"\n        try:\n            self._filecache.set(self._cache_key, pickle.dumps(list(self._known_targets)))\n        except Exception as e:\n            logger.debug(f\"QQ Bot 保存 known_targets 失败: {e}\")\n\n    def _forward_to_message_chain(self, payload: dict) -> None:\n        \"\"\"直接调用消息链处理，避免 HTTP 开销\"\"\"\n        def _run():\n            try:\n                MessageChain().process(\n                    body=payload,\n                    form={},\n                    args={\"source\": self._config_name},\n                )\n            except Exception as e:\n                logger.error(f\"QQ Bot 转发消息失败: {e}\")\n\n        threading.Thread(target=_run, daemon=True).start()\n\n    def _on_gateway_message(self, payload: dict) -> None:\n        \"\"\"Gateway 收到消息时转发至 MP 消息链，并记录发送者用于广播\"\"\"\n        msg_id = payload.get(\"id\")\n        if msg_id:\n            if msg_id in self._processed_msg_ids:\n                logger.debug(f\"QQ Bot: 跳过重复消息 id={msg_id}\")\n                return\n            self._processed_msg_ids.add(msg_id)\n            if len(self._processed_msg_ids) > self._max_processed_ids:\n                self._processed_msg_ids.clear()\n\n        # 记录发送者，用于无默认接收者时的广播\n        msg_type = payload.get(\"type\")\n        if msg_type == \"C2C_MESSAGE_CREATE\":\n            openid = (payload.get(\"author\") or {}).get(\"user_openid\")\n            if openid:\n                self._known_targets.add((openid, False))\n                self._save_known_targets()\n        elif msg_type == \"GROUP_AT_MESSAGE_CREATE\":\n            group_openid = payload.get(\"group_openid\")\n            if group_openid:\n                self._known_targets.add((group_openid, True))\n                self._save_known_targets()\n\n        self._forward_to_message_chain(payload)\n\n    def _start_gateway(self) -> None:\n        \"\"\"启动 Gateway WebSocket 连接（后台线程）\"\"\"\n        try:\n            self._gateway_thread = threading.Thread(\n                target=run_gateway,\n                kwargs={\n                    \"app_id\": self._app_id,\n                    \"app_secret\": self._app_secret,\n                    \"config_name\": self._config_name,\n                    \"get_token_fn\": get_access_token,\n                    \"get_gateway_url_fn\": get_gateway_url,\n                    \"on_message_fn\": self._on_gateway_message,\n                    \"stop_event\": self._gateway_stop,\n                },\n                daemon=True,\n            )\n            self._gateway_thread.start()\n            logger.info(f\"QQ Bot Gateway 已启动: {self._config_name}\")\n        except Exception as e:\n            logger.error(f\"QQ Bot Gateway 启动失败: {e}\")\n\n    def stop(self) -> None:\n        \"\"\"停止 Gateway 连接\"\"\"\n        if self._gateway_stop:\n            self._gateway_stop.set()\n        if self._gateway_thread and self._gateway_thread.is_alive():\n            self._gateway_thread.join(timeout=5)\n\n    def get_state(self) -> bool:\n        \"\"\"获取就绪状态\"\"\"\n        return self._ready\n\n    def _get_target(self, userid: Optional[str] = None, targets: Optional[dict] = None) -> tuple:\n        \"\"\"\n        解析发送目标\n        :return: (target_id, is_group)\n        \"\"\"\n        # 优先使用 userid（可能是 openid）\n        if userid:\n            # 格式支持：group:xxx 表示群聊\n            if str(userid).lower().startswith(\"group:\"):\n                return userid[6:].strip(), True\n            return str(userid), False\n\n        # 从 targets 获取\n        if targets:\n            qq_openid = targets.get(\"qq_userid\") or targets.get(\"qq_openid\")\n            qq_group = targets.get(\"qq_group_openid\") or targets.get(\"qq_group\")\n            if qq_group:\n                return str(qq_group), True\n            if qq_openid:\n                return str(qq_openid), False\n\n        # 使用默认配置\n        if self._default_group_openid:\n            return self._default_group_openid, True\n        if self._default_openid:\n            return self._default_openid, False\n\n        return None, False\n\n    def _get_broadcast_targets(self) -> list:\n        \"\"\"获取广播目标列表（曾发过消息的用户/群）\"\"\"\n        return list(self._known_targets)\n\n    @staticmethod\n    def _get_image_size(url: str) -> Optional[Tuple[int, int]]:\n        \"\"\"\n        从图片 URL 获取尺寸，只下载前 64KB 解析文件头（参考 OpenClaw）\n        :return: (width, height) 或 None\n        \"\"\"\n        try:\n            resp = RequestUtils(timeout=5).get_res(\n                url,\n                headers={\"Range\": \"bytes=0-65535\", \"User-Agent\": \"QQBot-Image-Size-Detector/1.0\"},\n            )\n            if not resp or not resp.content:\n                return None\n            data = resp.content[:65536] if len(resp.content) > 65536 else resp.content\n            with Image.open(io.BytesIO(data)) as img:\n                return img.width, img.height\n        except Exception as e:\n            logger.debug(f\"QQ Bot 获取图片尺寸失败 ({url[:60]}...): {e}\")\n            return None\n\n    @staticmethod\n    def _escape_markdown(text: str) -> str:\n        \"\"\"转义 Markdown 特殊字符，避免破坏格式。不转义 ()，QQ 会误解析 \\\\( \\\\) 导致括号丢失或乱码\"\"\"\n        if not text:\n            return \"\"\n        text = text.replace(\"\\\\\", \"\\\\\\\\\")\n        for char in (\"*\", \"_\", \"[\", \"]\", \"`\"):\n            text = text.replace(char, f\"\\\\{char}\")\n        return text\n\n    @staticmethod\n    def _format_message_markdown(\n        title: Optional[str] = None,\n        text: Optional[str] = None,\n        image: Optional[str] = None,\n        link: Optional[str] = None,\n    ) -> tuple:\n        \"\"\"\n        将消息格式化为 QQ Markdown，类似 Telegram 处理方式\n        :return: (content, use_markdown)\n        \"\"\"\n        parts = []\n        if title:\n            # 标题加粗，移除可能破坏格式的换行\n            safe_title = (title or \"\").replace(\"\\n\", \" \").strip()\n            if safe_title:\n                parts.append(f\"**{QQBot._escape_markdown(safe_title)}**\")\n        if text:\n            parts.append(QQBot._escape_markdown((text or \"\").strip()))\n        if image:\n            # QQ Markdown 图片需带尺寸才能正确渲染，格式: ![#宽px #高px](url)，否则会显示为 [图片] 文本\n            # 参考 OpenClaw，先获取图片真实尺寸，失败则用默认 512x512\n            img_url = (image or \"\").strip()\n            if img_url and (img_url.startswith(\"http://\") or img_url.startswith(\"https://\")):\n                size = QQBot._get_image_size(img_url)\n                w, h = size if size else _DEFAULT_IMAGE_SIZE\n                if size:\n                    logger.debug(f\"QQ Bot 图片尺寸: {w}x{h} - {img_url[:60]}...\")\n                parts.append(f\"![#{w}px #{h}px]({img_url})\")\n            elif img_url:\n                parts.append(img_url)\n        if link:\n            link_url = (link or \"\").strip()\n            if link_url:\n                parts.append(f\"[查看详情]({link_url})\")\n        content = \"\\n\\n\".join(p for p in parts if p).strip()\n        return content, bool(content)\n\n    def send_msg(\n        self,\n        title: str,\n        text: Optional[str] = None,\n        image: Optional[str] = None,\n        link: Optional[str] = None,\n        userid: Optional[str] = None,\n        targets: Optional[dict] = None,\n        **kwargs,\n    ) -> bool:\n        \"\"\"\n        发送 QQ 消息\n        :param title: 标题\n        :param text: 正文\n        :param image: 图片 URL（QQ 主动消息暂不支持图片，可拼入文本）\n        :param link: 链接\n        :param userid: 目标 openid 或 group:xxx\n        :param targets: 目标字典\n        \"\"\"\n        if not self._ready:\n            return False\n\n        target, is_group = self._get_target(userid, targets)\n        targets_to_send = []\n        if target:\n            targets_to_send = [(target, is_group)]\n        else:\n            # 无默认接收者时，向曾发过消息的用户/群广播\n            broadcast = self._get_broadcast_targets()\n            if broadcast:\n                targets_to_send = broadcast\n                logger.debug(f\"QQ Bot: 广播模式，共 {len(targets_to_send)} 个目标\")\n            else:\n                logger.warn(\"QQ Bot: 未指定接收者且无互动用户，请在配置中设置 QQ_OPENID/QQ_GROUP_OPENID 或先让用户发消息\")\n                return False\n\n        # 使用 Markdown 格式发送（类似 Telegram）\n        content, use_markdown = self._format_message_markdown(title=title, text=text, image=image, link=link)\n        logger.info(f\"QQ Bot 发送内容 (use_markdown={use_markdown}):\\n{content}\")\n\n        if not content:\n            logger.warn(\"QQ Bot: 消息内容为空\")\n            return False\n\n        success_count = 0\n        try:\n            token = get_access_token(self._app_id, self._app_secret)\n            for tgt, tgt_is_group in targets_to_send:\n                send_fn = send_proactive_group_message if tgt_is_group else send_proactive_c2c_message\n                try:\n                    send_fn(token, tgt, content, use_markdown=use_markdown)\n                    success_count += 1\n                    logger.debug(f\"QQ Bot: 消息已发送到 {'群' if tgt_is_group else '用户'} {tgt}\")\n                except Exception as e:\n                    err_msg = str(e)\n                    if use_markdown and (\"markdown\" in err_msg.lower() or \"11244\" in err_msg or \"权限\" in err_msg):\n                        # Markdown 未开通时回退为纯文本\n                        plain_parts = []\n                        if title:\n                            plain_parts.append(f\"【{title}】\")\n                        if text:\n                            plain_parts.append(text)\n                        if image:\n                            plain_parts.append(image)\n                        if link:\n                            plain_parts.append(link)\n                        plain_content = \"\\n\".join(plain_parts).strip()\n                        if plain_content:\n                            send_fn(token, tgt, plain_content, use_markdown=False)\n                            success_count += 1\n                            logger.debug(f\"QQ Bot: Markdown 不可用，已回退纯文本发送至 {tgt}\")\n                    else:\n                        logger.error(f\"QQ Bot 发送失败 ({tgt}): {e}\")\n            return success_count > 0\n        except Exception as e:\n            logger.error(f\"QQ Bot 发送失败: {e}\")\n            return False\n\n    def send_medias_msg(\n        self,\n        medias: List[MediaInfo],\n        userid: Optional[str] = None,\n        title: Optional[str] = None,\n        link: Optional[str] = None,\n        **kwargs,\n    ) -> bool:\n        \"\"\"发送媒体列表（转为文本）\"\"\"\n        if not medias:\n            return False\n        lines = [f\"{i + 1}. {m.title_year} - {m.type.value}\" for i, m in enumerate(medias)]\n        text = \"\\n\".join(lines)\n        return self.send_msg(\n            title=title or \"媒体列表\",\n            text=text,\n            link=link,\n            userid=userid,\n            **kwargs,\n        )\n\n    def send_torrents_msg(\n        self,\n        torrents: List[Context],\n        userid: Optional[str] = None,\n        title: Optional[str] = None,\n        link: Optional[str] = None,\n        **kwargs,\n    ) -> bool:\n        \"\"\"发送种子列表（转为文本）\"\"\"\n        if not torrents:\n            return False\n        lines = []\n        for i, ctx in enumerate(torrents):\n            t = ctx.torrent_info\n            meta = MetaInfo(t.title, t.description)\n            name = f\"{meta.season_episode} {meta.resource_term} {meta.video_term}\"\n            name = \" \".join(name.split())\n            lines.append(f\"{i + 1}.【{t.site_name}】{name} {StringUtils.str_filesize(t.size)} {t.seeders}↑\")\n        text = \"\\n\".join(lines)\n        return self.send_msg(\n            title=title or \"种子列表\",\n            text=text,\n            link=link,\n            userid=userid,\n            **kwargs,\n        )\n"
  },
  {
    "path": "app/modules/redis/__init__.py",
    "content": "from typing import Tuple, Union\n\nfrom app.core.config import settings\nfrom app.helper.redis import RedisHelper\nfrom app.modules import _ModuleBase\nfrom app.schemas.types import ModuleType, OtherModulesType\n\n\nclass RedisModule(_ModuleBase):\n    \"\"\"\n    Redis 数据库模块\n    \"\"\"\n\n    def init_module(self) -> None:\n        pass\n\n    @staticmethod\n    def get_name() -> str:\n        return \"Redis缓存\"\n\n    @staticmethod\n    def get_type() -> ModuleType:\n        \"\"\"\n        获取模块类型\n        \"\"\"\n        return ModuleType.Other\n\n    @staticmethod\n    def get_subtype() -> OtherModulesType:\n        \"\"\"\n        获取模块子类型\n        \"\"\"\n        return OtherModulesType.Redis\n\n    @staticmethod\n    def get_priority() -> int:\n        \"\"\"\n        获取模块优先级，数字越小优先级越高，只有同一接口下优先级才生效\n        \"\"\"\n        return 0\n\n    def init_setting(self) -> Tuple[str, Union[str, bool]]:\n        pass\n\n    def stop(self) -> None:\n        pass\n\n    def test(self):\n        \"\"\"\n        测试模块连接性\n        \"\"\"\n        if settings.CACHE_BACKEND_TYPE != \"redis\":\n            return None\n        if RedisHelper().test():\n            return True, \"\"\n        return False, \"Redis连接失败，请检查配置\"\n"
  },
  {
    "path": "app/modules/rtorrent/__init__.py",
    "content": "from pathlib import Path\nfrom typing import Set, Tuple, Optional, Union, List, Dict\n\nfrom torrentool.torrent import Torrent\n\nfrom app import schemas\nfrom app.core.cache import FileCache\nfrom app.core.config import settings\nfrom app.core.metainfo import MetaInfo\nfrom app.log import logger\nfrom app.modules import _ModuleBase, _DownloaderBase\nfrom app.modules.rtorrent.rtorrent import Rtorrent\nfrom app.schemas import TransferTorrent, DownloadingTorrent\nfrom app.schemas.types import TorrentStatus, ModuleType, DownloaderType\nfrom app.utils.string import StringUtils\n\n\nclass RtorrentModule(_ModuleBase, _DownloaderBase[Rtorrent]):\n    def init_module(self) -> None:\n        \"\"\"\n        初始化模块\n        \"\"\"\n        super().init_service(\n            service_name=Rtorrent.__name__.lower(), service_type=Rtorrent\n        )\n\n    @staticmethod\n    def get_name() -> str:\n        return \"Rtorrent\"\n\n    @staticmethod\n    def get_type() -> ModuleType:\n        \"\"\"\n        获取模块类型\n        \"\"\"\n        return ModuleType.Downloader\n\n    @staticmethod\n    def get_subtype() -> DownloaderType:\n        \"\"\"\n        获取模块子类型\n        \"\"\"\n        return DownloaderType.Rtorrent\n\n    @staticmethod\n    def get_priority() -> int:\n        \"\"\"\n        获取模块优先级，数字越小优先级越高，只有同一接口下优先级才生效\n        \"\"\"\n        return 3\n\n    def stop(self):\n        pass\n\n    def test(self) -> Optional[Tuple[bool, str]]:\n        \"\"\"\n        测试模块连接性\n        \"\"\"\n        if not self.get_instances():\n            return None\n        for name, server in self.get_instances().items():\n            if server.is_inactive():\n                server.reconnect()\n            if not server.transfer_info():\n                return False, f\"无法连接rTorrent下载器：{name}\"\n        return True, \"\"\n\n    def init_setting(self) -> Tuple[str, Union[str, bool]]:\n        pass\n\n    def scheduler_job(self) -> None:\n        \"\"\"\n        定时任务，每10分钟调用一次\n        \"\"\"\n        for name, server in self.get_instances().items():\n            if server.is_inactive():\n                logger.info(f\"rTorrent下载器 {name} 连接断开，尝试重连 ...\")\n                server.reconnect()\n\n    def download(\n        self,\n        content: Union[Path, str, bytes],\n        download_dir: Path,\n        cookie: str,\n        episodes: Set[int] = None,\n        category: Optional[str] = None,\n        label: Optional[str] = None,\n        downloader: Optional[str] = None,\n    ) -> Optional[Tuple[Optional[str], Optional[str], Optional[str], str]]:\n        \"\"\"\n        根据种子文件，选择并添加下载任务\n        :param content:  种子文件地址或者磁力链接或种子内容\n        :param download_dir:  下载目录\n        :param cookie:  cookie\n        :param episodes:  需要下载的集数\n        :param category:  分类，rTorrent中未使用\n        :param label:  标签\n        :param downloader:  下载器\n        :return: 下载器名称、种子Hash、种子文件布局、错误原因\n        \"\"\"\n\n        def __get_torrent_info() -> Tuple[Optional[Torrent], Optional[bytes]]:\n            \"\"\"\n            获取种子名称\n            \"\"\"\n            torrent_info, torrent_content = None, None\n            try:\n                if isinstance(content, Path):\n                    if content.exists():\n                        torrent_content = content.read_bytes()\n                    else:\n                        torrent_content = FileCache().get(\n                            content.as_posix(), region=\"torrents\"\n                        )\n                else:\n                    torrent_content = content\n\n                if torrent_content:\n                    if StringUtils.is_magnet_link(torrent_content):\n                        return None, torrent_content\n                    else:\n                        torrent_info = Torrent.from_string(torrent_content)\n\n                return torrent_info, torrent_content\n            except Exception as e:\n                logger.error(f\"获取种子名称失败：{e}\")\n                return None, None\n\n        if not content:\n            return None, None, None, \"下载内容为空\"\n\n        # 读取种子的名称\n        torrent_from_file, content = __get_torrent_info()\n        # 检查是否为磁力链接\n        is_magnet = (\n            isinstance(content, str)\n            and content.startswith(\"magnet:\")\n            or isinstance(content, bytes)\n            and content.startswith(b\"magnet:\")\n        )\n        if not torrent_from_file and not is_magnet:\n            return None, None, None, f\"添加种子任务失败：无法读取种子文件\"\n\n        # 获取下载器\n        server: Rtorrent = self.get_instance(downloader)\n        if not server:\n            return None\n\n        # 生成随机Tag\n        tag = StringUtils.generate_random_str(10)\n        if label:\n            tags = label.split(\",\") + [tag]\n        elif settings.TORRENT_TAG:\n            tags = [tag, settings.TORRENT_TAG]\n        else:\n            tags = [tag]\n        # 如果要选择文件则先暂停\n        is_paused = True if episodes else False\n        # 添加任务\n        state = server.add_torrent(\n            content=content,\n            download_dir=self.normalize_path(download_dir, downloader),\n            is_paused=is_paused,\n            tags=tags,\n            cookie=cookie,\n        )\n\n        # rTorrent 始终使用原始种子布局\n        torrent_layout = \"Original\"\n\n        if not state:\n            # 查询所有下载器的种子\n            torrents, error = server.get_torrents()\n            if error:\n                return None, None, None, \"无法连接rTorrent下载器\"\n            if torrents:\n                try:\n                    for torrent in torrents:\n                        # 名称与大小相等则认为是同一个种子\n                        if torrent.get(\"name\") == getattr(\n                            torrent_from_file, \"name\", \"\"\n                        ) and torrent.get(\"total_size\") == getattr(\n                            torrent_from_file, \"total_size\", 0\n                        ):\n                            torrent_hash = torrent.get(\"hash\")\n                            torrent_tags = [\n                                str(t).strip()\n                                for t in torrent.get(\"tags\", \"\").split(\",\")\n                                if t.strip()\n                            ]\n                            logger.warn(\n                                f\"下载器中已存在该种子任务：{torrent_hash} - {torrent.get('name')}\"\n                            )\n                            # 给种子打上标签\n                            if \"已整理\" in torrent_tags:\n                                server.remove_torrents_tag(\n                                    ids=torrent_hash, tag=[\"已整理\"]\n                                )\n                            if (\n                                settings.TORRENT_TAG\n                                and settings.TORRENT_TAG not in torrent_tags\n                            ):\n                                logger.info(\n                                    f\"给种子 {torrent_hash} 打上标签：{settings.TORRENT_TAG}\"\n                                )\n                                server.set_torrents_tag(\n                                    ids=torrent_hash, tags=[settings.TORRENT_TAG]\n                                )\n                            return (\n                                downloader or self.get_default_config_name(),\n                                torrent_hash,\n                                torrent_layout,\n                                f\"下载任务已存在\",\n                            )\n                finally:\n                    torrents.clear()\n                    del torrents\n            return None, None, None, f\"添加种子任务失败：{content}\"\n        else:\n            # 获取种子Hash\n            torrent_hash = server.get_torrent_id_by_tag(tags=tag)\n            if not torrent_hash:\n                return (\n                    None,\n                    None,\n                    None,\n                    f\"下载任务添加成功，但获取rTorrent任务信息失败：{content}\",\n                )\n            else:\n                if is_paused:\n                    # 种子文件\n                    torrent_files = server.get_files(torrent_hash)\n                    if not torrent_files:\n                        return (\n                            downloader or self.get_default_config_name(),\n                            torrent_hash,\n                            torrent_layout,\n                            \"获取种子文件失败，下载任务可能在暂停状态\",\n                        )\n\n                    # 不需要的文件ID\n                    file_ids = []\n                    # 需要的集清单\n                    sucess_epidised = set()\n                    try:\n                        for torrent_file in torrent_files:\n                            file_id = torrent_file.get(\"id\")\n                            file_name = torrent_file.get(\"name\")\n                            meta_info = MetaInfo(file_name)\n                            if not meta_info.episode_list or not set(\n                                meta_info.episode_list\n                            ).issubset(episodes):\n                                file_ids.append(file_id)\n                            else:\n                                sucess_epidised.update(meta_info.episode_list)\n                    finally:\n                        torrent_files.clear()\n                        del torrent_files\n                    sucess_epidised = list(sucess_epidised)\n                    if sucess_epidised and file_ids:\n                        # 设置不需要的文件优先级为0（不下载）\n                        server.set_files(\n                            torrent_hash=torrent_hash, file_ids=file_ids, priority=0\n                        )\n                    # 开始任务\n                    server.start_torrents(torrent_hash)\n                    return (\n                        downloader or self.get_default_config_name(),\n                        torrent_hash,\n                        torrent_layout,\n                        f\"添加下载成功，已选择集数：{sucess_epidised}\",\n                    )\n                else:\n                    return (\n                        downloader or self.get_default_config_name(),\n                        torrent_hash,\n                        torrent_layout,\n                        \"添加下载成功\",\n                    )\n\n    def list_torrents(\n        self,\n        status: TorrentStatus = None,\n        hashs: Union[list, str] = None,\n        downloader: Optional[str] = None,\n    ) -> Optional[List[Union[TransferTorrent, DownloadingTorrent]]]:\n        \"\"\"\n        获取下载器种子列表\n        :param status:  种子状态\n        :param hashs:  种子Hash\n        :param downloader:  下载器\n        :return: 下载器中符合状态的种子列表\n        \"\"\"\n        # 获取下载器\n        if downloader:\n            server: Rtorrent = self.get_instance(downloader)\n            if not server:\n                return None\n            servers = {downloader: server}\n        else:\n            servers: Dict[str, Rtorrent] = self.get_instances()\n        ret_torrents = []\n        if hashs:\n            # 按Hash获取\n            for name, server in servers.items():\n                torrents, _ = (\n                    server.get_torrents(ids=hashs, tags=settings.TORRENT_TAG) or []\n                )\n                try:\n                    for torrent in torrents:\n                        content_path = torrent.get(\"content_path\")\n                        if content_path:\n                            torrent_path = Path(content_path)\n                        else:\n                            torrent_path = Path(torrent.get(\"save_path\")) / torrent.get(\n                                \"name\"\n                            )\n                        ret_torrents.append(\n                            TransferTorrent(\n                                downloader=name,\n                                title=torrent.get(\"name\"),\n                                path=torrent_path,\n                                hash=torrent.get(\"hash\"),\n                                size=torrent.get(\"total_size\"),\n                                tags=torrent.get(\"tags\"),\n                                progress=torrent.get(\"progress\", 0),\n                                state=\"paused\"\n                                if torrent.get(\"state\") == 0\n                                else \"downloading\",\n                            )\n                        )\n                finally:\n                    torrents.clear()\n                    del torrents\n        elif status == TorrentStatus.TRANSFER:\n            # 获取已完成且未整理的\n            for name, server in servers.items():\n                torrents = (\n                    server.get_completed_torrents(tags=settings.TORRENT_TAG) or []\n                )\n                try:\n                    for torrent in torrents:\n                        tags = torrent.get(\"tags\") or \"\"\n                        tag_list = [t.strip() for t in tags.split(\",\") if t.strip()]\n                        if \"已整理\" in tag_list:\n                            continue\n                        content_path = torrent.get(\"content_path\")\n                        if content_path:\n                            torrent_path = Path(content_path)\n                        else:\n                            torrent_path = Path(torrent.get(\"save_path\")) / torrent.get(\n                                \"name\"\n                            )\n                        ret_torrents.append(\n                            TransferTorrent(\n                                downloader=name,\n                                title=torrent.get(\"name\"),\n                                path=torrent_path,\n                                hash=torrent.get(\"hash\"),\n                                tags=torrent.get(\"tags\"),\n                            )\n                        )\n                finally:\n                    torrents.clear()\n                    del torrents\n        elif status == TorrentStatus.DOWNLOADING:\n            # 获取正在下载的任务\n            for name, server in servers.items():\n                torrents = (\n                    server.get_downloading_torrents(tags=settings.TORRENT_TAG) or []\n                )\n                try:\n                    for torrent in torrents:\n                        meta = MetaInfo(torrent.get(\"name\"))\n                        dlspeed = torrent.get(\"dlspeed\", 0)\n                        upspeed = torrent.get(\"upspeed\", 0)\n                        total_size = torrent.get(\"total_size\", 0)\n                        completed = torrent.get(\"completed\", 0)\n                        ret_torrents.append(\n                            DownloadingTorrent(\n                                downloader=name,\n                                hash=torrent.get(\"hash\"),\n                                title=torrent.get(\"name\"),\n                                name=meta.name,\n                                year=meta.year,\n                                season_episode=meta.season_episode,\n                                progress=torrent.get(\"progress\", 0),\n                                size=total_size,\n                                state=\"paused\"\n                                if torrent.get(\"state\") == 0\n                                else \"downloading\",\n                                dlspeed=StringUtils.str_filesize(dlspeed),\n                                upspeed=StringUtils.str_filesize(upspeed),\n                                left_time=StringUtils.str_secends(\n                                    (total_size - completed) / dlspeed\n                                )\n                                if dlspeed > 0\n                                else \"\",\n                            )\n                        )\n                finally:\n                    torrents.clear()\n                    del torrents\n        else:\n            return None\n        return ret_torrents  # noqa\n\n    def transfer_completed(\n        self, hashs: Union[str, list], downloader: Optional[str] = None\n    ) -> None:\n        \"\"\"\n        转移完成后的处理\n        :param hashs:  种子Hash\n        :param downloader:  下载器\n        \"\"\"\n        server: Rtorrent = self.get_instance(downloader)\n        if not server:\n            return None\n        # 获取原标签\n        org_tags = server.get_torrent_tags(ids=hashs)\n        # 种子打上已整理标签\n        if org_tags:\n            tags = org_tags + [\"已整理\"]\n        else:\n            tags = [\"已整理\"]\n        # 直接设置完整标签（覆盖）\n        server.set_torrents_tag(ids=hashs, tags=tags, overwrite=True)\n        return None\n\n    def remove_torrents(\n        self,\n        hashs: Union[str, list],\n        delete_file: Optional[bool] = True,\n        downloader: Optional[str] = None,\n    ) -> Optional[bool]:\n        \"\"\"\n        删除下载器种子\n        :param hashs:  种子Hash\n        :param delete_file:  是否删除文件\n        :param downloader:  下载器\n        :return: bool\n        \"\"\"\n        server: Rtorrent = self.get_instance(downloader)\n        if not server:\n            return None\n        return server.delete_torrents(delete_file=delete_file, ids=hashs)\n\n    def start_torrents(\n        self, hashs: Union[list, str], downloader: Optional[str] = None\n    ) -> Optional[bool]:\n        \"\"\"\n        开始下载\n        :param hashs:  种子Hash\n        :param downloader:  下载器\n        :return: bool\n        \"\"\"\n        server: Rtorrent = self.get_instance(downloader)\n        if not server:\n            return None\n        return server.start_torrents(ids=hashs)\n\n    def stop_torrents(\n        self, hashs: Union[list, str], downloader: Optional[str] = None\n    ) -> Optional[bool]:\n        \"\"\"\n        停止下载\n        :param hashs:  种子Hash\n        :param downloader:  下载器\n        :return: bool\n        \"\"\"\n        server: Rtorrent = self.get_instance(downloader)\n        if not server:\n            return None\n        return server.stop_torrents(ids=hashs)\n\n    def torrent_files(\n        self, tid: str, downloader: Optional[str] = None\n    ) -> Optional[List[Dict]]:\n        \"\"\"\n        获取种子文件列表\n        \"\"\"\n        server: Rtorrent = self.get_instance(downloader)\n        if not server:\n            return None\n        return server.get_files(tid=tid)\n\n    def downloader_info(\n        self, downloader: Optional[str] = None\n    ) -> Optional[List[schemas.DownloaderInfo]]:\n        \"\"\"\n        下载器信息\n        \"\"\"\n        if downloader:\n            server: Rtorrent = self.get_instance(downloader)\n            if not server:\n                return None\n            servers = [server]\n        else:\n            servers = self.get_instances().values()\n        ret_info = []\n        for server in servers:\n            info = server.transfer_info()\n            if not info:\n                continue\n            ret_info.append(\n                schemas.DownloaderInfo(\n                    download_speed=info.get(\"dl_info_speed\"),\n                    upload_speed=info.get(\"up_info_speed\"),\n                    download_size=info.get(\"dl_info_data\"),\n                    upload_size=info.get(\"up_info_data\"),\n                )\n            )\n        return ret_info\n"
  },
  {
    "path": "app/modules/rtorrent/rtorrent.py",
    "content": "import socket\nimport traceback\nimport xmlrpc.client\nfrom pathlib import Path\nfrom typing import Optional, Union, Tuple, List, Dict\nfrom urllib.parse import urlparse\n\nfrom app.log import logger\n\n\nclass SCGITransport(xmlrpc.client.Transport):\n    \"\"\"\n    通过SCGI协议与rTorrent通信的Transport\n    \"\"\"\n\n    def single_request(self, host, handler, request_body, verbose=False):\n        # 建立socket连接\n        parsed = urlparse(f\"scgi://{host}\")\n        sock = socket.create_connection(\n            (parsed.hostname, parsed.port or 5000), timeout=60\n        )\n        try:\n            # 构造SCGI请求头\n            headers = (\n                f\"CONTENT_LENGTH\\x00{len(request_body)}\\x00\"\n                f\"SCGI\\x001\\x00\"\n                f\"REQUEST_METHOD\\x00POST\\x00\"\n                f\"REQUEST_URI\\x00/RPC2\\x00\"\n            )\n            # netstring格式: \"len:headers,\"\n            netstring = f\"{len(headers)}:{headers},\".encode()\n            # 发送请求\n            sock.sendall(netstring + request_body)\n            # 读取响应\n            response = b\"\"\n            while True:\n                chunk = sock.recv(4096)\n                if not chunk:\n                    break\n                response += chunk\n        finally:\n            sock.close()\n\n        # 跳过HTTP响应头\n        header_end = response.find(b\"\\r\\n\\r\\n\")\n        if header_end != -1:\n            response = response[header_end + 4 :]\n\n        # 解析XML-RPC响应\n        return self.parse_response(self._build_response(response))\n\n    @staticmethod\n    def _build_response(data: bytes):\n        \"\"\"\n        构造类文件对象用于parse_response\n        \"\"\"\n        import io\n        import http.client\n\n        class _FakeSocket(io.BytesIO):\n            def makefile(self, *args, **kwargs):\n                return self\n\n        raw = b\"HTTP/1.0 200 OK\\r\\nContent-Type: text/xml\\r\\n\\r\\n\" + data\n        response = http.client.HTTPResponse(_FakeSocket(raw))  # noqa\n        response.begin()\n        return response\n\n\nclass Rtorrent:\n    \"\"\"\n    rTorrent下载器\n    \"\"\"\n\n    def __init__(\n        self,\n        host: Optional[str] = None,\n        port: Optional[int] = None,\n        username: Optional[str] = None,\n        password: Optional[str] = None,\n        **kwargs,\n    ):\n        self._proxy = None\n        if host and port:\n            self._host = f\"{host}:{port}\"\n        elif host:\n            self._host = host\n        else:\n            logger.error(\"rTorrent配置不完整！\")\n            return\n        self._username = username\n        self._password = password\n        self._proxy = self.__login_rtorrent()\n\n    def __login_rtorrent(self) -> Optional[xmlrpc.client.ServerProxy]:\n        \"\"\"\n        连接rTorrent\n        \"\"\"\n        if not self._host:\n            return None\n        try:\n            url = self._host\n            if url.startswith(\"scgi://\"):\n                # SCGI直连模式\n                logger.info(f\"正在通过SCGI连接 rTorrent：{url}\")\n                proxy = xmlrpc.client.ServerProxy(url, transport=SCGITransport())\n            else:\n                # HTTP模式 (通过nginx/ruTorrent代理)\n                if not url.startswith(\"http\"):\n                    url = f\"http://{url}\"\n                # 注入认证信息到URL\n                if self._username and self._password:\n                    parsed = urlparse(url)\n                    url = f\"{parsed.scheme}://{self._username}:{self._password}@{parsed.hostname}\"\n                    if parsed.port:\n                        url += f\":{parsed.port}\"\n                    url += parsed.path or \"/RPC2\"\n                logger.info(\n                    f\"正在通过HTTP连接 rTorrent：{url.split('@')[-1] if '@' in url else url}\"\n                )\n                proxy = xmlrpc.client.ServerProxy(url)\n\n            # 测试连接\n            proxy.system.client_version()\n            return proxy\n        except Exception as err:\n            stack_trace = \"\".join(\n                traceback.format_exception(None, err, err.__traceback__)\n            )[:2000]\n            logger.error(f\"rTorrent 连接出错：{str(err)}\\n{stack_trace}\")\n            return None\n\n    def is_inactive(self) -> bool:\n        \"\"\"\n        判断是否需要重连\n        \"\"\"\n        if not self._host:\n            return False\n        return True if not self._proxy else False\n\n    def reconnect(self):\n        \"\"\"\n        重连\n        \"\"\"\n        self._proxy = self.__login_rtorrent()\n\n    def get_torrents(\n        self,\n        ids: Optional[Union[str, list]] = None,\n        status: Optional[str] = None,\n        tags: Optional[Union[str, list]] = None,\n    ) -> Tuple[List[Dict], bool]:\n        \"\"\"\n        获取种子列表\n        :return: 种子列表, 是否发生异常\n        \"\"\"\n        if not self._proxy:\n            return [], True\n        try:\n            # 使用d.multicall2获取种子列表\n            fields = [\n                \"d.hash=\",\n                \"d.name=\",\n                \"d.size_bytes=\",\n                \"d.completed_bytes=\",\n                \"d.down.rate=\",\n                \"d.up.rate=\",\n                \"d.state=\",\n                \"d.complete=\",\n                \"d.directory=\",\n                \"d.custom1=\",\n                \"d.is_active=\",\n                \"d.is_open=\",\n                \"d.ratio=\",\n                \"d.base_path=\",\n            ]\n            # 获取所有种子\n            results = self._proxy.d.multicall2(\"\", \"main\", *fields)\n            torrents = []\n            for r in results:\n                torrent = {\n                    \"hash\": r[0],\n                    \"name\": r[1],\n                    \"total_size\": r[2],\n                    \"completed\": r[3],\n                    \"dlspeed\": r[4],\n                    \"upspeed\": r[5],\n                    \"state\": r[6],  # 0=stopped, 1=started\n                    \"complete\": r[7],  # 0=incomplete, 1=complete\n                    \"save_path\": r[8],\n                    \"tags\": r[9],  # d.custom1 用于标签\n                    \"is_active\": r[10],\n                    \"is_open\": r[11],\n                    \"ratio\": int(r[12]) / 1000.0 if r[12] else 0,\n                    \"content_path\": r[13],  # base_path 即完整内容路径\n                }\n                # 计算进度\n                if torrent[\"total_size\"] > 0:\n                    torrent[\"progress\"] = (\n                        torrent[\"completed\"] / torrent[\"total_size\"] * 100\n                    )\n                else:\n                    torrent[\"progress\"] = 0\n\n                # ID过滤\n                if ids:\n                    if isinstance(ids, str):\n                        ids_list = [ids.upper()]\n                    else:\n                        ids_list = [i.upper() for i in ids]\n                    if torrent[\"hash\"].upper() not in ids_list:\n                        continue\n\n                # 标签过滤\n                if tags:\n                    torrent_tags = [\n                        t.strip() for t in torrent[\"tags\"].split(\",\") if t.strip()\n                    ]\n                    if isinstance(tags, str):\n                        tags_list = [t.strip() for t in tags.split(\",\")]\n                    else:\n                        tags_list = tags\n                    if not set(tags_list).issubset(set(torrent_tags)):\n                        continue\n\n                torrents.append(torrent)\n            return torrents, False\n        except Exception as err:\n            logger.error(f\"获取种子列表出错：{str(err)}\")\n            return [], True\n\n    def get_completed_torrents(\n        self, ids: Union[str, list] = None, tags: Union[str, list] = None\n    ) -> Optional[List[Dict]]:\n        \"\"\"\n        获取已完成的种子\n        \"\"\"\n        if not self._proxy:\n            return None\n        torrents, error = self.get_torrents(ids=ids, tags=tags)\n        if error:\n            return None\n        return [t for t in torrents if t.get(\"complete\") == 1]\n\n    def get_downloading_torrents(\n        self, ids: Union[str, list] = None, tags: Union[str, list] = None\n    ) -> Optional[List[Dict]]:\n        \"\"\"\n        获取正在下载的种子\n        \"\"\"\n        if not self._proxy:\n            return None\n        torrents, error = self.get_torrents(ids=ids, tags=tags)\n        if error:\n            return None\n        return [t for t in torrents if t.get(\"complete\") == 0]\n\n    def add_torrent(\n        self,\n        content: Union[str, bytes],\n        is_paused: Optional[bool] = False,\n        download_dir: Optional[str] = None,\n        tags: Optional[List[str]] = None,\n        cookie: Optional[str] = None,\n        **kwargs,\n    ) -> bool:\n        \"\"\"\n        添加种子\n        :param content: 种子内容（bytes）或磁力链接/URL（str）\n        :param is_paused: 添加后暂停\n        :param download_dir: 下载路径\n        :param tags: 标签列表\n        :param cookie: Cookie\n        :return: bool\n        \"\"\"\n        if not self._proxy or not content:\n            return False\n        try:\n            # 构造命令参数\n            commands = []\n            if download_dir:\n                commands.append(f'd.directory.set=\"{download_dir}\"')\n            if tags:\n                tag_str = \",\".join(tags)\n                commands.append(f'd.custom1.set=\"{tag_str}\"')\n\n            if isinstance(content, bytes):\n                # 检查是否为磁力链接（bytes形式）\n                if content.startswith(b\"magnet:\"):\n                    content = content.decode(\"utf-8\", errors=\"ignore\")\n                else:\n                    # 种子文件内容，使用load.raw\n                    raw = xmlrpc.client.Binary(content)\n                    if is_paused:\n                        self._proxy.load.raw(\"\", raw, *commands)\n                    else:\n                        self._proxy.load.raw_start(\"\", raw, *commands)\n                    return True\n\n            # URL或磁力链接\n            if is_paused:\n                self._proxy.load.normal(\"\", content, *commands)\n            else:\n                self._proxy.load.start(\"\", content, *commands)\n            return True\n        except Exception as err:\n            logger.error(f\"添加种子出错：{str(err)}\")\n            return False\n\n    def start_torrents(self, ids: Union[str, list]) -> bool:\n        \"\"\"\n        启动种子\n        \"\"\"\n        if not self._proxy:\n            return False\n        try:\n            if isinstance(ids, str):\n                ids = [ids]\n            for tid in ids:\n                self._proxy.d.start(tid)\n            return True\n        except Exception as err:\n            logger.error(f\"启动种子出错：{str(err)}\")\n            return False\n\n    def stop_torrents(self, ids: Union[str, list]) -> bool:\n        \"\"\"\n        停止种子\n        \"\"\"\n        if not self._proxy:\n            return False\n        try:\n            if isinstance(ids, str):\n                ids = [ids]\n            for tid in ids:\n                self._proxy.d.stop(tid)\n            return True\n        except Exception as err:\n            logger.error(f\"停止种子出错：{str(err)}\")\n            return False\n\n    def delete_torrents(self, delete_file: bool, ids: Union[str, list]) -> bool:\n        \"\"\"\n        删除种子\n        \"\"\"\n        if not self._proxy:\n            return False\n        if not ids:\n            return False\n        try:\n            if isinstance(ids, str):\n                ids = [ids]\n            for tid in ids:\n                if delete_file:\n                    # 先获取base_path用于删除文件\n                    try:\n                        base_path = self._proxy.d.base_path(tid)\n                        self._proxy.d.erase(tid)\n                        if base_path:\n                            import shutil\n\n                            path = Path(base_path)\n                            if path.is_dir():\n                                shutil.rmtree(str(path), ignore_errors=True)\n                            elif path.is_file():\n                                path.unlink(missing_ok=True)\n                    except Exception as e:\n                        logger.warning(f\"删除种子文件出错：{str(e)}\")\n                        self._proxy.d.erase(tid)\n                else:\n                    self._proxy.d.erase(tid)\n            return True\n        except Exception as err:\n            logger.error(f\"删除种子出错：{str(err)}\")\n            return False\n\n    def get_files(self, tid: str) -> Optional[List[Dict]]:\n        \"\"\"\n        获取种子文件列表\n        \"\"\"\n        if not self._proxy:\n            return None\n        if not tid:\n            return None\n        try:\n            files = self._proxy.f.multicall(\n                tid,\n                \"\",\n                \"f.path=\",\n                \"f.size_bytes=\",\n                \"f.priority=\",\n                \"f.completed_chunks=\",\n                \"f.size_chunks=\",\n            )\n            result = []\n            for idx, f in enumerate(files):\n                result.append(\n                    {\n                        \"id\": idx,\n                        \"name\": f[0],\n                        \"size\": f[1],\n                        \"priority\": f[2],\n                        \"progress\": int(f[3]) / int(f[4]) * 100 if int(f[4]) > 0 else 0,\n                    }\n                )\n            return result\n        except Exception as err:\n            logger.error(f\"获取种子文件列表出错：{str(err)}\")\n            return None\n\n    def set_files(\n        self, torrent_hash: str = None, file_ids: list = None, priority: int = 0\n    ) -> bool:\n        \"\"\"\n        设置下载文件的优先级，priority为0为不下载，priority为1为普通\n        \"\"\"\n        if not self._proxy:\n            return False\n        if not torrent_hash or not file_ids:\n            return False\n        try:\n            for file_id in file_ids:\n                self._proxy.f.priority.set(f\"{torrent_hash}:f{file_id}\", priority)\n            # 更新种子优先级\n            self._proxy.d.update_priorities(torrent_hash)\n            return True\n        except Exception as err:\n            logger.error(f\"设置种子文件状态出错：{str(err)}\")\n            return False\n\n    def set_torrents_tag(\n        self, ids: Union[str, list], tags: List[str], overwrite: bool = False\n    ) -> bool:\n        \"\"\"\n        设置种子标签（使用d.custom1）\n        :param ids: 种子Hash\n        :param tags: 标签列表\n        :param overwrite: 是否覆盖现有标签，默认为合并\n        \"\"\"\n        if not self._proxy:\n            return False\n        if not ids:\n            return False\n        try:\n            if isinstance(ids, str):\n                ids = [ids]\n            for tid in ids:\n                if overwrite:\n                    # 直接覆盖标签\n                    self._proxy.d.custom1.set(tid, \",\".join(tags))\n                else:\n                    # 获取现有标签\n                    existing = self._proxy.d.custom1(tid)\n                    existing_tags = (\n                        [t.strip() for t in existing.split(\",\") if t.strip()]\n                        if existing\n                        else []\n                    )\n                    # 合并标签\n                    merged = list(set(existing_tags + tags))\n                    self._proxy.d.custom1.set(tid, \",\".join(merged))\n            return True\n        except Exception as err:\n            logger.error(f\"设置种子Tag出错：{str(err)}\")\n            return False\n\n    def remove_torrents_tag(self, ids: Union[str, list], tag: Union[str, list]) -> bool:\n        \"\"\"\n        移除种子标签\n        \"\"\"\n        if not self._proxy:\n            return False\n        if not ids:\n            return False\n        try:\n            if isinstance(ids, str):\n                ids = [ids]\n            if isinstance(tag, str):\n                tag = [tag]\n            for tid in ids:\n                existing = self._proxy.d.custom1(tid)\n                existing_tags = (\n                    [t.strip() for t in existing.split(\",\") if t.strip()]\n                    if existing\n                    else []\n                )\n                new_tags = [t for t in existing_tags if t not in tag]\n                self._proxy.d.custom1.set(tid, \",\".join(new_tags))\n            return True\n        except Exception as err:\n            logger.error(f\"移除种子Tag出错：{str(err)}\")\n            return False\n\n    def get_torrent_tags(self, ids: str) -> List[str]:\n        \"\"\"\n        获取种子标签\n        \"\"\"\n        if not self._proxy:\n            return []\n        try:\n            existing = self._proxy.d.custom1(ids)\n            return (\n                [t.strip() for t in existing.split(\",\") if t.strip()]\n                if existing\n                else []\n            )\n        except Exception as err:\n            logger.error(f\"获取种子标签出错：{str(err)}\")\n            return []\n\n    def get_torrent_id_by_tag(\n        self, tags: Union[str, list], status: Optional[str] = None\n    ) -> Optional[str]:\n        \"\"\"\n        通过标签多次尝试获取刚添加的种子ID，并移除标签\n        \"\"\"\n        import time\n\n        if isinstance(tags, str):\n            tags = [tags]\n        torrent_id = None\n        for i in range(1, 10):\n            time.sleep(3)\n            torrents, error = self.get_torrents(tags=tags)\n            if not error and torrents:\n                torrent_id = torrents[0].get(\"hash\")\n                # 移除查找标签\n                for tag in tags:\n                    self.remove_torrents_tag(ids=torrent_id, tag=[tag])\n                break\n        return torrent_id\n\n    def transfer_info(self) -> Optional[Dict]:\n        \"\"\"\n        获取传输信息\n        \"\"\"\n        if not self._proxy:\n            return None\n        try:\n            return {\n                \"dl_info_speed\": self._proxy.throttle.global_down.rate(),\n                \"up_info_speed\": self._proxy.throttle.global_up.rate(),\n                \"dl_info_data\": self._proxy.throttle.global_down.total(),\n                \"up_info_data\": self._proxy.throttle.global_up.total(),\n            }\n        except Exception as err:\n            logger.error(f\"获取传输信息出错：{str(err)}\")\n            return None\n"
  },
  {
    "path": "app/modules/slack/__init__.py",
    "content": "import json\nimport re\nfrom typing import Optional, Union, List, Tuple, Any\n\nfrom app.core.context import MediaInfo, Context\nfrom app.log import logger\nfrom app.modules import _ModuleBase, _MessageBase\nfrom app.modules.slack.slack import Slack\nfrom app.schemas import MessageChannel, CommingMessage, Notification\nfrom app.schemas.types import ModuleType\n\n\nclass SlackModule(_ModuleBase, _MessageBase[Slack]):\n\n    def init_module(self) -> None:\n        \"\"\"\n        初始化模块\n        \"\"\"\n        super().init_service(service_name=Slack.__name__.lower(),\n                             service_type=Slack)\n        self._channel = MessageChannel.Slack\n\n    @staticmethod\n    def get_name() -> str:\n        return \"Slack\"\n\n    @staticmethod\n    def get_type() -> ModuleType:\n        \"\"\"\n        获取模块类型\n        \"\"\"\n        return ModuleType.Notification\n\n    @staticmethod\n    def get_subtype() -> MessageChannel:\n        \"\"\"\n        获取模块子类型\n        \"\"\"\n        return MessageChannel.Slack\n\n    @staticmethod\n    def get_priority() -> int:\n        \"\"\"\n        获取模块优先级，数字越小优先级越高，只有同一接口下优先级才生效\n        \"\"\"\n        return 3\n\n    def stop(self):\n        \"\"\"\n        停止模块\n        \"\"\"\n        for client in self.get_instances().values():\n            client.stop()\n\n    def test(self) -> Optional[Tuple[bool, str]]:\n        \"\"\"\n        测试模块连接性\n        \"\"\"\n        if not self.get_instances():\n            return None\n        for name, client in self.get_instances().items():\n            state = client.get_state()\n            if not state:\n                return False, f\"Slack {name} 未就绪\"\n        return True, \"\"\n\n    def init_setting(self) -> Tuple[str, Union[str, bool]]:\n        pass\n\n    def message_parser(self, source: str, body: Any, form: Any, args: Any) -> Optional[CommingMessage]:\n        \"\"\"\n        解析消息内容，返回字典，注意以下约定值：\n        userid: 用户ID\n        username: 用户名\n        text: 内容\n        :param source: 消息来源\n        :param body: 请求体\n        :param form: 表单\n        :param args: 参数\n        :return: 渠道、消息体\n        \"\"\"\n        \"\"\"\n        # 消息\n        {\n            'client_msg_id': '',\n            'type': 'message',\n            'text': 'hello',\n            'user': '',\n            'ts': '1670143568.444289',\n            'blocks': [{\n                'type': 'rich_text',\n                'block_id': 'i2j+',\n                'elements': [{\n                    'type': 'rich_text_section',\n                    'elements': [{\n                        'type': 'text',\n                        'text': 'hello'\n                    }]\n                }]\n            }],\n            'team': '',\n            'client': '',\n            'event_ts': '1670143568.444289',\n            'channel_type': 'im'\n        }\n        # 命令\n        {\n          \"token\": \"\",\n          \"team_id\": \"\",\n          \"team_domain\": \"\",\n          \"channel_id\": \"\",\n          \"channel_name\": \"directmessage\",\n          \"user_id\": \"\",\n          \"user_name\": \"\",\n          \"command\": \"/subscribes\",\n          \"text\": \"\",\n          \"api_app_id\": \"\",\n          \"is_enterprise_install\": \"false\",\n          \"response_url\": \"\",\n          \"trigger_id\": \"\"\n        }\n        # 快捷方式\n        {\n          \"type\": \"shortcut\",\n          \"token\": \"XXXXXXXXXXXXX\",\n          \"action_ts\": \"1581106241.371594\",\n          \"team\": {\n            \"id\": \"TXXXXXXXX\",\n            \"domain\": \"shortcuts-test\"\n          },\n          \"user\": {\n            \"id\": \"UXXXXXXXXX\",\n            \"username\": \"aman\",\n            \"team_id\": \"TXXXXXXXX\"\n          },\n          \"callback_id\": \"shortcut_create_task\",\n          \"trigger_id\": \"944799105734.773906753841.38b5894552bdd4a780554ee59d1f3638\"\n        }\n        # 按钮点击\n        {\n          \"type\": \"block_actions\",\n          \"team\": {\n            \"id\": \"T9TK3CUKW\",\n            \"domain\": \"example\"\n          },\n          \"user\": {\n            \"id\": \"UA8RXUSPL\",\n            \"username\": \"jtorrance\",\n            \"team_id\": \"T9TK3CUKW\"\n          },\n          \"api_app_id\": \"AABA1ABCD\",\n          \"token\": \"9s8d9as89d8as9d8as989\",\n          \"container\": {\n            \"type\": \"message_attachment\",\n            \"message_ts\": \"1548261231.000200\",\n            \"attachment_id\": 1,\n            \"channel_id\": \"CBR2V3XEX\",\n            \"is_ephemeral\": false,\n            \"is_app_unfurl\": false\n          },\n          \"trigger_id\": \"12321423423.333649436676.d8c1bb837935619ccad0f624c448ffb3\",\n          \"client\": {\n            \"id\": \"CBR2V3XEX\",\n            \"name\": \"review-updates\"\n          },\n          \"message\": {\n            \"bot_id\": \"BAH5CA16Z\",\n            \"type\": \"message\",\n            \"text\": \"This content can't be displayed.\",\n            \"user\": \"UAJ2RU415\",\n            \"ts\": \"1548261231.000200\",\n            ...\n          },\n          \"response_url\": \"https://hooks.slack.com/actions/AABA1ABCD/1232321423432/D09sSasdasdAS9091209\",\n          \"actions\": [\n            {\n              \"action_id\": \"WaXA\",\n              \"block_id\": \"=qXel\",\n              \"text\": {\n                \"type\": \"plain_text\",\n                \"text\": \"View\",\n                \"emoji\": true\n              },\n              \"value\": \"click_me_123\",\n              \"type\": \"button\",\n              \"action_ts\": \"1548426417.840180\"\n            }\n          ]\n        }\n        \"\"\"\n        # 获取服务配置\n        client_config = self.get_config(source)\n        if not client_config:\n            return None\n        try:\n            msg_json: dict = json.loads(body)\n        except Exception as err:\n            logger.debug(f\"解析Slack消息失败：{str(err)}\")\n            return None\n        if msg_json:\n            if msg_json.get(\"type\") == \"message\":\n                userid = msg_json.get(\"user\")\n                text = msg_json.get(\"text\")\n                username = msg_json.get(\"user\")\n            elif msg_json.get(\"type\") == \"block_actions\":\n                userid = msg_json.get(\"user\", {}).get(\"id\")\n                callback_data = msg_json.get(\"actions\")[0].get(\"value\")\n                # 使用CALLBACK前缀标识按钮回调\n                text = f\"CALLBACK:{callback_data}\"\n                username = msg_json.get(\"user\", {}).get(\"name\")\n\n                # 获取原消息信息用于编辑\n                message_info = msg_json.get(\"message\", {})\n                # Slack消息的时间戳作为消息ID\n                message_ts = message_info.get(\"ts\")\n                channel_id = msg_json.get(\"channel\", {}).get(\"id\") or msg_json.get(\"container\", {}).get(\"channel_id\")\n\n                logger.info(f\"收到来自 {client_config.name} 的Slack按钮回调：\"\n                            f\"userid={userid}, username={username}, callback_data={callback_data}\")\n\n                # 创建包含回调信息的CommingMessage\n                return CommingMessage(\n                    channel=MessageChannel.Slack,\n                    source=client_config.name,\n                    userid=userid,\n                    username=username,\n                    text=text,\n                    is_callback=True,\n                    callback_data=callback_data,\n                    message_id=message_ts,\n                    chat_id=channel_id\n                )\n            elif msg_json.get(\"type\") == \"event_callback\":\n                userid = msg_json.get('event', {}).get('user')\n                text = re.sub(r\"<@[0-9A-Z]+>\", \"\", msg_json.get(\"event\", {}).get(\"text\"), flags=re.IGNORECASE).strip()\n                username = \"\"\n            elif msg_json.get(\"type\") == \"shortcut\":\n                userid = msg_json.get(\"user\", {}).get(\"id\")\n                text = msg_json.get(\"callback_id\")\n                username = msg_json.get(\"user\", {}).get(\"username\")\n            elif msg_json.get(\"command\"):\n                userid = msg_json.get(\"user_id\")\n                text = msg_json.get(\"command\")\n                username = msg_json.get(\"user_name\")\n            else:\n                return None\n            logger.info(f\"收到来自 {client_config.name} 的Slack消息：userid={userid}, username={username}, text={text}\")\n            return CommingMessage(channel=MessageChannel.Slack, source=client_config.name,\n                                  userid=userid, username=username, text=text)\n        return None\n\n    def post_message(self, message: Notification, **kwargs) -> None:\n        \"\"\"\n        发送消息\n        :param message: 消息\n        :return: 成功或失败\n        \"\"\"\n        for conf in self.get_configs().values():\n            if not self.check_message(message, conf.name):\n                continue\n            targets = message.targets\n            userid = message.userid\n            if not userid and targets is not None:\n                userid = targets.get('slack_userid')\n                if not userid:\n                    logger.warn(f\"用户没有指定 Slack用户ID，消息无法发送\")\n                    return\n            client: Slack = self.get_instance(conf.name)\n            if client:\n                client.send_msg(title=message.title, text=message.text,\n                                image=message.image, userid=userid, link=message.link,\n                                buttons=message.buttons,\n                                original_message_id=message.original_message_id,\n                                original_chat_id=message.original_chat_id)\n\n    def post_medias_message(self, message: Notification, medias: List[MediaInfo]) -> None:\n        \"\"\"\n        发送媒体信息选择列表\n        :param message: 消息体\n        :param medias: 媒体信息\n        :return: 成功或失败\n        \"\"\"\n        for conf in self.get_configs().values():\n            if not self.check_message(message, conf.name):\n                continue\n            client: Slack = self.get_instance(conf.name)\n            if client:\n                client.send_medias_msg(title=message.title, medias=medias, userid=message.userid,\n                                       buttons=message.buttons,\n                                       original_message_id=message.original_message_id,\n                                       original_chat_id=message.original_chat_id)\n\n    def post_torrents_message(self, message: Notification, torrents: List[Context]) -> None:\n        \"\"\"\n        发送种子信息选择列表\n        :param message: 消息体\n        :param torrents: 种子信息\n        :return: 成功或失败\n        \"\"\"\n        for conf in self.get_configs().values():\n            if not self.check_message(message, conf.name):\n                continue\n            client: Slack = self.get_instance(conf.name)\n            if client:\n                client.send_torrents_msg(title=message.title, torrents=torrents,\n                                         userid=message.userid, buttons=message.buttons,\n                                         original_message_id=message.original_message_id,\n                                         original_chat_id=message.original_chat_id)\n\n    def delete_message(self, channel: MessageChannel, source: str,\n                       message_id: str, chat_id: Optional[str] = None) -> bool:\n        \"\"\"\n        删除消息\n        :param channel: 消息渠道\n        :param source: 指定的消息源\n        :param message_id: 消息ID（Slack中为时间戳）\n        :param chat_id: 聊天ID（频道ID）\n        :return: 删除是否成功\n        \"\"\"\n        success = False\n        for conf in self.get_configs().values():\n            if channel != self._channel:\n                break\n            if source != conf.name:\n                continue\n            client: Slack = self.get_instance(conf.name)\n            if client:\n                result = client.delete_msg(message_id=message_id, chat_id=chat_id)\n                if result:\n                    success = True\n        return success\n"
  },
  {
    "path": "app/modules/slack/slack.py",
    "content": "import re\nfrom threading import Lock\nfrom typing import List, Optional\nfrom urllib.parse import quote\n\nimport requests\nfrom slack_bolt import App\nfrom slack_bolt.adapter.socket_mode import SocketModeHandler\nfrom slack_sdk import WebClient\n\nfrom app.core.config import settings\nfrom app.core.context import MediaInfo, Context\nfrom app.core.metainfo import MetaInfo\nfrom app.log import logger\nfrom app.utils.string import StringUtils\n\nlock = Lock()\n\n\nclass Slack:\n    _client: WebClient = None\n    _service: SocketModeHandler = None\n    _ds_url = f\"http://127.0.0.1:{settings.PORT}/api/v1/message?token={settings.API_TOKEN}\"\n    _channel = \"\"\n\n    def __init__(self, SLACK_OAUTH_TOKEN: Optional[str] = None, SLACK_APP_TOKEN: Optional[str] = None,\n                 SLACK_CHANNEL: Optional[str] = None, **kwargs):\n\n        if not SLACK_OAUTH_TOKEN or not SLACK_APP_TOKEN:\n            logger.error(\"Slack 配置不完整！\")\n            return\n\n        try:\n            slack_app = App(token=SLACK_OAUTH_TOKEN,\n                            ssl_check_enabled=False,\n                            url_verification_enabled=False)\n        except Exception as err:\n            logger.error(f\"Slack初始化失败: {str(err)}\")\n            return\n\n        self._client = slack_app.client\n        self._channel = SLACK_CHANNEL\n\n        # 标记消息来源\n        if kwargs.get(\"name\"):\n            # URL encode the source name to handle special characters\n            encoded_name = quote(kwargs.get('name'), safe='')\n            self._ds_url = f\"{self._ds_url}&source={encoded_name}\"\n\n        # 注册消息响应\n        @slack_app.event(\"message\")\n        def slack_message(message):\n            with requests.post(self._ds_url, json=message, timeout=10) as local_res:\n                logger.debug(\"message: %s processed, response is: %s\" % (message, local_res.text))\n\n        @slack_app.action(re.compile(r\"actionId-.*\"))\n        def slack_action(ack, body):\n            ack()\n            with requests.post(self._ds_url, json=body, timeout=60) as local_res:\n                logger.debug(\"message: %s processed, response is: %s\" % (body, local_res.text))\n\n        @slack_app.event(\"app_mention\")\n        def slack_mention(say, body):\n            say(f\"收到，请稍等... <@{body.get('event', {}).get('user')}>\")\n            with requests.post(self._ds_url, json=body, timeout=10) as local_res:\n                logger.debug(\"message: %s processed, response is: %s\" % (body, local_res.text))\n\n        @slack_app.shortcut(re.compile(r\"/*\"))\n        def slack_shortcut(ack, body):\n            ack()\n            with requests.post(self._ds_url, json=body, timeout=10) as local_res:\n                logger.debug(\"message: %s processed, response is: %s\" % (body, local_res.text))\n\n        @slack_app.command(re.compile(r\"/*\"))\n        def slack_command(ack, body):\n            ack()\n            with requests.post(self._ds_url, json=body, timeout=10) as local_res:\n                logger.debug(\"message: %s processed, response is: %s\" % (body, local_res.text))\n\n        # 启动服务\n        try:\n            self._service = SocketModeHandler(\n                slack_app,\n                SLACK_APP_TOKEN\n            )\n            self._service.connect()\n            logger.info(\"Slack消息接收服务启动\")\n        except Exception as err:\n            logger.error(\"Slack消息接收服务启动失败: %s\" % str(err))\n\n    def stop(self):\n        if self._service:\n            try:\n                self._service.close()\n                logger.info(\"Slack消息接收服务已停止\")\n            except Exception as err:\n                logger.error(\"Slack消息接收服务停止失败: %s\" % str(err))\n\n    def get_state(self) -> bool:\n        \"\"\"\n        获取状态\n        \"\"\"\n        return True if self._client else False\n\n    def send_msg(self, title: str, text: Optional[str] = None,\n                 image: Optional[str] = None, link: Optional[str] = None,\n                 userid: Optional[str] = None, buttons: Optional[List[List[dict]]] = None,\n                 original_message_id: Optional[str] = None,\n                 original_chat_id: Optional[str] = None):\n        \"\"\"\n        发送Slack消息\n        :param title: 消息标题\n        :param text: 消息内容\n        :param image: 消息图片地址\n        :param link: 点击消息转转的URL\n        :param userid: 用户ID，如有则只发消息给该用户\n        :param buttons: 消息按钮列表，格式为 [[{\"text\": \"按钮文本\", \"callback_data\": \"回调数据\", \"url\": \"链接\"}]]\n        :param original_message_id: 原消息的时间戳，如果提供则编辑原消息\n        :param original_chat_id: 原消息的频道ID，编辑消息时需要\n        \"\"\"\n        if not self._client:\n            return False, \"消息客户端未就绪\"\n        if not title and not text:\n            return False, \"标题和内容不能同时为空\"\n        try:\n            if userid:\n                channel = userid\n            else:\n                # 消息广播\n                channel = self.__find_public_channel()\n            # 消息文本\n            message_text = \"\"\n            # 结构体\n            blocks = []\n            if not image:\n                message_text = f\"{title}\\n{text or ''}\"\n            else:\n                # 消息图片\n                if image:\n                    # 拼装消息内容\n                    blocks.append({\"type\": \"section\", \"text\": {\n                        \"type\": \"mrkdwn\",\n                        \"text\": f\"*{title}*\\n{text or ''}\"\n                    }, 'accessory': {\n                        \"type\": \"image\",\n                        \"image_url\": f\"{image}\",\n                        \"alt_text\": f\"{title}\"\n                    }})\n                # 自定义按钮\n                if buttons:\n                    for button_row in buttons:\n                        elements = []\n                        for button in button_row:\n                            if \"url\" in button:\n                                # URL按钮\n                                elements.append({\n                                    \"type\": \"button\",\n                                    \"text\": {\n                                        \"type\": \"plain_text\",\n                                        \"text\": button[\"text\"],\n                                        \"emoji\": True\n                                    },\n                                    \"url\": button[\"url\"],\n                                    \"action_id\": f\"actionId-url-{button.get('text', 'url')}-{len(elements)}\"\n                                })\n                            else:\n                                # 回调按钮\n                                elements.append({\n                                    \"type\": \"button\",\n                                    \"text\": {\n                                        \"type\": \"plain_text\",\n                                        \"text\": button[\"text\"],\n                                        \"emoji\": True\n                                    },\n                                    \"value\": button[\"callback_data\"],\n                                    \"action_id\": f\"actionId-{button['callback_data']}\"\n                                })\n                        if elements:\n                            blocks.append({\n                                \"type\": \"actions\",\n                                \"elements\": elements\n                            })\n                elif link:\n                    # 默认链接按钮\n                    blocks.append({\n                        \"type\": \"actions\",\n                        \"elements\": [\n                            {\n                                \"type\": \"button\",\n                                \"text\": {\n                                    \"type\": \"plain_text\",\n                                    \"text\": \"查看详情\",\n                                    \"emoji\": True\n                                },\n                                \"value\": \"click_me_url\",\n                                \"url\": f\"{link}\",\n                                \"action_id\": \"actionId-url\"\n                            }\n                        ]\n                    })\n\n            # 判断是编辑消息还是发送新消息\n            if original_message_id and original_chat_id:\n                # 编辑消息\n                result = self._client.chat_update(\n                    channel=original_chat_id,\n                    ts=original_message_id,\n                    text=message_text[:1000],\n                    blocks=blocks or []\n                )\n            else:\n                # 发送新消息\n                result = self._client.chat_postMessage(\n                    channel=channel,\n                    text=message_text[:1000],\n                    blocks=blocks,\n                    mrkdwn=True\n                )\n            return True, result\n        except Exception as msg_e:\n            logger.error(f\"Slack消息发送失败: {msg_e}\")\n            return False, str(msg_e)\n\n    def send_medias_msg(self, medias: List[MediaInfo], userid: Optional[str] = None, title: Optional[str] = None,\n                        buttons: Optional[List[List[dict]]] = None,\n                        original_message_id: Optional[str] = None,\n                        original_chat_id: Optional[str] = None) -> Optional[bool]:\n        \"\"\"\n        发送媒体列表消息\n        :param medias: 媒体信息列表\n        :param userid: 用户ID，如有则只发消息给该用户\n        :param title: 消息标题\n        :param buttons: 按钮列表，格式：[[{\"text\": \"按钮文本\", \"callback_data\": \"回调数据\"}]]\n        :param original_message_id: 原消息的时间戳，如果提供则编辑原消息\n        :param original_chat_id: 原消息的频道ID，编辑消息时需要\n        \"\"\"\n        if not self._client:\n            return False\n        if not medias:\n            return False\n        try:\n            if userid:\n                channel = userid\n            else:\n                # 消息广播\n                channel = self.__find_public_channel()\n            # 消息主体\n            title_section = {\n                \"type\": \"section\",\n                \"text\": {\n                    \"type\": \"mrkdwn\",\n                    \"text\": f\"*{title}*\"\n                }\n            }\n            blocks = [title_section]\n            # 列表\n            if medias:\n                blocks.append({\n                    \"type\": \"divider\"\n                })\n                index = 1\n\n                # 如果有自定义按钮，先添加所有媒体项，然后添加统一的按钮\n                if buttons:\n                    # 添加媒体列表（不带单独的选择按钮）\n                    for media in medias:\n                        if media.get_poster_image():\n                            if media.vote_star:\n                                text = f\"{index}. *<{media.detail_link}|{media.title_year}>*\" \\\n                                       f\"\\n类型：{media.type.value}\" \\\n                                       f\"\\n{media.vote_star}\" \\\n                                       f\"\\n{media.get_overview_string(50)}\"\n                            else:\n                                text = f\"{index}. *<{media.detail_link}|{media.title_year}>*\" \\\n                                       f\"\\n类型：{media.type.value}\" \\\n                                       f\"\\n{media.get_overview_string(50)}\"\n                            blocks.append(\n                                {\n                                    \"type\": \"section\",\n                                    \"text\": {\n                                        \"type\": \"mrkdwn\",\n                                        \"text\": text\n                                    },\n                                    \"accessory\": {\n                                        \"type\": \"image\",\n                                        \"image_url\": f\"{media.get_poster_image()}\",\n                                        \"alt_text\": f\"{media.title_year}\"\n                                    }\n                                }\n                            )\n                            index += 1\n\n                    # 添加统一的自定义按钮（在所有媒体项之后）\n                    for button_row in buttons:\n                        elements = []\n                        for button in button_row:\n                            if \"url\" in button:\n                                elements.append({\n                                    \"type\": \"button\",\n                                    \"text\": {\n                                        \"type\": \"plain_text\",\n                                        \"text\": button[\"text\"],\n                                        \"emoji\": True\n                                    },\n                                    \"url\": button[\"url\"],\n                                    \"action_id\": f\"actionId-url-{button.get('text', 'url')}-{len(elements)}\"\n                                })\n                            else:\n                                elements.append({\n                                    \"type\": \"button\",\n                                    \"text\": {\n                                        \"type\": \"plain_text\",\n                                        \"text\": button[\"text\"],\n                                        \"emoji\": True\n                                    },\n                                    \"value\": button[\"callback_data\"],\n                                    \"action_id\": f\"actionId-{button['callback_data']}\"\n                                })\n                        if elements:\n                            blocks.append({\n                                \"type\": \"actions\",\n                                \"elements\": elements\n                            })\n                else:\n                    # 使用默认的每个媒体项单独按钮\n                    for media in medias:\n                        if media.get_poster_image():\n                            if media.vote_star:\n                                text = f\"{index}. *<{media.detail_link}|{media.title_year}>*\" \\\n                                       f\"\\n类型：{media.type.value}\" \\\n                                       f\"\\n{media.vote_star}\" \\\n                                       f\"\\n{media.get_overview_string(50)}\"\n                            else:\n                                text = f\"{index}. *<{media.detail_link}|{media.title_year}>*\" \\\n                                       f\"\\n类型：{media.type.value}\" \\\n                                       f\"\\n{media.get_overview_string(50)}\"\n                            blocks.append(\n                                {\n                                    \"type\": \"section\",\n                                    \"text\": {\n                                        \"type\": \"mrkdwn\",\n                                        \"text\": text\n                                    },\n                                    \"accessory\": {\n                                        \"type\": \"image\",\n                                        \"image_url\": f\"{media.get_poster_image()}\",\n                                        \"alt_text\": f\"{media.title_year}\"\n                                    }\n                                }\n                            )\n                            # 使用默认选择按钮\n                            blocks.append(\n                                {\n                                    \"type\": \"actions\",\n                                    \"elements\": [\n                                        {\n                                            \"type\": \"button\",\n                                            \"text\": {\n                                                \"type\": \"plain_text\",\n                                                \"text\": \"选择\",\n                                                \"emoji\": True\n                                            },\n                                            \"value\": f\"{index}\",\n                                            \"action_id\": f\"actionId-{index}\"\n                                        }\n                                    ]\n                                }\n                            )\n                            index += 1\n\n            # 判断是编辑消息还是发送新消息\n            if original_message_id and original_chat_id:\n                # 编辑消息\n                result = self._client.chat_update(\n                    channel=original_chat_id,\n                    ts=original_message_id,\n                    text=title,\n                    blocks=blocks or []\n                )\n            else:\n                # 发送新消息\n                result = self._client.chat_postMessage(\n                    channel=channel,\n                    text=title,\n                    blocks=blocks\n                )\n            return True if result else False\n        except Exception as msg_e:\n            logger.error(f\"Slack消息发送失败: {msg_e}\")\n            return False\n\n    def send_torrents_msg(self, torrents: List[Context], userid: Optional[str] = None, title: Optional[str] = None,\n                          buttons: Optional[List[List[dict]]] = None,\n                          original_message_id: Optional[str] = None,\n                          original_chat_id: Optional[str] = None) -> Optional[bool]:\n        \"\"\"\n        发送种子列表消息\n        :param torrents: 种子信息列表\n        :param userid: 用户ID，如有则只发消息给该用户\n        :param title: 消息标题\n        :param buttons: 按钮列表，格式：[[{\"text\": \"按钮文本\", \"callback_data\": \"回调数据\"}]]\n        :param original_message_id: 原消息的时间戳，如果提供则编辑原消息\n        :param original_chat_id: 原消息的频道ID，编辑消息时需要\n        \"\"\"\n        if not self._client:\n            return None\n\n        try:\n            if userid:\n                channel = userid\n            else:\n                # 消息广播\n                channel = self.__find_public_channel()\n            # 消息主体\n            title_section = {\n                \"type\": \"section\",\n                \"text\": {\n                    \"type\": \"mrkdwn\",\n                    \"text\": f\"*{title}*\"\n                }\n            }\n            blocks = [title_section, {\n                \"type\": \"divider\"\n            }]\n            # 列表\n            index = 1\n\n            # 如果有自定义按钮，先添加种子列表，然后添加统一的按钮\n            if buttons:\n                # 添加种子列表（不带单独的选择按钮）\n                for context in torrents:\n                    torrent = context.torrent_info\n                    site_name = torrent.site_name\n                    meta = MetaInfo(torrent.title, torrent.description)\n                    link = torrent.page_url\n                    title_text = f\"{meta.season_episode} \" \\\n                                 f\"{meta.resource_term} \" \\\n                                 f\"{meta.video_term} \" \\\n                                 f\"{meta.release_group}\"\n                    title_text = re.sub(r\"\\s+\", \" \", title_text).strip()\n                    free = torrent.volume_factor\n                    seeder = f\"{torrent.seeders}↑\"\n                    description = torrent.description\n                    text = f\"{index}. 【{site_name}】<{link}|{title_text}> \" \\\n                           f\"{StringUtils.str_filesize(torrent.size)} {free} {seeder}\\n\" \\\n                           f\"{description}\"\n                    blocks.append(\n                        {\n                            \"type\": \"section\",\n                            \"text\": {\n                                \"type\": \"mrkdwn\",\n                                \"text\": text\n                            }\n                        }\n                    )\n                    index += 1\n\n                # 添加统一的自定义按钮\n                for button_row in buttons:\n                    elements = []\n                    for button in button_row:\n                        if \"url\" in button:\n                            elements.append({\n                                \"type\": \"button\",\n                                \"text\": {\n                                    \"type\": \"plain_text\",\n                                    \"text\": button[\"text\"],\n                                    \"emoji\": True\n                                },\n                                \"url\": button[\"url\"],\n                                \"action_id\": f\"actionId-url-{button.get('text', 'url')}-{len(elements)}\"\n                            })\n                        else:\n                            elements.append({\n                                \"type\": \"button\",\n                                \"text\": {\n                                    \"type\": \"plain_text\",\n                                    \"text\": button[\"text\"],\n                                    \"emoji\": True\n                                },\n                                \"value\": button[\"callback_data\"],\n                                \"action_id\": f\"actionId-{button['callback_data']}\"\n                            })\n                    if elements:\n                        blocks.append({\n                            \"type\": \"actions\",\n                            \"elements\": elements\n                        })\n            else:\n                # 使用默认的每个种子单独按钮\n                for context in torrents:\n                    torrent = context.torrent_info\n                    site_name = torrent.site_name\n                    meta = MetaInfo(torrent.title, torrent.description)\n                    link = torrent.page_url\n                    title_text = f\"{meta.season_episode} \" \\\n                                 f\"{meta.resource_term} \" \\\n                                 f\"{meta.video_term} \" \\\n                                 f\"{meta.release_group}\"\n                    title_text = re.sub(r\"\\s+\", \" \", title_text).strip()\n                    free = torrent.volume_factor\n                    seeder = f\"{torrent.seeders}↑\"\n                    description = torrent.description\n                    text = f\"{index}. 【{site_name}】<{link}|{title_text}> \" \\\n                           f\"{StringUtils.str_filesize(torrent.size)} {free} {seeder}\\n\" \\\n                           f\"{description}\"\n                    blocks.append(\n                        {\n                            \"type\": \"section\",\n                            \"text\": {\n                                \"type\": \"mrkdwn\",\n                                \"text\": text\n                            }\n                        }\n                    )\n                    blocks.append(\n                        {\n                            \"type\": \"actions\",\n                            \"elements\": [\n                                {\n                                    \"type\": \"button\",\n                                    \"text\": {\n                                        \"type\": \"plain_text\",\n                                        \"text\": \"选择\",\n                                        \"emoji\": True\n                                    },\n                                    \"value\": f\"{index}\",\n                                    \"action_id\": f\"actionId-{index}\"\n                                }\n                            ]\n                        }\n                    )\n                    index += 1\n\n            # 判断是编辑消息还是发送新消息\n            if original_message_id and original_chat_id:\n                # 编辑消息\n                result = self._client.chat_update(\n                    channel=original_chat_id,\n                    ts=original_message_id,\n                    text=title,\n                    blocks=blocks or []\n                )\n            else:\n                # 发送新消息\n                result = self._client.chat_postMessage(\n                    channel=channel,\n                    text=title,\n                    blocks=blocks\n                )\n            return True if result else False\n        except Exception as msg_e:\n            logger.error(f\"Slack消息发送失败: {msg_e}\")\n            return False\n\n    def delete_msg(self, message_id: str, chat_id: Optional[str] = None) -> Optional[bool]:\n        \"\"\"\n        删除Slack消息\n        :param message_id: 消息时间戳（Slack消息ID）\n        :param chat_id: 频道ID\n        :return: 删除是否成功\n        \"\"\"\n        if not self._client:\n            return None\n\n        try:\n            # 确定要删除消息的频道ID\n            if chat_id:\n                target_channel = chat_id\n            else:\n                target_channel = self.__find_public_channel()\n\n            if not target_channel:\n                logger.error(\"无法确定要删除消息的Slack频道\")\n                return False\n\n            # 删除消息\n            result = self._client.chat_delete(\n                channel=target_channel,\n                ts=message_id\n            )\n\n            if result.get(\"ok\"):\n                logger.info(f\"成功删除Slack消息: channel={target_channel}, ts={message_id}\")\n                return True\n            else:\n                logger.error(f\"删除Slack消息失败: {result.get('error', 'unknown error')}\")\n                return False\n        except Exception as e:\n            logger.error(f\"删除Slack消息异常: {str(e)}\")\n            return False\n\n    def __find_public_channel(self):\n        \"\"\"\n        查找公共频道\n        \"\"\"\n        if not self._client:\n            return \"\"\n        conversation_id = \"\"\n        try:\n            for result in self._client.conversations_list(types=\"public_channel,private_channel\"):\n                if conversation_id:\n                    break\n                for channel in result[\"channels\"]:\n                    if channel.get(\"name\") == (self._channel or \"全体\"):\n                        conversation_id = channel.get(\"id\")\n                        break\n        except Exception as e:\n            logger.error(f\"查找Slack公共频道失败: {str(e)}\")\n        return conversation_id\n"
  },
  {
    "path": "app/modules/subtitle/__init__.py",
    "content": "import shutil\nimport time\nfrom pathlib import Path\nfrom typing import Tuple, Union\n\nfrom lxml import etree\n\nfrom app.chain.storage import StorageChain\nfrom app.core.config import settings\nfrom app.core.context import Context\nfrom app.db.site_oper import SiteOper\nfrom app.helper.sites import SitesHelper  # noqa\nfrom app.helper.torrent import TorrentHelper\nfrom app.log import logger\nfrom app.modules import _ModuleBase\nfrom app.modules.indexer.spider.mtorrent import MTorrentSpider\nfrom app.schemas import TorrentInfo\nfrom app.schemas.file import FileURI \nfrom app.schemas.types import ModuleType, OtherModulesType\nfrom app.utils.http import RequestUtils\nfrom app.utils.string import StringUtils\nfrom app.utils.system import SystemUtils\n\n\nclass SubtitleModule(_ModuleBase):\n    \"\"\"\n    字幕下载模块\n    \"\"\"\n\n    # 站点详情页字幕下载链接识别XPATH\n    _SITE_SUBTITLE_XPATH = [\n        '//td[@class=\"rowhead\"][text()=\"字幕\"]/following-sibling::td//a[not(@class)]/@href',\n        '//td[@class=\"rowhead\"][text()=\"字幕\"]/following-sibling::td//a/@href',\n        '//div[contains(@class, \"font-bold\")][text()=\"字幕\"]/following-sibling::div[1]//a[not(@class)]/@href', # 憨憨\n    ]\n\n    def init_module(self) -> None:\n        pass\n\n    @staticmethod\n    def get_name() -> str:\n        return \"站点字幕\"\n\n    @staticmethod\n    def get_type() -> ModuleType:\n        \"\"\"\n        获取模块类型\n        \"\"\"\n        return ModuleType.Other\n\n    @staticmethod\n    def get_subtype() -> OtherModulesType:\n        \"\"\"\n        获取模块子类型\n        \"\"\"\n        return OtherModulesType.Subtitle\n\n    @staticmethod\n    def get_priority() -> int:\n        \"\"\"\n        获取模块优先级，数字越小优先级越高，只有同一接口下优先级才生效\n        \"\"\"\n        return 0\n\n    def init_setting(self) -> Tuple[str, Union[str, bool]]:\n        pass\n\n    def stop(self) -> None:\n        pass\n\n    def test(self):\n        pass\n\n    def _get_subtitle_links(self, torrent: TorrentInfo):\n        \"\"\"\n        获取字幕链接\n        \"\"\"\n        # API请求方式的站点需要特殊处理\n        if torrent.site is not None:\n            site = SiteOper().get(torrent.site)\n            if indexer := SitesHelper().get_indexer(site.domain):\n                if indexer.get(\"parser\") == \"mTorrent\":\n                    return MTorrentSpider(indexer).get_subtitle_links(\n                        torrent.page_url\n                    )\n                # TODO 其它采用API访问的站点\n        # 普通站点通过解析网站代码的方式获取\n        request = RequestUtils(\n            cookies=torrent.site_cookie,\n            ua=torrent.site_ua,\n            proxies=settings.PROXY if torrent.site_proxy else None,\n        )\n        res = request.get_res(torrent.page_url)\n        if res and res.status_code == 200:\n            if not res.text:\n                logger.warn(f\"读取页面代码失败：{torrent.page_url}\")\n                return []\n            html = etree.HTML(res.text)\n            try:\n                sublink_list = []\n                for xpath in self._SITE_SUBTITLE_XPATH:\n                    sublinks = html.xpath(xpath)\n                    if sublinks:\n                        for sublink in sublinks:\n                            if not sublink:\n                                continue\n                            if not sublink.startswith(\"http\"):\n                                base_url = StringUtils.get_base_url(torrent.page_url)\n                                if sublink.startswith(\"/\"):\n                                    sublink = \"%s%s\" % (base_url, sublink)\n                                else:\n                                    sublink = \"%s/%s\" % (base_url, sublink)\n                            sublink_list.append(sublink)\n                        # 已成功获取了链接，后续xpath可以忽略\n                        break\n                return sublink_list\n            finally:\n                if html is not None:\n                    del html\n        elif res is not None:\n            logger.warn(f\"连接 {torrent.page_url} 失败，状态码：{res.status_code}\")\n        else:\n            logger.warn(f\"无法打开链接：{torrent.page_url}\")\n        return None\n\n    def download_added(self, context: Context, download_dir: Path, torrent_content: Union[str, bytes] = None):\n        \"\"\"\n        添加下载任务成功后，从站点下载字幕，保存到下载目录\n        :param context:  上下文，包括识别信息、媒体信息、种子信息\n        :param download_dir:  下载目录\n        :param torrent_content: 种子内容，如果是种子文件，则为文件内容，否则为种子字符串\n        :return: None，该方法可被多个模块同时处理\n        \"\"\"\n        if not settings.DOWNLOAD_SUBTITLE:\n            return\n\n        # 没有种子文件不处理\n        if not torrent_content:\n            return\n\n        # 没有详情页不处理\n        torrent = context.torrent_info\n        if not torrent.page_url:\n            return\n        # 字幕下载目录\n        logger.info(\"开始从站点下载字幕：%s\" % torrent.page_url)\n        # 获取种子信息\n        folder_name, _ = TorrentHelper().get_fileinfo_from_torrent_content(torrent_content)\n        # 文件保存目录，如果是单文件种子，则folder_name是空，此时文件保存目录就是下载目录\n        storageChain = StorageChain()\n        # 等待目录存在\n        working_dir_item = None\n        # split download_dir into storage and path\n        fileURI = FileURI.from_uri(download_dir.as_posix())\n        storage = fileURI.storage\n        download_dir = Path(fileURI.path)\n        for _ in range(30):\n            found = storageChain.get_file_item(storage,  download_dir / folder_name)\n            if found:\n                working_dir_item = found\n                break\n            time.sleep(1)\n        # 目录仍然不存在，且有文件夹名，则创建目录\n        if not working_dir_item and folder_name:\n            parent_dir_item = storageChain.get_file_item(storage, download_dir)\n            if parent_dir_item:\n                working_dir_item = storageChain.create_folder(\n                    parent_dir_item,\n                    folder_name\n                )\n            else:\n                logger.error(f\"下载根目录不存在，无法创建字幕文件夹：{download_dir}\")\n                return\n        if not working_dir_item:\n            logger.error(f\"下载目录不存在，无法保存字幕：{download_dir / folder_name}\")\n            return\n        # 读取网站代码\n        sublink_list = self._get_subtitle_links(torrent)\n        if not sublink_list:\n            logger.warn(f\"{torrent.page_url} 页面未找到字幕下载链接\")\n            return\n        # 下载所有字幕文件\n        request = RequestUtils(\n            cookies=torrent.site_cookie,\n            ua=torrent.site_ua,\n            proxies=settings.PROXY if torrent.site_proxy else None,\n        )\n        for sublink in sublink_list:\n            logger.info(f\"找到字幕下载链接：{sublink}，开始下载...\")\n            # 下载\n            ret = request.get_res(sublink)\n            if ret and ret.status_code == 200:\n                # 保存ZIP\n                file_name = TorrentHelper.get_url_filename(ret, sublink)\n                if not file_name:\n                    logger.warn(f\"链接不是字幕文件：{sublink}\")\n                    continue\n                if file_name.lower().endswith(\".zip\"):\n                    # ZIP包\n                    zip_file = settings.TEMP_PATH / file_name\n                    # 保存\n                    zip_file.write_bytes(ret.content)\n                    # 解压路径\n                    zip_path = zip_file.with_name(zip_file.stem)\n                    # 解压文件\n                    shutil.unpack_archive(zip_file, zip_path, format='zip')\n                    # 遍历转移文件\n                    for sub_file in SystemUtils.list_files(zip_path, settings.RMT_SUBEXT):\n                        target_sub_file = Path(working_dir_item.path) / Path(sub_file.name)\n                        if storageChain.get_file_item(storage, target_sub_file):\n                            logger.info(f\"字幕文件已存在：{target_sub_file}\")\n                            continue\n                        logger.info(f\"转移字幕 {sub_file} 到 {target_sub_file} ...\")\n                        storageChain.upload_file(working_dir_item, sub_file)\n                    # 删除临时文件\n                    try:\n                        shutil.rmtree(zip_path)\n                        zip_file.unlink()\n                    except Exception as err:\n                        logger.error(f\"删除临时文件失败：{str(err)}\")\n                else:\n                    sub_file = settings.TEMP_PATH / file_name\n                    # 保存\n                    sub_file.write_bytes(ret.content)\n                    target_sub_file = Path(working_dir_item.path) / Path(sub_file.name)\n                    if storageChain.get_file_item(storage, target_sub_file):\n                        logger.info(f\"字幕文件已存在：{target_sub_file}\")\n                        continue\n                    logger.info(f\"转移字幕 {sub_file} 到 {target_sub_file} ...\")\n                    storageChain.upload_file(working_dir_item, sub_file)\n            else:\n                logger.error(f\"下载字幕文件失败：{sublink}\")\n                continue\n        logger.info(f\"{torrent.page_url} 页面字幕下载完成\")\n"
  },
  {
    "path": "app/modules/synologychat/__init__.py",
    "content": "from typing import Optional, Union, List, Tuple, Any\n\nfrom app.core.context import MediaInfo, Context\nfrom app.log import logger\nfrom app.modules import _ModuleBase, _MessageBase\nfrom app.modules.synologychat.synologychat import SynologyChat\nfrom app.schemas import MessageChannel, CommingMessage, Notification\nfrom app.schemas.types import ModuleType\n\n\nclass SynologyChatModule(_ModuleBase, _MessageBase[SynologyChat]):\n\n    def init_module(self) -> None:\n        \"\"\"\n        初始化模块\n        \"\"\"\n        super().init_service(service_name=SynologyChat.__name__.lower(),\n                             service_type=SynologyChat)\n        self._channel = MessageChannel.SynologyChat\n\n    @staticmethod\n    def get_name() -> str:\n        return \"Synology Chat\"\n\n    @staticmethod\n    def get_type() -> ModuleType:\n        \"\"\"\n        获取模块类型\n        \"\"\"\n        return ModuleType.Notification\n\n    @staticmethod\n    def get_subtype() -> MessageChannel:\n        \"\"\"\n        获取模块子类型\n        \"\"\"\n        return MessageChannel.SynologyChat\n\n    @staticmethod\n    def get_priority() -> int:\n        \"\"\"\n        获取模块优先级，数字越小优先级越高，只有同一接口下优先级才生效\n        \"\"\"\n        return 5\n\n    def stop(self):\n        pass\n\n    def test(self) -> Optional[Tuple[bool, str]]:\n        \"\"\"\n        测试模块连接性\n        \"\"\"\n        if not self.get_instances():\n            return None\n        for name, client in self.get_instances().items():\n            state = client.get_state()\n            if not state:\n                return False, f\"Synology Chat {name} 未就绪\"\n        return True, \"\"\n\n    def init_setting(self) -> Tuple[str, Union[str, bool]]:\n        pass\n\n    def message_parser(self, source: str, body: Any, form: Any,\n                       args: Any) -> Optional[CommingMessage]:\n        \"\"\"\n        解析消息内容，返回字典，注意以下约定值：\n        userid: 用户ID\n        username: 用户名\n        text: 内容\n        :param source: 消息来源\n        :param body: 请求体\n        :param form: 表单\n        :param args: 参数\n        :return: 渠道、消息体\n        \"\"\"\n        try:\n            # 获取服务配置\n            client_config = self.get_config(source)\n            if not client_config:\n                return None\n            client: SynologyChat = self.get_instance(client_config.name)\n            if not client:\n                return None\n            # 解析消息\n            message: dict = form\n            if not message:\n                return None\n            # 校验token\n            token = message.get(\"token\")\n            if not token or not client.check_token(token):\n                return None\n            # 文本\n            text = message.get(\"text\")\n            # 用户ID\n            user_id = int(message.get(\"user_id\"))\n            # 获取用户名\n            user_name = message.get(\"username\")\n            if text and user_id:\n                logger.info(f\"收到来自 {client_config.name} 的SynologyChat消息：\"\n                            f\"userid={user_id}, username={user_name}, text={text}\")\n                return CommingMessage(channel=MessageChannel.SynologyChat, source=client_config.name,\n                                      userid=user_id, username=user_name, text=text)\n        except Exception as err:\n            logger.debug(f\"解析SynologyChat消息失败：{str(err)}\")\n        return None\n\n    def post_message(self, message: Notification, **kwargs) -> None:\n        \"\"\"\n        发送消息\n        :param message: 消息体\n        :return: 成功或失败\n        \"\"\"\n        for conf in self.get_configs().values():\n            if not self.check_message(message, conf.name):\n                continue\n            targets = message.targets\n            userid = message.userid\n            if not userid and targets is not None:\n                userid = targets.get('synologychat_userid')\n                if not userid:\n                    logger.warn(f\"用户没有指定 SynologyChat用户ID，消息无法发送\")\n                    return\n            client: SynologyChat = self.get_instance(conf.name)\n            if client:\n                client.send_msg(title=message.title, text=message.text,\n                                image=message.image, userid=userid, link=message.link)\n\n    def post_medias_message(self, message: Notification, medias: List[MediaInfo]) -> None:\n        \"\"\"\n        发送媒体信息选择列表\n        :param message: 消息体\n        :param medias: 媒体列表\n        :return: 成功或失败\n        \"\"\"\n        for conf in self.get_configs().values():\n            if not self.check_message(message, conf.name):\n                continue\n            client: SynologyChat = self.get_instance(conf.name)\n            if client:\n                client.send_medias_msg(title=message.title, medias=medias,\n                                       userid=message.userid)\n\n    def post_torrents_message(self, message: Notification, torrents: List[Context]) -> None:\n        \"\"\"\n        发送种子信息选择列表\n        :param message: 消息体\n        :param torrents: 种子列表\n        :return: 成功或失败\n        \"\"\"\n        for conf in self.get_configs().values():\n            if not self.check_message(message, conf.name):\n                continue\n            client: SynologyChat = self.get_instance(conf.name)\n            if client:\n                client.send_torrents_msg(title=message.title, torrents=torrents,\n                                         userid=message.userid, link=message.link)\n"
  },
  {
    "path": "app/modules/synologychat/synologychat.py",
    "content": "import json\nimport re\nfrom threading import Lock\nfrom typing import Optional, List\nfrom urllib.parse import quote\n\nfrom app.core.context import MediaInfo, Context\nfrom app.core.metainfo import MetaInfo\nfrom app.log import logger\nfrom app.utils.http import RequestUtils\nfrom app.utils.string import StringUtils\n\nlock = Lock()\n\n\nclass SynologyChat:\n    def __init__(self, SYNOLOGYCHAT_WEBHOOK: Optional[str] = None, SYNOLOGYCHAT_TOKEN: Optional[str] = None, **kwargs):\n        if not SYNOLOGYCHAT_WEBHOOK or not SYNOLOGYCHAT_TOKEN:\n            logger.error(\"SynologyChat配置不完整！\")\n            return\n        self._req = RequestUtils(content_type=\"application/x-www-form-urlencoded\")\n        self._webhook_url = SYNOLOGYCHAT_WEBHOOK\n        self._token = SYNOLOGYCHAT_TOKEN\n        if self._webhook_url:\n            self._domain = StringUtils.get_base_url(self._webhook_url)\n\n    def check_token(self, token: str) -> bool:\n        return True if token == self._token else False\n\n    def get_state(self) -> bool:\n        \"\"\"\n        获取状态\n        \"\"\"\n        if not self._webhook_url or not self._token:\n            return False\n        ret = self.__get_bot_users()\n        if ret:\n            return True\n        return False\n\n    def send_msg(self, title: str, text: Optional[str] = None, image: Optional[str] = None,\n                 userid: Optional[str] = None, link: Optional[str] = None) -> Optional[bool]:\n        \"\"\"\n        发送SynologyChat消息\n        :param title: 消息标题\n        :param text: 消息内容\n        :param image: 消息图片地址\n        :param userid: 用户ID，如有则只发消息给该用户\n        :user_id: 发送消息的目标用户ID，为空则发给管理员\n        :param link: 链接地址\n        \"\"\"\n        if not title and not text:\n            logger.error(\"标题和内容不能同时为空\")\n            return False\n        if not self._webhook_url or not self._token:\n            return False\n        try:\n            # 拼装消息内容\n            titles = str(title).split('\\n')\n            if len(titles) > 1:\n                title = titles[0]\n                if not text:\n                    text = \"\\n\".join(titles[1:])\n                else:\n                    text = f\"%s\\n%s\" % (\"\\n\".join(titles[1:]), text)\n\n            if text:\n                caption = \"*%s*\\n%s\" % (title, text.replace(\"\\n\\n\", \"\\n\"))\n            else:\n                caption = title\n\n            if link:\n                caption = f\"{caption}\\n[查看详情]({link})\"\n\n            payload_data = {'text': quote(caption)}\n            if image:\n                payload_data['file_url'] = quote(image)\n            if userid:\n                payload_data['user_ids'] = [int(userid)]\n            else:\n                userids = self.__get_bot_users()\n                if not userids:\n                    logger.error(\"SynologyChat机器人没有对任何用户可见\")\n                    return False\n                payload_data['user_ids'] = userids\n\n            return self.__send_request(payload_data)\n\n        except Exception as msg_e:\n            logger.error(f\"SynologyChat发送消息错误：{str(msg_e)}\")\n            return False\n\n    def send_medias_msg(self, medias: List[MediaInfo], userid: Optional[str] = None, title: Optional[str] = None) -> Optional[bool]:\n        \"\"\"\n        发送列表类消息\n        \"\"\"\n        if not medias:\n            return False\n        if not self._webhook_url or not self._token:\n            return False\n        try:\n            if not title or not isinstance(medias, list):\n                return False\n            index, image, caption = 1, \"\", \"*%s*\" % title\n            for media in medias:\n                if not image:\n                    image = media.get_message_image()\n                if media.vote_average:\n                    caption = \"%s\\n%s. <%s|%s>\\n_%s，%s_\" % (caption,\n                                                            index,\n                                                            media.detail_link,\n                                                            media.title_year,\n                                                            f\"类型：{media.type.value}\",\n                                                            f\"评分：{media.vote_average}\")\n                else:\n                    caption = \"%s\\n%s. <%s|%s>\\n_%s_\" % (caption,\n                                                         index,\n                                                         media.detail_link,\n                                                         media.title_year,\n                                                         f\"类型：{media.type.value}\")\n                index += 1\n\n            if userid:\n                userids = [int(userid)]\n            else:\n                userids = self.__get_bot_users()\n            payload_data = {\n                \"text\": quote(caption),\n                \"user_ids\": userids\n            }\n            return self.__send_request(payload_data)\n\n        except Exception as msg_e:\n            logger.error(f\"SynologyChat发送消息错误：{str(msg_e)}\")\n            return False\n\n    def send_torrents_msg(self, torrents: List[Context],\n                          userid: Optional[str] = None, title: Optional[str] = None, link: Optional[str] = None) -> Optional[bool]:\n        \"\"\"\n        发送列表消息\n        \"\"\"\n        if not self._webhook_url or not self._token:\n            return None\n\n        if not torrents:\n            return False\n\n        try:\n            index, caption = 1, \"*%s*\" % title\n            for context in torrents:\n                torrent = context.torrent_info\n                site_name = torrent.site_name\n                meta = MetaInfo(torrent.title, torrent.description)\n                link = torrent.page_url\n                title = f\"{meta.season_episode} \" \\\n                        f\"{meta.resource_term} \" \\\n                        f\"{meta.video_term} \" \\\n                        f\"{meta.release_group}\"\n                title = re.sub(r\"\\s+\", \" \", title).strip()\n                free = torrent.volume_factor\n                seeder = f\"{torrent.seeders}↑\"\n                description = torrent.description\n                caption = f\"{caption}\\n{index}.【{site_name}】<{link}|{title}> \" \\\n                          f\"{StringUtils.str_filesize(torrent.size)} {free} {seeder}\\n\" \\\n                          f\"_{description}_\"\n                index += 1\n\n            if link:\n                caption = f\"{caption}\\n[查看详情]({link})\"\n\n            if userid:\n                userids = [int(userid)]\n            else:\n                userids = self.__get_bot_users()\n\n            payload_data = {\n                \"text\": quote(caption),\n                \"user_ids\": userids\n            }\n            return self.__send_request(payload_data)\n        except Exception as msg_e:\n            logger.error(f\"SynologyChat发送消息错误：{str(msg_e)}\")\n            return False\n\n    def __get_bot_users(self):\n        \"\"\"\n        查询机器人可见的用户列表\n        \"\"\"\n        if not self._domain or not self._token:\n            return []\n        req_url = f\"{self._domain}\" \\\n                  f\"/webapi/entry.cgi?api=SYNO.Chat.External&method=user_list&version=2&token=\" \\\n                  f\"{self._token}\"\n        ret = self._req.get_res(url=req_url)\n        if ret and ret.status_code == 200:\n            users = ret.json().get(\"data\", {}).get(\"users\", []) or []\n            return [user.get(\"user_id\") for user in users if user.get(\"deleted\", True) is False]\n        else:\n            return []\n\n    def __send_request(self, payload_data):\n        \"\"\"\n        发送消息请求\n        \"\"\"\n        payload = f\"payload={json.dumps(payload_data)}\"\n        ret = self._req.post_res(url=self._webhook_url, data=payload)\n        if ret and ret.status_code == 200:\n            result = ret.json()\n            if result:\n                errno = result.get('error', {}).get('code')\n                errmsg = result.get('error', {}).get('errors')\n                if not errno:\n                    return True\n                logger.error(f\"SynologyChat返回错误：{errno}-{errmsg}\")\n                return False\n            else:\n                logger.error(f\"SynologyChat返回：{ret.text}\")\n                return False\n        elif ret is not None:\n            logger.error(f\"SynologyChat请求失败，错误码：{ret.status_code}，错误原因：{ret.reason}\")\n            return False\n        else:\n            logger.error(f\"SynologyChat请求失败，未获取到返回信息\")\n            return False\n"
  },
  {
    "path": "app/modules/telegram/__init__.py",
    "content": "import copy\nimport json\nimport re\nfrom typing import Dict, Optional, Union, List, Tuple, Any\n\nfrom app.core.context import MediaInfo, Context\nfrom app.core.event import eventmanager\nfrom app.log import logger\nfrom app.modules import _ModuleBase, _MessageBase\nfrom app.modules.telegram.telegram import Telegram\nfrom app.schemas import MessageChannel, CommingMessage, Notification, CommandRegisterEventData, \\\n    NotificationConf\nfrom app.schemas.types import ModuleType, ChainEventType\nfrom app.utils.structures import DictUtils\n\n\nclass TelegramModule(_ModuleBase, _MessageBase[Telegram]):\n\n    def init_module(self) -> None:\n        \"\"\"\n        初始化模块\n        \"\"\"\n        super().init_service(service_name=Telegram.__name__.lower(),\n                             service_type=Telegram)\n        self._channel = MessageChannel.Telegram\n\n    @staticmethod\n    def get_name() -> str:\n        return \"Telegram\"\n\n    @staticmethod\n    def get_type() -> ModuleType:\n        \"\"\"\n        获取模块类型\n        \"\"\"\n        return ModuleType.Notification\n\n    @staticmethod\n    def get_subtype() -> MessageChannel:\n        \"\"\"\n        获取模块子类型\n        \"\"\"\n        return MessageChannel.Telegram\n\n    @staticmethod\n    def get_priority() -> int:\n        \"\"\"\n        获取模块优先级，数字越小优先级越高，只有同一接口下优先级才生效\n        \"\"\"\n        return 0\n\n    def stop(self):\n        \"\"\"\n        停止模块\n        \"\"\"\n        for client in self.get_instances().values():\n            client.stop()\n\n    def test(self) -> Optional[Tuple[bool, str]]:\n        \"\"\"\n        测试模块连接性\n        \"\"\"\n        if not self.get_instances():\n            return None\n        for name, client in self.get_instances().items():\n            state = client.get_state()\n            if not state:\n                return False, f\"Telegram {name} 未就绪\"\n        return True, \"\"\n\n    def init_setting(self) -> Tuple[str, Union[str, bool]]:\n        pass\n\n    def message_parser(self, source: str, body: Any, form: Any,\n                       args: Any) -> Optional[CommingMessage]:\n        \"\"\"\n        解析消息内容，返回字典，注意以下约定值：\n        userid: 用户ID\n        username: 用户名\n        text: 内容\n        :param source: 消息来源\n        :param body: 请求体\n        :param form: 表单\n        :param args: 参数\n        :return: 渠道、消息体\n        \"\"\"\n        \"\"\"\n            普通消息格式：\n            {\n                'update_id': ,\n                'message': {\n                    'message_id': ,\n                    'from': {\n                        'id': ,\n                        'is_bot': False,\n                        'first_name': '',\n                        'username': '',\n                        'language_code': 'zh-hans'\n                    },\n                    'chat': {\n                        'id': ,\n                        'first_name': '',\n                        'username': '',\n                        'type': 'private'\n                    },\n                    'date': ,\n                    'text': ''\n                }\n            }\n\n            按钮回调格式：\n            {\n                'callback_query': {\n                    'id': '',\n                    'from': {...},\n                    'message': {...},\n                    'data': 'callback_data'\n                }\n            }\n        \"\"\"\n        # 获取服务配置\n        client_config = self.get_config(source)\n        if not client_config:\n            return None\n        client: Telegram = self.get_instance(client_config.name)\n        try:\n            message: dict = json.loads(body)\n        except Exception as err:\n            logger.debug(f\"解析Telegram消息失败：{str(err)}\")\n            return None\n\n        if message:\n            # 处理按钮回调\n            if \"callback_query\" in message:\n                return self._handle_callback_query(message, client_config)\n\n            # 处理普通消息\n            return self._handle_text_message(message, client_config, client)\n\n        return None\n\n    @staticmethod\n    def _handle_callback_query(message: dict, client_config: NotificationConf) -> Optional[CommingMessage]:\n        \"\"\"\n        处理按钮回调查询\n        \"\"\"\n        callback_query = message.get(\"callback_query\", {})\n        user_info = callback_query.get(\"from\", {})\n        callback_data = callback_query.get(\"data\", \"\")\n        user_id = user_info.get(\"id\")\n        user_name = user_info.get(\"username\")\n\n        if callback_data and user_id:\n            logger.info(f\"收到来自 {client_config.name} 的Telegram按钮回调：\"\n                        f\"userid={user_id}, username={user_name}, callback_data={callback_data}\")\n\n            # 将callback_data作为特殊格式的text返回，以便主程序识别这是按钮回调\n            callback_text = f\"CALLBACK:{callback_data}\"\n\n            # 创建包含完整回调信息的CommingMessage\n            return CommingMessage(\n                channel=MessageChannel.Telegram,\n                source=client_config.name,\n                userid=user_id,\n                username=user_name,\n                text=callback_text,\n                is_callback=True,\n                callback_data=callback_data,\n                message_id=callback_query.get(\"message\", {}).get(\"message_id\"),\n                chat_id=str(callback_query.get(\"message\", {}).get(\"chat\", {}).get(\"id\", \"\")),\n                callback_query=callback_query\n            )\n        return None\n\n    def _handle_text_message(self, msg: dict,\n                             client_config: NotificationConf, client: Telegram) -> Optional[CommingMessage]:\n        \"\"\"\n        处理普通文本消息\n        \"\"\"\n        text = msg.get(\"text\")\n        user_id = msg.get(\"from\", {}).get(\"id\")\n        user_name = msg.get(\"from\", {}).get(\"username\")\n        # Extract chat_id to enable correct reply targeting\n        chat_id = msg.get(\"chat\", {}).get(\"id\")\n\n        if text and user_id:\n            logger.info(f\"收到来自 {client_config.name} 的Telegram消息：\"\n                        f\"userid={user_id}, username={user_name}, chat_id={chat_id}, text={text}\")\n\n            # Clean bot mentions from text to ensure consistent processing\n            cleaned_text = self._clean_bot_mention(text, client.bot_username if client else None)\n\n            # 检查权限\n            admin_users = client_config.config.get(\"TELEGRAM_ADMINS\")\n            user_list = client_config.config.get(\"TELEGRAM_USERS\")\n            config_chat_id = client_config.config.get(\"TELEGRAM_CHAT_ID\")\n\n            if cleaned_text.startswith(\"/\"):\n                if admin_users \\\n                        and str(user_id) not in admin_users.split(',') \\\n                        and str(user_id) != config_chat_id:\n                    client.send_msg(title=\"只有管理员才有权限执行此命令\", userid=user_id)\n                    return None\n            else:\n                if user_list \\\n                        and str(user_id) not in user_list.split(','):\n                    logger.info(f\"用户{user_id}不在用户白名单中，无法使用此机器人\")\n                    client.send_msg(title=\"你不在用户白名单中，无法使用此机器人\", userid=user_id)\n                    return None\n\n            return CommingMessage(\n                channel=MessageChannel.Telegram,\n                source=client_config.name,\n                userid=user_id,\n                username=user_name,\n                text=cleaned_text,  # Use cleaned text\n                chat_id=str(chat_id) if chat_id else None\n            )\n        return None\n\n    @staticmethod\n    def _clean_bot_mention(text: str, bot_username: Optional[str]) -> str:\n        \"\"\"\n        清理消息中的@bot部分，确保文本处理一致性\n        :param text: 原始消息文本\n        :param bot_username: bot用户名\n        :return: 清理后的文本\n        \"\"\"\n        if not text or not bot_username:\n            return text\n\n        # Remove @bot_username from the beginning and any position in text\n        cleaned = text\n        mention_pattern = f\"@{bot_username}\"\n\n        # Remove mention at the beginning with optional following space\n        if cleaned.startswith(mention_pattern):\n            cleaned = cleaned[len(mention_pattern):].lstrip()\n\n        # Remove mention at any other position\n        cleaned = cleaned.replace(mention_pattern, \"\").strip()\n\n        # Clean up multiple spaces\n        cleaned = re.sub(r'\\s+', ' ', cleaned).strip()\n\n        return cleaned\n\n    def post_message(self, message: Notification, **kwargs) -> None:\n        \"\"\"\n        发送消息\n        :param message: 消息体\n        :return: 成功或失败\n        \"\"\"\n        for conf in self.get_configs().values():\n            if not self.check_message(message, conf.name):\n                continue\n            targets = message.targets\n            userid = message.userid\n            if not userid and targets is not None:\n                userid = targets.get('telegram_userid')\n                if not userid:\n                    logger.warn(f\"用户没有指定 Telegram用户ID，消息无法发送\")\n                    return\n            client: Telegram = self.get_instance(conf.name)\n            if client:\n                client.send_msg(title=message.title, text=message.text,\n                                image=message.image, userid=userid, link=message.link,\n                                buttons=message.buttons,\n                                original_message_id=message.original_message_id,\n                                original_chat_id=message.original_chat_id)\n\n    def post_medias_message(self, message: Notification, medias: List[MediaInfo]) -> None:\n        \"\"\"\n        发送媒体信息选择列表\n        :param message: 消息体\n        :param medias: 媒体列表\n        :return: 成功或失败\n        \"\"\"\n        for conf in self.get_configs().values():\n            if not self.check_message(message, conf.name):\n                continue\n            client: Telegram = self.get_instance(conf.name)\n            if client:\n                client.send_medias_msg(title=message.title, medias=medias,\n                                       userid=message.userid, link=message.link,\n                                       buttons=message.buttons,\n                                       original_message_id=message.original_message_id,\n                                       original_chat_id=message.original_chat_id)\n\n    def post_torrents_message(self, message: Notification, torrents: List[Context]) -> None:\n        \"\"\"\n        发送种子信息选择列表\n        :param message: 消息体\n        :param torrents: 种子列表\n        :return: 成功或失败\n        \"\"\"\n        for conf in self.get_configs().values():\n            if not self.check_message(message, conf.name):\n                continue\n            client: Telegram = self.get_instance(conf.name)\n            if client:\n                client.send_torrents_msg(title=message.title, torrents=torrents,\n                                         userid=message.userid, link=message.link,\n                                         buttons=message.buttons,\n                                         original_message_id=message.original_message_id,\n                                         original_chat_id=message.original_chat_id)\n\n    def delete_message(self, channel: MessageChannel, source: str,\n                       message_id: int, chat_id: Optional[int] = None) -> bool:\n        \"\"\"\n        删除消息\n        :param channel: 消息渠道\n        :param source: 指定的消息源\n        :param message_id: 消息ID\n        :param chat_id: 聊天ID\n        :return: 删除是否成功\n        \"\"\"\n        success = False\n        for conf in self.get_configs().values():\n            if channel != self._channel:\n                break\n            if source != conf.name:\n                continue\n            client: Telegram = self.get_instance(conf.name)\n            if client:\n                result = client.delete_msg(message_id=message_id, chat_id=chat_id)\n                if result:\n                    success = True\n        return success\n\n    def register_commands(self, commands: Dict[str, dict]):\n        \"\"\"\n        注册命令，实现这个函数接收系统可用的命令菜单\n        :param commands: 命令字典\n        \"\"\"\n        for client_config in self.get_configs().values():\n            client = self.get_instance(client_config.name)\n            if not client:\n                continue\n\n            # 触发事件，允许调整命令数据，这里需要进行深复制，避免实例共享\n            scoped_commands = copy.deepcopy(commands)\n            event = eventmanager.send_event(\n                ChainEventType.CommandRegister,\n                CommandRegisterEventData(commands=scoped_commands, origin=\"Telegram\", service=client_config.name)\n            )\n\n            # 如果事件返回有效的 event_data，使用事件中调整后的命令\n            if event and event.event_data:\n                event_data: CommandRegisterEventData = event.event_data\n                # 如果事件被取消，跳过命令注册，并清理菜单\n                if event_data.cancel:\n                    client.delete_commands()\n                    logger.debug(\n                        f\"Command registration for {client_config.name} canceled by event: {event_data.source}\"\n                    )\n                    continue\n                scoped_commands = event_data.commands or {}\n                if not scoped_commands:\n                    logger.debug(\"Filtered commands are empty, skipping registration.\")\n                    client.delete_commands()\n\n            # scoped_commands 必须是 commands 的子集\n            filtered_scoped_commands = DictUtils.filter_keys_to_subset(scoped_commands, commands)\n            # 如果 filtered_scoped_commands 为空，则跳过注册\n            if not filtered_scoped_commands:\n                logger.debug(\"Filtered commands are empty, skipping registration.\")\n                client.delete_commands()\n                continue\n            # 对比调整后的命令与当前命令\n            if filtered_scoped_commands != commands:\n                logger.debug(f\"Command set has changed, Updating new commands: {filtered_scoped_commands}\")\n            client.register_commands(filtered_scoped_commands)\n"
  },
  {
    "path": "app/modules/telegram/telegram.py",
    "content": "import asyncio\nimport re\nimport threading\nfrom typing import Optional, List, Dict, Callable\nfrom urllib.parse import urljoin, quote\n\nfrom telebot import TeleBot, apihelper\nfrom telebot.types import BotCommand, InlineKeyboardMarkup, InlineKeyboardButton, InputMediaPhoto\nfrom telegramify_markdown import standardize, telegramify\nfrom telegramify_markdown.type import ContentTypes, SentType\n\nfrom app.core.config import settings\nfrom app.core.context import MediaInfo, Context\nfrom app.core.metainfo import MetaInfo\nfrom app.helper.thread import ThreadHelper\nfrom app.helper.image import ImageHelper\nfrom app.log import logger\nfrom app.utils.common import retry\nfrom app.utils.http import RequestUtils\nfrom app.utils.string import StringUtils\n\n\nclass RetryException(Exception):\n    pass\n\n\nclass Telegram:\n    _ds_url = f\"http://127.0.0.1:{settings.PORT}/api/v1/message?token={settings.API_TOKEN}\"\n    _bot: TeleBot = None\n    _callback_handlers: Dict[str, Callable] = {}  # 存储回调处理器\n    _user_chat_mapping: Dict[str, str] = {}  # userid -> chat_id mapping for reply targeting\n    _bot_username: Optional[str] = None  # Bot username for mention detection\n\n    def __init__(self, TELEGRAM_TOKEN: Optional[str] = None, TELEGRAM_CHAT_ID: Optional[str] = None, **kwargs):\n        \"\"\"\n        初始化参数\n        \"\"\"\n        if not TELEGRAM_TOKEN or not TELEGRAM_CHAT_ID:\n            logger.error(\"Telegram配置不完整！\")\n            return\n        # Token\n        self._telegram_token = TELEGRAM_TOKEN\n        # Chat Id\n        self._telegram_chat_id = TELEGRAM_CHAT_ID\n        # 初始化机器人\n        if self._telegram_token and self._telegram_chat_id:\n            # telegram bot api 地址，格式：https://api.telegram.org\n            if kwargs.get(\"API_URL\"):\n                apihelper.API_URL = urljoin(kwargs[\"API_URL\"], '/bot{0}/{1}')\n                apihelper.FILE_URL = urljoin(kwargs[\"API_URL\"], '/file/bot{0}/{1}')\n            else:\n                apihelper.proxy = settings.PROXY\n            # bot\n            _bot = TeleBot(self._telegram_token, parse_mode=\"MarkdownV2\")\n            # 记录句柄\n            self._bot = _bot\n            # 获取并存储bot用户名用于@检测\n            try:\n                bot_info = _bot.get_me()\n                self._bot_username = bot_info.username\n                logger.info(f\"Telegram bot用户名: @{self._bot_username}\")\n            except Exception as e:\n                logger.error(f\"获取bot信息失败: {e}\")\n                self._bot_username = None\n\n            # 标记渠道来源\n            if kwargs.get(\"name\"):\n                # URL encode the source name to handle special characters\n                encoded_name = quote(kwargs.get('name'), safe='')\n                self._ds_url = f\"{self._ds_url}&source={encoded_name}\"\n\n            @_bot.message_handler(commands=['start', 'help'])\n            def send_welcome(message):\n                _bot.reply_to(message, \"温馨提示：直接发送名称或`订阅`+名称，搜索或订阅电影、电视剧\")\n\n            @_bot.message_handler(func=lambda message: True)\n            def echo_all(message):\n                # Update user-chat mapping when receiving messages\n                self._update_user_chat_mapping(message.from_user.id, message.chat.id)\n\n                # Check if we should process this message\n                if self._should_process_message(message):\n                    # 发送正在输入状态\n                    try:\n                        _bot.send_chat_action(message.chat.id, 'typing')\n                    except Exception as e:\n                        logger.error(f\"发送Telegram正在输入状态失败：{e}\")\n                    RequestUtils(timeout=15).post_res(self._ds_url, json=message.json)\n\n            @_bot.callback_query_handler(func=lambda call: True)\n            def callback_query(call):\n                \"\"\"\n                处理按钮点击回调\n                \"\"\"\n                try:\n                    # Update user-chat mapping for callbacks too\n                    self._update_user_chat_mapping(call.from_user.id, call.message.chat.id)\n\n                    # 解析回调数据\n                    callback_data = call.data\n                    user_id = str(call.from_user.id)\n\n                    logger.info(f\"收到按钮回调：{callback_data}，用户：{user_id}\")\n\n                    # 发送回调数据给主程序处理\n                    callback_json = {\n                        \"callback_query\": {\n                            \"id\": call.id,\n                            \"from\": call.from_user.to_dict(),\n                            \"message\": {\n                                \"message_id\": call.message.message_id,\n                                \"chat\": {\n                                    \"id\": call.message.chat.id,\n                                }\n                            },\n                            \"data\": callback_data\n                        }\n                    }\n\n                    # 先确认回调，避免用户看到loading状态\n                    _bot.answer_callback_query(call.id)\n\n                    # 发送正在输入状态\n                    try:\n                        _bot.send_chat_action(call.message.chat.id, 'typing')\n                    except Exception as e:\n                        logger.error(f\"发送Telegram正在输入状态失败：{e}\")\n\n                    # 发送给主程序处理\n                    RequestUtils(timeout=15).post_res(self._ds_url, json=callback_json)\n\n                except Exception as err:\n                    logger.error(f\"处理按钮回调失败：{str(err)}\")\n                    _bot.answer_callback_query(call.id, \"处理失败，请重试\")\n\n            def run_polling():\n                \"\"\"\n                定义线程函数来运行 infinity_polling\n                \"\"\"\n                try:\n                    _bot.infinity_polling(long_polling_timeout=30, logger_level=None)\n                except Exception as err:\n                    logger.error(f\"Telegram消息接收服务异常：{str(err)}\")\n\n            # 启动线程来运行 infinity_polling\n            self._polling_thread = threading.Thread(target=run_polling, daemon=True)\n            self._polling_thread.start()\n            logger.info(\"Telegram消息接收服务启动\")\n\n    @property\n    def bot_username(self) -> Optional[str]:\n        \"\"\"\n        获取Bot用户名\n        :return: Bot用户名或None\n        \"\"\"\n        return self._bot_username\n\n    def _update_user_chat_mapping(self, userid: int, chat_id: int) -> None:\n        \"\"\"\n        更新用户与聊天的映射关系\n        :param userid: 用户ID\n        :param chat_id: 聊天ID\n        \"\"\"\n        if userid and chat_id:\n            self._user_chat_mapping[str(userid)] = str(chat_id)\n\n    def _get_user_chat_id(self, userid: str) -> Optional[str]:\n        \"\"\"\n        获取用户对应的聊天ID\n        :param userid: 用户ID\n        :return: 聊天ID或None\n        \"\"\"\n        return self._user_chat_mapping.get(str(userid)) if userid else None\n\n    def _should_process_message(self, message) -> bool:\n        \"\"\"\n        判断是否应该处理这条消息\n        :param message: Telegram消息对象\n        :return: 是否处理\n        \"\"\"\n        # 私聊消息总是处理\n        if message.chat.type == 'private':\n            logger.debug(f\"处理私聊消息：用户 {message.from_user.id}\")\n            return True\n\n        # 群聊中的命令消息总是处理（以/开头）\n        if message.text and message.text.startswith('/'):\n            logger.debug(f\"处理群聊命令消息：{message.text[:20]}...\")\n            return True\n\n        # 群聊中检查是否@了机器人\n        if message.chat.type in ['group', 'supergroup']:\n            if not self._bot_username:\n                # 如果没有获取到bot用户名，为了安全起见处理所有消息\n                logger.debug(\"未获取到bot用户名，处理所有群聊消息\")\n                return True\n\n            # 检查消息文本中是否包含@bot_username\n            if message.text and f\"@{self._bot_username}\" in message.text:\n                logger.debug(f\"检测到@{self._bot_username}，处理群聊消息\")\n                return True\n\n            # 检查消息实体中是否有提及bot\n            if message.entities:\n                for entity in message.entities:\n                    if entity.type == 'mention':\n                        mention_text = message.text[entity.offset:entity.offset + entity.length]\n                        if mention_text == f\"@{self._bot_username}\":\n                            logger.debug(f\"通过实体检测到@{self._bot_username}，处理群聊消息\")\n                            return True\n\n            # 群聊中没有@机器人，不处理\n            logger.debug(f\"群聊消息未@机器人，跳过处理：{message.text[:30] if message.text else 'No text'}...\")\n            return False\n\n        # 其他类型的聊天默认处理\n        logger.debug(f\"处理其他类型聊天消息：{message.chat.type}\")\n        return True\n\n    def get_state(self) -> bool:\n        \"\"\"\n        获取状态\n        \"\"\"\n        return self._bot is not None\n\n    def send_msg(self, title: str, text: Optional[str] = None, image: Optional[str] = None,\n                 userid: Optional[str] = None, link: Optional[str] = None,\n                 buttons: Optional[List[List[dict]]] = None,\n                 original_message_id: Optional[int] = None,\n                 original_chat_id: Optional[str] = None) -> Optional[bool]:\n        \"\"\"\n        发送Telegram消息\n        :param title: 消息标题\n        :param text: 消息内容\n        :param image: 消息图片地址\n        :param userid: 用户ID，如有则只发消息给该用户\n        :param link: 跳转链接\n        :param buttons: 按钮列表，格式：[[{\"text\": \"按钮文本\", \"callback_data\": \"回调数据\"}]]\n        :param original_message_id: 原消息ID，如果提供则编辑原消息\n        :param original_chat_id: 原消息的聊天ID，编辑消息时需要\n\n        \"\"\"\n        if not self._telegram_token or not self._telegram_chat_id:\n            return None\n\n        if not title and not text:\n            logger.warn(\"标题和内容不能同时为空\")\n            return False\n\n        try:\n            # 标准化标题后再加粗，避免**符号被显示为文本\n            bold_title = (\n                f\"**{standardize(title).removesuffix('\\n')}**\" if title else None\n            )\n            if bold_title and text:\n                caption = f\"{bold_title}\\n{text}\"\n            elif bold_title:\n                caption = bold_title\n            elif text:\n                caption = text\n            else:\n                caption = \"\"\n\n            if link:\n                caption = f\"{caption}\\n[查看详情]({link})\"\n\n            # Determine target chat_id with improved logic using user mapping\n            chat_id = self._determine_target_chat_id(userid, original_chat_id)\n\n            # 创建按钮键盘\n            reply_markup = None\n            if buttons:\n                reply_markup = self._create_inline_keyboard(buttons)\n\n            # 判断是编辑消息还是发送新消息\n            if original_message_id and original_chat_id:\n                # 编辑消息\n                return self.__edit_message(original_chat_id, original_message_id, caption, buttons, image)\n            else:\n                # 发送新消息\n                return self.__send_request(userid=chat_id, image=image, caption=caption, reply_markup=reply_markup)\n\n        except Exception as msg_e:\n            logger.error(f\"发送消息失败：{msg_e}\")\n            return False\n\n    def _determine_target_chat_id(self, userid: Optional[str] = None,\n                                  original_chat_id: Optional[str] = None) -> str:\n        \"\"\"\n        确定目标聊天ID，使用用户映射确保回复到正确的聊天\n        :param userid: 用户ID\n        :param original_chat_id: 原消息的聊天ID\n        :return: 目标聊天ID\n        \"\"\"\n        # 1. 优先使用原消息的聊天ID (编辑消息场景)\n        if original_chat_id:\n            return original_chat_id\n\n        # 2. 如果有userid，尝试从映射中获取用户的聊天ID\n        if userid:\n            mapped_chat_id = self._get_user_chat_id(userid)\n            if mapped_chat_id:\n                return mapped_chat_id\n            # 如果映射中没有，回退到使用userid作为聊天ID (私聊场景)\n            return userid\n\n        # 3. 最后使用默认聊天ID\n        return self._telegram_chat_id\n\n    def send_medias_msg(self, medias: List[MediaInfo], userid: Optional[str] = None,\n                        title: Optional[str] = None, link: Optional[str] = None,\n                        buttons: Optional[List[List[Dict]]] = None,\n                        original_message_id: Optional[int] = None,\n                        original_chat_id: Optional[str] = None) -> Optional[bool]:\n        \"\"\"\n        发送媒体列表消息\n        :param medias: 媒体信息列表\n        :param userid: 用户ID，如有则只发消息给该用户\n        :param title: 消息标题\n        :param link: 跳转链接\n        :param buttons: 按钮列表，格式：[[{\"text\": \"按钮文本\", \"callback_data\": \"回调数据\"}]]\n        :param original_message_id: 原消息ID，如果提供则编辑原消息\n        :param original_chat_id: 原消息的聊天ID，编辑消息时需要\n        \"\"\"\n        if not self._telegram_token or not self._telegram_chat_id:\n            return None\n\n        try:\n            index, image, caption = 1, \"\", \"*%s*\" % title\n            for media in medias:\n                if not image:\n                    image = media.get_message_image()\n                if media.vote_average:\n                    caption = \"%s\\n%s. [%s](%s)\\n_%s，%s_\" % (caption,\n                                                             index,\n                                                             media.title_year,\n                                                             media.detail_link,\n                                                             f\"类型：{media.type.value}\",\n                                                             f\"评分：{media.vote_average}\")\n                else:\n                    caption = \"%s\\n%s. [%s](%s)\\n_%s_\" % (caption,\n                                                          index,\n                                                          media.title_year,\n                                                          media.detail_link,\n                                                          f\"类型：{media.type.value}\")\n                index += 1\n\n            if link:\n                caption = f\"{caption}\\n[查看详情]({link})\"\n\n            # Determine target chat_id with improved logic using user mapping\n            chat_id = self._determine_target_chat_id(userid, original_chat_id)\n\n            # 创建按钮键盘\n            reply_markup = None\n            if buttons:\n                reply_markup = self._create_inline_keyboard(buttons)\n\n            # 判断是编辑消息还是发送新消息\n            if original_message_id and original_chat_id:\n                # 编辑消息\n                return self.__edit_message(original_chat_id, original_message_id, caption, buttons, image)\n            else:\n                # 发送新消息\n                return self.__send_request(userid=chat_id, image=image, caption=caption, reply_markup=reply_markup)\n\n        except Exception as msg_e:\n            logger.error(f\"发送消息失败：{msg_e}\")\n            return False\n\n    def send_torrents_msg(self, torrents: List[Context],\n                          userid: Optional[str] = None, title: Optional[str] = None,\n                          link: Optional[str] = None, buttons: Optional[List[List[Dict]]] = None,\n                          original_message_id: Optional[int] = None,\n                          original_chat_id: Optional[str] = None) -> Optional[bool]:\n        \"\"\"\n        发送种子列表消息\n        :param torrents: 种子信息列表\n        :param userid: 用户ID，如有则只发消息给该用户\n        :param title: 消息标题\n        :param link: 跳转链接\n        :param buttons: 按钮列表，格式：[[{\"text\": \"按钮文本\", \"callback_data\": \"回调数据\"}]]\n        :param original_message_id: 原消息ID，如果提供则编辑原消息\n        :param original_chat_id: 原消息的聊天ID，编辑消息时需要\n        \"\"\"\n        if not self._telegram_token or not self._telegram_chat_id:\n            return None\n\n        try:\n            index, caption = 1, \"*%s*\" % title\n            image = torrents[0].media_info.get_message_image()\n            for context in torrents:\n                torrent = context.torrent_info\n                site_name = torrent.site_name\n                meta = MetaInfo(torrent.title, torrent.description)\n                link = torrent.page_url\n                title = f\"{meta.season_episode} \" \\\n                        f\"{meta.resource_term} \" \\\n                        f\"{meta.video_term} \" \\\n                        f\"{meta.release_group}\"\n                title = re.sub(r\"\\s+\", \" \", title).strip()\n                free = torrent.volume_factor\n                seeder = f\"{torrent.seeders}↑\"\n                caption = f\"{caption}\\n{index}.【{site_name}】[{title}]({link}) \" \\\n                          f\"{StringUtils.str_filesize(torrent.size)} {free} {seeder}\"\n                index += 1\n\n            if link:\n                caption = f\"{caption}\\n[查看详情]({link})\"\n\n            # Determine target chat_id with improved logic using user mapping\n            chat_id = self._determine_target_chat_id(userid, original_chat_id)\n\n            # 创建按钮键盘\n            reply_markup = None\n            if buttons:\n                reply_markup = self._create_inline_keyboard(buttons)\n\n            # 判断是编辑消息还是发送新消息\n            if original_message_id and original_chat_id:\n                # 编辑消息（种子消息通常没有图片）\n                return self.__edit_message(original_chat_id, original_message_id, caption, buttons, image)\n            else:\n                # 发送新消息\n                return self.__send_request(userid=chat_id, image=image, caption=caption, reply_markup=reply_markup)\n\n        except Exception as msg_e:\n            logger.error(f\"发送消息失败：{msg_e}\")\n            return False\n\n    @staticmethod\n    def _create_inline_keyboard(buttons: List[List[Dict]]) -> InlineKeyboardMarkup:\n        \"\"\"\n        创建内联键盘\n        :param buttons: 按钮配置，格式：[[{\"text\": \"按钮文本\", \"callback_data\": \"回调数据\", \"url\": \"链接\"}]]\n        :return: InlineKeyboardMarkup对象\n        \"\"\"\n        keyboard = []\n        for row in buttons:\n            button_row = []\n            for button in row:\n                if \"url\" in button:\n                    # URL按钮\n                    btn = InlineKeyboardButton(text=button[\"text\"], url=button[\"url\"])\n                else:\n                    # 回调按钮\n                    btn = InlineKeyboardButton(text=button[\"text\"], callback_data=button[\"callback_data\"])\n                button_row.append(btn)\n            keyboard.append(button_row)\n        return InlineKeyboardMarkup(keyboard)\n\n    def answer_callback_query(self, callback_query_id: int, text: Optional[str] = None,\n                              show_alert: bool = False) -> Optional[bool]:\n        \"\"\"\n        回应回调查询\n        \"\"\"\n        if not self._bot:\n            return None\n\n        try:\n            self._bot.answer_callback_query(callback_query_id, text=text, show_alert=show_alert)\n            return True\n        except Exception as e:\n            logger.error(f\"回应回调查询失败：{str(e)}\")\n            return False\n\n    def delete_msg(self, message_id: int, chat_id: Optional[int] = None) -> Optional[bool]:\n        \"\"\"\n        删除Telegram消息\n        :param message_id: 消息ID\n        :param chat_id: 聊天ID\n        :return: 删除是否成功\n        \"\"\"\n        if not self._telegram_token or not self._telegram_chat_id:\n            return None\n\n        try:\n            # 确定要删除消息的聊天ID\n            if chat_id:\n                target_chat_id = chat_id\n            else:\n                target_chat_id = self._telegram_chat_id\n\n            # 删除消息\n            result = self._bot.delete_message(chat_id=target_chat_id, message_id=int(message_id))\n            if result:\n                logger.info(f\"成功删除Telegram消息: chat_id={target_chat_id}, message_id={message_id}\")\n                return True\n            else:\n                logger.error(f\"删除Telegram消息失败: chat_id={target_chat_id}, message_id={message_id}\")\n                return False\n        except Exception as e:\n            logger.error(f\"删除Telegram消息异常: {str(e)}\")\n            return False\n\n    def __edit_message(self, chat_id: str, message_id: int, text: str,\n                       buttons: Optional[List[List[dict]]] = None,\n                       image: Optional[str] = None) -> Optional[bool]:\n        \"\"\"\n        编辑已发送的消息\n        :param chat_id: 聊天ID\n        :param message_id: 消息ID\n        :param text: 新的消息内容\n        :param buttons: 按钮列表\n        :param image: 图片URL或路径\n        :return: 编辑是否成功\n        \"\"\"\n        if not self._bot:\n            return None\n\n        try:\n\n            # 创建按钮键盘\n            reply_markup = None\n            if buttons:\n                reply_markup = self._create_inline_keyboard(buttons)\n\n            if image:\n                # 如果有图片，使用edit_message_media\n                media = InputMediaPhoto(media=image, caption=standardize(text), parse_mode=\"MarkdownV2\")\n                self._bot.edit_message_media(\n                    chat_id=chat_id,\n                    message_id=message_id,\n                    media=media,\n                    reply_markup=reply_markup\n                )\n            else:\n                # 如果没有图片，使用edit_message_text\n                self._bot.edit_message_text(\n                    chat_id=chat_id,\n                    message_id=message_id,\n                    text=standardize(text),\n                    parse_mode=\"MarkdownV2\",\n                    reply_markup=reply_markup\n                )\n            return True\n        except Exception as e:\n            logger.error(f\"编辑消息失败：{str(e)}\")\n            return False\n\n    def __send_request(self, userid: Optional[str] = None, image=\"\", caption=\"\",\n                       reply_markup: Optional[InlineKeyboardMarkup] = None) -> bool:\n        \"\"\"\n        向Telegram发送报文\n        :param reply_markup: 内联键盘\n        \"\"\"\n        kwargs = {\n            'chat_id': userid or self._telegram_chat_id,\n            'parse_mode': \"MarkdownV2\",\n            'reply_markup': reply_markup\n        }\n\n        # 处理图片\n        image = self.__process_image(image)\n\n        try:\n            # 图片消息的标题长度限制为1024，文本消息为4096\n            caption_limit = 1024 if image else 4096\n            if len(caption) < caption_limit:\n                ret = self.__send_short_message(image, caption, **kwargs)\n            else:\n                sent_idx = set()\n                ret = self.__send_long_message(image, caption, sent_idx, **kwargs)\n\n            return ret is not None\n        except Exception as e:\n            logger.error(f\"发送Telegram消息失败: {e}\")\n            return False\n\n    @staticmethod\n    def __process_image(image_url: Optional[str]) -> Optional[bytes]:\n        \"\"\"\n        处理图片URL，获取图片内容\n        \"\"\"\n        if not image_url:\n            return None\n        image = ImageHelper().fetch_image(image_url)\n        if not image:\n            logger.warn(f\"图片获取失败: {image_url}，仅发送文本消息\")\n        return image\n\n    @retry(RetryException, logger=logger)\n    def __send_short_message(self, image: Optional[bytes], caption: str, **kwargs):\n        \"\"\"\n        发送短消息\n        \"\"\"\n        try:\n            if image:\n                return self._bot.send_photo(\n                    photo=image,\n                    caption=standardize(caption),\n                    **kwargs\n                )\n            else:\n                return self._bot.send_message(\n                    text=standardize(caption),\n                    **kwargs\n                )\n        except Exception:\n            raise RetryException(f\"发送{'图片' if image else '文本'}消息失败\")\n\n    @retry(RetryException, logger=logger)\n    def __send_long_message(self, image: Optional[bytes], caption: str, sent_idx: set, **kwargs):\n        \"\"\"\n        发送长消息\n        \"\"\"\n        try:\n            reply_markup = kwargs.pop(\"reply_markup\", None)\n\n            boxs: SentType = ThreadHelper().submit(lambda x: asyncio.run(telegramify(x)), caption).result()\n\n            ret = None\n            for i, item in enumerate(boxs):\n                if i in sent_idx:\n                    # 跳过已发送消息\n                    continue\n\n                current_reply_markup = reply_markup if i == 0 else None\n\n                if item.content_type == ContentTypes.TEXT and (i != 0 or not image):\n                    ret = self._bot.send_message(**kwargs,\n                        text=item.content,\n                        reply_markup=current_reply_markup\n                    )\n\n                elif item.content_type == ContentTypes.PHOTO or (image and i == 0):\n                    ret = self._bot.send_photo(**kwargs,\n                        photo=(getattr(item, \"file_name\", \"\"),\n                            getattr(item, \"file_data\", image)),\n                        caption=getattr(item, \"caption\", item.content),\n                        reply_markup=current_reply_markup\n                    )\n\n                elif item.content_type == ContentTypes.FILE:\n                    ret = self._bot.send_document(**kwargs,\n                        document=(item.file_name, item.file_data),\n                        caption=item.caption,\n                        reply_markup=current_reply_markup\n                    )\n\n                sent_idx.add(i)\n\n            return ret\n        except Exception as e:\n            try:\n                raise RetryException(f\"消息 [{i + 1}/{len(boxs)}] 发送失败\") from e\n            except NameError:\n                raise\n\n    def register_commands(self, commands: Dict[str, dict]):\n        \"\"\"\n        注册菜单命令\n        \"\"\"\n        if not self._bot:\n            return\n        # 设置bot命令\n        if commands:\n            self._bot.delete_my_commands()\n            self._bot.set_my_commands(\n                commands=[\n                    BotCommand(cmd[1:], str(desc.get(\"description\"))) for cmd, desc in\n                    commands.items()\n                ]\n            )\n\n    def delete_commands(self):\n        \"\"\"\n        清理菜单命令\n        \"\"\"\n        if not self._bot:\n            return\n        # 清理菜单命令\n        self._bot.delete_my_commands()\n\n    def stop(self):\n        \"\"\"\n        停止Telegram消息接收服务\n        \"\"\"\n        if self._bot:\n            self._bot.stop_polling()\n            self._polling_thread.join()\n            logger.info(\"Telegram消息接收服务已停止\")\n"
  },
  {
    "path": "app/modules/themoviedb/__init__.py",
    "content": "import re\nfrom typing import Optional, List, Tuple, Union, Dict\n\nimport cn2an\nimport zhconv\n\nfrom app import schemas\nfrom app.core.config import settings\nfrom app.core.context import MediaInfo\nfrom app.core.meta import MetaBase\nfrom app.log import logger\nfrom app.modules import _ModuleBase\nfrom app.modules.themoviedb.category import CategoryHelper\nfrom app.modules.themoviedb.scraper import TmdbScraper\nfrom app.modules.themoviedb.tmdb_cache import TmdbCache\nfrom app.modules.themoviedb.tmdbapi import TmdbApi\nfrom app.schemas.category import CategoryConfig\nfrom app.schemas.types import MediaType, MediaImageType, ModuleType, MediaRecognizeType\nfrom app.utils.http import RequestUtils\n\n\n\nclass TheMovieDbModule(_ModuleBase):\n    \"\"\"\n    TMDB媒体信息匹配\n    \"\"\"\n    CONFIG_WATCH = {\"PROXY_HOST\", \"TMDB_API_DOMAIN\", \"TMDB_API_KEY\", \"TMDB_LOCALE\"}\n\n    # 元数据缓存\n    cache: TmdbCache = None\n    # TMDB\n    tmdb: TmdbApi = None\n    # 二级分类\n    category: CategoryHelper = None\n    # 刮削器\n    scraper: TmdbScraper = None\n\n    def init_module(self) -> None:\n        self.cache = TmdbCache()\n        self.tmdb = TmdbApi()\n        self.category = CategoryHelper()\n        self.scraper = TmdbScraper()\n\n    def on_config_changed(self):\n        # 停止模块\n        self.stop()\n        # 初始化模块\n        self.init_module()\n\n    @staticmethod\n    def get_name() -> str:\n        return \"TheMovieDb\"\n\n    @staticmethod\n    def get_type() -> ModuleType:\n        \"\"\"\n        获取模块类型\n        \"\"\"\n        return ModuleType.MediaRecognize\n\n    @staticmethod\n    def get_subtype() -> MediaRecognizeType:\n        \"\"\"\n        获取模块子类型\n        \"\"\"\n        return MediaRecognizeType.TMDB\n\n    @staticmethod\n    def get_priority() -> int:\n        \"\"\"\n        获取模块优先级，数字越小优先级越高，只有同一接口下优先级才生效\n        \"\"\"\n        return 1\n\n    def stop(self):\n        self.cache.save()\n        self.tmdb.close()\n\n    def test(self) -> Tuple[bool, str]:\n        \"\"\"\n        测试模块连接性\n        \"\"\"\n        ret = RequestUtils(ua=settings.NORMAL_USER_AGENT, proxies=settings.PROXY).get_res(\n            f\"https://{settings.TMDB_API_DOMAIN}/3/movie/550?api_key={settings.TMDB_API_KEY}\")\n        if ret and ret.status_code == 200:\n            return True, \"\"\n        elif ret:\n            return False, f\"无法连接 {settings.TMDB_API_DOMAIN}，错误码：{ret.status_code}\"\n        return False, f\"{settings.TMDB_API_DOMAIN} 网络连接失败\"\n\n    def init_setting(self) -> Tuple[str, Union[str, bool]]:\n        pass\n\n    @staticmethod\n    def _validate_recognize_params(meta: MetaBase, tmdbid: Optional[int]) -> bool:\n        \"\"\"\n        验证识别参数\n        \"\"\"\n        if not tmdbid and not meta:\n            return False\n\n        if meta and not tmdbid and settings.RECOGNIZE_SOURCE != \"themoviedb\":\n            return False\n\n        if meta and not meta.name:\n            logger.warn(\"识别媒体信息时未提供元数据名称\")\n            return False\n\n        return True\n\n    @staticmethod\n    def _prepare_search_names(meta: MetaBase) -> List[str]:\n        \"\"\"\n        准备搜索名称列表\n        \"\"\"\n        # 简体名称\n        zh_name = zhconv.convert(meta.cn_name, \"zh-hans\") if meta.cn_name else None\n        # 使用中英文名分别识别，去重去空，但要保持顺序\n        return list(dict.fromkeys([k for k in [meta.cn_name, zh_name, meta.en_name] if k]))\n\n    def _search_by_name(self, name: str, meta: MetaBase, group_seasons: List[dict]) -> dict:\n        \"\"\"\n        根据名称搜索媒体信息\n        \"\"\"\n        if meta.begin_season:\n            logger.info(f\"正在识别 {name} 第{meta.begin_season}季 ...\")\n        else:\n            logger.info(f\"正在识别 {name} ...\")\n\n        if meta.type == MediaType.UNKNOWN and not meta.year:\n            return self.tmdb.match_multi(name)\n        else:\n            if meta.type == MediaType.TV:\n                # 确定是电视\n                info = self.tmdb.match(name=name,\n                                       year=meta.year,\n                                       mtype=meta.type,\n                                       season_year=meta.year,\n                                       season_number=meta.begin_season,\n                                       group_seasons=group_seasons)\n                if not info:\n                    # 去掉年份再查一次\n                    info = self.tmdb.match(name=name, mtype=meta.type)\n                return info\n            else:\n                # 有年份先按电影查\n                info = self.tmdb.match(name=name, year=meta.year, mtype=MediaType.MOVIE)\n                # 没有再按电视剧查\n                if not info:\n                    info = self.tmdb.match(name=name, year=meta.year, mtype=MediaType.TV,\n                                           group_seasons=group_seasons)\n                if not info:\n                    # 去掉年份和类型再查一次\n                    info = self.tmdb.match_multi(name=name)\n                return info\n\n    async def _async_search_by_name(self, name: str, meta: MetaBase, group_seasons: List[dict]) -> dict:\n        \"\"\"\n        根据名称搜索媒体信息（异步版本）\n        \"\"\"\n        if meta.begin_season:\n            logger.info(f\"正在识别 {name} 第{meta.begin_season}季 ...\")\n        else:\n            logger.info(f\"正在识别 {name} ...\")\n\n        if meta.type == MediaType.UNKNOWN and not meta.year:\n            return await self.tmdb.async_match_multi(name)\n        else:\n            if meta.type == MediaType.TV:\n                # 确定是电视\n                info = await self.tmdb.async_match(name=name,\n                                                   year=meta.year,\n                                                   mtype=meta.type,\n                                                   season_year=meta.year,\n                                                   season_number=meta.begin_season,\n                                                   group_seasons=group_seasons)\n                if not info:\n                    # 去掉年份再查一次\n                    info = await self.tmdb.async_match(name=name, mtype=meta.type)\n                return info\n            else:\n                # 有年份先按电影查\n                info = await self.tmdb.async_match(name=name, year=meta.year, mtype=MediaType.MOVIE)\n                # 没有再按电视剧查\n                if not info:\n                    info = await self.tmdb.async_match(name=name, year=meta.year, mtype=MediaType.TV,\n                                                       group_seasons=group_seasons)\n                if not info:\n                    # 去掉年份和类型再查一次\n                    info = await self.tmdb.async_match_multi(name=name)\n                return info\n\n    def _process_episode_groups(self, mediainfo: MediaInfo, episode_group: Optional[str],\n                                group_seasons: List[dict]) -> MediaInfo:\n        \"\"\"\n        处理剧集组信息\n        \"\"\"\n        if mediainfo.type == MediaType.TV and mediainfo.episode_groups:\n            if group_seasons:\n                # 指定剧集组时\n                seasons = {}\n                season_info = []\n                season_years = {}\n                for group_season in group_seasons:\n                    # 季\n                    season = group_season.get(\"order\")\n                    # 集列表\n                    episodes = group_season.get(\"episodes\")\n                    if not episodes:\n                        continue\n                    seasons[season] = [ep.get(\"episode_number\") for ep in episodes]\n                    season_info.append(group_season)\n                    # 当前季第一季时间\n                    first_date = episodes[0].get(\"air_date\")\n                    if re.match(r\"^\\d{4}-\\d{2}-\\d{2}$\", first_date):\n                        season_years[season] = str(first_date).split(\"-\")[0]\n                # 每季集清单\n                if seasons:\n                    mediainfo.seasons = seasons\n                    mediainfo.number_of_seasons = len(seasons)\n                # 每季集详情\n                if season_info:\n                    mediainfo.season_info = season_info\n                # 每季年份\n                if season_years:\n                    mediainfo.season_years = season_years\n                # 所有剧集组\n                mediainfo.episode_group = episode_group\n                mediainfo.episode_groups = group_seasons\n            else:\n                # 每季年份\n                season_years = {}\n                for group in mediainfo.episode_groups:\n                    if group.get('type') != 6:\n                        # 只处理剧集部分\n                        continue\n                    group_episodes = self.tmdb.get_tv_group_seasons(group.get('id'))\n                    if not group_episodes:\n                        continue\n                    for group_episode in group_episodes:\n                        season = group_episode.get('order')\n                        episodes = group_episode.get('episodes')\n                        if not episodes:\n                            continue\n                        # 当前季第一季时间\n                        first_date = episodes[0].get(\"air_date\")\n                        # 判断是不是日期格式\n                        if first_date and re.match(r\"^\\d{4}-\\d{2}-\\d{2}$\", first_date):\n                            season_years[season] = str(first_date).split(\"-\")[0]\n                if season_years:\n                    mediainfo.season_years = season_years\n        return mediainfo\n\n    async def _async_process_episode_groups(self, mediainfo: MediaInfo, episode_group: Optional[str],\n                                            group_seasons: List[dict]) -> MediaInfo:\n        \"\"\"\n        处理剧集组信息（异步版本）\n        \"\"\"\n        if mediainfo.type == MediaType.TV and mediainfo.episode_groups:\n            if group_seasons:\n                # 指定剧集组时\n                seasons = {}\n                season_info = []\n                season_years = {}\n                for group_season in group_seasons:\n                    # 季\n                    season = group_season.get(\"order\")\n                    # 集列表\n                    episodes = group_season.get(\"episodes\")\n                    if not episodes:\n                        continue\n                    seasons[season] = [ep.get(\"episode_number\") for ep in episodes]\n                    season_info.append(group_season)\n                    # 当前季第一季时间\n                    first_date = episodes[0].get(\"air_date\")\n                    if re.match(r\"^\\d{4}-\\d{2}-\\d{2}$\", first_date):\n                        season_years[season] = str(first_date).split(\"-\")[0]\n                # 每季集清单\n                if seasons:\n                    mediainfo.seasons = seasons\n                    mediainfo.number_of_seasons = len(seasons)\n                # 每季集详情\n                if season_info:\n                    mediainfo.season_info = season_info\n                # 每季年份\n                if season_years:\n                    mediainfo.season_years = season_years\n                # 所有剧集组\n                mediainfo.episode_group = episode_group\n                mediainfo.episode_groups = group_seasons\n            else:\n                # 每季年份\n                season_years = {}\n                for group in mediainfo.episode_groups:\n                    if group.get('type') != 6:\n                        # 只处理剧集部分\n                        continue\n                    group_episodes = await self.tmdb.async_get_tv_group_seasons(group.get('id'))\n                    if not group_episodes:\n                        continue\n                    for group_episode in group_episodes:\n                        season = group_episode.get('order')\n                        episodes = group_episode.get('episodes')\n                        if not episodes:\n                            continue\n                        # 当前季第一季时间\n                        first_date = episodes[0].get(\"air_date\")\n                        # 判断是不是日期格式\n                        if first_date and re.match(r\"^\\d{4}-\\d{2}-\\d{2}$\", first_date):\n                            season_years[season] = str(first_date).split(\"-\")[0]\n                if season_years:\n                    mediainfo.season_years = season_years\n        return mediainfo\n\n    def _build_media_info_result(self, info: dict, meta: MetaBase, tmdbid: Optional[int],\n                                 episode_group: Optional[str], group_seasons: List[dict]) -> MediaInfo:\n        \"\"\"\n        构建MediaInfo结果\n        \"\"\"\n        # 确定二级分类\n        if info.get('media_type') == MediaType.TV:\n            cat = self.category.get_tv_category(info)\n        else:\n            cat = self.category.get_movie_category(info)\n\n        # 赋值TMDB信息并返回\n        mediainfo = MediaInfo(tmdb_info=info)\n        mediainfo.set_category(cat)\n\n        if meta:\n            logger.info(f\"{meta.name} TMDB识别结果：{mediainfo.type.value} \"\n                        f\"{mediainfo.title_year} \"\n                        f\"{mediainfo.tmdb_id}\")\n        else:\n            logger.info(f\"{tmdbid} TMDB识别结果：{mediainfo.type.value} \"\n                        f\"{mediainfo.title_year}\")\n\n        # 处理剧集组信息\n        return self._process_episode_groups(mediainfo, episode_group, group_seasons)\n\n    async def _async_build_media_info_result(self, info: dict, meta: MetaBase, tmdbid: Optional[int],\n                                             episode_group: Optional[str], group_seasons: List[dict]) -> MediaInfo:\n        \"\"\"\n        构建MediaInfo结果（异步版本）\n        \"\"\"\n        # 确定二级分类\n        if info.get('media_type') == MediaType.TV:\n            cat = self.category.get_tv_category(info)\n        else:\n            cat = self.category.get_movie_category(info)\n\n        # 赋值TMDB信息并返回\n        mediainfo = MediaInfo(tmdb_info=info)\n        mediainfo.set_category(cat)\n\n        if meta:\n            logger.info(f\"{meta.name} TMDB识别结果：{mediainfo.type.value} \"\n                        f\"{mediainfo.title_year} \"\n                        f\"{mediainfo.tmdb_id}\")\n        else:\n            logger.info(f\"{tmdbid} TMDB识别结果：{mediainfo.type.value} \"\n                        f\"{mediainfo.title_year}\")\n\n        # 处理剧集组信息\n        return await self._async_process_episode_groups(mediainfo, episode_group, group_seasons)\n\n    def recognize_media(self, meta: MetaBase = None,\n                        mtype: MediaType = None,\n                        tmdbid: Optional[int] = None,\n                        episode_group: Optional[str] = None,\n                        cache: Optional[bool] = True,\n                        **kwargs) -> Optional[MediaInfo]:\n        \"\"\"\n        识别媒体信息\n        :param meta:     识别的元数据\n        :param mtype:    识别的媒体类型，与tmdbid配套\n        :param tmdbid:   tmdbid\n        :param episode_group:  剧集组\n        :param cache:    是否使用缓存\n        :return: 识别的媒体信息，包括剧集信息\n        \"\"\"\n        # 验证参数\n        if not self._validate_recognize_params(meta, tmdbid):\n            return None\n\n        if not meta:\n            # 未提供元数据时，直接使用tmdbid查询，不使用缓存\n            cache_info = {}\n        else:\n            # 读取缓存\n            if mtype:\n                meta.type = mtype\n            if tmdbid:\n                meta.tmdbid = tmdbid\n            cache_info = self.cache.get(meta)\n\n        # 查询剧集组\n        group_seasons = []\n        if episode_group:\n            group_seasons = self.tmdb.get_tv_group_seasons(episode_group)\n\n        # 识别匹配\n        if not cache_info or not cache:\n            info = None\n            # 缓存没有或者强制不使用缓存\n            if tmdbid:\n                # 直接查询详情\n                info = self.tmdb.get_info(mtype=mtype, tmdbid=tmdbid)\n            if not info and meta:\n                # 准备搜索名称\n                names = self._prepare_search_names(meta)\n                for name in names:\n                    info = self._search_by_name(name, meta, group_seasons)\n                    if not info:\n                        # 从网站查询\n                        info = self.tmdb.match_web(name=name, mtype=meta.type)\n                    if info:\n                        # 查到就退出\n                        break\n                # 补充全量信息\n                if info and not info.get(\"genres\"):\n                    info = self.tmdb.get_info(mtype=info.get(\"media_type\"),\n                                              tmdbid=info.get(\"id\"))\n            elif not info:\n                logger.error(\"识别媒体信息时未提供元数据或唯一且有效的tmdbid\")\n                return None\n\n            # 保存到缓存\n            if meta:\n                self.cache.update(meta, info)\n        else:\n            # 使用缓存信息\n            if cache_info.get(\"title\"):\n                logger.info(f\"{meta.name} 使用TMDB识别缓存：{cache_info.get('title')}\")\n                info = self.tmdb.get_info(mtype=cache_info.get(\"type\"),\n                                          tmdbid=cache_info.get(\"id\"))\n            else:\n                logger.info(f\"{meta.name} 使用TMDB识别缓存：无法识别\")\n                info = None\n\n        if info:\n            return self._build_media_info_result(info, meta, tmdbid, episode_group, group_seasons)\n        else:\n            logger.info(f\"{meta.name if meta else tmdbid} 未匹配到TMDB媒体信息\")\n\n        return None\n\n    async def async_recognize_media(self, meta: MetaBase = None,\n                                    mtype: MediaType = None,\n                                    tmdbid: Optional[int] = None,\n                                    episode_group: Optional[str] = None,\n                                    cache: Optional[bool] = True,\n                                    **kwargs) -> Optional[MediaInfo]:\n        \"\"\"\n        识别媒体信息（异步版本）\n        :param meta:     识别的元数据\n        :param mtype:    识别的媒体类型，与tmdbid配套\n        :param tmdbid:   tmdbid\n        :param episode_group:  剧集组\n        :param cache:    是否使用缓存\n        :return: 识别的媒体信息，包括剧集信息\n        \"\"\"\n        # 验证参数\n        if not self._validate_recognize_params(meta, tmdbid):\n            return None\n\n        if not meta:\n            # 未提供元数据时，直接使用tmdbid查询，不使用缓存\n            cache_info = {}\n        else:\n            # 读取缓存\n            if mtype:\n                meta.type = mtype\n            if tmdbid:\n                meta.tmdbid = tmdbid\n            cache_info = self.cache.get(meta)\n\n        # 查询剧集组\n        group_seasons = []\n        if episode_group:\n            group_seasons = await self.tmdb.async_get_tv_group_seasons(episode_group)\n\n        # 识别匹配\n        if not cache_info or not cache:\n            info = None\n            # 缓存没有或者强制不使用缓存\n            if tmdbid:\n                # 直接查询详情\n                info = await self.tmdb.async_get_info(mtype=mtype, tmdbid=tmdbid)\n            if not info and meta:\n                # 准备搜索名称\n                names = self._prepare_search_names(meta)\n                for name in names:\n                    info = await self._async_search_by_name(name, meta, group_seasons)\n                    if not info:\n                        # 从网站查询\n                        info = await self.tmdb.async_match_web(name=name, mtype=meta.type)\n                    if info:\n                        # 查到就退出\n                        break\n                # 补充全量信息\n                if info and not info.get(\"genres\"):\n                    info = await self.tmdb.async_get_info(mtype=info.get(\"media_type\"),\n                                                          tmdbid=info.get(\"id\"))\n            elif not info:\n                logger.error(\"识别媒体信息时未提供元数据或唯一且有效的tmdbid\")\n                return None\n\n            # 保存到缓存\n            if meta:\n                self.cache.update(meta, info)\n        else:\n            # 使用缓存信息\n            if cache_info.get(\"title\"):\n                logger.info(f\"{meta.name} 使用TMDB识别缓存：{cache_info.get('title')}\")\n                info = await self.tmdb.async_get_info(mtype=cache_info.get(\"type\"),\n                                                      tmdbid=cache_info.get(\"id\"))\n            else:\n                logger.info(f\"{meta.name} 使用TMDB识别缓存：无法识别\")\n                info = None\n\n        if info:\n            return await self._async_build_media_info_result(info, meta, tmdbid, episode_group, group_seasons)\n        else:\n            logger.info(f\"{meta.name if meta else tmdbid} 未匹配到TMDB媒体信息\")\n\n        return None\n\n    def match_tmdbinfo(self, name: str, mtype: MediaType = None,\n                       year: Optional[str] = None, season: Optional[int] = None) -> dict:\n        \"\"\"\n        搜索和匹配TMDB信息\n        :param name:  名称\n        :param mtype:  类型\n        :param year:  年份\n        :param season:  季号\n        \"\"\"\n        # 搜索\n        logger.info(f\"开始使用 名称：{name} 年份：{year} 匹配TMDB信息 ...\")\n        info = self.tmdb.match(name=name,\n                               year=year,\n                               mtype=mtype,\n                               season_year=year,\n                               season_number=season)\n        if info and not info.get(\"genres\"):\n            info = self.tmdb.get_info(mtype=info.get(\"media_type\"),\n                                      tmdbid=info.get(\"id\"))\n        return info\n\n    async def async_match_tmdbinfo(self, name: str, mtype: MediaType = None,\n                                   year: Optional[str] = None, season: Optional[int] = None) -> dict:\n        \"\"\"\n        异步搜索和匹配TMDB信息\n        :param name:  名称\n        :param mtype:  类型\n        :param year:  年份\n        :param season:  季号\n        \"\"\"\n        # 搜索\n        logger.info(f\"开始使用 名称：{name} 年份：{year} 匹配TMDB信息 ...\")\n        info = await self.tmdb.async_match(name=name,\n                                           year=year,\n                                           mtype=mtype,\n                                           season_year=year,\n                                           season_number=season)\n        if info and not info.get(\"genres\"):\n            info = await self.tmdb.async_get_info(mtype=info.get(\"media_type\"),\n                                                  tmdbid=info.get(\"id\"))\n        return info\n\n    def tmdb_info(self, tmdbid: int, mtype: MediaType, season: Optional[int] = None) -> Optional[dict]:\n        \"\"\"\n        获取TMDB信息\n        :param tmdbid: int\n        :param mtype:  媒体类型\n        :param season:  季号\n        :return: TVDB信息\n        \"\"\"\n        if not season:\n            return self.tmdb.get_info(mtype=mtype, tmdbid=tmdbid)\n        else:\n            return self.tmdb.get_tv_season_detail(tmdbid=tmdbid, season=season)\n\n    async def async_tmdb_info(self, tmdbid: int, mtype: MediaType, season: Optional[int] = None) -> Optional[dict]:\n        \"\"\"\n        异步获取TMDB信息\n        :param tmdbid: int\n        :param mtype:  媒体类型\n        :param season:  季号\n        :return: TVDB信息\n        \"\"\"\n        if not season:\n            return await self.tmdb.async_get_info(mtype=mtype, tmdbid=tmdbid)\n        else:\n            return await self.tmdb.async_get_tv_season_detail(tmdbid=tmdbid, season=season)\n\n    def media_category(self) -> Optional[Dict[str, list]]:\n        \"\"\"\n        获取媒体分类\n        :return: 获取二级分类配置字典项，需包括电影、电视剧\n        \"\"\"\n        return {\n            MediaType.MOVIE.value: list(self.category.movie_categorys),\n            MediaType.TV.value: list(self.category.tv_categorys)\n        }\n\n    def search_medias(self, meta: MetaBase) -> Optional[List[MediaInfo]]:\n        \"\"\"\n        搜索媒体信息\n        :param meta:  识别的元数据\n        :reutrn: 媒体信息列表\n        \"\"\"\n        if settings.SEARCH_SOURCE and \"themoviedb\" not in settings.SEARCH_SOURCE:\n            return None\n        if not meta.name:\n            return []\n        if meta.type == MediaType.UNKNOWN and not meta.year:\n            results = self.tmdb.search_multiis(meta.name)\n        else:\n            if meta.type == MediaType.UNKNOWN:\n                results = self.tmdb.search_movies(meta.name, meta.year)\n                results.extend(self.tmdb.search_tvs(meta.name, meta.year))\n                # 组合结果的情况下要排序\n                results = sorted(\n                    results,\n                    key=lambda x: x.get(\"release_date\") or x.get(\"first_air_date\") or \"0000-00-00\",\n                    reverse=True\n                )\n            elif meta.type == MediaType.MOVIE:\n                results = self.tmdb.search_movies(meta.name, meta.year)\n            else:\n                results = self.tmdb.search_tvs(meta.name, meta.year)\n        # 将搜索词中的季写入标题中\n        if results:\n            medias = [MediaInfo(tmdb_info=info) for info in results]\n            if meta.begin_season:\n                # 小写数据转大写\n                season_str = cn2an.an2cn(meta.begin_season, \"low\")\n                for media in medias:\n                    if media.type == MediaType.TV:\n                        media.title = f\"{media.title} 第{season_str}季\"\n                        media.season = meta.begin_season\n            return medias\n        return []\n\n    def search_persons(self, name: str) -> Optional[List[schemas.MediaPerson]]:\n        \"\"\"\n        搜索人物信息\n        \"\"\"\n        if settings.SEARCH_SOURCE and \"themoviedb\" not in settings.SEARCH_SOURCE:\n            return None\n        if not name:\n            return []\n        results = self.tmdb.search_persons(name)\n        if results:\n            return [schemas.MediaPerson(source='themoviedb', **person) for person in results]\n        return []\n\n    async def async_search_persons(self, name: str) -> Optional[List[schemas.MediaPerson]]:\n        \"\"\"\n        异步搜索人物信息\n        \"\"\"\n        if settings.SEARCH_SOURCE and \"themoviedb\" not in settings.SEARCH_SOURCE:\n            return None\n        if not name:\n            return []\n        results = await self.tmdb.async_search_persons(name)\n        if results:\n            return [schemas.MediaPerson(source='themoviedb', **person) for person in results]\n        return []\n\n    def search_collections(self, name: str) -> Optional[List[MediaInfo]]:\n        \"\"\"\n        搜索集合信息\n        \"\"\"\n        if not name:\n            return []\n        results = self.tmdb.search_collections(name)\n        if results:\n            return [MediaInfo(tmdb_info=info) for info in results]\n        return []\n\n    async def async_search_collections(self, name: str) -> Optional[List[MediaInfo]]:\n        \"\"\"\n        异步搜索集合信息\n        \"\"\"\n        if not name:\n            return []\n        results = await self.tmdb.async_search_collections(name)\n        if results:\n            return [MediaInfo(tmdb_info=info) for info in results]\n        return []\n\n    def tmdb_collection(self, collection_id: int) -> Optional[List[MediaInfo]]:\n        \"\"\"\n        根据合集ID查询集合\n        :param collection_id:  合集ID\n        \"\"\"\n        results = self.tmdb.get_collection(collection_id)\n        if results:\n            return [MediaInfo(tmdb_info=info) for info in results]\n        return []\n\n    def metadata_nfo(self, meta: MetaBase, mediainfo: MediaInfo,\n                     season: Optional[int] = None, episode: Optional[int] = None) -> Optional[str]:\n        \"\"\"\n        获取NFO文件内容文本\n        :param meta: 元数据\n        :param mediainfo: 媒体信息\n        :param season: 季号\n        :param episode: 集号\n        \"\"\"\n        if settings.SCRAP_SOURCE != \"themoviedb\":\n            return None\n        return self.scraper.get_metadata_nfo(meta=meta, mediainfo=mediainfo, season=season, episode=episode)\n\n    def metadata_img(self, mediainfo: MediaInfo, season: Optional[int] = None,\n                     episode: Optional[int] = None) -> Optional[dict]:\n        \"\"\"\n        获取图片名称和url\n        :param mediainfo: 媒体信息\n        :param season: 季号\n        :param episode: 集号\n        \"\"\"\n        if settings.SCRAP_SOURCE != \"themoviedb\":\n            return None\n        return self.scraper.get_metadata_img(mediainfo=mediainfo, season=season, episode=episode)\n\n    def tmdb_discover(self, mtype: MediaType, sort_by: str,\n                      with_genres: str,\n                      with_original_language: str,\n                      with_keywords: str,\n                      with_watch_providers: str,\n                      vote_average: float,\n                      vote_count: int,\n                      release_date: str,\n                      page: Optional[int] = 1) -> Optional[List[MediaInfo]]:\n        \"\"\"\n        :param mtype:  媒体类型\n        :param sort_by:  排序方式\n        :param with_genres:  类型\n        :param with_original_language:  语言\n        :param with_keywords:  关键字\n        :param with_watch_providers:  提供商\n        :param vote_average:  评分\n        :param vote_count:  评分人数\n        :param release_date:  发布日期\n        :param page:  页码\n        :return: 媒体信息列表\n        \"\"\"\n        if mtype == MediaType.MOVIE:\n            infos = self.tmdb.discover_movies({\n                \"sort_by\": sort_by,\n                \"with_genres\": with_genres,\n                \"with_original_language\": with_original_language,\n                \"with_keywords\": with_keywords,\n                \"with_watch_providers\": with_watch_providers,\n                \"vote_average.gte\": vote_average,\n                \"vote_count.gte\": vote_count,\n                \"release_date.gte\": release_date,\n                \"page\": page\n            })\n        elif mtype == MediaType.TV:\n            infos = self.tmdb.discover_tvs({\n                \"sort_by\": sort_by,\n                \"with_genres\": with_genres,\n                \"with_original_language\": with_original_language,\n                \"with_keywords\": with_keywords,\n                \"with_watch_providers\": with_watch_providers,\n                \"vote_average.gte\": vote_average,\n                \"vote_count.gte\": vote_count,\n                \"first_air_date.gte\": release_date,\n                \"page\": page\n            })\n        else:\n            return []\n        if infos:\n            return [MediaInfo(tmdb_info=info) for info in infos]\n        return []\n\n    def tmdb_trending(self, page: Optional[int] = 1) -> List[MediaInfo]:\n        \"\"\"\n        TMDB流行趋势\n        :param page: 第几页\n        :return: TMDB信息列表\n        \"\"\"\n        trending = self.tmdb.discover_trending(page=page)\n        if trending:\n            return [MediaInfo(tmdb_info=info) for info in trending]\n        return []\n\n    def tmdb_seasons(self, tmdbid: int) -> List[schemas.TmdbSeason]:\n        \"\"\"\n        根据TMDBID查询themoviedb所有季信息\n        :param tmdbid:  TMDBID\n        \"\"\"\n        tmdb_info = self.tmdb.get_info(tmdbid=tmdbid, mtype=MediaType.TV)\n        if not tmdb_info:\n            return []\n        return [schemas.TmdbSeason(**sea)\n                for sea in tmdb_info.get(\"seasons\", []) if sea.get(\"season_number\") is not None]\n\n    def tmdb_group_seasons(self, group_id: str) -> List[schemas.TmdbSeason]:\n        \"\"\"\n        根据剧集组ID查询themoviedb所有季集信息\n        :param group_id: 剧集组ID\n        \"\"\"\n        group_seasons = self.tmdb.get_tv_group_seasons(group_id)\n        if not group_seasons:\n            return []\n        return [schemas.TmdbSeason(\n            season_number=sea.get(\"order\"),\n            name=sea.get(\"name\"),\n            episode_count=len(sea.get(\"episodes\") or []),\n            air_date=sea.get(\"episodes\")[0].get(\"air_date\") if sea.get(\"episodes\") else None,\n        ) for sea in group_seasons]\n\n    def tmdb_episodes(self, tmdbid: int, season: int, episode_group: Optional[str] = None) -> List[schemas.TmdbEpisode]:\n        \"\"\"\n        根据TMDBID查询某季的所有集信息\n        :param tmdbid:  TMDBID\n        :param season:  季\n        :param episode_group:  剧集组\n        \"\"\"\n        if episode_group:\n            season_info = self.tmdb.get_tv_group_detail(episode_group, season=season)\n        else:\n            season_info = self.tmdb.get_tv_season_detail(tmdbid=tmdbid, season=season)\n        if not season_info or not season_info.get(\"episodes\"):\n            return []\n        return [schemas.TmdbEpisode(**episode) for episode in season_info.get(\"episodes\")]\n\n    def scheduler_job(self) -> None:\n        \"\"\"\n        定时任务，每10分钟调用一次\n        \"\"\"\n        self.cache.save()\n\n    @staticmethod\n    def _validate_obtain_images_params(mediainfo: MediaInfo) -> Optional[MediaInfo]:\n        \"\"\"\n        验证 obtain_images 参数\n        :param mediainfo: 媒体信息\n        :return: None 表示不处理，MediaInfo 表示继续处理\n        \"\"\"\n        if settings.RECOGNIZE_SOURCE != \"themoviedb\":\n            return None\n        if not mediainfo.tmdb_id:\n            return mediainfo\n        if mediainfo.logo_path \\\n                and mediainfo.poster_path \\\n                and mediainfo.backdrop_path:\n            # 没有图片缺失\n            return mediainfo\n        return None\n\n    @staticmethod\n    def _process_tmdb_images(mediainfo: MediaInfo, images: dict) -> MediaInfo:\n        \"\"\"\n        处理 TMDB 图片数据\n        :param mediainfo: 媒体信息\n        :param images: 图片数据\n        :return: 更新后的媒体信息\n        \"\"\"\n        if isinstance(images, list):\n            images = images[0]\n        # 背景图\n        if not mediainfo.backdrop_path:\n            backdrops = images.get(\"backdrops\")\n            if backdrops:\n                backdrops = sorted(backdrops, key=lambda x: x.get(\"vote_average\"), reverse=True)\n                mediainfo.backdrop_path = settings.TMDB_IMAGE_URL(backdrops[0].get(\"file_path\"))\n        # 标志\n        if not mediainfo.logo_path:\n            logos = images.get(\"logos\")\n            if logos:\n                logos = sorted(logos, key=lambda x: x.get(\"vote_average\"), reverse=True)\n                mediainfo.logo_path = settings.TMDB_IMAGE_URL(logos[0].get(\"file_path\"))\n        # 海报\n        if not mediainfo.poster_path:\n            posters = images.get(\"posters\")\n            if posters:\n                posters = sorted(posters, key=lambda x: x.get(\"vote_average\"), reverse=True)\n                mediainfo.poster_path = settings.TMDB_IMAGE_URL(posters[0].get(\"file_path\"))\n        return mediainfo\n\n    def obtain_images(self, mediainfo: MediaInfo) -> Optional[MediaInfo]:\n        \"\"\"\n        补充抓取媒体信息图片\n        :param mediainfo:  识别的媒体信息\n        :return: 更新后的媒体信息\n        \"\"\"\n        # 验证参数\n        result = self._validate_obtain_images_params(mediainfo)\n        if result is not None:\n            return result\n\n        # 调用TMDB图片接口\n        if mediainfo.type == MediaType.MOVIE:\n            images = self.tmdb.get_movie_images(mediainfo.tmdb_id)\n        else:\n            images = self.tmdb.get_tv_images(mediainfo.tmdb_id)\n        if not images:\n            return mediainfo\n\n        # 处理图片数据\n        return self._process_tmdb_images(mediainfo, images)\n\n    async def async_obtain_images(self, mediainfo: MediaInfo) -> Optional[MediaInfo]:\n        \"\"\"\n        补充抓取媒体信息图片（异步版本）\n        :param mediainfo:  识别的媒体信息\n        :return: 更新后的媒体信息\n        \"\"\"\n        # 验证参数\n        result = self._validate_obtain_images_params(mediainfo)\n        if result is not None:\n            return result\n\n        # 调用TMDB图片接口\n        if mediainfo.type == MediaType.MOVIE:\n            images = await self.tmdb.async_get_movie_images(mediainfo.tmdb_id)\n        else:\n            images = await self.tmdb.async_get_tv_images(mediainfo.tmdb_id)\n        if not images:\n            return mediainfo\n\n        # 处理图片数据\n        return self._process_tmdb_images(mediainfo, images)\n\n    def obtain_specific_image(self, mediaid: Union[str, int], mtype: MediaType,\n                              image_type: MediaImageType, image_prefix: Optional[str] = \"w500\",\n                              season: Optional[int] = None, episode: Optional[int] = None) -> Optional[str]:\n        \"\"\"\n        获取指定媒体信息图片，返回图片地址\n        :param mediaid:     媒体ID\n        :param mtype:       媒体类型\n        :param image_type:  图片类型\n        :param image_prefix: 图片前缀\n        :param season:      季\n        :param episode:     集\n        \"\"\"\n        if not str(mediaid).isdigit():\n            return None\n        # 图片相对路径\n        image_path = None\n        image_prefix = image_prefix or \"w500\"\n        if season is None and not episode:\n            tmdbinfo = self.tmdb.get_info(mtype=mtype, tmdbid=int(mediaid))\n            if tmdbinfo:\n                image_path = tmdbinfo.get(image_type.value)\n        elif season is not None and episode:\n            episodeinfo = self.tmdb.get_tv_episode_detail(tmdbid=int(mediaid), season=season, episode=episode)\n            if episodeinfo:\n                image_path = episodeinfo.get(\"still_path\")\n        elif season is not None:\n            seasoninfo = self.tmdb.get_tv_season_detail(tmdbid=int(mediaid), season=season)\n            if seasoninfo:\n                image_path = seasoninfo.get(image_type.value)\n\n        if image_path:\n            return settings.TMDB_IMAGE_URL(image_path, image_prefix)\n        return None\n\n    def tmdb_movie_similar(self, tmdbid: int) -> List[MediaInfo]:\n        \"\"\"\n        根据TMDBID查询类似电影\n        :param tmdbid:  TMDBID\n        \"\"\"\n        similar = self.tmdb.get_movie_similar(tmdbid=tmdbid)\n        if similar:\n            return [MediaInfo(tmdb_info=info) for info in similar]\n        return []\n\n    def tmdb_tv_similar(self, tmdbid: int) -> List[MediaInfo]:\n        \"\"\"\n        根据TMDBID查询类似电视剧\n        :param tmdbid:  TMDBID\n        \"\"\"\n        similar = self.tmdb.get_tv_similar(tmdbid=tmdbid)\n        if similar:\n            return [MediaInfo(tmdb_info=info) for info in similar]\n        return []\n\n    def tmdb_movie_recommend(self, tmdbid: int) -> List[MediaInfo]:\n        \"\"\"\n        根据TMDBID查询推荐电影\n        :param tmdbid:  TMDBID\n        \"\"\"\n        recommend = self.tmdb.get_movie_recommend(tmdbid=tmdbid)\n        if recommend:\n            return [MediaInfo(tmdb_info=info) for info in recommend]\n        return []\n\n    def tmdb_tv_recommend(self, tmdbid: int) -> List[MediaInfo]:\n        \"\"\"\n        根据TMDBID查询推荐电视剧\n        :param tmdbid:  TMDBID\n        \"\"\"\n        recommend = self.tmdb.get_tv_recommend(tmdbid=tmdbid)\n        if recommend:\n            return [MediaInfo(tmdb_info=info) for info in recommend]\n        return []\n\n    def tmdb_movie_credits(self, tmdbid: int, page: Optional[int] = 1) -> List[schemas.MediaPerson]:\n        \"\"\"\n        根据TMDBID查询电影演职员表\n        :param tmdbid:  TMDBID\n        :param page:  页码\n        \"\"\"\n        credit_infos = self.tmdb.get_movie_credits(tmdbid=tmdbid, page=page)\n        if credit_infos:\n            return [schemas.MediaPerson(source=\"themoviedb\", **info) for info in credit_infos]\n        return []\n\n    def tmdb_tv_credits(self, tmdbid: int, page: Optional[int] = 1) -> List[schemas.MediaPerson]:\n        \"\"\"\n        根据TMDBID查询电视剧演职员表\n        :param tmdbid:  TMDBID\n        :param page:  页码\n        \"\"\"\n        credit_infos = self.tmdb.get_tv_credits(tmdbid=tmdbid, page=page)\n        if credit_infos:\n            return [schemas.MediaPerson(source=\"themoviedb\", **info) for info in credit_infos]\n        return []\n\n    def tmdb_person_detail(self, person_id: int) -> schemas.MediaPerson:\n        \"\"\"\n        根据TMDBID查询人物详情\n        :param person_id:  人物ID\n        \"\"\"\n        detail = self.tmdb.get_person_detail(person_id=person_id)\n        if detail:\n            return schemas.MediaPerson(source=\"themoviedb\", **detail)\n        return schemas.MediaPerson()\n\n    def tmdb_person_credits(self, person_id: int, page: Optional[int] = 1) -> List[MediaInfo]:\n        \"\"\"\n        根据TMDBID查询人物参演作品\n        :param person_id:  人物ID\n        :param page:  页码\n        \"\"\"\n        infos = self.tmdb.get_person_credits(person_id=person_id, page=page)\n        if infos:\n            return [MediaInfo(tmdb_info=tmdbinfo) for tmdbinfo in infos]\n        return []\n\n    # 异步方法\n    async def async_search_medias(self, meta: MetaBase) -> Optional[List[MediaInfo]]:\n        \"\"\"\n        搜索媒体信息（异步版本）\n        :param meta:  识别的元数据\n        :reutrn: 媒体信息列表\n        \"\"\"\n        if settings.SEARCH_SOURCE and \"themoviedb\" not in settings.SEARCH_SOURCE:\n            return None\n        if not meta.name:\n            return []\n        if meta.type == MediaType.UNKNOWN and not meta.year:\n            results = await self.tmdb.async_search_multiis(meta.name)\n        else:\n            if meta.type == MediaType.UNKNOWN:\n                results = await self.tmdb.async_search_movies(meta.name, meta.year)\n                results.extend(await self.tmdb.async_search_tvs(meta.name, meta.year))\n                # 组合结果的情况下要排序\n                results = sorted(\n                    results,\n                    key=lambda x: x.get(\"release_date\") or x.get(\"first_air_date\") or \"0000-00-00\",\n                    reverse=True\n                )\n            elif meta.type == MediaType.MOVIE:\n                results = await self.tmdb.async_search_movies(meta.name, meta.year)\n            else:\n                results = await self.tmdb.async_search_tvs(meta.name, meta.year)\n        # 将搜索词中的季写入标题中\n        if results:\n            medias = [MediaInfo(tmdb_info=info) for info in results]\n            if meta.begin_season:\n                # 小写数据转大写\n                season_str = cn2an.an2cn(meta.begin_season, \"low\")\n                for media in medias:\n                    if media.type == MediaType.TV:\n                        media.title = f\"{media.title} 第{season_str}季\"\n                        media.season = meta.begin_season\n            return medias\n        return []\n\n    async def async_tmdb_discover(self, mtype: MediaType, sort_by: str,\n                                  with_genres: str,\n                                  with_original_language: str,\n                                  with_keywords: str,\n                                  with_watch_providers: str,\n                                  vote_average: float,\n                                  vote_count: int,\n                                  release_date: str,\n                                  page: Optional[int] = 1) -> Optional[List[MediaInfo]]:\n        \"\"\"\n        TMDB发现功能（异步版本）\n        :param mtype:  媒体类型\n        :param sort_by:  排序方式\n        :param with_genres:  类型\n        :param with_original_language:  语言\n        :param with_keywords:  关键字\n        :param with_watch_providers:  提供商\n        :param vote_average:  评分\n        :param vote_count:  评分人数\n        :param release_date:  发布日期\n        :param page:  页码\n        :return: 媒体信息列表\n        \"\"\"\n        if mtype == MediaType.MOVIE:\n            infos = await self.tmdb.async_discover_movies({\n                \"sort_by\": sort_by,\n                \"with_genres\": with_genres,\n                \"with_original_language\": with_original_language,\n                \"with_keywords\": with_keywords,\n                \"with_watch_providers\": with_watch_providers,\n                \"vote_average.gte\": vote_average,\n                \"vote_count.gte\": vote_count,\n                \"release_date.gte\": release_date,\n                \"page\": page\n            })\n        elif mtype == MediaType.TV:\n            infos = await self.tmdb.async_discover_tvs({\n                \"sort_by\": sort_by,\n                \"with_genres\": with_genres,\n                \"with_original_language\": with_original_language,\n                \"with_keywords\": with_keywords,\n                \"with_watch_providers\": with_watch_providers,\n                \"vote_average.gte\": vote_average,\n                \"vote_count.gte\": vote_count,\n                \"first_air_date.gte\": release_date,\n                \"page\": page\n            })\n        else:\n            return []\n        if infos:\n            return [MediaInfo(tmdb_info=info) for info in infos]\n        return []\n\n    async def async_tmdb_trending(self, page: Optional[int] = 1) -> List[MediaInfo]:\n        \"\"\"\n        TMDB流行趋势（异步版本）\n        :param page: 第几页\n        :return: TMDB信息列表\n        \"\"\"\n        trending = await self.tmdb.async_discover_trending(page=page)\n        if trending:\n            return [MediaInfo(tmdb_info=info) for info in trending]\n        return []\n\n    async def async_tmdb_collection(self, collection_id: int) -> Optional[List[MediaInfo]]:\n        \"\"\"\n        根据合集ID查询集合（异步版本）\n        :param collection_id:  合集ID\n        \"\"\"\n        results = await self.tmdb.async_get_collection(collection_id)\n        if results:\n            return [MediaInfo(tmdb_info=info) for info in results]\n        return []\n\n    async def async_tmdb_seasons(self, tmdbid: int) -> List[schemas.TmdbSeason]:\n        \"\"\"\n        根据TMDBID查询themoviedb所有季信息（异步版本）\n        :param tmdbid:  TMDBID\n        \"\"\"\n        tmdb_info = await self.tmdb.async_get_info(tmdbid=tmdbid, mtype=MediaType.TV)\n        if not tmdb_info:\n            return []\n        return [schemas.TmdbSeason(**sea)\n                for sea in tmdb_info.get(\"seasons\", []) if sea.get(\"season_number\") is not None]\n\n    async def async_tmdb_group_seasons(self, group_id: str) -> List[schemas.TmdbSeason]:\n        \"\"\"\n        根据剧集组ID查询themoviedb所有季集信息（异步版本）\n        :param group_id: 剧集组ID\n        \"\"\"\n        group_seasons = await self.tmdb.async_get_tv_group_seasons(group_id)\n        if not group_seasons:\n            return []\n        return [schemas.TmdbSeason(\n            season_number=sea.get(\"order\"),\n            name=sea.get(\"name\"),\n            episode_count=len(sea.get(\"episodes\") or []),\n            air_date=sea.get(\"episodes\")[0].get(\"air_date\") if sea.get(\"episodes\") else None,\n        ) for sea in group_seasons]\n\n    async def async_tmdb_episodes(self, tmdbid: int, season: int,\n                                  episode_group: Optional[str] = None) -> List[schemas.TmdbEpisode]:\n        \"\"\"\n        根据TMDBID查询某季的所有集信息（异步版本）\n        :param tmdbid:  TMDBID\n        :param season:  季\n        :param episode_group:  剧集组\n        \"\"\"\n        if episode_group:\n            season_info = await self.tmdb.async_get_tv_group_detail(episode_group, season=season)\n        else:\n            season_info = await self.tmdb.async_get_tv_season_detail(tmdbid=tmdbid, season=season)\n        if not season_info or not season_info.get(\"episodes\"):\n            return []\n        return [schemas.TmdbEpisode(**episode) for episode in season_info.get(\"episodes\")]\n\n    async def async_tmdb_movie_similar(self, tmdbid: int) -> List[MediaInfo]:\n        \"\"\"\n        根据TMDBID查询类似电影（异步版本）\n        :param tmdbid:  TMDBID\n        \"\"\"\n        similar = await self.tmdb.async_get_movie_similar(tmdbid=tmdbid)\n        if similar:\n            return [MediaInfo(tmdb_info=info) for info in similar]\n        return []\n\n    async def async_tmdb_tv_similar(self, tmdbid: int) -> List[MediaInfo]:\n        \"\"\"\n        根据TMDBID查询类似电视剧（异步版本）\n        :param tmdbid:  TMDBID\n        \"\"\"\n        similar = await self.tmdb.async_get_tv_similar(tmdbid=tmdbid)\n        if similar:\n            return [MediaInfo(tmdb_info=info) for info in similar]\n        return []\n\n    async def async_tmdb_movie_recommend(self, tmdbid: int) -> List[MediaInfo]:\n        \"\"\"\n        根据TMDBID查询推荐电影（异步版本）\n        :param tmdbid:  TMDBID\n        \"\"\"\n        recommend = await self.tmdb.async_get_movie_recommend(tmdbid=tmdbid)\n        if recommend:\n            return [MediaInfo(tmdb_info=info) for info in recommend]\n        return []\n\n    async def async_tmdb_tv_recommend(self, tmdbid: int) -> List[MediaInfo]:\n        \"\"\"\n        根据TMDBID查询推荐电视剧（异步版本）\n        :param tmdbid:  TMDBID\n        \"\"\"\n        recommend = await self.tmdb.async_get_tv_recommend(tmdbid=tmdbid)\n        if recommend:\n            return [MediaInfo(tmdb_info=info) for info in recommend]\n        return []\n\n    async def async_tmdb_movie_credits(self, tmdbid: int, page: Optional[int] = 1) -> List[schemas.MediaPerson]:\n        \"\"\"\n        根据TMDBID查询电影演职员表（异步版本）\n        :param tmdbid:  TMDBID\n        :param page:  页码\n        \"\"\"\n        credit_infos = await self.tmdb.async_get_movie_credits(tmdbid=tmdbid, page=page)\n        if credit_infos:\n            return [schemas.MediaPerson(source=\"themoviedb\", **info) for info in credit_infos]\n        return []\n\n    async def async_tmdb_tv_credits(self, tmdbid: int, page: Optional[int] = 1) -> List[schemas.MediaPerson]:\n        \"\"\"\n        根据TMDBID查询电视剧演职员表（异步版本）\n        :param tmdbid:  TMDBID\n        :param page:  页码\n        \"\"\"\n        credit_infos = await self.tmdb.async_get_tv_credits(tmdbid=tmdbid, page=page)\n        if credit_infos:\n            return [schemas.MediaPerson(source=\"themoviedb\", **info) for info in credit_infos]\n        return []\n\n    async def async_tmdb_person_detail(self, person_id: int) -> schemas.MediaPerson:\n        \"\"\"\n        根据TMDBID查询人物详情（异步版本）\n        :param person_id:  人物ID\n        \"\"\"\n        detail = await self.tmdb.async_get_person_detail(person_id=person_id)\n        if detail:\n            return schemas.MediaPerson(source=\"themoviedb\", **detail)\n        return schemas.MediaPerson()\n\n    async def async_tmdb_person_credits(self, person_id: int, page: Optional[int] = 1) -> List[MediaInfo]:\n        \"\"\"\n        根据TMDBID查询人物参演作品（异步版本）\n        :param person_id:  人物ID\n        :param page:  页码\n        \"\"\"\n        infos = await self.tmdb.async_get_person_credits(person_id=person_id, page=page)\n        if infos:\n            return [MediaInfo(tmdb_info=tmdbinfo) for tmdbinfo in infos]\n        return []\n\n    def clear_cache(self):\n        \"\"\"\n        清除缓存\n        \"\"\"\n        logger.info(\"开始清除TMDB缓存 ...\")\n        self.tmdb.clear_cache()\n        self.cache.clear()\n        logger.info(\"TMDB缓存清除完成\")\n\n    def load_category_config(self) -> CategoryConfig:\n        \"\"\"\n        加载分类配置\n        \"\"\"\n        return self.category.load()\n\n    def save_category_config(self, config: CategoryConfig) -> bool:\n        \"\"\"\n        保存分类配置\n        \"\"\"\n        return self.category.save(config)\n"
  },
  {
    "path": "app/modules/themoviedb/category.py",
    "content": "import shutil\nfrom pathlib import Path\nfrom typing import Union\n\nimport ruamel.yaml\nfrom ruamel.yaml import CommentedMap\n\nfrom app.core.config import settings\nfrom app.log import logger\nfrom app.schemas.category import CategoryConfig\nfrom app.utils.singleton import WeakSingleton\n\nHEADER_COMMENTS = \"\"\"####### 配置说明 #######\n# 1. 该配置文件用于配置电影和电视剧的分类策略，配置后程序会按照配置的分类策略名称进行分类，配置文件采用yaml格式，需要严格符合语法规则\n# 2. 配置文件中的一级分类名称：`movie`、`tv` 为固定名称不可修改，二级名称同时也是目录名称，会按先后顺序匹配，匹配后程序会按这个名称建立二级目录\n# 3. 支持的分类条件：\n#   `original_language` 语种，具体含义参考下方字典\n#   `production_countries` 国家或地区（电影）、`origin_country` 国家或地区（电视剧），具体含义参考下方字典\n#   `genre_ids` 内容类型，具体含义参考下方字典\n#   `release_year` 发行年份，格式：YYYY，电影实际对应`release_date`字段，电视剧实际对应`first_air_date`字段，支持范围设定，如：`YYYY-YYYY`\n#   themoviedb 详情API返回的其它一级字段\n# 4. 配置多项条件时需要同时满足，一个条件需要匹配多个值是使用`,`分隔\n# 5. !条件值表示排除该值\n\n\"\"\"\n\n\nclass CategoryHelper(metaclass=WeakSingleton):\n    \"\"\"\n    二级分类\n    \"\"\"\n\n    def __init__(self):\n        self._category_path: Path = settings.CONFIG_PATH / \"category.yaml\"\n        self._categorys = {}\n        self._movie_categorys = {}\n        self._tv_categorys = {}\n        self.init()\n\n    def init(self):\n        \"\"\"\n        初始化\n        \"\"\"\n        try:\n            if not self._category_path.exists():\n                shutil.copy(settings.INNER_CONFIG_PATH / \"category.yaml\", self._category_path)\n            with open(self._category_path, mode='r', encoding='utf-8') as f:\n                try:\n                    yaml_loader = ruamel.yaml.YAML()\n                    self._categorys = yaml_loader.load(f)\n                except Exception as e:\n                    logger.warn(f\"二级分类策略配置文件格式出现严重错误！请检查：{str(e)}\")\n                    self._categorys = {}\n        except Exception as err:\n            logger.warn(f\"二级分类策略配置文件加载出错：{str(err)}\")\n\n        if self._categorys:\n            self._movie_categorys = self._categorys.get('movie')\n            self._tv_categorys = self._categorys.get('tv')\n        logger.info(f\"已加载二级分类策略 category.yaml\")\n\n    def load(self) -> CategoryConfig:\n        \"\"\"\n        加载配置\n        \"\"\"\n        config = CategoryConfig()\n        if not self._category_path.exists():\n            return config\n        try:\n            with open(self._category_path, 'r', encoding='utf-8') as f:\n                yaml_loader = ruamel.yaml.YAML()\n                data = yaml_loader.load(f)\n                if data:\n                    config = CategoryConfig(**data)\n        except Exception as e:\n            logger.error(f\"Load category config failed: {e}\")\n        return config\n\n    def save(self, config: CategoryConfig) -> bool:\n        \"\"\"\n        保存配置\n        \"\"\"\n        data = config.model_dump(exclude_none=True)\n        try:\n            with open(self._category_path, 'w', encoding='utf-8') as f:\n                f.write(HEADER_COMMENTS)\n                yaml_dumper = ruamel.yaml.YAML()\n                yaml_dumper.dump(data, f)\n            # 保存后重新加载配置\n            self.init()\n            return True\n        except Exception as e:\n            logger.error(f\"Save category config failed: {e}\")\n            return False\n\n    @property\n    def is_movie_category(self) -> bool:\n        \"\"\"\n        获取电影分类标志\n        \"\"\"\n        if self._movie_categorys:\n            return True\n        return False\n\n    @property\n    def is_tv_category(self) -> bool:\n        \"\"\"\n        获取电视剧分类标志\n        \"\"\"\n        if self._tv_categorys:\n            return True\n        return False\n\n    @property\n    def movie_categorys(self) -> list:\n        \"\"\"\n        获取电影分类清单\n        \"\"\"\n        if not self._movie_categorys:\n            return []\n        return list(self._movie_categorys.keys())\n\n    @property\n    def tv_categorys(self) -> list:\n        \"\"\"\n        获取电视剧分类清单\n        \"\"\"\n        if not self._tv_categorys:\n            return []\n        return list(self._tv_categorys.keys())\n\n    def get_movie_category(self, tmdb_info) -> str:\n        \"\"\"\n        判断电影的分类\n        :param tmdb_info: 识别的TMDB中的信息\n        :return: 二级分类的名称\n        \"\"\"\n        return self.get_category(self._movie_categorys, tmdb_info)\n\n    def get_tv_category(self, tmdb_info) -> str:\n        \"\"\"\n        判断电视剧的分类，包括动漫\n        :param tmdb_info: 识别的TMDB中的信息\n        :return: 二级分类的名称\n        \"\"\"\n        return self.get_category(self._tv_categorys, tmdb_info)\n\n    @staticmethod\n    def get_category(categorys: Union[dict, CommentedMap], tmdb_info: dict) -> str:\n        \"\"\"\n        根据 TMDB信息与分类配置文件进行比较，确定所属分类\n        :param categorys: 分类配置\n        :param tmdb_info: TMDB信息\n        :return: 分类的名称\n        \"\"\"\n        if not tmdb_info:\n            return \"\"\n        if not categorys:\n            return \"\"\n\n        for key, item in categorys.items():\n            if not item:\n                return key\n            match_flag = True\n            for attr, value in item.items():\n                if not value:\n                    continue\n                if attr == \"release_year\":\n                    # 发行年份\n                    info_value = tmdb_info.get(\"release_date\") or tmdb_info.get(\"first_air_date\")\n                    if info_value:\n                        info_value = str(info_value)[:4]\n                else:\n                    info_value = tmdb_info.get(attr)\n                if not info_value:\n                    match_flag = False\n                    continue\n                elif attr == \"production_countries\":\n                    # 制片国家\n                    info_values = [str(val.get(\"iso_3166_1\")).upper() for val in info_value]  # type: ignore\n                else:\n                    if isinstance(info_value, list):\n                        info_values = [str(val).upper() for val in info_value]\n                    else:\n                        info_values = [str(info_value).upper()]\n\n                values = []\n                invert_values = []\n\n                # 如果有 \",\" 进行分割\n                values = [str(val) for val in value.split(\",\") if val]\n\n                expanded_values = []\n                for v in values:\n                    if \"-\" not in v:\n                        expanded_values.append(v)\n                        continue\n\n                    # - 表示范围\n                    value_begin, value_end = v.split(\"-\", 1)\n\n                    prefix = \"\"\n                    if value_begin.startswith('!'):\n                        prefix = '!'\n                        value_begin = value_begin[1:]\n\n                    if value_begin.isdigit() and value_end.isdigit():\n                        # 数字范围\n                        expanded_values.extend(f\"{prefix}{val}\" for val in range(int(value_begin), int(value_end) + 1))\n                    else:\n                        # 字符串范围\n                        expanded_values.extend([f\"{prefix}{value_begin}\", f\"{prefix}{value_end}\"])\n\n                values = list(map(str.upper, expanded_values))\n\n                invert_values = [val[1:] for val in values if val.startswith('!')]\n                values = [val for val in values if not val.startswith('!')]\n\n                if values and not set(values).intersection(set(info_values)):\n                    match_flag = False\n                if invert_values and set(invert_values).intersection(set(info_values)):\n                    match_flag = False\n            if match_flag:\n                return key\n        return \"\"\n"
  },
  {
    "path": "app/modules/themoviedb/scraper.py",
    "content": "from pathlib import Path\nfrom typing import Optional, Tuple\nfrom xml.dom import minidom\n\nfrom app.core.config import settings\nfrom app.core.context import MediaInfo\nfrom app.core.meta import MetaBase\nfrom app.schemas.types import MediaType\nfrom app.utils.dom import DomUtils\nfrom app.modules.themoviedb.tmdbapi import TmdbApi\n\n\nclass TmdbScraper:\n    _meta_tmdb = None\n    _img_tmdb = None\n\n    @property\n    def default_tmdb(self):\n        \"\"\"\n        获取元数据TMDB Api\n        \"\"\"\n        if not self._meta_tmdb:\n            self._meta_tmdb = TmdbApi(language=settings.TMDB_LOCALE)\n        return self._meta_tmdb\n\n    def original_tmdb(self, mediainfo: Optional[MediaInfo] = None):\n        \"\"\"\n        获取图片TMDB Api\n        \"\"\"\n        if settings.TMDB_SCRAP_ORIGINAL_IMAGE and mediainfo:\n            return TmdbApi(language=mediainfo.original_language)\n        return self.default_tmdb\n\n    def get_metadata_nfo(self, meta: MetaBase, mediainfo: MediaInfo,\n                         season: Optional[int] = None, episode: Optional[int] = None) -> Optional[str]:\n        \"\"\"\n        获取NFO文件内容文本\n        :param meta: 元数据\n        :param mediainfo: 媒体信息\n        :param season: 季号\n        :param episode: 集号\n        \"\"\"\n        if mediainfo.type == MediaType.MOVIE:\n            # 电影元数据文件\n            doc = self.__gen_movie_nfo_file(mediainfo=mediainfo)\n        else:\n            if season is not None:\n                # 查询季信息\n                if mediainfo.episode_group:\n                    seasoninfo = self.default_tmdb.get_tv_group_detail(mediainfo.episode_group, season=season)\n                else:\n                    seasoninfo = self.default_tmdb.get_tv_season_detail(mediainfo.tmdb_id, season=season)\n                if episode:\n                    # 集元数据文件\n                    episodeinfo = self.__get_episode_detail(seasoninfo, meta.begin_episode)\n                    doc = self.__gen_tv_episode_nfo_file(episodeinfo=episodeinfo, tmdbid=mediainfo.tmdb_id,\n                                                         season=season, episode=episode)\n                else:\n                    # 季元数据文件\n                    doc = self.__gen_tv_season_nfo_file(seasoninfo=seasoninfo, season=season)\n            else:\n                # 电视剧元数据文件\n                doc = self.__gen_tv_nfo_file(mediainfo=mediainfo)\n        if doc:\n            return doc.toprettyxml(indent=\"  \", encoding=\"utf-8\")  # noqa\n\n        return None\n\n    def get_metadata_img(self, mediainfo: MediaInfo, season: Optional[int] = None,\n                         episode: Optional[int] = None) -> dict:\n        \"\"\"\n        获取图片名称和url\n        :param mediainfo: 媒体信息\n        :param season: 季号\n        :param episode: 集号\n        \"\"\"\n        images = {}\n        if season is not None:\n            # 只需要季集的图片\n            if episode:\n                # 集的图片\n                if mediainfo.episode_group:\n                    seasoninfo = self.original_tmdb(mediainfo).get_tv_group_detail(mediainfo.episode_group, season)\n                else:\n                    seasoninfo = self.original_tmdb(mediainfo).get_tv_season_detail(mediainfo.tmdb_id, season)\n                if seasoninfo:\n                    episodeinfo = self.__get_episode_detail(seasoninfo, episode)\n                    if still_path := episodeinfo.get(\"still_path\"):\n                        # TMDB集still图片\n                        still_name = f\"{episode}\"\n                        still_url = settings.TMDB_IMAGE_URL(still_path)\n                        images[still_name] = still_url\n            else:\n                # 季的图片\n                seasoninfo = self.original_tmdb(mediainfo).get_tv_season_detail(mediainfo.tmdb_id, season)\n                if seasoninfo:\n                    # TMDB季poster图片\n                    poster_name, poster_url = self.get_season_poster(seasoninfo, season)\n                    if poster_name and poster_url:\n                        images[poster_name] = poster_url\n            return images\n        else:\n            # 获取媒体信息中原有图片（TheMovieDb或Fanart）\n            for attr_name, attr_value in vars(mediainfo).items():\n                if attr_value \\\n                        and attr_name.endswith(\"_path\") \\\n                        and attr_value \\\n                        and isinstance(attr_value, str) \\\n                        and attr_value.startswith(\"http\"):\n                    image_name = attr_name.replace(\"_path\", \"\") + Path(attr_value).suffix\n                    images[image_name] = attr_value\n            # 替换原语言Poster\n            if settings.TMDB_SCRAP_ORIGINAL_IMAGE:\n                _mediainfo = self.original_tmdb(mediainfo).get_info(mediainfo.type, mediainfo.tmdb_id)\n                if _mediainfo:\n                    for attr_name, attr_value in _mediainfo.items():\n                        if attr_name.endswith(\"_path\") and attr_value is not None:\n                            image_url = settings.TMDB_IMAGE_URL(attr_value)\n                            image_name = attr_name.replace(\"_path\", \"\") + Path(image_url).suffix\n                            images[image_name] = image_url\n            return images\n\n    @staticmethod\n    def get_season_poster(seasoninfo: dict, season: int) -> Tuple[str, str]:\n        \"\"\"\n        获取季的海报\n        \"\"\"\n        # TMDB季poster图片\n        sea_seq = str(season).rjust(2, '0')\n        if poster_path := seasoninfo.get(\"poster_path\"):\n            # 后缀\n            ext = Path(poster_path).suffix\n            # URL\n            url = settings.TMDB_IMAGE_URL(poster_path)\n            # S0海报格式不同\n            if season == 0:\n                image_name = f\"season-specials-poster{ext}\"\n            else:\n                image_name = f\"season{sea_seq}-poster{ext}\"\n            return image_name, url\n        return \"\", \"\"\n\n    @staticmethod\n    def __get_episode_detail(seasoninfo: dict, episode: int) -> dict:\n        \"\"\"\n        根据季信息获取集的信息\n        \"\"\"\n        for _episode_info in seasoninfo.get(\"episodes\") or []:\n            if _episode_info.get(\"episode_number\") == episode:\n                return _episode_info\n        return {}\n\n    @staticmethod\n    def __gen_common_nfo(mediainfo: MediaInfo, doc: minidom.Document, root: minidom.Element):\n        \"\"\"\n        生成公共NFO\n        \"\"\"\n        # TMDB\n        DomUtils.add_node(doc, root, \"tmdbid\", mediainfo.tmdb_id or \"\")\n        uniqueid_tmdb = DomUtils.add_node(doc, root, \"uniqueid\", mediainfo.tmdb_id or \"\")\n        uniqueid_tmdb.setAttribute(\"type\", \"tmdb\")\n        uniqueid_tmdb.setAttribute(\"default\", \"true\")\n        # TVDB\n        if mediainfo.tvdb_id:\n            DomUtils.add_node(doc, root, \"tvdbid\", str(mediainfo.tvdb_id))\n            uniqueid_tvdb = DomUtils.add_node(doc, root, \"uniqueid\", str(mediainfo.tvdb_id))\n            uniqueid_tvdb.setAttribute(\"type\", \"tvdb\")\n        # IMDB\n        if mediainfo.imdb_id:\n            DomUtils.add_node(doc, root, \"imdbid\", mediainfo.imdb_id)\n            uniqueid_imdb = DomUtils.add_node(doc, root, \"uniqueid\", mediainfo.imdb_id)\n            uniqueid_imdb.setAttribute(\"type\", \"imdb\")\n            uniqueid_imdb.setAttribute(\"default\", \"true\")\n            uniqueid_tmdb.setAttribute(\"default\", \"false\")\n\n        # 简介\n        xplot = DomUtils.add_node(doc, root, \"plot\")\n        xplot.appendChild(doc.createCDATASection(mediainfo.overview or \"\"))\n        xoutline = DomUtils.add_node(doc, root, \"outline\")\n        xoutline.appendChild(doc.createCDATASection(mediainfo.overview or \"\"))\n        # 导演\n        for director in mediainfo.directors:\n            xdirector = DomUtils.add_node(doc, root, \"director\", director.get(\"name\") or \"\")\n            xdirector.setAttribute(\"tmdbid\", str(director.get(\"id\") or \"\"))\n        # 演员\n        for actor in mediainfo.actors:\n            # 获取中文名\n            xactor = DomUtils.add_node(doc, root, \"actor\")\n            DomUtils.add_node(doc, xactor, \"name\", actor.get(\"name\") or \"\")\n            DomUtils.add_node(doc, xactor, \"type\", \"Actor\")\n            DomUtils.add_node(doc, xactor, \"role\", actor.get(\"character\") or actor.get(\"role\") or \"\")\n            DomUtils.add_node(doc, xactor, \"tmdbid\", actor.get(\"id\") or \"\")\n            if profile_path := actor.get('profile_path'):\n                DomUtils.add_node(doc, xactor, \"thumb\", settings.TMDB_IMAGE_URL(profile_path))\n            DomUtils.add_node(doc, xactor, \"profile\",\n                              f\"https://www.themoviedb.org/person/{actor.get('id')}\")\n        # 风格\n        genres = mediainfo.genres or []\n        for genre in genres:\n            DomUtils.add_node(doc, root, \"genre\", genre.get(\"name\") or \"\")\n        # 评分\n        DomUtils.add_node(doc, root, \"rating\", mediainfo.vote_average or \"0\")\n        # 内容分级\n        if content_rating := mediainfo.content_rating:\n            DomUtils.add_node(doc, root, \"mpaa\", content_rating)\n\n        return doc\n\n    def __gen_movie_nfo_file(self, mediainfo: MediaInfo) -> minidom.Document:\n        \"\"\"\n        生成电影的NFO描述文件\n        :param mediainfo: 识别后的媒体信息\n        \"\"\"\n        # 开始生成XML\n        doc = minidom.Document()\n        root = DomUtils.add_node(doc, doc, \"movie\")\n        # 公共部分\n        doc = self.__gen_common_nfo(mediainfo=mediainfo,\n                                    doc=doc,\n                                    root=root)\n        # 标题\n        DomUtils.add_node(doc, root, \"title\", mediainfo.title or \"\")\n        DomUtils.add_node(doc, root, \"originaltitle\", mediainfo.original_title or \"\")\n        # 发布日期\n        DomUtils.add_node(doc, root, \"premiered\", mediainfo.release_date or \"\")\n        # 年份\n        DomUtils.add_node(doc, root, \"year\", mediainfo.year or \"\")\n        return doc\n\n    def __gen_tv_nfo_file(self, mediainfo: MediaInfo) -> minidom.Document:\n        \"\"\"\n        生成电视剧的NFO描述文件\n        :param mediainfo: 媒体信息\n        \"\"\"\n        # 开始生成XML\n        doc = minidom.Document()\n        root = DomUtils.add_node(doc, doc, \"tvshow\")\n        # 公共部分\n        doc = self.__gen_common_nfo(mediainfo=mediainfo,\n                                    doc=doc,\n                                    root=root)\n        # 标题\n        DomUtils.add_node(doc, root, \"title\", mediainfo.title or \"\")\n        DomUtils.add_node(doc, root, \"originaltitle\", mediainfo.original_title or \"\")\n        # 发布日期\n        DomUtils.add_node(doc, root, \"premiered\", mediainfo.release_date or \"\")\n        # 年份\n        DomUtils.add_node(doc, root, \"year\", mediainfo.year or \"\")\n        DomUtils.add_node(doc, root, \"season\", \"-1\")\n        DomUtils.add_node(doc, root, \"episode\", \"-1\")\n\n        return doc\n\n    @staticmethod\n    def __gen_tv_season_nfo_file(seasoninfo: dict, season: int) -> minidom.Document:\n        \"\"\"\n        生成电视剧季的NFO描述文件\n        :param seasoninfo: TMDB季媒体信息\n        :param season: 季号\n        \"\"\"\n        doc = minidom.Document()\n        root = DomUtils.add_node(doc, doc, \"season\")\n        # 简介\n        xplot = DomUtils.add_node(doc, root, \"plot\")\n        xplot.appendChild(doc.createCDATASection(seasoninfo.get(\"overview\") or \"\"))\n        xoutline = DomUtils.add_node(doc, root, \"outline\")\n        xoutline.appendChild(doc.createCDATASection(seasoninfo.get(\"overview\") or \"\"))\n        # 标题\n        DomUtils.add_node(doc, root, \"title\", seasoninfo.get(\"name\") or \"季 %s\" % season)\n        # 发行日期\n        DomUtils.add_node(doc, root, \"premiered\", seasoninfo.get(\"air_date\") or \"\")\n        DomUtils.add_node(doc, root, \"releasedate\", seasoninfo.get(\"air_date\") or \"\")\n        # 发行年份\n        DomUtils.add_node(doc, root, \"year\",\n                          seasoninfo.get(\"air_date\")[:4] if seasoninfo.get(\"air_date\") else \"\")\n        # seasonnumber\n        DomUtils.add_node(doc, root, \"seasonnumber\", str(season))\n        return doc\n\n    @staticmethod\n    def __gen_tv_episode_nfo_file(tmdbid: int,\n                                  episodeinfo: dict,\n                                  season: int,\n                                  episode: int) -> minidom.Document:\n        \"\"\"\n        生成电视剧集的NFO描述文件\n        :param tmdbid: TMDBID\n        :param episodeinfo: 集TMDB元数据\n        :param season: 季号\n        :param episode: 集号\n        \"\"\"\n        # 开始生成集的信息\n        doc = minidom.Document()\n        root = DomUtils.add_node(doc, doc, \"episodedetails\")\n        # TMDBID\n        uniqueid = DomUtils.add_node(doc, root, \"uniqueid\", str(episodeinfo.get(\"id\")))\n        uniqueid.setAttribute(\"type\", \"tmdb\")\n        uniqueid.setAttribute(\"default\", \"true\")\n        # tmdbid\n        # 应与uniqueid一致 使用剧集id 否则jellyfin/emby会将此id覆盖上面的uniqueid\n        DomUtils.add_node(doc, root, \"tmdbid\", str(episodeinfo.get(\"id\")))\n        # 标题\n        DomUtils.add_node(doc, root, \"title\", episodeinfo.get(\"name\") or \"第 %s 集\" % episode)\n        # 简介\n        xplot = DomUtils.add_node(doc, root, \"plot\")\n        xplot.appendChild(doc.createCDATASection(episodeinfo.get(\"overview\") or \"\"))\n        xoutline = DomUtils.add_node(doc, root, \"outline\")\n        xoutline.appendChild(doc.createCDATASection(episodeinfo.get(\"overview\") or \"\"))\n        # 发布日期\n        DomUtils.add_node(doc, root, \"aired\", episodeinfo.get(\"air_date\") or \"\")\n        # 年份\n        DomUtils.add_node(doc, root, \"year\",\n                          episodeinfo.get(\"air_date\")[:4] if episodeinfo.get(\"air_date\") else \"\")\n        # 季\n        DomUtils.add_node(doc, root, \"season\", str(season))\n        # 集\n        DomUtils.add_node(doc, root, \"episode\", str(episode))\n        # 评分\n        DomUtils.add_node(doc, root, \"rating\", episodeinfo.get(\"vote_average\") or \"0\")\n        # 导演\n        directors = episodeinfo.get(\"crew\") or []\n        for director in directors:\n            if director.get(\"known_for_department\") == \"Directing\":\n                xdirector = DomUtils.add_node(doc, root, \"director\", director.get(\"name\") or \"\")\n                xdirector.setAttribute(\"tmdbid\", str(director.get(\"id\") or \"\"))\n        # 演员\n        actors = episodeinfo.get(\"guest_stars\") or []\n        for actor in actors:\n            if actor.get(\"known_for_department\") == \"Acting\":\n                xactor = DomUtils.add_node(doc, root, \"actor\")\n                DomUtils.add_node(doc, xactor, \"name\", actor.get(\"name\") or \"\")\n                DomUtils.add_node(doc, xactor, \"type\", \"Actor\")\n                DomUtils.add_node(doc, xactor, \"tmdbid\", actor.get(\"id\") or \"\")\n                if profile_path := actor.get('profile_path'):\n                    DomUtils.add_node(doc, xactor, \"thumb\", settings.TMDB_IMAGE_URL(profile_path))\n                DomUtils.add_node(doc, xactor, \"profile\",\n                                  f\"https://www.themoviedb.org/person/{actor.get('id')}\")\n        return doc\n"
  },
  {
    "path": "app/modules/themoviedb/tmdb_cache.py",
    "content": "import pickle\nimport traceback\nfrom pathlib import Path\nfrom threading import RLock\n\nfrom app.core.cache import TTLCache\nfrom app.core.config import settings\nfrom app.core.meta import MetaBase\nfrom app.log import logger\nfrom app.schemas.types import MediaType\nfrom app.utils.singleton import WeakSingleton\n\nlock = RLock()\n\n\nclass TmdbCache(metaclass=WeakSingleton):\n    \"\"\"\n    TMDB缓存数据\n    {\n        \"id\": '',\n        \"title\": '',\n        \"year\": '',\n        \"type\": MediaType\n    }\n    \"\"\"\n    # TMDB缓存过期\n    _tmdb_cache_expire: bool = True\n\n    def __init__(self):\n        self.maxsize = settings.CONF.douban\n        self.ttl = settings.CONF.meta\n        self.region = \"__tmdb_cache__\"\n        self._meta_filepath = settings.TEMP_PATH / self.region\n        # 初始化缓存\n        self._cache = TTLCache(region=self.region, maxsize=self.maxsize, ttl=self.ttl)\n        # 非Redis加载本地缓存数据\n        if not self._cache.is_redis():\n            for key, value in self.__load(self._meta_filepath).items():\n                self._cache.set(key, value)\n\n    def clear(self):\n        \"\"\"\n        清空所有TMDB缓存\n        \"\"\"\n        with lock:\n            self._cache.clear()\n\n    @staticmethod\n    def __get_key(meta: MetaBase) -> str:\n        \"\"\"\n        获取缓存KEY\n        \"\"\"\n        return f\"[{meta.type.value if meta.type else '未知'}][{settings.TMDB_LOCALE}]{meta.tmdbid or meta.name}-{meta.year}-{meta.begin_season}\"\n\n    def get(self, meta: MetaBase):\n        \"\"\"\n        根据KEY值获取缓存值\n        \"\"\"\n        key = self.__get_key(meta)\n\n        with lock:\n            return self._cache.get(key) or {}\n\n    def delete(self, key: str) -> dict:\n        \"\"\"\n        删除缓存信息\n        @param key: 缓存key\n        @return: 被删除的缓存内容\n        \"\"\"\n        with lock:\n            redis_data = self._cache.get(key)\n            if redis_data:\n                self._cache.delete(key)\n                return redis_data\n            return {}\n\n    def modify(self, key: str, title: str) -> dict:\n        \"\"\"\n        修改缓存信息\n        @param key: 缓存key\n        @param title: 标题\n        @return: 被修改后缓存内容\n        \"\"\"\n        with lock:\n            redis_data = self._cache.get(key)\n            if redis_data:\n                redis_data['title'] = title\n                self._cache.set(key, redis_data)\n                return redis_data\n            return {}\n\n    @staticmethod\n    def __load(path: Path) -> dict:\n        \"\"\"\n        从文件中加载缓存\n        \"\"\"\n        try:\n            if path.exists():\n                with open(path, 'rb') as f:\n                    data = pickle.load(f)\n                return data\n        except Exception as e:\n            logger.error(f'加载缓存失败：{str(e)} - {traceback.format_exc()}')\n        return {}\n\n    def update(self, meta: MetaBase, info: dict) -> None:\n        \"\"\"\n        新增或更新缓存条目\n        \"\"\"\n        key = self.__get_key(meta)\n        if info:\n            # 缓存标题\n            cache_title = info.get(\"title\") \\\n                if info.get(\"media_type\") == MediaType.MOVIE else info.get(\"name\")\n            # 缓存年份\n            cache_year = info.get('release_date') \\\n                if info.get(\"media_type\") == MediaType.MOVIE else info.get('first_air_date')\n            if cache_year:\n                cache_year = cache_year[:4]\n\n            with lock:\n                # 缓存数据\n                cache_data = {\n                    \"id\": info.get(\"id\"),\n                    \"type\": info.get(\"media_type\"),\n                    \"year\": cache_year,\n                    \"title\": cache_title,\n                    \"poster_path\": info.get(\"poster_path\"),\n                    \"backdrop_path\": info.get(\"backdrop_path\")\n                }\n                self._cache.set(key, cache_data)\n\n        elif info is not None:\n            # None时不缓存，此时代表网络错误，允许重复请求\n            with lock:\n                self._cache.set(key, {\"id\": 0})\n\n    def save(self, force: bool = False) -> None:\n        \"\"\"\n        保存缓存数据到文件\n        \"\"\"\n        # Redis不需要保存到本地文件\n        if self._cache.is_redis():\n            return\n\n        # Redis不可用时，保存到本地文件\n        meta_data = self.__load(self._meta_filepath)\n        # 当前缓存，去除无法识别\n        new_meta_data = {k: v for k, v in self._cache.items() if v.get(\"id\")}\n\n        if not force \\\n                and meta_data.keys() == new_meta_data.keys():\n            return\n\n        with open(self._meta_filepath, 'wb') as f:\n            pickle.dump(new_meta_data, f, pickle.HIGHEST_PROTOCOL)  # type: ignore\n\n    def __del__(self):\n        self.save()\n"
  },
  {
    "path": "app/modules/themoviedb/tmdbapi.py",
    "content": "import re\nimport traceback\nfrom typing import Optional, List\nfrom urllib.parse import quote\n\nimport zhconv\nfrom lxml import etree\n\nfrom app.core.cache import cached\nfrom app.core.config import settings\nfrom app.log import logger\nfrom app.schemas import APIRateLimitException\nfrom app.schemas.types import MediaType\nfrom app.utils.http import RequestUtils, AsyncRequestUtils\nfrom app.utils.limit import rate_limit_exponential\nfrom app.utils.string import StringUtils\nfrom .tmdbv3api import TMDb, Search, Movie, TV, Season, Episode, Discover, Trending, Person, Collection\nfrom .tmdbv3api.exceptions import TMDbException\n\n\nclass TmdbApi:\n    \"\"\"\n    TMDB识别匹配\n    \"\"\"\n\n    def __init__(self, language: Optional[str] = None):\n        # TMDB主体\n        self.tmdb = TMDb(language=language)\n        # TMDB查询对象\n        self.search = Search(language=language)\n        self.movie = Movie(language=language)\n        self.tv = TV(language=language)\n        self.season_obj = Season(language=language)\n        self.episode_obj = Episode(language=language)\n        self.discover = Discover(language=language)\n        self.trending = Trending(language=language)\n        self.person = Person(language=language)\n        self.collection = Collection(language=language)\n\n    def search_multiis(self, title: str) -> List[dict]:\n        \"\"\"\n        同时查询模糊匹配的电影、电视剧TMDB信息\n        \"\"\"\n        if not title:\n            return []\n        ret_infos = []\n        multis = self.search.multi(term=title) or []\n        for multi in multis:\n            if multi.get(\"media_type\") in [\"movie\", \"tv\"]:\n                multi['media_type'] = MediaType.MOVIE if multi.get(\"media_type\") == \"movie\" else MediaType.TV\n                ret_infos.append(multi)\n        return ret_infos\n\n    def search_movies(self, title: str, year: str) -> List[dict]:\n        \"\"\"\n        查询模糊匹配的所有电影TMDB信息\n        \"\"\"\n        if not title:\n            return []\n        ret_infos = []\n        if year:\n            movies = self.search.movies(term=title, year=year) or []\n        else:\n            movies = self.search.movies(term=title) or []\n        for movie in movies:\n            if title in movie.get(\"title\"):\n                movie['media_type'] = MediaType.MOVIE\n                ret_infos.append(movie)\n        return ret_infos\n\n    def search_tvs(self, title: str, year: str) -> List[dict]:\n        \"\"\"\n        查询模糊匹配的所有电视剧TMDB信息\n        \"\"\"\n        if not title:\n            return []\n        ret_infos = []\n        if year:\n            tvs = self.search.tv_shows(term=title, release_year=year) or []\n        else:\n            tvs = self.search.tv_shows(term=title) or []\n        for tv in tvs:\n            if title in tv.get(\"name\"):\n                tv['media_type'] = MediaType.TV\n                ret_infos.append(tv)\n        return ret_infos\n\n    def search_persons(self, name: str) -> List[dict]:\n        \"\"\"\n        查询模糊匹配的所有人物TMDB信息\n        \"\"\"\n        if not name:\n            return []\n        return self.search.people(term=name) or []\n\n    def search_collections(self, name: str) -> List[dict]:\n        \"\"\"\n        查询模糊匹配的所有合集TMDB信息\n        \"\"\"\n        if not name:\n            return []\n        collections = self.search.collections(term=name) or []\n        for collection in collections:\n            collection['media_type'] = MediaType.COLLECTION\n            collection['collection_id'] = collection.get(\"id\")\n        return collections\n\n    def get_collection(self, collection_id: int) -> List[dict]:\n        \"\"\"\n        根据合集ID查询合集详情\n        \"\"\"\n        if not collection_id:\n            return []\n        try:\n            return self.collection.details(collection_id=collection_id)\n        except TMDbException as err:\n            logger.error(f\"连接TMDB出错：{str(err)}\")\n        except Exception as e:\n            logger.error(f\"连接TMDB出错：{str(e)}\")\n        return []\n\n    @staticmethod\n    def __compare_names(file_name: str, tmdb_names: list) -> bool:\n        \"\"\"\n        比较文件名是否匹配，忽略大小写和特殊字符\n        :param file_name: 识别的文件名或者种子名\n        :param tmdb_names: TMDB返回的译名\n        :return: True or False\n        \"\"\"\n        if not file_name or not tmdb_names:\n            return False\n        if not isinstance(tmdb_names, list):\n            tmdb_names = [tmdb_names]\n        file_name = StringUtils.clear(file_name).upper()\n        for tmdb_name in tmdb_names:\n            tmdb_name = StringUtils.clear(tmdb_name).strip().upper()\n            if file_name == tmdb_name:\n                return True\n        return False\n\n    # 公共方法\n    @staticmethod\n    def _validate_match_params(name: str, search_obj) -> bool:\n        \"\"\"\n        验证匹配方法的基本参数\n        \"\"\"\n        if not search_obj:\n            return False\n        if not name:\n            return False\n        return True\n\n    @staticmethod\n    def _generate_year_range(year: Optional[str]) -> List[Optional[str]]:\n        \"\"\"\n        生成年份范围用于匹配\n        \"\"\"\n        year_range = [year]\n        if year:\n            year_range.append(str(int(year) + 1))\n            year_range.append(str(int(year) - 1))\n        return year_range\n\n    @staticmethod\n    def _log_match_debug(mtype: MediaType, name: str, year: Optional[str] = None,\n                         season_number: Optional[int] = None, season_year: Optional[str] = None):\n        \"\"\"\n        记录匹配调试日志\n        \"\"\"\n        if season_number is not None and season_year:\n            logger.debug(f\"正在识别{mtype.value}：{name}, 季集={season_number}, 季集年份={season_year} ...\")\n        else:\n            logger.debug(f\"正在识别{mtype.value}：{name}, 年份={year} ...\")\n\n    @staticmethod\n    def _set_media_type(info: dict, mtype: MediaType) -> dict:\n        \"\"\"\n        设置媒体类型\n        \"\"\"\n        if info:\n            info['media_type'] = mtype\n        return info\n\n    @staticmethod\n    def _sort_multi_results(multis: List[dict]) -> List[dict]:\n        \"\"\"\n        按年份降序排列搜索结果，电影在前面\n        \"\"\"\n        return sorted(\n            multis,\n            key=lambda x: (\"1\"\n                           if x.get(\"media_type\") == \"movie\"\n                           else \"0\") + (x.get('release_date')\n                                        or x.get('first_air_date')\n                                        or '0000-00-00'),\n            reverse=True\n        )\n\n    @staticmethod\n    def _convert_media_type(ret_info: dict) -> dict:\n        \"\"\"\n        转换媒体类型为MediaType枚举\n        \"\"\"\n        if (ret_info\n                and not isinstance(ret_info.get(\"media_type\"), MediaType)):\n            ret_info['media_type'] = MediaType.MOVIE if ret_info.get(\"media_type\") == \"movie\" else MediaType.TV\n        return ret_info\n\n    def _match_multi_item(self, name: str, multi: dict, get_info_func) -> Optional[dict]:\n        \"\"\"\n        匹配单个多媒体搜索结果项\n        :param name: 查询名称\n        :param multi: 搜索结果项\n        :param get_info_func: 获取详细信息的函数（同步或异步）\n        :return: 匹配的结果或None\n        \"\"\"\n        if multi.get(\"media_type\") == \"movie\":\n            if self.__compare_names(name, multi.get('title')) \\\n                    or self.__compare_names(name, multi.get('original_title')):\n                return multi\n            # 匹配别名、译名\n            if not multi.get(\"names\"):\n                multi = get_info_func(mtype=MediaType.MOVIE, tmdbid=multi.get(\"id\"))\n            if multi and self.__compare_names(name, multi.get(\"names\")):\n                return multi\n        elif multi.get(\"media_type\") == \"tv\":\n            if self.__compare_names(name, multi.get('name')) \\\n                    or self.__compare_names(name, multi.get('original_name')):\n                return multi\n            # 匹配别名、译名\n            if not multi.get(\"names\"):\n                multi = get_info_func(mtype=MediaType.TV, tmdbid=multi.get(\"id\"))\n            if multi and self.__compare_names(name, multi.get(\"names\")):\n                return multi\n        return None\n\n    async def _async_match_multi_item(self, name: str, multi: dict) -> Optional[dict]:\n        \"\"\"\n        匹配单个多媒体搜索结果项（异步版本）\n        :param name: 查询名称\n        :param multi: 搜索结果项\n        :return: 匹配的结果或None\n        \"\"\"\n        if multi.get(\"media_type\") == \"movie\":\n            if self.__compare_names(name, multi.get('title')) \\\n                    or self.__compare_names(name, multi.get('original_title')):\n                return multi\n            # 匹配别名、译名\n            if not multi.get(\"names\"):\n                multi = await self.async_get_info(mtype=MediaType.MOVIE, tmdbid=multi.get(\"id\"))\n            if multi and self.__compare_names(name, multi.get(\"names\")):\n                return multi\n        elif multi.get(\"media_type\") == \"tv\":\n            if self.__compare_names(name, multi.get('name')) \\\n                    or self.__compare_names(name, multi.get('original_name')):\n                return multi\n            # 匹配别名、译名\n            if not multi.get(\"names\"):\n                multi = await self.async_get_info(mtype=MediaType.TV, tmdbid=multi.get(\"id\"))\n            if multi and self.__compare_names(name, multi.get(\"names\")):\n                return multi\n        return None\n\n    # match_web 公共方法\n    @staticmethod\n    def _validate_web_params(name: str) -> Optional[dict]:\n        \"\"\"\n        验证网站搜索参数\n        :return: None表示继续，dict表示直接返回结果\n        \"\"\"\n        if not name:\n            return None\n        if StringUtils.is_chinese(name):\n            return {}\n        return None  # 继续执行\n\n    @staticmethod\n    def _build_tmdb_search_url(name: str) -> str:\n        \"\"\"\n        构建TMDB搜索URL\n        \"\"\"\n        return \"https://www.themoviedb.org/search?query=%s\" % quote(name)\n\n    @staticmethod\n    def _validate_response(res) -> Optional[dict]:\n        \"\"\"\n        验证HTTP响应\n        :return: None表示继续，dict表示直接返回结果，Exception表示抛出异常\n        \"\"\"\n        if res is None:\n            return None\n        if res.status_code == 429:\n            raise APIRateLimitException(\"触发TheDbMovie网站限流，获取媒体信息失败\")\n        if res.status_code != 200:\n            return {}\n        return None  # 继续执行\n\n    @staticmethod\n    def _extract_tmdb_links(html_text: str, mtype: MediaType) -> List[str]:\n        \"\"\"\n        从HTML文本中提取TMDB链接\n        \"\"\"\n        if not html_text:\n            return []\n\n        html = None\n        try:\n            tmdb_links = []\n            html = etree.HTML(html_text)\n            if mtype == MediaType.TV:\n                links = html.xpath(\"//a[@data-id and @data-media-type='tv']/@href\")\n            else:\n                links = html.xpath(\"//a[@data-id]/@href\")\n            for link in links:\n                if not link or (not link.startswith(\"/tv\") and not link.startswith(\"/movie\")):\n                    continue\n                if link not in tmdb_links:\n                    tmdb_links.append(link)\n            return tmdb_links\n        except Exception as err:\n            logger.error(f\"解析TMDB网站HTML出错：{str(err)}\")\n            return []\n        finally:\n            if html is not None:\n                del html\n\n    @staticmethod\n    def _log_web_search_result(name: str, tmdbinfo: dict):\n        \"\"\"\n        记录网站搜索结果日志\n        \"\"\"\n        if tmdbinfo.get('media_type') == MediaType.MOVIE:\n            logger.info(\"%s 从WEB识别到 电影：TMDBID=%s, 名称=%s, 上映日期=%s\" % (\n                name,\n                tmdbinfo.get('id'),\n                tmdbinfo.get('title'),\n                tmdbinfo.get('release_date')))\n        else:\n            logger.info(\"%s 从WEB识别到 电视剧：TMDBID=%s, 名称=%s, 首播日期=%s\" % (\n                name,\n                tmdbinfo.get('id'),\n                tmdbinfo.get('name'),\n                tmdbinfo.get('first_air_date')))\n\n    def _process_web_search_links(self, name: str, mtype: MediaType,\n                                  tmdb_links: List[str], get_info_func) -> Optional[dict]:\n        \"\"\"\n        处理网站搜索得到的链接\n        \"\"\"\n        if len(tmdb_links) == 1:\n            tmdbid = self._parse_tmdb_id_from_link(tmdb_links[0])\n            if not tmdbid:\n                logger.warn(f\"无法从链接解析TMDBID：{tmdb_links[0]}\")\n                return {}\n            tmdbinfo = get_info_func(\n                mtype=MediaType.TV if tmdb_links[0].startswith(\"/tv\") else MediaType.MOVIE,\n                tmdbid=tmdbid)\n            if tmdbinfo:\n                if mtype == MediaType.TV and tmdbinfo.get('media_type') != MediaType.TV:\n                    return {}\n                self._log_web_search_result(name, tmdbinfo)\n            return tmdbinfo\n        elif len(tmdb_links) > 1:\n            logger.info(\"%s TMDB网站返回数据过多：%s\" % (name, len(tmdb_links)))\n        else:\n            logger.info(\"%s TMDB网站未查询到媒体信息！\" % name)\n        return {}\n\n    async def _async_process_web_search_links(self, name: str,\n                                              mtype: MediaType, tmdb_links: List[str]) -> Optional[dict]:\n        \"\"\"\n        处理网站搜索得到的链接（异步版本）\n        \"\"\"\n        if len(tmdb_links) == 1:\n            tmdbid = self._parse_tmdb_id_from_link(tmdb_links[0])\n            if not tmdbid:\n                logger.warn(f\"无法从链接解析TMDBID：{tmdb_links[0]}\")\n                return {}\n            tmdbinfo = await self.async_get_info(\n                mtype=MediaType.TV if tmdb_links[0].startswith(\"/tv\") else MediaType.MOVIE,\n                tmdbid=tmdbid)\n            if tmdbinfo:\n                if mtype == MediaType.TV and tmdbinfo.get('media_type') != MediaType.TV:\n                    return {}\n                self._log_web_search_result(name, tmdbinfo)\n            return tmdbinfo\n        elif len(tmdb_links) > 1:\n            logger.info(\"%s TMDB网站返回数据过多：%s\" % (name, len(tmdb_links)))\n        else:\n            logger.info(\"%s TMDB网站未查询到媒体信息！\" % name)\n        return {}\n\n    @staticmethod\n    def _parse_tmdb_id_from_link(link: str) -> Optional[int]:\n        \"\"\"\n        从 TMDB 相对链接中解析数值 ID。\n        兼容格式：/movie/1195631-william-tell、/tv/65942-re、/tv/79744-the-rookie\n        \"\"\"\n        if not link:\n            return None\n        match = re.match(r\"^/[^/]+/(\\d+)\", link)\n        if match:\n            try:\n                return int(match.group(1))\n            except Exception as err:\n                logger.debug(f\"解析TMDBID失败：{str(err)} - {traceback.format_exc()}\")\n                return None\n        return None\n\n    @staticmethod\n    def __get_names(tmdb_info: dict) -> List[str]:\n        \"\"\"\n        搜索tmdb中所有的标题和译名，用于名称匹配\n        :param tmdb_info: TMDB信息\n        :return: 所有译名的清单\n        \"\"\"\n        if not tmdb_info:\n            return []\n        ret_names = []\n        if tmdb_info.get('media_type') == MediaType.MOVIE:\n            alternative_titles = tmdb_info.get(\"alternative_titles\", {}).get(\"titles\", [])\n            for alternative_title in alternative_titles:\n                title = alternative_title.get(\"title\")\n                if title and title not in ret_names:\n                    ret_names.append(title)\n            translations = tmdb_info.get(\"translations\", {}).get(\"translations\", [])\n            for translation in translations:\n                title = translation.get(\"data\", {}).get(\"title\")\n                if title and title not in ret_names:\n                    ret_names.append(title)\n        else:\n            alternative_titles = tmdb_info.get(\"alternative_titles\", {}).get(\"results\", [])\n            for alternative_title in alternative_titles:\n                name = alternative_title.get(\"title\")\n                if name and name not in ret_names:\n                    ret_names.append(name)\n            translations = tmdb_info.get(\"translations\", {}).get(\"translations\", [])\n            for translation in translations:\n                name = translation.get(\"data\", {}).get(\"name\")\n                if name and name not in ret_names:\n                    ret_names.append(name)\n        return ret_names\n\n    def match(self, name: str,\n              mtype: MediaType,\n              year: Optional[str] = None,\n              season_year: Optional[str] = None,\n              season_number: Optional[int] = None,\n              group_seasons: Optional[List[dict]] = None) -> Optional[dict]:\n        \"\"\"\n        搜索tmdb中的媒体信息，匹配返回一条尽可能正确的信息\n        :param name: 检索的名称\n        :param mtype: 类型：电影、电视剧\n        :param year: 年份，如要是季集需要是首播年份(first_air_date)\n        :param season_year: 当前季集年份\n        :param season_number: 季集，整数\n        :param group_seasons: 集数组信息\n        :return: TMDB的INFO，同时会将mtype赋值到media_type中\n        \"\"\"\n        # 基本参数验证\n        if not self._validate_match_params(name, self.search):\n            return None\n\n        # TMDB搜索\n        info = {}\n        if mtype != MediaType.TV:\n            year_range = self._generate_year_range(year)\n            for search_year in year_range:\n                self._log_match_debug(mtype, name, search_year)\n                info = self.__search_movie_by_name(name, search_year)\n                if info:\n                    break\n            info = self._set_media_type(info, MediaType.MOVIE)\n        else:\n            # 有当前季和当前季集年份，使用精确匹配\n            if season_year and season_number is not None:\n                self._log_match_debug(mtype, name, season_year, season_number, season_year)\n                info = self.__search_tv_by_season(name,\n                                                  season_year,\n                                                  season_number,\n                                                  group_seasons)\n            if not info:\n                year_range = self._generate_year_range(year)\n                for search_year in year_range:\n                    self._log_match_debug(mtype, name, search_year)\n                    info = self.__search_tv_by_name(name, search_year)\n                    if info:\n                        break\n            info = self._set_media_type(info, MediaType.TV)\n        return info\n\n    def __search_movie_by_name(self, name: str, year: str) -> Optional[dict]:\n        \"\"\"\n        根据名称查询电影TMDB匹配\n        :param name: 识别的文件名或种子名\n        :param year: 电影上映日期\n        :return: 匹配的媒体信息\n        \"\"\"\n        try:\n            if year:\n                movies = self.search.movies(term=name, year=year)\n            else:\n                movies = self.search.movies(term=name)\n        except TMDbException as err:\n            logger.error(f\"连接TMDB出错：{str(err)}\")\n            return None\n        except Exception as e:\n            logger.error(f\"连接TMDB出错：{str(e)} - {traceback.format_exc()}\")\n            return None\n        logger.debug(f\"API返回：{str(self.search.total_results)}\")\n        if (movies is None) or (len(movies) == 0):\n            logger.debug(f\"{name} 未找到相关电影信息!\")\n            return {}\n        else:\n            # 按年份降序排列\n            movies = sorted(\n                movies,\n                key=lambda x: x.get('release_date') or '0000-00-00',\n                reverse=True\n            )\n            for movie in movies:\n                # 年份\n                movie_year = movie.get('release_date')[0:4] if movie.get('release_date') else None\n                if year and movie_year != year:\n                    # 年份不匹配\n                    continue\n                # 匹配标题、原标题\n                if self.__compare_names(name, movie.get('title')):\n                    return movie\n                if self.__compare_names(name, movie.get('original_title')):\n                    return movie\n                # 匹配别名、译名\n                if not movie.get(\"names\"):\n                    movie = self.get_info(mtype=MediaType.MOVIE, tmdbid=movie.get(\"id\"))\n                if movie and self.__compare_names(name, movie.get(\"names\")):\n                    return movie\n        return {}\n\n    def __search_tv_by_name(self, name: str, year: str) -> Optional[dict]:\n        \"\"\"\n        根据名称查询电视剧TMDB匹配\n        :param name: 识别的文件名或者种子名\n        :param year: 电视剧的首播年份\n        :return: 匹配的媒体信息\n        \"\"\"\n        try:\n            if year:\n                tvs = self.search.tv_shows(term=name, release_year=year)\n            else:\n                tvs = self.search.tv_shows(term=name)\n        except TMDbException as err:\n            logger.error(f\"连接TMDB出错：{str(err)}\")\n            return None\n        except Exception as e:\n            logger.error(f\"连接TMDB出错：{str(e)} - {traceback.format_exc()}\")\n            return None\n        logger.debug(f\"API返回：{str(self.search.total_results)}\")\n        if (tvs is None) or (len(tvs) == 0):\n            logger.debug(f\"{name} 未找到相关剧集信息!\")\n            return {}\n        else:\n            # 按年份降序排列\n            tvs = sorted(\n                tvs,\n                key=lambda x: x.get('first_air_date') or '0000-00-00',\n                reverse=True\n            )\n            for tv in tvs:\n                tv_year = tv.get('first_air_date')[0:4] if tv.get('first_air_date') else None\n                if year and tv_year != year:\n                    # 年份不匹配\n                    continue\n                # 匹配标题、原标题\n                if self.__compare_names(name, tv.get('name')):\n                    return tv\n                if self.__compare_names(name, tv.get('original_name')):\n                    return tv\n                # 匹配别名、译名\n                if not tv.get(\"names\"):\n                    tv = self.get_info(mtype=MediaType.TV, tmdbid=tv.get(\"id\"))\n                if tv and self.__compare_names(name, tv.get(\"names\")):\n                    return tv\n        return {}\n\n    def __search_tv_by_season(self, name: str, season_year: str, season_number: int,\n                              group_seasons: Optional[List[dict]] = None) -> Optional[dict]:\n        \"\"\"\n        根据电视剧的名称和季的年份及序号匹配TMDB\n        :param name: 识别的文件名或者种子名\n        :param season_year: 季的年份\n        :param season_number: 季序号\n        :param group_seasons: 集数组信息\n        :return: 匹配的媒体信息\n        \"\"\"\n\n        def __season_match(tv_info: dict, _season_year: str) -> bool:\n            if not tv_info:\n                return False\n            try:\n                if group_seasons:\n                    for group_season in group_seasons:\n                        season = group_season.get('order')\n                        if season != season_number:\n                            continue\n                        episodes = group_season.get('episodes')\n                        if not episodes:\n                            continue\n                        first_date = episodes[0].get(\"air_date\")\n                        if re.match(r\"^\\d{4}-\\d{2}-\\d{2}$\", first_date):\n                            if str(_season_year) == str(first_date).split(\"-\")[0]:\n                                return True\n                else:\n                    seasons = self.__get_tv_seasons(tv_info)\n                    for season, season_info in seasons.items():\n                        if season_info.get(\"air_date\"):\n                            if season_info.get(\"air_date\")[0:4] == str(_season_year) \\\n                                    and season == int(season_number):\n                                return True\n            except Exception as e1:\n                logger.error(f\"连接TMDB出错：{e1}\")\n                print(traceback.format_exc())\n                return False\n            return False\n\n        try:\n            tvs = self.search.tv_shows(term=name)\n        except TMDbException as err:\n            logger.error(f\"连接TMDB出错：{str(err)}\")\n            return None\n        except Exception as e:\n            logger.error(f\"连接TMDB出错：{str(e)}\")\n            print(traceback.format_exc())\n            return None\n\n        if (tvs is None) or (len(tvs) == 0):\n            logger.debug(\"%s 未找到季%s相关信息!\" % (name, season_number))\n            return {}\n        else:\n            # 按年份降序排列\n            tvs = sorted(\n                tvs,\n                key=lambda x: x.get('first_air_date') or '0000-00-00',\n                reverse=True\n            )\n            for tv in tvs:\n                # 使用年份、名称匹配\n                tv_year = tv.get('first_air_date')[0:4] if tv.get('first_air_date') else None\n                if (self.__compare_names(name, tv.get('name'))\n                    or self.__compare_names(name, tv.get('original_name'))) \\\n                        and (tv_year == str(season_year)):\n                    return tv\n                # 获取别名、译名重新匹配\n                if not tv.get(\"names\"):\n                    tv = self.get_info(mtype=MediaType.TV, tmdbid=tv.get(\"id\"))\n                if not tv or not (\n                    self.__compare_names(name, tv.get(\"name\"))\n                    or self.__compare_names(name, tv.get(\"original_name\"))\n                    or self.__compare_names(name, tv.get(\"names\"))):\n                    continue\n                if tv_year == str(season_year):\n                    return tv\n                # 季年份匹配\n                if __season_match(tv_info=tv, _season_year=season_year):\n                    return tv\n        return {}\n\n    @staticmethod\n    def __get_tv_seasons(tv_info: dict) -> Optional[dict]:\n        \"\"\"\n        查询TMDB电视剧的所有季\n        :param tv_info: TMDB 的季信息\n        :return: 包括每季集数的字典\n        \"\"\"\n        \"\"\"\n        \"seasons\": [\n            {\n              \"air_date\": \"2006-01-08\",\n              \"episode_count\": 11,\n              \"id\": 3722,\n              \"name\": \"特别篇\",\n              \"overview\": \"\",\n              \"poster_path\": \"/snQYndfsEr3Sto2jOmkmsQuUXAQ.jpg\",\n              \"season_number\": 0\n            },\n            {\n              \"air_date\": \"2005-03-27\",\n              \"episode_count\": 9,\n              \"id\": 3718,\n              \"name\": \"第 1 季\",\n              \"overview\": \"\",\n              \"poster_path\": \"/foM4ImvUXPrD2NvtkHyixq5vhPx.jpg\",\n              \"season_number\": 1\n            }\n        ]\n        \"\"\"\n        if not tv_info:\n            return {}\n        ret_seasons = {}\n        for season_info in tv_info.get(\"seasons\") or []:\n            if season_info.get(\"season_number\") is None:\n                continue\n            ret_seasons[season_info.get(\"season_number\")] = season_info\n        return ret_seasons\n\n    def match_multi(self, name: str) -> Optional[dict]:\n        \"\"\"\n        根据名称同时查询电影和电视剧，没有类型也没有年份时使用\n        :param name: 识别的文件名或种子名\n        :return: 匹配的媒体信息\n        \"\"\"\n        try:\n            multis = self.search.multi(term=name) or []\n        except TMDbException as err:\n            logger.error(f\"连接TMDB出错：{str(err)}\")\n            return None\n        except Exception as e:\n            logger.error(f\"连接TMDB出错：{str(e)}\")\n            print(traceback.format_exc())\n            return None\n        logger.debug(f\"API返回：{str(self.search.total_results)}\")\n\n        # 返回结果\n        if (multis is None) or (len(multis) == 0):\n            logger.debug(f\"{name} 未找到相关媒体息!\")\n            return {}\n\n        # 按年份降序排列，电影在前面\n        multis = self._sort_multi_results(multis)\n\n        ret_info = {}\n        for multi in multis:\n            matched = self._match_multi_item(name, multi, self.get_info)\n            if matched:\n                ret_info = matched\n                break\n\n        # 类型变更\n        return self._convert_media_type(ret_info)\n\n    @cached(maxsize=settings.CONF.tmdb, ttl=settings.CONF.meta)\n    @rate_limit_exponential(source=\"match_tmdb_web\", base_wait=5, max_wait=1800, enable_logging=True)\n    def match_web(self, name: str, mtype: MediaType) -> Optional[dict]:\n        \"\"\"\n        搜索TMDB网站，直接抓取结果，结果只有一条时才返回\n        :param name: 名称\n        :param mtype: 媒体类型\n        \"\"\"\n        # 参数验证\n        validation_result = self._validate_web_params(name)\n        if validation_result is not None:\n            return validation_result\n\n        logger.info(\"正在从TheMovieDb网站查询：%s ...\" % name)\n        tmdb_url = self._build_tmdb_search_url(name)\n        res = RequestUtils(timeout=5, ua=settings.NORMAL_USER_AGENT, proxies=settings.PROXY).get_res(url=tmdb_url)\n        if res is None:\n            logger.error(\"无法连接TheMovieDb\")\n            return None\n\n        # 响应验证\n        response_result = self._validate_response(res)\n        if response_result is not None:\n            return response_result\n\n        try:\n            # 提取链接\n            tmdb_links = self._extract_tmdb_links(res.text, mtype)\n            # 处理结果\n            return self._process_web_search_links(name, mtype, tmdb_links, self.get_info)\n        except Exception as err:\n            logger.error(f\"从TheDbMovie网站查询出错：{str(err)}\")\n            return {}\n\n    def get_info(self,\n                 mtype: MediaType,\n                 tmdbid: int) -> dict:\n        \"\"\"\n        给定TMDB号，查询一条媒体信息\n        :param mtype: 类型：电影、电视剧，为空时都查（此时用不上年份）\n        :param tmdbid: TMDB的ID，有tmdbid时优先使用tmdbid，否则使用年份和标题\n        \"\"\"\n\n        def __get_genre_ids(genres: list) -> list:\n            \"\"\"\n            从TMDB详情中获取genre_id列表\n            \"\"\"\n            if not genres:\n                return []\n            genre_ids = []\n            for genre in genres:\n                genre_ids.append(genre.get('id'))\n            return genre_ids\n\n        # 查询TMDB详情\n        if mtype == MediaType.MOVIE:\n            tmdb_info = self.__get_movie_detail(tmdbid)\n            if tmdb_info:\n                tmdb_info['media_type'] = MediaType.MOVIE\n        elif mtype == MediaType.TV:\n            tmdb_info = self.__get_tv_detail(tmdbid)\n            if tmdb_info:\n                tmdb_info['media_type'] = MediaType.TV\n        else:\n            tmdb_info_tv = self.__get_tv_detail(tmdbid)\n            tmdb_info_movie = self.__get_movie_detail(tmdbid)\n            if tmdb_info_tv and tmdb_info_movie:\n                tmdb_info = None\n                logger.warn(f\"无法判断tmdb_id:{tmdbid} 是电影还是电视剧\")\n            elif tmdb_info_tv:\n                tmdb_info = tmdb_info_tv\n                tmdb_info['media_type'] = MediaType.TV\n            elif tmdb_info_movie:\n                tmdb_info = tmdb_info_movie\n                tmdb_info['media_type'] = MediaType.MOVIE\n            else:\n                tmdb_info = None\n                logger.warn(f\"tmdb_id:{tmdbid} 未查询到媒体信息\")\n\n        if tmdb_info:\n            # 转换genreid\n            tmdb_info['genre_ids'] = __get_genre_ids(tmdb_info.get('genres'))\n            # 别名和译名\n            tmdb_info['names'] = self.__get_names(tmdb_info)\n            # 内容分级\n            tmdb_info['content_rating'] = self.__get_content_rating(tmdb_info)\n            # 转换多语种标题\n            self.__update_tmdbinfo_extra_title(tmdb_info)\n            # 转换中文标题\n            if self.tmdb.language in (\"zh\", \"zh-CN\"):\n                self.__update_tmdbinfo_cn_title(tmdb_info)\n\n        return tmdb_info\n\n    @staticmethod\n    def __get_content_rating(tmdb_info: dict) -> Optional[str]:\n        \"\"\"\n        获得tmdb中的内容评级\n        :param tmdb_info: TMDB信息\n        :return: 内容评级\n        \"\"\"\n        if not tmdb_info:\n            return None\n        # dict[地区:分级]\n        ratings = {}\n        if results := (tmdb_info.get(\"release_dates\") or {}).get(\"results\"):\n            \"\"\"\n            [\n                {\n                    \"iso_3166_1\": \"AR\",\n                    \"release_dates\": [\n                        {\n                            \"certification\": \"+13\",\n                            \"descriptors\": [],\n                            \"iso_639_1\": \"\",\n                            \"note\": \"\",\n                            \"release_date\": \"2025-01-23T00:00:00.000Z\",\n                            \"type\": 3\n                        }\n                    ]\n                }\n            ]\n            \"\"\"\n            for item in results:\n                iso_3166_1 = item.get(\"iso_3166_1\")\n                if not iso_3166_1:\n                    continue\n                dates = item.get(\"release_dates\")\n                if not dates:\n                    continue\n                certification = dates[0].get(\"certification\")\n                if not certification:\n                    continue\n                ratings[iso_3166_1] = certification\n        elif results := (tmdb_info.get(\"content_ratings\") or {}).get(\"results\"):\n            \"\"\"\n            [\n                {\n                    \"descriptors\": [],\n                    \"iso_3166_1\": \"US\",\n                    \"rating\": \"TV-MA\"\n                }\n            ]\n            \"\"\"\n            for item in results:\n                iso_3166_1 = item.get(\"iso_3166_1\")\n                if not iso_3166_1:\n                    continue\n                rating = item.get(\"rating\")\n                if not rating:\n                    continue\n                ratings[iso_3166_1] = rating\n        if not ratings:\n            return None\n        return ratings.get(\"CN\") or ratings.get(\"US\")\n\n    @staticmethod\n    def __update_tmdbinfo_cn_title(tmdb_info: dict):\n        \"\"\"\n        更新TMDB信息中的中文名称\n        \"\"\"\n\n        def __get_tmdb_chinese_title(tmdbinfo) -> Optional[str]:\n            \"\"\"\n            从别名中获取中文标题\n            \"\"\"\n            if not tmdbinfo:\n                return None\n            if tmdbinfo.get(\"media_type\") == MediaType.MOVIE:\n                alternative_titles = tmdbinfo.get(\"alternative_titles\", {}).get(\"titles\", [])\n            else:\n                alternative_titles = tmdbinfo.get(\"alternative_titles\", {}).get(\"results\", [])\n            for alternative_title in alternative_titles:\n                iso_3166_1 = alternative_title.get(\"iso_3166_1\")\n                if iso_3166_1 == \"CN\":\n                    title = alternative_title.get(\"title\")\n                    if title and StringUtils.is_chinese(title) \\\n                            and zhconv.convert(title, \"zh-hans\") == title:\n                        return title\n            return tmdbinfo.get(\"title\") if tmdbinfo.get(\"media_type\") == MediaType.MOVIE else tmdbinfo.get(\"name\")\n\n        # 原标题\n        org_title = tmdb_info.get(\"title\") \\\n            if tmdb_info.get(\"media_type\") == MediaType.MOVIE \\\n            else tmdb_info.get(\"name\")\n        # 查找中文名\n        if not StringUtils.is_chinese(org_title):\n            cn_title = __get_tmdb_chinese_title(tmdb_info)\n            if cn_title and cn_title != org_title:\n                # 使用中文别名\n                if tmdb_info.get(\"media_type\") == MediaType.MOVIE:\n                    tmdb_info['title'] = cn_title\n                else:\n                    tmdb_info['name'] = cn_title\n            else:\n                # 使用新加坡名\n                sg_title = tmdb_info.get(\"sg_title\")\n                if sg_title and sg_title != org_title and StringUtils.is_chinese(sg_title):\n                    if tmdb_info.get(\"media_type\") == MediaType.MOVIE:\n                        tmdb_info['title'] = sg_title\n                    else:\n                        tmdb_info['name'] = sg_title\n\n    @staticmethod\n    def __update_tmdbinfo_extra_title(tmdb_info: dict):\n        \"\"\"\n        更新TMDB信息中的其它语种名称\n        \"\"\"\n\n        def __get_tmdb_lang_title(tmdbinfo: dict, lang: Optional[str] = \"US\") -> Optional[str]:\n            \"\"\"\n            从译名中获取其它语种标题\n            \"\"\"\n            if not tmdbinfo:\n                return None\n            translations = tmdb_info.get(\"translations\", {}).get(\"translations\", [])\n            for translation in translations:\n                if translation.get(\"iso_3166_1\") == lang:\n                    return translation.get(\"data\", {}).get(\"title\") if tmdbinfo.get(\"media_type\") == MediaType.MOVIE \\\n                        else translation.get(\"data\", {}).get(\"name\")\n            return None\n\n        # 原标题\n        org_title = (\n            tmdb_info.get(\"original_title\")\n            if tmdb_info.get(\"media_type\") == MediaType.MOVIE\n            else tmdb_info.get(\"original_name\")\n        )\n        # 查找英文名\n        if tmdb_info.get(\"original_language\") == \"en\":\n            tmdb_info['en_title'] = org_title\n        else:\n            en_title = __get_tmdb_lang_title(tmdb_info, \"US\")\n            tmdb_info['en_title'] = en_title or org_title\n\n        # 查找香港台湾译名\n        tmdb_info['hk_title'] = __get_tmdb_lang_title(tmdb_info, \"HK\")\n        tmdb_info['tw_title'] = __get_tmdb_lang_title(tmdb_info, \"TW\")\n\n        # 查找新加坡名（用于替代中文名）\n        tmdb_info['sg_title'] = __get_tmdb_lang_title(tmdb_info, \"SG\") or org_title\n\n    def __get_movie_detail(self,\n                           tmdbid: int,\n                           append_to_response: Optional[str] = \"images,\"\n                                                               \"credits,\"\n                                                               \"alternative_titles,\"\n                                                               \"translations,\"\n                                                               \"release_dates,\"\n                                                               \"external_ids\") -> Optional[dict]:\n        \"\"\"\n        获取电影的详情\n        :param tmdbid: TMDB ID\n        :return: TMDB信息\n        \"\"\"\n        \"\"\"\n        {\n          \"adult\": false,\n          \"backdrop_path\": \"/r9PkFnRUIthgBp2JZZzD380MWZy.jpg\",\n          \"belongs_to_collection\": {\n            \"id\": 94602,\n            \"name\": \"穿靴子的猫（系列）\",\n            \"poster_path\": \"/anHwj9IupRoRZZ98WTBvHpTiE6A.jpg\",\n            \"backdrop_path\": \"/feU1DWV5zMWxXUHJyAIk3dHRQ9c.jpg\"\n          },\n          \"budget\": 90000000,\n          \"genres\": [\n            {\n              \"id\": 16,\n              \"name\": \"动画\"\n            },\n            {\n              \"id\": 28,\n              \"name\": \"动作\"\n            },\n            {\n              \"id\": 12,\n              \"name\": \"冒险\"\n            },\n            {\n              \"id\": 35,\n              \"name\": \"喜剧\"\n            },\n            {\n              \"id\": 10751,\n              \"name\": \"家庭\"\n            },\n            {\n              \"id\": 14,\n              \"name\": \"奇幻\"\n            }\n          ],\n          \"homepage\": \"\",\n          \"id\": 315162,\n          \"imdb_id\": \"tt3915174\",\n          \"original_language\": \"en\",\n          \"original_title\": \"Puss in Boots: The Last Wish\",\n          \"overview\": \"时隔11年，臭屁自大又爱卖萌的猫大侠回来了！如今的猫大侠（安东尼奥·班德拉斯 配音），依旧幽默潇洒又不拘小节、数次“花式送命”后，九条命如今只剩一条，于是不得不请求自己的老搭档兼“宿敌”——迷人的软爪妞（萨尔玛·海耶克 配音）来施以援手来恢复自己的九条生命。\",\n          \"popularity\": 8842.129,\n          \"poster_path\": \"/rnn30OlNPiC3IOoWHKoKARGsBRK.jpg\",\n          \"production_companies\": [\n            {\n              \"id\": 33,\n              \"logo_path\": \"/8lvHyhjr8oUKOOy2dKXoALWKdp0.png\",\n              \"name\": \"Universal Pictures\",\n              \"origin_country\": \"US\"\n            },\n            {\n              \"id\": 521,\n              \"logo_path\": \"/kP7t6RwGz2AvvTkvnI1uteEwHet.png\",\n              \"name\": \"DreamWorks Animation\",\n              \"origin_country\": \"US\"\n            }\n          ],\n          \"production_countries\": [\n            {\n              \"iso_3166_1\": \"US\",\n              \"name\": \"United States of America\"\n            }\n          ],\n          \"release_date\": \"2022-12-07\",\n          \"revenue\": 260725470,\n          \"runtime\": 102,\n          \"spoken_languages\": [\n            {\n              \"english_name\": \"English\",\n              \"iso_639_1\": \"en\",\n              \"name\": \"English\"\n            },\n            {\n              \"english_name\": \"Spanish\",\n              \"iso_639_1\": \"es\",\n              \"name\": \"Español\"\n            }\n          ],\n          \"status\": \"Released\",\n          \"tagline\": \"\",\n          \"title\": \"穿靴子的猫2\",\n          \"video\": false,\n          \"vote_average\": 8.614,\n          \"vote_count\": 2291\n        }\n        \"\"\"\n        if not self.movie:\n            return {}\n        try:\n            logger.debug(\"正在查询TMDB电影：%s ...\" % tmdbid)\n            tmdbinfo = self.movie.details(tmdbid, append_to_response)\n            if tmdbinfo:\n                logger.debug(f\"{tmdbid} 查询结果：{tmdbinfo.get('title')}\")\n            return tmdbinfo or {}\n        except Exception as e:\n            logger.error(str(e))\n            return None\n\n    def __get_tv_detail(self,\n                        tmdbid: int,\n                        append_to_response: Optional[str] = \"images,\"\n                                                            \"credits,\"\n                                                            \"alternative_titles,\"\n                                                            \"translations,\"\n                                                            \"content_ratings,\"\n                                                            \"external_ids,\"\n                                                            \"episode_groups\") -> Optional[dict]:\n        \"\"\"\n        获取电视剧的详情\n        :param tmdbid: TMDB ID\n        :return: TMDB信息\n        \"\"\"\n        \"\"\"\n        {\n          \"adult\": false,\n          \"backdrop_path\": \"/uDgy6hyPd82kOHh6I95FLtLnj6p.jpg\",\n          \"created_by\": [\n            {\n              \"id\": 35796,\n              \"credit_id\": \"5e84f06a3344c600153f6a57\",\n              \"name\": \"Craig Mazin\",\n              \"gender\": 2,\n              \"profile_path\": \"/uEhna6qcMuyU5TP7irpTUZ2ZsZc.jpg\"\n            },\n            {\n              \"id\": 1295692,\n              \"credit_id\": \"5e84f03598f1f10016a985c0\",\n              \"name\": \"Neil Druckmann\",\n              \"gender\": 2,\n              \"profile_path\": \"/bVUsM4aYiHbeSYE1xAw2H5Z1ANU.jpg\"\n            }\n          ],\n          \"episode_run_time\": [],\n          \"first_air_date\": \"2023-01-15\",\n          \"genres\": [\n            {\n              \"id\": 18,\n              \"name\": \"剧情\"\n            },\n            {\n              \"id\": 10765,\n              \"name\": \"Sci-Fi & Fantasy\"\n            },\n            {\n              \"id\": 10759,\n              \"name\": \"动作冒险\"\n            }\n          ],\n          \"homepage\": \"https://www.hbo.com/the-last-of-us\",\n          \"id\": 100088,\n          \"in_production\": true,\n          \"languages\": [\n            \"en\"\n          ],\n          \"last_air_date\": \"2023-01-15\",\n          \"last_episode_to_air\": {\n            \"air_date\": \"2023-01-15\",\n            \"episode_number\": 1,\n            \"id\": 2181581,\n            \"name\": \"当你迷失在黑暗中\",\n            \"overview\": \"在一场全球性的流行病摧毁了文明之后，一个顽强的幸存者负责照顾一个 14 岁的小女孩，她可能是人类最后的希望。\",\n            \"production_code\": \"\",\n            \"runtime\": 81,\n            \"season_number\": 1,\n            \"show_id\": 100088,\n            \"still_path\": \"/aRquEWm8wWF1dfa9uZ1TXLvVrKD.jpg\",\n            \"vote_average\": 8,\n            \"vote_count\": 33\n          },\n          \"name\": \"最后生还者\",\n          \"next_episode_to_air\": {\n            \"air_date\": \"2023-01-22\",\n            \"episode_number\": 2,\n            \"id\": 4071039,\n            \"name\": \"虫草变异菌\",\n            \"overview\": \"\",\n            \"production_code\": \"\",\n            \"runtime\": 55,\n            \"season_number\": 1,\n            \"show_id\": 100088,\n            \"still_path\": \"/jkUtYTmeap6EvkHI4n0j5IRFrIr.jpg\",\n            \"vote_average\": 10,\n            \"vote_count\": 1\n          },\n          \"networks\": [\n            {\n              \"id\": 49,\n              \"name\": \"HBO\",\n              \"logo_path\": \"/tuomPhY2UtuPTqqFnKMVHvSb724.png\",\n              \"origin_country\": \"US\"\n            }\n          ],\n          \"number_of_episodes\": 9,\n          \"number_of_seasons\": 1,\n          \"origin_country\": [\n            \"US\"\n          ],\n          \"original_language\": \"en\",\n          \"original_name\": \"The Last of Us\",\n          \"overview\": \"不明真菌疫情肆虐之后的美国，被真菌感染的人都变成了可怕的怪物，乔尔（Joel）为了换回武器答应将小女孩儿艾莉（Ellie）送到指定地点，由此开始了两人穿越美国的漫漫旅程。\",\n          \"popularity\": 5585.639,\n          \"poster_path\": \"/nOY3VBFO0VnlN9nlRombnMTztyh.jpg\",\n          \"production_companies\": [\n            {\n              \"id\": 3268,\n              \"logo_path\": \"/tuomPhY2UtuPTqqFnKMVHvSb724.png\",\n              \"name\": \"HBO\",\n              \"origin_country\": \"US\"\n            },\n            {\n              \"id\": 11073,\n              \"logo_path\": \"/aCbASRcI1MI7DXjPbSW9Fcv9uGR.png\",\n              \"name\": \"Sony Pictures Television Studios\",\n              \"origin_country\": \"US\"\n            },\n            {\n              \"id\": 23217,\n              \"logo_path\": \"/kXBZdQigEf6QiTLzo6TFLAa7jKD.png\",\n              \"name\": \"Naughty Dog\",\n              \"origin_country\": \"US\"\n            },\n            {\n              \"id\": 115241,\n              \"logo_path\": null,\n              \"name\": \"The Mighty Mint\",\n              \"origin_country\": \"US\"\n            },\n            {\n              \"id\": 119645,\n              \"logo_path\": null,\n              \"name\": \"Word Games\",\n              \"origin_country\": \"US\"\n            },\n            {\n              \"id\": 125281,\n              \"logo_path\": \"/3hV8pyxzAJgEjiSYVv1WZ0ZYayp.png\",\n              \"name\": \"PlayStation Productions\",\n              \"origin_country\": \"US\"\n            }\n          ],\n          \"production_countries\": [\n            {\n              \"iso_3166_1\": \"US\",\n              \"name\": \"United States of America\"\n            }\n          ],\n          \"seasons\": [\n            {\n              \"air_date\": \"2023-01-15\",\n              \"episode_count\": 9,\n              \"id\": 144593,\n              \"name\": \"第 1 季\",\n              \"overview\": \"\",\n              \"poster_path\": \"/aUQKIpZZ31KWbpdHMCmaV76u78T.jpg\",\n              \"season_number\": 1\n            }\n          ],\n          \"spoken_languages\": [\n            {\n              \"english_name\": \"English\",\n              \"iso_639_1\": \"en\",\n              \"name\": \"English\"\n            }\n          ],\n          \"status\": \"Returning Series\",\n          \"tagline\": \"\",\n          \"type\": \"Scripted\",\n          \"vote_average\": 8.924,\n          \"vote_count\": 601\n        }\n        \"\"\"\n        if not self.tv:\n            return {}\n        try:\n            logger.debug(\"正在查询TMDB电视剧：%s ...\" % tmdbid)\n            tmdbinfo = self.tv.details(tv_id=tmdbid, append_to_response=append_to_response)\n            if tmdbinfo:\n                logger.debug(f\"{tmdbid} 查询结果：{tmdbinfo.get('name')}\")\n            return tmdbinfo or {}\n        except Exception as e:\n            logger.error(str(e))\n            return None\n\n    def get_tv_season_detail(self, tmdbid: int, season: int):\n        \"\"\"\n        获取电视剧季的详情\n        :param tmdbid: TMDB ID\n        :param season: 季，数字\n        :return: TMDB信息\n        \"\"\"\n        \"\"\"\n        {\n          \"_id\": \"5e614cd3357c00001631a6ef\",\n          \"air_date\": \"2023-01-15\",\n          \"episodes\": [\n            {\n              \"air_date\": \"2023-01-15\",\n              \"episode_number\": 1,\n              \"id\": 2181581,\n              \"name\": \"当你迷失在黑暗中\",\n              \"overview\": \"在一场全球性的流行病摧毁了文明之后，一个顽强的幸存者负责照顾一个 14 岁的小女孩，她可能是人类最后的希望。\",\n              \"production_code\": \"\",\n              \"runtime\": 81,\n              \"season_number\": 1,\n              \"show_id\": 100088,\n              \"still_path\": \"/aRquEWm8wWF1dfa9uZ1TXLvVrKD.jpg\",\n              \"vote_average\": 8,\n              \"vote_count\": 33,\n              \"crew\": [\n                {\n                  \"job\": \"Writer\",\n                  \"department\": \"Writing\",\n                  \"credit_id\": \"619c370063536a00619a08ee\",\n                  \"adult\": false,\n                  \"gender\": 2,\n                  \"id\": 35796,\n                  \"known_for_department\": \"Writing\",\n                  \"name\": \"Craig Mazin\",\n                  \"original_name\": \"Craig Mazin\",\n                  \"popularity\": 15.211,\n                  \"profile_path\": \"/uEhna6qcMuyU5TP7irpTUZ2ZsZc.jpg\"\n                },\n              ],\n              \"guest_stars\": [\n                {\n                  \"character\": \"Marlene\",\n                  \"credit_id\": \"63c4ca5e5f2b8d00aed539fc\",\n                  \"order\": 500,\n                  \"adult\": false,\n                  \"gender\": 1,\n                  \"id\": 1253388,\n                  \"known_for_department\": \"Acting\",\n                  \"name\": \"Merle Dandridge\",\n                  \"original_name\": \"Merle Dandridge\",\n                  \"popularity\": 21.679,\n                  \"profile_path\": \"/lKwHdTtDf6NGw5dUrSXxbfkZLEk.jpg\"\n                }\n              ]\n            },\n          ],\n          \"name\": \"第 1 季\",\n          \"overview\": \"\",\n          \"id\": 144593,\n          \"poster_path\": \"/aUQKIpZZ31KWbpdHMCmaV76u78T.jpg\",\n          \"season_number\": 1\n        }\n        \"\"\"\n        if not self.season_obj:\n            return {}\n        try:\n            logger.debug(\"正在查询TMDB电视剧：%s，季：%s ...\" % (tmdbid, season))\n            tmdbinfo = self.season_obj.details(tv_id=tmdbid, season_num=season)\n            return tmdbinfo or {}\n        except Exception as e:\n            logger.error(str(e))\n            return {}\n\n    def get_tv_episode_detail(self, tmdbid: int, season: int, episode: int) -> dict:\n        \"\"\"\n        获取电视剧集的详情\n        :param tmdbid: TMDB ID\n        :param season: 季，数字\n        :param episode: 集，数字\n        \"\"\"\n        if not self.episode_obj:\n            return {}\n        try:\n            logger.debug(\"正在查询TMDB集详情：%s，季：%s，集：%s ...\" % (tmdbid, season, episode))\n            tmdbinfo = self.episode_obj.details(tv_id=tmdbid, season_num=season, episode_num=episode)\n            return tmdbinfo or {}\n        except Exception as e:\n            logger.error(str(e))\n            return {}\n\n    def discover_movies(self, params: dict) -> List[dict]:\n        \"\"\"\n        发现电影\n        :param params: 参数\n        :return:\n        \"\"\"\n        if not self.discover:\n            return []\n        try:\n            logger.debug(f\"正在发现电影：{params}...\")\n            tmdbinfo = self.discover.discover_movies(tuple(params.items()))\n            if tmdbinfo:\n                for info in tmdbinfo:\n                    info['media_type'] = MediaType.MOVIE\n            return tmdbinfo or []\n        except Exception as e:\n            logger.error(str(e))\n            return []\n\n    def discover_tvs(self, params: dict) -> List[dict]:\n        \"\"\"\n        发现电视剧\n        :param params: 参数\n        :return:\n        \"\"\"\n        if not self.discover:\n            return []\n        try:\n            logger.debug(f\"正在发现电视剧：{params}...\")\n            tmdbinfo = self.discover.discover_tv_shows(tuple(params.items()))\n            if tmdbinfo:\n                for info in tmdbinfo:\n                    info['media_type'] = MediaType.TV\n            return tmdbinfo or []\n        except Exception as e:\n            logger.error(str(e))\n            return []\n\n    def discover_trending(self, page: Optional[int] = 1) -> List[dict]:\n        \"\"\"\n        流行趋势\n        \"\"\"\n        if not self.trending:\n            return []\n        try:\n            logger.debug(f\"正在获取流行趋势：page={page} ...\")\n            return self.trending.all_week(page=page)\n        except Exception as e:\n            logger.error(str(e))\n            return []\n\n    def get_movie_images(self, tmdbid: int) -> dict:\n        \"\"\"\n        获取电影的图片\n        \"\"\"\n        if not self.movie:\n            return {}\n        try:\n            logger.debug(f\"正在获取电影图片：{tmdbid}...\")\n            return self.movie.images(movie_id=tmdbid) or {}\n        except Exception as e:\n            logger.error(str(e))\n            return {}\n\n    def get_tv_images(self, tmdbid: int) -> dict:\n        \"\"\"\n        获取电视剧的图片\n        \"\"\"\n        if not self.tv:\n            return {}\n        try:\n            logger.debug(f\"正在获取电视剧图片：{tmdbid}...\")\n            return self.tv.images(tv_id=tmdbid) or {}\n        except Exception as e:\n            logger.error(str(e))\n            return {}\n\n    def get_movie_similar(self, tmdbid: int) -> List[dict]:\n        \"\"\"\n        获取电影的相似电影\n        \"\"\"\n        if not self.movie:\n            return []\n        try:\n            logger.debug(f\"正在获取相似电影：{tmdbid}...\")\n            return self.movie.similar(movie_id=tmdbid) or []\n        except Exception as e:\n            logger.error(str(e))\n            return []\n\n    def get_tv_similar(self, tmdbid: int) -> List[dict]:\n        \"\"\"\n        获取电视剧的相似电视剧\n        \"\"\"\n        if not self.tv:\n            return []\n        try:\n            logger.debug(f\"正在获取相似电视剧：{tmdbid}...\")\n            return self.tv.similar(tv_id=tmdbid) or []\n        except Exception as e:\n            logger.error(str(e))\n            return []\n\n    def get_movie_recommend(self, tmdbid: int) -> List[dict]:\n        \"\"\"\n        获取电影的推荐电影\n        \"\"\"\n        if not self.movie:\n            return []\n        try:\n            logger.debug(f\"正在获取推荐电影：{tmdbid}...\")\n            return self.movie.recommendations(movie_id=tmdbid) or []\n        except Exception as e:\n            logger.error(str(e))\n            return []\n\n    def get_tv_recommend(self, tmdbid: int) -> List[dict]:\n        \"\"\"\n        获取电视剧的推荐电视剧\n        \"\"\"\n        if not self.tv:\n            return []\n        try:\n            logger.debug(f\"正在获取推荐电视剧：{tmdbid}...\")\n            return self.tv.recommendations(tv_id=tmdbid) or []\n        except Exception as e:\n            logger.error(str(e))\n            return []\n\n    def get_movie_credits(self, tmdbid: int, page: Optional[int] = 1, count: Optional[int] = 24) -> List[dict]:\n        \"\"\"\n        获取电影的演职员列表\n        \"\"\"\n        if not self.movie:\n            return []\n        try:\n            logger.debug(f\"正在获取电影演职人员：{tmdbid}...\")\n            info = self.movie.credits(movie_id=tmdbid) or {}\n            cast = info.get('cast') or []\n            if cast:\n                return cast[(page - 1) * count: page * count]\n            return []\n        except Exception as e:\n            logger.error(str(e))\n            return []\n\n    def get_tv_credits(self, tmdbid: int, page: Optional[int] = 1, count: Optional[int] = 24) -> List[dict]:\n        \"\"\"\n        获取电视剧的演职员列表\n        \"\"\"\n        if not self.tv:\n            return []\n        try:\n            logger.debug(f\"正在获取电视剧演职人员：{tmdbid}...\")\n            info = self.tv.credits(tv_id=tmdbid) or {}\n            cast = info.get('cast') or []\n            if cast:\n                return cast[(page - 1) * count: page * count]\n            return []\n        except Exception as e:\n            logger.error(str(e))\n            return []\n\n    def get_tv_group_seasons(self, group_id: str) -> List[dict]:\n        \"\"\"\n        获取电视剧剧集组季集列表\n        \"\"\"\n        if not self.tv:\n            return []\n        try:\n            logger.debug(f\"正在获取剧集组：{group_id}...\")\n            group_seasons = self.tv.group_episodes(group_id) or []\n            return [\n                {\n                    **group_season,\n                    \"episodes\": [\n                        {**ep, \"episode_number\": idx}\n                        # 剧集组中每个季的episode_number从1开始\n                        for idx, ep in enumerate(group_season.get(\"episodes\", []), start=1)\n                    ]\n                }\n                for group_season in group_seasons\n            ]\n        except Exception as e:\n            logger.error(str(e))\n            return []\n\n    def get_tv_group_detail(self, group_id: str, season: int) -> dict:\n        \"\"\"\n        获取剧集组某个季的信息\n        \"\"\"\n        group_seasons = self.get_tv_group_seasons(group_id)\n        if not group_seasons:\n            return {}\n        for group_season in group_seasons:\n            if group_season.get('order') == season:\n                return group_season\n        return {}\n\n    def get_person_detail(self, person_id: int) -> dict:\n        \"\"\"\n        获取人物详情\n        {\n            \"adult\": false,\n            \"also_known_as\": [\n                \"Michael Chen\",\n                \"Chen He\",\n                \"陈赫\"\n            ],\n            \"biography\": \"陈赫，xxx\",\n            \"birthday\": \"1985-11-09\",\n            \"deathday\": null,\n            \"gender\": 2,\n            \"homepage\": \"https://movie.douban.com/celebrity/1313841/\",\n            \"id\": 1397016,\n            \"imdb_id\": \"nm4369305\",\n            \"known_for_department\": \"Acting\",\n            \"name\": \"Chen He\",\n            \"place_of_birth\": \"Fuzhou，Fujian Province，China\",\n            \"popularity\": 9.228,\n            \"profile_path\": \"/2Bk39zVuoHUNHtpZ7LVg7OgkDd4.jpg\"\n        }\n        \"\"\"\n        if not self.person:\n            return {}\n        try:\n            logger.debug(f\"正在获取人物详情：{person_id}...\")\n            return self.person.details(person_id=person_id) or {}\n        except Exception as e:\n            logger.error(str(e))\n            return {}\n\n    def get_person_credits(self, person_id: int, page: Optional[int] = 1, count: Optional[int] = 24) -> List[dict]:\n        \"\"\"\n        获取人物参演作品\n        \"\"\"\n        if not self.person:\n            return []\n        try:\n            logger.debug(f\"正在获取人物参演作品：{person_id}...\")\n            movies = self.person.movie_credits(person_id=person_id) or {}\n            tvs = self.person.tv_credits(person_id=person_id) or {}\n            cast = (movies.get('cast') or []) + (tvs.get('cast') or [])\n            if cast:\n                # 按年份降序排列\n                cast = sorted(cast, key=lambda x: x.get('release_date') or x.get('first_air_date') or '1900-01-01',\n                              reverse=True)\n                return cast[(page - 1) * count: page * count]\n            return []\n        except Exception as e:\n            logger.error(str(e))\n            return []\n\n    def clear_cache(self):\n        \"\"\"\n        清除缓存\n        \"\"\"\n        self.match_web.cache_clear()\n        self.discover.discover_movies.cache_clear()\n        self.discover.discover_tv_shows.cache_clear()\n        self.tmdb.cache_clear()\n\n    # 私有异步方法\n    async def __async_search_movie_by_name(self, name: str, year: str) -> Optional[dict]:\n        \"\"\"\n        根据名称查询电影TMDB匹配（异步版本）\n        :param name: 识别的文件名或种子名\n        :param year: 电影上映日期\n        :return: 匹配的媒体信息\n        \"\"\"\n        try:\n            if year:\n                movies = await self.search.async_movies(term=name, year=year)\n            else:\n                movies = await self.search.async_movies(term=name)\n        except TMDbException as err:\n            logger.error(f\"连接TMDB出错：{str(err)}\")\n            return None\n        except Exception as e:\n            logger.error(f\"连接TMDB出错：{str(e)} - {traceback.format_exc()}\")\n            return None\n        logger.debug(f\"API返回：{str(self.search.total_results)}\")\n        if (movies is None) or (len(movies) == 0):\n            logger.debug(f\"{name} 未找到相关电影信息!\")\n            return {}\n        else:\n            # 按年份降序排列\n            movies = sorted(\n                movies,\n                key=lambda x: x.get('release_date') or '0000-00-00',\n                reverse=True\n            )\n            for movie in movies:\n                # 年份\n                movie_year = movie.get('release_date')[0:4] if movie.get('release_date') else None\n                if year and movie_year != year:\n                    # 年份不匹配\n                    continue\n                # 匹配标题、原标题\n                if self.__compare_names(name, movie.get('title')):\n                    return movie\n                if self.__compare_names(name, movie.get('original_title')):\n                    return movie\n                # 匹配别名、译名\n                if not movie.get(\"names\"):\n                    movie = await self.async_get_info(mtype=MediaType.MOVIE, tmdbid=movie.get(\"id\"))\n                if movie and self.__compare_names(name, movie.get(\"names\")):\n                    return movie\n        return {}\n\n    async def __async_search_tv_by_name(self, name: str, year: str) -> Optional[dict]:\n        \"\"\"\n        根据名称查询电视剧TMDB匹配（异步版本）\n        :param name: 识别的文件名或者种子名\n        :param year: 电视剧的首播年份\n        :return: 匹配的媒体信息\n        \"\"\"\n        try:\n            if year:\n                tvs = await self.search.async_tv_shows(term=name, release_year=year)\n            else:\n                tvs = await self.search.async_tv_shows(term=name)\n        except TMDbException as err:\n            logger.error(f\"连接TMDB出错：{str(err)}\")\n            return None\n        except Exception as e:\n            logger.error(f\"连接TMDB出错：{str(e)} - {traceback.format_exc()}\")\n            return None\n        logger.debug(f\"API返回：{str(self.search.total_results)}\")\n        if (tvs is None) or (len(tvs) == 0):\n            logger.debug(f\"{name} 未找到相关剧集信息!\")\n            return {}\n        else:\n            # 按年份降序排列\n            tvs = sorted(\n                tvs,\n                key=lambda x: x.get('first_air_date') or '0000-00-00',\n                reverse=True\n            )\n            for tv in tvs:\n                tv_year = tv.get('first_air_date')[0:4] if tv.get('first_air_date') else None\n                if year and tv_year != year:\n                    # 年份不匹配\n                    continue\n                # 匹配标题、原标题\n                if self.__compare_names(name, tv.get('name')):\n                    return tv\n                if self.__compare_names(name, tv.get('original_name')):\n                    return tv\n                # 匹配别名、译名\n                if not tv.get(\"names\"):\n                    tv = await self.async_get_info(mtype=MediaType.TV, tmdbid=tv.get(\"id\"))\n                if tv and self.__compare_names(name, tv.get(\"names\")):\n                    return tv\n        return {}\n\n    async def __async_search_tv_by_season(self, name: str, season_year: str, season_number: int,\n                                          group_seasons: Optional[List[dict]] = None) -> Optional[dict]:\n        \"\"\"\n        根据电视剧的名称和季的年份及序号匹配TMDB（异步版本）\n        :param name: 识别的文件名或者种子名\n        :param season_year: 季的年份\n        :param season_number: 季序号\n        :param group_seasons: 集数组信息\n        :return: 匹配的媒体信息\n        \"\"\"\n\n        def __season_match(tv_info: dict, _season_year: str) -> bool:\n            if not tv_info:\n                return False\n            try:\n                if group_seasons:\n                    for group_season in group_seasons:\n                        season = group_season.get('order')\n                        if season != season_number:\n                            continue\n                        episodes = group_season.get('episodes')\n                        if not episodes:\n                            continue\n                        first_date = episodes[0].get(\"air_date\")\n                        if re.match(r\"^\\d{4}-\\d{2}-\\d{2}$\", first_date):\n                            if str(_season_year) == str(first_date).split(\"-\")[0]:\n                                return True\n                else:\n                    seasons = self.__get_tv_seasons(tv_info)\n                    for season, season_info in seasons.items():\n                        if season_info.get(\"air_date\"):\n                            if season_info.get(\"air_date\")[0:4] == str(_season_year) \\\n                                    and season == int(season_number):\n                                return True\n            except Exception as e1:\n                logger.error(f\"连接TMDB出错：{e1}\")\n                print(traceback.format_exc())\n                return False\n            return False\n\n        try:\n            tvs = await self.search.async_tv_shows(term=name)\n        except TMDbException as err:\n            logger.error(f\"连接TMDB出错：{str(err)}\")\n            return None\n        except Exception as e:\n            logger.error(f\"连接TMDB出错：{str(e)}\")\n            print(traceback.format_exc())\n            return None\n\n        if (tvs is None) or (len(tvs) == 0):\n            logger.debug(\"%s 未找到季%s相关信息!\" % (name, season_number))\n            return {}\n        else:\n            # 按年份降序排列\n            tvs = sorted(\n                tvs,\n                key=lambda x: x.get('first_air_date') or '0000-00-00',\n                reverse=True\n            )\n            for tv in tvs:\n                # 年份\n                tv_year = tv.get('first_air_date')[0:4] if tv.get('first_air_date') else None\n                if (self.__compare_names(name, tv.get('name'))\n                    or self.__compare_names(name, tv.get('original_name'))) \\\n                        and (tv_year == str(season_year)):\n                    return tv\n                # 匹配别名、译名\n                if not tv.get(\"names\"):\n                    tv = await self.async_get_info(mtype=MediaType.TV, tmdbid=tv.get(\"id\"))\n                if not tv or not self.__compare_names(name, tv.get(\"names\")):\n                    continue\n                if __season_match(tv_info=tv, _season_year=season_year):\n                    return tv\n        return {}\n\n    async def __async_get_movie_detail(self,\n                                       tmdbid: int,\n                                       append_to_response: Optional[str] = \"images,\"\n                                                                           \"credits,\"\n                                                                           \"alternative_titles,\"\n                                                                           \"translations,\"\n                                                                           \"release_dates,\"\n                                                                           \"external_ids\") -> Optional[dict]:\n        \"\"\"\n        获取电影的详情（异步版本）\n        :param tmdbid: TMDB ID\n        :return: TMDB信息\n        \"\"\"\n        if not self.movie:\n            return {}\n        try:\n            logger.debug(\"正在查询TMDB电影：%s ...\" % tmdbid)\n            tmdbinfo = await self.movie.async_details(tmdbid, append_to_response)\n            if tmdbinfo:\n                logger.debug(f\"{tmdbid} 查询结果：{tmdbinfo.get('title')}\")\n            return tmdbinfo or {}\n        except Exception as e:\n            logger.error(str(e))\n            return None\n\n    async def __async_get_tv_detail(self,\n                                    tmdbid: int,\n                                    append_to_response: Optional[str] = \"images,\"\n                                                                        \"credits,\"\n                                                                        \"alternative_titles,\"\n                                                                        \"translations,\"\n                                                                        \"content_ratings,\"\n                                                                        \"external_ids,\"\n                                                                        \"episode_groups\") -> Optional[dict]:\n        \"\"\"\n        获取电视剧的详情（异步版本）\n        :param tmdbid: TMDB ID\n        :return: TMDB信息\n        \"\"\"\n        if not self.tv:\n            return {}\n        try:\n            logger.debug(\"正在查询TMDB电视剧：%s ...\" % tmdbid)\n            tmdbinfo = await self.tv.async_details(tv_id=tmdbid, append_to_response=append_to_response)\n            if tmdbinfo:\n                logger.debug(f\"{tmdbid} 查询结果：{tmdbinfo.get('name')}\")\n            return tmdbinfo or {}\n        except Exception as e:\n            logger.error(str(e))\n            return None\n\n    # 公共异步方法\n    @cached(maxsize=settings.CONF.tmdb, ttl=settings.CONF.meta)\n    @rate_limit_exponential(source=\"match_tmdb_web\", base_wait=5, max_wait=1800, enable_logging=True)\n    async def async_match_web(self, name: str, mtype: MediaType) -> Optional[dict]:\n        \"\"\"\n        搜索TMDB网站，直接抓取结果，结果只有一条时才返回（异步版本）\n        :param name: 名称\n        :param mtype: 媒体类型\n        \"\"\"\n        # 参数验证\n        validation_result = self._validate_web_params(name)\n        if validation_result is not None:\n            return validation_result\n\n        logger.info(\"正在从TheDbMovie网站查询：%s ...\" % name)\n        tmdb_url = self._build_tmdb_search_url(name)\n        res = await AsyncRequestUtils(timeout=5, ua=settings.NORMAL_USER_AGENT, proxies=settings.PROXY).get_res(\n            url=tmdb_url)\n        if res is None:\n            logger.error(\"无法连接TheDbMovie\")\n            return None\n\n        # 响应验证\n        response_result = self._validate_response(res)\n        if response_result is not None:\n            return response_result\n\n        try:\n            # 提取链接\n            tmdb_links = self._extract_tmdb_links(res.text, mtype)\n            # 处理结果\n            return await self._async_process_web_search_links(name, mtype, tmdb_links)\n        except Exception as err:\n            logger.error(f\"从TheDbMovie网站查询出错：{str(err)}\")\n            return {}\n\n    async def async_search_multiis(self, title: str) -> List[dict]:\n        \"\"\"\n        同时查询模糊匹配的电影、电视剧TMDB信息（异步版本）\n        \"\"\"\n        if not title:\n            return []\n        ret_infos = []\n        multis = await self.search.async_multi(term=title) or []\n        for multi in multis:\n            if multi.get(\"media_type\") in [\"movie\", \"tv\"]:\n                multi['media_type'] = MediaType.MOVIE if multi.get(\"media_type\") == \"movie\" else MediaType.TV\n                ret_infos.append(multi)\n        return ret_infos\n\n    async def async_search_movies(self, title: str, year: str) -> List[dict]:\n        \"\"\"\n        查询模糊匹配的所有电影TMDB信息（异步版本）\n        \"\"\"\n        if not title:\n            return []\n        ret_infos = []\n        if year:\n            movies = await self.search.async_movies(term=title, year=year) or []\n        else:\n            movies = await self.search.async_movies(term=title) or []\n        for movie in movies:\n            if title in movie.get(\"title\"):\n                movie['media_type'] = MediaType.MOVIE\n                ret_infos.append(movie)\n        return ret_infos\n\n    async def async_search_tvs(self, title: str, year: str) -> List[dict]:\n        \"\"\"\n        查询模糊匹配的所有电视剧TMDB信息（异步版本）\n        \"\"\"\n        if not title:\n            return []\n        ret_infos = []\n        if year:\n            tvs = await self.search.async_tv_shows(term=title, release_year=year) or []\n        else:\n            tvs = await self.search.async_tv_shows(term=title) or []\n        for tv in tvs:\n            if title in tv.get(\"name\"):\n                tv['media_type'] = MediaType.TV\n                ret_infos.append(tv)\n        return ret_infos\n\n    async def async_discover_movies(self, params: dict) -> List[dict]:\n        \"\"\"\n        发现电影（异步版本）\n        \"\"\"\n        if not params:\n            return []\n        try:\n            items = await self.discover.async_discover_movies(params_tuple=tuple(params.items())) or []\n            for item in items:\n                item['media_type'] = MediaType.MOVIE\n            return items\n        except Exception as e:\n            logger.error(f\"获取电影发现失败：{str(e)}\")\n            return []\n\n    async def async_discover_tvs(self, params: dict) -> List[dict]:\n        \"\"\"\n        发现电视剧（异步版本）\n        \"\"\"\n        if not params:\n            return []\n        try:\n            items = await self.discover.async_discover_tv_shows(params_tuple=tuple(params.items())) or []\n            for item in items:\n                item['media_type'] = MediaType.TV\n            return items\n        except Exception as e:\n            logger.error(f\"获取电视剧发现失败：{str(e)}\")\n            return []\n\n    async def async_search_persons(self, name: str) -> List[dict]:\n        \"\"\"\n        查询模糊匹配的所有人物TMDB信息（异步版本）\n        \"\"\"\n        if not name:\n            return []\n        return await self.search.async_people(term=name) or []\n\n    async def async_search_collections(self, name: str) -> List[dict]:\n        \"\"\"\n        查询模糊匹配的所有合集TMDB信息（异步版本）\n        \"\"\"\n        if not name:\n            return []\n        collections = await self.search.async_collections(term=name) or []\n        for collection in collections:\n            collection['media_type'] = MediaType.COLLECTION\n            collection['collection_id'] = collection.get(\"id\")\n        return collections\n\n    async def async_get_collection(self, collection_id: int) -> List[dict]:\n        \"\"\"\n        根据合集ID查询合集详情（异步版本）\n        \"\"\"\n        if not collection_id:\n            return []\n        try:\n            return await self.collection.async_details(collection_id=collection_id)\n        except TMDbException as err:\n            logger.error(f\"连接TMDB出错：{str(err)}\")\n        except Exception as e:\n            logger.error(f\"连接TMDB出错：{str(e)}\")\n        return []\n\n    async def async_match(self, name: str,\n                          mtype: MediaType,\n                          year: Optional[str] = None,\n                          season_year: Optional[str] = None,\n                          season_number: Optional[int] = None,\n                          group_seasons: Optional[List[dict]] = None) -> Optional[dict]:\n        \"\"\"\n        搜索tmdb中的媒体信息，匹配返回一条尽可能正确的信息（异步版本）\n        :param name: 检索的名称\n        :param mtype: 类型：电影、电视剧\n        :param year: 年份，如要是季集需要是首播年份(first_air_date)\n        :param season_year: 当前季集年份\n        :param season_number: 季集，整数\n        :param group_seasons: 集数组信息\n        :return: TMDB的INFO，同时会将mtype赋值到media_type中\n        \"\"\"\n        # 基本参数验证\n        if not self._validate_match_params(name, self.search):\n            return None\n\n        # TMDB搜索\n        info = {}\n        if mtype != MediaType.TV:\n            year_range = self._generate_year_range(year)\n            for search_year in year_range:\n                self._log_match_debug(mtype, name, search_year)\n                info = await self.__async_search_movie_by_name(name, search_year)\n                if info:\n                    break\n            info = self._set_media_type(info, MediaType.MOVIE)\n        else:\n            # 有当前季和当前季集年份，使用精确匹配\n            if season_year and season_number is not None:\n                self._log_match_debug(mtype, name, season_year, season_number, season_year)\n                info = await self.__async_search_tv_by_season(name,\n                                                              season_year,\n                                                              season_number,\n                                                              group_seasons)\n            if not info:\n                year_range = self._generate_year_range(year)\n                for search_year in year_range:\n                    self._log_match_debug(mtype, name, search_year)\n                    info = await self.__async_search_tv_by_name(name, search_year)\n                    if info:\n                        break\n            info = self._set_media_type(info, MediaType.TV)\n        return info\n\n    async def async_match_multi(self, name: str) -> Optional[dict]:\n        \"\"\"\n        根据名称同时查询电影和电视剧，没有类型也没有年份时使用（异步版本）\n        :param name: 识别的文件名或种子名\n        :return: 匹配的媒体信息\n        \"\"\"\n        try:\n            multis = await self.search.async_multi(term=name) or []\n        except TMDbException as err:\n            logger.error(f\"连接TMDB出错：{str(err)}\")\n            return None\n        except Exception as e:\n            logger.error(f\"连接TMDB出错：{str(e)}\")\n            print(traceback.format_exc())\n            return None\n        logger.debug(f\"API返回：{str(self.search.total_results)}\")\n\n        # 返回结果\n        if (multis is None) or (len(multis) == 0):\n            logger.debug(f\"{name} 未找到相关媒体息!\")\n            return {}\n\n        # 按年份降序排列，电影在前面\n        multis = self._sort_multi_results(multis)\n\n        ret_info = {}\n        for multi in multis:\n            matched = await self._async_match_multi_item(name, multi)\n            if matched:\n                ret_info = matched\n                break\n\n        # 类型变更\n        return self._convert_media_type(ret_info)\n\n    async def async_get_info(self,\n                             mtype: MediaType,\n                             tmdbid: int) -> dict:\n        \"\"\"\n        给定TMDB号，查询一条媒体信息（异步版本）\n        :param mtype: 类型：电影、电视剧，为空时都查（此时用不上年份）\n        :param tmdbid: TMDB的ID，有tmdbid时优先使用tmdbid，否则使用年份和标题\n        \"\"\"\n\n        def __get_genre_ids(genres: list) -> list:\n            \"\"\"\n            从TMDB详情中获取genre_id列表\n            \"\"\"\n            if not genres:\n                return []\n            genre_ids = []\n            for genre in genres:\n                genre_ids.append(genre.get('id'))\n            return genre_ids\n\n        # 查询TMDB详情\n        if mtype == MediaType.MOVIE:\n            tmdb_info = await self.__async_get_movie_detail(tmdbid)\n            if tmdb_info:\n                tmdb_info['media_type'] = MediaType.MOVIE\n        elif mtype == MediaType.TV:\n            tmdb_info = await self.__async_get_tv_detail(tmdbid)\n            if tmdb_info:\n                tmdb_info['media_type'] = MediaType.TV\n        else:\n            tmdb_info_tv = await self.__async_get_tv_detail(tmdbid)\n            tmdb_info_movie = await self.__async_get_movie_detail(tmdbid)\n            if tmdb_info_tv and tmdb_info_movie:\n                tmdb_info = None\n                logger.warn(f\"无法判断tmdb_id:{tmdbid} 是电影还是电视剧\")\n            elif tmdb_info_tv:\n                tmdb_info = tmdb_info_tv\n                tmdb_info['media_type'] = MediaType.TV\n            elif tmdb_info_movie:\n                tmdb_info = tmdb_info_movie\n                tmdb_info['media_type'] = MediaType.MOVIE\n            else:\n                tmdb_info = None\n                logger.warn(f\"tmdb_id:{tmdbid} 未查询到媒体信息\")\n\n        if tmdb_info:\n            # 转换genreid\n            tmdb_info['genre_ids'] = __get_genre_ids(tmdb_info.get('genres'))\n            # 别名和译名\n            tmdb_info['names'] = self.__get_names(tmdb_info)\n            # 内容分级\n            tmdb_info['content_rating'] = self.__get_content_rating(tmdb_info)\n            # 转换多语种标题\n            self.__update_tmdbinfo_extra_title(tmdb_info)\n            # 转换中文标题\n            if self.tmdb.language in (\"zh\", \"zh-CN\"):\n                self.__update_tmdbinfo_cn_title(tmdb_info)\n\n        return tmdb_info\n\n    async def async_get_tv_season_detail(self, tmdbid: int, season: int):\n        \"\"\"\n        获取电视剧季的详情（异步版本）\n        :param tmdbid: TMDB ID\n        :param season: 季，数字\n        :return: TMDB信息\n        \"\"\"\n        if not self.season_obj:\n            return {}\n        try:\n            logger.debug(\"正在查询TMDB电视剧：%s，季：%s ...\" % (tmdbid, season))\n            tmdbinfo = await self.season_obj.async_details(tv_id=tmdbid, season_num=season)\n            return tmdbinfo or {}\n        except Exception as e:\n            logger.error(str(e))\n            return {}\n\n    async def async_get_tv_episode_detail(self, tmdbid: int, season: int, episode: int) -> dict:\n        \"\"\"\n        获取电视剧集的详情（异步版本）\n        :param tmdbid: TMDB ID\n        :param season: 季，数字\n        :param episode: 集，数字\n        \"\"\"\n        if not self.episode_obj:\n            return {}\n        try:\n            logger.debug(\"正在查询TMDB集详情：%s，季：%s，集：%s ...\" % (tmdbid, season, episode))\n            tmdbinfo = await self.episode_obj.async_details(tv_id=tmdbid, season_num=season, episode_num=episode)\n            return tmdbinfo or {}\n        except Exception as e:\n            logger.error(str(e))\n            return {}\n\n    async def async_discover_trending(self, page: Optional[int] = 1) -> List[dict]:\n        \"\"\"\n        流行趋势（异步版本）\n        \"\"\"\n        if not self.trending:\n            return []\n        try:\n            logger.debug(f\"正在获取流行趋势：page={page} ...\")\n            return await self.trending.async_all_week(page=page)\n        except Exception as e:\n            logger.error(str(e))\n            return []\n\n    async def async_get_movie_images(self, tmdbid: int) -> dict:\n        \"\"\"\n        获取电影的图片（异步版本）\n        \"\"\"\n        if not self.movie:\n            return {}\n        try:\n            logger.debug(f\"正在获取电影图片：{tmdbid}...\")\n            return await self.movie.async_images(movie_id=tmdbid) or {}\n        except Exception as e:\n            logger.error(str(e))\n            return {}\n\n    async def async_get_tv_images(self, tmdbid: int) -> dict:\n        \"\"\"\n        获取电视剧的图片（异步版本）\n        \"\"\"\n        if not self.tv:\n            return {}\n        try:\n            logger.debug(f\"正在获取电视剧图片：{tmdbid}...\")\n            return await self.tv.async_images(tv_id=tmdbid) or {}\n        except Exception as e:\n            logger.error(str(e))\n            return {}\n\n    async def async_get_movie_similar(self, tmdbid: int) -> List[dict]:\n        \"\"\"\n        获取电影的相似电影（异步版本）\n        \"\"\"\n        if not self.movie:\n            return []\n        try:\n            logger.debug(f\"正在获取相似电影：{tmdbid}...\")\n            return await self.movie.async_similar(movie_id=tmdbid) or []\n        except Exception as e:\n            logger.error(str(e))\n            return []\n\n    async def async_get_tv_similar(self, tmdbid: int) -> List[dict]:\n        \"\"\"\n        获取电视剧的相似电视剧（异步版本）\n        \"\"\"\n        if not self.tv:\n            return []\n        try:\n            logger.debug(f\"正在获取相似电视剧：{tmdbid}...\")\n            return await self.tv.async_similar(tv_id=tmdbid) or []\n        except Exception as e:\n            logger.error(str(e))\n            return []\n\n    async def async_get_movie_recommend(self, tmdbid: int) -> List[dict]:\n        \"\"\"\n        获取电影的推荐电影（异步版本）\n        \"\"\"\n        if not self.movie:\n            return []\n        try:\n            logger.debug(f\"正在获取推荐电影：{tmdbid}...\")\n            return await self.movie.async_recommendations(movie_id=tmdbid) or []\n        except Exception as e:\n            logger.error(str(e))\n            return []\n\n    async def async_get_tv_recommend(self, tmdbid: int) -> List[dict]:\n        \"\"\"\n        获取电视剧的推荐电视剧（异步版本）\n        \"\"\"\n        if not self.tv:\n            return []\n        try:\n            logger.debug(f\"正在获取推荐电视剧：{tmdbid}...\")\n            return await self.tv.async_recommendations(tv_id=tmdbid) or []\n        except Exception as e:\n            logger.error(str(e))\n            return []\n\n    async def async_get_movie_credits(self, tmdbid: int,\n                                      page: Optional[int] = 1, count: Optional[int] = 24) -> List[dict]:\n        \"\"\"\n        获取电影的演职员列表（异步版本）\n        \"\"\"\n        if not self.movie:\n            return []\n        try:\n            logger.debug(f\"正在获取电影演职人员：{tmdbid}...\")\n            info = await self.movie.async_credits(movie_id=tmdbid) or {}\n            cast = info.get('cast') or []\n            if cast:\n                return cast[(page - 1) * count: page * count]\n            return []\n        except Exception as e:\n            logger.error(str(e))\n            return []\n\n    async def async_get_tv_credits(self, tmdbid: int, page: Optional[int] = 1, count: Optional[int] = 24) -> List[dict]:\n        \"\"\"\n        获取电视剧的演职员列表（异步版本）\n        \"\"\"\n        if not self.tv:\n            return []\n        try:\n            logger.debug(f\"正在获取电视剧演职人员：{tmdbid}...\")\n            info = await self.tv.async_credits(tv_id=tmdbid) or {}\n            cast = info.get('cast') or []\n            if cast:\n                return cast[(page - 1) * count: page * count]\n            return []\n        except Exception as e:\n            logger.error(str(e))\n            return []\n\n    async def async_get_tv_group_seasons(self, group_id: str) -> List[dict]:\n        \"\"\"\n        获取电视剧剧集组季集列表（异步版本）\n        \"\"\"\n        if not self.tv:\n            return []\n        try:\n            logger.debug(f\"正在获取剧集组：{group_id}...\")\n            group_seasons = await self.tv.async_group_episodes(group_id) or []\n            return [\n                {\n                    **group_season,\n                    \"episodes\": [\n                        {**ep, \"episode_number\": idx}\n                        # 剧集组中每个季的episode_number从1开始\n                        for idx, ep in enumerate(group_season.get(\"episodes\", []), start=1)\n                    ]\n                }\n                for group_season in group_seasons\n            ]\n        except Exception as e:\n            logger.error(str(e))\n            return []\n\n    async def async_get_tv_group_detail(self, group_id: str, season: int) -> dict:\n        \"\"\"\n        获取剧集组某个季的信息（异步版本）\n        \"\"\"\n        group_seasons = await self.async_get_tv_group_seasons(group_id)\n        if not group_seasons:\n            return {}\n        for group_season in group_seasons:\n            if group_season.get('order') == season:\n                return group_season\n        return {}\n\n    async def async_get_person_detail(self, person_id: int) -> dict:\n        \"\"\"\n        获取人物详情（异步版本）\n        \"\"\"\n        if not self.person:\n            return {}\n        try:\n            logger.debug(f\"正在获取人物详情：{person_id}...\")\n            return await self.person.async_details(person_id=person_id) or {}\n        except Exception as e:\n            logger.error(str(e))\n            return {}\n\n    async def async_get_person_credits(self, person_id: int,\n                                       page: Optional[int] = 1, count: Optional[int] = 24) -> List[dict]:\n        \"\"\"\n        获取人物参演作品（异步版本）\n        \"\"\"\n        if not self.person:\n            return []\n        try:\n            logger.debug(f\"正在获取人物参演作品：{person_id}...\")\n            movies = await self.person.async_movie_credits(person_id=person_id) or {}\n            tvs = await self.person.async_tv_credits(person_id=person_id) or {}\n            cast = (movies.get('cast') or []) + (tvs.get('cast') or [])\n            if cast:\n                # 按年份降序排列\n                cast = sorted(cast, key=lambda x: x.get('release_date') or x.get('first_air_date') or '1900-01-01',\n                              reverse=True)\n                return cast[(page - 1) * count: page * count]\n            return []\n        except Exception as e:\n            logger.error(str(e))\n            return []\n\n    def close(self):\n        \"\"\"\n        关闭连接\n        \"\"\"\n        self.tmdb.close()\n"
  },
  {
    "path": "app/modules/themoviedb/tmdbv3api/__init__.py",
    "content": "from .objs.account import Account\nfrom .objs.auth import Authentication\nfrom .objs.certification import Certification\nfrom .objs.change import Change\nfrom .objs.collection import Collection\nfrom .objs.company import Company\nfrom .objs.configuration import Configuration\nfrom .objs.credit import Credit\nfrom .objs.discover import Discover\nfrom .objs.episode import Episode\nfrom .objs.find import Find\nfrom .objs.genre import Genre\nfrom .objs.group import Group\nfrom .objs.keyword import Keyword\nfrom .objs.list import List\nfrom .objs.movie import Movie\nfrom .objs.network import Network\nfrom .objs.person import Person\nfrom .objs.provider import Provider\nfrom .objs.review import Review\nfrom .objs.search import Search\nfrom .objs.season import Season\nfrom .objs.trending import Trending\nfrom .objs.tv import TV\nfrom .tmdb import TMDb\n"
  },
  {
    "path": "app/modules/themoviedb/tmdbv3api/as_obj.py",
    "content": "# encoding: utf-8\nimport sys\n\n\nclass AsObj:\n    def __init__(self, json=None, key=None, dict_key=False, dict_key_name=None):\n        self._json = json if json else {}\n        self._key = key\n        self._dict_key = dict_key\n        self._dict_key_name = dict_key_name\n        self._obj_list = []\n        self._list_only = False\n        if isinstance(self._json, list):\n            self._obj_list = [AsObj(o) if isinstance(o, (dict, list)) else o for o in self._json]\n            self._list_only = True\n        elif dict_key:\n            self._obj_list = [\n                AsObj({k: v}, key=k, dict_key_name=dict_key_name) if isinstance(v, (dict, list)) else v\n                for k, v in self._json.items()\n            ]\n            self._list_only = True\n        else:\n            for key, value in self._json.items():\n                if isinstance(value, (dict, list)):\n                    if self._key and key == self._key:\n                        final = AsObj(value, dict_key=isinstance(value, dict), dict_key_name=key)\n                        self._obj_list = final\n                    else:\n                        final = AsObj(value)\n                else:\n                    final = value\n                if dict_key_name:\n                    setattr(self, dict_key_name, key)\n                setattr(self, key, final)\n\n    def _dict(self):\n        return {k: v for k, v in self.__dict__.items() if not k.startswith(\"_\")}\n\n    def to_dict(self):\n        return self._dict()\n\n    def __delitem__(self, key):\n        return delattr(self, key)\n\n    def __getitem__(self, key):\n        if isinstance(key, int) and self._obj_list:\n            return self._obj_list[key]\n        else:\n            return getattr(self, key)\n\n    def __iter__(self):\n        return (o for o in self._obj_list) if self._obj_list else iter(self._dict())\n\n    def __len__(self):\n        return len(self._obj_list) if self._obj_list else len(self._dict())\n\n    def __repr__(self):\n        return str(self._obj_list) if self._list_only else str(self._dict())\n\n    def __setitem__(self, key, value):\n        return setattr(self, key, value)\n\n    def __str__(self):\n        return str(self._obj_list) if self._list_only else str(self._dict())\n\n    if sys.version_info >= (3, 8):\n        def __reversed__(self):\n            return reversed(self._dict())\n\n    if sys.version_info >= (3, 9):\n        def __class_getitem__(cls, key):\n            return cls.__dict__.__class_getitem__(key)\n\n        def __ior__(self, value):\n            return self._dict().__ior__(value)\n\n        def __or__(self, value):\n            return self._dict().__or__(value)\n\n    def copy(self):\n        return AsObj(self._json.copy(), key=self._key, dict_key=self._dict_key, dict_key_name=self._dict_key_name)\n\n    def get(self, key, value=None):\n        return self._dict().get(key, value)\n\n    def items(self):\n        return self._dict().items()\n\n    def keys(self):\n        return self._dict().keys()\n\n    def pop(self, key, value=None):\n        return self.__dict__.pop(key, value)\n\n    def popitem(self):\n        return self.__dict__.popitem()\n\n    def setdefault(self, key, value=None):\n        return self.__dict__.setdefault(key, value)\n\n    def update(self, entries):\n        return self.__dict__.update(entries)\n\n    def values(self):\n        return self._dict().values()\n"
  },
  {
    "path": "app/modules/themoviedb/tmdbv3api/exceptions.py",
    "content": "class TMDbException(Exception):\n    pass\n"
  },
  {
    "path": "app/modules/themoviedb/tmdbv3api/objs/__init__.py",
    "content": ""
  },
  {
    "path": "app/modules/themoviedb/tmdbv3api/objs/account.py",
    "content": "import os\n\nfrom ..exceptions import TMDbException\nfrom ..tmdb import TMDb\n\n\nclass Account(TMDb):\n    _urls = {\n        \"details\": \"/account\",\n        \"created_lists\": \"/account/%s/lists\",\n        \"favorite_movies\": \"/account/%s/favorite/movies\",\n        \"favorite_tv\": \"/account/%s/favorite/tv\",\n        \"favorite\": \"/account/%s/favorite\",\n        \"rated_movies\": \"/account/%s/rated/movies\",\n        \"rated_tv\": \"/account/%s/rated/tv\",\n        \"rated_episodes\": \"/account/%s/rated/tv/episodes\",\n        \"movie_watchlist\": \"/account/%s/watchlist/movies\",\n        \"tv_watchlist\": \"/account/%s/watchlist/tv\",\n        \"watchlist\": \"/account/%s/watchlist\",\n    }\n\n    @property\n    def account_id(self):\n        if not os.environ.get(\"TMDB_ACCOUNT_ID\"):\n            os.environ[\"TMDB_ACCOUNT_ID\"] = str(self.details()[\"id\"])\n        return os.environ.get(\"TMDB_ACCOUNT_ID\")\n\n    def details(self):\n        \"\"\"\n        Get your account details.\n        :return:\n        \"\"\"\n        return self._request_obj(\n            self._urls[\"details\"],\n            params=\"session_id=%s\" % self.session_id\n        )\n\n    async def async_details(self):\n        \"\"\"\n        Get your account details.（异步版本）\n        :return:\n        \"\"\"\n        return await self._async_request_obj(\n            self._urls[\"details\"],\n            params=\"session_id=%s\" % self.session_id\n        )\n\n    def created_lists(self, page=1):\n        \"\"\"\n        Get all of the lists created by an account. Will include private lists if you are the owner.\n        :param page: int\n        :return:\n        \"\"\"\n        return self._request_obj(\n            self._urls[\"created_lists\"] % self.account_id,\n            params=\"session_id=%s&page=%s\" % (self.session_id, page),\n            key=\"results\"\n        )\n\n    async def async_created_lists(self, page=1):\n        \"\"\"\n        Get all of the lists created by an account. Will include private lists if you are the owner.（异步版本）\n        :param page: int\n        :return:\n        \"\"\"\n        return await self._async_request_obj(\n            self._urls[\"created_lists\"] % self.account_id,\n            params=\"session_id=%s&page=%s\" % (self.session_id, page),\n            key=\"results\"\n        )\n\n    def _get_list(self, url, asc_sort=True, page=1):\n        params = \"session_id=%s&page=%s\" % (self.session_id, page)\n        if asc_sort is False:\n            params += \"&sort_by=created_at.desc\"\n        return self._request_obj(\n            self._urls[url] % self.account_id,\n            params=params,\n            key=\"results\"\n        )\n\n    async def _async_get_list(self, url, asc_sort=True, page=1):\n        params = \"session_id=%s&page=%s\" % (self.session_id, page)\n        if asc_sort is False:\n            params += \"&sort_by=created_at.desc\"\n        return await self._async_request_obj(\n            self._urls[url] % self.account_id,\n            params=params,\n            key=\"results\"\n        )\n\n    def favorite_movies(self, asc_sort=True, page=1):\n        \"\"\"\n        Get the list of your favorite movies.\n        :param asc_sort: bool\n        :param page: int\n        :return:\n        \"\"\"\n        return self._get_list(\"favorite_movies\", asc_sort=asc_sort, page=page)\n\n    async def async_favorite_movies(self, asc_sort=True, page=1):\n        \"\"\"\n        Get the list of your favorite movies.（异步版本）\n        :param asc_sort: bool\n        :param page: int\n        :return:\n        \"\"\"\n        return await self._async_get_list(\"favorite_movies\", asc_sort=asc_sort, page=page)\n\n    def favorite_tv_shows(self, asc_sort=True, page=1):\n        \"\"\"\n        Get the list of your favorite TV shows.\n        :param asc_sort: bool\n        :param page: int\n        :return:\n        \"\"\"\n        return self._get_list(\"favorite_tv\", asc_sort=asc_sort, page=page)\n\n    async def async_favorite_tv_shows(self, asc_sort=True, page=1):\n        \"\"\"\n        Get the list of your favorite TV shows.（异步版本）\n        :param asc_sort: bool\n        :param page: int\n        :return:\n        \"\"\"\n        return await self._async_get_list(\"favorite_tv\", asc_sort=asc_sort, page=page)\n\n    def mark_as_favorite(self, media_id, media_type, favorite=True):\n        \"\"\"\n        This method allows you to mark a movie or TV show as a favorite item.\n        :param media_id: int\n        :param media_type: str\n        :param favorite:bool\n        \"\"\"\n        if media_type not in [\"tv\", \"movie\"]:\n            raise TMDbException(\"Media Type should be tv or movie.\")\n        self._request_obj(\n            self._urls[\"favorite\"] % self.account_id,\n            params=\"session_id=%s\" % self.session_id,\n            method=\"POST\",\n            json={\n                \"media_type\": media_type,\n                \"media_id\": media_id,\n                \"favorite\": favorite,\n            }\n        )\n\n    async def async_mark_as_favorite(self, media_id, media_type, favorite=True):\n        \"\"\"\n        This method allows you to mark a movie or TV show as a favorite item.（异步版本）\n        :param media_id: int\n        :param media_type: str\n        :param favorite:bool\n        \"\"\"\n        if media_type not in [\"tv\", \"movie\"]:\n            raise TMDbException(\"Media Type should be tv or movie.\")\n        await self._async_request_obj(\n            self._urls[\"favorite\"] % self.account_id,\n            params=\"session_id=%s\" % self.session_id,\n            method=\"POST\",\n            json={\n                \"media_type\": media_type,\n                \"media_id\": media_id,\n                \"favorite\": favorite,\n            }\n        )\n\n    def unmark_as_favorite(self, media_id, media_type):\n        \"\"\"\n        This method allows you to unmark a movie or TV show as a favorite item.\n        :param media_id: int\n        :param media_type: str\n        \"\"\"\n        self.mark_as_favorite(media_id, media_type, favorite=False)\n\n    async def async_unmark_as_favorite(self, media_id, media_type):\n        \"\"\"\n        This method allows you to unmark a movie or TV show as a favorite item.（异步版本）\n        :param media_id: int\n        :param media_type: str\n        \"\"\"\n        await self.async_mark_as_favorite(media_id, media_type, favorite=False)\n\n    def rated_movies(self, asc_sort=True, page=1):\n        \"\"\"\n        Get a list of all the movies you have rated.\n        :param asc_sort: bool\n        :param page: int\n        :return:\n        \"\"\"\n        return self._get_list(\"rated_movies\", asc_sort=asc_sort, page=page)\n\n    async def async_rated_movies(self, asc_sort=True, page=1):\n        \"\"\"\n        Get a list of all the movies you have rated.（异步版本）\n        :param asc_sort: bool\n        :param page: int\n        :return:\n        \"\"\"\n        return await self._async_get_list(\"rated_movies\", asc_sort=asc_sort, page=page)\n\n    def rated_tv_shows(self, asc_sort=True, page=1):\n        \"\"\"\n        Get a list of all the TV shows you have rated.\n        :param asc_sort: bool\n        :param page: int\n        :return:\n        \"\"\"\n        return self._get_list(\"rated_tv\", asc_sort=asc_sort, page=page)\n\n    async def async_rated_tv_shows(self, asc_sort=True, page=1):\n        \"\"\"\n        Get a list of all the TV shows you have rated.（异步版本）\n        :param asc_sort: bool\n        :param page: int\n        :return:\n        \"\"\"\n        return await self._async_get_list(\"rated_tv\", asc_sort=asc_sort, page=page)\n\n    def rated_episodes(self, asc_sort=True, page=1):\n        \"\"\"\n        Get a list of all the TV episodes you have rated.\n        :param asc_sort: bool\n        :param page: int\n        :return:\n        \"\"\"\n        return self._get_list(\"rated_episodes\", asc_sort=asc_sort, page=page)\n\n    async def async_rated_episodes(self, asc_sort=True, page=1):\n        \"\"\"\n        Get a list of all the TV episodes you have rated.（异步版本）\n        :param asc_sort: bool\n        :param page: int\n        :return:\n        \"\"\"\n        return await self._async_get_list(\"rated_episodes\", asc_sort=asc_sort, page=page)\n\n    def movie_watchlist(self, asc_sort=True, page=1):\n        \"\"\"\n        Get a list of all the movies you have added to your watchlist.\n        :param asc_sort: bool\n        :param page: int\n        :return:\n        \"\"\"\n        return self._get_list(\"movie_watchlist\", asc_sort=asc_sort, page=page)\n\n    async def async_movie_watchlist(self, asc_sort=True, page=1):\n        \"\"\"\n        Get a list of all the movies you have added to your watchlist.（异步版本）\n        :param asc_sort: bool\n        :param page: int\n        :return:\n        \"\"\"\n        return await self._async_get_list(\"movie_watchlist\", asc_sort=asc_sort, page=page)\n\n    def tv_show_watchlist(self, asc_sort=True, page=1):\n        \"\"\"\n        Get a list of all the TV shows you have added to your watchlist.\n        :param asc_sort: bool\n        :param page: int\n        :return:\n        \"\"\"\n        return self._get_list(\"tv_watchlist\", asc_sort=asc_sort, page=page)\n\n    async def async_tv_show_watchlist(self, asc_sort=True, page=1):\n        \"\"\"\n        Get a list of all the TV shows you have added to your watchlist.（异步版本）\n        :param asc_sort: bool\n        :param page: int\n        :return:\n        \"\"\"\n        return await self._async_get_list(\"tv_watchlist\", asc_sort=asc_sort, page=page)\n\n    def add_to_watchlist(self, media_id, media_type, watchlist=True):\n        \"\"\"\n        Add a movie or TV show to your watchlist.\n        :param media_id: int\n        :param media_type: str\n        :param watchlist: bool\n        \"\"\"\n        if media_type not in [\"tv\", \"movie\"]:\n            raise TMDbException(\"Media Type should be tv or movie.\")\n        self._request_obj(\n            self._urls[\"watchlist\"] % self.account_id,\n            \"session_id=%s\" % self.session_id,\n            method=\"POST\",\n            json={\n                \"media_type\": media_type,\n                \"media_id\": media_id,\n                \"watchlist\": watchlist,\n            }\n        )\n\n    async def async_add_to_watchlist(self, media_id, media_type, watchlist=True):\n        \"\"\"\n        Add a movie or TV show to your watchlist.（异步版本）\n        :param media_id: int\n        :param media_type: str\n        :param watchlist: bool\n        \"\"\"\n        if media_type not in [\"tv\", \"movie\"]:\n            raise TMDbException(\"Media Type should be tv or movie.\")\n        await self._async_request_obj(\n            self._urls[\"watchlist\"] % self.account_id,\n            \"session_id=%s\" % self.session_id,\n            method=\"POST\",\n            json={\n                \"media_type\": media_type,\n                \"media_id\": media_id,\n                \"watchlist\": watchlist,\n            }\n        )\n\n    def remove_from_watchlist(self, media_id, media_type):\n        \"\"\"\n        Remove a movie or TV show from your watchlist.\n        :param media_id: int\n        :param media_type: str\n        \"\"\"\n        self.add_to_watchlist(media_id, media_type, watchlist=False)\n\n    async def async_remove_from_watchlist(self, media_id, media_type):\n        \"\"\"\n        Remove a movie or TV show from your watchlist.（异步版本）\n        :param media_id: int\n        :param media_type: str\n        \"\"\"\n        await self.async_add_to_watchlist(media_id, media_type, watchlist=False)\n"
  },
  {
    "path": "app/modules/themoviedb/tmdbv3api/objs/auth.py",
    "content": "from ..tmdb import TMDb\n\n\nclass Authentication(TMDb):\n    _urls = {\n        \"create_request_token\": \"/authentication/token/new\",\n        \"validate_with_login\": \"/authentication/token/validate_with_login\",\n        \"create_session\": \"/authentication/session/new\",\n        \"delete_session\": \"/authentication/session\",\n    }\n\n    def __init__(self, username, password):\n        super().__init__()\n        self.username = username\n        self.password = password\n        self.expires_at = None\n        self.request_token = self._create_request_token()\n        self._authorise_request_token_with_login()\n        self._create_session()\n\n    def _create_request_token(self):\n        \"\"\"\n        Create a temporary request token that can be used to validate a TMDb user login.\n        \"\"\"\n        response = self._request_obj(self._urls[\"create_request_token\"])\n        self.expires_at = response.expires_at\n        return response.request_token\n\n    def _create_session(self):\n        \"\"\"\n        You can use this method to create a fully valid session ID once a user has validated the request token.\n        \"\"\"\n        response = self._request_obj(\n            self._urls[\"create_session\"],\n            method=\"POST\",\n            json={\"request_token\": self.request_token}\n        )\n        self.session_id = response.session_id\n\n    def _authorise_request_token_with_login(self):\n        \"\"\"\n        This method allows an application to validate a request token by entering a username and password.\n        \"\"\"\n        self._request_obj(\n            self._urls[\"validate_with_login\"],\n            method=\"POST\",\n            json={\n                \"username\": self.username,\n                \"password\": self.password,\n                \"request_token\": self.request_token,\n            }\n        )\n\n    def delete_session(self):\n        \"\"\"\n        If you would like to delete (or \"logout\") from a session, call this method with a valid session ID.\n        \"\"\"\n        if self.has_session:\n            self._request_obj(\n                self._urls[\"delete_session\"],\n                method=\"DELETE\",\n                json={\"session_id\": self.session_id}\n            )\n            self.session_id = \"\"\n"
  },
  {
    "path": "app/modules/themoviedb/tmdbv3api/objs/certification.py",
    "content": "from ..tmdb import TMDb\n\n\nclass Certification(TMDb):\n    _urls = {\n        \"movie_list\": \"/certification/movie/list\",\n        \"tv_list\": \"/certification/tv/list\",\n    }\n\n    def movie_list(self):\n        \"\"\"\n        Get an up to date list of the officially supported movie certifications on TMDB.\n        :return:\n        \"\"\"\n        return self._request_obj(self._urls[\"movie_list\"], key=\"certifications\")\n\n    async def async_movie_list(self):\n        \"\"\"\n        Get an up to date list of the officially supported movie certifications on TMDB.（异步版本）\n        :return:\n        \"\"\"\n        return await self._async_request_obj(self._urls[\"movie_list\"], key=\"certifications\")\n\n    def tv_list(self):\n        \"\"\"\n        Get an up to date list of the officially supported TV show certifications on TMDB.\n        :return:\n        \"\"\"\n        return self._request_obj(self._urls[\"tv_list\"], key=\"certifications\")\n\n    async def async_tv_list(self):\n        \"\"\"\n        Get an up to date list of the officially supported TV show certifications on TMDB.（异步版本）\n        :return:\n        \"\"\"\n        return await self._async_request_obj(self._urls[\"tv_list\"], key=\"certifications\")\n"
  },
  {
    "path": "app/modules/themoviedb/tmdbv3api/objs/change.py",
    "content": "from ..tmdb import TMDb\n\n\nclass Change(TMDb):\n    _urls = {\n        \"movie\": \"/movie/changes\",\n        \"tv\": \"/tv/changes\",\n        \"person\": \"/person/changes\"\n    }\n\n    def _change_list(self, change_type, start_date=\"\", end_date=\"\", page=1):\n        params = \"page=%s\" % page\n        if start_date:\n            params += \"&start_date=%s\" % start_date\n        if end_date:\n            params += \"&end_date=%s\" % end_date\n        return self._request_obj(\n            self._urls[change_type],\n            params=params,\n            key=\"results\"\n        )\n\n    async def _async_change_list(self, change_type, start_date=\"\", end_date=\"\", page=1):\n        params = \"page=%s\" % page\n        if start_date:\n            params += \"&start_date=%s\" % start_date\n        if end_date:\n            params += \"&end_date=%s\" % end_date\n        return await self._async_request_obj(\n            self._urls[change_type],\n            params=params,\n            key=\"results\"\n        )\n\n    def movie_change_list(self, start_date=\"\", end_date=\"\", page=1):\n        \"\"\"\n        Get the changes for a movie. By default only the last 24 hours are returned.\n        You can query up to 14 days in a single query by using the start_date and end_date query parameters.\n        :param start_date: str\n        :param end_date: str\n        :param page: int\n        :return:\n        \"\"\"\n        return self._change_list(\"movie\", start_date=start_date, end_date=end_date, page=page)\n\n    async def async_movie_change_list(self, start_date=\"\", end_date=\"\", page=1):\n        \"\"\"\n        Get the changes for a movie. By default only the last 24 hours are returned.（异步版本）\n        You can query up to 14 days in a single query by using the start_date and end_date query parameters.\n        :param start_date: str\n        :param end_date: str\n        :param page: int\n        :return:\n        \"\"\"\n        return await self._async_change_list(\"movie\", start_date=start_date, end_date=end_date, page=page)\n\n    def tv_change_list(self, start_date=\"\", end_date=\"\", page=1):\n        \"\"\"\n        Get a list of all of the TV show ids that have been changed in the past 24 hours.\n        You can query up to 14 days in a single query by using the start_date and end_date query parameters.\n        :param start_date: str\n        :param end_date: str\n        :param page: int\n        :return:\n        \"\"\"\n        return self._change_list(\"tv\", start_date=start_date, end_date=end_date, page=page)\n\n    async def async_tv_change_list(self, start_date=\"\", end_date=\"\", page=1):\n        \"\"\"\n        Get a list of all of the TV show ids that have been changed in the past 24 hours.（异步版本）\n        You can query up to 14 days in a single query by using the start_date and end_date query parameters.\n        :param start_date: str\n        :param end_date: str\n        :param page: int\n        :return:\n        \"\"\"\n        return await self._async_change_list(\"tv\", start_date=start_date, end_date=end_date, page=page)\n\n    def person_change_list(self, start_date=\"\", end_date=\"\", page=1):\n        \"\"\"\n        Get a list of all of the person ids that have been changed in the past 24 hours.\n        You can query up to 14 days in a single query by using the start_date and end_date query parameters.\n        :param start_date: str\n        :param end_date: str\n        :param page: int\n        :return:\n        \"\"\"\n        return self._change_list(\"person\", start_date=start_date, end_date=end_date, page=page)\n\n    async def async_person_change_list(self, start_date=\"\", end_date=\"\", page=1):\n        \"\"\"\n        Get a list of all of the person ids that have been changed in the past 24 hours.（异步版本）\n        You can query up to 14 days in a single query by using the start_date and end_date query parameters.\n        :param start_date: str\n        :param end_date: str\n        :param page: int\n        :return:\n        \"\"\"\n        return await self._async_change_list(\"person\", start_date=start_date, end_date=end_date, page=page)\n"
  },
  {
    "path": "app/modules/themoviedb/tmdbv3api/objs/collection.py",
    "content": "from ..tmdb import TMDb\n\n\nclass Collection(TMDb):\n    _urls = {\n        \"details\": \"/collection/%s\",\n        \"images\": \"/collection/%s/images\",\n        \"translations\": \"/collection/%s/translations\"\n    }\n\n    def details(self, collection_id):\n        \"\"\"\n        Get collection details by id.\n        :param collection_id: int\n        :return:\n        \"\"\"\n        return self._request_obj(self._urls[\"details\"] % collection_id, key=\"parts\")\n\n    def images(self, collection_id):\n        \"\"\"\n        Get the images for a collection by id.\n        :param collection_id: int\n        :return:\n        \"\"\"\n        return self._request_obj(self._urls[\"images\"] % collection_id)\n\n    def translations(self, collection_id):\n        \"\"\"\n        Get the list translations for a collection by id.\n        :param collection_id: int\n        :return:\n        \"\"\"\n        return self._request_obj(self._urls[\"translations\"] % collection_id, key=\"translations\")\n\n    # 异步版本方法\n    async def async_details(self, collection_id):\n        \"\"\"\n        Get collection details by id.（异步版本）\n        :param collection_id: int\n        :return:\n        \"\"\"\n        return await self._async_request_obj(self._urls[\"details\"] % collection_id, key=\"parts\")\n\n    async def async_images(self, collection_id):\n        \"\"\"\n        Get the images for a collection by id.（异步版本）\n        :param collection_id: int\n        :return:\n        \"\"\"\n        return await self._async_request_obj(self._urls[\"images\"] % collection_id)\n\n    async def async_translations(self, collection_id):\n        \"\"\"\n        Get the list translations for a collection by id.（异步版本）\n        :param collection_id: int\n        :return:\n        \"\"\"\n        return await self._async_request_obj(self._urls[\"translations\"] % collection_id, key=\"translations\")\n"
  },
  {
    "path": "app/modules/themoviedb/tmdbv3api/objs/company.py",
    "content": "from ..tmdb import TMDb\n\n\nclass Company(TMDb):\n    _urls = {\n        \"details\": \"/company/%s\",\n        \"alternative_names\": \"/company/%s/alternative_names\",\n        \"images\": \"/company/%s/images\",\n        \"movies\": \"/company/%s/movies\"\n    }\n\n    def details(self, company_id):\n        \"\"\"\n        Get a companies details by id.\n        :param company_id: int\n        :return:\n        \"\"\"\n        return self._request_obj(self._urls[\"details\"] % company_id)\n\n    def alternative_names(self, company_id):\n        \"\"\"\n        Get the alternative names of a company.\n        :param company_id: int\n        :return:\n        \"\"\"\n        return self._request_obj(self._urls[\"alternative_names\"] % company_id, key=\"results\")\n\n    def images(self, company_id):\n        \"\"\"\n        Get the alternative names of a company.\n        :param company_id: int\n        :return:\n        \"\"\"\n        return self._request_obj(self._urls[\"images\"] % company_id, key=\"logos\")\n\n    def movies(self, company_id, page=1):\n        \"\"\"\n        Get the movies of a company by id.\n        :param company_id: int\n        :param page: int\n        :return:\n        \"\"\"\n        return self._request_obj(\n            self._urls[\"movies\"] % company_id,\n            params=\"page=%s\" % page,\n            key=\"results\"\n        )\n\n    # 异步版本方法\n    async def async_details(self, company_id):\n        \"\"\"\n        Get a companies details by id.（异步版本）\n        :param company_id: int\n        :return:\n        \"\"\"\n        return await self._async_request_obj(self._urls[\"details\"] % company_id)\n\n    async def async_alternative_names(self, company_id):\n        \"\"\"\n        Get the alternative names of a company.（异步版本）\n        :param company_id: int\n        :return:\n        \"\"\"\n        return await self._async_request_obj(self._urls[\"alternative_names\"] % company_id, key=\"results\")\n\n    async def async_images(self, company_id):\n        \"\"\"\n        Get the alternative names of a company.（异步版本）\n        :param company_id: int\n        :return:\n        \"\"\"\n        return await self._async_request_obj(self._urls[\"images\"] % company_id, key=\"logos\")\n\n    async def async_movies(self, company_id, page=1):\n        \"\"\"\n        Get the movies of a company by id.（异步版本）\n        :param company_id: int\n        :param page: int\n        :return:\n        \"\"\"\n        return await self._async_request_obj(\n            self._urls[\"movies\"] % company_id,\n            params=\"page=%s\" % page,\n            key=\"results\"\n        )\n"
  },
  {
    "path": "app/modules/themoviedb/tmdbv3api/objs/configuration.py",
    "content": "import warnings\n\nfrom ..tmdb import TMDb\n\n\nclass Configuration(TMDb):\n    _urls = {\n        \"api_configuration\": \"/configuration\",\n        \"countries\": \"/configuration/countries\",\n        \"jobs\": \"/configuration/jobs\",\n        \"languages\": \"/configuration/languages\",\n        \"primary_translations\": \"/configuration/primary_translations\",\n        \"timezones\": \"/configuration/timezones\"\n    }\n\n    def info(self):\n        warnings.warn(\"info method is deprecated use tmdbv3api.Configuration().api_configuration()\",\n                      DeprecationWarning)\n        return self.api_configuration()\n\n    def api_configuration(self):\n        \"\"\"\n        Get the system wide configuration info.\n        \"\"\"\n        return self._request_obj(self._urls[\"api_configuration\"])\n\n    def countries(self):\n        \"\"\"\n        Get the list of countries (ISO 3166-1 tags) used throughout TMDb.\n        \"\"\"\n        return self._request_obj(self._urls[\"countries\"])\n\n    def jobs(self):\n        \"\"\"\n        Get a list of the jobs and departments we use on TMDb.\n        \"\"\"\n        return self._request_obj(self._urls[\"jobs\"])\n\n    def languages(self):\n        \"\"\"\n        Get the list of languages (ISO 639-1 tags) used throughout TMDb.\n        \"\"\"\n        return self._request_obj(self._urls[\"languages\"])\n\n    def primary_translations(self):\n        \"\"\"\n        Get a list of the officially supported translations on TMDb.\n        \"\"\"\n        return self._request_obj(self._urls[\"primary_translations\"])\n\n    def timezones(self):\n        \"\"\"\n        Get the list of timezones used throughout TMDb.\n        \"\"\"\n        return self._request_obj(self._urls[\"timezones\"])\n\n    # 异步版本方法\n    async def async_api_configuration(self):\n        \"\"\"\n        Get the system wide configuration info.（异步版本）\n        \"\"\"\n        return await self._async_request_obj(self._urls[\"api_configuration\"])\n\n    async def async_countries(self):\n        \"\"\"\n        Get the list of countries (ISO 3166-1 tags) used throughout TMDb.（异步版本）\n        \"\"\"\n        return await self._async_request_obj(self._urls[\"countries\"])\n\n    async def async_jobs(self):\n        \"\"\"\n        Get a list of the jobs and departments we use on TMDb.（异步版本）\n        \"\"\"\n        return await self._async_request_obj(self._urls[\"jobs\"])\n\n    async def async_languages(self):\n        \"\"\"\n        Get the list of languages (ISO 639-1 tags) used throughout TMDb.（异步版本）\n        \"\"\"\n        return await self._async_request_obj(self._urls[\"languages\"])\n\n    async def async_primary_translations(self):\n        \"\"\"\n        Get a list of the officially supported translations on TMDb.（异步版本）\n        \"\"\"\n        return await self._async_request_obj(self._urls[\"primary_translations\"])\n\n    async def async_timezones(self):\n        \"\"\"\n        Get the list of timezones used throughout TMDb.（异步版本）\n        \"\"\"\n        return await self._async_request_obj(self._urls[\"timezones\"])\n"
  },
  {
    "path": "app/modules/themoviedb/tmdbv3api/objs/credit.py",
    "content": "from ..tmdb import TMDb\n\n\nclass Credit(TMDb):\n    _urls = {\n        \"details\": \"/credit/%s\"\n    }\n\n    def details(self, credit_id):\n        \"\"\"\n        Get a movie or TV credit details by id.\n        :param credit_id: int\n        :return:\n        \"\"\"\n        return self._request_obj(self._urls[\"details\"] % credit_id)\n\n    async def async_details(self, credit_id):\n        \"\"\"\n        Get a movie or TV credit details by id.（异步版本）\n        :param credit_id: int\n        :return:\n        \"\"\"\n        return await self._async_request_obj(self._urls[\"details\"] % credit_id)\n"
  },
  {
    "path": "app/modules/themoviedb/tmdbv3api/objs/discover.py",
    "content": "from app.core.cache import cached\nfrom ..tmdb import TMDb\n\ntry:\n    from urllib import urlencode\nexcept ImportError:\n    from urllib.parse import urlencode\n\n\nclass Discover(TMDb):\n    _urls = {\n        \"movies\": \"/discover/movie\",\n        \"tv\": \"/discover/tv\"\n    }\n\n    @cached(maxsize=1, ttl=43200)\n    def discover_movies(self, params_tuple):\n        \"\"\"\n        Discover movies by different types of data like average rating, number of votes, genres and certifications.\n        :param params_tuple: dict\n        :return:\n        \"\"\"\n        params = dict(params_tuple)\n        return self._request_obj(self._urls[\"movies\"], urlencode(params), key=\"results\", call_cached=False)\n\n    @cached(maxsize=1, ttl=43200)\n    def discover_tv_shows(self, params_tuple):\n        \"\"\"\n        Discover TV shows by different types of data like average rating, number of votes, genres,\n        the network they aired on and air dates.\n        :param params_tuple: dict\n        :return:\n        \"\"\"\n        return self._request_obj(self._urls[\"tv\"], urlencode(params_tuple), key=\"results\", call_cached=False)\n\n    @cached(maxsize=1, ttl=43200)\n    async def async_discover_movies(self, params_tuple):\n        \"\"\"\n        Discover movies by different types of data like average rating, number of votes, genres and certifications.（异步版本）\n        :param params_tuple: dict\n        :return:\n        \"\"\"\n        params = dict(params_tuple)\n        return await self._async_request_obj(self._urls[\"movies\"], urlencode(params), key=\"results\", call_cached=False)\n\n    @cached(maxsize=1, ttl=43200)\n    async def async_discover_tv_shows(self, params_tuple):\n        \"\"\"\n        Discover TV shows by different types of data like average rating, number of votes, genres,\n        the network they aired on and air dates.（异步版本）\n        :param params_tuple: dict\n        :return:\n        \"\"\"\n        return await self._async_request_obj(self._urls[\"tv\"], urlencode(params_tuple), key=\"results\",\n                                             call_cached=False)\n"
  },
  {
    "path": "app/modules/themoviedb/tmdbv3api/objs/episode.py",
    "content": "from ..tmdb import TMDb\n\n\nclass Episode(TMDb):\n    _urls = {\n        \"details\": \"/tv/%s/season/%s/episode/%s\",\n        \"account_states\": \"/tv/%s/season/%s/episode/%s/account_states\",\n        \"changes\": \"/tv/episode/%s/changes\",\n        \"credits\": \"/tv/%s/season/%s/episode/%s/credits\",\n        \"external_ids\": \"/tv/%s/season/%s/episode/%s/external_ids\",\n        \"images\": \"/tv/%s/season/%s/episode/%s/images\",\n        \"translations\": \"/tv/%s/season/%s/episode/%s/translations\",\n        \"rate_tv_episode\": \"/tv/%s/season/%s/episode/%s/rating\",\n        \"delete_rating\": \"/tv/%s/season/%s/episode/%s/rating\",\n        \"videos\": \"/tv/%s/season/%s/episode/%s/videos\",\n    }\n\n    def details(self, tv_id, season_num, episode_num, append_to_response=\"trailers,images,casts,translations\"):\n        \"\"\"\n        Get the TV episode details by id.\n        :param tv_id: int\n        :param season_num: int\n        :param episode_num: int\n        :param append_to_response: str\n        :return:\n        \"\"\"\n        return self._request_obj(\n            self._urls[\"details\"] % (tv_id, season_num, episode_num),\n            params=\"append_to_response=%s\" % append_to_response\n        )\n\n    def account_states(self, tv_id, season_num, episode_num):\n        \"\"\"\n        Get your rating for a episode.\n        :param tv_id: int\n        :param season_num: int\n        :param episode_num: int\n        :return:\n        \"\"\"\n        return self._request_obj(\n            self._urls[\"account_states\"] % (tv_id, season_num, episode_num),\n            params=\"session_id=%s\" % self.session_id\n        )\n\n    def changes(self, episode_id, start_date=None, end_date=None, page=1):\n        \"\"\"\n        Get the changes for a TV episode. By default only the last 24 hours are returned.\n        You can query up to 14 days in a single query by using the start_date and end_date query parameters.\n        :param episode_id: int\n        :param start_date: str\n        :param end_date: str\n        :param page: int\n        :return:\n        \"\"\"\n        params = \"page=%s\" % page\n        if start_date:\n            params += \"&start_date=%s\" % start_date\n        if end_date:\n            params += \"&end_date=%s\" % end_date\n        return self._request_obj(\n            self._urls[\"changes\"] % episode_id,\n            params=params,\n            key=\"changes\"\n        )\n\n    def credits(self, tv_id, season_num, episode_num):\n        \"\"\"\n        Get the credits for TV season.\n        :param tv_id: int\n        :param season_num: int\n        :param episode_num: int\n        :return:\n        \"\"\"\n        return self._request_obj(self._urls[\"credits\"] % (tv_id, season_num, episode_num))\n\n    def external_ids(self, tv_id, season_num, episode_num):\n        \"\"\"\n        Get the external ids for a TV episode.\n        :param tv_id: int\n        :param season_num: int\n        :param episode_num: int\n        :return:\n        \"\"\"\n        return self._request_obj(self._urls[\"external_ids\"] % (tv_id, season_num, episode_num))\n\n    def images(self, tv_id, season_num, episode_num, include_image_language=None):\n        \"\"\"\n        Get the images that belong to a TV episode.\n        :param tv_id: int\n        :param season_num: int\n        :param episode_num: int\n        :param include_image_language: str\n        :return:\n        \"\"\"\n        return self._request_obj(\n            self._urls[\"images\"] % (tv_id, season_num, episode_num),\n            params=\"include_image_language=%s\" % include_image_language if include_image_language else \"\",\n            key=\"stills\"\n        )\n\n    def translations(self, tv_id, season_num, episode_num):\n        \"\"\"\n        Get the translation data for an episode.\n        :param tv_id: int\n        :param season_num: int\n        :param episode_num: int\n        :return:\n        \"\"\"\n        return self._request_obj(\n            self._urls[\"translations\"] % (tv_id, season_num, episode_num),\n            key=\"translations\"\n        )\n\n    def rate_tv_episode(self, tv_id, season_num, episode_num, rating):\n        \"\"\"\n        Rate a TV episode.\n        :param tv_id: int\n        :param season_num: int\n        :param episode_num: int\n        :param rating: float\n        \"\"\"\n        self._request_obj(\n            self._urls[\"rate_tv_episode\"] % (tv_id, season_num, episode_num),\n            params=\"session_id=%s\" % self.session_id,\n            method=\"POST\",\n            json={\"value\": rating}\n        )\n\n    def delete_rating(self, tv_id, season_num, episode_num):\n        \"\"\"\n        Remove your rating for a TV episode.\n        :param tv_id: int\n        :param season_num: int\n        :param episode_num: int\n        \"\"\"\n        self._request_obj(\n            self._urls[\"delete_rating\"] % (tv_id, season_num, episode_num),\n            params=\"session_id=%s\" % self.session_id,\n            method=\"DELETE\"\n        )\n\n    def videos(self, tv_id, season_num, episode_num, include_video_language=None):\n        \"\"\"\n        Get the videos that have been added to a TV episode.\n        :param tv_id: int\n        :param season_num: int\n        :param episode_num: int\n        :param include_video_language: str\n        :return:\n        \"\"\"\n        params = \"\"\n        if include_video_language:\n            params += \"&include_video_language=%s\" % include_video_language\n        return self._request_obj(\n            self._urls[\"videos\"] % (tv_id, season_num, episode_num),\n            params=params\n        )\n\n    # 异步版本方法\n    async def async_details(self, tv_id, season_num, episode_num,\n                            append_to_response=\"trailers,images,casts,translations\"):\n        \"\"\"\n        Get the TV episode details by id.（异步版本）\n        :param tv_id: int\n        :param season_num: int\n        :param episode_num: int\n        :param append_to_response: str\n        :return:\n        \"\"\"\n        return await self._async_request_obj(\n            self._urls[\"details\"] % (tv_id, season_num, episode_num),\n            params=\"append_to_response=%s\" % append_to_response\n        )\n\n    async def async_account_states(self, tv_id, season_num, episode_num):\n        \"\"\"\n        Get your rating for a episode.（异步版本）\n        :param tv_id: int\n        :param season_num: int\n        :param episode_num: int\n        :return:\n        \"\"\"\n        return await self._async_request_obj(\n            self._urls[\"account_states\"] % (tv_id, season_num, episode_num),\n            params=\"session_id=%s\" % self.session_id\n        )\n\n    async def async_changes(self, episode_id, start_date=None, end_date=None, page=1):\n        \"\"\"\n        Get the changes for a TV episode. By default only the last 24 hours are returned.\n        You can query up to 14 days in a single query by using the start_date and end_date query parameters.（异步版本）\n        :param episode_id: int\n        :param start_date: str\n        :param end_date: str\n        :param page: int\n        :return:\n        \"\"\"\n        params = \"page=%s\" % page\n        if start_date:\n            params += \"&start_date=%s\" % start_date\n        if end_date:\n            params += \"&end_date=%s\" % end_date\n        return await self._async_request_obj(\n            self._urls[\"changes\"] % episode_id,\n            params=params,\n            key=\"changes\"\n        )\n\n    async def async_credits(self, tv_id, season_num, episode_num):\n        \"\"\"\n        Get the credits for TV season.（异步版本）\n        :param tv_id: int\n        :param season_num: int\n        :param episode_num: int\n        :return:\n        \"\"\"\n        return await self._async_request_obj(self._urls[\"credits\"] % (tv_id, season_num, episode_num))\n\n    async def async_external_ids(self, tv_id, season_num, episode_num):\n        \"\"\"\n        Get the external ids for a TV episode.（异步版本）\n        :param tv_id: int\n        :param season_num: int\n        :param episode_num: int\n        :return:\n        \"\"\"\n        return await self._async_request_obj(self._urls[\"external_ids\"] % (tv_id, season_num, episode_num))\n\n    async def async_images(self, tv_id, season_num, episode_num, include_image_language=None):\n        \"\"\"\n        Get the images that belong to a TV episode.（异步版本）\n        :param tv_id: int\n        :param season_num: int\n        :param episode_num: int\n        :param include_image_language: str\n        :return:\n        \"\"\"\n        return await self._async_request_obj(\n            self._urls[\"images\"] % (tv_id, season_num, episode_num),\n            params=\"include_image_language=%s\" % include_image_language if include_image_language else \"\",\n            key=\"stills\"\n        )\n\n    async def async_translations(self, tv_id, season_num, episode_num):\n        \"\"\"\n        Get the translation data for an episode.（异步版本）\n        :param tv_id: int\n        :param season_num: int\n        :param episode_num: int\n        :return:\n        \"\"\"\n        return await self._async_request_obj(\n            self._urls[\"translations\"] % (tv_id, season_num, episode_num),\n            key=\"translations\"\n        )\n\n    async def async_rate_tv_episode(self, tv_id, season_num, episode_num, rating):\n        \"\"\"\n        Rate a TV episode.（异步版本）\n        :param tv_id: int\n        :param season_num: int\n        :param episode_num: int\n        :param rating: float\n        \"\"\"\n        await self._async_request_obj(\n            self._urls[\"rate_tv_episode\"] % (tv_id, season_num, episode_num),\n            params=\"session_id=%s\" % self.session_id,\n            method=\"POST\",\n            json={\"value\": rating}\n        )\n\n    async def async_delete_rating(self, tv_id, season_num, episode_num):\n        \"\"\"\n        Remove your rating for a TV episode.（异步版本）\n        :param tv_id: int\n        :param season_num: int\n        :param episode_num: int\n        \"\"\"\n        await self._async_request_obj(\n            self._urls[\"delete_rating\"] % (tv_id, season_num, episode_num),\n            params=\"session_id=%s\" % self.session_id,\n            method=\"DELETE\"\n        )\n\n    async def async_videos(self, tv_id, season_num, episode_num, include_video_language=None):\n        \"\"\"\n        Get the videos that have been added to a TV episode.（异步版本）\n        :param tv_id: int\n        :param season_num: int\n        :param episode_num: int\n        :param include_video_language: str\n        :return:\n        \"\"\"\n        params = \"\"\n        if include_video_language:\n            params += \"&include_video_language=%s\" % include_video_language\n        return await self._async_request_obj(\n            self._urls[\"videos\"] % (tv_id, season_num, episode_num),\n            params=params\n        )\n"
  },
  {
    "path": "app/modules/themoviedb/tmdbv3api/objs/find.py",
    "content": "from ..tmdb import TMDb\n\n\nclass Find(TMDb):\n    _urls = {\n        \"find\": \"/find/%s\"\n    }\n\n    def find(self, external_id, external_source):\n        \"\"\"\n        The find method makes it easy to search for objects in our database by an external id. For example, an IMDB ID.\n        :param external_id: str\n        :param external_source str\n        :return:\n        \"\"\"\n        return self._request_obj(\n            self._urls[\"find\"] % external_id.replace(\"/\", \"%2F\"),\n            params=\"external_source=\" + external_source\n        )\n\n    def find_by_imdb_id(self, imdb_id):\n        \"\"\"\n        The find method makes it easy to search for objects in our database by an IMDB ID.\n        :param imdb_id: str\n        :return:\n        \"\"\"\n        return self.find(imdb_id, \"imdb_id\")\n\n    def find_by_tvdb_id(self, tvdb_id):\n        \"\"\"\n        The find method makes it easy to search for objects in our database by a TVDB ID.\n        :param tvdb_id: int\n        :return:\n        \"\"\"\n        return self.find(tvdb_id, \"tvdb_id\")\n\n    def find_by_freebase_mid(self, freebase_mid):\n        \"\"\"\n        The find method makes it easy to search for objects in our database by a Freebase MID.\n        :param freebase_mid: str\n        :return:\n        \"\"\"\n        return self.find(freebase_mid, \"freebase_mid\")\n\n    def find_by_freebase_id(self, freebase_id):\n        \"\"\"\n        The find method makes it easy to search for objects in our database by a Freebase ID.\n        :param freebase_id: str\n        :return:\n        \"\"\"\n        return self.find(freebase_id, \"freebase_id\")\n\n    def find_by_tvrage_id(self, tvrage_id):\n        \"\"\"\n        The find method makes it easy to search for objects in our database by a TVRage ID.\n        :param tvrage_id: str\n        :return:\n        \"\"\"\n        return self.find(tvrage_id, \"tvrage_id\")\n\n    def find_by_facebook_id(self, facebook_id):\n        \"\"\"\n        The find method makes it easy to search for objects in our database by a Facebook ID.\n        :param facebook_id: str\n        :return:\n        \"\"\"\n        return self.find(facebook_id, \"facebook_id\")\n\n    def find_by_instagram_id(self, instagram_id):\n        \"\"\"\n        The find method makes it easy to search for objects in our database by a Instagram ID.\n        :param instagram_id: str\n        :return:\n        \"\"\"\n        return self.find(instagram_id, \"instagram_id\")\n\n    def find_by_twitter_id(self, twitter_id):\n        \"\"\"\n        The find method makes it easy to search for objects in our database by a Twitter ID.\n        :param twitter_id: str\n        :return:\n        \"\"\"\n        return self.find(twitter_id, \"twitter_id\")\n\n    # 异步版本方法\n    async def async_find(self, external_id, external_source):\n        \"\"\"\n        The find method makes it easy to search for objects in our database by an external id. For example, an IMDB ID.（异步版本）\n        :param external_id: str\n        :param external_source str\n        :return:\n        \"\"\"\n        return await self._async_request_obj(\n            self._urls[\"find\"] % external_id.replace(\"/\", \"%2F\"),\n            params=\"external_source=\" + external_source\n        )\n\n    async def async_find_by_imdb_id(self, imdb_id):\n        \"\"\"\n        The find method makes it easy to search for objects in our database by an IMDB ID.（异步版本）\n        :param imdb_id: str\n        :return:\n        \"\"\"\n        return await self.async_find(imdb_id, \"imdb_id\")\n\n    async def async_find_by_tvdb_id(self, tvdb_id):\n        \"\"\"\n        The find method makes it easy to search for objects in our database by a TVDB ID.（异步版本）\n        :param tvdb_id: int\n        :return:\n        \"\"\"\n        return await self.async_find(tvdb_id, \"tvdb_id\")\n\n    async def async_find_by_freebase_mid(self, freebase_mid):\n        \"\"\"\n        The find method makes it easy to search for objects in our database by a Freebase MID.（异步版本）\n        :param freebase_mid: str\n        :return:\n        \"\"\"\n        return await self.async_find(freebase_mid, \"freebase_mid\")\n\n    async def async_find_by_freebase_id(self, freebase_id):\n        \"\"\"\n        The find method makes it easy to search for objects in our database by a Freebase ID.（异步版本）\n        :param freebase_id: str\n        :return:\n        \"\"\"\n        return await self.async_find(freebase_id, \"freebase_id\")\n\n    async def async_find_by_tvrage_id(self, tvrage_id):\n        \"\"\"\n        The find method makes it easy to search for objects in our database by a TVRage ID.（异步版本）\n        :param tvrage_id: str\n        :return:\n        \"\"\"\n        return await self.async_find(tvrage_id, \"tvrage_id\")\n\n    async def async_find_by_facebook_id(self, facebook_id):\n        \"\"\"\n        The find method makes it easy to search for objects in our database by a Facebook ID.（异步版本）\n        :param facebook_id: str\n        :return:\n        \"\"\"\n        return await self.async_find(facebook_id, \"facebook_id\")\n\n    async def async_find_by_instagram_id(self, instagram_id):\n        \"\"\"\n        The find method makes it easy to search for objects in our database by a Instagram ID.（异步版本）\n        :param instagram_id: str\n        :return:\n        \"\"\"\n        return await self.async_find(instagram_id, \"instagram_id\")\n\n    async def async_find_by_twitter_id(self, twitter_id):\n        \"\"\"\n        The find method makes it easy to search for objects in our database by a Twitter ID.（异步版本）\n        :param twitter_id: str\n        :return:\n        \"\"\"\n        return await self.async_find(twitter_id, \"twitter_id\")\n"
  },
  {
    "path": "app/modules/themoviedb/tmdbv3api/objs/genre.py",
    "content": "from ..tmdb import TMDb\n\n\nclass Genre(TMDb):\n    _urls = {\n        \"movie_list\": \"/genre/movie/list\",\n        \"tv_list\": \"/genre/tv/list\"\n    }\n\n    def movie_list(self):\n        \"\"\"\n        Get the list of official genres for movies.\n        :return:\n        \"\"\"\n        return self._request_obj(self._urls[\"movie_list\"], key=\"genres\")\n\n    def tv_list(self):\n        \"\"\"\n        Get the list of official genres for TV shows.\n        :return:\n        \"\"\"\n        return self._request_obj(self._urls[\"tv_list\"], key=\"genres\")\n\n    # 异步版本方法\n    async def async_movie_list(self):\n        \"\"\"\n        Get the list of official genres for movies.（异步版本）\n        :return:\n        \"\"\"\n        return await self._async_request_obj(self._urls[\"movie_list\"], key=\"genres\")\n\n    async def async_tv_list(self):\n        \"\"\"\n        Get the list of official genres for TV shows.（异步版本）\n        :return:\n        \"\"\"\n        return await self._async_request_obj(self._urls[\"tv_list\"], key=\"genres\")\n"
  },
  {
    "path": "app/modules/themoviedb/tmdbv3api/objs/group.py",
    "content": "from ..tmdb import TMDb\n\n\nclass Group(TMDb):\n    _urls = {\n        \"details\": \"/tv/episode_group/%s\"\n    }\n\n    def details(self, group_id):\n        \"\"\"\n        Get the details of a TV episode group.\n        :param group_id: int\n        :return:\n        \"\"\"\n        return self._request_obj(self._urls[\"details\"] % group_id, key=\"groups\")\n\n    async def async_details(self, group_id):\n        \"\"\"\n        Get the details of a TV episode group.（异步版本）\n        :param group_id: int\n        :return:\n        \"\"\"\n        return await self._async_request_obj(self._urls[\"details\"] % group_id, key=\"groups\")\n"
  },
  {
    "path": "app/modules/themoviedb/tmdbv3api/objs/keyword.py",
    "content": "from ..tmdb import TMDb\n\n\nclass Keyword(TMDb):\n    _urls = {\n        \"details\": \"/keyword/%s\",\n        \"movies\": \"/keyword/%s/movies\"\n    }\n\n    def details(self, keyword_id):\n        \"\"\"\n        Get a keywords details by id.\n        :param keyword_id: int\n        :return:\n        \"\"\"\n        return self._request_obj(self._urls[\"details\"] % keyword_id)\n\n    def movies(self, keyword_id):\n        \"\"\"\n        Get the movies of a keyword by id.\n        :param keyword_id: int\n        :return:\n        \"\"\"\n        return self._request_obj(self._urls[\"movies\"] % keyword_id, key=\"results\")\n\n    # 异步版本方法\n    async def async_details(self, keyword_id):\n        \"\"\"\n        Get a keywords details by id.（异步版本）\n        :param keyword_id: int\n        :return:\n        \"\"\"\n        return await self._async_request_obj(self._urls[\"details\"] % keyword_id)\n\n    async def async_movies(self, keyword_id):\n        \"\"\"\n        Get the movies of a keyword by id.（异步版本）\n        :param keyword_id: int\n        :return:\n        \"\"\"\n        return await self._async_request_obj(self._urls[\"movies\"] % keyword_id, key=\"results\")\n"
  },
  {
    "path": "app/modules/themoviedb/tmdbv3api/objs/list.py",
    "content": "from ..tmdb import TMDb\n\n\nclass List(TMDb):\n    _urls = {\n        \"details\": \"/list/%s\",\n        \"check_status\": \"/list/%s/item_status\",\n        \"create\": \"/list\",\n        \"add_movie\": \"/list/%s/add_item\",\n        \"remove_movie\": \"/list/%s/remove_item\",\n        \"clear_list\": \"/list/%s/clear\",\n        \"delete_list\": \"/list/%s\",\n    }\n\n    def details(self, list_id):\n        \"\"\"\n        Get list details by id.\n        :param list_id: int\n        :return:\n        \"\"\"\n        return self._request_obj(self._urls[\"details\"] % list_id, key=\"items\")\n\n    def check_item_status(self, list_id, movie_id):\n        \"\"\"\n        You can use this method to check if a movie has already been added to the list.\n        :param list_id: int\n        :param movie_id: int\n        :return:\n        \"\"\"\n        return self._request_obj(self._urls[\"check_status\"] % list_id, params=\"movie_id=%s\" % movie_id)[\"item_present\"]\n\n    def create_list(self, name, description):\n        \"\"\"\n        You can use this method to check if a movie has already been added to the list.\n        :param name: str\n        :param description: str\n        :return:\n        \"\"\"\n        return self._request_obj(\n            self._urls[\"create\"],\n            params=\"session_id=%s\" % self.session_id,\n            method=\"POST\",\n            json={\n                \"name\": name,\n                \"description\": description,\n                \"language\": self.language\n            }\n        ).list_id\n\n    def add_movie(self, list_id, movie_id):\n        \"\"\"\n        Add a movie to a list.\n        :param list_id: int\n        :param movie_id: int\n        \"\"\"\n        self._request_obj(\n            self._urls[\"add_movie\"] % list_id,\n            params=\"session_id=%s\" % self.session_id,\n            method=\"POST\",\n            json={\"media_id\": movie_id}\n        )\n\n    def remove_movie(self, list_id, movie_id):\n        \"\"\"\n        Remove a movie from a list.\n        :param list_id: int\n        :param movie_id: int\n        \"\"\"\n        self._request_obj(\n            self._urls[\"remove_movie\"] % list_id,\n            params=\"session_id=%s\" % self.session_id,\n            method=\"POST\",\n            json={\"media_id\": movie_id}\n        )\n\n    def clear_list(self, list_id):\n        \"\"\"\n        Clear all of the items from a list.\n        :param list_id: int\n        \"\"\"\n        self._request_obj(\n            self._urls[\"clear_list\"] % list_id,\n            params=\"session_id=%s&confirm=true\" % self.session_id,\n            method=\"POST\"\n        )\n\n    def delete_list(self, list_id):\n        \"\"\"\n        Delete a list.\n        :param list_id: int\n        \"\"\"\n        self._request_obj(\n            self._urls[\"delete_list\"] % list_id,\n            params=\"session_id=%s\" % self.session_id,\n            method=\"DELETE\"\n        )\n\n    # 异步版本方法\n    async def async_details(self, list_id):\n        \"\"\"\n        Get list details by id.（异步版本）\n        :param list_id: int\n        :return:\n        \"\"\"\n        return await self._async_request_obj(self._urls[\"details\"] % list_id, key=\"items\")\n\n    async def async_check_item_status(self, list_id, movie_id):\n        \"\"\"\n        You can use this method to check if a movie has already been added to the list.（异步版本）\n        :param list_id: int\n        :param movie_id: int\n        :return:\n        \"\"\"\n        result = await self._async_request_obj(self._urls[\"check_status\"] % list_id, params=\"movie_id=%s\" % movie_id)\n        return result[\"item_present\"]\n\n    async def async_create_list(self, name, description):\n        \"\"\"\n        You can use this method to check if a movie has already been added to the list.（异步版本）\n        :param name: str\n        :param description: str\n        :return:\n        \"\"\"\n        result = await self._async_request_obj(\n            self._urls[\"create\"],\n            params=\"session_id=%s\" % self.session_id,\n            method=\"POST\",\n            json={\n                \"name\": name,\n                \"description\": description,\n                \"language\": self.language\n            }\n        )\n        return result.list_id\n\n    async def async_add_movie(self, list_id, movie_id):\n        \"\"\"\n        Add a movie to a list.（异步版本）\n        :param list_id: int\n        :param movie_id: int\n        \"\"\"\n        await self._async_request_obj(\n            self._urls[\"add_movie\"] % list_id,\n            params=\"session_id=%s\" % self.session_id,\n            method=\"POST\",\n            json={\"media_id\": movie_id}\n        )\n\n    async def async_remove_movie(self, list_id, movie_id):\n        \"\"\"\n        Remove a movie from a list.（异步版本）\n        :param list_id: int\n        :param movie_id: int\n        \"\"\"\n        await self._async_request_obj(\n            self._urls[\"remove_movie\"] % list_id,\n            params=\"session_id=%s\" % self.session_id,\n            method=\"POST\",\n            json={\"media_id\": movie_id}\n        )\n\n    async def async_clear_list(self, list_id):\n        \"\"\"\n        Clear all of the items from a list.（异步版本）\n        :param list_id: int\n        \"\"\"\n        await self._async_request_obj(\n            self._urls[\"clear_list\"] % list_id,\n            params=\"session_id=%s&confirm=true\" % self.session_id,\n            method=\"POST\"\n        )\n\n    async def async_delete_list(self, list_id):\n        \"\"\"\n        Delete a list.（异步版本）\n        :param list_id: int\n        \"\"\"\n        await self._async_request_obj(\n            self._urls[\"delete_list\"] % list_id,\n            params=\"session_id=%s\" % self.session_id,\n            method=\"DELETE\"\n        )\n"
  },
  {
    "path": "app/modules/themoviedb/tmdbv3api/objs/movie.py",
    "content": "from ..tmdb import TMDb\n\n\nclass Movie(TMDb):\n    _urls = {\n        \"details\": \"/movie/%s\",\n        \"account_states\": \"/movie/%s/account_states\",\n        \"alternative_titles\": \"/movie/%s/alternative_titles\",\n        \"changes\": \"/movie/%s/changes\",\n        \"credits\": \"/movie/%s/credits\",\n        \"external_ids\": \"/movie/%s/external_ids\",\n        \"images\": \"/movie/%s/images\",\n        \"keywords\": \"/movie/%s/keywords\",\n        \"lists\": \"/movie/%s/lists\",\n        \"recommendations\": \"/movie/%s/recommendations\",\n        \"release_dates\": \"/movie/%s/release_dates\",\n        \"reviews\": \"/movie/%s/reviews\",\n        \"similar\": \"/movie/%s/similar\",\n        \"translations\": \"/movie/%s/translations\",\n        \"videos\": \"/movie/%s/videos\",\n        \"watch_providers\": \"/movie/%s/watch/providers\",\n        \"rate_movie\": \"/movie/%s/rating\",\n        \"delete_rating\": \"/movie/%s/rating\",\n        \"latest\": \"/movie/latest\",\n        \"now_playing\": \"/movie/now_playing\",\n        \"popular\": \"/movie/popular\",\n        \"top_rated\": \"/movie/top_rated\",\n        \"upcoming\": \"/movie/upcoming\",\n    }\n\n    def details(self, movie_id, append_to_response=\"videos,trailers,images,casts,translations,keywords,release_dates\"):\n        \"\"\"\n        Get the primary information about a movie.\n        :param movie_id: int\n        :param append_to_response: str\n        :return:\n        \"\"\"\n        return self._request_obj(\n            self._urls[\"details\"] % movie_id,\n            params=\"append_to_response=%s\" % append_to_response\n        )\n\n    def account_states(self, movie_id):\n        \"\"\"\n        Grab the following account states for a session:\n        Movie rating, If it belongs to your watchlist, or If it belongs to your favourite list.\n        :param movie_id: int\n        :return:\n        \"\"\"\n        return self._request_obj(\n            self._urls[\"account_states\"] % movie_id,\n            params=\"session_id=%s\" % self.session_id\n        )\n\n    def alternative_titles(self, movie_id, country=None):\n        \"\"\"\n        Get all of the alternative titles for a movie.\n        :param movie_id: int\n        :param country: str\n        :return:\n        \"\"\"\n        return self._request_obj(\n            self._urls[\"alternative_titles\"] % movie_id,\n            params=\"country=%s\" % country if country else \"\",\n            key=\"titles\"\n        )\n\n    def changes(self, movie_id, start_date=None, end_date=None, page=1):\n        \"\"\"\n        Get the changes for a movie. By default only the last 24 hours are returned.\n        You can query up to 14 days in a single query by using the start_date and end_date query parameters.\n        :param movie_id: int\n        :param start_date: str\n        :param end_date: str\n        :param page: int\n        :return:\n        \"\"\"\n        params = \"page=%s\" % page\n        if start_date:\n            params += \"&start_date=%s\" % start_date\n        if end_date:\n            params += \"&end_date=%s\" % end_date\n        return self._request_obj(\n            self._urls[\"changes\"] % movie_id,\n            params=params,\n            key=\"changes\"\n        )\n\n    def credits(self, movie_id):\n        \"\"\"\n        Get the cast and crew for a movie.\n        :param movie_id: int\n        :return:\n        \"\"\"\n        return self._request_obj(self._urls[\"credits\"] % movie_id)\n\n    def external_ids(self, movie_id):\n        \"\"\"\n        Get the external ids for a movie.\n        :param movie_id: int\n        :return:\n        \"\"\"\n        return self._request_obj(self._urls[\"external_ids\"] % movie_id)\n\n    def images(self, movie_id, include_image_language=None):\n        \"\"\"\n        Get the images that belong to a movie.\n        Querying images with a language parameter will filter the results.\n        If you want to include a fallback language (especially useful for backdrops)\n        you can use the include_image_language parameter.\n        This should be a comma separated value like so: include_image_language=en,null.\n        :param movie_id: int\n        :param include_image_language: str\n        :return:\n        \"\"\"\n        return self._request_obj(\n            self._urls[\"images\"] % movie_id,\n            params=\"include_image_language=%s\" % include_image_language if include_image_language else \"\"\n        )\n\n    def keywords(self, movie_id):\n        \"\"\"\n        Get the keywords associated to a movie.\n        :param movie_id: int\n        :return:\n        \"\"\"\n        return self._request_obj(\n            self._urls[\"keywords\"] % movie_id,\n            key=\"keywords\"\n        )\n\n    def lists(self, movie_id, page=1):\n        \"\"\"\n        Get a list of lists that this movie belongs to.\n        :param movie_id: int\n        :param page: int\n        :return:\n        \"\"\"\n        return self._request_obj(\n            self._urls[\"lists\"] % movie_id,\n            params=\"page=%s\" % page,\n            key=\"results\"\n        )\n\n    def recommendations(self, movie_id, page=1):\n        \"\"\"\n        Get a list of recommended movies for a movie.\n        :param movie_id: int\n        :param page: int\n        :return:\n        \"\"\"\n        return self._request_obj(\n            self._urls[\"recommendations\"] % movie_id,\n            params=\"page=%s\" % page,\n            key=\"results\"\n        )\n\n    def release_dates(self, movie_id):\n        \"\"\"\n        Get the release date along with the certification for a movie.\n        :param movie_id: int\n        :return:\n        \"\"\"\n        return self._request_obj(\n            self._urls[\"release_dates\"] % movie_id,\n            key=\"results\"\n        )\n\n    def reviews(self, movie_id, page=1):\n        \"\"\"\n        Get the user reviews for a movie.\n        :param movie_id: int\n        :param page: int\n        :return:\n        \"\"\"\n        return self._request_obj(\n            self._urls[\"reviews\"] % movie_id,\n            params=\"page=%s\" % page,\n            key=\"results\"\n        )\n\n    def similar(self, movie_id, page=1):\n        \"\"\"\n        Get a list of similar movies.\n        :param movie_id: int\n        :param page: int\n        :return:\n        \"\"\"\n        return self._request_obj(\n            self._urls[\"similar\"] % movie_id,\n            params=\"page=%s\" % page,\n            key=\"results\"\n        )\n\n    def translations(self, movie_id):\n        \"\"\"\n        Get a list of translations that have been created for a movie.\n        :param movie_id: int\n        :return:\n        \"\"\"\n        return self._request_obj(\n            self._urls[\"translations\"] % movie_id,\n            key=\"translations\"\n        )\n\n    def videos(self, movie_id, page=1):\n        \"\"\"\n        Get the videos that have been added to a movie.\n        :param movie_id: int\n        :param page: int\n        :return:\n        \"\"\"\n        return self._request_obj(\n            self._urls[\"videos\"] % movie_id,\n            params=\"page=%s\" % page,\n            key=\"results\"\n        )\n\n    def watch_providers(self, movie_id):\n        \"\"\"\n        You can query this method to get a list of the availabilities per country by provider.\n        :param movie_id: int\n        :return:\n        \"\"\"\n        return self._request_obj(\n            self._urls[\"watch_providers\"] % movie_id,\n            key=\"results\"\n        )\n\n    def rate_movie(self, movie_id, rating):\n        \"\"\"\n        Rate a movie.\n        :param movie_id: int\n        :param rating: float\n        \"\"\"\n        self._request_obj(\n            self._urls[\"rate_movie\"] % movie_id,\n            params=\"session_id=%s\" % self.session_id,\n            method=\"POST\",\n            json={\"value\": rating}\n        )\n\n    def delete_rating(self, movie_id):\n        \"\"\"\n        Remove your rating for a movie.\n        :param movie_id: int\n        \"\"\"\n        self._request_obj(\n            self._urls[\"delete_rating\"] % movie_id,\n            params=\"session_id=%s\" % self.session_id,\n            method=\"DELETE\"\n        )\n\n    def latest(self):\n        \"\"\"\n        Get the most newly created movie. This is a live response and will continuously change.\n        :return:\n        \"\"\"\n        return self._request_obj(self._urls[\"latest\"])\n\n    def now_playing(self, region=None, page=1):\n        \"\"\"\n        Get a list of movies in theatres.\n        :param region: str\n        :param page: int\n        :return:\n        \"\"\"\n        params = \"page=%s\" % page\n        if region:\n            params += \"&region=%s\" % region\n        return self._request_obj(\n            self._urls[\"now_playing\"],\n            params=params,\n            key=\"results\"\n        )\n\n    def popular(self, region=None, page=1):\n        \"\"\"\n        Get a list of the current popular movies on TMDb. This list updates daily.\n        :param region: str\n        :param page: int\n        :return:\n        \"\"\"\n        params = \"page=%s\" % page\n        if region:\n            params += \"&region=%s\" % region\n        return self._request_obj(\n            self._urls[\"popular\"],\n            params=params,\n            key=\"results\"\n        )\n\n    def top_rated(self, region=None, page=1):\n        \"\"\"\n        Get the top rated movies on TMDb.\n        :param region: str\n        :param page: int\n        :return:\n        \"\"\"\n        params = \"page=%s\" % page\n        if region:\n            params += \"&region=%s\" % region\n        return self._request_obj(\n            self._urls[\"top_rated\"],\n            params=params,\n            key=\"results\"\n        )\n\n    def upcoming(self, region=None, page=1):\n        \"\"\"\n        Get a list of upcoming movies in theatres.\n        :param region: str\n        :param page: int\n        :return:\n        \"\"\"\n        params = \"page=%s\" % page\n        if region:\n            params += \"&region=%s\" % region\n        return self._request_obj(\n            self._urls[\"upcoming\"],\n            params=params,\n            key=\"results\"\n        )\n\n    # 异步版本方法\n    async def async_details(self, movie_id,\n                            append_to_response=\"videos,trailers,images,casts,translations,keywords,release_dates\"):\n        \"\"\"\n        Get the primary information about a movie.（异步版本）\n        :param movie_id: int\n        :param append_to_response: str\n        :return:\n        \"\"\"\n        return await self._async_request_obj(\n            self._urls[\"details\"] % movie_id,\n            params=\"append_to_response=%s\" % append_to_response\n        )\n\n    async def async_account_states(self, movie_id):\n        \"\"\"\n        Grab the following account states for a session:\n        Movie rating, If it belongs to your watchlist, or If it belongs to your favourite list.（异步版本）\n        :param movie_id: int\n        :return:\n        \"\"\"\n        return await self._async_request_obj(\n            self._urls[\"account_states\"] % movie_id,\n            params=\"session_id=%s\" % self.session_id\n        )\n\n    async def async_alternative_titles(self, movie_id, country=None):\n        \"\"\"\n        Get all of the alternative titles for a movie.（异步版本）\n        :param movie_id: int\n        :param country: str\n        :return:\n        \"\"\"\n        return await self._async_request_obj(\n            self._urls[\"alternative_titles\"] % movie_id,\n            params=\"country=%s\" % country if country else \"\",\n            key=\"titles\"\n        )\n\n    async def async_changes(self, movie_id, start_date=None, end_date=None, page=1):\n        \"\"\"\n        Get the changes for a movie. By default only the last 24 hours are returned.\n        You can query up to 14 days in a single query by using the start_date and end_date query parameters.（异步版本）\n        :param movie_id: int\n        :param start_date: str\n        :param end_date: str\n        :param page: int\n        :return:\n        \"\"\"\n        params = \"page=%s\" % page\n        if start_date:\n            params += \"&start_date=%s\" % start_date\n        if end_date:\n            params += \"&end_date=%s\" % end_date\n        return await self._async_request_obj(\n            self._urls[\"changes\"] % movie_id,\n            params=params,\n            key=\"changes\"\n        )\n\n    async def async_credits(self, movie_id):\n        \"\"\"\n        Get the cast and crew for a movie.（异步版本）\n        :param movie_id: int\n        :return:\n        \"\"\"\n        return await self._async_request_obj(self._urls[\"credits\"] % movie_id)\n\n    async def async_external_ids(self, movie_id):\n        \"\"\"\n        Get the external ids for a movie.（异步版本）\n        :param movie_id: int\n        :return:\n        \"\"\"\n        return await self._async_request_obj(self._urls[\"external_ids\"] % movie_id)\n\n    async def async_images(self, movie_id, include_image_language=None):\n        \"\"\"\n        Get the images that belong to a movie.\n        Querying images with a language parameter will filter the results.\n        If you want to include a fallback language (especially useful for backdrops)\n        you can use the include_image_language parameter.\n        This should be a comma separated value like so: include_image_language=en,null.（异步版本）\n        :param movie_id: int\n        :param include_image_language: str\n        :return:\n        \"\"\"\n        return await self._async_request_obj(\n            self._urls[\"images\"] % movie_id,\n            params=\"include_image_language=%s\" % include_image_language if include_image_language else \"\"\n        )\n\n    async def async_keywords(self, movie_id):\n        \"\"\"\n        Get the keywords associated to a movie.（异步版本）\n        :param movie_id: int\n        :return:\n        \"\"\"\n        return await self._async_request_obj(\n            self._urls[\"keywords\"] % movie_id,\n            key=\"keywords\"\n        )\n\n    async def async_lists(self, movie_id, page=1):\n        \"\"\"\n        Get a list of lists that this movie belongs to.（异步版本）\n        :param movie_id: int\n        :param page: int\n        :return:\n        \"\"\"\n        return await self._async_request_obj(\n            self._urls[\"lists\"] % movie_id,\n            params=\"page=%s\" % page,\n            key=\"results\"\n        )\n\n    async def async_recommendations(self, movie_id, page=1):\n        \"\"\"\n        Get a list of recommended movies for a movie.（异步版本）\n        :param movie_id: int\n        :param page: int\n        :return:\n        \"\"\"\n        return await self._async_request_obj(\n            self._urls[\"recommendations\"] % movie_id,\n            params=\"page=%s\" % page,\n            key=\"results\"\n        )\n\n    async def async_release_dates(self, movie_id):\n        \"\"\"\n        Get the release date along with the certification for a movie.（异步版本）\n        :param movie_id: int\n        :return:\n        \"\"\"\n        return await self._async_request_obj(\n            self._urls[\"release_dates\"] % movie_id,\n            key=\"results\"\n        )\n\n    async def async_reviews(self, movie_id, page=1):\n        \"\"\"\n        Get the user reviews for a movie.（异步版本）\n        :param movie_id: int\n        :param page: int\n        :return:\n        \"\"\"\n        return await self._async_request_obj(\n            self._urls[\"reviews\"] % movie_id,\n            params=\"page=%s\" % page,\n            key=\"results\"\n        )\n\n    async def async_similar(self, movie_id, page=1):\n        \"\"\"\n        Get a list of similar movies.（异步版本）\n        :param movie_id: int\n        :param page: int\n        :return:\n        \"\"\"\n        return await self._async_request_obj(\n            self._urls[\"similar\"] % movie_id,\n            params=\"page=%s\" % page,\n            key=\"results\"\n        )\n\n    async def async_translations(self, movie_id):\n        \"\"\"\n        Get a list of translations that have been created for a movie.（异步版本）\n        :param movie_id: int\n        :return:\n        \"\"\"\n        return await self._async_request_obj(\n            self._urls[\"translations\"] % movie_id,\n            key=\"translations\"\n        )\n\n    async def async_videos(self, movie_id, page=1):\n        \"\"\"\n        Get the videos that have been added to a movie.（异步版本）\n        :param movie_id: int\n        :param page: int\n        :return:\n        \"\"\"\n        return await self._async_request_obj(\n            self._urls[\"videos\"] % movie_id,\n            params=\"page=%s\" % page,\n            key=\"results\"\n        )\n\n    async def async_watch_providers(self, movie_id):\n        \"\"\"\n        You can query this method to get a list of the availabilities per country by provider.（异步版本）\n        :param movie_id: int\n        :return:\n        \"\"\"\n        return await self._async_request_obj(\n            self._urls[\"watch_providers\"] % movie_id,\n            key=\"results\"\n        )\n\n    async def async_rate_movie(self, movie_id, rating):\n        \"\"\"\n        Rate a movie.（异步版本）\n        :param movie_id: int\n        :param rating: float\n        \"\"\"\n        await self._async_request_obj(\n            self._urls[\"rate_movie\"] % movie_id,\n            params=\"session_id=%s\" % self.session_id,\n            method=\"POST\",\n            json={\"value\": rating}\n        )\n\n    async def async_delete_rating(self, movie_id):\n        \"\"\"\n        Remove your rating for a movie.（异步版本）\n        :param movie_id: int\n        \"\"\"\n        await self._async_request_obj(\n            self._urls[\"delete_rating\"] % movie_id,\n            params=\"session_id=%s\" % self.session_id,\n            method=\"DELETE\"\n        )\n\n    async def async_latest(self):\n        \"\"\"\n        Get the most newly created movie. This is a live response and will continuously change.（异步版本）\n        :return:\n        \"\"\"\n        return await self._async_request_obj(self._urls[\"latest\"])\n\n    async def async_now_playing(self, region=None, page=1):\n        \"\"\"\n        Get a list of movies in theatres.（异步版本）\n        :param region: str\n        :param page: int\n        :return:\n        \"\"\"\n        params = \"page=%s\" % page\n        if region:\n            params += \"&region=%s\" % region\n        return await self._async_request_obj(\n            self._urls[\"now_playing\"],\n            params=params,\n            key=\"results\"\n        )\n\n    async def async_popular(self, region=None, page=1):\n        \"\"\"\n        Get a list of the current popular movies on TMDb. This list updates daily.（异步版本）\n        :param region: str\n        :param page: int\n        :return:\n        \"\"\"\n        params = \"page=%s\" % page\n        if region:\n            params += \"&region=%s\" % region\n        return await self._async_request_obj(\n            self._urls[\"popular\"],\n            params=params,\n            key=\"results\"\n        )\n\n    async def async_top_rated(self, region=None, page=1):\n        \"\"\"\n        Get the top rated movies on TMDb.（异步版本）\n        :param region: str\n        :param page: int\n        :return:\n        \"\"\"\n        params = \"page=%s\" % page\n        if region:\n            params += \"&region=%s\" % region\n        return await self._async_request_obj(\n            self._urls[\"top_rated\"],\n            params=params,\n            key=\"results\"\n        )\n\n    async def async_upcoming(self, region=None, page=1):\n        \"\"\"\n        Get a list of upcoming movies in theatres.（异步版本）\n        :param region: str\n        :param page: int\n        :return:\n        \"\"\"\n        params = \"page=%s\" % page\n        if region:\n            params += \"&region=%s\" % region\n        return await self._async_request_obj(\n            self._urls[\"upcoming\"],\n            params=params,\n            key=\"results\"\n        )\n"
  },
  {
    "path": "app/modules/themoviedb/tmdbv3api/objs/network.py",
    "content": "from ..tmdb import TMDb\n\n\nclass Network(TMDb):\n    _urls = {\n        \"details\": \"/network/%s\",\n        \"alternative_names\": \"/network/%s/alternative_names\",\n        \"images\": \"/network/%s/images\"\n    }\n\n    def details(self, network_id):\n        \"\"\"\n        Get a networks details by id.\n        :param network_id: int\n        :return:\n        \"\"\"\n        return self._request_obj(self._urls[\"details\"] % network_id)\n\n    async def async_details(self, network_id):\n        \"\"\"\n        Get a networks details by id.（异步版本）\n        :param network_id: int\n        :return:\n        \"\"\"\n        return await self._async_request_obj(self._urls[\"details\"] % network_id)\n\n    def alternative_names(self, network_id):\n        \"\"\"\n        Get the alternative names of a network.\n        :param network_id: int\n        :return:\n        \"\"\"\n        return self._request_obj(\n            self._urls[\"alternative_names\"] % network_id,\n            key=\"results\"\n        )\n\n    async def async_alternative_names(self, network_id):\n        \"\"\"\n        Get the alternative names of a network.（异步版本）\n        :param network_id: int\n        :return:\n        \"\"\"\n        return await self._async_request_obj(\n            self._urls[\"alternative_names\"] % network_id,\n            key=\"results\"\n        )\n\n    def images(self, network_id):\n        \"\"\"\n        Get the TV network logos by id.\n        :param network_id: int\n        :return:\n        \"\"\"\n        return self._request_obj(\n            self._urls[\"images\"] % network_id,\n            key=\"logos\"\n        )\n\n    async def async_images(self, network_id):\n        \"\"\"\n        Get the TV network logos by id.（异步版本）\n        :param network_id: int\n        :return:\n        \"\"\"\n        return await self._async_request_obj(\n            self._urls[\"images\"] % network_id,\n            key=\"logos\"\n        )\n"
  },
  {
    "path": "app/modules/themoviedb/tmdbv3api/objs/person.py",
    "content": "from ..tmdb import TMDb\n\n\nclass Person(TMDb):\n    _urls = {\n        \"details\": \"/person/%s\",\n        \"changes\": \"/person/%s/changes\",\n        \"movie_credits\": \"/person/%s/movie_credits\",\n        \"tv_credits\": \"/person/%s/tv_credits\",\n        \"combined_credits\": \"/person/%s/combined_credits\",\n        \"external_ids\": \"/person/%s/external_ids\",\n        \"images\": \"/person/%s/images\",\n        \"tagged_images\": \"/person/%s/tagged_images\",\n        \"translations\": \"/person/%s/translations\",\n        \"latest\": \"/person/latest\",\n        \"popular\": \"/person/popular\",\n    }\n\n    def details(self, person_id, append_to_response=\"videos,images\"):\n        \"\"\"\n        Get the primary person details by id.\n        :param append_to_response: str\n        :param person_id: int\n        :return:\n        \"\"\"\n        return self._request_obj(\n            self._urls[\"details\"] % person_id,\n            params=\"append_to_response=%s\" % append_to_response\n        )\n\n    def changes(self, person_id, start_date=None, end_date=None, page=1):\n        \"\"\"\n        Get the changes for a person. By default only the last 24 hours are returned.\n        You can query up to 14 days in a single query by using the start_date and end_date query parameters.\n        :param person_id: int\n        :param start_date: str\n        :param end_date: str\n        :param page: int\n        :return:\n        \"\"\"\n        params = \"page=%s\" % page\n        if start_date:\n            params += \"&start_date=%s\" % start_date\n        if end_date:\n            params += \"&end_date=%s\" % end_date\n        return self._request_obj(\n            self._urls[\"changes\"] % person_id,\n            params=params,\n            key=\"changes\"\n        )\n\n    def movie_credits(self, person_id):\n        \"\"\"\n        Get the movie credits for a person.\n        :param person_id: int\n        :return:\n        \"\"\"\n        return self._request_obj(self._urls[\"movie_credits\"] % person_id)\n\n    def tv_credits(self, person_id):\n        \"\"\"\n        Get the TV show credits for a person.\n        :param person_id: int\n        :return:\n        \"\"\"\n        return self._request_obj(self._urls[\"tv_credits\"] % person_id)\n\n    def combined_credits(self, person_id):\n        \"\"\"\n        Get the movie and TV credits together in a single response.\n        :param person_id: int\n        :return:\n        \"\"\"\n        return self._request_obj(self._urls[\"combined_credits\"] % person_id)\n\n    def external_ids(self, person_id):\n        \"\"\"\n        Get the external ids for a person. We currently support the following external sources.\n        IMDB ID, Facebook, Freebase MID, Freebase ID, Instagram, TVRage ID, and Twitter\n        :param person_id: int\n        :return:\n        \"\"\"\n        return self._request_obj(self._urls[\"external_ids\"] % person_id)\n\n    def images(self, person_id):\n        \"\"\"\n        Get the images for a person.\n        :param person_id: int\n        :return:\n        \"\"\"\n        return self._request_obj(\n            self._urls[\"images\"] % person_id,\n            key=\"profiles\"\n        )\n\n    def tagged_images(self, person_id, page=1):\n        \"\"\"\n        Get the images that this person has been tagged in.\n        :param person_id: int\n        :param page: int\n        :return:\n        \"\"\"\n        return self._request_obj(\n            self._urls[\"tagged_images\"] % person_id,\n            params=\"page=%s\" % page,\n            key=\"results\"\n        )\n\n    def translations(self, person_id):\n        \"\"\"\n        Get a list of translations that have been created for a person.\n        :param person_id: int\n        :return:\n        \"\"\"\n        return self._request_obj(\n            self._urls[\"translations\"] % person_id,\n            key=\"translations\"\n        )\n\n    def latest(self):\n        \"\"\"\n        Get the most newly created person. This is a live response and will continuously change.\n        :return:\n        \"\"\"\n        return self._request_obj(self._urls[\"latest\"])\n\n    def popular(self, page=1):\n        \"\"\"\n        Get the list of popular people on TMDb. This list updates daily.\n        :param page: int\n        :return:\n        \"\"\"\n        return self._request_obj(\n            self._urls[\"popular\"],\n            params=\"page=%s\" % page,\n            key=\"results\"\n        )\n\n    # 异步版本方法\n    async def async_details(self, person_id, append_to_response=\"videos,images\"):\n        \"\"\"\n        Get the primary person details by id.（异步版本）\n        :param append_to_response: str\n        :param person_id: int\n        :return:\n        \"\"\"\n        return await self._async_request_obj(\n            self._urls[\"details\"] % person_id,\n            params=\"append_to_response=%s\" % append_to_response\n        )\n\n    async def async_changes(self, person_id, start_date=None, end_date=None, page=1):\n        \"\"\"\n        Get the changes for a person. By default only the last 24 hours are returned.\n        You can query up to 14 days in a single query by using the start_date and end_date query parameters.（异步版本）\n        :param person_id: int\n        :param start_date: str\n        :param end_date: str\n        :param page: int\n        :return:\n        \"\"\"\n        params = \"page=%s\" % page\n        if start_date:\n            params += \"&start_date=%s\" % start_date\n        if end_date:\n            params += \"&end_date=%s\" % end_date\n        return await self._async_request_obj(\n            self._urls[\"changes\"] % person_id,\n            params=params,\n            key=\"changes\"\n        )\n\n    async def async_movie_credits(self, person_id):\n        \"\"\"\n        Get the movie credits for a person.（异步版本）\n        :param person_id: int\n        :return:\n        \"\"\"\n        return await self._async_request_obj(self._urls[\"movie_credits\"] % person_id)\n\n    async def async_tv_credits(self, person_id):\n        \"\"\"\n        Get the TV show credits for a person.（异步版本）\n        :param person_id: int\n        :return:\n        \"\"\"\n        return await self._async_request_obj(self._urls[\"tv_credits\"] % person_id)\n\n    async def async_combined_credits(self, person_id):\n        \"\"\"\n        Get the movie and TV credits together in a single response.（异步版本）\n        :param person_id: int\n        :return:\n        \"\"\"\n        return await self._async_request_obj(self._urls[\"combined_credits\"] % person_id)\n\n    async def async_external_ids(self, person_id):\n        \"\"\"\n        Get the external ids for a person. We currently support the following external sources.\n        IMDB ID, Facebook, Freebase MID, Freebase ID, Instagram, TVRage ID, and Twitter（异步版本）\n        :param person_id: int\n        :return:\n        \"\"\"\n        return await self._async_request_obj(self._urls[\"external_ids\"] % person_id)\n\n    async def async_images(self, person_id):\n        \"\"\"\n        Get the images for a person.（异步版本）\n        :param person_id: int\n        :return:\n        \"\"\"\n        return await self._async_request_obj(\n            self._urls[\"images\"] % person_id,\n            key=\"profiles\"\n        )\n\n    async def async_tagged_images(self, person_id, page=1):\n        \"\"\"\n        Get the images that this person has been tagged in.（异步版本）\n        :param person_id: int\n        :param page: int\n        :return:\n        \"\"\"\n        return await self._async_request_obj(\n            self._urls[\"tagged_images\"] % person_id,\n            params=\"page=%s\" % page,\n            key=\"results\"\n        )\n\n    async def async_translations(self, person_id):\n        \"\"\"\n        Get a list of translations that have been created for a person.（异步版本）\n        :param person_id: int\n        :return:\n        \"\"\"\n        return await self._async_request_obj(\n            self._urls[\"translations\"] % person_id,\n            key=\"translations\"\n        )\n\n    async def async_latest(self):\n        \"\"\"\n        Get the most newly created person. This is a live response and will continuously change.（异步版本）\n        :return:\n        \"\"\"\n        return await self._async_request_obj(self._urls[\"latest\"])\n\n    async def async_popular(self, page=1):\n        \"\"\"\n        Get the list of popular people on TMDb. This list updates daily.（异步版本）\n        :param page: int\n        :return:\n        \"\"\"\n        return await self._async_request_obj(\n            self._urls[\"popular\"],\n            params=\"page=%s\" % page,\n            key=\"results\"\n        )\n"
  },
  {
    "path": "app/modules/themoviedb/tmdbv3api/objs/provider.py",
    "content": "from ..tmdb import TMDb\n\n\nclass Provider(TMDb):\n    _urls = {\n        \"regions\": \"/watch/providers/regions\",  # TODO:\n        \"movie\": \"/watch/providers/movie\",  # TODO:\n        \"tv\": \"/watch/providers/tv\",  # TODO:\n    }\n\n    def available_regions(self):\n        \"\"\"\n        Returns a list of all of the countries we have watch provider (OTT/streaming) data for.\n        :return:\n        \"\"\"\n        return self._request_obj(\n            self._urls[\"regions\"],\n            key=\"results\"\n        )\n\n    async def async_available_regions(self):\n        \"\"\"\n        Returns a list of all of the countries we have watch provider (OTT/streaming) data for.（异步版本）\n        :return:\n        \"\"\"\n        return await self._async_request_obj(\n            self._urls[\"regions\"],\n            key=\"results\"\n        )\n\n    def movie_providers(self, region=None):\n        \"\"\"\n        Returns a list of the watch provider (OTT/streaming) data we have available for movies.\n        :return:\n        \"\"\"\n        return self._request_obj(\n            self._urls[\"movie\"],\n            params=\"watch_region=%s\" % region if region else \"\",\n            key=\"results\"\n        )\n\n    async def async_movie_providers(self, region=None):\n        \"\"\"\n        Returns a list of the watch provider (OTT/streaming) data we have available for movies.（异步版本）\n        :return:\n        \"\"\"\n        return await self._async_request_obj(\n            self._urls[\"movie\"],\n            params=\"watch_region=%s\" % region if region else \"\",\n            key=\"results\"\n        )\n\n    def tv_providers(self, region=None):\n        \"\"\"\n        Returns a list of the watch provider (OTT/streaming) data we have available for TV series.\n        :return:\n        \"\"\"\n        return self._request_obj(\n            self._urls[\"tv\"],\n            params=\"watch_region=%s\" % region if region else \"\",\n            key=\"results\"\n        )\n\n    async def async_tv_providers(self, region=None):\n        \"\"\"\n        Returns a list of the watch provider (OTT/streaming) data we have available for TV series.（异步版本）\n        :return:\n        \"\"\"\n        return await self._async_request_obj(\n            self._urls[\"tv\"],\n            params=\"watch_region=%s\" % region if region else \"\",\n            key=\"results\"\n        )\n"
  },
  {
    "path": "app/modules/themoviedb/tmdbv3api/objs/review.py",
    "content": "from ..tmdb import TMDb\n\n\nclass Review(TMDb):\n    _urls = {\n        \"details\": \"/review/%s\",\n    }\n\n    def details(self, review_id):\n        \"\"\"\n        Get the primary person details by id.\n        :param review_id: int\n        :return:\n        \"\"\"\n        return self._request_obj(self._urls[\"details\"] % review_id)\n\n    async def async_details(self, review_id):\n        \"\"\"\n        Get the primary person details by id.（异步版本）\n        :param review_id: int\n        :return:\n        \"\"\"\n        return await self._async_request_obj(self._urls[\"details\"] % review_id)\n"
  },
  {
    "path": "app/modules/themoviedb/tmdbv3api/objs/search.py",
    "content": "from ..tmdb import TMDb\n\ntry:\n    from urllib import quote\nexcept ImportError:\n    from urllib.parse import quote\n\n\nclass Search(TMDb):\n    _urls = {\n        \"companies\": \"/search/company\",\n        \"collections\": \"/search/collection\",\n        \"keywords\": \"/search/keyword\",\n        \"movies\": \"/search/movie\",\n        \"multi\": \"/search/multi\",\n        \"people\": \"/search/person\",\n        \"tv_shows\": \"/search/tv\",\n    }\n\n    def companies(self, term, page=1):\n        \"\"\"\n        Search for companies.\n        :param term: str\n        :param page: int\n        :return:\n        \"\"\"\n        return self._request_obj(\n            self._urls[\"companies\"],\n            params=\"query=%s&page=%s\" % (quote(term), page),\n            key=\"results\"\n        )\n\n    def collections(self, term, page=1):\n        \"\"\"\n        Search for collections.\n        :param term: str\n        :param page: int\n        :return:\n        \"\"\"\n        return self._request_obj(\n            self._urls[\"collections\"],\n            params=\"query=%s&page=%s\" % (quote(term), page),\n            key=\"results\"\n        )\n\n    def keywords(self, term, page=1):\n        \"\"\"\n        Search for keywords.\n        :param term: str\n        :param page: int\n        :return:\n        \"\"\"\n        return self._request_obj(\n            self._urls[\"keywords\"],\n            params=\"query=%s&page=%s\" % (quote(term), page),\n            key=\"results\"\n        )\n\n    def movies(self, term, adult=None, region=None, year=None, release_year=None, page=1):\n        \"\"\"\n        Search for movies.\n        :param term: str\n        :param adult: bool\n        :param region: str\n        :param year: int\n        :param release_year: int\n        :param page: int\n        :return:\n        \"\"\"\n        params = \"query=%s&page=%s\" % (quote(term), page)\n        if adult is not None:\n            params += \"&include_adult=%s\" % \"true\" if adult else \"false\"\n        if region is not None:\n            params += \"&region=%s\" % quote(region)\n        if year is not None:\n            params += \"&year=%s\" % year\n        if release_year is not None:\n            params += \"&primary_release_year=%s\" % release_year\n        return self._request_obj(\n            self._urls[\"movies\"],\n            params=params,\n            key=\"results\"\n        )\n\n    def multi(self, term, adult=None, region=None, page=1):\n        \"\"\"\n        Search multiple models in a single request.\n        Multi search currently supports searching for movies, tv shows and people in a single request.\n        :param term: str\n        :param adult: bool\n        :param region: str\n        :param page: int\n        :return:\n        \"\"\"\n        params = \"query=%s&page=%s\" % (quote(term), page)\n        if adult is not None:\n            params += \"&include_adult=%s\" % \"true\" if adult else \"false\"\n        if region is not None:\n            params += \"&region=%s\" % quote(region)\n        return self._request_obj(\n            self._urls[\"multi\"],\n            params=params,\n            key=\"results\"\n        )\n\n    def people(self, term, adult=None, region=None, page=1):\n        \"\"\"\n        Search for people.\n        :param term: str\n        :param adult: bool\n        :param region: str\n        :param page: int\n        :return:\n        \"\"\"\n        params = \"query=%s&page=%s\" % (quote(term), page)\n        if adult is not None:\n            params += \"&include_adult=%s\" % \"true\" if adult else \"false\"\n        if region is not None:\n            params += \"&region=%s\" % quote(region)\n        return self._request_obj(\n            self._urls[\"people\"],\n            params=params,\n            key=\"results\"\n        )\n\n    def tv_shows(self, term, adult=None, release_year=None, page=1):\n        \"\"\"\n        Search for a TV show.\n        :param term: str\n        :param adult: bool\n        :param release_year: int\n        :param page: int\n        :return:\n        \"\"\"\n        params = \"query=%s&page=%s\" % (quote(term), page)\n        if adult is not None:\n            params += \"&include_adult=%s\" % \"true\" if adult else \"false\"\n        if release_year is not None:\n            params += \"&first_air_date_year=%s\" % release_year\n        return self._request_obj(\n            self._urls[\"tv_shows\"],\n            params=params,\n            key=\"results\"\n        )\n\n    # 异步版本方法\n    async def async_companies(self, term, page=1):\n        \"\"\"\n        Search for companies.（异步版本）\n        :param term: str\n        :param page: int\n        :return:\n        \"\"\"\n        return await self._async_request_obj(\n            self._urls[\"companies\"],\n            params=\"query=%s&page=%s\" % (quote(term), page),\n            key=\"results\"\n        )\n\n    async def async_collections(self, term, page=1):\n        \"\"\"\n        Search for collections.（异步版本）\n        :param term: str\n        :param page: int\n        :return:\n        \"\"\"\n        return await self._async_request_obj(\n            self._urls[\"collections\"],\n            params=\"query=%s&page=%s\" % (quote(term), page),\n            key=\"results\"\n        )\n\n    async def async_keywords(self, term, page=1):\n        \"\"\"\n        Search for keywords.（异步版本）\n        :param term: str\n        :param page: int\n        :return:\n        \"\"\"\n        return await self._async_request_obj(\n            self._urls[\"keywords\"],\n            params=\"query=%s&page=%s\" % (quote(term), page),\n            key=\"results\"\n        )\n\n    async def async_people(self, term, adult=None, region=None, page=1):\n        \"\"\"\n        Search for people.（异步版本）\n        :param term: str\n        :param adult: bool\n        :param region: str\n        :param page: int\n        :return:\n        \"\"\"\n        params = \"query=%s&page=%s\" % (quote(term), page)\n        if adult is not None:\n            params += \"&include_adult=%s\" % \"true\" if adult else \"false\"\n        if region is not None:\n            params += \"&region=%s\" % quote(region)\n        return await self._async_request_obj(\n            self._urls[\"people\"],\n            params=params,\n            key=\"results\"\n        )\n\n    async def async_multi(self, term, adult=None, region=None, page=1):\n        \"\"\"\n        Search multiple models in a single request.（异步版本）\n        Multi search currently supports searching for movies, tv shows and people in a single request.\n        :param term: str\n        :param adult: bool\n        :param region: str\n        :param page: int\n        :return:\n        \"\"\"\n        params = \"query=%s&page=%s\" % (quote(term), page)\n        if adult is not None:\n            params += \"&include_adult=%s\" % \"true\" if adult else \"false\"\n        if region is not None:\n            params += \"&region=%s\" % quote(region)\n        return await self._async_request_obj(\n            self._urls[\"multi\"],\n            params=params,\n            key=\"results\"\n        )\n\n    async def async_movies(self, term, adult=None, region=None, year=None, release_year=None, page=1):\n        \"\"\"\n        Search for movies.（异步版本）\n        :param term: str\n        :param adult: bool\n        :param region: str\n        :param year: int\n        :param release_year: int\n        :param page: int\n        :return:\n        \"\"\"\n        params = \"query=%s&page=%s\" % (quote(term), page)\n        if adult is not None:\n            params += \"&include_adult=%s\" % \"true\" if adult else \"false\"\n        if region is not None:\n            params += \"&region=%s\" % quote(region)\n        if year is not None:\n            params += \"&year=%s\" % year\n        if release_year is not None:\n            params += \"&primary_release_year=%s\" % release_year\n\n        return await self._async_request_obj(\n            self._urls[\"movies\"],\n            params=params,\n            key=\"results\"\n        )\n\n    async def async_tv_shows(self, term, adult=None, release_year=None, page=1):\n        \"\"\"\n        Search for a TV show.（异步版本）\n        :param term: str\n        :param adult: bool\n        :param release_year: int\n        :param page: int\n        :return:\n        \"\"\"\n        params = \"query=%s&page=%s\" % (quote(term), page)\n        if adult is not None:\n            params += \"&include_adult=%s\" % \"true\" if adult else \"false\"\n        if release_year is not None:\n            params += \"&first_air_date_year=%s\" % release_year\n        return await self._async_request_obj(\n            self._urls[\"tv_shows\"],\n            params=params,\n            key=\"results\"\n        )\n"
  },
  {
    "path": "app/modules/themoviedb/tmdbv3api/objs/season.py",
    "content": "from ..tmdb import TMDb\n\n\nclass Season(TMDb):\n    _urls = {\n        \"details\": \"/tv/%s/season/%s\",\n        \"account_states\": \"/tv/%s/season/%s/account_states\",\n        \"aggregate_credits\": \"/tv/%s/season/%s/aggregate_credits\",\n        \"changes\": \"/tv/season/%s/changes\",\n        \"credits\": \"/tv/%s/season/%s/credits\",\n        \"external_ids\": \"/tv/%s/season/%s/external_ids\",\n        \"images\": \"/tv/%s/season/%s/images\",\n        \"translations\": \"/tv/%s/season/%s/translations\",\n        \"videos\": \"/tv/%s/season/%s/videos\",\n    }\n\n    def details(self, tv_id, season_num, append_to_response=\"videos,trailers,images,credits,translations\"):\n        \"\"\"\n        Get the TV season details by id.\n        :param tv_id: int\n        :param season_num: int\n        :param append_to_response: str\n        :return:\n        \"\"\"\n        return self._request_obj(\n            self._urls[\"details\"] % (tv_id, season_num),\n            params=\"append_to_response=%s\" % append_to_response\n        )\n\n    def account_states(self, tv_id, season_num):\n        \"\"\"\n        Get all of the user ratings for the season's episodes.\n        :param tv_id: int\n        :param season_num: int\n        :return:\n        \"\"\"\n        return self._request_obj(\n            self._urls[\"account_states\"] % (tv_id, season_num),\n            params=\"session_id=%s\" % self.session_id,\n            key=\"results\"\n        )\n\n    def aggregate_credits(self, tv_id, season_num):\n        \"\"\"\n        Get the aggregate credits for TV season.\n        This call differs from the main credits call in that it does not only return the season credits,\n        but rather is a view of all the cast & crew for all of the episodes belonging to a season.\n        :param tv_id: int\n        :param season_num: int\n        :return:\n        \"\"\"\n        return self._request_obj(self._urls[\"aggregate_credits\"] % (tv_id, season_num))\n\n    def changes(self, season_id, start_date=None, end_date=None, page=1):\n        \"\"\"\n        Get the changes for a TV season. By default only the last 24 hours are returned.\n        You can query up to 14 days in a single query by using the start_date and end_date query parameters.\n        :param season_id: int\n        :param start_date: str\n        :param end_date: str\n        :param page: int\n        :return:\n        \"\"\"\n        params = \"page=%s\" % page\n        if start_date:\n            params += \"&start_date=%s\" % start_date\n        if end_date:\n            params += \"&end_date=%s\" % end_date\n        return self._request_obj(\n            self._urls[\"changes\"] % season_id,\n            params=params,\n            key=\"changes\"\n        )\n\n    def credits(self, tv_id, season_num):\n        \"\"\"\n        Get the credits for TV season.\n        :param tv_id: int\n        :param season_num: int\n        :return:\n        \"\"\"\n        return self._request_obj(self._urls[\"credits\"] % (tv_id, season_num))\n\n    def external_ids(self, tv_id, season_num):\n        \"\"\"\n        Get the external ids for a TV season.\n        :param tv_id: int\n        :param season_num: int\n        :return:\n        \"\"\"\n        return self._request_obj(self._urls[\"external_ids\"] % (tv_id, season_num))\n\n    def images(self, tv_id, season_num, include_image_language=None):\n        \"\"\"\n        Get the images that belong to a TV season.\n        :param tv_id: int\n        :param season_num: int\n        :param include_image_language: str\n        :return:\n        \"\"\"\n        return self._request_obj(\n            self._urls[\"images\"] % (tv_id, season_num),\n            params=\"include_image_language=%s\" % include_image_language if include_image_language else \"\",\n            key=\"posters\"\n        )\n\n    def translations(self, tv_id, season_num):\n        \"\"\"\n        Get a list of the translations that exist for a TV show.\n        :param tv_id: int\n        :param season_num: int\n        \"\"\"\n        return self._request_obj(\n            self._urls[\"translations\"] % (tv_id, season_num),\n            key=\"translations\"\n        )\n\n    def videos(self, tv_id, season_num, include_video_language=None, page=1):\n        \"\"\"\n        Get the videos that have been added to a TV show.\n        :param tv_id: int\n        :param season_num: int\n        :param include_video_language: str\n        :param page: int\n        :return:\n        \"\"\"\n        params = \"page=%s\" % page\n        if include_video_language:\n            params += \"&include_video_language=%s\" % include_video_language\n        return self._request_obj(\n            self._urls[\"videos\"] % (tv_id, season_num),\n            params=params\n        )\n\n    # 异步版本方法\n    async def async_details(self, tv_id, season_num, append_to_response=\"videos,trailers,images,credits,translations\"):\n        \"\"\"\n        Get the TV season details by id.（异步版本）\n        :param tv_id: int\n        :param season_num: int\n        :param append_to_response: str\n        :return:\n        \"\"\"\n        return await self._async_request_obj(\n            self._urls[\"details\"] % (tv_id, season_num),\n            params=\"append_to_response=%s\" % append_to_response\n        )\n\n    async def async_account_states(self, tv_id, season_num):\n        \"\"\"\n        Get all of the user ratings for the season's episodes.（异步版本）\n        :param tv_id: int\n        :param season_num: int\n        :return:\n        \"\"\"\n        return await self._async_request_obj(\n            self._urls[\"account_states\"] % (tv_id, season_num),\n            params=\"session_id=%s\" % self.session_id,\n            key=\"results\"\n        )\n\n    async def async_aggregate_credits(self, tv_id, season_num):\n        \"\"\"\n        Get the aggregate credits for TV season.\n        This call differs from the main credits call in that it does not only return the season credits,\n        but rather is a view of all the cast & crew for all of the episodes belonging to a season.（异步版本）\n        :param tv_id: int\n        :param season_num: int\n        :return:\n        \"\"\"\n        return await self._async_request_obj(self._urls[\"aggregate_credits\"] % (tv_id, season_num))\n\n    async def async_changes(self, season_id, start_date=None, end_date=None, page=1):\n        \"\"\"\n        Get the changes for a TV season. By default only the last 24 hours are returned.\n        You can query up to 14 days in a single query by using the start_date and end_date query parameters.（异步版本）\n        :param season_id: int\n        :param start_date: str\n        :param end_date: str\n        :param page: int\n        :return:\n        \"\"\"\n        params = \"page=%s\" % page\n        if start_date:\n            params += \"&start_date=%s\" % start_date\n        if end_date:\n            params += \"&end_date=%s\" % end_date\n        return await self._async_request_obj(\n            self._urls[\"changes\"] % season_id,\n            params=params,\n            key=\"changes\"\n        )\n\n    async def async_credits(self, tv_id, season_num):\n        \"\"\"\n        Get the credits for TV season.（异步版本）\n        :param tv_id: int\n        :param season_num: int\n        :return:\n        \"\"\"\n        return await self._async_request_obj(self._urls[\"credits\"] % (tv_id, season_num))\n\n    async def async_external_ids(self, tv_id, season_num):\n        \"\"\"\n        Get the external ids for a TV season.（异步版本）\n        :param tv_id: int\n        :param season_num: int\n        :return:\n        \"\"\"\n        return await self._async_request_obj(self._urls[\"external_ids\"] % (tv_id, season_num))\n\n    async def async_images(self, tv_id, season_num, include_image_language=None):\n        \"\"\"\n        Get the images that belong to a TV season.（异步版本）\n        :param tv_id: int\n        :param season_num: int\n        :param include_image_language: str\n        :return:\n        \"\"\"\n        return await self._async_request_obj(\n            self._urls[\"images\"] % (tv_id, season_num),\n            params=\"include_image_language=%s\" % include_image_language if include_image_language else \"\",\n            key=\"posters\"\n        )\n\n    async def async_translations(self, tv_id, season_num):\n        \"\"\"\n        Get a list of the translations that exist for a TV show.（异步版本）\n        :param tv_id: int\n        :param season_num: int\n        \"\"\"\n        return await self._async_request_obj(\n            self._urls[\"translations\"] % (tv_id, season_num),\n            key=\"translations\"\n        )\n\n    async def async_videos(self, tv_id, season_num, include_video_language=None, page=1):\n        \"\"\"\n        Get the videos that have been added to a TV show.（异步版本）\n        :param tv_id: int\n        :param season_num: int\n        :param include_video_language: str\n        :param page: int\n        :return:\n        \"\"\"\n        params = \"page=%s\" % page\n        if include_video_language:\n            params += \"&include_video_language=%s\" % include_video_language\n        return await self._async_request_obj(\n            self._urls[\"videos\"] % (tv_id, season_num),\n            params=params\n        )\n"
  },
  {
    "path": "app/modules/themoviedb/tmdbv3api/objs/trending.py",
    "content": "from ..tmdb import TMDb\n\n\nclass Trending(TMDb):\n    _urls = {\"trending\": \"/trending/%s/%s\"}\n\n    def _trending(self, media_type=\"all\", time_window=\"day\", page=1):\n        \"\"\"\n        Get trending, TTLCache 12 hours\n        \"\"\"\n        return self._request_obj(\n            self._urls[\"trending\"] % (media_type, time_window),\n            params=\"page=%s\" % page,\n            key=\"results\",\n            call_cached=False\n        )\n\n    def all_day(self, page=1):\n        \"\"\"\n        Get all daily trending\n        :param page: int\n        :return:\n        \"\"\"\n        return self._trending(media_type=\"all\", time_window=\"day\", page=page)\n\n    def all_week(self, page=1):\n        \"\"\"\n        Get all weekly trending\n        :param page: int\n        :return:\n        \"\"\"\n        return self._trending(media_type=\"all\", time_window=\"week\", page=page)\n\n    def movie_day(self, page=1):\n        \"\"\"\n        Get movie daily trending\n        :param page: int\n        :return:\n        \"\"\"\n        return self._trending(media_type=\"movie\", time_window=\"day\", page=page)\n\n    def movie_week(self, page=1):\n        \"\"\"\n        Get movie weekly trending\n        :param page: int\n        :return:\n        \"\"\"\n        return self._trending(media_type=\"movie\", time_window=\"week\", page=page)\n\n    def tv_day(self, page=1):\n        \"\"\"\n        Get tv daily trending\n        :param page: int\n        :return:\n        \"\"\"\n        return self._trending(media_type=\"tv\", time_window=\"day\", page=page)\n\n    def tv_week(self, page=1):\n        \"\"\"\n        Get tv weekly trending\n        :param page: int\n        :return:\n        \"\"\"\n        return self._trending(media_type=\"tv\", time_window=\"week\", page=page)\n\n    def person_day(self, page=1):\n        \"\"\"\n        Get person daily trending\n        :param page: int\n        :return:\n        \"\"\"\n        return self._trending(media_type=\"person\", time_window=\"day\", page=page)\n\n    def person_week(self, page=1):\n        \"\"\"\n        Get person weekly trending\n        :param page: int\n        :return:\n        \"\"\"\n        return self._trending(media_type=\"person\", time_window=\"week\", page=page)\n\n    # 异步版本方法\n    async def _async_trending(self, media_type=\"all\", time_window=\"day\", page=1):\n        \"\"\"\n        Get trending, TTLCache 12 hours（异步版本）\n        \"\"\"\n        return await self._async_request_obj(\n            self._urls[\"trending\"] % (media_type, time_window),\n            params=\"page=%s\" % page,\n            key=\"results\",\n            call_cached=False\n        )\n\n    async def async_all_day(self, page=1):\n        \"\"\"\n        Get all daily trending（异步版本）\n        :param page: int\n        :return:\n        \"\"\"\n        return await self._async_trending(media_type=\"all\", time_window=\"day\", page=page)\n\n    async def async_all_week(self, page=1):\n        \"\"\"\n        Get all weekly trending（异步版本）\n        :param page: int\n        :return:\n        \"\"\"\n        return await self._async_trending(media_type=\"all\", time_window=\"week\", page=page)\n\n    async def async_movie_day(self, page=1):\n        \"\"\"\n        Get movie daily trending（异步版本）\n        :param page: int\n        :return:\n        \"\"\"\n        return await self._async_trending(media_type=\"movie\", time_window=\"day\", page=page)\n\n    async def async_movie_week(self, page=1):\n        \"\"\"\n        Get movie weekly trending（异步版本）\n        :param page: int\n        :return:\n        \"\"\"\n        return await self._async_trending(media_type=\"movie\", time_window=\"week\", page=page)\n\n    async def async_tv_day(self, page=1):\n        \"\"\"\n        Get tv daily trending（异步版本）\n        :param page: int\n        :return:\n        \"\"\"\n        return await self._async_trending(media_type=\"tv\", time_window=\"day\", page=page)\n\n    async def async_tv_week(self, page=1):\n        \"\"\"\n        Get tv weekly trending（异步版本）\n        :param page: int\n        :return:\n        \"\"\"\n        return await self._async_trending(media_type=\"tv\", time_window=\"week\", page=page)\n\n    async def async_person_day(self, page=1):\n        \"\"\"\n        Get person daily trending（异步版本）\n        :param page: int\n        :return:\n        \"\"\"\n        return await self._async_trending(media_type=\"person\", time_window=\"day\", page=page)\n\n    async def async_person_week(self, page=1):\n        \"\"\"\n        Get person weekly trending（异步版本）\n        :param page: int\n        :return:\n        \"\"\"\n        return await self._async_trending(media_type=\"person\", time_window=\"week\", page=page)\n"
  },
  {
    "path": "app/modules/themoviedb/tmdbv3api/objs/tv.py",
    "content": "from ..tmdb import TMDb\n\ntry:\n    from urllib import quote\nexcept ImportError:\n    from urllib.parse import quote\n\n\nclass TV(TMDb):\n    _urls = {\n        \"details\": \"/tv/%s\",\n        \"account_states\": \"/tv/%s/account_states\",\n        \"aggregate_credits\": \"/tv/%s/aggregate_credits\",\n        \"alternative_titles\": \"/tv/%s/alternative_titles\",\n        \"changes\": \"/tv/%s/changes\",\n        \"content_ratings\": \"/tv/%s/content_ratings\",\n        \"credits\": \"/tv/%s/credits\",\n        \"episode_groups\": \"/tv/%s/episode_groups\",\n        \"external_ids\": \"/tv/%s/external_ids\",\n        \"images\": \"/tv/%s/images\",\n        \"keywords\": \"/tv/%s/keywords\",\n        \"recommendations\": \"/tv/%s/recommendations\",\n        \"reviews\": \"/tv/%s/reviews\",\n        \"screened_theatrically\": \"/tv/%s/screened_theatrically\",\n        \"similar\": \"/tv/%s/similar\",\n        \"translations\": \"/tv/%s/translations\",\n        \"videos\": \"/tv/%s/videos\",\n        \"watch_providers\": \"/tv/%s/watch/providers\",\n        \"rate_tv_show\": \"/tv/%s/rating\",\n        \"delete_rating\": \"/tv/%s/rating\",\n        \"latest\": \"/tv/latest\",\n        \"airing_today\": \"/tv/airing_today\",\n        \"on_the_air\": \"/tv/on_the_air\",\n        \"popular\": \"/tv/popular\",\n        \"top_rated\": \"/tv/top_rated\",\n        \"group_episodes\": \"/tv/episode_group/%s\",\n    }\n\n    def details(self, tv_id, append_to_response=\"videos,trailers,images,credits,translations\"):\n        \"\"\"\n        Get the primary TV show details by id.\n        :param tv_id: int\n        :param append_to_response: str\n        :return:\n        \"\"\"\n        return self._request_obj(\n            self._urls[\"details\"] % tv_id,\n            params=\"append_to_response=%s\" % append_to_response,\n        )\n\n    def account_states(self, tv_id):\n        \"\"\"\n        Grab the following account states for a session:\n        TV show rating, If it belongs to your watchlist, or If it belongs to your favourite list.\n        :param tv_id: int\n        :return:\n        \"\"\"\n        return self._request_obj(\n            self._urls[\"account_states\"] % tv_id,\n            params=\"session_id=%s\" % self.session_id\n        )\n\n    def aggregate_credits(self, tv_id):\n        \"\"\"\n        Get the aggregate credits (cast and crew) that have been added to a TV show.\n        This call differs from the main credits call in that it does not return the newest season but rather,\n        is a view of all the entire cast & crew for all episodes belonging to a TV show.\n        :param tv_id: int\n        :return:\n        \"\"\"\n        return self._request_obj(self._urls[\"aggregate_credits\"] % tv_id)\n\n    def alternative_titles(self, tv_id):\n        \"\"\"\n        Returns all of the alternative titles for a TV show.\n        :param tv_id: int\n        :return:\n        \"\"\"\n        return self._request_obj(\n            self._urls[\"alternative_titles\"] % tv_id,\n            key=\"results\"\n        )\n\n    def changes(self, tv_id, start_date=None, end_date=None, page=1):\n        \"\"\"\n        Get the changes for a TV show. By default only the last 24 hours are returned.\n        You can query up to 14 days in a single query by using the start_date and end_date query parameters.\n        :param tv_id: int\n        :param start_date: str\n        :param end_date: str\n        :param page: int\n        \"\"\"\n        params = \"page=%s\" % page\n        if start_date:\n            params += \"&start_date=%s\" % start_date\n        if end_date:\n            params += \"&end_date=%s\" % end_date\n        return self._request_obj(\n            self._urls[\"changes\"] % tv_id,\n            params=params,\n            key=\"changes\"\n        )\n\n    def content_ratings(self, tv_id):\n        \"\"\"\n        Get the list of content ratings (certifications) that have been added to a TV show.\n        :param tv_id: int\n        :return:\n        \"\"\"\n        return self._request_obj(\n            self._urls[\"content_ratings\"] % tv_id,\n            key=\"results\"\n        )\n\n    def credits(self, tv_id):\n        \"\"\"\n        Get the credits (cast and crew) that have been added to a TV show.\n        :param tv_id: int\n        :return:\n        \"\"\"\n        return self._request_obj(self._urls[\"credits\"] % tv_id)\n\n    def episode_groups(self, tv_id):\n        \"\"\"\n        Get all of the episode groups that have been created for a TV show.\n        :param tv_id: int\n        :return:\n        \"\"\"\n        return self._request_obj(\n            self._urls[\"episode_groups\"] % tv_id,\n            key=\"results\"\n        )\n\n    def group_episodes(self, group_id):\n        \"\"\"\n        查询剧集组所有剧集\n        :param group_id: int\n        :return:\n        \"\"\"\n        return self._request_obj(\n            self._urls[\"group_episodes\"] % group_id,\n            key=\"groups\"\n        )\n\n    def external_ids(self, tv_id):\n        \"\"\"\n        Get the external ids for a TV show.\n        :param tv_id: int\n        :return:\n        \"\"\"\n        return self._request_obj(self._urls[\"external_ids\"] % tv_id)\n\n    def images(self, tv_id, include_image_language=None):\n        \"\"\"\n        Get the images that belong to a TV show.\n        Querying images with a language parameter will filter the results.\n        If you want to include a fallback language (especially useful for backdrops)\n        you can use the include_image_language parameter.\n        This should be a comma separated value like so: include_image_language=en,null.\n        :param tv_id: int\n        :param include_image_language: str\n        :return:\n        \"\"\"\n        return self._request_obj(\n            self._urls[\"images\"] % tv_id,\n            params=\"include_image_language=%s\" % include_image_language if include_image_language else \"\"\n        )\n\n    def keywords(self, tv_id):\n        \"\"\"\n        Get the keywords that have been added to a TV show.\n        :param tv_id: int\n        :return:\n        \"\"\"\n        return self._request_obj(\n            self._urls[\"keywords\"] % tv_id,\n            key=\"results\"\n        )\n\n    def recommendations(self, tv_id, page=1):\n        \"\"\"\n        Get the list of TV show recommendations for this item.\n        :param tv_id: int\n        :param page: int\n        :return:\n        \"\"\"\n        return self._request_obj(\n            self._urls[\"recommendations\"] % tv_id,\n            params=\"page=%s\" % page,\n            key=\"results\"\n        )\n\n    def reviews(self, tv_id, page=1):\n        \"\"\"\n        Get the reviews for a TV show.\n        :param tv_id: int\n        :param page: int\n        :return:\n        \"\"\"\n        return self._request_obj(\n            self._urls[\"reviews\"] % tv_id,\n            params=\"page=%s\" % page,\n            key=\"results\"\n        )\n\n    def screened_theatrically(self, tv_id):\n        \"\"\"\n        Get a list of seasons or episodes that have been screened in a film festival or theatre.\n        :param tv_id: int\n        :return:\n        \"\"\"\n        return self._request_obj(\n            self._urls[\"screened_theatrically\"] % tv_id,\n            key=\"results\"\n        )\n\n    def similar(self, tv_id, page=1):\n        \"\"\"\n        Get the primary TV show details by id.\n        :param tv_id: int\n        :param page: int\n        :return:\n        \"\"\"\n        return self._request_obj(\n            self._urls[\"similar\"] % tv_id,\n            params=\"page=%s\" % page,\n            key=\"results\"\n        )\n\n    def translations(self, tv_id):\n        \"\"\"\n        Get a list of the translations that exist for a TV show.\n        :param tv_id: int\n        :return:\n        \"\"\"\n        return self._request_obj(\n            self._urls[\"translations\"] % tv_id,\n            key=\"translations\"\n        )\n\n    def videos(self, tv_id, include_video_language=None, page=1):\n        \"\"\"\n        Get the videos that have been added to a TV show.\n        :param tv_id: int\n        :param include_video_language: str\n        :param page: int\n        :return:\n        \"\"\"\n        params = \"page=%s\" % page\n        if include_video_language:\n            params += \"&include_video_language=%s\" % include_video_language\n        return self._request_obj(\n            self._urls[\"videos\"] % tv_id,\n            params=params\n        )\n\n    def watch_providers(self, tv_id):\n        \"\"\"\n        You can query this method to get a list of the availabilities per country by provider.\n        :param tv_id: int\n        :return:\n        \"\"\"\n        return self._request_obj(\n            self._urls[\"watch_providers\"] % tv_id,\n            key=\"results\"\n        )\n\n    def rate_tv_show(self, tv_id, rating):\n        \"\"\"\n        Rate a TV show.\n        :param tv_id: int\n        :param rating: float\n        \"\"\"\n        self._request_obj(\n            self._urls[\"rate_tv_show\"] % tv_id,\n            params=\"session_id=%s\" % self.session_id,\n            method=\"POST\",\n            json={\"value\": rating}\n        )\n\n    def delete_rating(self, tv_id):\n        \"\"\"\n        Remove your rating for a TV show.\n        :param tv_id: int\n        \"\"\"\n        self._request_obj(\n            self._urls[\"delete_rating\"] % tv_id,\n            params=\"session_id=%s\" % self.session_id,\n            method=\"DELETE\"\n        )\n\n    def latest(self):\n        \"\"\"\n        Get the most newly created TV show. This is a live response and will continuously change.\n        :return:\n        \"\"\"\n        return self._request_obj(self._urls[\"latest\"])\n\n    def airing_today(self, page=1):\n        \"\"\"\n        Get a list of TV shows that are airing today.\n        This query is purely day based as we do not currently support airing times.\n        :param page: int\n        :return:\n        \"\"\"\n        return self._request_obj(\n            self._urls[\"airing_today\"],\n            params=\"page=%s\" % page,\n            key=\"results\"\n        )\n\n    def on_the_air(self, page=1):\n        \"\"\"\n        Get a list of shows that are currently on the air.\n        :param page:\n        :return:\n        \"\"\"\n        return self._request_obj(\n            self._urls[\"on_the_air\"],\n            params=\"page=%s\" % page,\n            key=\"results\"\n        )\n\n    def popular(self, page=1):\n        \"\"\"\n        Get a list of the current popular TV shows on TMDb. This list updates daily.\n        :param page:\n        :return:\n        \"\"\"\n        return self._request_obj(\n            self._urls[\"popular\"],\n            params=\"page=%s\" % page,\n            key=\"results\"\n        )\n\n    def top_rated(self, page=1):\n        \"\"\"\n        Get a list of the top rated TV shows on TMDb.\n        :param page:\n        :return:\n        \"\"\"\n        return self._request_obj(\n            self._urls[\"top_rated\"],\n            params=\"page=%s\" % page,\n            key=\"results\"\n        )\n\n    # 异步版本方法\n    async def async_details(self, tv_id, append_to_response=\"videos,trailers,images,credits,translations\"):\n        \"\"\"\n        Get the primary TV show details by id.（异步版本）\n        :param tv_id: int\n        :param append_to_response: str\n        :return:\n        \"\"\"\n        return await self._async_request_obj(\n            self._urls[\"details\"] % tv_id,\n            params=\"append_to_response=%s\" % append_to_response,\n        )\n\n    async def async_account_states(self, tv_id):\n        \"\"\"\n        Grab the following account states for a session:\n        TV show rating, If it belongs to your watchlist, or If it belongs to your favourite list.（异步版本）\n        :param tv_id: int\n        :return:\n        \"\"\"\n        return await self._async_request_obj(\n            self._urls[\"account_states\"] % tv_id,\n            params=\"session_id=%s\" % self.session_id\n        )\n\n    async def async_aggregate_credits(self, tv_id):\n        \"\"\"\n        Get the aggregate credits (cast and crew) that have been added to a TV show.\n        This call differs from the main credits call in that it does not return the newest season but rather,\n        is a view of all the entire cast & crew for all episodes belonging to a TV show.（异步版本）\n        :param tv_id: int\n        :return:\n        \"\"\"\n        return await self._async_request_obj(self._urls[\"aggregate_credits\"] % tv_id)\n\n    async def async_alternative_titles(self, tv_id):\n        \"\"\"\n        Returns all of the alternative titles for a TV show.（异步版本）\n        :param tv_id: int\n        :return:\n        \"\"\"\n        return await self._async_request_obj(\n            self._urls[\"alternative_titles\"] % tv_id,\n            key=\"results\"\n        )\n\n    async def async_changes(self, tv_id, start_date=None, end_date=None, page=1):\n        \"\"\"\n        Get the changes for a TV show. By default only the last 24 hours are returned.\n        You can query up to 14 days in a single query by using the start_date and end_date query parameters.（异步版本）\n        :param tv_id: int\n        :param start_date: str\n        :param end_date: str\n        :param page: int\n        \"\"\"\n        params = \"page=%s\" % page\n        if start_date:\n            params += \"&start_date=%s\" % start_date\n        if end_date:\n            params += \"&end_date=%s\" % end_date\n        return await self._async_request_obj(\n            self._urls[\"changes\"] % tv_id,\n            params=params,\n            key=\"changes\"\n        )\n\n    async def async_content_ratings(self, tv_id):\n        \"\"\"\n        Get the list of content ratings (certifications) that have been added to a TV show.（异步版本）\n        :param tv_id: int\n        :return:\n        \"\"\"\n        return await self._async_request_obj(\n            self._urls[\"content_ratings\"] % tv_id,\n            key=\"results\"\n        )\n\n    async def async_credits(self, tv_id):\n        \"\"\"\n        Get the credits (cast and crew) that have been added to a TV show.（异步版本）\n        :param tv_id: int\n        :return:\n        \"\"\"\n        return await self._async_request_obj(self._urls[\"credits\"] % tv_id)\n\n    async def async_episode_groups(self, tv_id):\n        \"\"\"\n        Get all of the episode groups that have been created for a TV show.（异步版本）\n        :param tv_id: int\n        :return:\n        \"\"\"\n        return await self._async_request_obj(\n            self._urls[\"episode_groups\"] % tv_id,\n            key=\"results\"\n        )\n\n    async def async_group_episodes(self, group_id):\n        \"\"\"\n        查询剧集组所有剧集（异步版本）\n        :param group_id: int\n        :return:\n        \"\"\"\n        return await self._async_request_obj(\n            self._urls[\"group_episodes\"] % group_id,\n            key=\"groups\"\n        )\n\n    async def async_external_ids(self, tv_id):\n        \"\"\"\n        Get the external ids for a TV show.（异步版本）\n        :param tv_id: int\n        :return:\n        \"\"\"\n        return await self._async_request_obj(self._urls[\"external_ids\"] % tv_id)\n\n    async def async_images(self, tv_id, include_image_language=None):\n        \"\"\"\n        Get the images that belong to a TV show.\n        Querying images with a language parameter will filter the results.\n        If you want to include a fallback language (especially useful for backdrops)\n        you can use the include_image_language parameter.\n        This should be a comma separated value like so: include_image_language=en,null.（异步版本）\n        :param tv_id: int\n        :param include_image_language: str\n        :return:\n        \"\"\"\n        return await self._async_request_obj(\n            self._urls[\"images\"] % tv_id,\n            params=\"include_image_language=%s\" % include_image_language if include_image_language else \"\"\n        )\n\n    async def async_keywords(self, tv_id):\n        \"\"\"\n        Get the keywords that have been added to a TV show.（异步版本）\n        :param tv_id: int\n        :return:\n        \"\"\"\n        return await self._async_request_obj(\n            self._urls[\"keywords\"] % tv_id,\n            key=\"results\"\n        )\n\n    async def async_recommendations(self, tv_id, page=1):\n        \"\"\"\n        Get the list of TV show recommendations for this item.（异步版本）\n        :param tv_id: int\n        :param page: int\n        :return:\n        \"\"\"\n        return await self._async_request_obj(\n            self._urls[\"recommendations\"] % tv_id,\n            params=\"page=%s\" % page,\n            key=\"results\"\n        )\n\n    async def async_reviews(self, tv_id, page=1):\n        \"\"\"\n        Get the reviews for a TV show.（异步版本）\n        :param tv_id: int\n        :param page: int\n        :return:\n        \"\"\"\n        return await self._async_request_obj(\n            self._urls[\"reviews\"] % tv_id,\n            params=\"page=%s\" % page,\n            key=\"results\"\n        )\n\n    async def async_screened_theatrically(self, tv_id):\n        \"\"\"\n        Get a list of seasons or episodes that have been screened in a film festival or theatre.（异步版本）\n        :param tv_id: int\n        :return:\n        \"\"\"\n        return await self._async_request_obj(\n            self._urls[\"screened_theatrically\"] % tv_id,\n            key=\"results\"\n        )\n\n    async def async_similar(self, tv_id, page=1):\n        \"\"\"\n        Get the primary TV show details by id.（异步版本）\n        :param tv_id: int\n        :param page: int\n        :return:\n        \"\"\"\n        return await self._async_request_obj(\n            self._urls[\"similar\"] % tv_id,\n            params=\"page=%s\" % page,\n            key=\"results\"\n        )\n\n    async def async_translations(self, tv_id):\n        \"\"\"\n        Get a list of the translations that exist for a TV show.（异步版本）\n        :param tv_id: int\n        :return:\n        \"\"\"\n        return await self._async_request_obj(\n            self._urls[\"translations\"] % tv_id,\n            key=\"translations\"\n        )\n\n    async def async_videos(self, tv_id, include_video_language=None, page=1):\n        \"\"\"\n        Get the videos that have been added to a TV show.（异步版本）\n        :param tv_id: int\n        :param include_video_language: str\n        :param page: int\n        :return:\n        \"\"\"\n        params = \"page=%s\" % page\n        if include_video_language:\n            params += \"&include_video_language=%s\" % include_video_language\n        return await self._async_request_obj(\n            self._urls[\"videos\"] % tv_id,\n            params=params\n        )\n\n    async def async_watch_providers(self, tv_id):\n        \"\"\"\n        You can query this method to get a list of the availabilities per country by provider.（异步版本）\n        :param tv_id: int\n        :return:\n        \"\"\"\n        return await self._async_request_obj(\n            self._urls[\"watch_providers\"] % tv_id,\n            key=\"results\"\n        )\n\n    async def async_rate_tv_show(self, tv_id, rating):\n        \"\"\"\n        Rate a TV show.（异步版本）\n        :param tv_id: int\n        :param rating: float\n        \"\"\"\n        await self._async_request_obj(\n            self._urls[\"rate_tv_show\"] % tv_id,\n            params=\"session_id=%s\" % self.session_id,\n            method=\"POST\",\n            json={\"value\": rating}\n        )\n\n    async def async_delete_rating(self, tv_id):\n        \"\"\"\n        Remove your rating for a TV show.（异步版本）\n        :param tv_id: int\n        \"\"\"\n        await self._async_request_obj(\n            self._urls[\"delete_rating\"] % tv_id,\n            params=\"session_id=%s\" % self.session_id,\n            method=\"DELETE\"\n        )\n\n    async def async_latest(self):\n        \"\"\"\n        Get the most newly created TV show. This is a live response and will continuously change.（异步版本）\n        :return:\n        \"\"\"\n        return await self._async_request_obj(self._urls[\"latest\"])\n\n    async def async_airing_today(self, page=1):\n        \"\"\"\n        Get a list of TV shows that are airing today.\n        This query is purely day based as we do not currently support airing times.（异步版本）\n        :param page: int\n        :return:\n        \"\"\"\n        return await self._async_request_obj(\n            self._urls[\"airing_today\"],\n            params=\"page=%s\" % page,\n            key=\"results\"\n        )\n\n    async def async_on_the_air(self, page=1):\n        \"\"\"\n        Get a list of shows that are currently on the air.（异步版本）\n        :param page:\n        :return:\n        \"\"\"\n        return await self._async_request_obj(\n            self._urls[\"on_the_air\"],\n            params=\"page=%s\" % page,\n            key=\"results\"\n        )\n\n    async def async_popular(self, page=1):\n        \"\"\"\n        Get a list of the current popular TV shows on TMDb. This list updates daily.（异步版本）\n        :param page:\n        :return:\n        \"\"\"\n        return await self._async_request_obj(\n            self._urls[\"popular\"],\n            params=\"page=%s\" % page,\n            key=\"results\"\n        )\n\n    async def async_top_rated(self, page=1):\n        \"\"\"\n        Get a list of the top rated TV shows on TMDb.（异步版本）\n        :param page:\n        :return:\n        \"\"\"\n        return await self._async_request_obj(\n            self._urls[\"top_rated\"],\n            params=\"page=%s\" % page,\n            key=\"results\"\n        )\n"
  },
  {
    "path": "app/modules/themoviedb/tmdbv3api/tmdb.py",
    "content": "# -*- coding: utf-8 -*-\n\nimport asyncio\nimport logging\nimport time\nfrom datetime import datetime\n\nimport requests\nimport requests.exceptions\n\nfrom app.core.cache import cached, fresh, async_fresh\nfrom app.core.config import settings\nfrom app.utils.http import RequestUtils, AsyncRequestUtils\nfrom .exceptions import TMDbException\n\nlogger = logging.getLogger(__name__)\n\n\nclass TMDb(object):\n\n    def __init__(self, session=None, language=None):\n        self._api_key = settings.TMDB_API_KEY\n        self._language = language or settings.TMDB_LOCALE or \"en-US\"\n        self._session_id = None\n        self._session = session\n        self._wait_on_rate_limit = True\n        self._proxies = settings.PROXY\n        self._domain = settings.TMDB_API_DOMAIN\n        self._page = None\n        self._total_results = None\n        self._total_pages = None\n\n        if not self._session:\n            self._session = requests.Session()\n        self._req = RequestUtils(ua=settings.NORMAL_USER_AGENT, session=self._session, proxies=self.proxies)\n\n        self._async_req = AsyncRequestUtils(ua=settings.NORMAL_USER_AGENT, proxies=self.proxies)\n\n        self._remaining = 40\n        self._reset = None\n        self._timeout = 15\n\n    @property\n    def page(self):\n        return self._page\n\n    @property\n    def total_results(self):\n        return self._total_results\n\n    @property\n    def total_pages(self):\n        return self._total_pages\n\n    @property\n    def api_key(self):\n        return self._api_key\n\n    @property\n    def domain(self):\n        return self._domain\n\n    @property\n    def proxies(self):\n        return self._proxies\n\n    @proxies.setter\n    def proxies(self, proxies):\n        self._proxies = proxies\n\n    @api_key.setter\n    def api_key(self, api_key):\n        self._api_key = str(api_key)\n\n    @domain.setter\n    def domain(self, domain):\n        self._domain = str(domain)\n\n    @property\n    def language(self):\n        return self._language\n\n    @language.setter\n    def language(self, language):\n        self._language = language\n\n    @property\n    def has_session(self):\n        return True if self._session_id else False\n\n    @property\n    def session_id(self):\n        if not self._session_id:\n            raise TMDbException(\"Must Authenticate to create a session run Authentication(username, password)\")\n        return self._session_id\n\n    @session_id.setter\n    def session_id(self, session_id):\n        self._session_id = session_id\n\n    @property\n    def wait_on_rate_limit(self):\n        return self._wait_on_rate_limit\n\n    @wait_on_rate_limit.setter\n    def wait_on_rate_limit(self, wait_on_rate_limit):\n        self._wait_on_rate_limit = bool(wait_on_rate_limit)\n\n    @cached(maxsize=settings.CONF.tmdb, ttl=settings.CONF.meta, skip_none=True)\n    def request(self, method, url, data, json, **kwargs):\n        if method == \"GET\":\n            req = self._req.get_res(url, params=data, json=json)\n        else:\n            req = self._req.post_res(url, data=data, json=json)\n        if req is None:\n            raise TMDbException(\"无法连接TheMovieDb，请检查网络连接！\")\n        return req\n\n    @cached(maxsize=settings.CONF.tmdb, ttl=settings.CONF.meta, skip_none=True)\n    async def async_request(self, method, url, data, json, **kwargs):\n        if method == \"GET\":\n            req = await self._async_req.get_res(url, params=data, json=json)\n        else:\n            req = await self._async_req.post_res(url, data=data, json=json)\n        if req is None:\n            raise TMDbException(\"无法连接TheMovieDb，请检查网络连接！\")\n        return req\n\n    def cache_clear(self):\n        return self.request.cache_clear()\n\n    def _validate_api_key(self):\n        if self.api_key is None or self.api_key == \"\":\n            raise TMDbException(\"TheMovieDb API Key 未设置！\")\n\n    def _build_url(self, action, params=\"\"):\n        return \"https://%s/3%s?api_key=%s&%s&language=%s\" % (\n            self.domain,\n            action,\n            self.api_key,\n            params,\n            self.language,\n        )\n\n    def _handle_headers(self, headers):\n        if \"X-RateLimit-Remaining\" in headers:\n            self._remaining = int(headers[\"X-RateLimit-Remaining\"])\n\n        if \"X-RateLimit-Reset\" in headers:\n            self._reset = int(headers[\"X-RateLimit-Reset\"])\n\n    def _handle_rate_limit(self):\n        if self._remaining < 1:\n            current_time = int(time.time())\n            sleep_time = self._reset - current_time\n\n            if self.wait_on_rate_limit:\n                logger.warning(\"达到请求频率限制，休眠：%d 秒...\" % sleep_time)\n                return abs(sleep_time)\n            else:\n                raise TMDbException(\"达到请求频率限制，请稍后再试！\")\n        return 0\n\n    def _process_json_response(self, json_data, is_async=False):\n        if \"page\" in json_data:\n            self._page = json_data[\"page\"]\n\n        if \"total_results\" in json_data:\n            self._total_results = json_data[\"total_results\"]\n\n        if \"total_pages\" in json_data:\n            self._total_pages = json_data[\"total_pages\"]\n\n    @staticmethod\n    def _handle_errors(json_data):\n        if \"errors\" in json_data:\n            raise TMDbException(json_data[\"errors\"])\n\n        if \"success\" in json_data and json_data[\"success\"] is False:\n            raise TMDbException(json_data[\"status_message\"])\n\n    def _request_obj(self, action, params=\"\", call_cached=True,\n                     method=\"GET\", data=None, json=None, key=None):\n        self._validate_api_key()\n        url = self._build_url(action, params)\n\n        with fresh(not call_cached or method == \"POST\"):\n            req = self.request(method, url, data, json,\n                                      _ts=datetime.strftime(datetime.now(), '%Y%m%d'))\n\n        if req is None:\n            return None\n\n        self._handle_headers(req.headers)\n\n        rate_limit_result = self._handle_rate_limit()\n        if rate_limit_result:\n            logger.warning(\"达到请求频率限制，将在 %d 秒后重试...\" % rate_limit_result)\n            time.sleep(rate_limit_result)\n            return self._request_obj(action, params, False, method, data, json, key)\n\n        json_data = req.json()\n        self._process_json_response(json_data, is_async=False)\n        self._handle_errors(json_data)\n\n        if key:\n            return json_data.get(key)\n        return json_data\n\n    async def _async_request_obj(self, action, params=\"\", call_cached=True,\n                                 method=\"GET\", data=None, json=None, key=None):\n        self._validate_api_key()\n        url = self._build_url(action, params)\n\n        async with async_fresh(not call_cached or method == \"POST\"):\n            req = await self.async_request(method, url, data, json,\n                                           _ts=datetime.strftime(datetime.now(), '%Y%m%d'))\n\n        if req is None:\n            return None\n\n        self._handle_headers(req.headers)\n\n        rate_limit_result = self._handle_rate_limit()\n        if rate_limit_result:\n            logger.warning(\"达到请求频率限制，将在 %d 秒后重试...\" % rate_limit_result)\n            await asyncio.sleep(rate_limit_result)\n            return await self._async_request_obj(action, params, False, method, data, json, key)\n\n        json_data = req.json()\n        self._process_json_response(json_data, is_async=True)\n        self._handle_errors(json_data)\n\n        if key:\n            return json_data.get(key)\n        return json_data\n\n    def close(self):\n        if self._session:\n            self._session.close()\n"
  },
  {
    "path": "app/modules/thetvdb/__init__.py",
    "content": "from threading import Lock\nfrom typing import Optional, Tuple, Union\n\nfrom app.core.config import settings\nfrom app.log import logger\nfrom app.modules import _ModuleBase\nfrom app.modules.thetvdb import tvdb_v4_official\nfrom app.schemas.types import ModuleType, MediaRecognizeType\n\n\nclass TheTvDbModule(_ModuleBase):\n    \"\"\"\n    TVDB媒体信息匹配\n    \"\"\"\n    __timeout: int = 15\n    tvdb: Optional[tvdb_v4_official.TVDB] = None\n    __auth_lock = Lock()\n\n    def init_module(self) -> None:\n        pass\n\n    def _initialize_tvdb_session(self, is_retry: bool = False) -> None:\n        \"\"\"\n        创建或刷新 TVDB 登录会话。\n        :param is_retry: 是否是由于token失效后的重试登录\n        \"\"\"\n        action = \"刷新\" if is_retry else \"创建\"\n        logger.info(f\"开始{action}TVDB登录会话...\")\n        try:\n            if not settings.TVDB_V4_API_KEY:\n                raise ConnectionError(\"TVDB API Key 未配置，无法初始化会话。\")\n            self.tvdb = tvdb_v4_official.TVDB(apikey=settings.TVDB_V4_API_KEY,\n                                              pin=settings.TVDB_V4_API_PIN,\n                                              proxy=settings.PROXY,\n                                              timeout=self.__timeout)\n            if self.tvdb:\n                logger.info(f\"TVDB登录会话{action}成功。\")\n            else:\n                raise ValueError(f\"TVDB登录会话{action}后实例仍为None。\")\n        except Exception as e:\n            self.tvdb = None\n            raise ConnectionError(f\"TVDB登录会话{action}失败: {str(e)}\") from e\n\n    def _ensure_tvdb_session(self, is_retry: bool = False) -> None:\n        \"\"\"\n        确保TVDB会话存在。如果不存在或需要强制重新初始化，则进行初始化。\n        :param is_retry: 是否重新初始化（例如token失效时）\n        \"\"\"\n        # 第一次检查 (无锁)，提高性能，避免不必要锁竞争\n        if not self.tvdb or is_retry:\n            with self.__auth_lock:\n                # 第二次检查 (有锁)，防止多个线程都通过第一次检查后重复初始化\n                if not self.tvdb or is_retry:\n                    self._initialize_tvdb_session(is_retry=is_retry)\n\n    def _handle_tvdb_call(self, method_name: str, *args, **kwargs):\n        \"\"\"\n        包裹 TVDB 调用，处理 token 失效情况并尝试重新初始化\n        :param method_name: 要在 self.tvdb 实例上调用的方法的名称 (字符串)\n        \"\"\"\n        try:\n            self._ensure_tvdb_session()\n            actual_method = getattr(self.tvdb, method_name)\n            return actual_method(*args, **kwargs)\n        except ValueError as e:\n            if \"Unauthorized\" in str(e):\n                logger.warning(\"TVDB Token 可能已失效，正在尝试重新登录...\")\n                try:\n                    self._ensure_tvdb_session(is_retry=True)\n                    actual_method = getattr(self.tvdb, method_name)\n                    return actual_method(*args, **kwargs)\n                except ConnectionError as conn_err:\n                    logger.error(f\"TVDB Token失效后重新登录失败: {conn_err}\")\n                    raise\n            elif \"NotFoundException\" in str(e) or \"ID not found\" in str(e):\n                logger.warning(f\"TVDB 资源未找到 (调用 {method_name}): {e}\")\n                return None\n            else:\n                logger.error(f\"TVDB 调用 ({method_name}) 时发生未处理的 ValueError: {str(e)}\")\n                raise\n        except ConnectionError as e:\n            logger.error(f\"TVDB 连接会话错误: {str(e)}\")\n            raise\n        except AttributeError as e:\n            logger.error(f\"TVDB 实例上没有方法 '{method_name}': {e}\")\n            raise\n        except Exception as e:\n            logger.error(f\"TVDB 调用时发生未知错误: {str(e)}\", exc_info=True)\n            raise\n\n    @staticmethod\n    def get_name() -> str:\n        return \"TheTvDb\"\n\n    @staticmethod\n    def get_type() -> ModuleType:\n        \"\"\"\n        获取模块类型\n        \"\"\"\n        return ModuleType.MediaRecognize\n\n    @staticmethod\n    def get_subtype() -> MediaRecognizeType:\n        \"\"\"\n        获取模块子类型\n        \"\"\"\n        return MediaRecognizeType.TVDB\n\n    @staticmethod\n    def get_priority() -> int:\n        \"\"\"\n        获取模块优先级，数字越小优先级越高，只有同一接口下优先级才生效\n        \"\"\"\n        return 4\n\n    def stop(self):\n        logger.info(\"TheTvDbModule 停止。正在清除 TVDB 会话。\")\n        with self.__auth_lock:\n            self.tvdb = None\n\n    def test(self) -> Tuple[bool, str]:\n        \"\"\"\n        测试模块连接性\n        \"\"\"\n        try:\n            self._handle_tvdb_call(\"get_series\", 81189)\n            return True, \"\"\n        except Exception as e:\n            return False, str(e)\n\n    def init_setting(self) -> Tuple[str, Union[str, bool]]:\n        pass\n\n    def tvdb_info(self, tvdbid: int) -> Optional[dict]:\n        \"\"\"\n        获取TVDB信息\n        :param tvdbid: int\n        :return: TVDB信息\n        \"\"\"\n        try:\n            logger.info(f\"开始获取TVDB信息: {tvdbid} ...\")\n            return self._handle_tvdb_call(\"get_series_extended\", tvdbid)\n        except Exception as err:\n            logger.error(f\"获取TVDB信息失败: {str(err)}\")\n            return None\n\n    def search_tvdb(self, title: str) -> list:\n        \"\"\"\n        用标题搜索TVDB剧集\n        :param title: 标题\n        :return: TVDB信息\n        \"\"\"\n        try:\n            logger.info(f\"开始用标题搜索TVDB剧集: {title} ...\")\n            res = self._handle_tvdb_call(\"search\", title)\n            if res is None:\n                return []\n            if not isinstance(res, list):\n                logger.warning(f\"TVDB 搜索 '{title}' 未返回列表：{type(res)}\")\n                return []\n            return [item for item in res if isinstance(item, dict) and item.get(\"type\") == \"series\"]\n        except Exception as err:\n            logger.error(f\"用标题搜索TVDB剧集失败 ({title}): {str(err)}\")\n            return []\n\n    def clear_cache(self):\n        \"\"\"\n        清除缓存\n        \"\"\"\n        logger.info(f\"开始清除{self.get_name()}缓存 ...\")\n        if tvdb := self.tvdb:\n            tvdb.clear_cache()\n        logger.info(f\"{self.get_name()}缓存清除完成\")\n"
  },
  {
    "path": "app/modules/thetvdb/tvdb_v4_official.py",
    "content": "\"\"\"Official python package for using the tvdb v4 api\"\"\"\n\n__author__ = \"Weylin Wagnon\"\n__version__ = \"1.0.12\"\n\nimport json\nimport urllib.parse\nfrom http import HTTPStatus\n\nfrom app.core.cache import cached\nfrom app.core.config import settings\nfrom app.utils.http import RequestUtils\n\n\nclass Auth:\n    \"\"\"\n    TVDB认证类\n    \"\"\"\n\n    def __init__(self, url: str, apikey: str, pin: str = \"\", proxy: dict = None, timeout: int = 15):\n        login_info = {\"apikey\": apikey}\n        if pin != \"\":\n            login_info[\"pin\"] = pin\n\n        login_info_bytes = json.dumps(login_info, indent=2)\n\n        try:\n            # 使用项目统一的RequestUtils类\n            req_utils = RequestUtils(proxies=proxy, timeout=timeout)\n            response = req_utils.post_res(\n                url=url,\n                data=login_info_bytes,\n                headers={\"Content-Type\": \"application/json\"}\n            )\n\n            if response and response.status_code == 200:\n                result = response.json()\n                self.token = result[\"data\"][\"token\"]\n            else:\n                if response is not None:\n                    try:\n                        error_data = response.json()\n                        error_msg = f\"Code: {response.status_code}, {error_data.get('message', '未知错误')}\"\n                    except Exception as err:\n                        error_msg = f\"Code: {response.status_code}, 响应解析失败：{err}\"\n                else:\n                    error_msg = \"网络连接失败，未收到响应\"\n                raise Exception(error_msg)\n        except Exception as e:\n            raise Exception(f\"TVDB认证失败: {str(e)}\")\n\n    def get_token(self):\n        \"\"\"\n        获取认证token\n        \"\"\"\n        return self.token\n\n\nclass Request:\n    \"\"\"\n    请求处理类\n    \"\"\"\n\n    def __init__(self, auth_token: str, proxy: dict = None, timeout: int = 15):\n        self.auth_token = auth_token\n        self.links = None\n        self.proxy = proxy\n        self.timeout = timeout\n\n    @cached(maxsize=settings.CONF.tmdb, ttl=settings.CONF.meta, skip_none=True)\n    def make_request(self, url: str, if_modified_since: bool = None):\n        \"\"\"\n        向指定的 URL 发起请求并返回数据\n        \"\"\"\n        headers = {\"Authorization\": f\"Bearer {self.auth_token}\"}\n        if if_modified_since:\n            headers[\"If-Modified-Since\"] = str(if_modified_since)\n\n        try:\n            # 使用项目统一的RequestUtils类\n            req_utils = RequestUtils(proxies=self.proxy, timeout=self.timeout)\n            response = req_utils.get_res(url=url, headers=headers)\n\n            if response is None:\n                raise ValueError(f\"获取 {url} 失败\\n  网络连接失败\")\n\n            if response.status_code == HTTPStatus.NOT_MODIFIED:\n                return {\n                    \"code\": HTTPStatus.NOT_MODIFIED.real,\n                    \"message\": \"Not-Modified\",\n                }\n\n            if response.status_code == 200:\n                result = response.json()\n                data = result.get(\"data\", None)\n                if data is not None and result.get(\"status\", \"failure\") != \"failure\":\n                    self.links = result.get(\"links\", None)\n                    return data\n\n                msg = result.get(\"message\", \"未知错误\")\n                raise ValueError(f\"获取 {url} 失败\\n  {str(msg)}\")\n            else:\n                # 处理其他HTTP错误状态码\n                try:\n                    error_data = response.json()\n                    msg = error_data.get(\"message\", f\"HTTP {response.status_code}\")\n                except Exception as err:\n                    msg = f\"HTTP {response.status_code} {err}\"\n                raise ValueError(f\"获取 {url} 失败\\n  {str(msg)}\")\n\n        except Exception as e:\n            if isinstance(e, ValueError):\n                raise\n            raise ValueError(f\"获取 {url} 失败\\n  {str(e)}\")\n\n\nclass Url:\n    \"\"\"\n    URL构建类\n    \"\"\"\n\n    def __init__(self):\n        self.base_url = \"https://api4.thetvdb.com/v4/\"\n\n    def construct(self, url_sect: str, url_id: int = None,\n                  url_subsect: str = None, url_lang: str = None, **kwargs):\n        \"\"\"\n        构建API URL\n        \"\"\"\n        url = self.base_url + url_sect\n        if url_id:\n            url += \"/\" + str(url_id)\n        if url_subsect:\n            url += \"/\" + url_subsect\n        if url_lang:\n            url += \"/\" + url_lang\n        if kwargs:\n            params = {var: val for var, val in kwargs.items() if val is not None}\n            if params:\n                url += \"?\" + urllib.parse.urlencode(params)\n        return url\n\n\nclass TVDB:\n    \"\"\"\n    TVDB API主类\n    \"\"\"\n\n    def __init__(self, apikey: str, pin: str = \"\", proxy: dict = None, timeout: int = 15):\n        self.url = Url()\n        login_url = self.url.construct(\"login\")\n        self.auth = Auth(login_url, apikey, pin, proxy, timeout)\n        auth_token = self.auth.get_token()\n        self.request = Request(auth_token, proxy, timeout)\n\n    def get_req_links(self) -> dict:\n        \"\"\"\n        获取上一次请求返回的链接信息（例如分页链接）\n        \"\"\"\n        return self.request.links\n\n    def get_artwork_statuses(self, meta: str = None, if_modified_since: bool = None) -> list:\n        \"\"\"\n        返回艺术图状态列表\n        \"\"\"\n        url = self.url.construct(\"artwork/statuses\", meta=meta)\n        return self.request.make_request(url, if_modified_since)\n\n    def get_artwork_types(self, meta: str = None, if_modified_since: bool = None) -> list:\n        \"\"\"\n        返回艺术图类型列表\n        \"\"\"\n        url = self.url.construct(\"artwork/types\", meta=meta)\n        return self.request.make_request(url, if_modified_since)\n\n    def get_artwork(self, id: int, meta: str = None, if_modified_since: bool = None) -> dict:\n        \"\"\"\n        返回单个艺术图信息的字典\n        \"\"\"\n        url = self.url.construct(\"artwork\", id, meta=meta)\n        return self.request.make_request(url, if_modified_since)\n\n    def get_artwork_extended(self, id: int, meta: str = None, if_modified_since: bool = None) -> dict:\n        \"\"\"\n        返回单个艺术图的扩展信息字典\n        \"\"\"\n        url = self.url.construct(\"artwork\", id, \"extended\", meta=meta)\n        return self.request.make_request(url, if_modified_since)\n\n    def get_all_awards(self, meta: str = None, if_modified_since: bool = None) -> list:\n        \"\"\"\n        返回奖项列表\n        \"\"\"\n        url = self.url.construct(\"awards\", meta=meta)\n        return self.request.make_request(url, if_modified_since)\n\n    def get_award(self, id: int, meta: str = None, if_modified_since: bool = None) -> dict:\n        \"\"\"\n        返回单个奖项信息的字典\n        \"\"\"\n        url = self.url.construct(\"awards\", id, meta=meta)\n        return self.request.make_request(url, if_modified_since)\n\n    def get_award_extended(self, id: int, meta: str = None, if_modified_since: bool = None) -> dict:\n        \"\"\"\n        返回单个奖项的扩展信息字典\n        \"\"\"\n        url = self.url.construct(\"awards\", id, \"extended\", meta=meta)\n        return self.request.make_request(url, if_modified_since)\n\n    def get_all_award_categories(self, meta: str = None, if_modified_since: bool = None) -> list:\n        \"\"\"\n        返回奖项类别列表\n        \"\"\"\n        url = self.url.construct(\"awards/categories\", meta=meta)\n        return self.request.make_request(url, if_modified_since)\n\n    def get_award_category(self, id: int, meta: str = None, if_modified_since: bool = None) -> dict:\n        \"\"\"\n        返回单个奖项类别信息的字典\n        \"\"\"\n        url = self.url.construct(\"awards/categories\", id, meta=meta)\n        return self.request.make_request(url, if_modified_since)\n\n    def get_award_category_extended(self, id: int, meta: str = None, if_modified_since: bool = None) -> dict:\n        \"\"\"\n        返回单个奖项类别的扩展信息字典\n        \"\"\"\n        url = self.url.construct(\"awards/categories\", id, \"extended\", meta=meta)\n        return self.request.make_request(url, if_modified_since)\n\n    def get_content_ratings(self, meta: str = None, if_modified_since: bool = None) -> list:\n        \"\"\"\n        返回内容分级列表\n        \"\"\"\n        url = self.url.construct(\"content/ratings\", meta=meta)\n        return self.request.make_request(url, if_modified_since)\n\n    def get_countries(self, meta: str = None, if_modified_since: bool = None) -> list:\n        \"\"\"\n        返回国家列表\n        \"\"\"\n        url = self.url.construct(\"countries\", meta=meta)\n        return self.request.make_request(url, if_modified_since)\n\n    def get_all_companies(self, page: int = None, meta: str = None, if_modified_since: bool = None) -> list:\n        \"\"\"\n        返回公司列表 (可分页)\n        \"\"\"\n        url = self.url.construct(\"companies\", page=page, meta=meta)\n        return self.request.make_request(url, if_modified_since)\n\n    def get_company_types(self, meta: str = None, if_modified_since: bool = None) -> list:\n        \"\"\"\n        返回公司类型列表\n        \"\"\"\n        url = self.url.construct(\"companies/types\", meta=meta)\n        return self.request.make_request(url, if_modified_since)\n\n    def get_company(self, id: int, meta: str = None, if_modified_since: bool = None) -> dict:\n        \"\"\"\n        返回单个公司信息的字典\n        \"\"\"\n        url = self.url.construct(\"companies\", id, meta=meta)\n        return self.request.make_request(url, if_modified_since)\n\n    def get_all_series(self, page: int = None, meta: str = None, if_modified_since: bool = None) -> list:\n        \"\"\"\n        返回剧集列表 (可分页)\n        \"\"\"\n        url = self.url.construct(\"series\", page=page, meta=meta)\n        return self.request.make_request(url, if_modified_since)\n\n    def get_series(self, id: int, meta: str = None, if_modified_since: bool = None) -> dict:\n        \"\"\"\n        返回单个剧集信息的字典\n        \"\"\"\n        url = self.url.construct(\"series\", id, meta=meta)\n        return self.request.make_request(url, if_modified_since)\n\n    def get_series_by_slug(self, slug: str, meta: str = None, if_modified_since: bool = None) -> dict:\n        \"\"\"\n        通过 slug (别名) 返回单个剧集信息的字典\n        \"\"\"\n        url = self.url.construct(\"series/slug\", slug, meta=meta)\n        return self.request.make_request(url, if_modified_since)\n\n    def get_series_extended(self, id: int, meta=None, short=False, if_modified_since=None) -> dict:\n        \"\"\"\n        返回单个剧集的扩展信息字典\n        \"\"\"\n        url = self.url.construct(\"series\", id, \"extended\", meta=meta, short=short)\n        return self.request.make_request(url, if_modified_since)\n\n    def get_series_episodes(self, id: int, season_type: str = \"default\", page: int = 0,\n                            lang: str = None, meta: str = None, if_modified_since: bool = None, **kwargs) -> dict:\n        \"\"\"\n        返回指定剧集和季类型的各集信息字典 (可分页，可指定语言)\n        \"\"\"\n        url = self.url.construct(\n            \"series\", id, \"episodes/\" + season_type, lang, page=page, meta=meta, **kwargs\n        )\n        return self.request.make_request(url, if_modified_since)\n\n    def get_series_translation(self, id: int, lang: str, meta: str = None, if_modified_since: bool = None) -> dict:\n        \"\"\"\n        返回剧集的指定语言翻译信息字典\n        \"\"\"\n        url = self.url.construct(\"series\", id, \"translations\", lang, meta=meta)\n        return self.request.make_request(url, if_modified_since)\n\n    def get_series_artworks(self, id: int, lang: str, type=None, if_modified_since=None) -> dict:\n        \"\"\"\n        返回包含艺术图数组的剧集记录 (可指定语言和类型)\n        \"\"\"\n        url = self.url.construct(\"series\", id, \"artworks\", lang=lang, type=type)\n        return self.request.make_request(url, if_modified_since)\n\n    def get_series_next_aired(self, id: int, if_modified_since=None) -> dict:\n        \"\"\"\n        返回剧集的下一播出信息字典\n        \"\"\"\n        url = self.url.construct(\"series\", id, \"nextAired\")\n        return self.request.make_request(url, if_modified_since)\n\n    def get_all_movies(self, page: int = None, meta: str = None, if_modified_since: bool = None) -> list:\n        \"\"\"\n        返回电影列表 (可分页)\n        \"\"\"\n        url = self.url.construct(\"movies\", page=page, meta=meta)\n        return self.request.make_request(url, if_modified_since)\n\n    def get_movie(self, id: int, meta: str = None, if_modified_since: bool = None) -> dict:\n        \"\"\"\n        返回单个电影信息的字典\n        \"\"\"\n        url = self.url.construct(\"movies\", id, meta=meta)\n        return self.request.make_request(url, if_modified_since)\n\n    def get_movie_by_slug(self, slug: str, meta: str = None, if_modified_since: bool = None) -> dict:\n        \"\"\"\n        通过 slug (别名) 返回单个电影信息的字典\n        \"\"\"\n        url = self.url.construct(\"movies/slug\", slug, meta=meta)\n        return self.request.make_request(url, if_modified_since)\n\n    def get_movie_extended(self, id: int, meta=None, short=False, if_modified_since=None) -> dict:\n        \"\"\"\n        返回电影的扩展信息字典\n        \"\"\"\n        url = self.url.construct(\"movies\", id, \"extended\", meta=meta, short=short)\n        return self.request.make_request(url, if_modified_since)\n\n    def get_movie_translation(self, id: int, lang: str, meta: str = None, if_modified_since: bool = None) -> dict:\n        \"\"\"\n        返回电影的指定语言翻译信息字典\n        \"\"\"\n        url = self.url.construct(\"movies\", id, \"translations\", lang, meta=meta)\n        return self.request.make_request(url, if_modified_since)\n\n    def get_all_seasons(self, page: int = None, meta: str = None, if_modified_since: bool = None) -> list:\n        \"\"\"\n        返回季列表 (可分页)\n        \"\"\"\n        url = self.url.construct(\"seasons\", page=page, meta=meta)\n        return self.request.make_request(url, if_modified_since)\n\n    def get_season(self, id: int, meta: str = None, if_modified_since: bool = None) -> dict:\n        \"\"\"\n        返回单季信息的字典\n        \"\"\"\n        url = self.url.construct(\"seasons\", id, meta=meta)\n        return self.request.make_request(url, if_modified_since)\n\n    def get_season_extended(self, id: int, meta: str = None, if_modified_since: bool = None) -> dict:\n        \"\"\"\n        返回单季的扩展信息字典\n        \"\"\"\n        url = self.url.construct(\"seasons\", id, \"extended\", meta=meta)\n        return self.request.make_request(url, if_modified_since)\n\n    def get_season_types(self, meta: str = None, if_modified_since: bool = None) -> list:\n        \"\"\"\n        返回季类型列表\n        \"\"\"\n        url = self.url.construct(\"seasons/types\", meta=meta)\n        return self.request.make_request(url, if_modified_since)\n\n    def get_season_translation(self, id: int, lang: str, meta: str = None, if_modified_since: bool = None) -> dict:\n        \"\"\"\n        返回季的指定语言翻译信息字典\n        \"\"\"\n        url = self.url.construct(\"seasons\", id, \"translations\", lang, meta=meta)\n        return self.request.make_request(url, if_modified_since)\n\n    def get_all_episodes(self, page: int = None, meta: str = None, if_modified_since: bool = None) -> list:\n        \"\"\"\n        返回集列表 (可分页)\n        \"\"\"\n        url = self.url.construct(\"episodes\", page=page, meta=meta)\n        return self.request.make_request(url, if_modified_since)\n\n    def get_episode(self, id: int, meta: str = None, if_modified_since: bool = None) -> dict:\n        \"\"\"\n        返回单集信息的字典\n        \"\"\"\n        url = self.url.construct(\"episodes\", id, meta=meta)\n        return self.request.make_request(url, if_modified_since)\n\n    def get_episode_extended(self, id: int, meta: str = None, if_modified_since: bool = None) -> dict:\n        \"\"\"\n        返回单集的扩展信息字典\n        \"\"\"\n        url = self.url.construct(\"episodes\", id, \"extended\", meta=meta)\n        return self.request.make_request(url, if_modified_since)\n\n    def get_episode_translation(self, id: int, lang: str, meta: str = None, if_modified_since: bool = None) -> dict:\n        \"\"\"\n        返回单集的指定语言翻译信息字典\n        \"\"\"\n        url = self.url.construct(\"episodes\", id, \"translations\", lang, meta=meta)\n        return self.request.make_request(url, if_modified_since)\n\n    # 兼容旧函数名。\n    get_episodes_translation = get_episode_translation\n\n    def get_all_genders(self, meta: str = None, if_modified_since: bool = None) -> list:\n        \"\"\"\n        返回性别列表\n        \"\"\"\n        url = self.url.construct(\"genders\", meta=meta)\n        return self.request.make_request(url, if_modified_since)\n\n    def get_all_genres(self, meta: str = None, if_modified_since: bool = None) -> list:\n        \"\"\"\n        返回类型（流派）列表\n        \"\"\"\n        url = self.url.construct(\"genres\", meta=meta)\n        return self.request.make_request(url, if_modified_since)\n\n    def get_genre(self, id: int, meta: str = None, if_modified_since: bool = None) -> dict:\n        \"\"\"\n        返回单个类型（流派）信息的字典\n        \"\"\"\n        url = self.url.construct(\"genres\", id, meta=meta)\n        return self.request.make_request(url, if_modified_since)\n\n    def get_all_languages(self, meta: str = None, if_modified_since: bool = None) -> list:\n        \"\"\"\n        返回语言列表\n        \"\"\"\n        url = self.url.construct(\"languages\", meta=meta)\n        return self.request.make_request(url, if_modified_since)\n\n    def get_all_people(self, page: int = None, meta: str = None, if_modified_since: bool = None) -> list:\n        \"\"\"\n        返回人物列表 (可分页)\n        \"\"\"\n        url = self.url.construct(\"people\", page=page, meta=meta)\n        return self.request.make_request(url, if_modified_since)\n\n    def get_person(self, id: int, meta: str = None, if_modified_since: bool = None) -> dict:\n        \"\"\"\n        返回单个人物信息的字典\n        \"\"\"\n        url = self.url.construct(\"people\", id, meta=meta)\n        return self.request.make_request(url, if_modified_since)\n\n    def get_person_extended(self, id: int, meta: str = None, if_modified_since: bool = None) -> dict:\n        \"\"\"\n        返回单个人物的扩展信息字典\n        \"\"\"\n        url = self.url.construct(\"people\", id, \"extended\", meta=meta)\n        return self.request.make_request(url, if_modified_since)\n\n    def get_person_translation(self, id: int, lang: str, meta: str = None, if_modified_since: bool = None) -> dict:\n        \"\"\"\n        返回人物的指定语言翻译信息字典\n        \"\"\"\n        url = self.url.construct(\"people\", id, \"translations\", lang, meta=meta)\n        return self.request.make_request(url, if_modified_since)\n\n    def get_character(self, id: int, meta: str = None, if_modified_since: bool = None) -> dict:\n        \"\"\"\n        返回角色信息的字典\n        \"\"\"\n        url = self.url.construct(\"characters\", id, meta=meta)\n        return self.request.make_request(url, if_modified_since)\n\n    def get_people_types(self, meta: str = None, if_modified_since: bool = None) -> list:\n        \"\"\"\n        返回人物类型列表\n        \"\"\"\n        url = self.url.construct(\"people/types\", meta=meta)\n        return self.request.make_request(url, if_modified_since)\n\n    # 兼容旧函数名\n    get_all_people_types = get_people_types\n\n    def get_source_types(self, meta: str = None, if_modified_since: bool = None) -> list:\n        \"\"\"\n        返回来源类型列表\n        \"\"\"\n        url = self.url.construct(\"sources/types\", meta=meta)\n        return self.request.make_request(url, if_modified_since)\n\n    # 兼容旧函数名\n    get_all_sourcetypes = get_source_types\n\n    def get_updates(self, since: int, **kwargs) -> list:\n        \"\"\"\n        返回更新列表\n        \"\"\"\n        url = self.url.construct(\"updates\", since=since, **kwargs)\n        return self.request.make_request(url)\n\n    def get_all_tag_options(self, page: int = None, meta: str = None, if_modified_since: bool = None) -> list:\n        \"\"\"\n        返回标签选项列表 (可分页)\n        \"\"\"\n        url = self.url.construct(\"tags/options\", page=page, meta=meta)\n        return self.request.make_request(url, if_modified_since)\n\n    def get_tag_option(self, id: int, meta: str = None, if_modified_since: bool = None) -> dict:\n        \"\"\"\n        返回单个标签选项信息的字典\n        \"\"\"\n        url = self.url.construct(\"tags/options\", id, meta=meta)\n        return self.request.make_request(url, if_modified_since)\n\n    def get_all_lists(self, page: int = None, meta=None) -> dict:\n        \"\"\"\n        返回所有公开的列表信息 (可分页)\n        \"\"\"\n        url = self.url.construct(\"lists\", page=page, meta=meta)\n        return self.request.make_request(url)\n\n    def get_list(self, id: int, meta: str = None, if_modified_since: bool = None) -> dict:\n        \"\"\"\n        返回单个列表信息的字典\n        \"\"\"\n        url = self.url.construct(\"lists\", id, meta=meta)\n        return self.request.make_request(url, if_modified_since)\n\n    def get_list_by_slug(self, slug: str, meta: str = None, if_modified_since: bool = None) -> dict:\n        \"\"\"\n        通过 slug (别名) 返回单个列表信息的字典\n        \"\"\"\n        url = self.url.construct(\"lists/slug\", slug, meta=meta)\n        return self.request.make_request(url, if_modified_since)\n\n    def get_list_extended(self, id: int, meta: str = None, if_modified_since: bool = None) -> dict:\n        \"\"\"\n        返回单个列表的扩展信息字典\n        \"\"\"\n        url = self.url.construct(\"lists\", id, \"extended\", meta=meta)\n        return self.request.make_request(url, if_modified_since)\n\n    def get_list_translation(self, id: int, lang: str, meta: str = None, if_modified_since: bool = None) -> dict:\n        \"\"\"\n        返回列表的指定语言翻译信息字典\n        \"\"\"\n        url = self.url.construct(\"lists\", id, \"translations\", lang, meta=meta)\n        return self.request.make_request(url, if_modified_since)\n\n    def get_inspiration_types(self, meta: str = None, if_modified_since: bool = None) -> dict:\n        \"\"\"\n        返回灵感类型列表\n        \"\"\"\n        url = self.url.construct(\"inspiration/types\", meta=meta)\n        return self.request.make_request(url, if_modified_since)\n\n    def search(self, query: str, **kwargs) -> list:\n        \"\"\"\n        根据查询字符串进行搜索，并返回结果列表\n        \"\"\"\n        url = self.url.construct(\"search\", query=query, **kwargs)\n        return self.request.make_request(url)\n\n    def search_by_remote_id(self, remoteid: str) -> list:\n        \"\"\"\n        通过外部 ID 精确匹配搜索，并返回结果列表\n        \"\"\"\n        url = self.url.construct(\"search/remoteid\", remoteid)\n        return self.request.make_request(url)\n\n    def get_tags(self, slug: str, if_modified_since=None) -> dict:\n        \"\"\"\n        返回具有指定 slug 的标签实体信息字典 (此方法基于的 /entities/{slug} 端点非标准，请谨慎使用)\n        \"\"\"\n        url = self.url.construct(\"entities\", url_subsect=slug)\n        return self.request.make_request(url, if_modified_since)\n\n    def get_entities_types(self, if_modified_since=None) -> dict:\n        \"\"\"\n        返回可用的实体类型列表\n        \"\"\"\n        url = self.url.construct(\"entities\")\n        return self.request.make_request(url, if_modified_since)\n\n    def get_user_by_id(self, id: int) -> dict:\n        \"\"\"\n        通过用户 ID 返回用户信息字典\n        \"\"\"\n        url = self.url.construct(\"user\", id)\n        return self.request.make_request(url)\n\n    def get_user(self) -> dict:\n        \"\"\"\n        返回当前认证的用户信息字典\n        \"\"\"\n        url = self.url.construct(\"user\")\n        return self.request.make_request(url)\n\n    def get_user_favorites(self) -> dict:\n        \"\"\"\n        返回当前认证用户的收藏夹信息字典\n        \"\"\"\n        url = self.url.construct('user/favorites')\n        return self.request.make_request(url)\n\n    def clear_cache(self):\n        \"\"\"\n        清除缓存\n        \"\"\"\n        self.request.make_request.cache_clear()\n"
  },
  {
    "path": "app/modules/transmission/__init__.py",
    "content": "from pathlib import Path\nfrom typing import Set, Tuple, Optional, Union, List, Dict\n\nfrom torrentool.torrent import Torrent\nfrom transmission_rpc import File\n\nfrom app import schemas\nfrom app.core.cache import FileCache\nfrom app.core.config import settings\nfrom app.core.metainfo import MetaInfo\nfrom app.log import logger\nfrom app.modules import _ModuleBase, _DownloaderBase\nfrom app.modules.transmission.transmission import Transmission\nfrom app.schemas import TransferTorrent, DownloadingTorrent\nfrom app.schemas.types import TorrentStatus, ModuleType, DownloaderType\nfrom app.utils.string import StringUtils\n\n\nclass TransmissionModule(_ModuleBase, _DownloaderBase[Transmission]):\n\n    def init_module(self) -> None:\n        \"\"\"\n        初始化模块\n        \"\"\"\n        super().init_service(service_name=Transmission.__name__.lower(),\n                             service_type=Transmission)\n\n    @staticmethod\n    def get_name() -> str:\n        return \"Transmission\"\n\n    @staticmethod\n    def get_type() -> ModuleType:\n        \"\"\"\n        获取模块类型\n        \"\"\"\n        return ModuleType.Downloader\n\n    @staticmethod\n    def get_subtype() -> DownloaderType:\n        \"\"\"\n        获取模块子类型\n        \"\"\"\n        return DownloaderType.Transmission\n\n    @staticmethod\n    def get_priority() -> int:\n        \"\"\"\n        获取模块优先级，数字越小优先级越高，只有同一接口下优先级才生效\n        \"\"\"\n        return 2\n\n    def stop(self):\n        pass\n\n    def test(self) -> Optional[Tuple[bool, str]]:\n        \"\"\"\n        测试模块连接性\n        \"\"\"\n        if not self.get_instances():\n            return None\n        for name, server in self.get_instances().items():\n            if server.is_inactive():\n                server.reconnect()\n            if not server.transfer_info():\n                return False, f\"无法连接Transmission下载器：{name}\"\n        return True, \"\"\n\n    def init_setting(self) -> Tuple[str, Union[str, bool]]:\n        pass\n\n    def scheduler_job(self) -> None:\n        \"\"\"\n        定时任务，每10分钟调用一次\n        \"\"\"\n        # 定时重连\n        for name, server in self.get_instances().items():\n            if server.is_inactive():\n                logger.info(f\"Transmission下载器 {name} 连接断开，尝试重连 ...\")\n                server.reconnect()\n\n    def download(self, content: Union[Path, str, bytes], download_dir: Path, cookie: str,\n                 episodes: Set[int] = None, category: Optional[str] = None, label: Optional[str] = None,\n                 downloader: Optional[str] = None) -> Optional[Tuple[Optional[str], Optional[str], Optional[str], str]]:\n        \"\"\"\n        根据种子文件，选择并添加下载任务\n        :param content:  种子文件地址或者磁力链接或种子内容\n        :param download_dir:  下载目录\n        :param cookie:  cookie\n        :param episodes:  需要下载的集数\n        :param category:  分类，TR中未使用\n        :param label:  标签\n        :param downloader:  下载器\n        :return: 下载器名称、种子Hash、种子文件布局、错误原因\n        \"\"\"\n\n        def __get_torrent_info() -> Tuple[Optional[Torrent], Optional[bytes]]:\n            \"\"\"\n            获取种子名称\n            \"\"\"\n            torrent_info, torrent_content = None, None\n            try:\n                if isinstance(content, Path):\n                    if content.exists():\n                        torrent_content = content.read_bytes()\n                    else:\n                        # 读取缓存的种子文件\n                        torrent_content = FileCache().get(content.as_posix(), region=\"torrents\")\n                else:\n                    torrent_content = content\n\n                if torrent_content:\n                    # 检查是否为磁力链接\n                    if StringUtils.is_magnet_link(torrent_content):\n                        return None, torrent_content\n                    else:\n                        torrent_info = Torrent.from_string(torrent_content)\n\n                return torrent_info, torrent_content\n            except Exception as e:\n                logger.error(f\"获取种子名称失败：{e}\")\n                return None, None\n\n        if not content:\n            return None, None, None, \"下载内容为空\"\n\n        # 读取种子的名称\n        torrent_from_file, content = __get_torrent_info()\n        # 检查是否为磁力链接\n        is_magnet = isinstance(content, str) and content.startswith(\"magnet:\") or isinstance(content,\n                                                                                             bytes) and content.startswith(\n            b\"magnet:\")\n        if not torrent_from_file and not is_magnet:\n            return None, None, None, f\"添加种子任务失败：无法读取种子文件\"\n\n        # 获取下载器\n        server: Transmission = self.get_instance(downloader)\n        if not server:\n            return None\n\n        # 如果要选择文件则先暂停\n        is_paused = True if episodes else False\n\n        # 标签\n        if label:\n            labels = label.split(',')\n        elif settings.TORRENT_TAG:\n            labels = settings.TORRENT_TAG.split(',')\n        else:\n            labels = None\n        # 添加任务\n        added_torrent = server.add_torrent(\n            content=content,\n            download_dir=self.normalize_path(download_dir, downloader),\n            is_paused=is_paused,\n            labels=labels,\n            cookie=cookie\n        )\n        # TR 始终使用原始种子布局, 返回\"Original\"\n        torrent_layout = \"Original\"\n\n        if not added_torrent:\n            # 查询所有下载器的种子\n            torrents, error = server.get_torrents()\n            if error:\n                return None, None, None, \"无法连接transmission下载器\"\n            if torrents:\n                try:\n                    for torrent in torrents:\n                        # 名称与大小相等则认为是同一个种子\n                        if torrent.name == getattr(torrent_from_file, 'name', '') and torrent.total_size == getattr(torrent_from_file, 'total_size', 0):\n                            torrent_hash = torrent.hashString\n                            logger.warn(f\"下载器中已存在该种子任务：{torrent_hash} - {torrent.name}\")\n                            # 给种子打上标签\n                            if settings.TORRENT_TAG:\n                                logger.info(f\"给种子 {torrent_hash} 打上标签：{settings.TORRENT_TAG}\")\n                                # 种子标签\n                                labels = [str(tag).strip()\n                                          for tag in torrent.labels] if hasattr(torrent, \"labels\") else []\n                                if \"已整理\" in labels:\n                                    labels.remove(\"已整理\")\n                                    server.set_torrent_tag(ids=torrent_hash, tags=labels)\n                                if settings.TORRENT_TAG and settings.TORRENT_TAG not in labels:\n                                    labels.append(settings.TORRENT_TAG)\n                                    server.set_torrent_tag(ids=torrent_hash, tags=labels)\n                            return downloader or self.get_default_config_name(), torrent_hash, torrent_layout, f\"下载任务已存在\"\n                finally:\n                    torrents.clear()\n                    del torrents\n            return None, None, None, f\"添加种子任务失败：{content}\"\n        else:\n            torrent_hash = added_torrent.hashString\n            if is_paused:\n                # 选择文件\n                torrent_files = server.get_files(torrent_hash)\n                if not torrent_files:\n                    return downloader or self.get_default_config_name(), torrent_hash, torrent_layout, \"获取种子文件失败，下载任务可能在暂停状态\"\n                # 需要的文件信息\n                file_ids = []\n                unwanted_file_ids = []\n                try:\n                    for torrent_file in torrent_files:\n                        file_id = torrent_file.id\n                        file_name = torrent_file.name\n                        meta_info = MetaInfo(file_name)\n                        if not meta_info.episode_list:\n                            unwanted_file_ids.append(file_id)\n                            continue\n                        selected = set(meta_info.episode_list).issubset(set(episodes))\n                        if not selected:\n                            unwanted_file_ids.append(file_id)\n                            continue\n                        file_ids.append(file_id)\n                    # 选择文件\n                    server.set_files(torrent_hash, file_ids)\n                    server.set_unwanted_files(torrent_hash, unwanted_file_ids)\n                    # 开始任务\n                    server.start_torrents(torrent_hash)\n                finally:\n                    torrent_files.clear()\n                    del torrent_files\n                return downloader or self.get_default_config_name(), torrent_hash, torrent_layout, \"添加下载任务成功\"\n            else:\n                return downloader or self.get_default_config_name(), torrent_hash, torrent_layout, \"添加下载任务成功\"\n\n    def list_torrents(self, status: TorrentStatus = None,\n                      hashs: Union[list, str] = None,\n                      downloader: Optional[str] = None\n                      ) -> Optional[List[Union[TransferTorrent, DownloadingTorrent]]]:\n        \"\"\"\n        获取下载器种子列表\n        :param status:  种子状态\n        :param hashs:  种子Hash\n        :param downloader:  下载器\n        :return: 下载器中符合状态的种子列表\n        \"\"\"\n        # 获取下载器\n        if downloader:\n            server: Transmission = self.get_instance(downloader)\n            if not server:\n                return None\n            servers = {downloader: server}\n        else:\n            servers: Dict[str, Transmission] = self.get_instances()\n        ret_torrents = []\n        if hashs:\n            # 按Hash获取\n            for name, server in servers.items():\n                torrents, _ = server.get_torrents(ids=hashs, tags=settings.TORRENT_TAG) or []\n                try:\n                    for torrent in torrents:\n                        ret_torrents.append(TransferTorrent(\n                            downloader=name,\n                            title=torrent.name,\n                            path=Path(torrent.download_dir) / torrent.name,\n                            hash=torrent.hashString,\n                            size=torrent.total_size,\n                            tags=\",\".join(torrent.labels or []),\n                            progress=torrent.progress\n                        ))\n                finally:\n                    torrents.clear()\n                    del torrents\n        elif status == TorrentStatus.TRANSFER:\n            # 获取已完成且未整理的\n            for name, server in servers.items():\n                torrents = server.get_completed_torrents(tags=settings.TORRENT_TAG) or []\n                try:\n                    for torrent in torrents:\n                        # 含\"已整理\"tag的不处理\n                        if \"已整理\" in torrent.labels or []:\n                            continue\n                        # 下载路径\n                        path = torrent.download_dir\n                        # 无法获取下载路径的不处理\n                        if not path:\n                            logger.debug(f\"未获取到 {torrent.name} 下载保存路径\")\n                            continue\n                        ret_torrents.append(TransferTorrent(\n                            downloader=name,\n                            title=torrent.name,\n                            path=Path(torrent.download_dir) / torrent.name,\n                            hash=torrent.hashString,\n                            tags=\",\".join(torrent.labels or []),\n                            progress=torrent.progress,\n                            state=\"paused\" if torrent.status == \"stopped\" else \"downloading\",\n                        ))\n                finally:\n                    torrents.clear()\n                    del torrents\n        elif status == TorrentStatus.DOWNLOADING:\n            # 获取正在下载的任务\n            for name, server in servers.items():\n                torrents = server.get_downloading_torrents(tags=settings.TORRENT_TAG) or []\n                try:\n                    for torrent in torrents:\n                        meta = MetaInfo(torrent.name)\n                        dlspeed = torrent.rate_download if hasattr(torrent, \"rate_download\") else torrent.rateDownload\n                        upspeed = torrent.rate_upload if hasattr(torrent, \"rate_upload\") else torrent.rateUpload\n                        ret_torrents.append(DownloadingTorrent(\n                            downloader=name,\n                            hash=torrent.hashString,\n                            title=torrent.name,\n                            name=meta.name,\n                            year=meta.year,\n                            season_episode=meta.season_episode,\n                            progress=torrent.progress,\n                            size=torrent.total_size,\n                            state=\"paused\" if torrent.status == \"stopped\" else \"downloading\",\n                            dlspeed=StringUtils.str_filesize(dlspeed),\n                            upspeed=StringUtils.str_filesize(upspeed),\n                            left_time=StringUtils.str_secends(torrent.left_until_done / dlspeed) if dlspeed > 0 else ''\n                        ))\n                finally:\n                    torrents.clear()\n                    del torrents\n        else:\n            return None\n        return ret_torrents  # noqa\n\n    def transfer_completed(self, hashs: str, downloader: Optional[str] = None) -> None:\n        \"\"\"\n        转移完成后的处理\n        :param hashs:  种子Hash\n        :param downloader:  下载器\n        \"\"\"\n        # 获取下载器\n        server: Transmission = self.get_instance(downloader)\n        if not server:\n            return None\n        # 获取原标签\n        org_tags = server.get_torrent_tags(ids=hashs)\n        # 种子打上已整理标签\n        if org_tags:\n            tags = org_tags + ['已整理']\n        else:\n            tags = ['已整理']\n        server.set_torrent_tag(ids=hashs, tags=tags)\n        return None\n\n    def remove_torrents(self, hashs: Union[str, list], delete_file: Optional[bool] = True,\n                        downloader: Optional[str] = None) -> Optional[bool]:\n        \"\"\"\n        删除下载器种子\n        :param hashs:  种子Hash\n        :param delete_file:  是否删除文件\n        :param downloader:  下载器\n        :return: bool\n        \"\"\"\n        # 获取下载器\n        server: Transmission = self.get_instance(downloader)\n        if not server:\n            return None\n        return server.delete_torrents(delete_file=delete_file, ids=hashs)\n\n    def start_torrents(self, hashs: Union[list, str],\n                       downloader: Optional[str] = None) -> Optional[bool]:\n        \"\"\"\n        开始下载\n        :param hashs:  种子Hash\n        :param downloader:  下载器\n        :return: bool\n        \"\"\"\n        # 获取下载器\n        server: Transmission = self.get_instance(downloader)\n        if not server:\n            return None\n        return server.start_torrents(ids=hashs)\n\n    def stop_torrents(self, hashs: Union[list, str],\n                      downloader: Optional[str] = None) -> Optional[bool]:\n        \"\"\"\n        停止下载\n        :param hashs:  种子Hash\n        :param downloader:  下载器\n        :return: bool\n        \"\"\"\n        # 获取下载器\n        server: Transmission = self.get_instance(downloader)\n        if not server:\n            return None\n        return server.stop_torrents(ids=hashs)\n\n    def torrent_files(self, tid: str, downloader: Optional[str] = None) -> Optional[List[File]]:\n        \"\"\"\n        获取种子文件列表\n        \"\"\"\n        # 获取下载器\n        server: Transmission = self.get_instance(downloader)\n        if not server:\n            return None\n        return server.get_files(tid=tid)\n\n    def downloader_info(self, downloader: Optional[str] = None) -> Optional[List[schemas.DownloaderInfo]]:\n        \"\"\"\n        下载器信息\n        \"\"\"\n        if downloader:\n            server: Transmission = self.get_instance(downloader)\n            if not server:\n                return None\n            servers = [server]\n        else:\n            servers = self.get_instances().values()\n        # 调用Qbittorrent API查询实时信息\n        ret_info = []\n        for server in servers:\n            info = server.transfer_info()\n            if not info:\n                continue\n            ret_info.append(schemas.DownloaderInfo(\n                download_speed=info.download_speed,\n                upload_speed=info.upload_speed,\n                download_size=info.current_stats.downloaded_bytes,\n                upload_size=info.current_stats.uploaded_bytes\n            ))\n        return ret_info\n"
  },
  {
    "path": "app/modules/transmission/transmission.py",
    "content": "from typing import Optional, Union, Tuple, List\n\nimport transmission_rpc\nfrom transmission_rpc import Client, Torrent, File\nfrom transmission_rpc.session import SessionStats, Session\n\nfrom app.log import logger\nfrom app.utils.url import UrlUtils\n\n\nclass Transmission:\n    \"\"\"\n    Transmission下载器\n    \"\"\"\n    # 参考transmission web，仅查询需要的参数，加速种子搜索\n    _trarg = [\"id\", \"name\", \"status\", \"labels\", \"hashString\", \"totalSize\", \"percentDone\", \"addedDate\", \"trackerList\",\n              \"trackerStats\",\n              \"leftUntilDone\", \"rateDownload\", \"rateUpload\", \"recheckProgress\", \"rateDownload\", \"rateUpload\",\n              \"peersGettingFromUs\", \"peersSendingToUs\", \"uploadRatio\", \"uploadedEver\", \"downloadedEver\", \"downloadDir\",\n              \"error\", \"errorString\", \"doneDate\", \"queuePosition\", \"activityDate\", \"trackers\"]\n\n    def __init__(self, host: Optional[str] = None, port: Optional[int] = None,\n                 username: Optional[str] = None, password: Optional[str] = None, **kwargs):\n        \"\"\"\n        若不设置参数，则创建配置文件设置的下载器\n        \"\"\"\n        self.trc = None\n        if host and port:\n            self._protocol, self._host, self._port = kwargs.get(\"protocol\", \"http\"), host, port\n        elif host:\n            result = UrlUtils.parse_url_params(url=host)\n            if result:\n                self._protocol, self._host, self._port, path = result\n            else:\n                logger.error(\"Transmission配置不正确！\")\n                return\n        else:\n            logger.error(\"Transmission配置不完整！\")\n            return\n        self._username = username\n        self._password = password\n        self.trc = self.__login_transmission()\n\n    def __login_transmission(self) -> Optional[Client]:\n        \"\"\"\n        连接transmission\n        :return: transmission对象\n        \"\"\"\n        if not self._host or not self._port:\n            return None\n        try:\n            # 登录\n            logger.info(f\"正在连接 transmission：{self._protocol}://{self._host}:{self._port}\")\n            trt = transmission_rpc.Client(protocol=self._protocol, # noqa\n                                          host=self._host,\n                                          port=self._port,\n                                          username=self._username,\n                                          password=self._password,\n                                          timeout=60)\n            return trt\n        except Exception as err:\n            logger.error(f\"transmission 连接出错：{str(err)}\")\n            return None\n\n    def is_inactive(self) -> bool:\n        \"\"\"\n        判断是否需要重连\n        \"\"\"\n        if not self._host or not self._port:\n            return False\n        return True if not self.trc else False\n\n    def reconnect(self):\n        \"\"\"\n        重连\n        \"\"\"\n        self.trc = self.__login_transmission()\n\n    def get_torrents(self, ids: Union[str, list] = None, status: Union[str, list] = None,\n                     tags: Union[str, list] = None) -> Tuple[List[Torrent], bool]:\n        \"\"\"\n        获取种子列表\n        返回结果 种子列表, 是否有错误\n        \"\"\"\n        if not self.trc:\n            return [], True\n        try:\n            torrents = self.trc.get_torrents(ids=ids, arguments=self._trarg)\n        except Exception as err:\n            logger.error(f\"获取种子列表出错：{str(err)}\")\n            return [], True\n        if status and not isinstance(status, list):\n            status = [status]\n        if tags and not isinstance(tags, list):\n            tags = tags.split(',')\n        ret_torrents = []\n        try:\n            for torrent in torrents:\n                # 状态过滤\n                if status and torrent.status not in status:\n                    continue\n                # 种子标签\n                labels = [str(tag).strip()\n                          for tag in torrent.labels] if hasattr(torrent, \"labels\") else []\n                if tags and not set(tags).issubset(set(labels)):\n                    continue\n                ret_torrents.append(torrent)\n        finally:\n            torrents.clear()\n            del torrents\n        return ret_torrents, False\n\n    def get_completed_torrents(self, ids: Union[str, list] = None,\n                               tags: Union[str, list] = None) -> Optional[List[Torrent]]:\n        \"\"\"\n        获取已完成的种子列表\n        return 种子列表, 发生错误时返回None\n        \"\"\"\n        if not self.trc:\n            return None\n        try:\n            torrents, error = self.get_torrents(status=[\"seeding\", \"seed_pending\"], ids=ids, tags=tags)\n            return None if error else torrents or []\n        except Exception as err:\n            logger.error(f\"获取已完成的种子列表出错：{str(err)}\")\n            return None\n\n    def get_downloading_torrents(self, ids: Union[str, list] = None,\n                                 tags: Union[str, list] = None) -> Optional[List[Torrent]]:\n        \"\"\"\n        获取正在下载的种子列表\n        return 种子列表, 发生错误时返回None\n        \"\"\"\n        if not self.trc:\n            return None\n        try:\n            torrents, error = self.get_torrents(ids=ids,\n                                                status=[\"downloading\", \"download_pending\"],\n                                                tags=tags)\n            return None if error else torrents or []\n        except Exception as err:\n            logger.error(f\"获取正在下载的种子列表出错：{str(err)}\")\n            return None\n\n    def set_torrent_tag(self, ids: str, tags: list, org_tags: list = None) -> bool:\n        \"\"\"\n        设置种子标签，注意TR默认会覆盖原有标签，如需追加需传入原有标签\n        \"\"\"\n        if not self.trc:\n            return False\n        if not ids or not tags:\n            return False\n        try:\n            self.trc.change_torrent(labels=list(set((org_tags or []) + tags)), ids=ids)\n            return True\n        except Exception as err:\n            logger.error(f\"设置种子标签出错：{str(err)}\")\n            return False\n\n    def get_torrent_tags(self, ids: str) -> List[str]:\n        \"\"\"\n        获取所有种子标签\n        \"\"\"\n        if not self.trc:\n            return []\n        try:\n            torrents = self.trc.get_torrents(ids=ids, arguments=self._trarg)\n            if len(torrents):\n                torrent = torrents[0]\n                labels = [str(tag).strip()\n                          for tag in torrent.labels] if hasattr(torrent, \"labels\") else []\n                return labels\n        except Exception as err:\n            logger.error(f\"获取种子标签出错：{str(err)}\")\n            return []\n        return []\n\n    def add_torrent(self, content: Union[str, bytes],\n                    is_paused: Optional[bool] = False,\n                    download_dir: Optional[str] = None,\n                    labels=None,\n                    cookie=None) -> Optional[Torrent]:\n        \"\"\"\n        添加下载任务\n        :param content: 种子urls或文件内容\n        :param is_paused: 添加后暂停\n        :param download_dir: 下载路径\n        :param labels: 标签\n        :param cookie: 站点Cookie用于辅助下载种子\n        :return: Torrent\n        \"\"\"\n        if not self.trc:\n            return None\n        try:\n            return self.trc.add_torrent(torrent=content,\n                                        download_dir=download_dir,\n                                        paused=is_paused,\n                                        labels=labels,\n                                        cookies=cookie)\n        except Exception as err:\n            logger.error(f\"添加种子出错：{str(err)}\")\n            return None\n\n    def start_torrents(self, ids: Union[str, list]) -> bool:\n        \"\"\"\n        启动种子\n        \"\"\"\n        if not self.trc:\n            return False\n        try:\n            self.trc.start_torrent(ids=ids)\n            return True\n        except Exception as err:\n            logger.error(f\"启动种子出错：{str(err)}\")\n            return False\n\n    def stop_torrents(self, ids: Union[str, list]) -> bool:\n        \"\"\"\n        停止种子\n        \"\"\"\n        if not self.trc:\n            return False\n        try:\n            self.trc.stop_torrent(ids=ids)\n            return True\n        except Exception as err:\n            logger.error(f\"停止种子出错：{str(err)}\")\n            return False\n\n    def delete_torrents(self, delete_file: bool, ids: Union[str, list]) -> bool:\n        \"\"\"\n        删除种子\n        \"\"\"\n        if not self.trc:\n            return False\n        if not ids:\n            return False\n        try:\n            self.trc.remove_torrent(delete_data=delete_file, ids=ids)\n            return True\n        except Exception as err:\n            logger.error(f\"删除种子出错：{str(err)}\")\n            return False\n\n    def get_files(self, tid: str) -> Optional[List[File]]:\n        \"\"\"\n        获取种子文件列表\n        \"\"\"\n        if not self.trc:\n            return None\n        if not tid:\n            return None\n        try:\n            torrent = self.trc.get_torrent(tid)\n        except Exception as err:\n            logger.error(f\"获取种子文件列表出错：{str(err)}\")\n            return None\n        if torrent:\n            return torrent.files()\n        else:\n            return None\n\n    def set_files(self, tid: str, file_ids: list) -> bool:\n        \"\"\"\n        设置下载文件的状态\n        \"\"\"\n        if not self.trc:\n            return False\n        try:\n            self.trc.change_torrent(ids=tid, files_wanted=file_ids)\n            return True\n        except Exception as err:\n            logger.error(f\"设置下载文件状态出错：{str(err)}\")\n            return False\n\n    def set_unwanted_files(self, tid: str, file_ids: list) -> bool:\n        \"\"\"\n        设置下载文件的状态\n        \"\"\"\n        if not self.trc:\n            return False\n        try:\n            self.trc.change_torrent(ids=tid, files_unwanted=file_ids)\n            return True\n        except Exception as err:\n            logger.error(f\"设置下载文件状态出错：{str(err)}\")\n            return False\n\n    def transfer_info(self) -> Optional[SessionStats]:\n        \"\"\"\n        获取传输信息\n        \"\"\"\n        if not self.trc:\n            return None\n        try:\n            return self.trc.session_stats()\n        except Exception as err:\n            logger.error(f\"获取传输信息出错：{str(err)}\")\n            return None\n\n    def set_speed_limit(self, download_limit: float = None, upload_limit: float = None) -> bool:\n        \"\"\"\n        设置速度限制\n        :param download_limit: 下载速度限制，单位KB/s\n        :param upload_limit: 上传速度限制，单位kB/s\n        \"\"\"\n        if not self.trc:\n            return False\n        try:\n            download_limit_enabled = True if download_limit else False\n            upload_limit_enabled = True if upload_limit else False\n            self.trc.set_session(\n                speed_limit_down=int(download_limit),\n                speed_limit_up=int(upload_limit),\n                speed_limit_down_enabled=download_limit_enabled,\n                speed_limit_up_enabled=upload_limit_enabled\n            )\n            return True\n        except Exception as err:\n            logger.error(f\"设置速度限制出错：{str(err)}\")\n            return False\n\n    def get_speed_limit(self) -> Optional[Tuple[float, float]]:\n        \"\"\"\n        获取TR速度\n        :return: download_limit 下载速度 默认是0\n                 upload_limit 上传速度   默认是0\n        \"\"\"\n        if not self.trc:\n            return None\n\n        download_limit = 0\n        upload_limit = 0\n        try:\n            download_limit = self.trc.get_session().get('speed_limit_down')\n            upload_limit = self.trc.get_session().get('speed_limit_up')\n\n        except Exception as err:\n            logger.error(f\"获取速度限制出错：{str(err)}\")\n\n        return (\n            download_limit,\n            upload_limit\n        )\n\n    def recheck_torrents(self, ids: Union[str, list]) -> bool:\n        \"\"\"\n        重新校验种子\n        \"\"\"\n        if not self.trc:\n            return False\n        try:\n            self.trc.verify_torrent(ids=ids)\n            return True\n        except Exception as err:\n            logger.error(f\"重新校验种子出错：{str(err)}\")\n            return False\n\n    def change_torrent(self,\n                       hash_string: str,\n                       upload_limit=None,\n                       download_limit=None,\n                       ratio_limit=None,\n                       seeding_time_limit=None) -> bool:\n        \"\"\"\n        设置种子\n        :param hash_string: ID\n        :param upload_limit: 上传限速 Kb/s\n        :param download_limit: 下载限速 Kb/s\n        :param ratio_limit: 分享率限制\n        :param seeding_time_limit: 做种时间限制\n        :return: bool\n        \"\"\"\n        if not hash_string:\n            return False\n        if upload_limit:\n            uploadLimited = True\n            uploadLimit = int(upload_limit)\n        else:\n            uploadLimited = False\n            uploadLimit = 0\n        if download_limit:\n            downloadLimited = True\n            downloadLimit = int(download_limit)\n        else:\n            downloadLimited = False\n            downloadLimit = 0\n        if ratio_limit:\n            seedRatioMode = 1\n            seedRatioLimit = round(float(ratio_limit), 2)\n        else:\n            seedRatioMode = 2\n            seedRatioLimit = 0\n        if seeding_time_limit:\n            seedIdleMode = 1\n            seedIdleLimit = int(seeding_time_limit)\n        else:\n            seedIdleMode = 2\n            seedIdleLimit = 0\n        try:\n            self.trc.change_torrent(ids=hash_string,\n                                    uploadLimited=uploadLimited,\n                                    uploadLimit=uploadLimit,\n                                    downloadLimited=downloadLimited,\n                                    downloadLimit=downloadLimit,\n                                    seedRatioMode=seedRatioMode,\n                                    seedRatioLimit=seedRatioLimit,\n                                    seedIdleMode=seedIdleMode,\n                                    seedIdleLimit=seedIdleLimit)\n            return True\n        except Exception as err:\n            logger.error(f\"设置种子出错：{str(err)}\")\n            return False\n\n    def update_tracker(self, hash_string: str, tracker_list: list = None) -> bool:\n        \"\"\"\n        tr4.0及以上弃用直接设置tracker 共用change方法\n        https://github.com/trim21/transmission-rpc/blob/8eb82629492a0eeb0bb565f82c872bf9ccdcb313/transmission_rpc/client.py#L654\n        \"\"\"\n        if not self.trc:\n            return False\n        try:\n            self.trc.change_torrent(ids=hash_string,\n                                    tracker_list=tracker_list)\n            return True\n        except Exception as err:\n            logger.error(f\"修改tracker出错：{str(err)}\")\n            return False\n\n    def get_session(self) -> Optional[Session]:\n        \"\"\"\n        获取Transmission当前的会话信息和配置设置\n        :return dict\n        \"\"\"\n        if not self.trc:\n            return None\n        try:\n            return self.trc.get_session()\n        except Exception as err:\n            logger.error(f\"获取session出错：{str(err)}\")\n            return None\n"
  },
  {
    "path": "app/modules/trimemedia/__init__.py",
    "content": "from typing import Any, Generator, List, Optional, Tuple, Union\n\nfrom app import schemas\nfrom app.core.context import MediaInfo\nfrom app.core.event import eventmanager\nfrom app.log import logger\nfrom app.modules import _MediaServerBase, _ModuleBase\nfrom app.modules.trimemedia.trimemedia import TrimeMedia\nfrom app.schemas import AuthCredentials, AuthInterceptCredentials\nfrom app.schemas.types import ChainEventType, MediaServerType, MediaType, ModuleType\n\n\nclass TrimeMediaModule(_ModuleBase, _MediaServerBase[TrimeMedia]):\n\n    def init_module(self) -> None:\n        \"\"\"\n        初始化模块\n        \"\"\"\n        super().init_service(\n            service_name=TrimeMedia.__name__.lower(),\n            service_type=lambda conf: TrimeMedia(\n                **conf.config, sync_libraries=conf.sync_libraries\n            ),\n        )\n\n    @staticmethod\n    def get_name() -> str:\n        return \"飞牛影视\"\n\n    @staticmethod\n    def get_type() -> ModuleType:\n        \"\"\"\n        获取模块类型\n        \"\"\"\n        return ModuleType.MediaServer\n\n    @staticmethod\n    def get_subtype() -> MediaServerType:\n        \"\"\"\n        获取模块子类型\n        \"\"\"\n        return MediaServerType.TrimeMedia\n\n    @staticmethod\n    def get_priority() -> int:\n        \"\"\"\n        获取模块优先级，数字越小优先级越高，只有同一接口下优先级才生效\n        \"\"\"\n        return 4\n\n    def init_setting(self) -> Tuple[str, Union[str, bool]]:\n        pass\n\n    def scheduler_job(self) -> None:\n        \"\"\"\n        定时任务，每10分钟调用一次\n        \"\"\"\n        # 定时重连\n        for name, server in self.get_instances().items():\n            if server.is_configured() and server.is_inactive():\n                logger.info(f\"飞牛影视 {name} 连接断开，尝试重连 ...\")\n                server.reconnect()\n\n    def stop(self):\n        for server in self.get_instances().values():\n            if server.is_authenticated():\n                server.disconnect()\n\n    def test(self) -> Optional[Tuple[bool, str]]:\n        \"\"\"\n        测试模块连接性\n        \"\"\"\n        if not self.get_instances():\n            return None\n        for name, server in self.get_instances().items():\n            if not server.is_configured():\n                return False, f\"飞牛影视配置不完整：{name}\"\n            if server.is_inactive() and not server.reconnect():\n                return False, f\"无法连接飞牛影视：{name}\"\n        return True, \"\"\n\n    def user_authenticate(\n        self, credentials: AuthCredentials, service_name: Optional[str] = None\n    ) -> Optional[AuthCredentials]:\n        \"\"\"\n        使用飞牛影视用户辅助完成用户认证\n\n        :param credentials: 认证数据\n        :param service_name: 指定要认证的媒体服务器名称，若为 None 则认证所有服务\n        :return: 认证数据\n        \"\"\"\n        # 飞牛影视认证\n        if not credentials or credentials.grant_type != \"password\":\n            return None\n        # 确定要认证的服务器列表\n        if service_name:\n            # 如果指定了服务名，获取该服务实例\n            servers = (\n                [(service_name, server)]\n                if (server := self.get_instance(service_name))\n                else []\n            )\n        else:\n            # 如果没有指定服务名，遍历所有服务\n            servers = self.get_instances().items()\n        # 遍历要认证的服务器\n        for name, server in servers:\n            # 触发认证拦截事件\n            intercept_event = eventmanager.send_event(\n                etype=ChainEventType.AuthIntercept,\n                data=AuthInterceptCredentials(\n                    username=credentials.username,\n                    channel=self.get_name(),\n                    service=name,\n                    status=\"triggered\",\n                ),\n            )\n            if intercept_event and intercept_event.event_data:\n                intercept_data: AuthInterceptCredentials = intercept_event.event_data\n                if intercept_data.cancel:\n                    continue\n            token = server.authenticate(credentials.username, credentials.password)\n            if token:\n                credentials.channel = self.get_name()\n                credentials.service = name\n                credentials.token = token\n                return credentials\n        return None\n\n    def webhook_parser(\n        self, body: Any, form: Any, args: Any\n    ) -> Optional[schemas.WebhookEventInfo]:\n        \"\"\"\n        解析Webhook报文体\n\n        :param body:  请求体\n        :param form:  请求表单\n        :param args:  请求参数\n        :return: 字典，解析为消息时需要包含：title、text、image\n        \"\"\"\n        source = args.get(\"source\")\n        if source:\n            server: Optional[TrimeMedia] = self.get_instance(source)\n            if not server:\n                return None\n            result = server.get_webhook_message(body)\n            if result:\n                result.server_name = source\n            return result\n\n        for server in self.get_instances().values():\n            if server:\n                result = server.get_webhook_message(body)\n                if result:\n                    return result\n        return None\n\n    def media_exists(\n        self,\n        mediainfo: MediaInfo,\n        itemid: Optional[str] = None,\n        server: Optional[str] = None,\n    ) -> Optional[schemas.ExistMediaInfo]:\n        \"\"\"\n        判断媒体文件是否存在\n\n        :param mediainfo:  识别的媒体信息\n        :param itemid:  媒体服务器ItemID\n        :param server:  媒体服务器名称\n        :return: 如不存在返回None，存在时返回信息，包括每季已存在所有集{type: movie/tv, seasons: {season: [episodes]}}\n        \"\"\"\n        if server:\n            servers = [(server, self.get_instance(server))]\n        else:\n            servers = self.get_instances().items()\n        for name, s in servers:\n            if not s:\n                continue\n            if mediainfo.type == MediaType.MOVIE:\n                if itemid:\n                    movie = s.get_iteminfo(itemid)\n                    if movie:\n                        logger.info(f\"媒体库 {name} 中找到了 {movie}\")\n                        return schemas.ExistMediaInfo(\n                            type=MediaType.MOVIE,\n                            server_type=\"trimemedia\",\n                            server=name,\n                            itemid=movie.item_id,\n                        )\n                movies = s.get_movies(\n                    title=mediainfo.title,\n                    year=mediainfo.year,\n                    tmdb_id=mediainfo.tmdb_id,\n                )\n                if not movies:\n                    logger.info(f\"{mediainfo.title_year} 没有在媒体库 {name} 中\")\n                    continue\n                else:\n                    logger.info(f\"媒体库 {name} 中找到了 {movies}\")\n                    return schemas.ExistMediaInfo(\n                        type=MediaType.MOVIE,\n                        server_type=\"trimemedia\",\n                        server=name,\n                        itemid=movies[0].item_id,\n                    )\n            else:\n                itemid, tvs = s.get_tv_episodes(\n                    title=mediainfo.title,\n                    year=mediainfo.year,\n                    tmdb_id=mediainfo.tmdb_id,\n                    item_id=itemid,\n                )\n                if not tvs:\n                    logger.info(f\"{mediainfo.title_year} 没有在媒体库 {name} 中\")\n                    continue\n                else:\n                    logger.info(\n                        f\"{mediainfo.title_year} 在媒体库 {name} 中找到了这些季集：{tvs}\"\n                    )\n                    return schemas.ExistMediaInfo(\n                        type=MediaType.TV,\n                        seasons=tvs,\n                        server_type=\"trimemedia\",\n                        server=name,\n                        itemid=itemid,\n                    )\n        return None\n\n    def media_statistic(\n        self, server: Optional[str] = None\n    ) -> Optional[List[schemas.Statistic]]:\n        \"\"\"\n        媒体数量统计\n        \"\"\"\n        if server:\n            server_obj: Optional[TrimeMedia] = self.get_instance(server)\n            if not server_obj:\n                return None\n            servers = [server_obj]\n        else:\n            servers = self.get_instances().values()\n        media_statistics = []\n        for s in servers:\n            media_statistic = s.get_medias_count()\n            if not media_statistic:\n                continue\n            media_statistic.user_count = s.get_user_count()\n            media_statistics.append(media_statistic)\n        return media_statistics\n\n    def mediaserver_librarys(\n        self, server: Optional[str] = None, hidden: Optional[bool] = False, **kwargs\n    ) -> Optional[List[schemas.MediaServerLibrary]]:\n        \"\"\"\n        媒体库列表\n        \"\"\"\n        server_obj: Optional[TrimeMedia] = self.get_instance(server)\n        if server_obj:\n            return server_obj.get_librarys(hidden=hidden)\n        return None\n\n    def mediaserver_items(\n        self,\n        server: str,\n        library_id: Union[str, int],\n        start_index: Optional[int] = 0,\n        limit: Optional[int] = -1,\n    ) -> Optional[Generator]:\n        \"\"\"\n        获取媒体服务器项目列表，支持分页和不分页逻辑，默认不分页获取所有数据\n\n        :param server: 媒体服务器名称\n        :param library_id: 媒体库ID，用于标识要获取的媒体库\n        :param start_index: 起始索引，用于分页获取数据。默认为 0，即从第一个项目开始获取\n        :param limit: 每次请求的最大项目数，用于分页。如果为 None 或 -1，则表示一次性获取所有数据，默认为 -1\n\n        :return: 返回一个生成器对象，用于逐步获取媒体服务器中的项目\n        \"\"\"\n        server_obj: Optional[TrimeMedia] = self.get_instance(server)\n        if server_obj:\n            return server_obj.get_items(library_id, start_index, limit)\n        return None\n\n    def mediaserver_iteminfo(\n        self, server: str, item_id: str\n    ) -> Optional[schemas.MediaServerItem]:\n        \"\"\"\n        媒体库项目详情\n        \"\"\"\n        server_obj: Optional[TrimeMedia] = self.get_instance(server)\n        if server_obj:\n            return server_obj.get_iteminfo(item_id)\n        return None\n\n    def mediaserver_tv_episodes(\n        self, server: str, item_id: Union[str, int]\n    ) -> Optional[List[schemas.MediaServerSeasonInfo]]:\n        \"\"\"\n        获取剧集信息\n        \"\"\"\n        if not isinstance(item_id, str):\n            return None\n        server_obj: Optional[TrimeMedia] = self.get_instance(server)\n        if not server_obj:\n            return None\n        _, seasoninfo = server_obj.get_tv_episodes(item_id=item_id)\n        if not seasoninfo:\n            return []\n        return [\n            schemas.MediaServerSeasonInfo(season=season, episodes=episodes)\n            for season, episodes in seasoninfo.items()\n        ]\n\n    def mediaserver_playing(\n        self, server: str, count: Optional[int] = 20, **kwargs\n    ) -> List[schemas.MediaServerPlayItem]:\n        \"\"\"\n        获取媒体服务器正在播放信息\n        \"\"\"\n        server_obj: Optional[TrimeMedia] = self.get_instance(server)\n        if not server_obj:\n            return []\n        return server_obj.get_resume(num=count) or []\n\n    def mediaserver_play_url(\n        self, server: str, item_id: Union[str, int]\n    ) -> Optional[str]:\n        \"\"\"\n        获取媒体库播放地址\n        \"\"\"\n        if not isinstance(item_id, str):\n            return None\n        server_obj: Optional[TrimeMedia] = self.get_instance(server)\n        if not server_obj:\n            return None\n        return server_obj.get_play_url(item_id)\n\n    def mediaserver_latest(\n        self,\n        server: Optional[str] = None,\n        count: Optional[int] = 20,\n        **kwargs,\n    ) -> List[schemas.MediaServerPlayItem]:\n        \"\"\"\n        获取媒体服务器最新入库条目\n        \"\"\"\n        server_obj: Optional[TrimeMedia] = self.get_instance(server)\n        if not server_obj:\n            return []\n        return server_obj.get_latest(num=count) or []\n\n    def mediaserver_latest_images(\n        self,\n        server: Optional[str] = None,\n        count: Optional[int] = 20,\n        remote: Optional[bool] = False,\n        **kwargs,\n    ) -> List[str]:\n        \"\"\"\n        获取媒体服务器最新入库条目的图片\n\n        :param server: 媒体服务器名称\n        :param count: 获取数量\n        :param remote: True为外网链接, False为内网链接\n        :return: 图片链接列表\n        \"\"\"\n        server_obj: Optional[TrimeMedia] = self.get_instance(server)\n        if not server_obj:\n            return []\n        return server_obj.get_latest_backdrops(num=count, remote=remote) or []\n\n    def mediaserver_image_cookies(\n        self,\n        server: Optional[str] = None,\n        image_url: Optional[str] = None,\n        **kwargs,\n    ) -> Optional[str | dict]:\n        \"\"\"\n        获取飞牛影视服务器的图片Cookies\n\n        :param server: 媒体服务器名称\n        :param image_url: 图片网址\n        \"\"\"\n        if not image_url:\n            return None\n        if server:\n            server_obj = self.get_instance(server)\n            if not server_obj:\n                return None\n            return server_obj.get_image_cookies(image_url)\n        else:\n            for server_obj in self.get_instances().values():\n                if cookies := server_obj.get_image_cookies(image_url):\n                    return cookies\n"
  },
  {
    "path": "app/modules/trimemedia/api.py",
    "content": "import hashlib\r\nimport json\r\nimport random\r\nimport time\r\nfrom dataclasses import dataclass\r\nfrom enum import Enum\r\nfrom typing import List, Optional, Union\r\n\r\nfrom app.core.config import settings\r\nfrom app.log import logger\r\nfrom app.utils.http import RequestUtils, requests\r\n\r\n\r\n@dataclass\r\nclass User:\r\n    guid: str\r\n    username: str\r\n    is_admin: int = 0\r\n\r\n\r\nclass Category(Enum):\r\n    MOVIE = \"Movie\"\r\n    TV = \"TV\"\r\n    MIX = \"Mix\"\r\n    OTHERS = \"Others\"\r\n\r\n    @classmethod\r\n    def _missing_(cls, value):\r\n        return cls.OTHERS\r\n\r\n\r\nclass Type(Enum):\r\n    MOVIE = \"Movie\"\r\n    TV = \"TV\"\r\n    SEASON = \"Season\"\r\n    EPISODE = \"Episode\"\r\n    VIDEO = \"Video\"\r\n    DIRECTORY = \"Directory\"\r\n\r\n    @classmethod\r\n    def _missing_(cls, value):\r\n        return cls.VIDEO\r\n\r\n\r\n@dataclass\r\nclass MediaDb:\r\n    guid: str\r\n    category: Category\r\n    name: Optional[str] = None\r\n    posters: Optional[list[str]] = None\r\n    dir_list: Optional[list[str]] = None\r\n\r\n\r\n@dataclass\r\nclass MediaDbSummary:\r\n    favorite: int = 0\r\n    movie: int = 0\r\n    tv: int = 0\r\n    video: int = 0\r\n    total: int = 0\r\n\r\n\r\n@dataclass\r\nclass Version:\r\n    # 飞牛影视版本\r\n    frontend: Optional[str] = None\r\n    backend: Optional[str] = None\r\n\r\n\r\n@dataclass\r\nclass Item:\r\n    guid: str\r\n    ancestor_guid: str = \"\"\r\n    type: Optional[Type] = None\r\n    # 当type为Episode时是剧名，parent_title是季名，title作为分集名称\r\n    tv_title: Optional[str] = None\r\n    parent_title: Optional[str] = None\r\n    title: Optional[str] = None\r\n    original_title: Optional[str] = None\r\n    overview: Optional[str] = None\r\n    poster: Optional[str] = None\r\n    backdrops: Optional[str] = None\r\n    posters: Optional[str] = None\r\n    douban_id: Optional[int] = None\r\n    imdb_id: Optional[str] = None\r\n    trim_id: Optional[str] = None\r\n    release_date: Optional[str] = None\r\n    air_date: Optional[str] = None\r\n    vote_average: Optional[str] = None\r\n    season_number: Optional[int] = None\r\n    episode_number: Optional[int] = None\r\n    duration: Optional[int] = None  # 片长(秒)\r\n    ts: Optional[int] = None  # 已播放(秒)\r\n    watched: Optional[int] = None  # 1:已看完\r\n\r\n    @property\r\n    def tmdb_id(self) -> Optional[int]:\r\n        if self.trim_id is None:\r\n            return None\r\n        if self.trim_id.startswith(\"tt\") or self.trim_id.startswith(\"tm\"):\r\n            # 飞牛给tmdbid加了前缀用以区分tv或movie\r\n            return int(self.trim_id[2:])\r\n        return None\r\n\r\n\r\nclass Api:\r\n    __slots__ = (\r\n        \"_host\",\r\n        \"_token\",\r\n        \"_apikey\",\r\n        \"_api_path\",\r\n        \"_request_utils\",\r\n        \"_version\",\r\n        \"_session\",\r\n    )\r\n\r\n    @property\r\n    def token(self) -> Optional[str]:\r\n        return self._token\r\n\r\n    @property\r\n    def host(self) -> str:\r\n        return self._host\r\n\r\n    @property\r\n    def apikey(self) -> str:\r\n        return self._apikey\r\n\r\n    @property\r\n    def version(self) -> Optional[Version]:\r\n        return self._version\r\n\r\n    def __init__(self, host: str, apikey: str):\r\n        \"\"\"\r\n        :param host: 飞牛服务端地址，如http://127.0.0.1:5666/v\r\n        \"\"\"\r\n        self._api_path = \"/api/v1\"\r\n        self._host = host.rstrip(\"/\")\r\n        self._apikey = apikey\r\n        self._token: Optional[str] = None\r\n        self._version: Optional[Version] = None\r\n        self._session = requests.Session()\r\n        self._request_utils = RequestUtils(session=self._session, timeout=10)\r\n\r\n    def sys_version(self) -> Optional[Version]:\r\n        \"\"\"\r\n        飞牛影视版本号\r\n        \"\"\"\r\n        if (res := self.request(\"/sys/version\")) and res.success:\r\n            if res.data:\r\n                self._version = Version(\r\n                    frontend=res.data.get(\"version\"),\r\n                    backend=res.data.get(\"mediasrvVersion\"),\r\n                )\r\n                return self._version\r\n        return None\r\n\r\n    def login(self, username, password) -> Optional[str]:\r\n        \"\"\"\r\n        登录飞牛影视\r\n\r\n        :return: 成功返回token 否则返回None\r\n        \"\"\"\r\n        if (\r\n            res := self.request(\r\n                \"/login\",\r\n                data={\r\n                    \"username\": username,\r\n                    \"password\": password,\r\n                    \"app_name\": \"trimemedia-web\",\r\n                },\r\n            )\r\n        ) and res.success:\r\n            self._token = res.data.get(\"token\")\r\n        return self._token\r\n\r\n    def logout(self) -> bool:\r\n        \"\"\"\r\n        退出账号\r\n        \"\"\"\r\n        if not self._token:\r\n            return True\r\n        if (res := self.request(\"/user/logout\", method=\"post\")) and res.success:\r\n            if res.data:\r\n                self._token = None\r\n                return True\r\n        return False\r\n\r\n    def user_list(self) -> Optional[list[User]]:\r\n        \"\"\"\r\n        用户列表(仅管理员有权访问)\r\n        \"\"\"\r\n        if (res := self.request(\"/manager/user/list\")) and res.success:\r\n            if not res.data:\r\n                return []\r\n            return [\r\n                User(\r\n                    guid=info.get(\"guid\"),\r\n                    username=info.get(\"username\"),\r\n                    is_admin=info.get(\"is_admin\", 0),\r\n                )\r\n                for info in res.data\r\n            ]\r\n        return None\r\n\r\n    def user_info(self) -> Optional[User]:\r\n        \"\"\"\r\n        当前用户信息\r\n        \"\"\"\r\n        if (res := self.request(\"/user/info\")) and res.success:\r\n            _user = User(\"\", \"\")\r\n            _user.__dict__.update(res.data)\r\n            return _user\r\n        return None\r\n\r\n    def mediadb_sum(self) -> Optional[MediaDbSummary]:\r\n        \"\"\"\r\n        媒体数量统计\r\n        \"\"\"\r\n        if (res := self.request(\"/mediadb/sum\")) and res.success:\r\n            sums = MediaDbSummary()\r\n            sums.__dict__.update(res.data)\r\n            return sums\r\n        return None\r\n\r\n    def mediadb_list(self) -> Optional[List[MediaDb]]:\r\n        \"\"\"\r\n        媒体库列表(普通用户)\r\n        \"\"\"\r\n        if (res := self.request(\"/mediadb/list\")) and res.success:\r\n            _items = []\r\n            for info in res.data or []:\r\n                mdb = MediaDb(\r\n                    guid=info.get(\"guid\"),\r\n                    category=Category(info.get(\"category\")),\r\n                    name=info.get(\"title\", \"\"),\r\n                    posters=[\r\n                        self.__build_img_api_url(poster)\r\n                        for poster in info.get(\"posters\", [])\r\n                    ],\r\n                )\r\n                _items.append(mdb)\r\n            return _items\r\n        return None\r\n\r\n    def __build_img_api_url(self, img_path: Optional[str]) -> Optional[str]:\r\n        if not img_path:\r\n            return None\r\n        if img_path[0] != \"/\":\r\n            img_path = \"/\" + img_path\r\n        return f\"{self._api_path}/sys/img{img_path}\"\r\n\r\n    def mdb_list(self) -> Optional[list[MediaDb]]:\r\n        \"\"\"\r\n        媒体库列表(管理员)\r\n        \"\"\"\r\n        if (res := self.request(\"/mdb/list\")) and res.success:\r\n            _items = []\r\n            for info in res.data or []:\r\n                mdb = MediaDb(\r\n                    guid=info.get(\"guid\"),\r\n                    category=Category(info.get(\"category\")),\r\n                    name=info.get(\"name\", \"\"),\r\n                    posters=[\r\n                        self.__build_img_api_url(poster)\r\n                        for poster in info.get(\"posters\", [])\r\n                    ],\r\n                    dir_list=info.get(\"dir_list\"),\r\n                )\r\n                _items.append(mdb)\r\n            return _items\r\n        return None\r\n\r\n    def mdb_scanall(self) -> bool:\r\n        \"\"\"\r\n        扫描所有媒体库\r\n        \"\"\"\r\n        if (res := self.request(\"/mdb/scanall\", method=\"post\")) and res.success:\r\n            if res.data:\r\n                return True\r\n        return False\r\n\r\n    def mdb_scan(self, mdb: MediaDb) -> bool:\r\n        \"\"\"\r\n        扫描指定媒体库\r\n        \"\"\"\r\n        if (res := self.request(f\"/mdb/scan/{mdb.guid}\", data={})) and res.success:\r\n            if res.data:\r\n                return True\r\n        return False\r\n\r\n    def task_running(self):\r\n        \"\"\"\r\n        当前正在运行的任务\r\n        \"\"\"\r\n        if (res := self.request(\"/task/running\")) and res.success:\r\n            if res.data:\r\n                # TODO 具体正在运行的任务\r\n                return True\r\n        return False\r\n\r\n    def __build_item(self, info: dict) -> Item:\r\n        \"\"\"\r\n        构造媒体Item\r\n        \"\"\"\r\n        item = Item(guid=\"\")\r\n        item.__dict__.update(info)\r\n        item.type = Type(info.get(\"type\"))\r\n        # Item详情接口才有posters和backdrops\r\n        item.posters = self.__build_img_api_url(item.posters)\r\n        item.backdrops = self.__build_img_api_url(item.backdrops)\r\n        item.poster = (\r\n            self.__build_img_api_url(item.poster) if item.poster else item.posters\r\n        )\r\n        return item\r\n\r\n    def item_list(\r\n        self,\r\n        guid: Optional[str] = None,\r\n        types=None,\r\n        exclude_grouped_video=True,\r\n        page=1,\r\n        page_size=20,\r\n        sort_by=\"create_time\",\r\n        sort=\"DESC\",\r\n    ) -> Optional[list[Item]]:\r\n        \"\"\"\r\n        媒体列表\r\n        \"\"\"\r\n        if types is None:\r\n            types = [Type.MOVIE, Type.TV, Type.DIRECTORY, Type.VIDEO]\r\n        post = {\r\n            \"tags\": {\"type\": types} if types else {},\r\n            \"sort_type\": sort,\r\n            \"sort_column\": sort_by,\r\n            \"page\": page,\r\n            \"page_size\": page_size,\r\n        }\r\n        if guid:\r\n            post[\"ancestor_guid\"] = guid\r\n        if exclude_grouped_video:\r\n            post[\"exclude_grouped_video\"] = 1\r\n\r\n        if (res := self.request(\"/item/list\", data=post)) and res.success:\r\n            if not res.data:\r\n                return []\r\n            return [self.__build_item(info) for info in res.data.get(\"list\", [])]\r\n        return None\r\n\r\n    def search_list(self, keywords: str) -> Optional[list[Item]]:\r\n        \"\"\"\r\n        搜索影片、演员\r\n        \"\"\"\r\n        if (\r\n            res := self.request(\"/search/list\", params={\"q\": keywords})\r\n        ) and res.success:\r\n            if not res.data:\r\n                return []\r\n            return [self.__build_item(info) for info in res.data]\r\n        return None\r\n\r\n    def item(self, guid: str) -> Optional[Item]:\r\n        \"\"\"\r\n        查询媒体详情\r\n        \"\"\"\r\n        if (res := self.request(f\"/item/{guid}\")) and res.success:\r\n            return self.__build_item(res.data)\r\n        return None\r\n\r\n    def del_item(self, guid: str, delete_file: bool) -> bool:\r\n        \"\"\"\r\n        删除媒体\r\n        :param guid: 媒体GUID\r\n        :param delete_file: True删除媒体文件，False仅从媒体库移除\r\n        \"\"\"\r\n        if (\r\n            res := self.request(\r\n                f\"/item/{guid}\",\r\n                method=\"delete\",\r\n                data={\"delete_file\": 1 if delete_file else 0, \"media_guids\": []},\r\n            )\r\n        ) and res.success:\r\n            if res.data:\r\n                return True\r\n        return False\r\n\r\n    def season_list(self, tv_guid: str) -> Optional[list[Item]]:\r\n        \"\"\"\r\n        查询季列表\r\n        \"\"\"\r\n        if (res := self.request(f\"/season/list/{tv_guid}\")) and res.success:\r\n            if not res.data:\r\n                return []\r\n            return [self.__build_item(info) for info in res.data]\r\n        return None\r\n\r\n    def episode_list(self, season_guid: str) -> Optional[list[Item]]:\r\n        \"\"\"\r\n        查询剧集列表\r\n        \"\"\"\r\n        if (res := self.request(f\"/episode/list/{season_guid}\")) and res.success:\r\n            if not res.data:\r\n                return []\r\n            return [self.__build_item(info) for info in res.data]\r\n        return None\r\n\r\n    def play_list(self) -> Optional[list[Item]]:\r\n        \"\"\"\r\n        继续观看列表\r\n        \"\"\"\r\n        if (res := self.request(\"/play/list\")) and res.success:\r\n            if not res.data:\r\n                return []\r\n            return [self.__build_item(info) for info in res.data]\r\n        return None\r\n\r\n    def __get_authx(self, api_path: str, body: Optional[str]):\r\n        \"\"\"\r\n        计算消息签名\r\n        \"\"\"\r\n        if not api_path.startswith(\"/v\"):\r\n            api_path = \"/v\" + api_path\r\n        nonce = str(random.randint(100000, 999999))\r\n        ts = str(int(time.time() * 1000))\r\n        md5 = hashlib.md5()\r\n        md5.update((body or \"\").encode())\r\n        data_hash = md5.hexdigest()\r\n        md5 = hashlib.md5()\r\n        md5.update(\r\n            \"_\".join(\r\n                [\r\n                    \"NDzZTVxnRKP8Z0jXg1VAMonaG8akvh\",\r\n                    api_path,\r\n                    nonce,\r\n                    ts,\r\n                    data_hash,\r\n                    self._apikey,\r\n                ]\r\n            ).encode()\r\n        )\r\n        sign = md5.hexdigest()\r\n        return f\"nonce={nonce}&timestamp={ts}&sign={sign}\"\r\n\r\n    def request(\r\n        self,\r\n        api: str,\r\n        method: Optional[str] = None,\r\n        params: Optional[dict] = None,\r\n        data: Optional[dict] = None,\r\n        suppress_log=False,\r\n    ):\r\n        \"\"\"\r\n        请求飞牛影视API\r\n\r\n        :param suppress_log: 是否禁止日志\r\n        \"\"\"\r\n\r\n        @dataclass\r\n        class Result:\r\n            @property\r\n            def success(self) -> bool:\r\n                return code == 0\r\n\r\n            code: int\r\n            msg: Optional[str] = None\r\n            data: Optional[Union[dict, list, str, bool]] = None\r\n\r\n        class JsonEncoder(json.JSONEncoder):\r\n            def default(self, obj):\r\n                if isinstance(obj, Type):\r\n                    return obj.value\r\n                return super().default(obj)\r\n\r\n        if not self._host or not api:\r\n            return None\r\n        if not api.startswith(\"/\"):\r\n            api_path = f\"{self._api_path}/{api}\"\r\n        else:\r\n            api_path = self._api_path + api\r\n        url = self._host + api_path\r\n        if method is None:\r\n            method = \"get\" if data is None else \"post\"\r\n        if method != \"get\":\r\n            json_body = (\r\n                json.dumps(data, allow_nan=False, cls=JsonEncoder) if data else \"\"\r\n            )\r\n        else:\r\n            json_body = None\r\n        if params:\r\n            queries_unquoted = \"&\".join([f\"{k}={v}\" for k, v in params.items()])\r\n        else:\r\n            queries_unquoted = None\r\n        headers = {\r\n            \"User-Agent\": settings.USER_AGENT,\r\n            \"Accept\": \"application/json\",\r\n            \"Referer\": self._host,\r\n            \"Authorization\": self._token,\r\n            \"authx\": self.__get_authx(api_path, json_body or queries_unquoted),\r\n        }\r\n        if json_body is not None:\r\n            headers[\"Content-Type\"] = \"application/json\"\r\n        try:\r\n            res = self._request_utils.request(\r\n                method=method, url=url, headers=headers, params=params, data=json_body\r\n            )\r\n            if res:\r\n                resp = res.json()\r\n                msg = resp.get(\"msg\")\r\n                if code := int(resp.get(\"code\", -1)):\r\n                    if not suppress_log:\r\n                        logger.error(f\"请求接口 {url} 失败，错误码：{code} {msg}\")\r\n                    return Result(code, msg)\r\n                return Result(0, msg, resp.get(\"data\"))\r\n            elif not suppress_log:\r\n                logger.error(f\"请求接口 {url} 失败\")\r\n        except Exception as e:\r\n            if not suppress_log:\r\n                logger.error(f\"请求接口 {url} 异常：\" + str(e))\r\n        return None\r\n\r\n    def close(self):\r\n        \"\"\"\r\n        关闭API会话\r\n        \"\"\"\r\n        if self._session:\r\n            self._session.close()\r\n"
  },
  {
    "path": "app/modules/trimemedia/trimemedia.py",
    "content": "from pathlib import Path\nfrom typing import Any, Dict, Generator, List, Optional, Tuple, Union\n\nimport app.modules.trimemedia.api as fnapi\nfrom app import schemas\nfrom app.log import logger\nfrom app.schemas import MediaType\nfrom app.utils.security import SecurityUtils\nfrom app.utils.url import UrlUtils\n\n\nclass TrimeMedia:\n    _username: Optional[str] = None\n    _password: Optional[str] = None\n\n    _userinfo: Optional[fnapi.User] = None\n    _host: Optional[str] = None\n    _playhost: Optional[str] = None\n\n    _libraries: dict[str, fnapi.MediaDb] = {}\n    _sync_libraries: List[str] = []\n\n    _api: Optional[fnapi.Api] = None\n    _version: Optional[fnapi.Version] = None\n\n    def __init__(\n        self,\n        host: Optional[str] = None,\n        username: Optional[str] = None,\n        password: Optional[str] = None,\n        play_host: Optional[str] = None,\n        sync_libraries: Optional[list] = None,\n        **kwargs,\n    ):\n        if not host or not username or not password:\n            logger.error(\"飞牛影视配置不完整！！\")\n            return\n        self._username = username\n        self._password = password\n        self._host = host\n        self._sync_libraries = sync_libraries or []\n\n        if not self.reconnect():\n            logger.error(f\"请检查服务端地址 {host}\")\n            return\n        if result := self.__create_api(play_host):\n            self._playhost = result.api.host\n            result.api.close()\n        elif play_host:\n            logger.warning(f\"请检查外网播放地址 {play_host}\")\n            self._playhost = UrlUtils.standardize_base_url(play_host).rstrip(\"/\")\n\n    @property\n    def api(self) -> Optional[fnapi.Api]:\n        \"\"\"\n        获得飞牛API\n        \"\"\"\n        return self._api\n\n    @property\n    def version(self) -> Optional[fnapi.Version]:\n        \"\"\"\n        获得飞牛API的版本\n        \"\"\"\n        return self._version\n\n    class _ApiCreateResult:\n        api: fnapi.Api\n        version: fnapi.Version\n\n    @staticmethod\n    def __create_api(host: Optional[str]) -> Optional[\"TrimeMedia._ApiCreateResult\"]:\n        \"\"\"\n        创建一个飞牛API\n\n        :param host:  服务端地址\n        :return: 如果地址无效、不可访问则返回None\n        \"\"\"\n\n        if not host:\n            return None\n        api_key = \"16CCEB3D-AB42-077D-36A1-F355324E4237\"\n        host = UrlUtils.standardize_base_url(host).rstrip(\"/\")\n\n        if not host.endswith(\"/v\"):\n            # 尝试补上结尾的/v 测试能否正常访问\n            res = TrimeMedia._ApiCreateResult()\n            res.api = fnapi.Api(host + \"/v\", api_key)\n            if fnver := res.api.sys_version():\n                res.version = fnver\n                return res\n        # 测试用户配置的地址\n        res = TrimeMedia._ApiCreateResult()\n        res.api = fnapi.Api(host, api_key)\n        if fnver := res.api.sys_version():\n            res.version = fnver\n            return res\n        return None\n\n    def close(self):\n        self.disconnect()\n\n    def is_configured(self) -> bool:\n        return bool(self._host and self._username and self._password)\n\n    def is_authenticated(self) -> bool:\n        \"\"\"\n        是否已登录\n        \"\"\"\n        return (\n            self.is_configured()\n            and self._api is not None\n            and self._api.token is not None\n            and self._userinfo is not None\n        )\n\n    def is_inactive(self) -> bool:\n        \"\"\"\n        判断是否需要重连\n        \"\"\"\n        if not self.is_authenticated():\n            return True\n        self._userinfo = self._api.user_info()\n        return self._userinfo is None\n\n    def reconnect(self):\n        \"\"\"\n        重连\n        \"\"\"\n        if not self.is_configured():\n            return False\n        self.disconnect()\n        if result := self.__create_api(self._host):\n            self._api = result.api\n            self._version = result.version\n            # 版本号:0.8.53, 服务版本:0.8.23\n            # 版本号:0.8.56, 服务版本:0.8.23 接口/memory/user/list改为/manager/user/list\n            logger.debug(\n                f\"版本号:{result.version.frontend}, 服务版本:{result.version.backend}\"\n            )\n        else:\n            return False\n        if self._api.login(self._username, self._password) is None:\n            return False\n        self._userinfo = self._api.user_info()\n        if self._userinfo is None:\n            return False\n        logger.debug(f\"{self._username} 成功登录飞牛影视\")\n        # 刷新媒体库列表\n        self.get_librarys()\n        return True\n\n    def disconnect(self):\n        \"\"\"\n        断开与飞牛的连接\n        \"\"\"\n        if self._api:\n            self._api.logout()\n            self._api.close()\n            self._api = None\n            self._userinfo = None\n            logger.debug(f\"{self._username} 已断开飞牛影视\")\n\n    def get_librarys(\n        self, hidden: Optional[bool] = False\n    ) -> List[schemas.MediaServerLibrary]:\n        \"\"\"\n        获取媒体服务器所有媒体库列表\n        \"\"\"\n        if not self.is_authenticated():\n            return []\n        if self._userinfo.is_admin == 1:\n            mdb_list = self._api.mdb_list() or []\n        else:\n            mdb_list = self._api.mediadb_list() or []\n        self._libraries = {lib.guid: lib for lib in mdb_list}\n        libraries = []\n        for library in self._libraries.values():\n            if hidden and self.__is_library_blocked(library.guid):\n                continue\n            if library.category == fnapi.Category.MOVIE:\n                library_type = MediaType.MOVIE.value\n            elif library.category == fnapi.Category.TV:\n                library_type = MediaType.TV.value\n            elif library.category == fnapi.Category.OTHERS:\n                # 忽略这个库\n                continue\n            else:\n                library_type = MediaType.UNKNOWN.value\n            libraries.append(\n                schemas.MediaServerLibrary(\n                    server=\"trimemedia\",\n                    id=library.guid,\n                    name=library.name,\n                    type=library_type,\n                    path=library.dir_list,\n                    image_list=[\n                        f\"{self._api.host}{img_path}?w=256\"\n                        for img_path in library.posters or []\n                    ],\n                    link=f\"{self._playhost or self._api.host}/library/{library.guid}\",\n                    server_type=\"trimemedia\",\n                    use_cookies=True,\n                )\n            )\n        return libraries\n\n    def get_user_count(self) -> int:\n        \"\"\"\n        获取用户数量(非管理员不能调用)\n        \"\"\"\n        if not self.is_authenticated():\n            return 0\n        if not self._userinfo or self._userinfo.is_admin != 1:\n            return 0\n        return len(self._api.user_list() or [])\n\n    def get_medias_count(self) -> schemas.Statistic:\n        \"\"\"\n        获取媒体数量\n\n        :return: MovieCount SeriesCount\n        \"\"\"\n        if not self.is_authenticated():\n            return schemas.Statistic()\n        if (info := self._api.mediadb_sum()) is None:\n            return schemas.Statistic()\n        return schemas.Statistic(\n            movie_count=info.movie,\n            tv_count=info.tv,\n        )\n\n    def authenticate(self, username: str, password: str) -> Optional[str]:\n        \"\"\"\n        用户认证\n\n        :param username: 用户名\n        :param password: 密码\n        :return: 认证成功返回token，否则返回None\n        \"\"\"\n        if not username or not password:\n            return None\n        if not self.is_configured():\n            return None\n        if result := self.__create_api(self._host):\n            try:\n                return result.api.login(username, password)\n            finally:\n                result.api.logout()\n                result.api.close()\n\n    def get_movies(\n        self, title: str, year: Optional[str] = None, tmdb_id: Optional[int] = None\n    ) -> Optional[List[schemas.MediaServerItem]]:\n        \"\"\"\n        根据标题和年份，检查电影是否在飞牛中存在，存在则返回列表\n\n        :param title: 标题\n        :param year: 年份，为空则不过滤\n        :param tmdb_id: TMDB ID\n        :return: 含title、year属性的字典列表\n        \"\"\"\n        if not self.is_authenticated():\n            return None\n        movies = []\n        items = self._api.search_list(keywords=title) or []\n        for item in items:\n            if item.type != fnapi.Type.MOVIE:\n                continue\n            if (\n                (not tmdb_id or tmdb_id == item.tmdb_id)\n                and title in [item.title, item.original_title]\n                and (not year or (item.release_date and item.release_date[:4] == year))\n            ):\n                movies.append(self.__build_media_server_item(item))\n        return movies\n\n    def __get_series_id_by_name(self, name: str, year: str) -> Optional[str]:\n        items = self._api.search_list(keywords=name) or []\n        for item in items:\n            if item.type != fnapi.Type.TV:\n                continue\n            # 可惜搜索接口不下发original_title 也不能指定分类、年份\n            if name in [item.title, item.original_title]:\n                if not year or (item.air_date and item.air_date[:4] == year):\n                    return item.guid\n        return None\n\n    def get_tv_episodes(\n        self,\n        item_id: Optional[str] = None,\n        title: Optional[str] = None,\n        year: Optional[str] = None,\n        tmdb_id: Optional[int] = None,\n        season: Optional[int] = None,\n    ) -> Tuple[Optional[str], Optional[Dict[int, list]]]:\n        \"\"\"\n        根据标题和年份和季，返回飞牛中的剧集列表\n\n        :param item_id: 飞牛影视中的guid\n        :param title: 标题\n        :param year: 年份\n        :param tmdb_id: TMDBID\n        :param season: 季\n        :return: 集号的列表\n        \"\"\"\n        if not self.is_authenticated():\n            return None, None\n\n        if not item_id:\n            item_id = self.__get_series_id_by_name(title, year)\n            if item_id is None:\n                return None, None\n\n        item_info = self.get_iteminfo(item_id)\n        if not item_info:\n            return None, {}\n\n        if tmdb_id and item_info.tmdbid:\n            if tmdb_id != item_info.tmdbid:\n                return None, {}\n\n        seasons = self._api.season_list(item_id)\n        if not seasons:\n            # 季列表获取失败\n            return None, {}\n\n        if season is not None:\n            for item in seasons:\n                if item.season_number == season:\n                    seasons = [item]\n                    break\n            else:\n                # 没有匹配的季\n                return None, {}\n\n        season_episodes = {}\n        for item in seasons:\n            episodes = self._api.episode_list(item.guid)\n            for episode in episodes or []:\n                if episode.season_number not in season_episodes:\n                    season_episodes[episode.season_number] = []\n                season_episodes[episode.season_number].append(episode.episode_number)\n        return item_id, season_episodes\n\n    def refresh_root_library(self) -> Optional[bool]:\n        \"\"\"\n        通知飞牛刷新整个媒体库(非管理员不能调用)\n        \"\"\"\n        if not self.is_authenticated():\n            return None\n        if not self._userinfo or self._userinfo.is_admin != 1:\n            logger.error(\"飞牛仅支持管理员账号刷新媒体库\")\n            return False\n\n        # 必须调用 否则容易误报 -14 Task duplicate\n        self._api.task_running()\n        logger.info(\"刷新所有媒体库\")\n        return self._api.mdb_scanall()\n\n    def refresh_library_by_items(\n        self, items: List[schemas.RefreshMediaItem]\n    ) -> Optional[bool]:\n        \"\"\"\n        按路径刷新所在的媒体库(非管理员不能调用)\n\n        :param items: 已识别的需要刷新媒体库的媒体信息列表\n        \"\"\"\n        if not self.is_authenticated():\n            return None\n        if not self._userinfo or self._userinfo.is_admin != 1:\n            logger.error(\"飞牛仅支持管理员账号刷新媒体库\")\n            return False\n\n        libraries = set()\n        for item in items:\n            lib = self.__match_library_by_path(item.target_path)\n            if lib is None:\n                # 如果有匹配失败的,刷新整个库\n                return self.refresh_root_library()\n            # 媒体库去重\n            libraries.add(lib.guid)\n\n        # 必须调用 否则容易误报 -14 Task duplicate\n        self._api.task_running()\n        for lib_guid in libraries:\n            # 逐个刷新\n            lib = self._libraries[lib_guid]\n            logger.info(f\"刷新媒体库：{lib.name}\")\n            if not self._api.mdb_scan(lib):\n                # 如果失败，刷新整个库\n                return self.refresh_root_library()\n        return True\n\n    def __match_library_by_path(self, path: Path) -> Optional[fnapi.MediaDb]:\n        def is_subpath(_path: Path, _parent: Path) -> bool:\n            \"\"\"\n            判断_path是否是_parent的子目录下\n            \"\"\"\n            _path = _path.resolve()\n            _parent = _parent.resolve()\n            return _path.parts[: len(_parent.parts)] == _parent.parts\n\n        if path is None:\n            return None\n        for lib in self._libraries.values():\n            for d in lib.dir_list or []:\n                if is_subpath(path, Path(d)):\n                    return lib\n        return None\n\n    def get_webhook_message(self, body: any) -> Optional[schemas.WebhookEventInfo]:\n        pass\n\n    def get_iteminfo(self, itemid: str) -> Optional[schemas.MediaServerItem]:\n        \"\"\"\n        获取单个项目详情\n        \"\"\"\n        if not self.is_authenticated():\n            return None\n        if item := self._api.item(guid=itemid):\n            return self.__build_media_server_item(item)\n        return None\n\n    @staticmethod\n    def __build_media_server_item(item: fnapi.Item):\n        if item.air_date and item.type == fnapi.Type.TV:\n            year = item.air_date[:4]\n        elif item.release_date:\n            year = item.release_date[:4]\n        else:\n            year = None\n\n        user_state = schemas.MediaServerItemUserState()\n        if item.watched:\n            user_state.played = True\n        if item.duration and item.ts is not None:\n            user_state.percentage = item.ts / item.duration * 100\n            user_state.resume = True\n        if item.type is None:\n            item_type = None\n        else:\n            # 将飞牛的媒体类型转为MP能识别的\n            item_type = \"Series\" if item.type == fnapi.Type.TV else item.type.value\n        return schemas.MediaServerItem(\n            server=\"trimemedia\",\n            library=item.ancestor_guid,\n            item_id=item.guid,\n            item_type=item_type,\n            title=item.title,\n            original_title=item.original_title,\n            year=year,\n            tmdbid=item.tmdb_id,\n            imdbid=item.imdb_id,\n            user_state=user_state,\n        )\n\n    @staticmethod\n    def __build_play_url(host: str, item: fnapi.Item) -> str:\n        \"\"\"\n        拼装播放链接\n        \"\"\"\n        if item.type == fnapi.Type.EPISODE:\n            return f\"{host}/tv/episode/{item.guid}\"\n        elif item.type == fnapi.Type.SEASON:\n            return f\"{host}/tv/season/{item.guid}\"\n        elif item.type == fnapi.Type.MOVIE:\n            return f\"{host}/movie/{item.guid}\"\n        elif item.type == fnapi.Type.TV:\n            return f\"{host}/tv/{item.guid}\"\n        else:\n            # 其它类型走通用页面，由飞牛来判断\n            return f\"{host}/other/{item.guid}\"\n\n    def __build_media_server_play_item(\n        self, item: fnapi.Item\n    ) -> schemas.MediaServerPlayItem:\n        if item.type == fnapi.Type.EPISODE:\n            title = item.tv_title\n            subtitle = f\"S{item.season_number}:{item.episode_number} - {item.title}\"\n        else:\n            title = item.title\n            subtitle = \"电影\" if item.type == fnapi.Type.MOVIE else \"视频\"\n        types = (\n            MediaType.MOVIE.value\n            if item.type in [fnapi.Type.MOVIE, fnapi.Type.VIDEO]\n            else MediaType.TV.value\n        )\n        return schemas.MediaServerPlayItem(\n            id=item.guid,\n            title=title,\n            subtitle=subtitle,\n            type=types,\n            image=f\"{self._api.host}{item.poster}\",\n            link=self.__build_play_url(self._playhost or self._api.host, item),\n            percent=(\n                item.ts / item.duration * 100.0\n                if item.duration and item.ts is not None\n                else 0\n            ),\n            server_type=\"trimemedia\",\n            use_cookies=True,\n        )\n\n    def get_items(\n        self,\n        parent: Union[str, int],\n        start_index: Optional[int] = 0,\n        limit: Optional[int] = -1,\n    ) -> Generator[schemas.MediaServerItem | None | Any, Any, None]:\n        \"\"\"\n        获取媒体服务器项目列表，支持分页和不分页逻辑，默认不分页获取所有数据\n\n        :param parent: 媒体库ID，用于标识要获取的媒体库\n        :param start_index: 起始索引，用于分页获取数据。默认为 0，即从第一个项目开始获取\n        :param limit: 每次请求的最大项目数，用于分页。如果为 None 或 -1，则表示一次性获取所有数据，默认为 -1\n\n        :return: 返回一个生成器对象，用于逐步获取媒体服务器中的项目\n        \"\"\"\n        if not self.is_authenticated():\n            return None\n        if (page_size := limit) is None:\n            page_size = -1\n        items = (\n            self._api.item_list(\n                guid=parent,\n                page=start_index + 1,\n                page_size=page_size,\n                types=[fnapi.Type.MOVIE, fnapi.Type.TV, fnapi.Type.DIRECTORY],\n            )\n            or []\n        )\n        for item in items:\n            if item.type == fnapi.Type.DIRECTORY:\n                for items in self.get_items(parent=item.guid):\n                    yield items\n            elif item.type in [fnapi.Type.MOVIE, fnapi.Type.TV]:\n                yield self.__build_media_server_item(item)\n        return None\n\n    def get_play_url(self, item_id: str) -> Optional[str]:\n        \"\"\"\n        获取媒体的外网播放链接\n\n        :param item_id: 媒体ID\n        \"\"\"\n        if not self.is_authenticated():\n            return None\n        if (item := self._api.item(item_id)) is None:\n            return None\n        # 根据查询到的信息拼装出播放链接\n        return self.__build_play_url(self._playhost or self._api.host, item)\n\n    def get_resume(\n        self, num: Optional[int] = 12\n    ) -> Optional[List[schemas.MediaServerPlayItem]]:\n        \"\"\"\n        获取继续观看列表\n\n        :param num: 列表大小，None不限制数量\n        \"\"\"\n        if not self.is_authenticated():\n            return None\n        ret_resume = []\n        for item in self._api.play_list() or []:\n            if len(ret_resume) == num:\n                break\n            if self.__is_library_blocked(item.ancestor_guid):\n                continue\n            ret_resume.append(self.__build_media_server_play_item(item))\n        return ret_resume\n\n    def get_latest(self, num=20) -> Optional[List[schemas.MediaServerPlayItem]]:\n        \"\"\"\n        获取最近更新列表\n        \"\"\"\n        if not self.is_authenticated():\n            return None\n        items = (\n            self._api.item_list(\n                page=1,\n                page_size=max(100, num * 5),\n                types=[fnapi.Type.MOVIE, fnapi.Type.TV],\n            )\n            or []\n        )\n        latest = []\n        for item in items:\n            if len(latest) == num:\n                break\n            if self.__is_library_blocked(item.ancestor_guid):\n                continue\n            latest.append(self.__build_media_server_play_item(item))\n        return latest\n\n    def get_latest_backdrops(self, num=20, remote=False) -> Optional[List[str]]:\n        \"\"\"\n        获取最近更新的媒体Backdrop图片\n        \"\"\"\n        if not self.is_authenticated():\n            return None\n        items = (\n            self._api.item_list(\n                page=1,\n                page_size=max(100, num * 5),\n                types=[fnapi.Type.MOVIE, fnapi.Type.TV],\n            )\n            or []\n        )\n        backdrops = []\n        for item in items:\n            if len(backdrops) == num:\n                break\n            if self.__is_library_blocked(item.ancestor_guid):\n                continue\n            if (item_details := self._api.item(item.guid)) is None:\n                continue\n            if remote:\n                # FIXME 新版飞牛的壁纸无法直接在浏览器中访问\n                img_host = self._playhost or self._api.host\n            else:\n                img_host = self._api.host\n            if item_details.backdrops:\n                item_image = item_details.backdrops\n            else:\n                item_image = (\n                    item_details.posters\n                    if item_details.posters\n                    else item_details.poster\n                )\n            backdrops.append(f\"{img_host}{item_image}\")\n        return backdrops\n\n    def __is_library_blocked(self, library_guid: str):\n        if library := self._libraries.get(library_guid):\n            if library.category == fnapi.Category.OTHERS:\n                # 忽略这个库\n                return True\n        return (\n            True\n            if (\n                self._sync_libraries\n                and \"all\" not in self._sync_libraries\n                and library_guid not in self._sync_libraries\n            )\n            else False\n        )\n\n    def get_image_cookies(self, image_url: str):\n        \"\"\"\n        获得指定图片的Cookies\n        \"\"\"\n        if not self.is_authenticated():\n            return None\n        if not image_url or not SecurityUtils.is_safe_url(\n            image_url, [self._api.host], strict=True\n        ):\n            return None\n        return {\"Trim-MC-token\": self._api.token}\n"
  },
  {
    "path": "app/modules/ugreen/__init__.py",
    "content": "from typing import Any, Generator, List, Optional, Tuple, Union\n\nfrom app import schemas\nfrom app.core.context import MediaInfo\nfrom app.core.event import eventmanager\nfrom app.log import logger\nfrom app.modules import _MediaServerBase, _ModuleBase\nfrom app.modules.ugreen.ugreen import Ugreen\nfrom app.schemas import AuthCredentials, AuthInterceptCredentials\nfrom app.schemas.types import ChainEventType, MediaServerType, MediaType, ModuleType\n\n\nclass UgreenModule(_ModuleBase, _MediaServerBase[Ugreen]):\n\n    def init_module(self) -> None:\n        \"\"\"\n        初始化模块\n        \"\"\"\n        super().init_service(\n            service_name=Ugreen.__name__.lower(),\n            service_type=lambda conf: Ugreen(\n                **conf.config, sync_libraries=conf.sync_libraries\n            ),\n        )\n\n    @staticmethod\n    def get_name() -> str:\n        return \"绿联影视\"\n\n    @staticmethod\n    def get_type() -> ModuleType:\n        \"\"\"\n        获取模块类型\n        \"\"\"\n        return ModuleType.MediaServer\n\n    @staticmethod\n    def get_subtype() -> MediaServerType:\n        \"\"\"\n        获取模块子类型\n        \"\"\"\n        return MediaServerType.Ugreen\n\n    @staticmethod\n    def get_priority() -> int:\n        \"\"\"\n        获取模块优先级，数字越小优先级越高，只有同一接口下优先级才生效\n        \"\"\"\n        return 5\n\n    def init_setting(self) -> Tuple[str, Union[str, bool]]:\n        pass\n\n    def scheduler_job(self) -> None:\n        \"\"\"\n        定时任务，每10分钟调用一次\n        \"\"\"\n        for name, server in self.get_instances().items():\n            if server.is_configured() and server.is_inactive():\n                logger.info(f\"绿联影视 {name} 连接断开，尝试重连 ...\")\n                server.reconnect()\n\n    def stop(self):\n        for server in self.get_instances().values():\n            if server.is_authenticated():\n                server.disconnect()\n\n    def test(self) -> Optional[Tuple[bool, str]]:\n        \"\"\"\n        测试模块连接性\n        \"\"\"\n        if not self.get_instances():\n            return None\n        for name, server in self.get_instances().items():\n            if not server.is_configured():\n                return False, f\"绿联影视配置不完整：{name}\"\n            if server.is_inactive() and not server.reconnect():\n                return False, f\"无法连接绿联影视：{name}\"\n        return True, \"\"\n\n    def user_authenticate(\n        self, credentials: AuthCredentials, service_name: Optional[str] = None\n    ) -> Optional[AuthCredentials]:\n        \"\"\"\n        使用绿联影视用户辅助完成用户认证\n        \"\"\"\n        if not credentials or credentials.grant_type != \"password\":\n            return None\n\n        if service_name:\n            servers = (\n                [(service_name, server)]\n                if (server := self.get_instance(service_name))\n                else []\n            )\n        else:\n            servers = self.get_instances().items()\n\n        for name, server in servers:\n            intercept_event = eventmanager.send_event(\n                etype=ChainEventType.AuthIntercept,\n                data=AuthInterceptCredentials(\n                    username=credentials.username,\n                    channel=self.get_name(),\n                    service=name,\n                    status=\"triggered\",\n                ),\n            )\n            if intercept_event and intercept_event.event_data:\n                intercept_data: AuthInterceptCredentials = intercept_event.event_data\n                if intercept_data.cancel:\n                    continue\n            token = server.authenticate(credentials.username, credentials.password)\n            if token:\n                credentials.channel = self.get_name()\n                credentials.service = name\n                credentials.token = token\n                return credentials\n        return None\n\n    def webhook_parser(\n        self, body: Any, form: Any, args: Any\n    ) -> Optional[schemas.WebhookEventInfo]:\n        \"\"\"\n        解析Webhook报文体\n        \"\"\"\n        source = args.get(\"source\")\n        if source:\n            server: Optional[Ugreen] = self.get_instance(source)\n            if not server:\n                return None\n            result = server.get_webhook_message(body)\n            if result:\n                result.server_name = source\n            return result\n\n        for server in self.get_instances().values():\n            if server:\n                result = server.get_webhook_message(body)\n                if result:\n                    return result\n        return None\n\n    def media_exists(\n        self,\n        mediainfo: MediaInfo,\n        itemid: Optional[str] = None,\n        server: Optional[str] = None,\n    ) -> Optional[schemas.ExistMediaInfo]:\n        \"\"\"\n        判断媒体文件是否存在\n        \"\"\"\n        if server:\n            servers = [(server, self.get_instance(server))]\n        else:\n            servers = self.get_instances().items()\n\n        for name, s in servers:\n            if not s:\n                continue\n            if mediainfo.type == MediaType.MOVIE:\n                if itemid:\n                    movie = s.get_iteminfo(itemid)\n                    if movie:\n                        logger.info(f\"媒体库 {name} 中找到了 {movie}\")\n                        return schemas.ExistMediaInfo(\n                            type=MediaType.MOVIE,\n                            server_type=\"ugreen\",\n                            server=name,\n                            itemid=movie.item_id,\n                        )\n                movies = s.get_movies(\n                    title=mediainfo.title,\n                    year=mediainfo.year,\n                    tmdb_id=mediainfo.tmdb_id,\n                )\n                if not movies:\n                    logger.info(f\"{mediainfo.title_year} 没有在媒体库 {name} 中\")\n                    continue\n                logger.info(f\"媒体库 {name} 中找到了 {movies}\")\n                return schemas.ExistMediaInfo(\n                    type=MediaType.MOVIE,\n                    server_type=\"ugreen\",\n                    server=name,\n                    itemid=movies[0].item_id,\n                )\n\n            itemid, tvs = s.get_tv_episodes(\n                title=mediainfo.title,\n                year=mediainfo.year,\n                tmdb_id=mediainfo.tmdb_id,\n                item_id=itemid,\n            )\n            if not tvs:\n                logger.info(f\"{mediainfo.title_year} 没有在媒体库 {name} 中\")\n                continue\n            logger.info(f\"{mediainfo.title_year} 在媒体库 {name} 中找到了这些季集：{tvs}\")\n            return schemas.ExistMediaInfo(\n                type=MediaType.TV,\n                seasons=tvs,\n                server_type=\"ugreen\",\n                server=name,\n                itemid=itemid,\n            )\n        return None\n\n    def media_statistic(\n        self, server: Optional[str] = None\n    ) -> Optional[List[schemas.Statistic]]:\n        \"\"\"\n        媒体数量统计\n        \"\"\"\n        if server:\n            server_obj: Optional[Ugreen] = self.get_instance(server)\n            if not server_obj:\n                return None\n            servers = [server_obj]\n        else:\n            servers = self.get_instances().values()\n\n        media_statistics = []\n        for s in servers:\n            media_statistic = s.get_medias_count()\n            if not media_statistic:\n                continue\n            media_statistic.user_count = s.get_user_count()\n            media_statistics.append(media_statistic)\n        return media_statistics\n\n    def mediaserver_librarys(\n        self, server: Optional[str] = None, hidden: Optional[bool] = False, **kwargs\n    ) -> Optional[List[schemas.MediaServerLibrary]]:\n        \"\"\"\n        媒体库列表\n        \"\"\"\n        server_obj: Optional[Ugreen] = self.get_instance(server)\n        if server_obj:\n            return server_obj.get_librarys(hidden=hidden)\n        return None\n\n    def mediaserver_items(\n        self,\n        server: str,\n        library_id: Union[str, int],\n        start_index: Optional[int] = 0,\n        limit: Optional[int] = -1,\n    ) -> Optional[Generator]:\n        \"\"\"\n        获取媒体服务器项目列表\n        \"\"\"\n        server_obj: Optional[Ugreen] = self.get_instance(server)\n        if server_obj:\n            return server_obj.get_items(library_id, start_index, limit)\n        return None\n\n    def mediaserver_iteminfo(\n        self, server: str, item_id: str\n    ) -> Optional[schemas.MediaServerItem]:\n        \"\"\"\n        媒体库项目详情\n        \"\"\"\n        server_obj: Optional[Ugreen] = self.get_instance(server)\n        if server_obj:\n            return server_obj.get_iteminfo(item_id)\n        return None\n\n    def mediaserver_tv_episodes(\n        self, server: str, item_id: Union[str, int]\n    ) -> Optional[List[schemas.MediaServerSeasonInfo]]:\n        \"\"\"\n        获取剧集信息\n        \"\"\"\n        if not item_id:\n            return None\n        server_obj: Optional[Ugreen] = self.get_instance(server)\n        if not server_obj:\n            return None\n        _, seasoninfo = server_obj.get_tv_episodes(item_id=str(item_id))\n        if not seasoninfo:\n            return []\n        return [\n            schemas.MediaServerSeasonInfo(season=season, episodes=episodes)\n            for season, episodes in seasoninfo.items()\n        ]\n\n    def mediaserver_playing(\n        self, server: str, count: Optional[int] = 20, **kwargs\n    ) -> List[schemas.MediaServerPlayItem]:\n        \"\"\"\n        获取媒体服务器正在播放信息\n        \"\"\"\n        server_obj: Optional[Ugreen] = self.get_instance(server)\n        if not server_obj:\n            return []\n        return server_obj.get_resume(num=count) or []\n\n    def mediaserver_play_url(\n        self, server: str, item_id: Union[str, int]\n    ) -> Optional[str]:\n        \"\"\"\n        获取媒体库播放地址\n        \"\"\"\n        if not item_id:\n            return None\n        server_obj: Optional[Ugreen] = self.get_instance(server)\n        if not server_obj:\n            return None\n        return server_obj.get_play_url(str(item_id))\n\n    def mediaserver_latest(\n        self,\n        server: Optional[str] = None,\n        count: Optional[int] = 20,\n        **kwargs,\n    ) -> List[schemas.MediaServerPlayItem]:\n        \"\"\"\n        获取媒体服务器最新入库条目\n        \"\"\"\n        server_obj: Optional[Ugreen] = self.get_instance(server)\n        if not server_obj:\n            return []\n        return server_obj.get_latest(num=count) or []\n\n    def mediaserver_latest_images(\n        self,\n        server: Optional[str] = None,\n        count: Optional[int] = 20,\n        remote: Optional[bool] = False,\n        **kwargs,\n    ) -> List[str]:\n        \"\"\"\n        获取媒体服务器最新入库条目的图片\n        \"\"\"\n        server_obj: Optional[Ugreen] = self.get_instance(server)\n        if not server_obj:\n            return []\n        return server_obj.get_latest_backdrops(num=count, remote=remote) or []\n\n    def mediaserver_image_cookies(\n        self,\n        server: Optional[str] = None,\n        image_url: Optional[str] = None,\n        **kwargs,\n    ) -> Optional[str | dict]:\n        \"\"\"\n        获取绿联影视服务器的图片Cookies\n        \"\"\"\n        if not image_url:\n            return None\n        if server:\n            server_obj: Optional[Ugreen] = self.get_instance(server)\n            if not server_obj:\n                return None\n            return server_obj.get_image_cookies(image_url)\n        for server_obj in self.get_instances().values():\n            if cookies := server_obj.get_image_cookies(image_url):\n                return cookies\n        return None\n"
  },
  {
    "path": "app/modules/ugreen/api.py",
    "content": "import base64\nimport uuid\nfrom dataclasses import dataclass\nfrom typing import Any, Dict, Mapping, Optional, Union\nfrom urllib.parse import urlsplit, urlunsplit\n\nfrom requests import Session\n\nfrom app.log import logger\nfrom app.utils.ugreen_crypto import UgreenCrypto\nfrom app.utils.url import UrlUtils\n\n\n@dataclass\nclass ApiResult:\n    code: int = -1\n    msg: str = \"\"\n    data: Any = None\n    debug: Optional[str] = None\n    raw: Optional[dict] = None\n\n    @property\n    def success(self) -> bool:\n        return self.code == 200\n\n\nclass Api:\n    \"\"\"\n    绿联影视 API 客户端（统一加密通道）。\n\n    说明：\n    1. 所有业务接口调用都应走 `request()`；\n    2. `request()` 会自动将明文查询参数加密为 `encrypt_query`；\n    3. 若响应包含 `encrypt_resp_body`，会自动完成解密后再返回。\n    \"\"\"\n\n    __slots__ = (\n        \"_host\",\n        \"_session\",\n        \"_token\",\n        \"_static_token\",\n        \"_is_ugk\",\n        \"_public_key\",\n        \"_crypto\",\n        \"_username\",\n        \"_client_id\",\n        \"_client_version\",\n        \"_language\",\n        \"_ug_agent\",\n        \"_timeout\",\n        \"_verify_ssl\",\n    )\n\n    def __init__(\n        self,\n        host: str,\n        client_version: str = \"76363\",\n        language: str = \"zh-CN\",\n        ug_agent: str = \"PC/WEB\",\n        timeout: int = 20,\n        verify_ssl: bool = True,\n    ):\n        self._host = self._normalize_base_url(host)\n        self._session = Session()\n\n        self._token: Optional[str] = None\n        self._static_token: Optional[str] = None\n        self._is_ugk: bool = False\n        self._public_key: Optional[str] = None\n        self._crypto: Optional[UgreenCrypto] = None\n        self._username: Optional[str] = None\n\n        self._client_id = f\"{uuid.uuid4()}-WEB\"\n        self._client_version = client_version\n        self._language = language\n        self._ug_agent = ug_agent\n        self._timeout = timeout\n        # 是否校验证书，默认开启；仅在用户明确配置时才应关闭。\n        self._verify_ssl = bool(verify_ssl)\n\n    @property\n    def host(self) -> str:\n        return self._host\n\n    @property\n    def token(self) -> Optional[str]:\n        return self._token\n\n    @property\n    def static_token(self) -> Optional[str]:\n        return self._static_token\n\n    @property\n    def is_ugk(self) -> bool:\n        return self._is_ugk\n\n    @property\n    def public_key(self) -> Optional[str]:\n        return self._public_key\n\n    def close(self):\n        \"\"\"\n        关闭底层 HTTP 会话。\n        \"\"\"\n        self._session.close()\n\n    @staticmethod\n    def _normalize_base_url(host: str) -> str:\n        if not host:\n            return \"\"\n        host = UrlUtils.standardize_base_url(host).rstrip(\"/\")\n        parsed = urlsplit(host)\n        return urlunsplit((parsed.scheme, parsed.netloc, \"\", \"\", \"\")).rstrip(\"/\")\n\n    @staticmethod\n    def _decode_public_key(raw: Optional[str]) -> Optional[str]:\n        if not raw:\n            return None\n        value = str(raw).strip()\n        if not value:\n            return None\n        if \"BEGIN\" in value:\n            return value\n        try:\n            return base64.b64decode(value).decode(\"utf-8\")\n        except Exception:\n            return None\n\n    @staticmethod\n    def _extract_rsa_token(resp_json: dict, headers: Mapping[str, str]) -> Optional[str]:\n        token = headers.get(\"x-rsa-token\") or headers.get(\"X-Rsa-Token\")\n        if token:\n            return token\n        token = resp_json.get(\"xRsaToken\") or resp_json.get(\"x-rsa-token\")\n        if token:\n            return token\n        data = resp_json.get(\"data\") if isinstance(resp_json, Mapping) else None\n        if isinstance(data, Mapping):\n            return data.get(\"xRsaToken\") or data.get(\"x-rsa-token\")\n        return None\n\n    def _common_headers(self) -> dict[str, str]:\n        \"\"\"\n        获取绿联 Web 端通用请求头。\n        \"\"\"\n        return {\n            \"Accept\": \"application/json, text/plain, */*\",\n            \"Client-Id\": self._client_id,\n            \"Client-Version\": self._client_version,\n            \"UG-Agent\": self._ug_agent,\n            \"X-Specify-Language\": self._language,\n        }\n\n    def _request_json(\n        self,\n        url: str,\n        method: str = \"GET\",\n        headers: Optional[dict] = None,\n        params: Optional[dict] = None,\n        json_data: Optional[dict] = None,\n    ) -> Optional[dict]:\n        \"\"\"\n        发送 HTTP 请求并尝试解析为 JSON。\n        \"\"\"\n        try:\n            method = method.upper()\n            if method == \"POST\":\n                resp = self._session.post(\n                    url=url,\n                    headers=headers,\n                    params=params,\n                    json=json_data,\n                    timeout=self._timeout,\n                    verify=self._verify_ssl,\n                )\n            else:\n                resp = self._session.get(\n                    url=url,\n                    headers=headers,\n                    params=params,\n                    timeout=self._timeout,\n                    verify=self._verify_ssl,\n                )\n            return resp.json()\n        except Exception as err:\n            logger.error(f\"请求绿联接口失败：{url} {err}\")\n            return None\n\n    @staticmethod\n    def _build_result(payload: Any) -> ApiResult:\n        if not isinstance(payload, Mapping):\n            return ApiResult(code=-1, msg=\"响应格式错误\", raw=None)\n        code = payload.get(\"code\")\n        try:\n            code = int(code)\n        except Exception:\n            code = -1\n        return ApiResult(\n            code=code,\n            msg=str(payload.get(\"msg\") or \"\"),\n            data=payload.get(\"data\"),\n            debug=payload.get(\"debug\"),\n            raw=dict(payload),\n        )\n\n    def login(self, username: str, password: str, keepalive: bool = True) -> Optional[str]:\n        \"\"\"\n        登录绿联账号并初始化加密上下文。\n\n        :param username: 用户名\n        :param password: 密码（会先做 RSA 分段加密）\n        :param keepalive: 是否保持登录\n        :return: 登录成功返回 token\n        \"\"\"\n        if not username or not password:\n            return None\n\n        headers = self._common_headers()\n\n        try:\n            check_resp = self._session.post(\n                url=f\"{self._host}/ugreen/v1/verify/check\",\n                headers=headers,\n                json={\"username\": username},\n                timeout=self._timeout,\n                verify=self._verify_ssl,\n            )\n            check_json = check_resp.json()\n        except Exception as err:\n            logger.error(f\"绿联获取登录公钥失败：{err}\")\n            return None\n\n        check_result = self._build_result(check_json)\n        if not check_result.success:\n            logger.error(f\"绿联获取登录公钥失败：{check_result.msg}\")\n            return None\n\n        rsa_token = self._extract_rsa_token(check_json, check_resp.headers)\n        login_public_key = self._decode_public_key(rsa_token)\n        if not login_public_key:\n            logger.error(\"绿联获取登录公钥失败：公钥为空\")\n            return None\n\n        encrypted_password = UgreenCrypto(public_key=login_public_key).rsa_encrypt_long(password)\n        login_json = self._request_json(\n            url=f\"{self._host}/ugreen/v1/verify/login\",\n            method=\"POST\",\n            headers=headers,\n            json_data={\n                \"username\": username,\n                \"password\": encrypted_password,\n                \"keepalive\": keepalive,\n                \"otp\": True,\n                \"is_simple\": True,\n            },\n        )\n        if not login_json:\n            return None\n\n        login_result = self._build_result(login_json)\n        if not login_result.success or not isinstance(login_result.data, Mapping):\n            logger.error(f\"绿联登录失败：{login_result.msg}\")\n            return None\n\n        token = str(login_result.data.get(\"token\") or \"\").strip()\n        public_key = self._decode_public_key(str(login_result.data.get(\"public_key\") or \"\"))\n        if not token or not public_key:\n            logger.error(\"绿联登录失败：未返回 token/public_key\")\n            return None\n\n        self._token = token\n        static_token = str(login_result.data.get(\"static_token\") or \"\").strip()\n        self._static_token = static_token or self._token\n        self._is_ugk = bool(login_result.data.get(\"is_ugk\"))\n        self._public_key = public_key\n        self._crypto = UgreenCrypto(\n            public_key=self._public_key,\n            token=self._token,\n            client_id=self._client_id,\n            client_version=self._client_version,\n            ug_agent=self._ug_agent,\n            language=self._language,\n        )\n        self._username = username\n        return self._token\n\n    def export_session_state(self) -> Optional[dict]:\n        \"\"\"\n        导出当前登录会话，供持久化存储使用。\n        \"\"\"\n        if not self._token or not self._public_key:\n            return None\n        return {\n            \"token\": self._token,\n            \"static_token\": self._static_token,\n            \"is_ugk\": self._is_ugk,\n            \"public_key\": self._public_key,\n            \"username\": self._username,\n            \"client_id\": self._client_id,\n            \"client_version\": self._client_version,\n            \"language\": self._language,\n            \"ug_agent\": self._ug_agent,\n            \"cookies\": self._session.cookies.get_dict(),\n        }\n\n    def import_session_state(self, state: Mapping[str, Any]) -> bool:\n        \"\"\"\n        从持久化数据恢复登录会话，避免重复登录。\n        \"\"\"\n        if not isinstance(state, Mapping):\n            return False\n\n        token = str(state.get(\"token\") or \"\").strip()\n        public_key = self._decode_public_key(str(state.get(\"public_key\") or \"\"))\n        if not token or not public_key:\n            return False\n\n        static_token = str(state.get(\"static_token\") or \"\").strip()\n        is_ugk = bool(state.get(\"is_ugk\"))\n\n        # 会话可能与 client_id 绑定，需恢复原客户端信息\n        client_id = str(state.get(\"client_id\") or \"\").strip()\n        if client_id:\n            self._client_id = client_id\n\n        client_version = str(state.get(\"client_version\") or \"\").strip()\n        if client_version:\n            self._client_version = client_version\n\n        language = str(state.get(\"language\") or \"\").strip()\n        if language:\n            self._language = language\n\n        ug_agent = str(state.get(\"ug_agent\") or \"\").strip()\n        if ug_agent:\n            self._ug_agent = ug_agent\n\n        username = str(state.get(\"username\") or \"\").strip()\n        self._username = username or None\n\n        cookies = state.get(\"cookies\")\n        if isinstance(cookies, Mapping):\n            try:\n                self._session.cookies.update(\n                    {\n                        str(k): str(v)\n                        for k, v in cookies.items()\n                        if k is not None and v is not None\n                    }\n                )\n            except Exception:\n                pass\n\n        self._token = token\n        self._static_token = static_token or self._token\n        self._is_ugk = is_ugk\n        self._public_key = public_key\n        self._crypto = UgreenCrypto(\n            public_key=self._public_key,\n            token=self._token,\n            client_id=self._client_id,\n            client_version=self._client_version,\n            ug_agent=self._ug_agent,\n            language=self._language,\n        )\n        return True\n\n    def logout(self):\n        \"\"\"\n        登出并清理本地认证状态。\n        \"\"\"\n        if not self._token or not self._crypto:\n            return\n        try:\n            req = self._crypto.build_encrypted_request(\n                url=f\"{self._host}/ugreen/v1/verify/logout\",\n                method=\"GET\",\n                params={},\n            )\n            self._session.get(\n                req.url,\n                headers=req.headers,\n                params=req.params,\n                timeout=self._timeout,\n                verify=self._verify_ssl,\n            )\n        except Exception:\n            pass\n        self._token = None\n        self._static_token = None\n        self._is_ugk = False\n        self._public_key = None\n        self._crypto = None\n        self._username = None\n\n    def request(\n        self,\n        path: str,\n        method: str = \"GET\",\n        params: Optional[dict] = None,\n        data: Optional[dict] = None,\n    ) -> ApiResult:\n        \"\"\"\n        统一请求入口。\n\n        核心行为：\n        1. 自动把 `params` 明文序列化并加密为 `encrypt_query`；\n        2. 自动注入绿联安全头（`X-Ugreen-*`）；\n        3. 对 `POST/PUT/PATCH` 的 JSON 体加密；\n        4. 自动解密 `encrypt_resp_body`。\n\n        :param path: `/ugreen/` 后的相对路径，例如 `v1/video/homepage/media_list`\n        :param method: HTTP 方法\n        :param params: 明文查询参数（无需自己处理 encrypt_query）\n        :param data: 明文 JSON 请求体（自动加密）\n        \"\"\"\n        if not self._crypto:\n            return ApiResult(code=-1, msg=\"未登录\")\n\n        api_path = path.strip(\"/\")\n        # 由加密工具自动构建 encrypt_query 与加密请求体\n        req = self._crypto.build_encrypted_request(\n            url=f\"{self._host}/ugreen/{api_path}\",\n            method=method.upper(),\n            params=params or {},\n            data=data,\n            encrypt_body=method.upper() in {\"POST\", \"PUT\", \"PATCH\"},\n        )\n\n        payload = self._request_json(\n            url=req.url,\n            method=method,\n            headers=req.headers,\n            params=req.params,\n            json_data=req.json,\n        )\n        if payload is None:\n            return ApiResult(code=-1, msg=\"接口请求失败\")\n\n        # 响应若包含 encrypt_resp_body，这里会自动解密\n        decrypted = self._crypto.decrypt_response(payload, req.aes_key)\n        return self._build_result(decrypted)\n\n    def current_user(self) -> Optional[dict]:\n        \"\"\"\n        获取当前登录用户信息。\n        \"\"\"\n        result = self.request(\"v1/user/current/user\")\n        if not result.success or not isinstance(result.data, Mapping):\n            return None\n        return dict(result.data)\n\n    def media_list(self) -> list[dict]:\n        \"\"\"\n        获取首页媒体库列表（`media_lib_info_list`）。\n        \"\"\"\n        result = self.request(\"v1/video/homepage/media_list\")\n        if not result.success or not isinstance(result.data, Mapping):\n            return []\n        items = result.data.get(\"media_lib_info_list\")\n        return items if isinstance(items, list) else []\n\n    def media_lib_users(self) -> list[dict]:\n        \"\"\"\n        获取媒体库用户列表。\n        \"\"\"\n        result = self.request(\"v1/video/media_lib/get_user_list\")\n        if not result.success or not isinstance(result.data, Mapping):\n            return []\n        users = result.data.get(\"user_info_arr\")\n        return users if isinstance(users, list) else []\n\n    def recently_played(self, page: int = 1, page_size: int = 12) -> Optional[dict]:\n        \"\"\"\n        获取继续观看列表。\n        \"\"\"\n        result = self.request(\n            \"v1/video/recently_played/get\",\n            params={\n                \"page\": page,\n                \"page_size\": page_size,\n                \"language\": self._language,\n                \"create_time_order\": \"false\",\n            },\n        )\n        return result.data if result.success and isinstance(result.data, Mapping) else None\n\n    def recently_updated(self, page: int = 1, page_size: int = 20) -> Optional[dict]:\n        \"\"\"\n        获取最近更新列表。\n        \"\"\"\n        result = self.request(\n            \"v1/video/recently_update/get\",\n            params={\n                \"page\": page,\n                \"page_size\": page_size,\n                \"language\": self._language,\n                \"create_time_order\": \"false\",\n            },\n        )\n        return result.data if result.success and isinstance(result.data, Mapping) else None\n\n    def recently_played_info(self, item_id: Union[str, int]) -> Optional[dict]:\n        \"\"\"\n        获取单个视频的播放状态与基础详情信息。\n        \"\"\"\n        result = self.request(\n            \"v1/video/recently_played/info\",\n            params={\n                \"ug_video_info_id\": item_id,\n                \"version_control\": \"true\",\n            },\n        )\n        if result.code in {200, 1303} and isinstance(result.data, Mapping):\n            return dict(result.data)\n        return None\n\n    def search(self, keyword: str, offset: int = 0, limit: int = 200) -> Optional[dict]:\n        \"\"\"\n        搜索媒体（电影/剧集）。\n        \"\"\"\n        result = self.request(\n            \"v1/video/search\",\n            params={\n                \"language\": self._language,\n                \"search_type\": 1,\n                \"offset\": offset,\n                \"limit\": limit,\n                \"keyword\": keyword,\n            },\n        )\n        return result.data if result.success and isinstance(result.data, Mapping) else None\n\n    def video_all(self, classification: int, page: int = 1, page_size: int = 20) -> Optional[dict]:\n        \"\"\"\n        获取 `v1/video/all` 分类列表。\n\n        常用分类：\n        -102: 电影\n        -103: 电视剧\n        \"\"\"\n        result = self.request(\n            \"v1/video/all\",\n            params={\n                \"page\": page,\n                \"pageSize\": page_size,\n                \"classification\": classification,\n                \"sort_type\": 2,\n                \"order_type\": 2,\n                \"release_date_begin\": -9999999999,\n                \"release_date_end\": -9999999999,\n                \"identify_status\": 0,\n                \"watch_status\": -1,\n                \"ug_style_id\": 0,\n                \"ug_country_id\": 0,\n                \"clarity\": -1,\n            },\n        )\n        return result.data if result.success and isinstance(result.data, Mapping) else None\n\n    def poster_wall_get_folder(\n        self,\n        path: Optional[str] = None,\n        page: int = 1,\n        page_size: int = 100,\n        sort_type: int = 1,\n        order_type: int = 1,\n    ) -> Optional[dict]:\n        \"\"\"\n        获取海报墙文件夹与条目（可按目录路径递归展开）。\n        \"\"\"\n        params: Dict[str, Any] = {\n            \"page\": page,\n            \"page_size\": page_size,\n            \"sort_type\": sort_type,\n            \"order_type\": order_type,\n        }\n        if path:\n            params[\"path\"] = path\n        result = self.request(\"v1/video/poster_wall/media_lib/get_folder\", params=params)\n        return result.data if result.success and isinstance(result.data, Mapping) else None\n\n    def get_movie(\n        self,\n        item_id: Union[str, int],\n        media_lib_set_id: Union[str, int],\n        path: Optional[str] = None,\n        folder_path: Optional[str] = None,\n    ) -> Optional[dict]:\n        \"\"\"\n        获取电影详情。\n        \"\"\"\n        params: Dict[str, Any] = {\n            \"id\": item_id,\n            \"media_lib_set_id\": media_lib_set_id,\n            \"fileVersion\": \"true\",\n        }\n        if path:\n            params[\"path\"] = path\n        if folder_path:\n            params[\"folder_path\"] = folder_path\n        result = self.request(\"v1/video/details/getMovie\", params=params)\n        return result.data if result.success and isinstance(result.data, Mapping) else None\n\n    def get_tv(self, item_id: Union[str, int], folder_path: str = \"ALL\") -> Optional[dict]:\n        \"\"\"\n        获取剧集详情（含季/集信息）。\n        \"\"\"\n        result = self.request(\n            \"v2/video/details/getTV\",\n            params={\n                \"ug_video_info_id\": item_id,\n                \"folder_path\": folder_path,\n            },\n        )\n        return result.data if result.success and isinstance(result.data, Mapping) else None\n\n    def scan(self, media_lib_set_id: Union[str, int], scan_type: int = 2, op_type: int = 2) -> bool:\n        \"\"\"\n        触发媒体库扫描。\n\n        :param media_lib_set_id: 媒体库 ID\n        :param scan_type: 扫描类型（1: 新添加和修改, 2: 补充缺失, 3: 覆盖扫描）\n        :param op_type: 操作类型（网页端常用 2）\n        \"\"\"\n        result = self.request(\n            \"v1/video/media_lib/scan\",\n            params={\n                \"op_type\": op_type,\n                \"media_lib_set_id\": media_lib_set_id,\n                \"media_lib_scan_type\": scan_type,\n            },\n        )\n        return result.success\n\n    def scan_status(self, only_brief: bool = True) -> list[dict]:\n        \"\"\"\n        获取媒体库扫描状态。\n        \"\"\"\n        result = self.request(\n            \"v1/video/media_lib/scan/status\",\n            params={\"only_brief\": \"true\" if only_brief else \"false\"},\n        )\n        if not result.success or not isinstance(result.data, Mapping):\n            return []\n        arr = result.data.get(\"media_lib_scan_status_arr\")\n        return arr if isinstance(arr, list) else []\n\n    def preferences_all(self) -> Optional[Any]:\n        \"\"\"\n        获取影视偏好设置（`v1/video/preferences/all`）。\n        \"\"\"\n        result = self.request(\"v1/video/preferences/all\")\n        return result.data if result.success else None\n\n    def history_get(self, num: int = 10) -> Optional[Any]:\n        \"\"\"\n        获取历史记录（`v1/video/history/get`）。\n        \"\"\"\n        result = self.request(\"v1/video/history/get\", params={\"num\": num})\n        return result.data if result.success else None\n\n    def data_source_get_config(self) -> Optional[Any]:\n        \"\"\"\n        获取数据源配置（`v1/video/data_source/get_config`）。\n        \"\"\"\n        result = self.request(\"v1/video/data_source/get_config\")\n        return result.data if result.success else None\n\n    def homepage_slider(\n        self, language: Optional[str] = None, app_name: str = \"web\"\n    ) -> Optional[Any]:\n        \"\"\"\n        获取首页轮播数据（`v1/video/homepage/slider`）。\n        \"\"\"\n        result = self.request(\n            \"v1/video/homepage/slider\",\n            params={\n                \"language\": language or self._language,\n                \"app_name\": app_name,\n            },\n        )\n        return result.data if result.success else None\n\n    def media_lib_guide_init(self) -> Optional[Any]:\n        \"\"\"\n        获取媒体库引导初始化信息（`v1/video/media_lib/guide_init`）。\n        \"\"\"\n        result = self.request(\"v1/video/media_lib/guide_init\")\n        return result.data if result.success else None\n\n    def media_lib_filter_options(\n        self, media_type: int = 0, language: Optional[str] = None\n    ) -> Optional[Any]:\n        \"\"\"\n        获取媒体库筛选项（`v1/video/media_lib/filter/options`）。\n        \"\"\"\n        result = self.request(\n            \"v1/video/media_lib/filter/options\",\n            params={\n                \"type\": media_type,\n                \"language\": language or self._language,\n            },\n        )\n        return result.data if result.success else None\n\n    def guide(self, guide_position: int = 1, client_type: int = 1) -> Optional[Any]:\n        \"\"\"\n        获取引导位数据（`v1/video/guide`）。\n        \"\"\"\n        result = self.request(\n            \"v1/video/guide\",\n            params={\n                \"guide_position\": guide_position,\n                \"client_type\": client_type,\n            },\n        )\n        return result.data if result.success else None\n\n    def homepage_v2(self, language: Optional[str] = None) -> Optional[Any]:\n        \"\"\"\n        获取新版首页聚合数据（`v2/video/homepage`）。\n        \"\"\"\n        result = self.request(\n            \"v2/video/homepage\",\n            params={\"language\": language or self._language},\n        )\n        return result.data if result.success else None\n\n    def media_lib_init_user_permission(self) -> Optional[Any]:\n        \"\"\"\n        初始化用户媒体库权限（`v1/video/media_lib/init_user_permission`）。\n        \"\"\"\n        result = self.request(\"v1/video/media_lib/init_user_permission\")\n        return result.data if result.success else None\n\n    def media_lib_get_all(\n        self, req_type: int = 2, language: Optional[str] = None\n    ) -> Optional[Any]:\n        \"\"\"\n        获取全部媒体库集合（`v1/video/media_lib/get_all`）。\n        \"\"\"\n        result = self.request(\n            \"v1/video/media_lib/get_all\",\n            params={\n                \"mediaLib_get_all_req_type\": req_type,\n                \"language\": language or self._language,\n            },\n        )\n        return result.data if result.success else None\n"
  },
  {
    "path": "app/modules/ugreen/ugreen.py",
    "content": "import hashlib\nfrom collections import deque\nfrom datetime import datetime\nfrom pathlib import Path\nfrom typing import Any, Dict, Generator, List, Mapping, Optional, Union\nfrom urllib.parse import parse_qs, urlparse\n\nfrom app import schemas\nfrom app.db.systemconfig_oper import SystemConfigOper\nfrom app.log import logger\nfrom app.modules.ugreen.api import Api\nfrom app.schemas import MediaType\nfrom app.schemas.types import SystemConfigKey\nfrom app.utils.url import UrlUtils\n\n\nclass Ugreen:\n    _username: Optional[str] = None\n    _password: Optional[str] = None\n\n    _userinfo: Optional[dict] = None\n    _host: Optional[str] = None\n    _playhost: Optional[str] = None\n\n    _libraries: dict[str, dict] = {}\n    _library_paths: dict[str, str] = {}\n    _sync_libraries: List[str] = []\n    _scan_type: int = 2\n    _verify_ssl: bool = True\n\n    _api: Optional[Api] = None\n\n    def __init__(\n        self,\n        host: Optional[str] = None,\n        username: Optional[str] = None,\n        password: Optional[str] = None,\n        play_host: Optional[str] = None,\n        sync_libraries: Optional[list] = None,\n        scan_mode: Optional[Union[str, int]] = None,\n        scan_type: Optional[Union[str, int]] = None,\n        verify_ssl: Optional[Union[bool, str, int]] = True,\n        **kwargs,\n    ):\n        if not host or not username or not password:\n            logger.error(\"绿联影视配置不完整！！\")\n            return\n\n        self._host = host\n        self._username = username\n        self._password = password\n        self._sync_libraries = sync_libraries or []\n        # 绿联媒体库扫描模式：\n        # 1 新添加和修改、2 补充缺失、3 覆盖扫描\n        self._scan_type = self.__resolve_scan_type(scan_mode=scan_mode, scan_type=scan_type)\n        # HTTPS 证书校验开关：默认开启，仅兼容自签证书等场景下可关闭。\n        self._verify_ssl = self.__resolve_verify_ssl(verify_ssl)\n\n        if play_host:\n            self._playhost = UrlUtils.standardize_base_url(play_host).rstrip(\"/\")\n\n        if not self.reconnect():\n            logger.error(f\"请检查服务端地址 {host}\")\n\n    @property\n    def api(self) -> Optional[Api]:\n        return self._api\n\n    def close(self):\n        self.disconnect()\n\n    def is_configured(self) -> bool:\n        return bool(self._host and self._username and self._password)\n\n    def is_authenticated(self) -> bool:\n        return (\n            self.is_configured()\n            and self._api is not None\n            and self._api.token is not None\n            and self._userinfo is not None\n        )\n\n    def is_inactive(self) -> bool:\n        if not self.is_authenticated():\n            return True\n        self._userinfo = self._api.current_user() if self._api else None\n        return self._userinfo is None\n\n    def __session_cache_key(self) -> str:\n        \"\"\"\n        生成当前绿联实例的会话缓存键（基于 host + username）。\n        \"\"\"\n        normalized_host = UrlUtils.standardize_base_url(self._host or \"\").rstrip(\"/\").lower()\n        username = (self._username or \"\").strip().lower()\n        raw = f\"{normalized_host}|{username}\"\n        return hashlib.sha256(raw.encode(\"utf-8\")).hexdigest()\n\n    def __password_digest(self) -> str:\n        \"\"\"\n        存储密码摘要用于检测配置是否变更，避免明文落盘。\n        \"\"\"\n        return hashlib.sha256((self._password or \"\").encode(\"utf-8\")).hexdigest()\n\n    @staticmethod\n    def __load_all_session_cache() -> dict:\n        sessions = SystemConfigOper().get(SystemConfigKey.UgreenSessionCache)\n        return sessions if isinstance(sessions, dict) else {}\n\n    @staticmethod\n    def __save_all_session_cache(sessions: dict):\n        SystemConfigOper().set(SystemConfigKey.UgreenSessionCache, sessions)\n\n    def __remove_persisted_session(self):\n        cache_key = self.__session_cache_key()\n        sessions = self.__load_all_session_cache()\n        if cache_key in sessions:\n            sessions.pop(cache_key, None)\n            self.__save_all_session_cache(sessions)\n\n    def __save_persisted_session(self):\n        if not self._api:\n            return\n        session_state = self._api.export_session_state()\n        if not session_state:\n            return\n\n        sessions = self.__load_all_session_cache()\n        cache_key = self.__session_cache_key()\n        sessions[cache_key] = {\n            **session_state,\n            \"host\": UrlUtils.standardize_base_url(self._host or \"\").rstrip(\"/\"),\n            \"username\": self._username,\n            \"password_digest\": self.__password_digest(),\n            \"updated_at\": int(datetime.now().timestamp()),\n        }\n        self.__save_all_session_cache(sessions)\n\n    def __restore_persisted_session(self) -> bool:\n        cache_key = self.__session_cache_key()\n        sessions = self.__load_all_session_cache()\n        cached = sessions.get(cache_key)\n        if not isinstance(cached, Mapping):\n            return False\n\n        # 配置变更（尤其密码变更）后，不复用旧会话\n        if cached.get(\"password_digest\") != self.__password_digest():\n            logger.info(f\"绿联影视 {self._username} 检测到密码变更，清理旧会话缓存\")\n            self.__remove_persisted_session()\n            return False\n\n        api = Api(host=self._host, verify_ssl=self._verify_ssl)\n        if not api.import_session_state(cached):\n            api.close()\n            self.__remove_persisted_session()\n            return False\n\n        userinfo = api.current_user()\n        if not userinfo:\n            # 会话失效，清理缓存并走正常登录\n            api.close()\n            self.__remove_persisted_session()\n            logger.info(f\"绿联影视 {self._username} 持久化会话已失效，准备重新登录\")\n            return False\n\n        self._api = api\n        self._userinfo = userinfo\n        logger.debug(f\"{self._username} 已复用绿联影视持久化会话\")\n        return True\n\n    def reconnect(self) -> bool:\n        if not self.is_configured():\n            return False\n\n        # 关闭旧连接（不主动登出，避免破坏可复用会话）\n        self.disconnect(logout=False)\n\n        if self.__restore_persisted_session():\n            self.get_librarys()\n            return True\n\n        self._api = Api(host=self._host, verify_ssl=self._verify_ssl)\n        if self._api.login(self._username, self._password) is None:\n            self.__remove_persisted_session()\n            return False\n\n        self._userinfo = self._api.current_user()\n        if not self._userinfo:\n            self.__remove_persisted_session()\n            return False\n\n        # 登录成功后持久化参数，下次优先复用\n        self.__save_persisted_session()\n        logger.debug(f\"{self._username} 成功登录绿联影视\")\n        self.get_librarys()\n        return True\n\n    def disconnect(self, logout: bool = False):\n        if self._api:\n            if logout:\n                # 显式登出时同步清理本地缓存\n                self._api.logout()\n                self.__remove_persisted_session()\n            self._api.close()\n            self._api = None\n            self._userinfo = None\n            logger.debug(f\"{self._username} 已断开绿联影视\")\n\n    @staticmethod\n    def __normalize_dir_path(path: Union[str, Path, None]) -> str:\n        if path is None:\n            return \"\"\n        value = str(path).replace(\"\\\\\", \"/\").rstrip(\"/\")\n        return value\n\n    @staticmethod\n    def __is_subpath(path: Union[str, Path, None], parent: Union[str, Path, None]) -> bool:\n        path_str = Ugreen.__normalize_dir_path(path)\n        parent_str = Ugreen.__normalize_dir_path(parent)\n        if not path_str or not parent_str:\n            return False\n        return path_str == parent_str or path_str.startswith(parent_str + \"/\")\n\n    def __build_image_stream_url(self, source_url: str, size: int = 1) -> Optional[str]:\n        \"\"\"\n        通过绿联 getImaStream 中转图片，规避 scraper.ugnas.com 403 问题。\n        \"\"\"\n        if not self._api:\n            return None\n\n        auth_token = self._api.static_token or self._api.token\n        if not auth_token:\n            return None\n\n        params = {\n            \"app_name\": \"web\",\n            \"name\": source_url,\n            \"size\": size,\n        }\n        if self._api.is_ugk:\n            params[\"ugk\"] = auth_token\n        else:\n            params[\"token\"] = auth_token\n\n        return UrlUtils.combine_url(\n            host=self._api.host,\n            path=\"/ugreen/v2/video/getImaStream\",\n            query=params,\n        )\n\n    def __resolve_image(self, path: Optional[str]) -> Optional[str]:\n        if not path:\n            return None\n        if path.startswith(\"http://\") or path.startswith(\"https://\"):\n            parsed = urlparse(path)\n            if parsed.netloc.lower() == \"scraper.ugnas.com\":\n                # scraper 链接优先改为本机 getImaStream，避免签名过期导致 403\n                if image_stream_url := self.__build_image_stream_url(path):\n                    return image_stream_url\n\n            # 绿联返回的 scraper.ugnas.com 图片常带 auth_key 时效签名，\n            # 过期后会直接 403。这里提前过滤，避免前端出现裂图。\n            if self.__is_expired_signed_image(path):\n                return None\n            return path\n        # 绿联本地图片路径需要额外鉴权头，MP图片代理当前仅支持Cookie，故先忽略本地路径。\n        return None\n\n    @staticmethod\n    def __is_expired_signed_image(url: str) -> bool:\n        \"\"\"\n        判断绿联 scraper 签名图是否已过期。\n\n        auth_key 结构通常为：\n        `{过期时间戳}-{随机串}-...`\n        \"\"\"\n        try:\n            parsed = urlparse(url)\n            if parsed.netloc.lower() != \"scraper.ugnas.com\":\n                return False\n            auth_key = parse_qs(parsed.query).get(\"auth_key\", [None])[0]\n            if not auth_key:\n                return False\n            expire_part = str(auth_key).split(\"-\", 1)[0]\n            expire_ts = int(expire_part)\n            now_ts = int(datetime.now().timestamp())\n            return expire_ts <= now_ts\n        except Exception:\n            return False\n\n    @staticmethod\n    def __parse_year(video_info: dict) -> Optional[Union[str, int]]:\n        year = video_info.get(\"year\")\n        if isinstance(year, int) and year > 0:\n            return year\n        release_date = video_info.get(\"release_date\")\n        if isinstance(release_date, (int, float)) and release_date > 0:\n            try:\n                return datetime.fromtimestamp(release_date).year\n            except Exception:\n                return None\n        return None\n\n    @staticmethod\n    def __map_item_type(video_type: Any) -> Optional[str]:\n        if video_type == 2:\n            return \"Series\"\n        if video_type == 1:\n            return \"Movie\"\n        if video_type == 3:\n            return \"Collection\"\n        if video_type == 0:\n            return \"Folder\"\n        return \"Video\"\n\n    @staticmethod\n    def __build_media_server_item(video_info: dict, play_status: Optional[dict] = None):\n        user_state = schemas.MediaServerItemUserState()\n        if isinstance(play_status, dict):\n            progress = play_status.get(\"progress\")\n            watch_status = play_status.get(\"watch_status\")\n            if watch_status == 2:\n                user_state.played = True\n            if isinstance(progress, (int, float)) and progress > 0:\n                user_state.resume = progress < 1\n                user_state.percentage = progress * 100.0\n            last_play_time = play_status.get(\"last_access_time\") or play_status.get(\"LastPlayTime\")\n            if isinstance(last_play_time, (int, float)) and last_play_time > 0:\n                user_state.last_played_date = str(int(last_play_time))\n\n        tmdb_id = video_info.get(\"tmdb_id\")\n        if not isinstance(tmdb_id, int) or tmdb_id <= 0:\n            tmdb_id = None\n\n        item_id = video_info.get(\"ug_video_info_id\")\n        if item_id is None:\n            return None\n\n        return schemas.MediaServerItem(\n            server=\"ugreen\",\n            library=video_info.get(\"media_lib_set_id\"),\n            item_id=str(item_id),\n            item_type=Ugreen.__map_item_type(video_info.get(\"type\")),\n            title=video_info.get(\"name\"),\n            original_title=video_info.get(\"original_name\"),\n            year=Ugreen.__parse_year(video_info),\n            tmdbid=tmdb_id,\n            user_state=user_state,\n        )\n\n    def __build_root_url(self) -> str:\n        \"\"\"\n        统一返回 NAS Web 根地址作为跳转链接，避免失效深链。\n        \"\"\"\n        host = self._playhost or (self._api.host if self._api else \"\")\n        if not host:\n            return \"\"\n        return f\"{host.rstrip('/')}/\"\n\n    def __build_play_url(self, item_id: Union[str, int], video_type: Any, media_lib_set_id: Any) -> str:\n        # 绿联深链在部分版本会失效，统一回落到 NAS 根地址。\n        return self.__build_root_url()\n\n    def __build_play_item_from_wrapper(self, wrapper: dict) -> Optional[schemas.MediaServerPlayItem]:\n        video_info = wrapper.get(\"video_info\") if isinstance(wrapper.get(\"video_info\"), dict) else wrapper\n        if not isinstance(video_info, dict):\n            return None\n\n        item_id = video_info.get(\"ug_video_info_id\")\n        if item_id is None:\n            return None\n\n        play_status = wrapper.get(\"play_status\") if isinstance(wrapper.get(\"play_status\"), dict) else {}\n        progress = play_status.get(\"progress\") if isinstance(play_status.get(\"progress\"), (int, float)) else 0\n\n        if video_info.get(\"type\") == 2:\n            subtitle = play_status.get(\"tv_name\") or \"剧集\"\n            media_type = MediaType.TV.value\n        else:\n            subtitle = \"电影\" if video_info.get(\"type\") == 1 else \"视频\"\n            media_type = MediaType.MOVIE.value\n\n        image = self.__resolve_image(video_info.get(\"poster_path\")) or self.__resolve_image(\n            video_info.get(\"backdrop_path\")\n        )\n\n        return schemas.MediaServerPlayItem(\n            id=str(item_id),\n            title=video_info.get(\"name\"),\n            subtitle=subtitle,\n            type=media_type,\n            image=image,\n            link=self.__build_play_url(item_id, video_info.get(\"type\"), video_info.get(\"media_lib_set_id\")),\n            percent=max(0.0, min(100.0, progress * 100.0)),\n            server_type=\"ugreen\",\n            use_cookies=False,\n        )\n\n    @staticmethod\n    def __infer_library_type(name: str, path: Optional[str]) -> str:\n        name = name or \"\"\n        path = path or \"\"\n        if \"电视剧\" in path or any(key in name for key in [\"剧\", \"综艺\", \"动漫\", \"纪录片\"]):\n            return MediaType.TV.value\n        if \"电影\" in path or \"电影\" in name:\n            return MediaType.MOVIE.value\n        return MediaType.UNKNOWN.value\n\n    def __is_library_blocked(self, library_id: str) -> bool:\n        return (\n            True\n            if (\n                self._sync_libraries\n                and \"all\" not in self._sync_libraries\n                and str(library_id) not in self._sync_libraries\n            )\n            else False\n        )\n\n    @staticmethod\n    def __resolve_scan_type(\n        scan_mode: Optional[Union[str, int]] = None,\n        scan_type: Optional[Union[str, int]] = None,\n    ) -> int:\n        \"\"\"\n        解析绿联扫描模式并转为 `media_lib_scan_type`。\n\n        支持值：\n        - 1 / new_and_modified: 新添加和修改\n        - 2 / supplement_missing: 补充缺失\n        - 3 / full_override: 覆盖扫描\n        \"\"\"\n        # 优先使用显式 scan_type 数值配置。\n        for value in (scan_type, scan_mode):\n            try:\n                parsed = int(value)  # type: ignore[arg-type]\n                if parsed in (1, 2, 3):\n                    return parsed\n            except Exception:\n                pass\n\n        mode = str(scan_mode or \"\").strip().lower()\n        mode_map = {\n            \"new_and_modified\": 1,\n            \"new_modified\": 1,\n            \"add\": 1,\n            \"added\": 1,\n            \"new\": 1,\n            \"scan_new_modified\": 1,\n            \"supplement_missing\": 2,\n            \"supplement\": 2,\n            \"additional\": 2,\n            \"missing\": 2,\n            \"scan_missing\": 2,\n            \"full_override\": 3,\n            \"override\": 3,\n            \"cover\": 3,\n            \"replace\": 3,\n            \"scan_override\": 3,\n        }\n        return mode_map.get(mode, 2)\n\n    @staticmethod\n    def __resolve_verify_ssl(verify_ssl: Optional[Union[bool, str, int]]) -> bool:\n        if isinstance(verify_ssl, bool):\n            return verify_ssl\n        if verify_ssl is None:\n            return True\n        value = str(verify_ssl).strip().lower()\n        if value in {\"1\", \"true\", \"yes\", \"on\"}:\n            return True\n        if value in {\"0\", \"false\", \"no\", \"off\"}:\n            return False\n        return True\n\n    def __scan_library(self, library_id: str, scan_type: Optional[int] = None) -> bool:\n        if not self._api:\n            return False\n        return self._api.scan(\n            media_lib_set_id=library_id,\n            scan_type=scan_type or self._scan_type,\n            op_type=2,\n        )\n\n    def __load_library_paths(self) -> dict[str, str]:\n        if not self._api:\n            return {}\n\n        paths: dict[str, str] = {}\n        page = 1\n        while True:\n            data = self._api.poster_wall_get_folder(page=page, page_size=100)\n            if not data:\n                break\n\n            for folder in data.get(\"folder_arr\") or []:\n                lib_id = folder.get(\"media_lib_set_id\")\n                lib_path = folder.get(\"path\")\n                if lib_id is not None and lib_path:\n                    paths[str(lib_id)] = str(lib_path)\n\n            if data.get(\"is_last_page\"):\n                break\n            page += 1\n\n        return paths\n\n    def get_librarys(self, hidden: Optional[bool] = False) -> List[schemas.MediaServerLibrary]:\n        if not self.is_authenticated() or not self._api:\n            return []\n\n        media_libs = self._api.media_list()\n        self._library_paths = self.__load_library_paths()\n        libraries = []\n        self._libraries = {}\n\n        for lib in media_libs:\n            lib_id = str(lib.get(\"media_lib_set_id\"))\n            if hidden and self.__is_library_blocked(lib_id):\n                continue\n\n            lib_name = lib.get(\"media_name\") or \"\"\n            lib_path = self._library_paths.get(lib_id)\n            library_type = self.__infer_library_type(lib_name, lib_path)\n\n            poster_paths = lib.get(\"poster_paths\") or []\n            backdrop_paths = lib.get(\"backdrop_paths\") or []\n            image_list = list(\n                filter(\n                    None,\n                    [self.__resolve_image(p) for p in [*poster_paths, *backdrop_paths]],\n                )\n            )\n\n            self._libraries[lib_id] = {\n                \"id\": lib_id,\n                \"name\": lib_name,\n                \"path\": lib_path,\n                \"type\": library_type,\n                \"video_count\": lib.get(\"video_count\") or 0,\n            }\n\n            libraries.append(\n                schemas.MediaServerLibrary(\n                    server=\"ugreen\",\n                    id=lib_id,\n                    name=lib_name,\n                    type=library_type,\n                    path=lib_path,\n                    image_list=image_list,\n                    link=self.__build_root_url(),\n                    server_type=\"ugreen\",\n                    use_cookies=False,\n                )\n            )\n\n        return libraries\n\n    def get_user_count(self) -> int:\n        if not self.is_authenticated() or not self._api:\n            return 0\n        users = self._api.media_lib_users()\n        return len(users)\n\n    def get_medias_count(self) -> schemas.Statistic:\n        if not self.is_authenticated() or not self._api:\n            return schemas.Statistic()\n\n        movie_data = self._api.video_all(classification=-102, page=1, page_size=1) or {}\n        tv_data = self._api.video_all(classification=-103, page=1, page_size=1) or {}\n\n        return schemas.Statistic(\n            movie_count=int(movie_data.get(\"total_num\") or 0),\n            tv_count=int(tv_data.get(\"total_num\") or 0),\n            # 绿联当前不统计剧集总数，返回 None 由前端展示“未获取”。\n            episode_count=None,\n        )\n\n    def authenticate(self, username: str, password: str) -> Optional[str]:\n        if not username or not password or not self._host:\n            return None\n\n        api = Api(self._host, verify_ssl=self._verify_ssl)\n        try:\n            return api.login(username, password)\n        finally:\n            api.logout()\n            api.close()\n\n    @staticmethod\n    def __extract_video_info_list(bucket: Any) -> list[dict]:\n        if not isinstance(bucket, Mapping):\n            return []\n        video_arr = bucket.get(\"video_arr\")\n        if not isinstance(video_arr, list):\n            return []\n        result = []\n        for item in video_arr:\n            if not isinstance(item, Mapping):\n                continue\n            info = item.get(\"video_info\")\n            if isinstance(info, Mapping):\n                result.append(dict(info))\n        return result\n\n    def get_movies(\n        self, title: str, year: Optional[str] = None, tmdb_id: Optional[int] = None\n    ) -> Optional[List[schemas.MediaServerItem]]:\n        if not self.is_authenticated() or not self._api or not title:\n            return None\n\n        data = self._api.search(title)\n        if not data:\n            return []\n\n        movies = []\n        for info in self.__extract_video_info_list(data.get(\"movies_list\")):\n            info_tmdb = info.get(\"tmdb_id\")\n            if tmdb_id and tmdb_id != info_tmdb:\n                continue\n            if title not in [info.get(\"name\"), info.get(\"original_name\")]:\n                continue\n            item_year = info.get(\"year\")\n            if year and str(item_year) != str(year):\n                continue\n            media_item = self.__build_media_server_item(info)\n            if media_item:\n                movies.append(media_item)\n        return movies\n\n    def __search_tv_item(self, title: str, year: Optional[str] = None, tmdb_id: Optional[int] = None) -> Optional[dict]:\n        if not self._api:\n            return None\n        data = self._api.search(title)\n        if not data:\n            return None\n\n        for info in self.__extract_video_info_list(data.get(\"tv_list\")):\n            if tmdb_id and tmdb_id != info.get(\"tmdb_id\"):\n                continue\n            if title not in [info.get(\"name\"), info.get(\"original_name\")]:\n                continue\n            item_year = info.get(\"year\")\n            if year and str(item_year) != str(year):\n                continue\n            return info\n        return None\n\n    def get_tv_episodes(\n        self,\n        item_id: Optional[str] = None,\n        title: Optional[str] = None,\n        year: Optional[str] = None,\n        tmdb_id: Optional[int] = None,\n        season: Optional[int] = None,\n    ) -> tuple[Optional[str], Optional[Dict[int, list]]]:\n        if not self.is_authenticated() or not self._api:\n            return None, None\n\n        if not item_id:\n            if not title:\n                return None, None\n            if not (tv_info := self.__search_tv_item(title, year, tmdb_id)):\n                return None, None\n            found_item_id = tv_info.get(\"ug_video_info_id\")\n            if found_item_id is None:\n                return None, None\n            item_id = str(found_item_id)\n        else:\n            item_id = str(item_id)\n\n        item_info = self.get_iteminfo(item_id)\n        if not item_info:\n            return None, {}\n        if tmdb_id and item_info.tmdbid and tmdb_id != item_info.tmdbid:\n            return None, {}\n\n        tv_detail = self._api.get_tv(item_id, folder_path=\"ALL\")\n        if not tv_detail:\n            return None, {}\n\n        season_map = {}\n        for info in tv_detail.get(\"season_info\") or []:\n            if not isinstance(info, dict):\n                continue\n            category_id = info.get(\"category_id\")\n            season_num = info.get(\"season_num\")\n            if category_id and isinstance(season_num, int):\n                season_map[str(category_id)] = season_num\n\n        season_episodes: Dict[int, list] = {}\n        for ep in tv_detail.get(\"tv_info\") or []:\n            if not isinstance(ep, dict):\n                continue\n            episode = ep.get(\"episode\")\n            if not isinstance(episode, int):\n                continue\n            season_num = season_map.get(str(ep.get(\"category_id\")), 1)\n            if season is not None and season_num != season:\n                continue\n            season_episodes.setdefault(season_num, []).append(episode)\n\n        for season_num in list(season_episodes.keys()):\n            season_episodes[season_num] = sorted(set(season_episodes[season_num]))\n\n        return item_id, season_episodes\n\n    def refresh_root_library(self, scan_mode: Optional[Union[str, int]] = None) -> Optional[bool]:\n        if not self.is_authenticated() or not self._api:\n            return None\n\n        if not self._libraries:\n            self.get_librarys()\n\n        scan_type = (\n            self.__resolve_scan_type(scan_mode=scan_mode)\n            if scan_mode is not None\n            else self._scan_type\n        )\n        results = []\n        for lib_id in self._libraries.keys():\n            logger.info(\n                f\"刷新媒体库：{self._libraries[lib_id].get('name')}（扫描模式: {scan_type}）\"\n            )\n            results.append(self.__scan_library(library_id=lib_id, scan_type=scan_type))\n\n        return all(results) if results else True\n\n    def __match_library_id_by_path(self, path: Optional[Path]) -> Optional[str]:\n        if path is None:\n            return None\n\n        path_str = self.__normalize_dir_path(path)\n        if not self._library_paths:\n            self.get_librarys()\n\n        for lib_id, lib_path in self._library_paths.items():\n            if self.__is_subpath(path_str, lib_path):\n                return lib_id\n        return None\n\n    def refresh_library_by_items(\n        self,\n        items: List[schemas.RefreshMediaItem],\n        scan_mode: Optional[Union[str, int]] = None,\n    ) -> Optional[bool]:\n        if not self.is_authenticated() or not self._api:\n            return None\n\n        scan_type = (\n            self.__resolve_scan_type(scan_mode=scan_mode)\n            if scan_mode is not None\n            else self._scan_type\n        )\n        library_ids = set()\n        for item in items:\n            library_id = self.__match_library_id_by_path(item.target_path)\n            if library_id is None:\n                return self.refresh_root_library(scan_mode=scan_mode)\n            library_ids.add(library_id)\n\n        for library_id in library_ids:\n            lib_name = self._libraries.get(library_id, {}).get(\"name\", library_id)\n            logger.info(f\"刷新媒体库：{lib_name}（扫描模式: {scan_type}）\")\n            if not self.__scan_library(library_id=library_id, scan_type=scan_type):\n                return self.refresh_root_library(scan_mode=scan_mode)\n\n        return True\n\n    @staticmethod\n    def get_webhook_message(body: Any) -> Optional[schemas.WebhookEventInfo]:\n        return None\n\n    def get_iteminfo(self, itemid: str) -> Optional[schemas.MediaServerItem]:\n        if not self.is_authenticated() or not self._api or not itemid:\n            return None\n\n        info = self._api.recently_played_info(itemid)\n        if not info:\n            return None\n\n        video_info = info.get(\"video_info\") if isinstance(info.get(\"video_info\"), dict) else None\n        if not video_info or not video_info.get(\"ug_video_info_id\"):\n            return None\n\n        return self.__build_media_server_item(video_info, info.get(\"play_status\"))\n\n    def _iter_library_videos(self, root_path: str, page_size: int = 100):\n        if not self._api or not root_path:\n            return\n\n        queue = deque([root_path])\n        visited: set[str] = set()\n        max_paths = 20000\n\n        while queue and len(visited) < max_paths:\n            current_path = queue.popleft()\n            if current_path in visited:\n                continue\n            visited.add(current_path)\n\n            page = 1\n            while True:\n                data = self._api.poster_wall_get_folder(\n                    path=current_path,\n                    page=page,\n                    page_size=page_size,\n                    sort_type=1,\n                    order_type=1,\n                )\n                if not data:\n                    break\n\n                for video in data.get(\"video_arr\") or []:\n                    if isinstance(video, dict):\n                        yield video\n\n                for folder in data.get(\"folder_arr\") or []:\n                    if not isinstance(folder, dict):\n                        continue\n                    sub_path = folder.get(\"path\")\n                    if sub_path and sub_path not in visited:\n                        queue.append(str(sub_path))\n\n                if data.get(\"is_last_page\"):\n                    break\n                page += 1\n\n    def get_items(\n        self,\n        parent: Union[str, int],\n        start_index: Optional[int] = 0,\n        limit: Optional[int] = -1,\n    ) -> Generator[schemas.MediaServerItem | None | Any, Any, None]:\n        if not self.is_authenticated() or not self._api:\n            return None\n\n        library_id = str(parent)\n        if not self._library_paths:\n            self.get_librarys()\n\n        root_path = self._library_paths.get(library_id)\n        if not root_path:\n            return None\n\n        skip = max(0, start_index or 0)\n        remain = -1 if limit in [None, -1] else max(0, limit)\n\n        for video in self._iter_library_videos(root_path=root_path):\n            video_type = video.get(\"type\")\n            if video_type not in [1, 2]:\n                continue\n\n            if skip > 0:\n                skip -= 1\n                continue\n\n            item = self.__build_media_server_item(video)\n            if item:\n                yield item\n                if remain != -1:\n                    remain -= 1\n                    if remain <= 0:\n                        break\n\n        return None\n\n    def get_play_url(self, item_id: str) -> Optional[str]:\n        if not self.is_authenticated() or not self._api:\n            return None\n\n        info = self._api.recently_played_info(item_id)\n        if not info:\n            return None\n\n        video_info = info.get(\"video_info\") if isinstance(info.get(\"video_info\"), dict) else None\n        if not video_info:\n            return None\n\n        return self.__build_play_url(\n            item_id=item_id,\n            video_type=video_info.get(\"type\"),\n            media_lib_set_id=video_info.get(\"media_lib_set_id\"),\n        )\n\n    def get_resume(self, num: Optional[int] = 12) -> Optional[List[schemas.MediaServerPlayItem]]:\n        if not self.is_authenticated() or not self._api:\n            return None\n\n        page_size = max(1, num or 12)\n        data = self._api.recently_played(page=1, page_size=page_size)\n        if not data:\n            return []\n\n        ret_resume = []\n        for item in data.get(\"video_arr\") or []:\n            if len(ret_resume) == page_size:\n                break\n            if not isinstance(item, dict):\n                continue\n            video_info = item.get(\"video_info\") if isinstance(item.get(\"video_info\"), dict) else {}\n            library_id = str(video_info.get(\"media_lib_set_id\") or \"\")\n            if self.__is_library_blocked(library_id):\n                continue\n            play_item = self.__build_play_item_from_wrapper(item)\n            if play_item:\n                ret_resume.append(play_item)\n\n        return ret_resume\n\n    def get_latest(self, num: int = 20) -> Optional[List[schemas.MediaServerPlayItem]]:\n        if not self.is_authenticated() or not self._api:\n            return None\n\n        page_size = max(1, num)\n        data = self._api.recently_updated(page=1, page_size=page_size)\n        if not data:\n            return []\n\n        latest = []\n        for item in data.get(\"video_arr\") or []:\n            if len(latest) == page_size:\n                break\n            if not isinstance(item, dict):\n                continue\n            video_info = item.get(\"video_info\") if isinstance(item.get(\"video_info\"), dict) else {}\n            library_id = str(video_info.get(\"media_lib_set_id\") or \"\")\n            if self.__is_library_blocked(library_id):\n                continue\n            play_item = self.__build_play_item_from_wrapper(item)\n            if play_item:\n                latest.append(play_item)\n\n        return latest\n\n    def get_latest_backdrops(self, num: int = 20, remote: bool = False) -> Optional[List[str]]:\n        if not self.is_authenticated() or not self._api:\n            return None\n\n        data = self._api.recently_updated(page=1, page_size=max(1, num))\n        if not data:\n            return []\n\n        images: List[str] = []\n        for item in data.get(\"video_arr\") or []:\n            if len(images) == num:\n                break\n            if not isinstance(item, dict):\n                continue\n\n            video_info = item.get(\"video_info\") if isinstance(item.get(\"video_info\"), dict) else {}\n            library_id = str(video_info.get(\"media_lib_set_id\") or \"\")\n            if self.__is_library_blocked(library_id):\n                continue\n\n            image = self.__resolve_image(video_info.get(\"backdrop_path\")) or self.__resolve_image(\n                video_info.get(\"poster_path\")\n            )\n            if image:\n                images.append(image)\n\n        return images\n\n    @staticmethod\n    def get_image_cookies(image_url: str):\n        # 绿联图片流接口依赖加密鉴权头，当前图片代理仅支持Cookie注入。\n        return None\n"
  },
  {
    "path": "app/modules/vocechat/__init__.py",
    "content": "import json\nfrom typing import Optional, Union, List, Tuple, Any, Dict\n\nfrom app.core.context import Context, MediaInfo\nfrom app.log import logger\nfrom app.modules import _ModuleBase, _MessageBase\nfrom app.modules.vocechat.vocechat import VoceChat\nfrom app.schemas import MessageChannel, CommingMessage, Notification\nfrom app.schemas.types import ModuleType\n\n\nclass VoceChatModule(_ModuleBase, _MessageBase[VoceChat]):\n\n    def init_module(self) -> None:\n        \"\"\"\n        初始化模块\n        \"\"\"\n        super().init_service(service_name=VoceChat.__name__.lower(),\n                             service_type=VoceChat)\n        self._channel = MessageChannel.VoceChat\n\n    @staticmethod\n    def get_name() -> str:\n        return \"VoceChat\"\n\n    @staticmethod\n    def get_type() -> ModuleType:\n        \"\"\"\n        获取模块类型\n        \"\"\"\n        return ModuleType.Notification\n\n    @staticmethod\n    def get_subtype() -> MessageChannel:\n        \"\"\"\n        获取模块子类型\n        \"\"\"\n        return MessageChannel.VoceChat\n\n    @staticmethod\n    def get_priority() -> int:\n        \"\"\"\n        获取模块优先级，数字越小优先级越高，只有同一接口下优先级才生效\n        \"\"\"\n        return 4\n\n    def stop(self):\n        pass\n\n    def test(self) -> Optional[Tuple[bool, str]]:\n        \"\"\"\n        测试模块连接性\n        \"\"\"\n        if not self.get_instances():\n            return None\n        for name, client in self.get_instances().items():\n            state = client.get_state()\n            if not state:\n                return False, f\"VoceChat {name} 未就绪\"\n        return True, \"\"\n\n    def init_setting(self) -> Tuple[str, Union[str, bool]]:\n        pass\n\n    def message_parser(self, source: str, body: Any, form: Any,\n                       args: Any) -> Optional[CommingMessage]:\n        \"\"\"\n        解析消息内容，返回字典，注意以下约定值：\n        userid: 用户ID\n        username: 用户名\n        text: 内容\n        :param source: 消息来源\n        :param body: 请求体\n        :param form: 表单\n        :param args: 参数\n        :return: 渠道、消息体\n        \"\"\"\n        try:\n            \"\"\"\n            {\n              \"created_at\": 1672048481664, //消息创建的时间戳\n              \"detail\": {\n                \"content\": \"hello this is my message to you\", //消息内容\n                \"content_type\": \"text/plain\", //消息类型，text/plain：纯文本消息，text/markdown：markdown消息，vocechat/file：文件类消息\n                \"expires_in\": null, //消息过期时长，如果有大于0数字，说明该消息是个限时消息\n                \"properties\": null, //一些有关消息的元数据，比如at信息，文件消息的具体类型信息，如果是个图片消息，还会有一些宽高，图片名称等元信息\n                \"type\": \"normal\" //消息类型，normal代表是新消息\n              },\n              \"from_uid\": 7910, //来自于谁\n              \"mid\": 2978, //消息ID\n              \"target\": { \"gid\": 2 } //发送给谁，gid代表是发送给频道，uid代表是发送给个人，此时的数据结构举例：{\"uid\":1}\n            }\n            \"\"\"\n            # 获取服务配置\n            client_config = self.get_config(source)\n            if not client_config:\n                return None\n            # 报文体\n            msg_body = json.loads(body)\n            # 类型\n            msg_type = msg_body.get(\"detail\", {}).get(\"type\")\n            if msg_type != \"normal\":\n                # 非新消息\n                return None\n            logger.debug(f\"收到VoceChat请求：{msg_body}\")\n            # 文本内容\n            content = msg_body.get(\"detail\", {}).get(\"content\")\n            # 用户ID\n            gid = msg_body.get(\"target\", {}).get(\"gid\")\n            channel_id = client_config.config.get(\"channel_id\")\n            if gid and str(gid) == str(channel_id):\n                # 来自监听频道的消息\n                userid = f\"GID#{gid}\"\n            else:\n                # 来自个人的消息\n                userid = f\"UID#{msg_body.get('from_uid')}\"\n\n            # 处理消息内容\n            if content and userid:\n                logger.info(f\"收到来自 {client_config.name} 的VoceChat消息：userid={userid}, text={content}\")\n                return CommingMessage(channel=MessageChannel.VoceChat, source=client_config.name,\n                                      userid=userid, username=userid, text=content)\n        except Exception as err:\n            logger.error(f\"VoceChat消息处理发生错误：{str(err)}\")\n        return None\n\n    def post_message(self, message: Notification, **kwargs) -> None:\n        \"\"\"\n        发送消息\n        :param message: 消息内容\n        :return: 成功或失败\n        \"\"\"\n        for conf in self.get_configs().values():\n            if not self.check_message(message, conf.name):\n                continue\n            targets = message.targets\n            userid = message.userid\n            if not message.userid and targets:\n                userid = targets.get('telegram_userid')\n            client: VoceChat = self.get_instance(conf.name)\n            if client:\n                client.send_msg(title=message.title, text=message.text,\n                                userid=userid, link=message.link)\n\n    def post_medias_message(self, message: Notification, medias: List[MediaInfo]) -> None:\n        \"\"\"\n        发送媒体信息选择列表\n        :param message: 消息内容\n        :param medias: 媒体列表\n        :return: 成功或失败\n        \"\"\"\n        for conf in self.get_configs().values():\n            if not self.check_message(message, conf.name):\n                continue\n            client: VoceChat = self.get_instance(conf.name)\n            if client:\n                client.send_msg(title=message.title, userid=message.userid)\n                client.send_medias_msg(title=message.title, medias=medias,\n                                       userid=message.userid, link=message.link)\n\n    def post_torrents_message(self, message: Notification, torrents: List[Context]) -> None:\n        \"\"\"\n        发送种子信息选择列表\n        :param message: 消息内容\n        :param torrents: 种子列表\n        :return: 成功或失败\n        \"\"\"\n        for conf in self.get_configs().values():\n            if not self.check_message(message, conf.name):\n                continue\n            targets = message.targets\n            userid = message.userid\n            if not userid and targets is not None:\n                userid = targets.get('vocechat_userid')\n                if not userid:\n                    logger.warn(f\"用户没有指定 VoceChat用户ID，消息无法发送\")\n                    return\n            client: VoceChat = self.get_instance(conf.name)\n            if client:\n                client.send_torrents_msg(title=message.title, torrents=torrents,\n                                         userid=userid, link=message.link)\n\n    def register_commands(self, commands: Dict[str, dict]):\n        pass\n"
  },
  {
    "path": "app/modules/vocechat/vocechat.py",
    "content": "import re\nimport threading\nfrom typing import Optional, List\n\nfrom app.core.context import MediaInfo, Context\nfrom app.core.metainfo import MetaInfo\nfrom app.log import logger\nfrom app.utils.common import retry\nfrom app.utils.http import RequestUtils\nfrom app.utils.string import StringUtils\n\nlock = threading.Lock()\n\n\nclass VoceChat:\n    # host\n    _host = None\n    # apikey\n    _apikey = None\n    # 频道ID\n    _channel_id = None\n    # 请求对象\n    _client = None\n\n    def __init__(self, VOCECHAT_HOST: Optional[str] = None, VOCECHAT_API_KEY: Optional[str] = None, VOCECHAT_CHANNEL_ID: Optional[str] = None, **kwargs):\n        \"\"\"\n        初始化\n        \"\"\"\n        if not VOCECHAT_HOST or not VOCECHAT_API_KEY or not VOCECHAT_CHANNEL_ID:\n            logger.error(\"VoceChat配置不完整！\")\n            return\n        self._host = VOCECHAT_HOST\n        if self._host:\n            if not self._host.endswith(\"/\"):\n                self._host += \"/\"\n            if not self._host.startswith(\"http\"):\n                self._playhost = \"http://\" + self._host\n        self._apikey = VOCECHAT_API_KEY\n        self._channel_id = VOCECHAT_CHANNEL_ID\n        if self._apikey and self._host and self._channel_id:\n            self._client = RequestUtils(headers={\n                \"content-type\": \"text/markdown\",\n                \"x-api-key\": self._apikey,\n                \"accept\": \"application/json; charset=utf-8\"\n            })\n\n    def get_state(self):\n        \"\"\"\n        获取状态\n        \"\"\"\n        return True if self.get_groups() else False\n\n    def get_groups(self):\n        \"\"\"\n        获取频道列表\n        \"\"\"\n        if not self._client:\n            return None\n        result = self._client.get_res(f\"{self._host}api/bot\")\n        if result and result.status_code == 200:\n            return result.json()\n\n    def send_msg(self, title: str, text: Optional[str] = None,\n                 userid: Optional[str] = None, link: Optional[str] = None) -> Optional[bool]:\n        \"\"\"\n        微信消息发送入口，支持文本、图片、链接跳转、指定发送对象\n        :param title: 消息标题\n        :param text: 消息内容\n        :param userid: 消息发送对象的ID，为空则发给所有人\n        :param link: 消息链接\n        :return: 发送状态，错误信息\n        \"\"\"\n        if not self._client:\n            return None\n\n        if not title and not text:\n            logger.warn(\"标题和内容不能同时为空\")\n            return False\n\n        try:\n            if text:\n                caption = f\"**{title}**\\n{text}\"\n            else:\n                caption = f\"**{title}**\"\n\n            if link:\n                caption = f\"{caption}\\n[查看详情]({link})\"\n\n            if userid:\n                chat_id = userid\n            else:\n                chat_id = f\"GID#{self._channel_id}\"\n\n            return self.__send_request(userid=chat_id, caption=caption)\n\n        except Exception as msg_e:\n            logger.error(f\"发送消息失败：{msg_e}\")\n            return False\n\n    def send_medias_msg(self, title: str, medias: List[MediaInfo],\n                        userid: Optional[str] = None, link: Optional[str] = None) -> Optional[bool]:\n        \"\"\"\n        发送列表类消息\n        \"\"\"\n        if not self._client:\n            return None\n\n        try:\n            index, caption = 1, \"**%s**\" % title\n            for media in medias:\n                if media.vote_average:\n                    caption = \"%s\\n%s. [%s](%s)\\n_%s，%s_\" % (caption,\n                                                             index,\n                                                             media.title_year,\n                                                             media.detail_link,\n                                                             f\"类型：{media.type.value}\",\n                                                             f\"评分：{media.vote_average}\")\n                else:\n                    caption = \"%s\\n%s. [%s](%s)\\n_%s_\" % (caption,\n                                                          index,\n                                                          media.title_year,\n                                                          media.detail_link,\n                                                          f\"类型：{media.type.value}\")\n                index += 1\n\n            if link:\n                caption = f\"{caption}\\n[查看详情]({link})\"\n\n            if userid:\n                chat_id = userid\n            else:\n                chat_id = f\"GID#{self._channel_id}\"\n\n            return self.__send_request(userid=chat_id, caption=caption)\n\n        except Exception as msg_e:\n            logger.error(f\"发送消息失败：{msg_e}\")\n            return False\n\n    def send_torrents_msg(self, torrents: List[Context],\n                          userid: Optional[str] = None,\n                          title: Optional[str] = None,\n                          link: Optional[str] = None) -> Optional[bool]:\n        \"\"\"\n        发送列表消息\n        \"\"\"\n        if not self._client:\n            return None\n\n        if not torrents:\n            return False\n\n        try:\n            index, caption = 1, \"**%s**\" % title\n            for context in torrents:\n                torrent = context.torrent_info\n                site_name = torrent.site_name\n                meta = MetaInfo(torrent.title, torrent.description)\n                link = torrent.page_url\n                title = f\"{meta.season_episode} \" \\\n                        f\"{meta.resource_term} \" \\\n                        f\"{meta.video_term} \" \\\n                        f\"{meta.release_group}\"\n                title = re.sub(r\"\\s+\", \" \", title).strip()\n                free = torrent.volume_factor\n                seeder = f\"{torrent.seeders}↑\"\n                caption = f\"{caption}\\n{index}.【{site_name}】[{title}]({link}) \" \\\n                          f\"{StringUtils.str_filesize(torrent.size)} {free} {seeder}\"\n                index += 1\n\n            if link:\n                caption = f\"{caption}\\n[查看详情]({link})\"\n\n            if userid:\n                chat_id = userid\n            else:\n                chat_id = f\"GID#{self._channel_id}\"\n\n            return self.__send_request(userid=chat_id, caption=caption)\n\n        except Exception as msg_e:\n            logger.error(f\"发送消息失败：{msg_e}\")\n            return False\n\n    @retry(Exception, logger=logger)\n    def __send_request(self, userid: str, caption: str) -> bool:\n        \"\"\"\n        向VoceChat发送报文\n        userid格式：UID#xxx / GID#xxx\n        \"\"\"\n        if not self._client:\n            return False\n        if userid.startswith(\"GID#\"):\n            action = \"send_to_group\"\n        else:\n            action = \"send_to_user\"\n        idstr = userid[4:]\n\n        with lock:\n            result = self._client.post_res(f\"{self._host}api/bot/{action}/{idstr}\", data=caption.encode(\"utf-8\"))\n            if result and result.status_code == 200:\n                return True\n            elif result is not None:\n                logger.error(f\"VoceChat发送消息失败，错误码：{result.status_code}\")\n                return False\n            else:\n                raise Exception(\"VoceChat发送消息失败，连接失败\")\n"
  },
  {
    "path": "app/modules/webpush/__init__.py",
    "content": "import json\nfrom typing import Union, Tuple\n\nfrom pywebpush import webpush, WebPushException\n\nfrom app.core.config import global_vars, settings\nfrom app.log import logger\nfrom app.modules import _ModuleBase, _MessageBase\nfrom app.schemas import Notification\nfrom app.schemas.types import ModuleType, MessageChannel\n\n\nclass WebPushModule(_ModuleBase, _MessageBase):\n\n    def init_module(self) -> None:\n        \"\"\"\n        初始化模块\n        \"\"\"\n        super().init_service(service_name=self.get_name().lower())\n        self._channel = MessageChannel.WebPush\n\n    @staticmethod\n    def get_name() -> str:\n        return \"WebPush\"\n\n    @staticmethod\n    def get_type() -> ModuleType:\n        \"\"\"\n        获取模块类型\n        \"\"\"\n        return ModuleType.Notification\n\n    @staticmethod\n    def get_subtype() -> MessageChannel:\n        \"\"\"\n        获取模块子类型\n        \"\"\"\n        return MessageChannel.WebPush\n\n    @staticmethod\n    def get_priority() -> int:\n        \"\"\"\n        获取模块优先级，数字越小优先级越高，只有同一接口下优先级才生效\n        \"\"\"\n        return 6\n\n    def stop(self):\n        pass\n\n    def test(self) -> Tuple[bool, str]:\n        \"\"\"\n        测试模块连接性\n        \"\"\"\n        return True, \"\"\n\n    def init_setting(self) -> Tuple[str, Union[str, bool]]:\n        pass\n\n    def post_message(self, message: Notification, **kwargs) -> None:\n        \"\"\"\n        发送消息\n        :param message: 消息内容\n        :return: 成功或失败\n        \"\"\"\n        for conf in self.get_configs().values():\n            if not self.check_message(message, conf.name):\n                continue\n            webpush_users = conf.config.get(\"WEBPUSH_USERNAME\") or \"\"\n            if webpush_users:\n                # 设定了接收用户时，非该用户的消息不接收\n                if not message.username or message.username not in webpush_users.split(\",\"):\n                    continue\n            if not message.title and not message.text:\n                logger.warn(\"标题和内容不能同时为空\")\n                return\n            try:\n                if message.title:\n                    caption = message.title\n                    content = message.text\n                else:\n                    caption = message.text\n                    content = \"\"\n                for sub in global_vars.get_subscriptions():\n                    logger.debug(f\"给 {sub} 发送WebPush：{caption} {content}\")\n                    try:\n                        webpush(\n                            subscription_info=sub,\n                            data=json.dumps({\n                                \"title\": caption,\n                                \"body\": content,\n                                \"url\": message.link or \"/?shotcut=message\"\n                            }),\n                            vapid_private_key=settings.VAPID.get(\"privateKey\"),\n                            vapid_claims={\n                                \"sub\": settings.VAPID.get(\"subject\")\n                            },\n                        )\n                    except WebPushException as err:\n                        logger.error(f\"WebPush发送失败: {str(err)}\")\n\n            except Exception as msg_e:\n                logger.error(f\"发送消息失败：{msg_e}\")\n"
  },
  {
    "path": "app/modules/wechat/WXBizMsgCrypt3.py",
    "content": "#!/usr/bin/env python\n# -*- encoding:utf-8 -*-\n\n\"\"\" 对企业微信发送给企业后台的消息加解密示例代码.\n@copyright: Copyright (c) 1998-2014 Tencent Inc.\n\n\"\"\"\nimport base64\nimport hashlib\n# ------------------------------------------------------------------------\nimport logging\nimport random\nimport socket\nimport struct\nimport time\nimport xml.etree.cElementTree as ET\n\nfrom Crypto.Cipher import AES\n\n# Description:定义错误码含义\n#########################################################################\nWXBizMsgCrypt_OK = 0\nWXBizMsgCrypt_ValidateSignature_Error = -40001\nWXBizMsgCrypt_ParseXml_Error = -40002\nWXBizMsgCrypt_ComputeSignature_Error = -40003\nWXBizMsgCrypt_IllegalAesKey = -40004\nWXBizMsgCrypt_ValidateCorpid_Error = -40005\nWXBizMsgCrypt_EncryptAES_Error = -40006\nWXBizMsgCrypt_DecryptAES_Error = -40007\nWXBizMsgCrypt_IllegalBuffer = -40008\nWXBizMsgCrypt_EncodeBase64_Error = -40009\nWXBizMsgCrypt_DecodeBase64_Error = -40010\nWXBizMsgCrypt_GenReturnXml_Error = -40011\n\n\"\"\"\n关于Crypto.Cipher模块，ImportError: No module named 'Crypto'解决方案\n请到官方网站 https://www.dlitz.net/software/pycrypto/ 下载pycrypto。\n下载后，按照README中的“Installation”小节的提示进行pycrypto安装。\n\"\"\"\n\n\nclass FormatException(Exception):\n    pass\n\n\ndef throw_exception(message, exception_class=FormatException):\n    \"\"\"my define raise exception function\"\"\"\n    raise exception_class(message)\n\n\nclass SHA1:\n    \"\"\"计算企业微信的消息签名接口\"\"\"\n\n    @staticmethod\n    def getSHA1(token, timestamp, nonce, encrypt):\n        \"\"\"用SHA1算法生成安全签名\n        @param token:  票据\n        @param timestamp: 时间戳\n        @param encrypt: 密文\n        @param nonce: 随机字符串\n        @return: 安全签名\n        \"\"\"\n        try:\n            sortlist = [token, timestamp, nonce, encrypt]\n            sortlist.sort()\n            sha = hashlib.sha1()\n            sha.update(\"\".join(sortlist).encode())\n            return WXBizMsgCrypt_OK, sha.hexdigest()\n        except Exception as e:\n            logger = logging.getLogger()\n            logger.error(e)\n            return WXBizMsgCrypt_ComputeSignature_Error, None\n\n\nclass XMLParse:\n    \"\"\"提供提取消息格式中的密文及生成回复消息格式的接口\"\"\"\n\n    # xml消息模板\n    AES_TEXT_RESPONSE_TEMPLATE = \"\"\"<xml>\n<Encrypt><![CDATA[%(msg_encrypt)s]]></Encrypt>\n<MsgSignature><![CDATA[%(msg_signaturet)s]]></MsgSignature>\n<TimeStamp>%(timestamp)s</TimeStamp>\n<Nonce><![CDATA[%(nonce)s]]></Nonce>\n</xml>\"\"\"\n\n    @staticmethod\n    def extract(xmltext):\n        \"\"\"提取出xml数据包中的加密消息\n        @param xmltext: 待提取的xml字符串\n        @return: 提取出的加密消息字符串\n        \"\"\"\n        try:\n            xml_tree = ET.fromstring(xmltext)\n            encrypt = xml_tree.find(\"Encrypt\")\n            return WXBizMsgCrypt_OK, encrypt.text\n        except Exception as e:\n            logger = logging.getLogger()\n            logger.error(e)\n            return WXBizMsgCrypt_ParseXml_Error, None\n\n    def generate(self, encrypt, signature, timestamp, nonce):\n        \"\"\"生成xml消息\n        @param encrypt: 加密后的消息密文\n        @param signature: 安全签名\n        @param timestamp: 时间戳\n        @param nonce: 随机字符串\n        @return: 生成的xml字符串\n        \"\"\"\n        resp_dict = {\n            'msg_encrypt': encrypt,\n            'msg_signaturet': signature,\n            'timestamp': timestamp,\n            'nonce': nonce,\n        }\n        resp_xml = self.AES_TEXT_RESPONSE_TEMPLATE % resp_dict\n        return resp_xml\n\n\nclass PKCS7Encoder:\n    \"\"\"提供基于PKCS7算法的加解密接口\"\"\"\n\n    block_size = 32\n\n    def encode(self, text):\n        \"\"\" 对需要加密的明文进行填充补位\n        @param text: 需要进行填充补位操作的明文\n        @return: 补齐明文字符串\n        \"\"\"\n        text_length = len(text)\n        # 计算需要填充的位数\n        amount_to_pad = self.block_size - (text_length % self.block_size)\n        if amount_to_pad == 0:\n            amount_to_pad = self.block_size\n        # 获得补位所用的字符\n        pad = chr(amount_to_pad)\n        return text + (pad * amount_to_pad).encode()\n\n    @staticmethod\n    def decode(decrypted):\n        \"\"\"删除解密后明文的补位字符\n        @param decrypted: 解密后的明文\n        @return: 删除补位字符后的明文\n        \"\"\"\n        pad = ord(decrypted[-1])\n        if pad < 1 or pad > 32:\n            pad = 0\n        return decrypted[:-pad]\n\n\nclass Prpcrypt(object):\n    \"\"\"提供接收和推送给企业微信消息的加解密接口\"\"\"\n\n    def __init__(self, key):\n\n        # self.key = base64.b64decode(key+\"=\")\n        self.key = key\n        # 设置加解密模式为AES的CBC模式\n        self.mode = AES.MODE_CBC\n\n    def encrypt(self, text, receiveid):\n        \"\"\"对明文进行加密\n        @param text: 需要加密的明文\n        @param receiveid: receiveid\n        @return: 加密得到的字符串\n        \"\"\"\n        # 16位随机字符串添加到明文开头\n        text = text.encode()\n        text = self.get_random_str() + struct.pack(\"I\", socket.htonl(len(text))) + text + receiveid.encode()\n\n        # 使用自定义的填充方式对明文进行补位填充\n        pkcs7 = PKCS7Encoder()\n        text = pkcs7.encode(text)\n        # 加密\n        cryptor = AES.new(self.key, self.mode, self.key[:16])\n        try:\n            ciphertext = cryptor.encrypt(text)\n            # 使用BASE64对加密后的字符串进行编码\n            return WXBizMsgCrypt_OK, base64.b64encode(ciphertext)\n        except Exception as e:\n            logger = logging.getLogger()\n            logger.error(e)\n            return WXBizMsgCrypt_EncryptAES_Error, None\n\n    def decrypt(self, text, receiveid):\n        \"\"\"对解密后的明文进行补位删除\n        @param text: 密文\n        @param receiveid: receiveid\n        @return: 删除填充补位后的明文\n        \"\"\"\n        try:\n            cryptor = AES.new(self.key, self.mode, self.key[:16])\n            # 使用BASE64对密文进行解码，然后AES-CBC解密\n            plain_text = cryptor.decrypt(base64.b64decode(text))\n        except Exception as e:\n            logger = logging.getLogger()\n            logger.error(e)\n            return WXBizMsgCrypt_DecryptAES_Error, None\n        try:\n            pad = plain_text[-1]\n            # 去掉补位字符串\n            # pkcs7 = PKCS7Encoder()\n            # plain_text = pkcs7.encode(plain_text)\n            # 去除16位随机字符串\n            content = plain_text[16:-pad]\n            xml_len = socket.ntohl(struct.unpack(\"I\", content[: 4])[0])\n            xml_content = content[4: xml_len + 4]\n            from_receiveid = content[xml_len + 4:]\n        except Exception as e:\n            logger = logging.getLogger()\n            logger.error(e)\n            return WXBizMsgCrypt_IllegalBuffer, None\n\n        if from_receiveid.decode('utf8') != receiveid:\n            return WXBizMsgCrypt_ValidateCorpid_Error, None\n        return 0, xml_content\n\n    @staticmethod\n    def get_random_str():\n        \"\"\" 随机生成16位字符串\n        @return: 16位字符串\n        \"\"\"\n        return str(random.randint(1000000000000000, 9999999999999999)).encode()\n\n\nclass WXBizMsgCrypt(object):\n    # 构造函数\n    def __init__(self, sToken, sEncodingAESKey, sReceiveId):\n        try:\n            self.key = base64.b64decode(sEncodingAESKey + \"=\")\n            assert len(self.key) == 32\n        except Exception as err:\n            print(str(err))\n            throw_exception(\"[error]: EncodingAESKey unvalid !\", FormatException)\n            # return WXBizMsgCrypt_IllegalAesKey,None\n        self.m_sToken = sToken\n        self.m_sReceiveId = sReceiveId\n\n        # 验证URL\n        # @param sMsgSignature: 签名串，对应URL参数的msg_signature\n        # @param sTimeStamp: 时间戳，对应URL参数的timestamp\n        # @param sNonce: 随机串，对应URL参数的nonce\n        # @param sEchoStr: 随机串，对应URL参数的echostr\n        # @param sReplyEchoStr: 解密之后的echostr，当return返回0时有效\n        # @return：成功0，失败返回对应的错误码\n\n    def VerifyURL(self, sMsgSignature, sTimeStamp, sNonce, sEchoStr):\n        sha1 = SHA1()\n        ret, signature = sha1.getSHA1(self.m_sToken, sTimeStamp, sNonce, sEchoStr)\n        if ret != 0:\n            return ret, None\n        if not signature == sMsgSignature:\n            return WXBizMsgCrypt_ValidateSignature_Error, None\n        pc = Prpcrypt(self.key)\n        ret, sReplyEchoStr = pc.decrypt(sEchoStr, self.m_sReceiveId)\n        return ret, sReplyEchoStr\n\n    def EncryptMsg(self, sReplyMsg, sNonce, timestamp=None):\n        # 将企业回复用户的消息加密打包\n        # @param sReplyMsg: 企业号待回复用户的消息，xml格式的字符串\n        # @param sTimeStamp: 时间戳，可以自己生成，也可以用URL参数的timestamp,如为None则自动用当前时间\n        # @param sNonce: 随机串，可以自己生成，也可以用URL参数的nonce\n        # sEncryptMsg: 加密后的可以直接回复用户的密文，包括msg_signature, timestamp, nonce, encrypt的xml格式的字符串,\n        # return：成功0，sEncryptMsg,失败返回对应的错误码None\n        pc = Prpcrypt(self.key)\n        ret, encrypt = pc.encrypt(sReplyMsg, self.m_sReceiveId)\n        encrypt = encrypt.decode('utf8')\n        if ret != 0:\n            return ret, None\n        if timestamp is None:\n            timestamp = str(int(time.time()))\n        # 生成安全签名\n        sha1 = SHA1()\n        ret, signature = sha1.getSHA1(self.m_sToken, timestamp, sNonce, encrypt)\n        if ret != 0:\n            return ret, None\n        xmlParse = XMLParse()\n        return ret, xmlParse.generate(encrypt, signature, timestamp, sNonce)\n\n    def DecryptMsg(self, sPostData, sMsgSignature, sTimeStamp, sNonce):\n        # 检验消息的真实性，并且获取解密后的明文\n        # @param sMsgSignature: 签名串，对应URL参数的msg_signature\n        # @param sTimeStamp: 时间戳，对应URL参数的timestamp\n        # @param sNonce: 随机串，对应URL参数的nonce\n        # @param sPostData: 密文，对应POST请求的数据\n        #  xml_content: 解密后的原文，当return返回0时有效\n        # @return: 成功0，失败返回对应的错误码\n        # 验证安全签名\n        xmlParse = XMLParse()\n        ret, encrypt = xmlParse.extract(sPostData)\n        if ret != 0:\n            return ret, None\n        sha1 = SHA1()\n        ret, signature = sha1.getSHA1(self.m_sToken, sTimeStamp, sNonce, encrypt)\n        if ret != 0:\n            return ret, None\n        if not signature == sMsgSignature:\n            return WXBizMsgCrypt_ValidateSignature_Error, None\n        pc = Prpcrypt(self.key)\n        ret, xml_content = pc.decrypt(encrypt, self.m_sReceiveId)\n        return ret, xml_content\n"
  },
  {
    "path": "app/modules/wechat/__init__.py",
    "content": "import copy\nimport xml.dom.minidom\nfrom typing import Optional, Union, List, Tuple, Any, Dict\n\nfrom app.core.context import Context, MediaInfo\nfrom app.core.event import eventmanager\nfrom app.log import logger\nfrom app.modules import _ModuleBase, _MessageBase\nfrom app.modules.wechat.WXBizMsgCrypt3 import WXBizMsgCrypt\nfrom app.modules.wechat.wechat import WeChat\nfrom app.modules.wechat.wechatbot import WeChatBot\nfrom app.schemas import MessageChannel, CommingMessage, Notification, CommandRegisterEventData\nfrom app.schemas.types import ModuleType, ChainEventType\nfrom app.utils.dom import DomUtils\nfrom app.utils.structures import DictUtils\n\n\nclass WechatModule(_ModuleBase, _MessageBase[WeChat]):\n\n    def init_module(self) -> None:\n        \"\"\"\n        初始化模块\n        \"\"\"\n        self.stop()\n        super().init_service(service_name=WeChat.__name__.lower(),\n                             service_type=self._create_client)\n        self._channel = MessageChannel.Wechat\n\n    @staticmethod\n    def get_name() -> str:\n        return \"微信\"\n\n    @staticmethod\n    def get_type() -> ModuleType:\n        \"\"\"\n        获取模块类型\n        \"\"\"\n        return ModuleType.Notification\n\n    @staticmethod\n    def get_subtype() -> MessageChannel:\n        \"\"\"\n        获取模块的子类型\n        \"\"\"\n        return MessageChannel.Wechat\n\n    @staticmethod\n    def get_priority() -> int:\n        \"\"\"\n        获取模块优先级，数字越小优先级越高，只有同一接口下优先级才生效\n        \"\"\"\n        return 1\n\n    def stop(self):\n        for client in self.get_instances().values():\n            if hasattr(client, \"stop\"):\n                try:\n                    client.stop()\n                except Exception as err:\n                    logger.error(f\"停止微信模块实例失败：{err}\")\n\n    @staticmethod\n    def _is_bot_mode(config: dict) -> bool:\n        return (config or {}).get(\"WECHAT_MODE\", \"app\") == \"bot\"\n\n    @classmethod\n    def _create_client(cls, conf):\n        if cls._is_bot_mode(conf.config):\n            return WeChatBot(name=conf.name, **conf.config)\n        return WeChat(name=conf.name, **conf.config)\n\n    def test(self) -> Optional[Tuple[bool, str]]:\n        \"\"\"\n        测试模块连接性\n        \"\"\"\n        if not self.get_instances():\n            return None\n        for name, client in self.get_instances().items():\n            state = client.get_state()\n            if not state:\n                return False, f\"企业微信 {name} 未就绪\"\n        return True, \"\"\n\n    def init_setting(self) -> Tuple[str, Union[str, bool]]:\n        pass\n\n    def message_parser(self, source: str, body: Any, form: Any,\n                       args: Any) -> Optional[CommingMessage]:\n        \"\"\"\n        解析消息内容，返回字典，注意以下约定值：\n        userid: 用户ID\n        username: 用户名\n        text: 内容\n        :param source: 消息来源\n        :param body: 请求体\n        :param form: 表单\n        :param args: 参数\n        :return: 渠道、消息体\n        \"\"\"\n        try:\n            # 获取服务配置\n            client_config = self.get_config(source)\n            if not client_config:\n                return None\n            if self._is_bot_mode(client_config.config):\n                return None\n            client: WeChat = self.get_instance(client_config.name)\n            # URL参数\n            sVerifyMsgSig = args.get(\"msg_signature\")\n            sVerifyTimeStamp = args.get(\"timestamp\")\n            sVerifyNonce = args.get(\"nonce\")\n            if not sVerifyMsgSig or not sVerifyTimeStamp or not sVerifyNonce:\n                logger.debug(f\"微信请求参数错误：{args}\")\n                return None\n            # 解密模块\n            wxcpt = WXBizMsgCrypt(sToken=client_config.config.get('WECHAT_TOKEN'),\n                                  sEncodingAESKey=client_config.config.get('WECHAT_ENCODING_AESKEY'),\n                                  sReceiveId=client_config.config.get('WECHAT_CORPID'))\n            # 报文数据\n            if not body:\n                logger.debug(f\"微信请求数据为空\")\n                return None\n            logger.debug(f\"收到微信请求：{body}\")\n            ret, sMsg = wxcpt.DecryptMsg(sPostData=body,\n                                         sMsgSignature=sVerifyMsgSig,\n                                         sTimeStamp=sVerifyTimeStamp,\n                                         sNonce=sVerifyNonce)\n            if ret != 0:\n                logger.error(f\"解密微信消息失败 DecryptMsg ret = {ret}\")\n                return None\n            # 解析XML报文\n            \"\"\"\n            1、消息格式：\n            <xml>\n               <ToUserName><![CDATA[toUser]]></ToUserName>\n               <FromUserName><![CDATA[fromUser]]></FromUserName>\n               <CreateTime>1348831860</CreateTime>\n               <MsgType><![CDATA[text]]></MsgType>\n               <Content><![CDATA[this is a test]]></Content>\n               <MsgId>1234567890123456</MsgId>\n               <AgentID>1</AgentID>\n            </xml>\n            2、事件格式：\n            <xml>\n                <ToUserName><![CDATA[toUser]]></ToUserName>\n                <FromUserName><![CDATA[UserID]]></FromUserName>\n                <CreateTime>1348831860</CreateTime>\n                <MsgType><![CDATA[event]]></MsgType>\n                <Event><![CDATA[subscribe]]></Event>\n                <AgentID>1</AgentID>\n            </xml>\n            \"\"\"\n            dom_tree = xml.dom.minidom.parseString(sMsg.decode('UTF-8'))\n            root_node = dom_tree.documentElement\n            # 消息类型\n            msg_type = DomUtils.tag_value(root_node, \"MsgType\")\n            # Event event事件只有click才有效,enter_agent无效\n            event = DomUtils.tag_value(root_node, \"Event\")\n            # 用户ID\n            user_id = DomUtils.tag_value(root_node, \"FromUserName\")\n            # 没的消息类型和用户ID的消息不要\n            if not msg_type or not user_id:\n                logger.warn(f\"解析不到消息类型和用户ID\")\n                return None\n            # 解析消息内容\n            if msg_type == \"event\" and event == \"click\":\n                # 校验用户有权限执行交互命令\n                if client_config.config.get('WECHAT_ADMINS'):\n                    wechat_admins = client_config.config.get('WECHAT_ADMINS').split(',')\n                    if wechat_admins and not any(\n                            user_id == admin_user for admin_user in wechat_admins):\n                        client.send_msg(title=\"用户无权限执行菜单命令\", userid=user_id)\n                        return None\n                # 根据EventKey执行命令\n                content = DomUtils.tag_value(root_node, \"EventKey\")\n                logger.info(f\"收到来自 {client_config.name} 的微信事件：userid={user_id}, event={content}\")\n            elif msg_type == \"text\":\n                # 文本消息\n                content = DomUtils.tag_value(root_node, \"Content\", default=\"\")\n                logger.info(f\"收到来自 {client_config.name} 的微信消息：userid={user_id}, text={content}\")\n            else:\n                return None\n\n            if content:\n                # 处理消息内容\n                return CommingMessage(channel=MessageChannel.Wechat, source=client_config.name,\n                                      userid=user_id, username=user_id, text=content)\n        except Exception as err:\n            logger.error(f\"微信消息处理发生错误：{str(err)}\")\n        return None\n\n    def post_message(self, message: Notification, **kwargs) -> None:\n        \"\"\"\n        发送消息\n        :param message: 消息内容\n        :return: 成功或失败\n        \"\"\"\n        for conf in self.get_configs().values():\n            if not self.check_message(message, conf.name):\n                continue\n            targets = message.targets\n            userid = message.userid\n            if not userid and targets is not None:\n                userid = targets.get('wechat_userid')\n                if not userid:\n                    logger.warn(f\"用户没有指定 微信用户ID，消息无法发送\")\n                    return\n            client: WeChat = self.get_instance(conf.name)\n            if client:\n                client.send_msg(title=message.title, text=message.text,\n                                image=message.image, userid=userid, link=message.link)\n\n    def post_medias_message(self, message: Notification, medias: List[MediaInfo]) -> None:\n        \"\"\"\n        发送媒体信息选择列表\n        :param message: 消息内容\n        :param medias: 媒体列表\n        :return: 成功或失败\n        \"\"\"\n        for conf in self.get_configs().values():\n            if not self.check_message(message, conf.name):\n                continue\n            client: WeChat = self.get_instance(conf.name)\n            if client:\n                # 先发送标题\n                client.send_msg(title=message.title, userid=message.userid, link=message.link)\n                # 再发送内容\n                client.send_medias_msg(medias=medias, userid=message.userid)\n\n    def post_torrents_message(self, message: Notification, torrents: List[Context]) -> None:\n        \"\"\"\n        发送种子信息选择列表\n        :param message: 消息内容\n        :param torrents: 种子列表\n        :return: 成功或失败\n        \"\"\"\n        for conf in self.get_configs().values():\n            if not self.check_message(message, conf.name):\n                continue\n            client: WeChat = self.get_instance(conf.name)\n            if client:\n                client.send_torrents_msg(title=message.title, torrents=torrents,\n                                         userid=message.userid, link=message.link)\n\n    def register_commands(self, commands: Dict[str, dict]):\n        \"\"\"\n        注册命令，实现这个函数接收系统可用的命令菜单\n        :param commands: 命令字典\n        \"\"\"\n        for client_config in self.get_configs().values():\n            if self._is_bot_mode(client_config.config):\n                logger.debug(f\"{client_config.name} 为智能机器人模式，跳过传统菜单初始化\")\n                continue\n            # 如果没有配置消息解密相关参数，则也没有必要进行菜单初始化\n            if not client_config.config.get(\"WECHAT_ENCODING_AESKEY\") or not client_config.config.get(\"WECHAT_TOKEN\"):\n                logger.debug(f\"{client_config.name} 缺少消息解密参数，跳过后续菜单初始化\")\n                continue\n\n            client = self.get_instance(client_config.name)\n            if not client:\n                continue\n\n            # 触发事件，允许调整命令数据，这里需要进行深复制，避免实例共享\n            scoped_commands = copy.deepcopy(commands)\n            event = eventmanager.send_event(\n                ChainEventType.CommandRegister,\n                CommandRegisterEventData(commands=scoped_commands, origin=\"WeChat\", service=client_config.name)\n            )\n\n            # 如果事件返回有效的 event_data，使用事件中调整后的命令\n            if event and event.event_data:\n                event_data: CommandRegisterEventData = event.event_data\n                # 如果事件被取消，跳过命令注册，并清理菜单\n                if event_data.cancel:\n                    client.delete_menus()\n                    logger.debug(\n                        f\"Command registration for {client_config.name} canceled by event: {event_data.source}\"\n                    )\n                    continue\n                scoped_commands = event_data.commands or {}\n                if not scoped_commands:\n                    logger.debug(\"Filtered commands are empty, skipping registration.\")\n                    client.delete_menus()\n\n            # scoped_commands 必须是 commands 的子集\n            filtered_scoped_commands = DictUtils.filter_keys_to_subset(scoped_commands, commands)\n            # 如果 filtered_scoped_commands 为空，则跳过注册\n            if not filtered_scoped_commands:\n                logger.debug(\"Filtered commands are empty, skipping registration.\")\n                client.delete_menus()\n                continue\n            # 对比调整后的命令与当前命令\n            if filtered_scoped_commands != commands:\n                logger.debug(f\"Command set has changed, Updating new commands: {filtered_scoped_commands}\")\n            client.create_menus(filtered_scoped_commands)\n"
  },
  {
    "path": "app/modules/wechat/wechat.py",
    "content": "import json\nimport re\nimport threading\nfrom datetime import datetime\nfrom typing import Optional, List, Dict\n\nfrom app.core.context import MediaInfo, Context\nfrom app.core.metainfo import MetaInfo\nfrom app.log import logger\nfrom app.utils.common import retry\nfrom app.utils.http import RequestUtils\nfrom app.utils.string import StringUtils\nfrom app.utils.url import UrlUtils\n\nlock = threading.Lock()\n\n\nclass RetryException(Exception):\n    pass\n\n\nclass WeChat:\n    # 企业微信Token\n    _access_token = None\n    # 企业微信Token过期时间\n    _expires_in: int = None\n    # 企业微信Token获取时间\n    _access_token_time: datetime = None\n    # 企业微信CorpID\n    _corpid = None\n    # 企业微信AppSecret\n    _appsecret = None\n    # 企业微信AppID\n    _appid = None\n    # 代理\n    _proxy = None\n\n    # 企业微信发送消息URL\n    _send_msg_url = \"cgi-bin/message/send?access_token={access_token}\"\n    # 企业微信获取TokenURL\n    _token_url = \"cgi-bin/gettoken?corpid={corpid}&corpsecret={corpsecret}\"\n    # 企业微信创建菜单URL\n    _create_menu_url = \"cgi-bin/menu/create?access_token={access_token}&agentid={agentid}\"\n    # 企业微信删除菜单URL\n    _delete_menu_url = \"cgi-bin/menu/delete?access_token={access_token}&agentid={agentid}\"\n\n    def __init__(self, WECHAT_CORPID: Optional[str] = None, WECHAT_APP_SECRET: Optional[str] = None,\n                 WECHAT_APP_ID: Optional[str] = None, WECHAT_PROXY: Optional[str] = None, **kwargs):\n        \"\"\"\n        初始化\n        \"\"\"\n        if not WECHAT_CORPID or not WECHAT_APP_SECRET or not WECHAT_APP_ID:\n            logger.error(\"企业微信配置不完整！\")\n            return\n        self._corpid = WECHAT_CORPID\n        self._appsecret = WECHAT_APP_SECRET\n        self._appid = WECHAT_APP_ID\n        self._proxy = WECHAT_PROXY or \"https://qyapi.weixin.qq.com\"\n\n        if self._proxy:\n            self._send_msg_url = UrlUtils.adapt_request_url(self._proxy, self._send_msg_url)\n            self._token_url = UrlUtils.adapt_request_url(self._proxy, self._token_url)\n            self._create_menu_url = UrlUtils.adapt_request_url(self._proxy, self._create_menu_url)\n            self._delete_menu_url = UrlUtils.adapt_request_url(self._proxy, self._delete_menu_url)\n\n        if self._corpid and self._appsecret and self._appid:\n            self.__get_access_token()\n\n    def get_state(self):\n        \"\"\"\n        获取状态\n        \"\"\"\n        return True if self.__get_access_token() else False\n\n    @retry(RetryException, logger=logger)\n    def __get_access_token(self, force=False):\n        \"\"\"\n        获取微信Token\n        :return： 微信Token\n        \"\"\"\n        token_flag = True\n        if not self._access_token:\n            token_flag = False\n        else:\n            if (datetime.now() - self._access_token_time).seconds >= self._expires_in:\n                token_flag = False\n\n        if not token_flag or force:\n            if not self._corpid or not self._appsecret:\n                return None\n            token_url = self._token_url.format(corpid=self._corpid, corpsecret=self._appsecret)\n            res = RequestUtils().get_res(token_url)\n            if res:\n                ret_json = res.json()\n                if ret_json.get(\"errcode\") == 0:\n                    self._access_token = ret_json.get(\"access_token\")\n                    self._expires_in = ret_json.get(\"expires_in\")\n                    self._access_token_time = datetime.now()\n            elif res is not None:\n                logger.error(f\"获取微信access_token失败，错误码：{res.status_code}，错误原因：{res.reason}\")\n            else:\n                logger.error(f\"获取微信access_token失败，未获取到返回信息\")\n                raise RetryException(\"获取微信access_token失败，重试中...\")\n        return self._access_token\n\n    @staticmethod\n    def __split_content(content: str, max_bytes: int = 2048) -> List[str]:\n        \"\"\"\n        将内容分块为不超过 max_bytes 字节的块\n        :param content: 待拆分的内容\n        :param max_bytes: 最大字节数\n        :return: 分块后的内容列表\n        \"\"\"\n        content_chunks = []\n        current_chunk = bytearray()\n\n        for line in content.splitlines():\n            encoded_line = (line + \"\\n\").encode(\"utf-8\")\n            line_length = len(encoded_line)\n\n            if line_length > max_bytes:\n                # 在处理长行之前，先将 current_chunk 添加到 content_chunks\n                if current_chunk:\n                    content_chunks.append(current_chunk.decode(\"utf-8\", errors=\"replace\").strip())\n                    current_chunk = bytearray()\n\n                # 处理长行，拆分为多个不超过 max_bytes 的块\n                start = 0\n                while start < line_length:\n                    end = start + max_bytes  # 不再需要为 \"...\" 预留空间\n                    if end >= line_length:\n                        end = line_length\n                    else:\n                        # 调整以避免拆分多字节字符\n                        while end > start and (encoded_line[end] & 0xC0) == 0x80:\n                            end -= 1\n                        if end == start:\n                            # 单个字符超过了 max_bytes，强制包含整个字符\n                            end = start + 1\n                            while end < line_length and (encoded_line[end] & 0xC0) == 0x80:\n                                end += 1\n                    truncated_line = encoded_line[start:end].decode(\"utf-8\", errors=\"replace\")\n                    content_chunks.append(truncated_line.strip())\n                    start = end\n                continue  # 继续处理下一行\n\n            # 检查添加当前行后是否会超过 max_bytes\n            if len(current_chunk) + line_length > max_bytes:\n                # 将 current_chunk 添加到 content_chunks\n                content_chunks.append(current_chunk.decode(\"utf-8\", errors=\"replace\").strip())\n                current_chunk = bytearray()\n\n            # 将当前行添加到 current_chunk\n            current_chunk += encoded_line\n\n        # 处理剩余的 current_chunk\n        if current_chunk:\n            content_chunks.append(current_chunk.decode(\"utf-8\", errors=\"replace\").strip())\n\n        return content_chunks\n\n    def __send_message(self, title: str, text: Optional[str] = None,\n                       userid: Optional[str] = None, link: Optional[str] = None) -> bool:\n        \"\"\"\n        发送文本消息\n        :param title: 消息标题\n        :param text: 消息内容\n        :param userid: 消息发送对象的ID，为空则发给所有人\n        :param link: 跳转链接\n        :return: 发送状态，错误信息\n        \"\"\"\n        if not title and not text:\n            logger.error(\"消息标题和内容不能都为空\")\n            return False\n        if text:\n            formatted_text = text.replace(\"\\n\\n\", \"\\n\")\n            content = f\"{title}\\n{formatted_text}\"\n        else:\n            content = title\n        if link:\n            content = f\"{content}\\n点击查看：{link}\"\n        if not userid:\n            userid = \"@all\"\n        # 分块处理逻辑\n        content_chunks = self.__split_content(content)\n        # 逐块发送消息\n        for chunk in content_chunks:\n            req_json = {\n                \"touser\": userid,\n                \"msgtype\": \"text\",\n                \"agentid\": self._appid,\n                \"text\": {\n                    \"content\": chunk\n                },\n                \"safe\": 0,\n                \"enable_id_trans\": 0,\n                \"enable_duplicate_check\": 0\n            }\n            try:\n                # 如果是超长消息，有一个发送失败就全部失败\n                if not self.__post_request(self._send_msg_url, req_json):\n                    return False\n            except Exception as e:\n                logger.error(f\"发送消息块失败：{e}\")\n                return False\n        return True\n\n    def __send_image_message(self, title: str, text: str, image_url: str,\n                             userid: Optional[str] = None, link: Optional[str] = None) -> Optional[bool]:\n        \"\"\"\n        发送图文消息\n        :param title: 消息标题\n        :param text: 消息内容\n        :param image_url: 图片地址\n        :param userid: 消息发送对象的ID，为空则发给所有人\n        :param link: 跳转链接\n        :return: 发送状态，错误信息\n        \"\"\"\n        if text:\n            text = text.replace(\"\\n\\n\", \"\\n\")\n        if not userid:\n            userid = \"@all\"\n        req_json = {\n            \"touser\": userid,\n            \"msgtype\": \"news\",\n            \"agentid\": self._appid,\n            \"news\": {\n                \"articles\": [\n                    {\n                        \"title\": title,\n                        \"description\": text,\n                        \"picurl\": image_url,\n                        \"url\": link\n                    }\n                ]\n            }\n        }\n        try:\n            return self.__post_request(self._send_msg_url, req_json)\n        except Exception as e:\n            logger.error(f\"发送图文消息失败：{e}\")\n            return False\n\n    def send_msg(self, title: str, text: Optional[str] = None, image: Optional[str] = None,\n                 userid: Optional[str] = None, link: Optional[str] = None) -> Optional[bool]:\n        \"\"\"\n        微信消息发送入口，支持文本、图片、链接跳转、指定发送对象\n        :param title: 消息标题\n        :param text: 消息内容\n        :param image: 图片地址\n        :param userid: 消息发送对象的ID，为空则发给所有人\n        :param link: 跳转链接\n        :return: 发送状态，错误信息\n        \"\"\"\n        try:\n            if not self.__get_access_token():\n                logger.error(\"获取微信access_token失败，请检查参数配置\")\n                return None\n\n            if image:\n                ret_code = self.__send_image_message(title=title, text=text, image_url=image, userid=userid, link=link)\n            else:\n                ret_code = self.__send_message(title=title, text=text, userid=userid, link=link)\n\n            return ret_code\n        except Exception as e:\n            logger.error(f\"发送消息失败：{e}\")\n            return False\n\n    def send_medias_msg(self, medias: List[MediaInfo], userid: Optional[str] = None) -> Optional[bool]:\n        \"\"\"\n        发送列表类消息\n        \"\"\"\n        try:\n            if not self.__get_access_token():\n                logger.error(\"获取微信access_token失败，请检查参数配置\")\n                return None\n\n            if not userid:\n                userid = \"@all\"\n            articles = []\n            index = 1\n            for media in medias:\n                if media.vote_average:\n                    title = f\"{index}. {media.title_year}\\n类型：{media.type.value}，评分：{media.vote_average}\"\n                else:\n                    title = f\"{index}. {media.title_year}\\n类型：{media.type.value}\"\n                articles.append({\n                    \"title\": title,\n                    \"description\": \"\",\n                    \"picurl\": media.get_message_image() if index == 1 else media.get_poster_image(),\n                    \"url\": media.detail_link\n                })\n                index += 1\n\n            req_json = {\n                \"touser\": userid,\n                \"msgtype\": \"news\",\n                \"agentid\": self._appid,\n                \"news\": {\n                    \"articles\": articles\n                }\n            }\n            return self.__post_request(self._send_msg_url, req_json)\n        except Exception as e:\n            logger.error(f\"发送消息失败：{e}\")\n            return False\n\n    def send_torrents_msg(self, torrents: List[Context],\n                          userid: Optional[str] = None, title: Optional[str] = None,\n                          link: Optional[str] = None) -> Optional[bool]:\n        \"\"\"\n        发送列表消息\n        \"\"\"\n        try:\n            if not self.__get_access_token():\n                logger.error(\"获取微信access_token失败，请检查参数配置\")\n                return None\n\n            # 先发送标题\n            if title:\n                self.__send_message(title=title, userid=userid, link=link)\n\n            # 发送列表\n            if not userid:\n                userid = \"@all\"\n            articles = []\n            index = 1\n            for context in torrents:\n                torrent = context.torrent_info\n                meta = MetaInfo(title=torrent.title, subtitle=torrent.description)\n                mediainfo = context.media_info\n                torrent_title = f\"{index}.【{torrent.site_name}】\" \\\n                                f\"{meta.season_episode} \" \\\n                                f\"{meta.resource_term} \" \\\n                                f\"{meta.video_term} \" \\\n                                f\"{meta.release_group} \" \\\n                                f\"{StringUtils.str_filesize(torrent.size)} \" \\\n                                f\"{torrent.volume_factor} \" \\\n                                f\"{torrent.seeders}↑\"\n                torrent_title = re.sub(r\"\\s+\", \" \", torrent_title).strip()\n                articles.append({\n                    \"title\": torrent_title,\n                    \"description\": torrent.description if index == 1 else \"\",\n                    \"picurl\": mediainfo.get_message_image() if index == 1 else \"\",\n                    \"url\": torrent.page_url\n                })\n                index += 1\n\n            req_json = {\n                \"touser\": userid,\n                \"msgtype\": \"news\",\n                \"agentid\": self._appid,\n                \"news\": {\n                    \"articles\": articles\n                }\n            }\n            return self.__post_request(self._send_msg_url, req_json)\n        except Exception as e:\n            logger.error(f\"发送消息失败：{e}\")\n            return False\n\n    @retry(RetryException, logger=logger)\n    def __post_request(self, url: str, req_json: dict) -> bool:\n        \"\"\"\n        向微信发送请求\n        \"\"\"\n        url = url.format(access_token=self.__get_access_token())\n        res = RequestUtils(content_type=\"application/json\").post(\n            url=url,\n            data=json.dumps(req_json, ensure_ascii=False).encode(\"utf-8\")\n        )\n        if res is None:\n            error_msg = \"发送请求失败，未获取到返回信息\"\n            raise Exception(error_msg)\n        if res.status_code != 200:\n            error_msg = f\"发送请求失败，错误码：{res.status_code}，错误原因：{res.reason}\"\n            raise Exception(error_msg)\n\n        ret_json = res.json()\n        if ret_json.get(\"errcode\") == 0:\n            return True\n        else:\n            if ret_json.get(\"errcode\") == 42001:\n                self.__get_access_token(force=True)\n                error_msg = (f\"access_token 已过期，尝试重新获取 access_token,\"\n                             f\"errcode: {ret_json.get('errcode')}, errmsg: {ret_json.get('errmsg')}\")\n                raise RetryException(error_msg)\n            else:\n                logger.error(f\"发送请求失败，错误信息：{ret_json.get('errmsg')}\")\n                return False\n\n    def create_menus(self, commands: Dict[str, dict]):\n        \"\"\"\n        自动注册微信菜单\n        :param commands: 命令字典\n        命令字典：\n        {\n            \"/cookiecloud\": {\n                \"func\": CookieCloudChain(self._db).remote_sync,\n                \"description\": \"同步站点\",\n                \"category\": \"站点\",\n                \"data\": {}\n            }\n        }\n        注册报文格式，一级菜单只有最多3条，子菜单最多只有5条：\n        {\n           \"button\":[\n               {\n                   \"type\":\"click\",\n                   \"name\":\"今日歌曲\",\n                   \"key\":\"V1001_TODAY_MUSIC\"\n               },\n               {\n                   \"name\":\"菜单\",\n                   \"sub_button\":[\n                       {\n                           \"type\":\"view\",\n                           \"name\":\"搜索\",\n                           \"url\":\"https://www.soso.com/\"\n                       },\n                       {\n                           \"type\":\"click\",\n                           \"name\":\"赞一下我们\",\n                           \"key\":\"V1001_GOOD\"\n                       }\n                   ]\n              }\n           ]\n        }\n        \"\"\"\n        try:\n            # 请求URL\n            req_url = self._create_menu_url.format(access_token=\"{access_token}\", agentid=self._appid)\n\n            # 对commands按category分组\n            category_dict = {}\n            for key, value in commands.items():\n                category: str = value.get(\"category\")\n                if category:\n                    if not category_dict.get(category):\n                        category_dict[category] = {}\n                    category_dict[category][key] = value\n\n            # 一级菜单\n            buttons = []\n            for category, menu in category_dict.items():\n                # 二级菜单\n                sub_buttons = []\n                for key, value in menu.items():\n                    sub_buttons.append({\n                        \"type\": \"click\",\n                        \"name\": value.get(\"description\"),\n                        \"key\": key\n                    })\n                buttons.append({\n                    \"name\": category,\n                    \"sub_button\": sub_buttons[:5]\n                })\n\n            if buttons:\n                # 发送请求\n                self.__post_request(req_url, {\n                    \"button\": buttons[:3]\n                })\n        except Exception as e:\n            logger.error(f\"创建菜单失败：{e}\")\n\n    def delete_menus(self):\n        \"\"\"\n        删除微信菜单\n        \"\"\"\n        try:\n            # 请求URL\n            req_url = self._delete_menu_url.format(access_token=self.__get_access_token(), agentid=self._appid)\n            # 发送请求\n            RequestUtils().get(req_url)\n        except Exception as e:\n            logger.error(f\"删除菜单失败：{e}\")\n"
  },
  {
    "path": "app/modules/wechat/wechatbot.py",
    "content": "import hashlib\nimport json\nimport pickle\nimport re\nimport threading\nimport time\nimport uuid\nfrom typing import Optional, List, Dict, Tuple, Set\n\nimport websocket\n\nfrom app.chain.message import MessageChain\nfrom app.core.cache import FileCache\nfrom app.core.context import MediaInfo, Context\nfrom app.core.metainfo import MetaInfo\nfrom app.log import logger\nfrom app.schemas.types import MessageChannel\nfrom app.utils.string import StringUtils\n\n\nclass WeChatBot:\n    \"\"\"\n    企业微信智能机器人（长连接模式）\n    固定使用：\n    - dmPolicy = open\n    - groupPolicy = disabled\n    \"\"\"\n\n    _default_ws_url = \"wss://openws.work.weixin.qq.com\"\n    _heartbeat_interval = 30\n    _ack_timeout = 10\n\n    def __init__(self,\n                 WECHAT_BOT_ID: Optional[str] = None,\n                 WECHAT_BOT_SECRET: Optional[str] = None,\n                 WECHAT_BOT_CHAT_ID: Optional[str] = None,\n                 WECHAT_BOT_WS_URL: Optional[str] = None,\n                 WECHAT_ADMINS: Optional[str] = None,\n                 name: Optional[str] = None,\n                 **kwargs):\n        self._config_name = name or \"wechat\"\n        self._bot_id = WECHAT_BOT_ID\n        self._bot_secret = WECHAT_BOT_SECRET\n        self._default_chat_id = WECHAT_BOT_CHAT_ID.strip() if WECHAT_BOT_CHAT_ID else None\n        self._ws_url = WECHAT_BOT_WS_URL or self._default_ws_url\n        self._admins = [item.strip() for item in (WECHAT_ADMINS or \"\").split(\",\") if item.strip()]\n        safe_name = hashlib.md5(self._config_name.encode()).hexdigest()[:12]\n        self._cache_key = f\"__wechatbot_known_targets_{safe_name}__\"\n        self._filecache = FileCache()\n        self._known_targets: Set[str] = set()\n\n        self._ready = False\n        self._ws_app: Optional[websocket.WebSocketApp] = None\n        self._ws_thread: Optional[threading.Thread] = None\n        self._heartbeat_thread: Optional[threading.Thread] = None\n        self._stop_event = threading.Event()\n        self._authenticated = threading.Event()\n        self._send_lock = threading.Lock()\n        self._acks_lock = threading.Lock()\n        self._pending_acks: Dict[str, dict] = {}\n\n        if not self._bot_id or not self._bot_secret:\n            logger.error(\"企业微信智能机器人配置不完整！\")\n            return\n\n        self._load_known_targets()\n        self._ready = True\n        self._start_gateway()\n\n    @staticmethod\n    def _build_req_id(prefix: str) -> str:\n        return f\"{prefix}_{uuid.uuid4().hex}\"\n\n    @staticmethod\n    def _split_content(content: str, max_bytes: int = 4000) -> List[str]:\n        \"\"\"\n        将 markdown 内容拆分为较小分块，避免消息过长发送失败\n        \"\"\"\n        if not content:\n            return []\n\n        chunks = []\n        current = bytearray()\n        for line in content.splitlines():\n            encoded = (line + \"\\n\").encode(\"utf-8\")\n            if len(encoded) > max_bytes:\n                if current:\n                    chunks.append(current.decode(\"utf-8\", errors=\"replace\").strip())\n                    current = bytearray()\n                start = 0\n                while start < len(encoded):\n                    end = min(start + max_bytes, len(encoded))\n                    while end > start and end < len(encoded) and (encoded[end] & 0xC0) == 0x80:\n                        end -= 1\n                    chunks.append(encoded[start:end].decode(\"utf-8\", errors=\"replace\").strip())\n                    start = end\n                continue\n\n            if len(current) + len(encoded) > max_bytes:\n                chunks.append(current.decode(\"utf-8\", errors=\"replace\").strip())\n                current = bytearray()\n            current += encoded\n\n        if current:\n            chunks.append(current.decode(\"utf-8\", errors=\"replace\").strip())\n\n        return [chunk for chunk in chunks if chunk]\n\n    def _start_gateway(self) -> None:\n        if self._ws_thread and self._ws_thread.is_alive():\n            return\n\n        self._stop_event.clear()\n        self._ws_thread = threading.Thread(target=self._run_gateway, daemon=True)\n        self._ws_thread.start()\n        self._heartbeat_thread = threading.Thread(target=self._heartbeat_loop, daemon=True)\n        self._heartbeat_thread.start()\n        logger.info(f\"企业微信智能机器人长连接已启动：{self._config_name}\")\n\n    def stop(self) -> None:\n        self._stop_event.set()\n        self._authenticated.clear()\n        if self._ws_app:\n            try:\n                self._ws_app.close()\n            except Exception as err:\n                logger.debug(f\"关闭企业微信智能机器人连接失败：{err}\")\n        if self._ws_thread and self._ws_thread.is_alive():\n            self._ws_thread.join(timeout=5)\n        if self._heartbeat_thread and self._heartbeat_thread.is_alive():\n            self._heartbeat_thread.join(timeout=2)\n\n    def get_state(self) -> bool:\n        return self._ready and self._authenticated.is_set()\n\n    def _load_known_targets(self) -> None:\n        try:\n            content = self._filecache.get(self._cache_key)\n            if not content:\n                return\n            data = pickle.loads(content)\n            if isinstance(data, (list, set, tuple)):\n                self._known_targets = {str(item).strip() for item in data if str(item).strip()}\n        except Exception as err:\n            logger.debug(f\"加载企业微信智能机器人已互动用户失败：{err}\")\n\n    def _save_known_targets(self) -> None:\n        try:\n            self._filecache.set(self._cache_key, pickle.dumps(sorted(self._known_targets)))\n        except Exception as err:\n            logger.debug(f\"保存企业微信智能机器人已互动用户失败：{err}\")\n\n    def _remember_target(self, userid: Optional[str]) -> None:\n        target = str(userid).strip() if userid else None\n        if not target:\n            return\n        if target not in self._known_targets:\n            self._known_targets.add(target)\n            self._save_known_targets()\n\n    def _run_gateway(self) -> None:\n        reconnect_delays = [1, 2, 5, 10, 30, 60]\n        attempt = 0\n\n        while not self._stop_event.is_set():\n            self._authenticated.clear()\n            try:\n                self._ws_app = websocket.WebSocketApp(\n                    self._ws_url,\n                    on_open=self._on_open,\n                    on_message=self._on_message,\n                    on_error=self._on_error,\n                    on_close=self._on_close,\n                )\n                self._ws_app.run_forever(\n                    ping_interval=None,\n                    ping_timeout=None,\n                    skip_utf8_validation=True,\n                )\n            except Exception as err:\n                logger.error(f\"企业微信智能机器人连接异常：{err}\")\n\n            if self._stop_event.is_set():\n                break\n\n            delay = reconnect_delays[min(attempt, len(reconnect_delays) - 1)]\n            attempt += 1\n            logger.info(f\"企业微信智能机器人将在 {delay}s 后重连：{self._config_name}\")\n            for _ in range(delay * 10):\n                if self._stop_event.is_set():\n                    break\n                time.sleep(0.1)\n\n    def _heartbeat_loop(self) -> None:\n        while not self._stop_event.is_set():\n            if self._authenticated.is_set():\n                try:\n                    self._send_raw({\n                        \"cmd\": \"ping\",\n                        \"headers\": {\"req_id\": self._build_req_id(\"ping\")},\n                    })\n                except Exception as err:\n                    logger.debug(f\"发送企业微信智能机器人心跳失败：{err}\")\n            for _ in range(self._heartbeat_interval * 10):\n                if self._stop_event.is_set():\n                    return\n                time.sleep(0.1)\n\n    def _on_open(self, ws) -> None:\n        logger.info(f\"企业微信智能机器人连接成功，开始订阅：{self._config_name}\")\n        self._send_raw({\n            \"cmd\": \"aibot_subscribe\",\n            \"headers\": {\"req_id\": self._build_req_id(\"aibot_subscribe\")},\n            \"body\": {\n                \"bot_id\": self._bot_id,\n                \"secret\": self._bot_secret,\n            },\n        })\n\n    def _on_message(self, ws, message: str) -> None:\n        try:\n            payload = json.loads(message)\n        except Exception as err:\n            logger.error(f\"解析企业微信智能机器人消息失败：{err}\")\n            return\n\n        req_id = (payload.get(\"headers\") or {}).get(\"req_id\")\n        if req_id:\n            self._resolve_ack(req_id, payload)\n\n        cmd = payload.get(\"cmd\")\n        if not cmd:\n            if str(req_id).startswith(\"aibot_subscribe\"):\n                if payload.get(\"errcode\") == 0:\n                    self._authenticated.set()\n                    logger.info(f\"企业微信智能机器人订阅成功：{self._config_name}\")\n                else:\n                    logger.error(\n                        f\"企业微信智能机器人订阅失败：{payload.get('errmsg')} ({payload.get('errcode')})\"\n                    )\n                    self._authenticated.clear()\n            return\n\n        if cmd == \"aibot_msg_callback\":\n            self._handle_callback_message(payload)\n        elif cmd == \"aibot_event_callback\":\n            self._handle_callback_event(payload)\n\n    def _on_error(self, ws, error) -> None:\n        self._authenticated.clear()\n        logger.error(f\"企业微信智能机器人 WebSocket 错误：{error}\")\n\n    def _on_close(self, ws, close_status_code, close_msg) -> None:\n        self._authenticated.clear()\n        logger.info(f\"企业微信智能机器人连接关闭：{close_status_code} {close_msg}\")\n\n    def _resolve_ack(self, req_id: str, payload: dict) -> None:\n        with self._acks_lock:\n            pending = self._pending_acks.get(req_id)\n        if not pending:\n            return\n        pending[\"payload\"] = payload\n        pending[\"event\"].set()\n\n    def _send_raw(self, payload: dict) -> None:\n        if not self._ws_app or not self._ws_app.sock or not self._ws_app.sock.connected:\n            raise RuntimeError(\"企业微信智能机器人未连接\")\n        self._ws_app.send(json.dumps(payload, ensure_ascii=False))\n\n    def _send_with_ack(self, payload: dict) -> bool:\n        req_id = (payload.get(\"headers\") or {}).get(\"req_id\")\n        if not req_id:\n            return False\n\n        if not self._authenticated.wait(timeout=self._ack_timeout):\n            logger.error(\"企业微信智能机器人未完成认证，无法发送消息\")\n            return False\n\n        pending = {\"event\": threading.Event(), \"payload\": None}\n        with self._acks_lock:\n            self._pending_acks[req_id] = pending\n\n        try:\n            with self._send_lock:\n                self._send_raw(payload)\n            if not pending[\"event\"].wait(timeout=self._ack_timeout):\n                logger.error(f\"企业微信智能机器人消息发送超时：req_id={req_id}\")\n                return False\n            ack = pending[\"payload\"] or {}\n            if ack.get(\"errcode\") != 0:\n                logger.error(\n                    f\"企业微信智能机器人消息发送失败：{ack.get('errmsg')} ({ack.get('errcode')})\"\n                )\n                return False\n            return True\n        finally:\n            with self._acks_lock:\n                self._pending_acks.pop(req_id, None)\n\n    def _handle_callback_event(self, payload: dict) -> None:\n        event = ((payload.get(\"body\") or {}).get(\"event\") or {}).get(\"eventtype\")\n        if event == \"disconnected_event\":\n            logger.info(f\"企业微信智能机器人旧连接被踢下线：{self._config_name}\")\n\n    @staticmethod\n    def _extract_text_from_body(body: dict) -> Optional[str]:\n        msgtype = body.get(\"msgtype\")\n        text_parts = []\n\n        if msgtype == \"text\":\n            text = ((body.get(\"text\") or {}).get(\"content\") or \"\").strip()\n            if text:\n                text_parts.append(text)\n        elif msgtype == \"voice\":\n            text = ((body.get(\"voice\") or {}).get(\"content\") or \"\").strip()\n            if text:\n                text_parts.append(text)\n        elif msgtype == \"mixed\":\n            for item in (body.get(\"mixed\") or {}).get(\"msg_item\") or []:\n                if item.get(\"msgtype\") == \"text\":\n                    content = ((item.get(\"text\") or {}).get(\"content\") or \"\").strip()\n                    if content:\n                        text_parts.append(content)\n\n        quote = body.get(\"quote\") or {}\n        if not text_parts and quote.get(\"msgtype\") == \"text\":\n            quote_text = ((quote.get(\"text\") or {}).get(\"content\") or \"\").strip()\n            if quote_text:\n                text_parts.append(quote_text)\n\n        text = \"\\n\".join(part for part in text_parts if part).strip()\n        return text or None\n\n    def _handle_callback_message(self, payload: dict) -> None:\n        body = payload.get(\"body\") or {}\n        sender = ((body.get(\"from\") or {}).get(\"userid\") or \"\").strip()\n        if not sender:\n            return\n\n        if body.get(\"chattype\") == \"group\":\n            logger.debug(f\"企业微信智能机器人忽略群聊消息（groupPolicy=disabled）：{self._config_name}\")\n            return\n\n        text = self._extract_text_from_body(body)\n        if not text:\n            return\n\n        text = re.sub(r\"@\\S+\", \"\", text).strip()\n        if not text:\n            return\n\n        self._remember_target(sender)\n\n        if text.startswith(\"/\") and self._admins and sender not in self._admins:\n            self.send_msg(title=\"只有管理员才有权限执行此命令\", userid=sender)\n            return\n\n        logger.info(f\"收到来自 {self._config_name} 的企业微信智能机器人消息：userid={sender}, text={text}\")\n        self._forward_to_message_chain(userid=sender, text=text)\n\n    def _forward_to_message_chain(self, userid: str, text: str) -> None:\n        def _run():\n            try:\n                MessageChain().handle_message(\n                    channel=MessageChannel.Wechat,\n                    source=self._config_name,\n                    userid=userid,\n                    username=userid,\n                    text=text,\n                )\n            except Exception as err:\n                logger.error(f\"企业微信智能机器人转发消息失败：{err}\")\n\n        threading.Thread(target=_run, daemon=True).start()\n\n    @staticmethod\n    def _normalize_target(userid: Optional[str], default_chat_id: Optional[str]) -> Tuple[Optional[str], int]:\n        target = str(userid).strip() if userid else (default_chat_id.strip() if default_chat_id else None)\n        if not target:\n            return None, 1\n\n        lowered = target.lower()\n        if lowered.startswith(\"group:\"):\n            return target[6:].strip(), 2\n        if lowered.startswith(\"user:\"):\n            return target[5:].strip(), 1\n        return target, 1\n\n    @staticmethod\n    def _build_markdown(title: Optional[str] = None,\n                        text: Optional[str] = None,\n                        image: Optional[str] = None,\n                        link: Optional[str] = None) -> str:\n        parts = []\n        if title:\n            parts.append(f\"**{title}**\")\n        if text:\n            parts.append(text.replace(\"\\n\\n\", \"\\n\"))\n        if image:\n            parts.append(f\"![]({image})\")\n        if link:\n            parts.append(f\"[点击查看]({link})\")\n        return \"\\n\\n\".join(part for part in parts if part).strip()\n\n    def _resolve_targets(self, userid: Optional[str] = None) -> List[Tuple[str, int]]:\n        target, chat_type = self._normalize_target(userid=userid, default_chat_id=self._default_chat_id)\n        if target:\n            return [(target, chat_type)]\n        return [(known_userid, 1) for known_userid in sorted(self._known_targets)]\n\n    def _send_markdown(self, content: str, userid: Optional[str] = None) -> Optional[bool]:\n        if not content:\n            return False\n\n        targets = self._resolve_targets(userid=userid)\n        if not targets:\n            logger.warning(f\"{self._config_name} 未配置默认发送目标，且暂无已互动用户\")\n            return False\n\n        send_success = False\n        for target, chat_type in targets:\n            target_success = True\n            for chunk in self._split_content(content):\n                req_id = self._build_req_id(\"aibot_send_msg\")\n                payload = {\n                    \"cmd\": \"aibot_send_msg\",\n                    \"headers\": {\"req_id\": req_id},\n                    \"body\": {\n                        \"chatid\": target,\n                        \"chat_type\": chat_type,\n                        \"msgtype\": \"markdown\",\n                        \"markdown\": {\n                            \"content\": chunk\n                        }\n                    }\n                }\n                if not self._send_with_ack(payload):\n                    target_success = False\n                    logger.warning(f\"{self._config_name} 向目标 {target} 发送通知失败\")\n                    break\n            send_success = send_success or target_success\n        return send_success\n\n    def send_msg(self, title: str, text: Optional[str] = None, image: Optional[str] = None,\n                 userid: Optional[str] = None, link: Optional[str] = None) -> Optional[bool]:\n        content = self._build_markdown(title=title, text=text, image=image, link=link)\n        return self._send_markdown(content=content, userid=userid)\n\n    def send_medias_msg(self, medias: List[MediaInfo], userid: Optional[str] = None) -> Optional[bool]:\n        if not medias:\n            return False\n\n        lines = [\"**媒体列表**\"]\n        for index, media in enumerate(medias, start=1):\n            line = f\"{index}. {media.title_year}\"\n            if media.vote_average:\n                line += f\" 评分：{media.vote_average}\"\n            if media.detail_link:\n                line += f\"\\n{media.detail_link}\"\n            lines.append(line)\n        return self._send_markdown(content=\"\\n\\n\".join(lines), userid=userid)\n\n    def send_torrents_msg(self, torrents: List[Context],\n                          userid: Optional[str] = None, title: Optional[str] = None,\n                          link: Optional[str] = None) -> Optional[bool]:\n        if not torrents:\n            return False\n\n        lines = [f\"**{title or '种子列表'}**\"]\n        if link:\n            lines.append(link)\n\n        for index, context in enumerate(torrents, start=1):\n            torrent = context.torrent_info\n            meta = MetaInfo(title=torrent.title, subtitle=torrent.description)\n            torrent_title = (\n                f\"{index}.【{torrent.site_name}】\"\n                f\"{meta.season_episode} \"\n                f\"{meta.resource_term} \"\n                f\"{meta.video_term} \"\n                f\"{meta.release_group} \"\n                f\"{StringUtils.str_filesize(torrent.size)} \"\n                f\"{torrent.volume_factor} \"\n                f\"{torrent.seeders}↑\"\n            )\n            torrent_title = re.sub(r\"\\s+\", \" \", torrent_title).strip()\n            if torrent.page_url:\n                torrent_title += f\"\\n{torrent.page_url}\"\n            lines.append(torrent_title)\n\n        return self._send_markdown(content=\"\\n\\n\".join(lines), userid=userid)\n\n    def create_menus(self, commands: Dict[str, dict]):\n        \"\"\"\n        智能机器人模式不支持传统自建应用菜单\n        \"\"\"\n        return\n\n    def delete_menus(self):\n        \"\"\"\n        智能机器人模式不支持传统自建应用菜单\n        \"\"\"\n        return\n"
  },
  {
    "path": "app/monitor.py",
    "content": "import json\nimport platform\nimport re\nimport threading\nimport time\nimport traceback\nfrom pathlib import Path\nfrom threading import Lock\nfrom typing import Any, Optional, Dict, List\n\nfrom apscheduler.schedulers.background import BackgroundScheduler\nfrom watchdog.events import FileSystemEventHandler, FileSystemMovedEvent, FileSystemEvent\nfrom watchdog.observers.polling import PollingObserver\n\nfrom app.chain import ChainBase\nfrom app.chain.storage import StorageChain\nfrom app.chain.transfer import TransferChain\nfrom app.core.cache import TTLCache, FileCache\nfrom app.core.config import settings\nfrom app.helper.directory import DirectoryHelper\nfrom app.helper.message import MessageHelper\nfrom app.log import logger\nfrom app.schemas import FileItem\nfrom app.schemas.types import SystemConfigKey\nfrom app.utils.mixins import ConfigReloadMixin\nfrom app.utils.singleton import SingletonClass\nfrom app.utils.system import SystemUtils\n\nlock = Lock()\nsnapshot_lock = Lock()\n\n\nclass MonitorChain(ChainBase):\n    pass\n\n\nclass FileMonitorHandler(FileSystemEventHandler):\n    \"\"\"\n    目录监控响应类\n    \"\"\"\n\n    def __init__(self, mon_path: Path, callback: Any, **kwargs):\n        super(FileMonitorHandler, self).__init__(**kwargs)\n        self._watch_path = mon_path\n        self.callback = callback\n\n    def on_created(self, event: FileSystemEvent):\n        try:\n            self.callback.event_handler(event=event, text=\"创建\", event_path=event.src_path,\n                                        file_size=Path(event.src_path).stat().st_size)\n        except Exception as e:\n            logger.error(f\"on_created 异常: {e}\")\n\n    def on_moved(self, event: FileSystemMovedEvent):\n        try:\n            self.callback.event_handler(event=event, text=\"移动\", event_path=event.dest_path,\n                                        file_size=Path(event.dest_path).stat().st_size)\n        except Exception as e:\n            logger.error(f\"on_moved 异常: {e}\")\n\n\nclass Monitor(ConfigReloadMixin, metaclass=SingletonClass):\n    \"\"\"\n    目录监控处理链，单例模式\n    \"\"\"\n    CONFIG_WATCH = {SystemConfigKey.Directories.value}\n\n    def __init__(self):\n        super().__init__()\n        # 退出事件\n        self._event = threading.Event()\n        # 监控服务\n        self._observers = []\n        # 定时服务\n        self._scheduler = None\n        # 存储过照间隔（分钟）\n        self._snapshot_interval = 5\n        # TTL缓存，10秒钟有效\n        self._cache = TTLCache(region=\"monitor\", maxsize=1024, ttl=10)\n        # 快照文件缓存\n        self._snapshot_cache = FileCache(base=settings.CACHE_PATH / \"snapshots\")\n        # 监控的文件扩展名\n        self.all_exts = settings.RMT_MEDIAEXT + settings.RMT_SUBEXT + settings.RMT_AUDIOEXT\n        # 启动目录监控和文件整理\n        self.init()\n\n    def on_config_changed(self):\n        self.init()\n\n    def get_reload_name(self):\n        return \"目录监控\"\n\n    def save_snapshot(self, storage: str, snapshot: Dict, file_count: int = 0,\n                      last_snapshot_time: Optional[float] = None):\n        \"\"\"\n        保存快照到文件缓存\n        :param storage: 存储名称\n        :param snapshot: 快照数据\n        :param last_snapshot_time: 上次快照时间戳\n        :param file_count: 文件数量，用于调整监控间隔\n        \"\"\"\n        try:\n            snapshot_time = max((item.get('modify_time', 0) for item in snapshot.values()), default=None)\n            if snapshot_time is None:\n                snapshot_time = last_snapshot_time or time.time()\n            snapshot_data = {\n                'timestamp': snapshot_time,\n                'file_count': file_count,\n                'snapshot': snapshot\n            }\n            # 使用FileCache保存快照数据\n            cache_key = f\"{storage}_snapshot\"\n            snapshot_json = json.dumps(snapshot_data, ensure_ascii=False, indent=2)\n            self._snapshot_cache.set(cache_key, snapshot_json.encode('utf-8'), region=\"snapshots\")\n            logger.debug(f\"快照已保存到缓存: {storage}\")\n        except Exception as e:\n            logger.error(f\"保存快照失败: {e}\")\n\n    def reset_snapshot(self, storage: str) -> bool:\n        \"\"\"\n        重置快照，强制下次扫描时重新建立基准\n        :param storage: 存储名称\n        :return: 是否成功\n        \"\"\"\n        try:\n            cache_key = f\"{storage}_snapshot\"\n            if self._snapshot_cache.exists(cache_key, region=\"snapshots\"):\n                self._snapshot_cache.delete(cache_key, region=\"snapshots\")\n                logger.info(f\"快照已重置: {storage}\")\n                return True\n            logger.debug(f\"快照文件不存在，无需重置: {storage}\")\n            return True\n        except Exception as e:\n            logger.error(f\"重置快照失败: {storage} - {e}\")\n            return False\n\n    def force_full_scan(self, storage: str, mon_path: Path) -> bool:\n        \"\"\"\n        强制全量扫描并处理所有文件（包括已存在的文件）\n        :param storage: 存储名称\n        :param mon_path: 监控路径\n        :return: 是否成功\n        \"\"\"\n        try:\n            logger.info(f\"开始强制全量扫描: {storage}:{mon_path}\")\n\n            # 生成快照\n            new_snapshot = StorageChain().snapshot_storage(\n                storage=storage,\n                path=mon_path,\n                last_snapshot_time=0  # 全量扫描，不使用增量\n            )\n\n            if new_snapshot is None:\n                logger.warn(f\"获取 {storage}:{mon_path} 快照失败\")\n                return False\n\n            file_count = len(new_snapshot)\n            logger.info(f\"{storage}:{mon_path} 全量扫描完成，发现 {file_count} 个文件\")\n\n            # 处理所有文件\n            processed_count = 0\n            for file_path, file_info in new_snapshot.items():\n                try:\n                    logger.info(f\"处理文件：{file_path}\")\n                    file_size = file_info.get('size', 0) if isinstance(file_info, dict) else file_info\n                    self.__handle_file(storage=storage, event_path=Path(file_path), file_size=file_size)\n                    processed_count += 1\n                except Exception as e:\n                    logger.error(f\"处理文件 {file_path} 失败: {e}\")\n                    continue\n\n            logger.info(f\"{storage}:{mon_path} 全量扫描完成，共处理 {processed_count}/{file_count} 个文件\")\n\n            # 保存快照\n            self.save_snapshot(storage, new_snapshot, file_count)\n\n            return True\n\n        except Exception as e:\n            logger.error(f\"强制全量扫描失败: {storage}:{mon_path} - {e}\")\n            return False\n\n    def load_snapshot(self, storage: str) -> Optional[Dict]:\n        \"\"\"\n        从文件缓存加载快照\n        :param storage: 存储名称\n        :return: 快照数据或None\n        \"\"\"\n        try:\n            cache_key = f\"{storage}_snapshot\"\n            snapshot_data = self._snapshot_cache.get(cache_key, region=\"snapshots\")\n            if snapshot_data:\n                data = json.loads(snapshot_data.decode('utf-8'))\n                logger.debug(f\"成功加载快照: {storage}, 包含 {len(data.get('snapshot', {}))} 个文件\")\n                return data\n            logger.debug(f\"快照文件不存在: {storage}\")\n            return None\n        except Exception as e:\n            logger.error(f\"加载快照失败: {e}\")\n            return None\n\n    @staticmethod\n    def adjust_monitor_interval(file_count: int) -> int:\n        \"\"\"\n        根据文件数量动态调整监控间隔\n        :param file_count: 文件数量\n        :return: 监控间隔（分钟）\n        \"\"\"\n        if file_count < 100:\n            return 5  # 5分钟\n        elif file_count < 500:\n            return 10  # 10分钟\n        elif file_count < 1000:\n            return 15  # 15分钟\n        else:\n            return 30  # 30分钟\n\n    @staticmethod\n    def compare_snapshots(old_snapshot: Dict, new_snapshot: Dict) -> Dict[str, List]:\n        \"\"\"\n        比对快照，找出变化的文件（只处理新增和修改，不处理删除）\n        :param old_snapshot: 旧快照\n        :param new_snapshot: 新快照\n        :return: 变化信息\n        \"\"\"\n        changes = {\n            'added': [],\n            'modified': []\n        }\n\n        old_files = set(old_snapshot.keys())\n        new_files = set(new_snapshot.keys())\n\n        # 新增文件\n        changes['added'] = list(new_files - old_files)\n\n        # 修改文件（大小或时间变化）\n        for file_path in old_files & new_files:\n            old_info = old_snapshot[file_path]\n            new_info = new_snapshot[file_path]\n\n            # 检查文件大小变化\n            old_size = old_info.get('size', 0) if isinstance(old_info, dict) else old_info\n            new_size = new_info.get('size', 0) if isinstance(new_info, dict) else new_info\n\n            # 检查修改时间变化（如果有的话）\n            old_time = old_info.get('modify_time', 0) if isinstance(old_info, dict) else 0\n            new_time = new_info.get('modify_time', 0) if isinstance(new_info, dict) else 0\n\n            if old_size != new_size or (old_time and new_time and old_time != new_time):\n                changes['modified'].append(file_path)\n\n        return changes\n\n    @staticmethod\n    def count_directory_files(directory: Path, max_check: int = 10000) -> int:\n        \"\"\"\n        统计目录下的文件数量（用于检测是否超过系统限制）\n        :param directory: 目录路径\n        :param max_check: 最大检查数量，避免长时间阻塞\n        :return: 文件数量\n        \"\"\"\n        try:\n            count = 0\n            import os\n            for root, dirs, files in os.walk(str(directory)):\n                count += len(files)\n                if count > max_check:\n                    return count\n            return count\n        except Exception as err:\n            logger.debug(f\"统计目录文件数量失败: {err}\")\n            return 0\n\n    @staticmethod\n    def check_system_limits() -> Dict[str, Any]:\n        \"\"\"\n        检查系统限制\n        :return: 系统限制信息\n        \"\"\"\n        limits = {\n            'max_user_watches': 0,\n            'max_user_instances': 0,\n            'current_watches': 0,\n            'warnings': []\n        }\n\n        try:\n            system = platform.system()\n            if system == 'Linux':\n                # 检查 inotify 限制\n                try:\n                    with open('/proc/sys/fs/inotify/max_user_watches', 'r') as f:\n                        limits['max_user_watches'] = int(f.read().strip())\n                except Exception as e:\n                    logger.debug(f\"读取 inotify 限制失败: {e}\")\n                    limits['max_user_watches'] = 8192  # 默认值\n\n                try:\n                    with open('/proc/sys/fs/inotify/max_user_instances', 'r') as f:\n                        limits['max_user_instances'] = int(f.read().strip())\n                except Exception as e:\n                    logger.debug(f\"读取 inotify 实例限制失败: {e}\")\n\n                # 检查当前使用的watches\n                try:\n                    import subprocess\n                    result = subprocess.run(['find', '/proc/*/fd', '-lname', 'anon_inode:inotify', '-printf', '%h\\n'],\n                                            capture_output=True, text=True, timeout=5)\n                    if result.returncode == 0:\n                        limits['current_watches'] = len(result.stdout.strip().split('\\n'))\n                except Exception as e:\n                    logger.debug(f\"检查当前 inotify 使用失败: {e}\")\n\n        except Exception as e:\n            limits['warnings'].append(f\"检查系统限制时出错: {e}\")\n\n        return limits\n\n    @staticmethod\n    def get_system_optimization_tips() -> List[str]:\n        \"\"\"\n        获取系统优化建议\n        :return: 优化建议列表\n        \"\"\"\n        tips = []\n        system = platform.system()\n\n        if system == 'Linux':\n            tips.extend([\n                \"增加 inotify 监控数量限制:\",\n                \"echo fs.inotify.max_user_watches=524288 | sudo tee -a /etc/sysctl.conf\",\n                \"echo fs.inotify.max_user_instances=524288 | sudo tee -a /etc/sysctl.conf\",\n                \"sudo sysctl -p\",\n                \"\",\n                \"如果在Docker中运行，请在宿主机上执行以上命令\"\n            ])\n        elif system == 'Darwin':\n            tips.extend([\n                \"macOS 系统优化建议:\",\n                \"sudo sysctl kern.maxfiles=65536\",\n                \"sudo sysctl kern.maxfilesperproc=32768\",\n                \"ulimit -n 32768\"\n            ])\n        elif system == 'Windows':\n            tips.extend([\n                \"Windows 系统优化建议:\",\n                \"1. 关闭不必要的实时保护软件对监控目录的扫描\",\n                \"2. 将监控目录添加到Windows Defender排除列表\",\n                \"3. 确保有足够的可用内存\"\n            ])\n\n        return tips\n\n    @staticmethod\n    def should_use_polling(directory: Path, monitor_mode: str,\n                           file_count: int, limits: dict) -> tuple[bool, str]:\n        \"\"\"\n        判断是否应该使用轮询模式\n        :param directory: 监控目录\n        :param monitor_mode: 配置的监控模式\n        :param file_count: 目录文件数量\n        :param limits: 系统限制信息\n        :return: (是否使用轮询, 原因)\n        \"\"\"\n        if monitor_mode == \"compatibility\":\n            return True, \"用户配置为兼容模式\"\n\n        # 检查网络文件系统\n        if SystemUtils.is_network_filesystem(directory):\n            return True, \"检测到网络文件系统，建议使用兼容模式\"\n\n        max_watches = limits.get('max_user_watches')\n        if max_watches and file_count > max_watches * 0.8:\n            return True, f\"目录文件数量({file_count})接近系统限制({max_watches})\"\n        return False, \"使用快速模式\"\n\n    def init(self):\n        \"\"\"\n        启动监控\n        \"\"\"\n        # 停止现有任务\n        self.stop()\n\n        # 读取目录配置\n        monitor_dirs = DirectoryHelper().get_download_dirs()\n        if not monitor_dirs:\n            logger.info(\"未找到任何目录监控配置\")\n            return\n\n        # 按下载目录去重\n        monitor_dirs = list({f\"{d.storage}_{d.download_path}\": d for d in monitor_dirs}.values())\n        logger.info(f\"找到 {len(monitor_dirs)} 个目录监控配置\")\n\n        # 启动定时服务进程\n        self._scheduler = BackgroundScheduler(timezone=settings.TZ)\n\n        messagehelper = MessageHelper()\n        mon_storages = {}\n        for mon_dir in monitor_dirs:\n            if not mon_dir.library_path:\n                logger.warn(f\"跳过监控配置 {mon_dir.download_path}：未设置媒体库目录\")\n                continue\n            if mon_dir.monitor_type != \"monitor\":\n                logger.debug(f\"跳过监控配置 {mon_dir.download_path}：监控类型为 {mon_dir.monitor_type}\")\n                continue\n\n            # 检查媒体库目录是不是下载目录的子目录\n            mon_path = Path(mon_dir.download_path)\n            target_path = Path(mon_dir.library_path)\n            if target_path.is_relative_to(mon_path):\n                logger.warn(f\"{target_path} 是监控目录 {mon_path} 的子目录，无法监控！\")\n                messagehelper.put(f\"{target_path} 是监控目录 {mon_path} 的子目录，无法监控\", title=\"目录监控\")\n                continue\n\n            # 启动监控\n            if mon_dir.storage == \"local\":\n                # 本地目录监控\n                logger.info(f\"正在启动本地目录监控: {mon_path}\")\n                logger.info(\"*** 重要提示：目录监控只处理新增和修改的文件，不会处理监控启动前已存在的文件 ***\")\n\n                try:\n                    # 统计文件数量并给出提示\n                    file_count = self.count_directory_files(mon_path)\n                    logger.info(f\"监控目录 {mon_path} 包含约 {file_count} 个文件\")\n\n                    # 检查系统限制\n                    limits = self.check_system_limits()\n\n                    # 检查是否需要使用轮询模式\n                    use_polling, reason = self.should_use_polling(mon_path,\n                                                                  monitor_mode=mon_dir.monitor_mode,\n                                                                  file_count=file_count,\n                                                                  limits=limits)\n                    logger.info(f\"监控模式决策: {reason}\")\n\n                    if use_polling:\n                        observer = PollingObserver()\n                        logger.info(f\"使用兼容模式(轮询)监控 {mon_path}\")\n                    else:\n                        observer = self.__choose_observer()\n                        if observer is None:\n                            logger.warn(f\"快速模式不可用，自动切换到兼容模式监控 {mon_path}\")\n                            observer = PollingObserver()\n                        else:\n                            logger.info(f\"使用快速模式监控 {mon_path}\")\n                            if limits['warnings']:\n                                for warning in limits['warnings']:\n                                    logger.warn(f\"系统限制警告: {warning}\")\n                            if limits['max_user_watches'] > 0:\n                                usage_percent = (file_count / limits['max_user_watches']) * 100\n                                logger.info(\n                                    f\"系统监控资源使用率: {usage_percent:.1f}% ({file_count}/{limits['max_user_watches']})\")\n\n                    self._observers.append(observer)\n                    observer.schedule(FileMonitorHandler(mon_path=mon_path, callback=self),\n                                      path=str(mon_path),\n                                      recursive=True)\n                    observer.daemon = True\n                    observer.start()\n\n                    mode_name = \"兼容模式(轮询)\" if use_polling else \"快速模式\"\n                    logger.info(f\"✓ 本地目录监控已启动: {mon_path} [{mode_name}]\")\n\n                except Exception as e:\n                    err_msg = str(e)\n                    logger.error(f\"启动本地目录监控失败: {mon_path}\")\n                    logger.error(f\"错误详情: {err_msg}\")\n\n                    if \"inotify\" in err_msg.lower():\n                        logger.error(\"inotify 相关错误，这通常是由于系统监控数量限制导致的\")\n                        logger.error(\"解决方案:\")\n                        tips = self.get_system_optimization_tips()\n                        for tip in tips:\n                            logger.error(f\"  {tip}\")\n                        logger.error(\"执行上述命令后重启 MoviePilot\")\n                    elif \"permission\" in err_msg.lower():\n                        logger.error(\"权限错误，请检查 MoviePilot 是否有足够的权限访问监控目录\")\n                    else:\n                        logger.error(\"建议尝试使用兼容模式进行监控\")\n\n                    messagehelper.put(f\"启动本地目录监控失败: {mon_path}\\n错误: {err_msg}\", title=\"目录监控\")\n            else:\n                if not mon_storages.get(mon_dir.storage):\n                    mon_storages[mon_dir.storage] = []\n                mon_storages[mon_dir.storage].append(mon_path)\n\n        for storage, paths in mon_storages.items():\n            # 远程目录监控 - 使用智能间隔\n            # 先尝试加载已有快照获取文件数量\n            snapshot_data = self.load_snapshot(storage)\n            file_count = snapshot_data.get('file_count', 0) if snapshot_data else 0\n            interval = self.adjust_monitor_interval(file_count)\n            for path in paths:\n                logger.info(f\"正在启动远程目录监控: {path} [{storage}]\")\n            logger.info(\"*** 重要提示：远程目录监控只处理新增和修改的文件，不会处理监控启动前已存在的文件 ***\")\n            logger.info(f\"预估文件数量: {file_count}, 监控间隔: {interval}分钟\")\n\n            self._scheduler.add_job(\n                self.polling_observer,\n                'interval',\n                minutes=interval,\n                kwargs={\n                    'storage': storage,\n                    'mon_paths': paths\n                },\n                id=f\"monitor_{storage}\",\n                replace_existing=True\n            )\n            logger.info(f\"✓ 远程目录监控已启动: [间隔: {interval}分钟]\")\n\n        # 启动定时服务\n        if self._scheduler.get_jobs():\n            self._scheduler.print_jobs()\n            self._scheduler.start()\n            logger.info(\"定时监控服务已启动\")\n\n        # 输出监控总结\n        local_count = len([d for d in monitor_dirs if d.storage == \"local\" and d.monitor_type == \"monitor\"])\n        remote_count = len([d for d in monitor_dirs if d.storage != \"local\" and d.monitor_type == \"monitor\"])\n        logger.info(f\"目录监控启动完成: 本地监控 {local_count} 个，远程监控 {remote_count} 个\")\n\n    def __choose_observer(self) -> Optional[Any]:\n        \"\"\"\n        选择最优的监控模式（带错误处理和自动回退）\n        \"\"\"\n        system = platform.system()\n\n        observers_to_try = []\n\n        try:\n            if system == 'Linux':\n                observers_to_try = [\n                    ('InotifyObserver',\n                     lambda: self.__try_import_observer('watchdog.observers.inotify', 'InotifyObserver')),\n                ]\n            elif system == 'Darwin':\n                observers_to_try = [\n                    ('FSEventsObserver',\n                     lambda: self.__try_import_observer('watchdog.observers.fsevents', 'FSEventsObserver')),\n                ]\n            elif system == 'Windows':\n                observers_to_try = [\n                    ('WindowsApiObserver',\n                     lambda: self.__try_import_observer('watchdog.observers.read_directory_changes',\n                                                        'WindowsApiObserver')),\n                ]\n\n            # 尝试每个观察者\n            for observer_name, observer_func in observers_to_try:\n                try:\n                    observer_class = observer_func()\n                    if observer_class:\n                        # 尝试创建实例以验证是否可用\n                        test_observer = observer_class()\n                        test_observer.stop()  # 立即停止测试实例\n                        logger.debug(f\"成功初始化 {observer_name}\")\n                        return observer_class()\n                except Exception as e:\n                    logger.debug(f\"初始化 {observer_name} 失败: {e}\")\n                    continue\n\n        except Exception as e:\n            logger.debug(f\"选择观察者时出错: {e}\")\n\n        logger.debug(\"所有快速监控模式都不可用，将使用兼容模式\")\n        return None\n\n    @staticmethod\n    def __try_import_observer(module_name: str, class_name: str):\n        \"\"\"\n        尝试导入观察者类\n        \"\"\"\n        try:\n            module = __import__(module_name, fromlist=[class_name])\n            return getattr(module, class_name)\n        except (ImportError, AttributeError) as e:\n            logger.debug(f\"导入 {module_name}.{class_name} 失败: {e}\")\n            return None\n\n    def polling_observer(self, storage: str, mon_paths: List[Path]):\n        \"\"\"\n        轮询监控（改进版）\n        \"\"\"\n        with snapshot_lock:\n            try:\n                # 加载上次快照数据\n                old_snapshot_data = self.load_snapshot(storage)\n                old_snapshot = old_snapshot_data.get('snapshot', {}) if old_snapshot_data else {}\n                last_snapshot_time = old_snapshot_data.get('timestamp', 0) if old_snapshot_data else 0\n\n                # 判断是否为首次快照：检查快照文件是否存在且有效\n                is_first_snapshot = old_snapshot_data is None\n                new_snapshot = {}\n                for mon_path in mon_paths:\n                    logger.debug(f\"开始对 {storage}:{mon_path} 进行快照...\")\n\n                    # 生成新快照（增量模式）\n                    snapshot = StorageChain().snapshot_storage(\n                        storage=storage,\n                        path=mon_path,\n                        last_snapshot_time=last_snapshot_time\n                    )\n\n                    if snapshot is None:\n                        logger.warn(f\"获取 {storage}:{mon_path} 快照失败\")\n                        continue\n                    new_snapshot.update(snapshot)\n                    file_count = len(snapshot)\n                    logger.info(f\"{storage}:{mon_path} 快照完成，发现 {file_count} 个文件\")\n                file_count = len(new_snapshot)\n                if not is_first_snapshot:\n                    # 比较快照找出变化\n                    changes = self.compare_snapshots(old_snapshot, new_snapshot)\n\n                    # 处理新增文件\n                    for new_file in changes['added']:\n                        logger.info(f\"发现新增文件：{new_file}\")\n                        file_info = new_snapshot.get(new_file, {})\n                        file_size = file_info.get('size', 0) if isinstance(file_info, dict) else file_info\n                        self.__handle_file(storage=storage, event_path=Path(new_file), file_size=file_size)\n\n                    # 处理修改文件\n                    for modified_file in changes['modified']:\n                        logger.info(f\"发现修改文件：{modified_file}\")\n                        file_info = new_snapshot.get(modified_file, {})\n                        file_size = file_info.get('size', 0) if isinstance(file_info, dict) else file_info\n                        self.__handle_file(storage=storage, event_path=Path(modified_file), file_size=file_size)\n\n                    if changes['added'] or changes['modified']:\n                        logger.info(\n                            f\"{storage} 发现 {len(changes['added'])} 个新增文件，{len(changes['modified'])} 个修改文件\")\n                    else:\n                        logger.debug(f\"{storage} 无文件变化\")\n                else:\n                    logger.info(f\"{storage} 首次快照完成，共 {file_count} 个文件\")\n                    logger.info(\"*** 首次快照仅建立基准，不会处理现有文件。后续监控将处理新增和修改的文件 ***\")\n\n                # 保存新快照\n                self.save_snapshot(storage, new_snapshot, file_count, last_snapshot_time)\n\n                # 动态调整监控间隔\n                new_interval = self.adjust_monitor_interval(file_count)\n                current_job = self._scheduler.get_job(f\"monitor_{storage}\")\n                if current_job and current_job.trigger.interval.total_seconds() / 60 != new_interval:\n                    # 重新安排任务\n                    self._scheduler.modify_job(\n                        f\"monitor_{storage}\",\n                        trigger='interval',\n                        minutes=new_interval\n                    )\n                    logger.info(f\"{storage}:{mon_path} 监控间隔已调整为 {new_interval} 分钟\")\n\n            except Exception as e:\n                logger.error(f\"轮询监控 {storage}:{mon_path} 出现错误：{e}\")\n                logger.debug(traceback.format_exc())\n\n    def event_handler(self, event, text: str, event_path: str, file_size: float = None):\n        \"\"\"\n        处理文件变化\n        :param event: 事件\n        :param text: 事件描述\n        :param event_path: 事件文件路径\n        :param file_size: 文件大小\n        \"\"\"\n        if not event.is_directory:\n            # 文件发生变化\n            logger.debug(f\"检测到文件变化: {event_path} [{text}]\")\n            # 整理文件\n            self.__handle_file(storage=\"local\", event_path=Path(event_path), file_size=file_size)\n\n    def __handle_file(self, storage: str, event_path: Path, file_size: float = None):\n        \"\"\"\n        整理一个文件\n        :param storage: 存储\n        :param event_path: 事件文件路径\n        :param file_size: 文件大小\n        \"\"\"\n\n        def __is_bluray_sub(_path: Path) -> bool:\n            \"\"\"\n            判断是否蓝光原盘目录内的子目录或文件\n            \"\"\"\n            return True if re.search(r\"BDMV/STREAM\", _path.as_posix(), re.IGNORECASE) else False\n\n        def __get_bluray_dir(_path: Path) -> Optional[Path]:\n            \"\"\"\n            获取蓝光原盘BDMV目录的上级目录\n            \"\"\"\n            for p in _path.parents:\n                if p.name == \"BDMV\":\n                    return p.parent\n            return None\n\n        # 全程加锁\n        with lock:\n            is_bluray_folder = False\n            # 蓝光原盘文件处理\n            if __is_bluray_sub(event_path):\n                event_path = __get_bluray_dir(event_path)\n                if not event_path:\n                    return\n                is_bluray_folder = True\n\n            # TTL缓存控重\n            if self._cache.get(str(event_path)):\n                logger.debug(f\"文件 {event_path} 在缓存中，跳过处理\")\n                return\n            self._cache[str(event_path)] = True\n\n            try:\n                if is_bluray_folder:\n                    logger.info(f\"开始整理蓝光原盘: {event_path}\")\n                else:\n                    logger.info(f\"开始整理文件: {event_path}\")\n                # 开始整理\n                TransferChain().do_transfer(\n                    fileitem=FileItem(\n                        storage=storage,\n                        path=(\n                            event_path.as_posix()\n                            if not is_bluray_folder\n                            else event_path.as_posix() + \"/\"\n                        ),\n                        type=\"file\" if not is_bluray_folder else \"dir\",\n                        name=event_path.name,\n                        basename=event_path.stem,\n                        extension=event_path.suffix[1:],\n                        size=file_size\n                    )\n                )\n            except Exception as e:\n                logger.error(\"目录监控整理文件发生错误：%s - %s\" % (str(e), traceback.format_exc()))\n\n    def stop(self):\n        \"\"\"\n        退出监控\n        \"\"\"\n        self._event.set()\n        if self._observers:\n            logger.info(\"正在停止本地目录监控服务...\")\n            for observer in self._observers:\n                try:\n                    observer.stop()\n                    observer.join()\n                    logger.debug(f\"已停止监控服务: {observer}\")\n                except Exception as e:\n                    logger.error(f\"停止目录监控服务出现了错误：{e}\")\n            self._observers = []\n            logger.info(\"本地目录监控服务已停止\")\n        if self._scheduler:\n            self._scheduler.remove_all_jobs()\n            if self._scheduler.running:\n                try:\n                    self._scheduler.shutdown()\n                    logger.info(\"定时监控服务已停止\")\n                except Exception as e:\n                    logger.error(f\"停止定时服务出现了错误：{e}\")\n            self._scheduler = None\n        if self._cache:\n            self._cache.close()\n        if self._snapshot_cache:\n            self._snapshot_cache.close()\n        self._event.clear()\n"
  },
  {
    "path": "app/plugins/__init__.py",
    "content": "from abc import ABCMeta, abstractmethod\nfrom pathlib import Path\nfrom typing import Any, List, Dict, Tuple, Optional, Type\n\nfrom app.chain import ChainBase\nfrom app.core.config import settings\nfrom app.core.event import EventManager\nfrom app.db.plugindata_oper import PluginDataOper\nfrom app.db.systemconfig_oper import SystemConfigOper\nfrom app.helper.message import MessageHelper\nfrom app.schemas import Notification, NotificationType, MessageChannel\n\n\nclass PluginChian(ChainBase):\n    \"\"\"\n    插件处理链\n    \"\"\"\n    pass\n\n\nclass _PluginBase(metaclass=ABCMeta):\n    \"\"\"\n    插件模块基类，通过继续该类实现插件功能\n    除内置属性外，还有以下方法可以扩展或调用：\n    - stop_service() 停止插件服务\n    - get_config() 获取配置信息\n    - update_config() 更新配置信息\n    - init_plugin() 生效配置信息\n    - get_data_path() 获取插件数据保存目录\n    \"\"\"\n    # 插件名称\n    plugin_name: Optional[str] = \"\"\n    # 插件描述\n    plugin_desc: Optional[str] = \"\"\n    # 插件顺序\n    plugin_order: Optional[int] = 9999\n    # 是否为插件分身\n    is_clone: bool = False\n\n    def __init__(self):\n        # 插件数据\n        self.plugindata = PluginDataOper()\n        # 处理链\n        self.chain = PluginChian()\n        # 系统配置\n        self.systemconfig = SystemConfigOper()\n        # 系统消息\n        self.systemmessage = MessageHelper()\n        # 事件管理器\n        self.eventmanager = EventManager()\n\n    @abstractmethod\n    def init_plugin(self, config: dict = None):\n        \"\"\"\n        生效配置信息\n        :param config: 配置信息字典\n        \"\"\"\n        pass\n\n    def get_name(self) -> str:\n        \"\"\"\n        获取插件名称\n        :return: 插件名称\n        \"\"\"\n        return self.plugin_name\n\n    @abstractmethod\n    def get_state(self) -> bool:\n        \"\"\"\n        获取插件运行状态\n        \"\"\"\n        pass\n\n    @staticmethod\n    def get_command() -> List[Dict[str, Any]]:\n        \"\"\"\n        注册插件远程命令\n        [{\n            \"cmd\": \"/xx\",\n            \"event\": EventType.xx,\n            \"desc\": \"名称\",\n            \"category\": \"分类，需要注册到Wechat时必须有分类\",\n            \"data\": {}\n        }]\n        \"\"\"\n        pass\n\n    @staticmethod\n    def get_render_mode() -> Tuple[str, Optional[str]]:\n        \"\"\"\n        获取插件渲染模式\n        :return: 1、渲染模式，支持：vue/vuetify，默认vuetify；2、vue模式下编译后文件的相对路径，默认为`dist/asserts`，vuetify模式下为None\n        \"\"\"\n        return \"vuetify\", None\n\n    @abstractmethod\n    def get_api(self) -> List[Dict[str, Any]]:\n        \"\"\"\n        注册插件API\n        [{\n            \"path\": \"/xx\",\n            \"endpoint\": self.xxx,\n            \"methods\": [\"GET\", \"POST\"],\n            \"auth: \"apikey\",  # 鉴权类型：apikey/bear\n            \"summary\": \"API名称\",\n            \"description\": \"API说明\"\n        }]\n        \"\"\"\n        pass\n\n    @abstractmethod\n    def get_form(self) -> Tuple[Optional[List[dict]], Dict[str, Any]]:\n        \"\"\"\n        拼装插件配置页面，插件配置页面使用Vuetify组件拼装，参考：https://vuetifyjs.com/\n        :return: 1、页面配置（vuetify模式）或 None（vue模式）；2、默认数据结构\n        \"\"\"\n        pass\n\n    @abstractmethod\n    def get_page(self) -> Optional[List[dict]]:\n        \"\"\"\n        拼装插件详情页面，需要返回页面配置，同时附带数据\n        插件详情页面使用Vuetify组件拼装，参考：https://vuetifyjs.com/\n        :return: 页面配置（vuetify模式）或 None（vue模式）\n        \"\"\"\n        pass\n\n    def get_service(self) -> List[Dict[str, Any]]:\n        \"\"\"\n        注册插件公共服务\n        [{\n            \"id\": \"服务ID\",\n            \"name\": \"服务名称\",\n            \"trigger\": \"触发器：cron/interval/date/CronTrigger.from_crontab()\",\n            \"func\": self.xxx,\n            \"kwargs\": {} # 定时器参数\n        }]\n        \"\"\"\n        pass\n\n    def get_dashboard(self, key: str, **kwargs) -> Optional[Tuple[Dict[str, Any], Dict[str, Any], Optional[List[dict]]]]:\n        \"\"\"\n        获取插件仪表盘页面，需要返回：1、仪表板col配置字典；2、全局配置（布局、自动刷新等）；3、仪表板页面元素配置含数据json（vuetify）或 None（vue模式）\n        1、col配置参考：\n        {\n            \"cols\": 12, \"md\": 6\n        }\n        2、全局配置参考：\n        {\n            \"refresh\": 10, // 自动刷新时间，单位秒\n            \"border\": True, // 是否显示边框，默认True，为False时取消组件边框和边距，由插件自行控制\n            \"title\": \"组件标题\", // 组件标题，如有将显示该标题，否则显示插件名称\n            \"subtitle\": \"组件子标题\", // 组件子标题，缺省时不展示子标题\n        }\n        3、vuetify模式页面配置使用Vuetify组件拼装，参考：https://vuetifyjs.com/；vue模式为None\n\n        kwargs参数可获取的值：1、user_agent：浏览器UA\n\n        :param key: 仪表盘key，根据指定的key返回相应的仪表盘数据，缺省时返回一个固定的仪表盘数据（兼容旧版）\n        \"\"\"\n        pass\n\n    def get_dashboard_meta(self) -> Optional[List[Dict[str, str]]]:\n        \"\"\"\n        获取插件仪表盘元信息\n        返回示例：\n            [{\n                \"key\": \"dashboard1\", // 仪表盘的key，在当前插件范围唯一\n                \"name\": \"仪表盘1\" // 仪表盘的名称\n            }, {\n                \"key\": \"dashboard2\",\n                \"name\": \"仪表盘2\"\n            }]\n        \"\"\"\n        pass\n\n    def get_module(self) -> Dict[str, Any]:\n        \"\"\"\n        获取插件模块声明，用于胁持系统模块实现（方法名：方法实现）\n        {\n            \"id1\": self.xxx1,\n            \"id2\": self.xxx2,\n        }\n        \"\"\"\n        pass\n\n    def get_actions(self) -> List[Dict[str, Any]]:\n        \"\"\"\n        获取插件工作流动作\n        [{\n            \"id\": \"动作ID\",\n            \"name\": \"动作名称\",\n            \"func\": self.xxx,\n            \"kwargs\": {} # 需要附加传递的参数\n        }]\n\n        对实现函数的要求：\n        1、函数的第一个参数固定为 ActionContent 实例，如需要传递额外参数，在kwargs中定义\n        2、函数的返回：执行状态 True / False，更新后的 ActionContent 实例\n        \"\"\"\n        pass\n\n    def get_agent_tools(self) -> List[Type]:\n        \"\"\"\n        获取插件智能体工具\n        返回工具类列表，每个工具类必须继承自 MoviePilotTool\n        [ToolClass1, ToolClass2, ...]\n\n        对工具类的要求：\n        1、工具类必须继承自 app.agent.tools.base.MoviePilotTool\n        2、工具类需要实现 run 方法（异步方法）\n        3、工具类需要定义 name 和 description 属性\n        4、工具类可以定义 args_schema 来指定输入参数模型\n        \"\"\"\n        pass\n\n    @abstractmethod\n    def stop_service(self):\n        \"\"\"\n        停止插件\n        \"\"\"\n        pass\n\n    def update_config(self, config: dict, plugin_id: Optional[str] = None) -> bool:\n        \"\"\"\n        更新配置信息\n        :param config: 配置信息字典\n        :param plugin_id: 插件ID\n        \"\"\"\n        if not plugin_id:\n            plugin_id = self.__class__.__name__\n        return self.systemconfig.set(f\"plugin.{plugin_id}\", config)\n\n    def get_config(self, plugin_id: Optional[str] = None) -> Any:\n        \"\"\"\n        获取配置信息\n        :param plugin_id: 插件ID\n        \"\"\"\n        if not plugin_id:\n            plugin_id = self.__class__.__name__\n        return self.systemconfig.get(f\"plugin.{plugin_id}\")\n\n    def get_data_path(self, plugin_id: Optional[str] = None) -> Path:\n        \"\"\"\n        获取插件数据保存目录\n        \"\"\"\n        if not plugin_id:\n            plugin_id = self.__class__.__name__\n        data_path = settings.PLUGIN_DATA_PATH / f\"{plugin_id}\"\n        if not data_path.exists():\n            data_path.mkdir(parents=True)\n        return data_path\n\n    def save_data(self, key: str, value: Any, plugin_id: Optional[str] = None):\n        \"\"\"\n        保存插件数据\n        :param key: 数据key\n        :param value: 数据值\n        :param plugin_id: 插件ID\n        \"\"\"\n        if not plugin_id:\n            plugin_id = self.__class__.__name__\n        self.plugindata.save(plugin_id, key, value)\n\n    def get_data(self, key: Optional[str] = None, plugin_id: Optional[str] = None) -> Any:\n        \"\"\"\n        获取插件数据\n        :param key: 数据key\n        :param plugin_id: plugin_id\n        \"\"\"\n        if not plugin_id:\n            plugin_id = self.__class__.__name__\n        return self.plugindata.get_data(plugin_id, key)\n\n    def del_data(self, key: str, plugin_id: Optional[str] = None) -> Any:\n        \"\"\"\n        删除插件数据\n        :param key: 数据key\n        :param plugin_id: plugin_id\n        \"\"\"\n        if not plugin_id:\n            plugin_id = self.__class__.__name__\n        return self.plugindata.del_data(plugin_id, key)\n\n    def post_message(self, channel: MessageChannel = None, mtype: NotificationType = None, title: Optional[str] = None,\n                     text: Optional[str] = None, image: Optional[str] = None, link: Optional[str] = None,\n                     userid: Optional[str] = None, username: Optional[str] = None,\n                     **kwargs):\n        \"\"\"\n        发送消息\n        \"\"\"\n        if not link:\n            link = settings.MP_DOMAIN(f\"#/plugins?tab=installed&id={self.__class__.__name__}\")\n        self.chain.post_message(Notification(\n            channel=channel, mtype=mtype, title=title, text=text,\n            image=image, link=link, userid=userid, username=username, **kwargs\n        ))\n\n    def close(self):\n        pass\n"
  },
  {
    "path": "app/scheduler.py",
    "content": "import asyncio\nimport gc\nimport inspect\nimport multiprocessing\nimport threading\nimport traceback\nfrom datetime import datetime, timedelta\nfrom typing import List, Optional\n\nimport pytz\nfrom apscheduler.executors.pool import ThreadPoolExecutor\nfrom apscheduler.jobstores.base import JobLookupError\nfrom apscheduler.schedulers.background import BackgroundScheduler\nfrom apscheduler.triggers.cron import CronTrigger\n\nfrom app import schemas\nfrom app.chain import ChainBase\nfrom app.chain.mediaserver import MediaServerChain\nfrom app.chain.recommend import RecommendChain\nfrom app.chain.site import SiteChain\nfrom app.chain.subscribe import SubscribeChain\nfrom app.chain.transfer import TransferChain\nfrom app.chain.workflow import WorkflowChain\nfrom app.core.config import settings, global_vars\nfrom app.core.event import eventmanager\nfrom app.core.plugin import PluginManager\nfrom app.db.systemconfig_oper import SystemConfigOper\nfrom app.helper.message import MessageHelper\nfrom app.helper.sites import SitesHelper  # noqa\nfrom app.helper.image import WallpaperHelper\nfrom app.log import logger\nfrom app.schemas import Notification, NotificationType, Workflow\nfrom app.schemas.types import EventType, SystemConfigKey\nfrom app.utils.gc import get_memory_usage\nfrom app.utils.mixins import ConfigReloadMixin\nfrom app.utils.singleton import SingletonClass\nfrom app.utils.timer import TimerUtils\n\nlock = threading.Lock()\n\n\nclass SchedulerChain(ChainBase):\n    pass\n\n\nclass Scheduler(ConfigReloadMixin, metaclass=SingletonClass):\n    \"\"\"\n    定时任务管理\n    \"\"\"\n    CONFIG_WATCH = {\n        \"DEV\",\n        \"COOKIECLOUD_INTERVAL\",\n        \"MEDIASERVER_SYNC_INTERVAL\",\n        \"SUBSCRIBE_SEARCH\",\n        \"SUBSCRIBE_SEARCH_INTERVAL\",\n        \"SUBSCRIBE_MODE\",\n        \"SUBSCRIBE_RSS_INTERVAL\",\n        \"SITEDATA_REFRESH_INTERVAL\",\n    }\n\n    def __init__(self):\n        # 定时服务\n        self._scheduler = None\n        # 退出事件\n        self._event = threading.Event()\n        # 锁\n        self._lock = threading.RLock()\n        # 各服务的运行状态\n        self._jobs = {}\n        # 用户认证失败次数\n        self._auth_count = 0\n        # 用户认证失败消息发送\n        self._auth_message = False\n        # 初始化\n        self.init()\n\n    def on_config_changed(self):\n        self.init()\n\n    def get_reload_name(self):\n        return \"定时服务\"\n\n    def init(self):\n        \"\"\"\n        初始化定时服务\n        \"\"\"\n\n        # 停止定时服务\n        self.stop()\n\n        # 调试模式不启动定时服务\n        if settings.DEV:\n            return\n\n        with lock:\n            # 各服务的运行状态\n            self._jobs = {\n                \"cookiecloud\": {\n                    \"name\": \"同步CookieCloud站点\",\n                    \"func\": SiteChain().sync_cookies,\n                    \"running\": False\n                },\n                \"mediaserver_sync\": {\n                    \"name\": \"同步媒体服务器\",\n                    \"func\": MediaServerChain().sync,\n                    \"running\": False\n                },\n                \"subscribe_tmdb\": {\n                    \"name\": \"订阅元数据更新\",\n                    \"func\": SubscribeChain().check,\n                    \"running\": False\n                },\n                \"subscribe_search\": {\n                    \"name\": \"订阅搜索补全\",\n                    \"func\": SubscribeChain().search,\n                    \"running\": False,\n                    \"kwargs\": {\n                        \"state\": \"R\"\n                    }\n                },\n                \"new_subscribe_search\": {\n                    \"name\": \"新增订阅搜索\",\n                    \"func\": SubscribeChain().search,\n                    \"running\": False,\n                    \"kwargs\": {\n                        \"state\": \"N\"\n                    }\n                },\n                \"subscribe_refresh\": {\n                    \"name\": \"订阅刷新\",\n                    \"func\": SubscribeChain().refresh,\n                    \"running\": False\n                },\n                \"subscribe_follow\": {\n                    \"name\": \"关注的订阅分享\",\n                    \"func\": SubscribeChain().follow,\n                    \"running\": False\n                },\n                \"transfer\": {\n                    \"name\": \"下载文件整理\",\n                    \"func\": TransferChain().process,\n                    \"running\": False\n                },\n                \"clear_cache\": {\n                    \"name\": \"缓存清理\",\n                    \"func\": self.clear_cache,\n                    \"running\": False\n                },\n                \"user_auth\": {\n                    \"name\": \"用户认证检查\",\n                    \"func\": self.user_auth,\n                    \"running\": False\n                },\n                \"scheduler_job\": {\n                    \"name\": \"公共定时服务\",\n                    \"func\": SchedulerChain().scheduler_job,\n                    \"running\": False\n                },\n                \"random_wallpager\": {\n                    \"name\": \"壁纸缓存\",\n                    \"func\": WallpaperHelper().get_wallpapers,\n                    \"running\": False\n                },\n                \"sitedata_refresh\": {\n                    \"name\": \"站点数据刷新\",\n                    \"func\": SiteChain().refresh_userdatas,\n                    \"running\": False\n                },\n                \"recommend_refresh\": {\n                    \"name\": \"推荐缓存\",\n                    \"func\": RecommendChain().refresh_recommend,\n                    \"running\": False\n                },\n                \"plugin_market_refresh\": {\n                    \"name\": \"插件市场缓存\",\n                    \"func\": PluginManager().async_get_online_plugins,\n                    \"running\": False,\n                    \"kwargs\": {\n                        \"force\": True\n                    }\n                },\n                \"subscribe_calendar_cache\": {\n                    \"name\": \"订阅日历缓存\",\n                    \"func\": SubscribeChain().cache_calendar,\n                    \"running\": False\n                },\n                \"full_gc\": {\n                    \"name\": \"主动内存回收\",\n                    \"func\": self.full_gc,\n                    \"running\": False\n                }\n            }\n\n            # 创建定时服务\n            self._scheduler = BackgroundScheduler(timezone=settings.TZ,\n                                                  executors={\n                                                      'default': ThreadPoolExecutor(settings.CONF.scheduler)\n                                                  })\n\n            # CookieCloud定时同步\n            if settings.COOKIECLOUD_INTERVAL \\\n                    and str(settings.COOKIECLOUD_INTERVAL).isdigit():\n                self._scheduler.add_job(\n                    self.start,\n                    \"interval\",\n                    id=\"cookiecloud\",\n                    name=\"同步CookieCloud站点\",\n                    minutes=int(settings.COOKIECLOUD_INTERVAL),\n                    next_run_time=datetime.now(pytz.timezone(settings.TZ)) + timedelta(minutes=5),\n                    kwargs={\n                        'job_id': 'cookiecloud'\n                    }\n                )\n\n            # 媒体服务器同步\n            if settings.MEDIASERVER_SYNC_INTERVAL \\\n                    and str(settings.MEDIASERVER_SYNC_INTERVAL).isdigit():\n                self._scheduler.add_job(\n                    self.start,\n                    \"interval\",\n                    id=\"mediaserver_sync\",\n                    name=\"同步媒体服务器\",\n                    hours=int(settings.MEDIASERVER_SYNC_INTERVAL),\n                    next_run_time=datetime.now(pytz.timezone(settings.TZ)) + timedelta(minutes=10),\n                    kwargs={\n                        'job_id': 'mediaserver_sync'\n                    }\n                )\n\n            # 新增订阅时搜索（5分钟检查一次）\n            self._scheduler.add_job(\n                self.start,\n                \"interval\",\n                id=\"new_subscribe_search\",\n                name=\"新增订阅搜索\",\n                minutes=5,\n                kwargs={\n                    'job_id': 'new_subscribe_search'\n                }\n            )\n\n            # 检查更新订阅TMDB数据（每隔6小时）\n            self._scheduler.add_job(\n                self.start,\n                \"interval\",\n                id=\"subscribe_tmdb\",\n                name=\"订阅元数据更新\",\n                hours=6,\n                kwargs={\n                    'job_id': 'subscribe_tmdb'\n                }\n            )\n\n            # 订阅状态每隔24小时搜索一次\n            if settings.SUBSCRIBE_SEARCH:\n                self._scheduler.add_job(\n                    self.start,\n                    \"interval\",\n                    id=\"subscribe_search\",\n                    name=\"订阅搜索补全\",\n                    hours=settings.SUBSCRIBE_SEARCH_INTERVAL,\n                    kwargs={\n                        'job_id': 'subscribe_search'\n                    }\n                )\n\n            if settings.SUBSCRIBE_MODE == \"spider\":\n                # 站点首页种子定时刷新模式\n                triggers = TimerUtils.random_scheduler(num_executions=32)\n                for trigger in triggers:\n                    self._scheduler.add_job(\n                        self.start,\n                        \"cron\",\n                        id=f\"subscribe_refresh|{trigger.hour}:{trigger.minute}\",\n                        name=\"订阅刷新\",\n                        hour=trigger.hour,\n                        minute=trigger.minute,\n                        kwargs={\n                            'job_id': 'subscribe_refresh'\n                        })\n            else:\n                # RSS订阅模式\n                if not settings.SUBSCRIBE_RSS_INTERVAL \\\n                        or not str(settings.SUBSCRIBE_RSS_INTERVAL).isdigit():\n                    settings.SUBSCRIBE_RSS_INTERVAL = 30\n                elif int(settings.SUBSCRIBE_RSS_INTERVAL) < 5:\n                    settings.SUBSCRIBE_RSS_INTERVAL = 5\n                self._scheduler.add_job(\n                    self.start,\n                    \"interval\",\n                    id=\"subscribe_refresh\",\n                    name=\"RSS订阅刷新\",\n                    minutes=int(settings.SUBSCRIBE_RSS_INTERVAL),\n                    kwargs={\n                        'job_id': 'subscribe_refresh'\n                    }\n                )\n\n            # 关注订阅分享（每1小时）\n            self._scheduler.add_job(\n                self.start,\n                \"interval\",\n                id=\"subscribe_follow\",\n                name=\"关注的订阅分享\",\n                hours=1,\n                kwargs={\n                    'job_id': 'subscribe_follow'\n                }\n            )\n\n            # 下载器文件转移（每5分钟）\n            self._scheduler.add_job(\n                self.start,\n                \"interval\",\n                id=\"transfer\",\n                name=\"下载文件整理\",\n                minutes=5,\n                kwargs={\n                    'job_id': 'transfer'\n                }\n            )\n\n            # 后台刷新TMDB壁纸\n            self._scheduler.add_job(\n                self.start,\n                \"interval\",\n                id=\"random_wallpager\",\n                name=\"壁纸缓存\",\n                minutes=30,\n                next_run_time=datetime.now(pytz.timezone(settings.TZ)) + timedelta(seconds=1),\n                kwargs={\n                    'job_id': 'random_wallpager'\n                }\n            )\n\n            # 公共定时服务\n            self._scheduler.add_job(\n                self.start,\n                \"interval\",\n                id=\"scheduler_job\",\n                name=\"公共定时服务\",\n                minutes=10,\n                kwargs={\n                    'job_id': 'scheduler_job'\n                }\n            )\n\n            # 缓存清理服务，每隔24小时\n            self._scheduler.add_job(\n                self.start,\n                \"interval\",\n                id=\"clear_cache\",\n                name=\"缓存清理\",\n                hours=settings.CONF.meta / 3600,\n                kwargs={\n                    'job_id': 'clear_cache'\n                }\n            )\n\n            # 定时检查用户认证，每隔10分钟\n            self._scheduler.add_job(\n                self.start,\n                \"interval\",\n                id=\"user_auth\",\n                name=\"用户认证检查\",\n                minutes=10,\n                kwargs={\n                    'job_id': 'user_auth'\n                }\n            )\n\n            # 站点数据刷新\n            if settings.SITEDATA_REFRESH_INTERVAL:\n                self._scheduler.add_job(\n                    self.start,\n                    \"interval\",\n                    id=\"sitedata_refresh\",\n                    name=\"站点数据刷新\",\n                    minutes=settings.SITEDATA_REFRESH_INTERVAL * 60,\n                    kwargs={\n                        'job_id': 'sitedata_refresh'\n                    }\n                )\n\n            # 推荐缓存\n            self._scheduler.add_job(\n                self.start,\n                \"interval\",\n                id=\"recommend_refresh\",\n                name=\"推荐缓存\",\n                hours=24,\n                next_run_time=datetime.now(pytz.timezone(settings.TZ)) + timedelta(seconds=5),\n                kwargs={\n                    'job_id': 'recommend_refresh'\n                }\n            )\n\n            # 插件市场缓存\n            self._scheduler.add_job(\n                self.start,\n                \"interval\",\n                id=\"plugin_market_refresh\",\n                name=\"插件市场缓存\",\n                minutes=30,\n                kwargs={\n                    'job_id': 'plugin_market_refresh'\n                }\n            )\n\n            # 订阅日历缓存\n            self._scheduler.add_job(\n                self.start,\n                \"interval\",\n                id=\"subscribe_calendar_cache\",\n                name=\"订阅日历缓存\",\n                hours=6,\n                next_run_time=datetime.now(pytz.timezone(settings.TZ)) + timedelta(minutes=2),\n                kwargs={\n                    'job_id': 'subscribe_calendar_cache'\n                }\n            )\n\n            # 主动内存回收\n            if settings.MEMORY_GC_INTERVAL:\n                self._scheduler.add_job(\n                    self.start,\n                    \"interval\",\n                    id=\"full_gc\",\n                    name=\"主动内存回收\",\n                    minutes=settings.MEMORY_GC_INTERVAL,\n                    kwargs={\n                        'job_id': 'full_gc'\n                    }\n                )\n\n            # 初始化工作流服务\n            self.init_workflow_jobs()\n\n            # 初始化插件服务\n            self.init_plugin_jobs()\n\n            # 启动定时服务\n            self._scheduler.start()\n\n    def __prepare_job(self, job_id: str) -> Optional[dict]:\n        \"\"\"\n        准备定时任务\n        \"\"\"\n        with self._lock:\n            job = self._jobs.get(job_id)\n            if not job:\n                return None\n            if job.get(\"running\"):\n                logger.warning(f\"定时任务 {job_id} - {job.get('name')} 正在运行 ...\")\n                return None\n            self._jobs[job_id][\"running\"] = True\n        return job\n\n    def __finish_job(self, job_id: str):\n        \"\"\"\n        完成定时任务\n        \"\"\"\n        with self._lock:\n            try:\n                self._jobs[job_id][\"running\"] = False\n            except KeyError:\n                pass\n\n    def start(self, job_id: str, *args, **kwargs):\n        \"\"\"\n        启动定时服务\n        \"\"\"\n\n        def __start_coro(coro):\n            \"\"\"\n            启动协程\n            \"\"\"\n            return asyncio.run_coroutine_threadsafe(coro, global_vars.loop)\n\n        # 获取定时任务\n        job = self.__prepare_job(job_id)\n        if not job:\n            return\n        # 开始运行\n        try:\n            if not kwargs:\n                kwargs = job.get(\"kwargs\") or {}\n            func = job.get(\"func\")\n            if not func:\n                return\n            # 是否多进程运行\n            run_in_process = job.get(\"run_in_process\", False)\n            if inspect.iscoroutinefunction(func):\n                # 协程函数\n                __start_coro(func(*args, **kwargs))\n            elif run_in_process:\n                # 多进程运行\n                p = multiprocessing.Process(target=func, args=args, kwargs=kwargs)\n                p.start()\n                p.join()\n            else:\n                # 普通函数\n                job[\"func\"](*args, **kwargs)\n        except Exception as e:\n            logger.error(f\"定时任务 {job.get('name')} 执行失败：{str(e)} - {traceback.format_exc()}\")\n            MessageHelper().put(title=f\"{job.get('name')} 执行失败\",\n                                message=str(e),\n                                role=\"system\")\n            eventmanager.send_event(\n                EventType.SystemError,\n                {\n                    \"type\": \"scheduler\",\n                    \"scheduler_id\": job_id,\n                    \"scheduler_name\": job.get('name'),\n                    \"error\": str(e),\n                    \"traceback\": traceback.format_exc()\n                }\n            )\n        # 运行结束\n        self.__finish_job(job_id)\n\n    def init_plugin_jobs(self):\n        \"\"\"\n        初始化插件定时服务\n        \"\"\"\n        for pid in PluginManager().get_running_plugin_ids():\n            self.update_plugin_job(pid)\n\n    def init_workflow_jobs(self):\n        \"\"\"\n        初始化工作流定时服务\n        \"\"\"\n        for workflow in WorkflowChain().get_timer_workflows() or []:\n            self.update_workflow_job(workflow)\n\n    def remove_workflow_job(self, workflow: Workflow):\n        \"\"\"\n        移除工作流服务\n        \"\"\"\n        if not self._scheduler:\n            return\n        with self._lock:\n            job_id = f\"workflow-{workflow.id}\"\n            service = self._jobs.pop(job_id, {})\n            if not service:\n                return\n            try:\n                # 在调度器中查找并移除对应的 job\n                job_removed = False\n                for job in list(self._scheduler.get_jobs()):\n                    if job_id == job.id:\n                        try:\n                            self._scheduler.remove_job(job.id)\n                            job_removed = True\n                        except JobLookupError:\n                            pass\n                        break\n                if job_removed:\n                    logger.info(f\"移除工作流服务：{service.get('name')}\")\n            except Exception as e:\n                logger.error(f\"移除工作流服务失败：{str(e)} - {job_id}: {service}\")\n                SchedulerChain().messagehelper.put(title=f\"工作流 {workflow.name} 服务移除失败\",\n                                                   message=str(e),\n                                                   role=\"system\")\n\n    def remove_plugin_job(self, pid: str, job_id: Optional[str] = None):\n        \"\"\"\n        移除定时服务，可以是单个服务（包括默认服务）或整个插件的所有服务\n        :param pid: 插件 ID\n        :param job_id: 可选，指定要移除的单个服务的 job_id。如果不提供，则移除该插件的所有服务，当移除单个服务时，默认服务也包含在内\n        \"\"\"\n        if not self._scheduler:\n            return\n        with self._lock:\n            if job_id:\n                # 移除单个服务\n                service = self._jobs.pop(job_id, None)\n                if not service:\n                    return\n                jobs_to_remove = [(job_id, service)]\n            else:\n                # 移除插件的所有服务\n                jobs_to_remove = [\n                    (job_id, service) for job_id, service in self._jobs.items() if service.get(\"pid\") == pid\n                ]\n                for job_id, _ in jobs_to_remove:\n                    self._jobs.pop(job_id, None)\n            if not jobs_to_remove:\n                return\n            plugin_name = PluginManager().get_plugin_attr(pid, \"plugin_name\")\n            # 遍历移除任务\n            for job_id, service in jobs_to_remove:\n                try:\n                    # 在调度器中查找并移除对应的 job\n                    job_removed = False\n                    for job in list(self._scheduler.get_jobs()):\n                        job_id_from_service = job.id.split(\"|\")[0]\n                        if job_id == job_id_from_service:\n                            try:\n                                self._scheduler.remove_job(job.id)\n                                job_removed = True\n                            except JobLookupError:\n                                pass\n                    if job_removed:\n                        logger.info(f\"移除插件服务({plugin_name})：{service.get('name')}\")  # noqa\n                except Exception as e:\n                    logger.error(f\"移除插件服务失败：{str(e)} - {job_id}: {service}\")\n                    SchedulerChain().messagehelper.put(title=f\"插件 {plugin_name} 服务移除失败\",\n                                                       message=str(e),\n                                                       role=\"system\")\n\n    def update_workflow_job(self, workflow: Workflow):\n        \"\"\"\n        更新工作流定时服务\n        \"\"\"\n        if not self._scheduler:\n            return\n        # 移除该工作流的全部服务\n        self.remove_workflow_job(workflow)\n        # 添加工作流服务\n        with self._lock:\n            try:\n                job_id = f\"workflow-{workflow.id}\"\n                self._jobs[job_id] = {\n                    \"func\": WorkflowChain().process,\n                    \"name\": workflow.name,\n                    \"provider_name\": \"工作流\",\n                    \"running\": False,\n                }\n                self._scheduler.add_job(\n                    self.start,\n                    trigger=CronTrigger.from_crontab(workflow.timer),\n                    id=job_id,\n                    name=workflow.name,\n                    kwargs={\"job_id\": job_id, \"workflow_id\": workflow.id},\n                    replace_existing=True\n                )\n                logger.info(f\"注册工作流服务：{workflow.name} - {workflow.timer}\")\n            except Exception as e:\n                logger.error(f\"注册工作流服务失败：{workflow.name} - {str(e)}\")\n                SchedulerChain().messagehelper.put(title=f\"工作流 {workflow.name} 服务注册失败\",\n                                                   message=str(e),\n                                                   role=\"system\")\n\n    def update_plugin_job(self, pid: str):\n        \"\"\"\n        更新插件定时服务\n        \"\"\"\n        if not self._scheduler or not pid:\n            return\n        # 移除该插件的全部服务\n        self.remove_plugin_job(pid)\n        # 获取插件服务列表\n        with self._lock:\n            plugin_manager = PluginManager()\n            try:\n                plugin_services = plugin_manager.get_plugin_services(pid=pid)\n            except Exception as e:\n                logger.error(f\"运行插件 {pid} 服务失败：{str(e)} - {traceback.format_exc()}\")\n                return\n            # 获取插件名称\n            plugin_name = plugin_manager.get_plugin_attr(pid, \"plugin_name\")\n            # 开始注册插件服务\n            for service in plugin_services:\n                try:\n                    sid = f\"{pid}_{service['id']}\"\n                    job_id = sid.split(\"|\")[0]\n                    self.remove_plugin_job(pid, job_id)\n                    self._jobs[job_id] = {\n                        \"func\": service[\"func\"],\n                        \"name\": service[\"name\"],\n                        \"pid\": pid,\n                        \"provider_name\": plugin_name,\n                        \"kwargs\": service.get(\"func_kwargs\") or {},\n                        \"running\": False,\n                    }\n                    self._scheduler.add_job(\n                        self.start,\n                        service[\"trigger\"],\n                        id=sid,\n                        name=service[\"name\"],\n                        **(service.get(\"kwargs\") or {}),\n                        kwargs={\"job_id\": job_id},\n                        replace_existing=True\n                    )\n                    logger.info(f\"注册插件{plugin_name}服务：{service['name']} - {service['trigger']}\")\n                except Exception as e:\n                    logger.error(f\"注册插件{plugin_name}服务失败：{str(e)} - {service}\")\n                    SchedulerChain().messagehelper.put(title=f\"插件 {plugin_name} 服务注册失败\",\n                                                       message=str(e),\n                                                       role=\"system\")\n\n    def list(self) -> List[schemas.ScheduleInfo]:\n        \"\"\"\n        当前所有任务\n        \"\"\"\n        if not self._scheduler:\n            return []\n        with self._lock:\n            # 返回计时任务\n            schedulers = []\n            # 去重\n            added = []\n            # 避免_scheduler.shutdown()处于阻塞状态导致的死锁\n            if not self._scheduler or not self._scheduler.running:\n                return []\n            jobs = self._scheduler.get_jobs()\n            # 按照下次运行时间排序\n            jobs.sort(key=lambda x: x.next_run_time)\n            # 将正在运行的任务提取出来 (保障一次性任务正常显示)\n            for job_id, service in self._jobs.items():\n                name = service.get(\"name\")\n                provider_name = service.get(\"provider_name\")\n                if service.get(\"running\") and name and provider_name:\n                    if job_id not in added:\n                        added.append(job_id)\n                    schedulers.append(schemas.ScheduleInfo(\n                        id=job_id,\n                        name=name,\n                        provider=provider_name,\n                        status=\"正在运行\",\n                    ))\n            # 获取其他待执行任务\n            for job in jobs:\n                job_id = job.id.split(\"|\")[0]\n                if job_id not in added:\n                    added.append(job_id)\n                else:\n                    continue\n                service = self._jobs.get(job_id)\n                if not service:\n                    continue\n                # 任务状态\n                status = \"正在运行\" if service.get(\"running\") else \"等待\"\n                # 下次运行时间\n                next_run = TimerUtils.time_difference(job.next_run_time)\n                schedulers.append(schemas.ScheduleInfo(\n                    id=job_id,\n                    name=job.name,\n                    provider=service.get(\"provider_name\", \"[系统]\"),\n                    status=status,\n                    next_run=next_run\n                ))\n            return schedulers\n\n    def stop(self):\n        \"\"\"\n        关闭定时服务\n        \"\"\"\n        with lock:\n            try:\n                if self._scheduler:\n                    logger.info(\"正在停止定时任务...\")\n                    self._event.set()\n                    self._scheduler.remove_all_jobs()\n                    if self._scheduler.running:\n                        self._scheduler.shutdown()\n                    self._scheduler = None\n                    logger.info(\"定时任务停止完成\")\n            except Exception as e:\n                logger.error(f\"停止定时任务失败：：{str(e)} - {traceback.format_exc()}\")\n\n    @staticmethod\n    def clear_cache():\n        \"\"\"\n        清理缓存\n        \"\"\"\n        SchedulerChain().clear_cache()\n\n    @staticmethod\n    def full_gc():\n        \"\"\"\n        主动内存回收\n        \"\"\"\n        memory_before = get_memory_usage()\n        collected = gc.collect()\n        memory_after = get_memory_usage()\n        memory_freed = memory_before - memory_after\n        logger.info(f\"主动内存回收完成，回收对象数: {collected}，释放内存: {memory_freed:.2f} MB\")\n\n    def user_auth(self):\n        \"\"\"\n        用户认证检查\n        \"\"\"\n        if SitesHelper().auth_level >= 2:\n            return\n        # 最大重试次数\n        __max_try__ = 30\n        if self._auth_count > __max_try__:\n            if not self._auth_message:\n                SchedulerChain().messagehelper.put(title=f\"用户认证失败\",\n                                                   message=\"用户认证失败次数过多，将不再尝试认证！\",\n                                                   role=\"system\")\n                self._auth_message = True\n            return\n        logger.info(\"用户未认证，正在尝试认证...\")\n        auth_conf = SystemConfigOper().get(SystemConfigKey.UserSiteAuthParams)\n        if auth_conf:\n            status, msg = SitesHelper().check_user(**auth_conf)\n        else:\n            status, msg = SitesHelper().check_user()\n        if status:\n            self._auth_count = 0\n            logger.info(f\"{msg} 用户认证成功\")\n            SchedulerChain().post_message(\n                Notification(\n                    mtype=NotificationType.Manual,\n                    title=\"MoviePilot用户认证成功\",\n                    text=f\"使用站点：{msg}，如有插件使用异常，请重启MoviePilot。\",\n                    link=settings.MP_DOMAIN('#/site')\n                )\n            )\n            # 认证通过后重新初始化插件\n            PluginManager().init_config()\n            self.init_plugin_jobs()\n\n        else:\n            self._auth_count += 1\n            logger.error(f\"用户认证失败，{msg}，共失败 {self._auth_count} 次\")\n            if self._auth_count >= __max_try__:\n                logger.error(\"用户认证失败次数过多，将不再尝试认证！\")\n"
  },
  {
    "path": "app/schemas/__init__.py",
    "content": "from .context import *\nfrom .dashboard import *\nfrom .download import *\nfrom .event import *\nfrom .exception import *\nfrom .file import *\nfrom .history import *\nfrom .mediaserver import *\nfrom .message import *\nfrom .monitoring import *\nfrom .plugin import *\nfrom .response import *\nfrom .rule import *\nfrom .servarr import *\nfrom .servcookie import *\nfrom .site import *\nfrom .subscribe import *\nfrom .system import *\nfrom .system import *\nfrom .tmdb import *\nfrom .token import *\nfrom .transfer import *\nfrom .user import *\nfrom .workflow import *\nfrom .mcp import *\n\n"
  },
  {
    "path": "app/schemas/agent.py",
    "content": "\"\"\"AI智能体相关数据模型\"\"\"\n\nfrom datetime import datetime\nfrom typing import Dict, List, Optional, Any\nfrom pydantic import BaseModel, Field, ConfigDict, field_serializer\n\n\nclass ConversationMemory(BaseModel):\n    \"\"\"对话记忆模型\"\"\"\n    \n    session_id: str = Field(description=\"会话ID\")\n    user_id: Optional[str] = Field(default=None, description=\"用户ID\")\n    title: Optional[str] = Field(default=None, description=\"会话标题\")\n    messages: List[Dict[str, Any]] = Field(default_factory=list, description=\"消息列表\")\n    context: Dict[str, Any] = Field(default_factory=dict, description=\"会话上下文\")\n    created_at: datetime = Field(default_factory=datetime.now, description=\"创建时间\")\n    updated_at: datetime = Field(default_factory=datetime.now, description=\"更新时间\")\n    \n    model_config = ConfigDict()\n    \n    @field_serializer('created_at', 'updated_at', when_used='json')\n    def serialize_datetime(self, value: datetime) -> str:\n        return value.isoformat()\n\n\nclass AgentState(BaseModel):\n    \"\"\"AI智能体状态模型\"\"\"\n    \n    session_id: str = Field(description=\"会话ID\")\n    current_task: Optional[str] = Field(default=None, description=\"当前任务\")\n    is_thinking: bool = Field(default=False, description=\"是否正在思考\")\n    last_activity: datetime = Field(default_factory=datetime.now, description=\"最后活动时间\")\n    \n    model_config = ConfigDict()\n    \n    @field_serializer('last_activity', when_used='json')\n    def serialize_datetime(self, value: datetime) -> str:\n        return value.isoformat()\n\n\nclass UserMessage(BaseModel):\n    \"\"\"用户消息模型\"\"\"\n    \n    session_id: str = Field(description=\"会话ID\")\n    content: str = Field(description=\"消息内容\")\n    user_id: Optional[str] = Field(default=None, description=\"用户ID\")\n    channel: Optional[str] = Field(default=None, description=\"消息渠道\")\n    source: Optional[str] = Field(default=None, description=\"消息来源\")\n\n\nclass ToolResult(BaseModel):\n    \"\"\"工具执行结果模型\"\"\"\n    \n    session_id: str = Field(description=\"会话ID\")\n    call_id: str = Field(description=\"调用ID\")\n    success: bool = Field(description=\"是否成功\")\n    result: Optional[str] = Field(default=None, description=\"执行结果\")\n    error: Optional[str] = Field(default=None, description=\"错误信息\")\n"
  },
  {
    "path": "app/schemas/category.py",
    "content": "from typing import Dict, Optional\n\nfrom pydantic import BaseModel, ConfigDict\n\n\nclass CategoryRule(BaseModel):\n    \"\"\"\n    分类规则详情\n    \"\"\"\n    # 内容类型\n    genre_ids: Optional[str] = None\n    # 语种\n    original_language: Optional[str] = None\n    # 国家或地区（电视剧）\n    origin_country: Optional[str] = None\n    # 国家或地区（电影）\n    production_countries: Optional[str] = None\n    # 发行年份\n    release_year: Optional[str] = None\n    # 允许接收其他动态字段\n    model_config = ConfigDict(extra='allow')\n\n\nclass CategoryConfig(BaseModel):\n    \"\"\"\n    分类策略配置\n    \"\"\"\n    # 电影分类策略\n    movie: Optional[Dict[str, Optional[CategoryRule]]] = {}\n    # 电视剧分类策略\n    tv: Optional[Dict[str, Optional[CategoryRule]]] = {}\n"
  },
  {
    "path": "app/schemas/context.py",
    "content": "from typing import Optional, Dict, List, Union, Any\n\nfrom pydantic import BaseModel, Field\n\n\nclass MetaInfo(BaseModel):\n    \"\"\"\n    识别元数据\n    \"\"\"\n    # 是否处理的文件\n    isfile: Optional[bool] = False\n    # 原字符串\n    org_string: Optional[str] = None\n    # 原标题\n    title: Optional[str] = None\n    # 副标题\n    subtitle: Optional[str] = None\n    # 类型 电影、电视剧\n    type: Optional[str] = None\n    # 名称\n    name: Optional[str] = None\n    # 识别的中文名\n    cn_name: Optional[str] = None\n    # 识别的英文名\n    en_name: Optional[str] = None\n    # 年份\n    year: Optional[str] = None\n    # 总季数\n    total_season: Optional[int] = 0\n    # 识别的开始季 数字\n    begin_season: Optional[int] = None\n    # 识别的结束季 数字\n    end_season: Optional[int] = None\n    # 总集数\n    total_episode: Optional[int] = 0\n    # 识别的开始集\n    begin_episode: Optional[int] = None\n    # 识别的结束集\n    end_episode: Optional[int] = None\n    # SxxExx\n    season_episode: Optional[str] = None\n    # 集列表\n    episode_list: Optional[List[int]] = Field(default_factory=list)\n    # Partx Cd Dvd Disk Disc\n    part: Optional[str] = None\n    # 识别的资源类型\n    resource_type: Optional[str] = None\n    # 识别的效果\n    resource_effect: Optional[str] = None\n    # 识别的分辨率\n    resource_pix: Optional[str] = None\n    # 识别的制作组/字幕组\n    resource_team: Optional[str] = None\n    # 视频编码\n    video_encode: Optional[str] = None\n    # 音频编码\n    audio_encode: Optional[str] = None\n    # 资源类型\n    edition: Optional[str] = None\n    # 流媒体平台\n    web_source: Optional[str] = None\n    # 应用的识别词信息\n    apply_words: Optional[List[str]] = None\n\n\nclass MediaInfo(BaseModel):\n    \"\"\"\n    识别媒体信息\n    \"\"\"\n    # 来源：themoviedb、douban、bangumi\n    source: Optional[str] = None\n    # 类型 电影、电视剧、合集\n    type: Optional[str] = None\n    # 媒体标题\n    title: Optional[str] = None\n    # 英文标题\n    en_title: Optional[str] = None\n    # 年份\n    year: Optional[str] = None\n    # 标题（年份）\n    title_year: Optional[str] = None\n    # 当前指定季，如有\n    season: Optional[int] = None\n    # TMDB ID\n    tmdb_id: Optional[int] = None\n    # IMDB ID\n    imdb_id: Optional[str] = None\n    # TVDB ID\n    tvdb_id: Optional[int] = None\n    # 豆瓣ID\n    douban_id: Optional[str] = None\n    # Bangumi ID\n    bangumi_id: Optional[int] = None\n    # 合集ID\n    collection_id: Optional[int] = None\n    # 其它媒体ID前缀\n    mediaid_prefix: Optional[str] = None\n    # 其它媒体ID值\n    media_id: Optional[str] = None\n    # 媒体原语种\n    original_language: Optional[str] = None\n    # 媒体原发行标题\n    original_title: Optional[str] = None\n    # 媒体发行日期\n    release_date: Optional[str] = None\n    # 背景图片\n    backdrop_path: Optional[str] = None\n    # 海报图片\n    poster_path: Optional[str] = None\n    # 评分\n    vote_average: Optional[float] = 0.0\n    # 描述\n    overview: Optional[str] = None\n    # 二级分类\n    category: Optional[str] = \"\"\n    # 季季集清单\n    seasons: Optional[Dict[int, list]] = Field(default_factory=dict)\n    # 季详情\n    season_info: Optional[List[dict]] = Field(default_factory=list)\n    # 别名和译名\n    names: Optional[list] = Field(default_factory=list)\n    # 演员\n    actors: Optional[list] = Field(default_factory=list)\n    # 导演\n    directors: Optional[list] = Field(default_factory=list)\n    # 详情链接\n    detail_link: Optional[str] = None\n    # 其它TMDB属性\n    # 是否成人内容\n    adult: Optional[bool] = False\n    # 创建人\n    created_by: Optional[list] = Field(default_factory=list)\n    # 集时长\n    episode_run_time: Optional[list] = Field(default_factory=list)\n    # 风格\n    genres: Optional[List[dict]] = Field(default_factory=list)\n    # 首播日期\n    first_air_date: Optional[str] = None\n    # 首页\n    homepage: Optional[str] = None\n    # 语种\n    languages: Optional[list] = Field(default_factory=list)\n    # 最后上映日期\n    last_air_date: Optional[str] = None\n    # 流媒体平台\n    networks: Optional[list] = Field(default_factory=list)\n    # 集数\n    number_of_episodes: Optional[int] = 0\n    # 季数\n    number_of_seasons: Optional[int] = 0\n    # 原产国\n    origin_country: Optional[list] = Field(default_factory=list)\n    # 原名\n    original_name: Optional[str] = None\n    # 出品公司\n    production_companies: Optional[list] = Field(default_factory=list)\n    # 出品国\n    production_countries: Optional[list] = Field(default_factory=list)\n    # 语种\n    spoken_languages: Optional[list] = Field(default_factory=list)\n    # 所有发行日期\n    release_dates: list = Field(default_factory=list)\n    # 状态\n    status: Optional[str] = None\n    # 标签\n    tagline: Optional[str] = None\n    # 风格ID\n    genre_ids: Optional[list] = Field(default_factory=list)\n    # 评价数量\n    vote_count: Optional[int] = 0\n    # 流行度\n    popularity: Optional[float] = 0.0\n    # 时长\n    runtime: Optional[int] = None\n    # 下一集\n    next_episode_to_air: Optional[dict] = Field(default_factory=dict)\n    # 全部剧集组\n    episode_groups: Optional[list] = Field(default_factory=list)\n    # 剧集组\n    episode_group: Optional[str] = None\n\n\nclass TorrentInfo(BaseModel):\n    \"\"\"\n    搜索种子信息\n    \"\"\"\n    # 站点ID\n    site: Optional[int] = None\n    # 站点名称\n    site_name: Optional[str] = None\n    # 站点Cookie\n    site_cookie: Optional[str] = None\n    # 站点UA\n    site_ua: Optional[str] = None\n    # 站点是否使用代理\n    site_proxy: Optional[bool] = False\n    # 站点优先级\n    site_order: Optional[int] = 0\n    # 站点下载器\n    site_downloader: Optional[str] = None\n    # 种子名称\n    title: Optional[str] = None\n    # 种子副标题\n    description: Optional[str] = None\n    # IMDB ID\n    imdbid: Optional[str] = None\n    # 种子链接\n    enclosure: Optional[str] = None\n    # 详情页面\n    page_url: Optional[str] = None\n    # 种子大小\n    size: Optional[float] = 0.0\n    # 做种者\n    seeders: Optional[int] = 0\n    # 下载者\n    peers: Optional[int] = 0\n    # 完成者\n    grabs: Optional[int] = 0\n    # 发布时间\n    pubdate: Optional[str] = None\n    # 已过时间\n    date_elapsed: Optional[str] = None\n    # 免费截止时间\n    freedate: Optional[str] = None\n    # 上传因子\n    uploadvolumefactor: Optional[float] = None\n    # 下载因子\n    downloadvolumefactor: Optional[float] = None\n    # HR\n    hit_and_run: Optional[bool] = False\n    # 种子标签\n    labels: Optional[list] = Field(default_factory=list)\n    # 种子优先级\n    pri_order: Optional[int] = 0\n    # 促销\n    volume_factor: Optional[str] = None\n    # 剩余免费时间\n    freedate_diff: Optional[str] = None\n\n\nclass Context(BaseModel):\n    \"\"\"\n    上下文\n    \"\"\"\n    # 元数据\n    meta_info: Optional[Union[MetaInfo, Any]] = None\n    # 媒体信息\n    media_info: Optional[Union[MediaInfo, Any]] = None\n    # 种子信息\n    torrent_info: Optional[TorrentInfo] = None\n\n\nclass MediaSeason(BaseModel):\n    \"\"\"\n    季信息\n    \"\"\"\n    air_date: Optional[str] = None\n    episode_count: Optional[int] = None\n    name: Optional[str] = None\n    overview: Optional[str] = None\n    poster_path: Optional[str] = None\n    season_number: Optional[int] = None\n    vote_average: Optional[float] = None\n\n\nclass MediaPerson(BaseModel):\n    \"\"\"\n    媒体人物信息\n    \"\"\"\n    # 来源：themoviedb、douban、bangumi\n    source: Optional[str] = None\n    # 公共\n    id: Optional[int] = None\n    type: Optional[Union[str, int]] = 1\n    name: Optional[str] = None\n    character: Optional[str] = None\n    images: Optional[dict] = Field(default_factory=dict)\n    # themoviedb\n    profile_path: Optional[str] = None\n    gender: Optional[Union[str, int]] = None\n    original_name: Optional[str] = None\n    credit_id: Optional[str] = None\n    also_known_as: Optional[list] = Field(default_factory=list)\n    birthday: Optional[str] = None\n    deathday: Optional[str] = None\n    imdb_id: Optional[str] = None\n    known_for_department: Optional[str] = None\n    place_of_birth: Optional[str] = None\n    popularity: Optional[float] = None\n    biography: Optional[str] = None\n    # douban\n    roles: Optional[list] = Field(default_factory=list)\n    title: Optional[str] = None\n    url: Optional[str] = None\n    avatar: Optional[Union[str, dict]] = None\n    latin_name: Optional[str] = None\n    # bangumi\n    career: Optional[list] = Field(default_factory=list)\n    relation: Optional[str] = None\n"
  },
  {
    "path": "app/schemas/dashboard.py",
    "content": "from typing import Optional\n\nfrom pydantic import BaseModel\n\n\nclass Statistic(BaseModel):\n    # 电影\n    movie_count: Optional[int] = 0\n    # 电视剧数量\n    tv_count: Optional[int] = 0\n    # 集数量\n    episode_count: Optional[int] = 0\n    # 用户数量\n    user_count: Optional[int] = 0\n\n\nclass Storage(BaseModel):\n    # 总存储空间\n    total_storage: Optional[float] = 0.0\n    # 已使用空间\n    used_storage: Optional[float] = 0.0\n\n\nclass ProcessInfo(BaseModel):\n    # 进程ID\n    pid: Optional[int] = 0\n    # 进程名称\n    name: Optional[str] = None\n    # 进程状态\n    status: Optional[str] = None\n    # 进程占用CPU\n    cpu: Optional[float] = 0.0\n    # 进程占用内存 MB\n    memory: Optional[float] = 0.0\n    # 进程创建时间\n    create_time: Optional[float] = 0.0\n    # 进程运行时间 秒\n    run_time: Optional[float] = 0.0\n\n\nclass DownloaderInfo(BaseModel):\n    # 下载速度\n    download_speed: Optional[float] = 0.0\n    # 上传速度\n    upload_speed: Optional[float] = 0.0\n    # 下载量\n    download_size: Optional[float] = 0.0\n    # 上传量\n    upload_size: Optional[float] = 0.0\n    # 剩余空间\n    free_space: Optional[float] = 0.0\n\n\nclass ScheduleInfo(BaseModel):\n    # ID\n    id: Optional[str] = None\n    # 名称\n    name: Optional[str] = None\n    # 提供者\n    provider: Optional[str] = None\n    # 状态\n    status: Optional[str] = None\n    # 下次执行时间\n    next_run: Optional[str] = None\n"
  },
  {
    "path": "app/schemas/download.py",
    "content": "from typing import Optional\n\nfrom pydantic import BaseModel, Field\n\n\nclass DownloadTask(BaseModel):\n    \"\"\"\n     下载任务\n    \"\"\"\n    download_id: Optional[str] = Field(default=None, description=\"任务ID\")\n    downloader: Optional[str] = Field(default=None, description=\"下载器\")\n    path: Optional[str] = Field(default=None, description=\"下载路径\")\n    completed: Optional[bool] = Field(default=False, description=\"是否完成\")\n"
  },
  {
    "path": "app/schemas/event.py",
    "content": "from pathlib import Path\nfrom typing import Iterable, Optional, Dict, Any, List, Set, Callable\n\nfrom pydantic import BaseModel, Field, field_validator, model_validator\n\nfrom app.schemas.message import MessageChannel\nfrom app.schemas.file import FileItem\n\n\nclass Event(BaseModel):\n    \"\"\"\n    事件模型\n    \"\"\"\n    event_type: str = Field(..., description=\"事件类型\")\n    event_data: Optional[dict] = Field(default={}, description=\"事件数据\")\n    priority: Optional[int] = Field(0, description=\"事件优先级\")\n\n\nclass BaseEventData(BaseModel):\n    \"\"\"\n    事件数据的基类，所有具体事件数据类应继承自此类\n    \"\"\"\n    pass\n\n\nclass ConfigChangeEventData(BaseEventData):\n    \"\"\"\n    ConfigChange 事件的数据模型\n    \"\"\"\n    key: set[str] = Field(..., description=\"配置项的键（集合类型）\")\n    value: Optional[Any] = Field(default=None, description=\"配置项的新值\")\n    change_type: str = Field(default=\"update\", description=\"配置项的变更类型，如 'add', 'update', 'delete'\")\n\n    @field_validator('key', mode='before')\n    @classmethod\n    def convert_to_set(cls, v):\n        \"\"\"将输入的 str、list、dict.keys() 等转为 set\"\"\"\n        if v is None:\n            return set()\n        elif isinstance(v, str):\n            return {v}\n        elif isinstance(v, dict):\n            return set(str(k) for k in v.keys())\n        elif isinstance(v, (list, tuple)):\n            return set(str(item) for item in v)\n        elif isinstance(v, set):\n            return set(str(item) for item in v)\n        elif isinstance(v, Iterable):\n            return set(str(item) for item in v)\n        else:\n            return {str(v)}\n\n\nclass ChainEventData(BaseEventData):\n    \"\"\"\n    链式事件数据的基类，所有具体事件数据类应继承自此类\n    \"\"\"\n    pass\n\n\nclass AuthCredentials(ChainEventData):\n    \"\"\"\n    AuthVerification 事件的数据模型\n\n    Attributes:\n        username (Optional[str]): 用户名，适用于 \"password\" grant_type\n        password (Optional[str]): 用户密码，适用于 \"password\" grant_type\n        mfa_code (Optional[str]): 一次性密码，目前仅适用于 \"password\" 认证类型\n        code (Optional[str]): 授权码，适用于 \"authorization_code\" grant_type\n        grant_type (str): 认证类型，如 \"password\", \"authorization_code\", \"client_credentials\"\n        # scope (List[str]): 权限范围，如 [\"read\", \"write\"]\n        token (Optional[str]): 认证令牌\n        channel (Optional[str]): 认证渠道\n        service (Optional[str]): 服务名称\n    \"\"\"\n    # 输入参数\n    username: Optional[str] = Field(None, description=\"用户名，适用于 'password' 认证类型\")\n    password: Optional[str] = Field(None, description=\"用户密码，适用于 'password' 认证类型\")\n    mfa_code: Optional[str] = Field(None, description=\"一次性密码，目前仅适用于 'password' 认证类型\")\n    code: Optional[str] = Field(None, description=\"授权码，适用于 'authorization_code' 认证类型\")\n    grant_type: str = Field(..., description=\"认证类型，如 'password', 'authorization_code', 'client_credentials'\")\n    # scope: List[str] = Field(default_factory=list, description=\"权限范围，如 ['read', 'write']\")\n\n    # 输出参数\n    # grant_type 为 authorization_code 时，输出参数包括 username、token、channel、service\n    token: Optional[str] = Field(default=None, description=\"认证令牌\")\n    channel: Optional[str] = Field(default=None, description=\"认证渠道\")\n    service: Optional[str] = Field(default=None, description=\"服务名称\")\n\n    @model_validator(mode='before')\n    @classmethod\n    def check_fields_based_on_grant_type(cls, values):  # noqa\n        grant_type = values.get(\"grant_type\")\n        if not grant_type:\n            values[\"grant_type\"] = \"password\"\n            grant_type = \"password\"\n\n        if grant_type == \"password\":\n            if not values.get(\"username\") or not values.get(\"password\"):\n                raise ValueError(\"username and password are required for grant_type 'password'\")\n\n        elif grant_type == \"authorization_code\":\n            if not values.get(\"code\"):\n                raise ValueError(\"code is required for grant_type 'authorization_code'\")\n\n        return values\n\n\nclass AuthInterceptCredentials(ChainEventData):\n    \"\"\"\n    AuthIntercept 事件的数据模型\n\n    Attributes:\n        # 输入参数\n        username (str): 用户名\n        channel (str): 认证渠道\n        service (str): 服务名称\n        token (str): 认证令牌\n        status (str): 认证状态，\"triggered\" 和 \"completed\" 两个状态\n\n        # 输出参数\n        source (str): 拦截源，默认值为 \"未知拦截源\"\n        cancel (bool): 是否取消认证，默认值为 False\n    \"\"\"\n    # 输入参数\n    username: Optional[str] = Field(..., description=\"用户名\")\n    channel: str = Field(..., description=\"认证渠道\")\n    service: str = Field(..., description=\"服务名称\")\n    status: str = Field(..., description=\"认证状态, 包含 'triggered' 表示认证触发，'completed' 表示认证成功\")\n    token: Optional[str] = Field(default=None, description=\"认证令牌\")\n\n    # 输出参数\n    source: str = Field(default=\"未知拦截源\", description=\"拦截源\")\n    cancel: bool = Field(default=False, description=\"是否取消认证\")\n\n\nclass CommandRegisterEventData(ChainEventData):\n    \"\"\"\n    CommandRegister 事件的数据模型\n\n    Attributes:\n        # 输入参数\n        commands (dict): 菜单命令\n        origin (str): 事件源，可以是 Chain 或具体的模块名称\n        service (str): 服务名称\n\n        # 输出参数\n        source (str): 拦截源，默认值为 \"未知拦截源\"\n        cancel (bool): 是否取消认证，默认值为 False\n    \"\"\"\n    # 输入参数\n    commands: Dict[str, dict] = Field(..., description=\"菜单命令\")\n    origin: str = Field(..., description=\"事件源\")\n    service: Optional[str] = Field(..., description=\"服务名称\")\n\n    # 输出参数\n    cancel: bool = Field(default=False, description=\"是否取消注册\")\n    source: str = Field(default=\"未知拦截源\", description=\"拦截源\")\n\n\nclass TransferRenameEventData(ChainEventData):\n    \"\"\"\n    TransferRename 事件的数据模型\n\n    Attributes:\n        # 输入参数\n        template_string (str): Jinja2 模板字符串\n        rename_dict (dict): 渲染上下文\n        render_str (str): 渲染生成的字符串\n        path (Optional[Path]): 当前文件的目标路径\n\n        # 输出参数\n        updated (bool): 是否已更新，默认值为 False\n        updated_str (str): 更新后的字符串\n        source (str): 拦截源，默认值为 \"未知拦截源\"\n    \"\"\"\n    # 输入参数\n    template_string: str = Field(..., description=\"模板字符串\")\n    rename_dict: Dict[str, Any] = Field(..., description=\"渲染上下文\")\n    path: Optional[Path] = Field(None, description=\"文件的目标路径\")\n    render_str: str = Field(..., description=\"渲染生成的字符串\")\n\n    # 输出参数\n    updated: bool = Field(default=False, description=\"是否已更新\")\n    updated_str: Optional[str] = Field(default=None, description=\"更新后的字符串\")\n    source: Optional[str] = Field(default=\"未知拦截源\", description=\"拦截源\")\n\n\nclass ResourceSelectionEventData(BaseModel):\n    \"\"\"\n    ResourceSelection 事件的数据模型\n\n    Attributes:\n        # 输入参数\n        contexts (List[Context]): 当前待选择的资源上下文列表\n        source (str): 事件源，指示事件的触发来源\n\n        # 输出参数\n        updated (bool): 是否已更新，默认值为 False\n        updated_contexts (Optional[List[Context]]): 已更新的资源上下文列表，默认值为 None\n        source (str): 更新源，默认值为 \"未知更新源\"\n    \"\"\"\n    # 输入参数\n    contexts: Any = Field(None, description=\"待选择的资源上下文列表\")\n    downloader: Optional[str] = Field(None, description=\"下载器\")\n    origin: Optional[str] = Field(None, description=\"来源\")\n\n    # 输出参数\n    updated: bool = Field(default=False, description=\"是否已更新\")\n    updated_contexts: Optional[List[Any]] = Field(default=None, description=\"已更新的资源上下文列表\")\n    source: Optional[str] = Field(default=\"未知拦截源\", description=\"拦截源\")\n\n\nclass ResourceDownloadEventData(ChainEventData):\n    \"\"\"\n    ResourceDownload 事件的数据模型\n\n    Attributes:\n        # 输入参数\n        context (Context): 当前资源上下文\n        episodes (Set[int]): 需要下载的集数\n        channel (MessageChannel): 通知渠道\n        origin (str): 来源（消息通知、Subscribe、Manual等）\n        downloader (str): 下载器\n        options (dict): 其他参数\n\n        # 输出参数\n        cancel (bool): 是否取消下载，默认值为 False\n        source (str): 拦截源，默认值为 \"未知拦截源\"\n        reason (str): 拦截原因，描述拦截的具体原因\n    \"\"\"\n    # 输入参数\n    context: Any = Field(None, description=\"当前资源上下文\")\n    episodes: Optional[Set[int]] = Field(None, description=\"需要下载的集数\")\n    channel: Optional[MessageChannel] = Field(None, description=\"通知渠道\")\n    origin: Optional[str] = Field(None, description=\"来源\")\n    downloader: Optional[str] = Field(None, description=\"下载器\")\n    options: Optional[dict] = Field(default={}, description=\"其他参数\")\n\n    # 输出参数\n    cancel: bool = Field(default=False, description=\"是否取消下载\")\n    source: str = Field(default=\"未知拦截源\", description=\"拦截源\")\n    reason: str = Field(default=\"\", description=\"拦截原因\")\n\n\nclass TransferInterceptEventData(ChainEventData):\n    \"\"\"\n    TransferIntercept 事件的数据模型\n\n    Attributes:\n        # 输入参数\n        fileitem (FileItem): 源文件\n        target_storage (str): 目标存储\n        target_path (Path): 目标路径\n        transfer_type (str): 整理方式（copy、move、link、softlink等）\n        options (dict): 其他参数\n\n        # 输出参数\n        cancel (bool): 是否取消下载，默认值为 False\n        source (str): 拦截源，默认值为 \"未知拦截源\"\n        reason (str): 拦截原因，描述拦截的具体原因\n    \"\"\"\n    # 输入参数\n    fileitem: FileItem = Field(..., description=\"源文件\")\n    mediainfo: Any = Field(..., description=\"媒体信息\")\n    target_storage: str = Field(..., description=\"目标存储\")\n    target_path: Path = Field(..., description=\"目标路径\")\n    transfer_type: str = Field(..., description=\"整理方式\")\n    options: Optional[dict] = Field(default=None, description=\"其他参数\")\n\n    # 输出参数\n    cancel: bool = Field(default=False, description=\"是否取消整理\")\n    source: str = Field(default=\"未知拦截源\", description=\"拦截源\")\n    reason: str = Field(default=\"\", description=\"拦截原因\")\n\n\nclass DiscoverMediaSource(BaseModel):\n    \"\"\"\n    探索媒体数据源的基类\n    \"\"\"\n    name: str = Field(..., description=\"数据源名称\")\n    mediaid_prefix: str = Field(..., description=\"媒体ID的前缀，不含:\")\n    api_path: str = Field(..., description=\"媒体数据源API地址\")\n    filter_params: Optional[Dict[str, Any]] = Field(default=None, description=\"过滤参数\")\n    filter_ui: Optional[List[dict]] = Field(default=[], description=\"过滤参数UI配置\")\n    depends: Optional[Dict[str, list]] = Field(default=None, description=\"UI依赖关系字典\")\n\n\nclass DiscoverSourceEventData(ChainEventData):\n    \"\"\"\n    DiscoverSource 事件的数据模型\n\n    Attributes:\n        # 输出参数\n        extra_sources (List[DiscoverMediaSource]): 额外媒体数据源\n    \"\"\"\n    # 输出参数\n    extra_sources: List[DiscoverMediaSource] = Field(default_factory=list, description=\"额外媒体数据源\")\n\n\nclass RecommendMediaSource(BaseModel):\n    \"\"\"\n    推荐媒体数据源的基类\n    \"\"\"\n    name: str = Field(..., description=\"数据源名称\")\n    api_path: str = Field(..., description=\"媒体数据源API地址\")\n    type: str = Field(..., description=\"类型\")\n\n\nclass RecommendSourceEventData(ChainEventData):\n    \"\"\"\n    RecommendSource 事件的数据模型\n\n    Attributes:\n        # 输出参数\n        extra_sources (List[RecommendMediaSource]): 额外媒体数据源\n    \"\"\"\n    # 输出参数\n    extra_sources: List[RecommendMediaSource] = Field(default_factory=list, description=\"额外媒体数据源\")\n\n\nclass MediaRecognizeConvertEventData(ChainEventData):\n    \"\"\"\n    MediaRecognizeConvert 事件的数据模型\n\n    Attributes:\n        # 输入参数\n        mediaid (str): 媒体ID，格式为`前缀:ID值`，如 tmdb:12345、douban:1234567\n        convert_type (str): 转换类型 仅支持：themoviedb/douban，需要转换为对应的媒体数据并返回\n\n        # 输出参数\n        media_dict (dict): TheMovieDb/豆瓣的媒体数据\n    \"\"\"\n    # 输入参数\n    mediaid: str = Field(..., description=\"媒体ID\")\n    convert_type: str = Field(..., description=\"转换类型（themoviedb/douban）\")\n\n    # 输出参数\n    media_dict: dict = Field(default_factory=dict, description=\"转换后的媒体信息（TheMovieDb/豆瓣）\")\n\n\nclass StorageOperSelectionEventData(ChainEventData):\n    \"\"\"\n    StorageOperSelect 事件的数据模型\n\n    Attributes:\n        # 输入参数\n        storage (str): 存储类型\n\n        # 输出参数\n        storage_oper (Callable): 存储操作对象\n    \"\"\"\n    # 输入参数\n    storage: Optional[str] = Field(default=None, description=\"存储类型\")\n\n    # 输出参数\n    storage_oper: Optional[Callable] = Field(default=None, description=\"存储操作对象\")\n"
  },
  {
    "path": "app/schemas/exception.py",
    "content": "class ImmediateException(Exception):\n    \"\"\"\n    用于立即抛出异常而不重试的特殊异常类。\n    当不希望使用重试机制时，可以抛出此异常。\n    \"\"\"\n    pass\n\n\nclass LimitException(ImmediateException):\n    \"\"\"\n    用于表示本地限流器或外部触发的限流异常的基类。\n    该异常类可用于本地限流逻辑或外部限流处理。\n    \"\"\"\n    pass\n\n\nclass APIRateLimitException(LimitException):\n    \"\"\"\n    用于表示API速率限制的异常类。\n    当API调用触发速率限制时，可以抛出此异常以立即终止操作并报告错误。\n    \"\"\"\n    pass\n\n\nclass RateLimitExceededException(LimitException):\n    \"\"\"\n    用于表示本地限流器触发的异常类。\n    当函数调用频率超过限流器的限制时，可以抛出此异常以停止当前操作并告知调用者限流情况。\n    这个异常通常用于本地限流逻辑（例如 RateLimiter），当系统检测到函数调用频率过高时，触发限流并抛出该异常。\n    \"\"\"\n    pass\n\n\nclass OperationInterrupted(KeyboardInterrupt):\n    \"\"\"\n    用于表示操作被中断\n    \"\"\"\n    pass\n"
  },
  {
    "path": "app/schemas/file.py",
    "content": "from typing import Optional\n\nfrom pathlib import Path\nfrom pydantic import BaseModel, Field\nfrom app.schemas.types import StorageSchema\n\n\nclass FileURI(BaseModel):\n    # 文件路径\n    path: Optional[str] = \"/\"\n    # 存储类型\n    storage: Optional[str] = Field(default=\"local\")\n\n    @property\n    def uri(self) -> str:\n        return self.path if self.storage == \"local\" else f\"{self.storage}:{self.path}\"\n\n    @classmethod\n    def from_uri(cls, uri: str) -> \"FileURI\":\n        storage, path = 'local', uri\n        for s in StorageSchema:\n            protocol = f\"{s.value}:\"\n            if uri.startswith(protocol):\n                path = uri[len(protocol):]\n                storage = s.value\n                break\n        if not path.startswith(\"/\"):\n            path = \"/\" + path\n        path = Path(path).as_posix()\n        return cls(storage=storage, path=path)\n\nclass FileItem(FileURI):\n    # 类型 dir/file\n    type: Optional[str] = None\n    # 文件名\n    name: Optional[str] = None\n    # 文件名\n    basename: Optional[str] = None\n    # 文件后缀\n    extension: Optional[str] = None\n    # 文件大小\n    size: Optional[int] = None\n    # 修改时间\n    modify_time: Optional[float] = None\n    # 子节点\n    children: Optional[list] = Field(default_factory=list)\n    # ID\n    fileid: Optional[str] = None\n    # 父ID\n    parent_fileid: Optional[str] = None\n    # 缩略图\n    thumbnail: Optional[str] = None\n    # 115 pickcode\n    pickcode: Optional[str] = None\n    # drive_id\n    drive_id: Optional[str] = None\n    # url\n    url: Optional[str] = None\n\n\nclass StorageUsage(BaseModel):\n    # 总空间\n    total: float = 0.0\n    # 剩余空间\n    available: float = 0.0\n\n\nclass StorageTransType(BaseModel):\n    # 传输类型\n    transtype: Optional[dict] = Field(default_factory=dict)\n\n"
  },
  {
    "path": "app/schemas/history.py",
    "content": "from typing import Optional, Any\n\nfrom pydantic import BaseModel, ConfigDict\n\n\nclass DownloadHistory(BaseModel):\n    # ID\n    id: int\n    # 保存路程\n    path: Optional[str] = None\n    # 类型：电影、电视剧\n    type: Optional[str] = None\n    # 标题\n    title: Optional[str] = None\n    # 年份\n    year: Optional[str] = None\n    # TMDBID\n    tmdbid: Optional[int] = None\n    # IMDBID\n    imdbid: Optional[str] = None\n    # TVDBID\n    tvdbid: Optional[int] = None\n    # 豆瓣ID\n    doubanid: Optional[str] = None\n    # 季Sxx\n    seasons: Optional[str] = None\n    # 集Exx\n    episodes: Optional[str] = None\n    # 海报\n    image: Optional[str] = None\n    # 下载器Hash\n    download_hash: Optional[str] = None\n    # 种子名称\n    torrent_name: Optional[str] = None\n    # 种子描述\n    torrent_description: Optional[str] = None\n    # 站点\n    torrent_site: Optional[str] = None\n    # 下载用户\n    userid: Optional[str] = None\n    # 下载用户名\n    username: Optional[str] = None\n    # 下载渠道\n    channel: Optional[str] = None\n    # 创建时间\n    date: Optional[str] = None\n    # 备注\n    note: Optional[Any] = None\n    # 自定义媒体类别\n    media_category: Optional[str] = None\n    # 自定义剧集组\n    episode_group: Optional[str] = None\n\n    model_config = ConfigDict(from_attributes=True)\n\n\nclass TransferHistory(BaseModel):\n    # ID\n    id: int\n    # 源目录\n    src: Optional[str] = None\n    # 目的目录\n    dest: Optional[str] = None\n    # 转移模式\n    mode: Optional[str] = None\n    # 类型：电影、电视剧\n    type: Optional[str] = None\n    # 二级分类\n    category: Optional[str] = None\n    # 标题\n    title: Optional[str] = None\n    # 年份\n    year: Optional[str] = None\n    # TMDBID\n    tmdbid: Optional[int] = None\n    # IMDBID\n    imdbid: Optional[str] = None\n    # TVDBID\n    tvdbid: Optional[int] = None\n    # 豆瓣ID\n    doubanid: Optional[str] = None\n    # 季Sxx\n    seasons: Optional[str] = None\n    # 集Exx\n    episodes: Optional[str] = None\n    # 海报\n    image: Optional[str] = None\n    # 下载器Hash\n    download_hash: Optional[str] = None\n    # 自定义剧集组\n    episode_group: Optional[str] = None\n    # 状态 1-成功，0-失败\n    status: bool = True\n    # 失败原因\n    errmsg: Optional[str] = None\n    # 日期\n    date: Optional[str] = None\n\n    model_config = ConfigDict(from_attributes=True)\n"
  },
  {
    "path": "app/schemas/mcp.py",
    "content": "from typing import Any, Dict, Optional\n\nfrom pydantic import BaseModel, Field\n\n\nclass ToolCallRequest(BaseModel):\n    \"\"\"工具调用请求模型\"\"\"\n    tool_name: str = Field(..., description=\"工具名称\")\n    arguments: Dict[str, Any] = Field(default_factory=dict, description=\"工具参数\")\n\n\nclass ToolCallResponse(BaseModel):\n    \"\"\"工具调用响应模型\"\"\"\n    success: bool = Field(..., description=\"是否成功\")\n    result: Optional[str] = Field(None, description=\"工具执行结果\")\n    error: Optional[str] = Field(None, description=\"错误信息\")\n"
  },
  {
    "path": "app/schemas/mediaserver.py",
    "content": "from pathlib import Path\nfrom typing import Optional, Dict, Union, List, Any\n\nfrom pydantic import BaseModel, Field, ConfigDict\n\nfrom app.schemas.types import MediaType\n\n\nclass ExistMediaInfo(BaseModel):\n    \"\"\"\n    媒体服务器存在媒体信息\n    \"\"\"\n    # 类型 电影、电视剧\n    type: Optional[MediaType] = None\n    # 季\n    seasons: Optional[Dict[int, list]] = Field(default_factory=dict)\n    # 媒体服务器类型：plex、jellyfin、emby、trimemedia、ugreen\n    server_type: Optional[str] = None\n    # 媒体服务器名称\n    server: Optional[str] = None\n    # 媒体ID\n    itemid: Optional[Union[str, int]] = None\n\n\nclass NotExistMediaInfo(BaseModel):\n    \"\"\"\n    媒体服务器不存在媒体信息\n    \"\"\"\n    # 季\n    season: Optional[int] = None\n    # 剧集列表\n    episodes: Optional[list] = Field(default_factory=list)\n    # 总集数\n    total_episode: Optional[int] = 0\n    # 开始集\n    start_episode: Optional[int] = 0\n\n\nclass RefreshMediaItem(BaseModel):\n    \"\"\"\n    媒体库刷新信息\n    \"\"\"\n    # 标题\n    title: Optional[str] = None\n    # 年份\n    year: Optional[Union[str, int]] = None\n    # 类型\n    type: Optional[MediaType] = None\n    # 类别\n    category: Optional[str] = None\n    # 目录\n    target_path: Optional[Path] = None\n\n\nclass MediaServerLibrary(BaseModel):\n    \"\"\"\n    媒体服务器媒体库信息\n    \"\"\"\n    # 服务器\n    server: Optional[str] = None\n    # ID\n    id: Optional[Union[str, int]] = None\n    # 名称\n    name: Optional[str] = None\n    # 路径\n    path: Optional[Union[str, list]] = None\n    # 类型\n    type: Optional[str] = None\n    # 封面图\n    image: Optional[str] = None\n    # 封面图列表\n    image_list: Optional[List[str]] = None\n    # 跳转链接\n    link: Optional[str] = None\n    # 服务器类型\n    server_type: Optional[str] = None\n    # 飞牛的图片需要Cookies\n    use_cookies: Optional[bool] = None\n\n\nclass MediaServerItemUserState(BaseModel):\n    # 已播放\n    played: Optional[bool] = None\n    # 继续播放\n    resume: Optional[bool] = None\n    # 上次播放时间 10位时间戳\n    last_played_date: Optional[str] = None\n    # 播放次数(不等于完播次数，理解为浏览次数)\n    play_count: Optional[int] = None\n    # 播放进度\n    percentage: Optional[float] = None\n\n\nclass MediaServerItem(BaseModel):\n    \"\"\"\n    媒体服务器媒体信息\n    \"\"\"\n    # ID\n    id: Optional[Union[str, int]] = None\n    # 服务器\n    server: Optional[str] = None\n    # 媒体库ID\n    library: Optional[Union[str, int]] = None\n    # ID\n    item_id: Optional[str] = None\n    # 类型\n    item_type: Optional[str] = None\n    # 标题\n    title: Optional[str] = None\n    # 原标题\n    original_title: Optional[str] = None\n    # 年份\n    year: Optional[Union[str, int]] = None\n    # TMDBID\n    tmdbid: Optional[int] = None\n    # IMDBID\n    imdbid: Optional[str] = None\n    # TVDBID\n    tvdbid: Optional[str] = None\n    # 路径\n    path: Optional[str] = None\n    # 季集\n    seasoninfo: Optional[Dict[int, list]] = None\n    # 备注\n    note: Optional[Any] = None\n    # 同步时间\n    lst_mod_date: Optional[str] = None\n    user_state: Optional[MediaServerItemUserState] = None\n\n    model_config = ConfigDict(from_attributes=True)\n\n\nclass MediaServerSeasonInfo(BaseModel):\n    \"\"\"\n    媒体服务器媒体剧集信息\n    \"\"\"\n    season: Optional[int] = None\n    episodes: Optional[List[int]] = Field(default_factory=list)\n\n\nclass WebhookEventInfo(BaseModel):\n    \"\"\"\n    Webhook事件信息\n    \"\"\"\n    event: Optional[str] = None\n    channel: Optional[str] = None\n    server_name: Optional[str] = None\n    item_type: Optional[str] = None\n    item_name: Optional[str] = None\n    item_id: Optional[str] = None\n    item_path: Optional[str] = None\n    season_id: Optional[str] = None\n    episode_id: Optional[str] = None\n    tmdb_id: Optional[str] = None\n    overview: Optional[str] = None\n    percentage: Optional[float] = None\n    ip: Optional[str] = None\n    device_name: Optional[str] = None\n    client: Optional[str] = None\n    user_name: Optional[str] = None\n    image_url: Optional[str] = None\n    item_favorite: Optional[bool] = None\n    save_reason: Optional[str] = None\n    item_isvirtual: Optional[bool] = None\n    media_type: Optional[str] = None\n    json_object: Optional[dict] = Field(default_factory=dict)\n\n\nclass MediaServerPlayItem(BaseModel):\n    \"\"\"\n    媒体服务器可播放项目信息\n    \"\"\"\n    id: Optional[Union[str, int]] = None\n    title: Optional[str] = None\n    subtitle: Optional[str] = None\n    type: Optional[str] = None\n    image: Optional[str] = None\n    link: Optional[str] = None\n    percent: Optional[float] = None\n    BackdropImageTags: Optional[list] = Field(default_factory=list)\n    server_type: Optional[str] = None\n    # 飞牛的图片需要Cookies\n    use_cookies: Optional[bool] = None\n"
  },
  {
    "path": "app/schemas/message.py",
    "content": "from dataclasses import dataclass\nfrom enum import Enum\nfrom typing import Optional, Union, List, Dict, Set\n\nfrom pydantic import BaseModel, Field\n\nfrom app.schemas.types import ContentType, NotificationType, MessageChannel\n\n\nclass CommingMessage(BaseModel):\n    \"\"\"\n    外来消息\n    \"\"\"\n    # 用户ID\n    userid: Optional[Union[str, int]] = None\n    # 用户名称\n    username: Optional[Union[str, int]] = None\n    # 消息渠道\n    channel: Optional[MessageChannel] = None\n    # 来源（渠道名称）\n    source: Optional[str] = None\n    # 消息体\n    text: Optional[str] = None\n    # 时间\n    date: Optional[str] = None\n    # 消息方向\n    action: Optional[int] = 0\n    # 是否为回调消息\n    is_callback: Optional[bool] = False\n    # 回调数据\n    callback_data: Optional[str] = None\n    # 消息ID（用于回调时定位原消息）\n    message_id: Optional[Union[str, int]] = None\n    # 聊天ID（用于回调时定位聊天）\n    chat_id: Optional[str] = None\n    # 完整的回调查询信息（原始数据）\n    callback_query: Optional[Dict] = None\n\n    def to_dict(self):\n        \"\"\"\n        转换为字典\n        \"\"\"\n        items = self.model_dump()\n        for k, v in items.items():\n            if isinstance(v, MessageChannel):\n                items[k] = v.value\n        return items\n\n\nclass Notification(BaseModel):\n    \"\"\"\n    消息\n    \"\"\"\n    # 消息渠道\n    channel: Optional[MessageChannel] = None\n    # 消息来源\n    source: Optional[str] = None\n    # 消息类型\n    mtype: Optional[NotificationType] = None\n    # 内容类型\n    ctype: Optional[ContentType] = None\n    # 标题\n    title: Optional[str] = None\n    # 文本内容\n    text: Optional[str] = None\n    # 图片\n    image: Optional[str] = None\n    # 链接\n    link: Optional[str] = None\n    # 用户ID\n    userid: Optional[Union[str, int]] = None\n    # 用户名称\n    username: Optional[Union[str, int]] = None\n    # 时间\n    date: Optional[str] = None\n    # 消息方向\n    action: Optional[int] = 1\n    # 消息目标用户ID字典，未指定用户ID时使用\n    targets: Optional[dict] = None\n    # 按钮列表，格式：[[{\"text\": \"按钮文本\", \"callback_data\": \"回调数据\", \"url\": \"链接\"}]]\n    buttons: Optional[List[List[dict]]] = None\n    # 原消息ID，用于编辑消息\n    original_message_id: Optional[Union[str, int]] = None\n    # 原消息的聊天ID，用于编辑消息\n    original_chat_id: Optional[str] = None\n\n    def to_dict(self):\n        \"\"\"\n        转换为字典\n        \"\"\"\n        items = self.model_dump()\n        for k, v in items.items():\n            if isinstance(v, MessageChannel) \\\n                    or isinstance(v, NotificationType):\n                items[k] = v.value\n        return items\n\n\nclass NotificationSwitch(BaseModel):\n    \"\"\"\n    消息开关\n    \"\"\"\n    # 消息类型\n    mtype: Optional[str] = None\n    # 微信开关\n    wechat: Optional[bool] = False\n    # TG开关\n    telegram: Optional[bool] = False\n    # Slack开关\n    slack: Optional[bool] = False\n    # SynologyChat开关\n    synologychat: Optional[bool] = False\n    # VoceChat开关\n    vocechat: Optional[bool] = False\n    # WebPush开关\n    webpush: Optional[bool] = False\n    # QQ开关\n    qq: Optional[bool] = False\n\n\nclass Subscription(BaseModel):\n    \"\"\"\n    客户端消息订阅\n    \"\"\"\n    endpoint: Optional[str] = None\n    keys: Optional[dict] = Field(default_factory=dict)\n\n\nclass SubscriptionMessage(BaseModel):\n    \"\"\"\n    客户端订阅消息体\n    \"\"\"\n    title: Optional[str] = None\n    body: Optional[str] = None\n    icon: Optional[str] = None\n    url: Optional[str] = None\n    data: Optional[dict] = Field(default_factory=dict)\n\n\nclass ChannelCapability(Enum):\n    \"\"\"\n    渠道能力枚举\n    \"\"\"\n    # 支持内联按钮\n    INLINE_BUTTONS = \"inline_buttons\"\n    # 支持菜单命令\n    MENU_COMMANDS = \"menu_commands\"\n    # 支持消息编辑\n    MESSAGE_EDITING = \"message_editing\"\n    # 支持消息删除\n    MESSAGE_DELETION = \"message_deletion\"\n    # 支持回调查询\n    CALLBACK_QUERIES = \"callback_queries\"\n    # 支持富文本\n    RICH_TEXT = \"rich_text\"\n    # 支持图片\n    IMAGES = \"images\"\n    # 支持链接\n    LINKS = \"links\"\n    # 支持文件发送\n    FILE_SENDING = \"file_sending\"\n\n\n@dataclass\nclass ChannelCapabilities:\n    \"\"\"\n    渠道能力配置\n    \"\"\"\n    channel: MessageChannel\n    capabilities: Set[ChannelCapability]\n    max_buttons_per_row: int = 5\n    max_button_rows: int = 10\n    max_button_text_length: int = 30\n    fallback_enabled: bool = True\n\n\nclass ChannelCapabilityManager:\n    \"\"\"\n    渠道能力管理器\n    \"\"\"\n\n    _capabilities: Dict[MessageChannel, ChannelCapabilities] = {\n        MessageChannel.Telegram: ChannelCapabilities(\n            channel=MessageChannel.Telegram,\n            capabilities={\n                ChannelCapability.INLINE_BUTTONS,\n                ChannelCapability.MENU_COMMANDS,\n                ChannelCapability.MESSAGE_EDITING,\n                ChannelCapability.MESSAGE_DELETION,\n                ChannelCapability.CALLBACK_QUERIES,\n                ChannelCapability.RICH_TEXT,\n                ChannelCapability.IMAGES,\n                ChannelCapability.LINKS,\n                ChannelCapability.FILE_SENDING\n            },\n            max_buttons_per_row=4,\n            max_button_rows=10,\n            max_button_text_length=30\n        ),\n        MessageChannel.Wechat: ChannelCapabilities(\n            channel=MessageChannel.Wechat,\n            capabilities={\n                ChannelCapability.IMAGES,\n                ChannelCapability.LINKS,\n                ChannelCapability.MENU_COMMANDS\n            },\n            fallback_enabled=True\n        ),\n        MessageChannel.Slack: ChannelCapabilities(\n            channel=MessageChannel.Slack,\n            capabilities={\n                ChannelCapability.INLINE_BUTTONS,\n                ChannelCapability.MESSAGE_EDITING,\n                ChannelCapability.MESSAGE_DELETION,\n                ChannelCapability.CALLBACK_QUERIES,\n                ChannelCapability.RICH_TEXT,\n                ChannelCapability.IMAGES,\n                ChannelCapability.LINKS,\n                ChannelCapability.MENU_COMMANDS\n            },\n            max_buttons_per_row=3,\n            max_button_rows=8,\n            max_button_text_length=25,\n            fallback_enabled=True\n        ),\n        MessageChannel.Discord: ChannelCapabilities(\n            channel=MessageChannel.Discord,\n            capabilities={\n                ChannelCapability.INLINE_BUTTONS,\n                ChannelCapability.MESSAGE_EDITING,\n                ChannelCapability.MESSAGE_DELETION,\n                ChannelCapability.CALLBACK_QUERIES,\n                ChannelCapability.RICH_TEXT,\n                ChannelCapability.IMAGES,\n                ChannelCapability.LINKS\n            },\n            max_buttons_per_row=5,\n            max_button_rows=5,\n            max_button_text_length=80,\n            fallback_enabled=True\n        ),\n        MessageChannel.SynologyChat: ChannelCapabilities(\n            channel=MessageChannel.SynologyChat,\n            capabilities={\n                ChannelCapability.RICH_TEXT,\n                ChannelCapability.IMAGES,\n                ChannelCapability.LINKS\n            },\n            fallback_enabled=True\n        ),\n        MessageChannel.VoceChat: ChannelCapabilities(\n            channel=MessageChannel.VoceChat,\n            capabilities={\n                ChannelCapability.RICH_TEXT,\n                ChannelCapability.IMAGES,\n                ChannelCapability.LINKS\n            },\n            fallback_enabled=True\n        ),\n        MessageChannel.WebPush: ChannelCapabilities(\n            channel=MessageChannel.WebPush,\n            capabilities={\n                ChannelCapability.LINKS\n            },\n            fallback_enabled=True\n        ),\n        MessageChannel.Web: ChannelCapabilities(\n            channel=MessageChannel.Web,\n            capabilities={\n                ChannelCapability.RICH_TEXT,\n                ChannelCapability.IMAGES,\n                ChannelCapability.LINKS\n            },\n            fallback_enabled=True\n        ),\n        MessageChannel.QQ: ChannelCapabilities(\n            channel=MessageChannel.QQ,\n            capabilities={\n                ChannelCapability.RICH_TEXT,\n                ChannelCapability.IMAGES,\n                ChannelCapability.LINKS\n            },\n            fallback_enabled=True\n        )\n    }\n\n    @classmethod\n    def get_capabilities(cls, channel: MessageChannel) -> Optional[ChannelCapabilities]:\n        \"\"\"\n        获取渠道能力\n        \"\"\"\n        return cls._capabilities.get(channel)\n\n    @classmethod\n    def supports_capability(cls, channel: MessageChannel, capability: ChannelCapability) -> bool:\n        \"\"\"\n        检查渠道是否支持某项能力\n        \"\"\"\n        channel_caps = cls.get_capabilities(channel)\n        if not channel_caps:\n            return False\n        return capability in channel_caps.capabilities\n\n    @classmethod\n    def supports_buttons(cls, channel: MessageChannel) -> bool:\n        \"\"\"\n        检查渠道是否支持按钮\n        \"\"\"\n        return cls.supports_capability(channel, ChannelCapability.INLINE_BUTTONS)\n\n    @classmethod\n    def supports_callbacks(cls, channel: MessageChannel) -> bool:\n        \"\"\"\n        检查渠道是否支持回调\n        \"\"\"\n        return cls.supports_capability(channel, ChannelCapability.CALLBACK_QUERIES)\n\n    @classmethod\n    def supports_editing(cls, channel: MessageChannel) -> bool:\n        \"\"\"\n        检查渠道是否支持消息编辑\n        \"\"\"\n        return cls.supports_capability(channel, ChannelCapability.MESSAGE_EDITING)\n\n    @classmethod\n    def supports_deletion(cls, channel: MessageChannel) -> bool:\n        \"\"\"\n        检查渠道是否支持消息删除\n        \"\"\"\n        return cls.supports_capability(channel, ChannelCapability.MESSAGE_DELETION)\n\n    @classmethod\n    def get_max_buttons_per_row(cls, channel: MessageChannel) -> int:\n        \"\"\"\n        获取每行最大按钮数\n        \"\"\"\n        channel_caps = cls.get_capabilities(channel)\n        return channel_caps.max_buttons_per_row if channel_caps else 2\n\n    @classmethod\n    def get_max_button_rows(cls, channel: MessageChannel) -> int:\n        \"\"\"\n        获取最大按钮行数\n        \"\"\"\n        channel_caps = cls.get_capabilities(channel)\n        return channel_caps.max_button_rows if channel_caps else 5\n\n    @classmethod\n    def get_max_button_text_length(cls, channel: MessageChannel) -> int:\n        \"\"\"\n        获取按钮文本最大长度\n        \"\"\"\n        channel_caps = cls.get_capabilities(channel)\n        return channel_caps.max_button_text_length if channel_caps else 20\n\n    @classmethod\n    def should_use_fallback(cls, channel: MessageChannel) -> bool:\n        \"\"\"\n        是否应该使用降级策略\n        \"\"\"\n        channel_caps = cls.get_capabilities(channel)\n        return channel_caps.fallback_enabled if channel_caps else True\n"
  },
  {
    "path": "app/schemas/monitoring.py",
    "content": "from datetime import datetime\nfrom typing import List\n\nfrom pydantic import BaseModel\n\n\nclass RequestMetrics(BaseModel):\n    \"\"\"\n    请求指标模型\n    \"\"\"\n    path: str\n    method: str\n    status_code: int\n    response_time: float\n    timestamp: datetime\n    client_ip: str\n    user_agent: str\n\n\nclass PerformanceSnapshot(BaseModel):\n    \"\"\"\n    性能快照模型\n    \"\"\"\n    timestamp: datetime\n    cpu_usage: float\n    memory_usage: float\n    active_requests: int\n    request_rate: float\n    avg_response_time: float\n    error_rate: float\n    slow_requests: int\n\n\nclass EndpointStats(BaseModel):\n    \"\"\"\n    端点统计模型\n    \"\"\"\n    endpoint: str\n    count: int\n    total_time: float\n    errors: int\n    avg_time: float\n\n\nclass ErrorRequest(BaseModel):\n    \"\"\"\n    错误请求模型\n    \"\"\"\n    timestamp: str\n    method: str\n    path: str\n    status_code: int\n    response_time: float\n    client_ip: str\n\n\nclass MonitoringOverview(BaseModel):\n    \"\"\"\n    监控概览模型\n    \"\"\"\n    performance: PerformanceSnapshot\n    top_endpoints: List[EndpointStats]\n    recent_errors: List[ErrorRequest]\n    alerts: List[str]\n\n\nclass MonitoringConfig(BaseModel):\n    \"\"\"\n    监控配置模型\n    \"\"\"\n    slow_request_threshold: float = 1.0\n    error_threshold: float = 0.05\n    cpu_threshold: float = 80.0\n    memory_threshold: float = 80.0\n    max_history: int = 1000\n    window_size: int = 60\n"
  },
  {
    "path": "app/schemas/plugin.py",
    "content": "from typing import Optional, List, Dict, Any\n\nfrom pydantic import BaseModel, Field\n\n\nclass Plugin(BaseModel):\n    \"\"\"\n    插件信息\n    \"\"\"\n    id: str = None\n    # 插件名称\n    plugin_name: Optional[str] = None\n    # 插件描述\n    plugin_desc: Optional[str] = None\n    # 插件图标\n    plugin_icon: Optional[str] = None\n    # 插件版本\n    plugin_version: Optional[str] = None\n    # 插件标签\n    plugin_label: Optional[str] = None\n    # 插件作者\n    plugin_author: Optional[str] = None\n    # 作者主页\n    author_url: Optional[str] = None\n    # 插件配置项ID前缀\n    plugin_config_prefix: Optional[str] = None\n    # 加载顺序\n    plugin_order: Optional[int] = 0\n    # 可使用的用户级别\n    auth_level: Optional[int] = 0\n    # 是否已安装\n    installed: Optional[bool] = False\n    # 运行状态\n    state: Optional[bool] = False\n    # 是否有详情页面\n    has_page: Optional[bool] = False\n    # 是否有新版本\n    has_update: Optional[bool] = False\n    # 是否本地\n    is_local: Optional[bool] = False\n    # 仓库地址\n    repo_url: Optional[str] = None\n    # 安装次数\n    install_count: Optional[int] = 0\n    # 更新记录\n    history: Optional[dict] = Field(default_factory=dict)\n    # 添加时间，值越小表示越靠后发布\n    add_time: Optional[int] = 0\n    # 插件公钥\n    plugin_public_key: Optional[str] = None\n\n\nclass PluginDashboard(Plugin):\n    \"\"\"\n    插件仪表盘\n    \"\"\"\n    id: Optional[str] = None\n    # 名称\n    name: Optional[str] = None\n    # 仪表板key\n    key: Optional[str] = None\n    # 演染模式\n    render_mode: Optional[str] = Field(default=\"vuetify\")\n    # 全局配置\n    attrs: Optional[dict] = Field(default_factory=dict)\n    # col列数\n    cols: Optional[dict] = Field(default_factory=dict)\n    # 页面元素\n    elements: Optional[List[dict]] = Field(default_factory=list)\n\n\nclass PluginMemoryInfo(BaseModel):\n    \"\"\"插件内存信息\"\"\"\n    plugin_id: str = Field(description=\"插件ID\")\n    plugin_name: str = Field(description=\"插件名称\")\n    plugin_version: str = Field(description=\"插件版本\")\n    total_memory_bytes: int = Field(description=\"总内存使用量(字节)\")\n    total_memory_mb: float = Field(description=\"总内存使用量(MB)\")\n    object_count: int = Field(description=\"对象数量\")\n    calculation_time_ms: float = Field(description=\"计算耗时(毫秒)\")\n    timestamp: float = Field(description=\"统计时间戳\")\n    error: Optional[str] = Field(default=None, description=\"错误信息\")\n    object_details: Optional[List[Dict[str, Any]]] = Field(default=None, description=\"大对象详情\")\n"
  },
  {
    "path": "app/schemas/response.py",
    "content": "from typing import Optional, Union\n\nfrom pydantic import BaseModel, Field\n\n\nclass Response(BaseModel):\n    # 状态\n    success: bool\n    # 消息文本\n    message: Optional[str] = None\n    # 数据\n    data: Optional[Union[dict, list]] = Field(default_factory=dict)\n"
  },
  {
    "path": "app/schemas/rule.py",
    "content": "from typing import Optional\n\nfrom pydantic import BaseModel\n\n\nclass CustomRule(BaseModel):\n    \"\"\"\n    自定义规则项\n    \"\"\"\n    # 规则ID\n    id: Optional[str] = None\n    # 名称\n    name: Optional[str] = None\n    # 包含\n    include: Optional[str] = None\n    # 排除\n    exclude: Optional[str] = None\n    # 大小范围（MB）\n    size_range: Optional[str] = None\n    # 最少做种人数\n    seeders: Optional[str] = None\n    # 发布时间\n    publish_time: Optional[str] = None\n\n\nclass FilterRuleGroup(BaseModel):\n    \"\"\"\n    过滤规则组\n    \"\"\"\n    # 名称\n    name: Optional[str] = None\n    # 规则串\n    rule_string: Optional[str] = None\n    # 适用类媒体类型 None-全部 电影/电视剧\n    media_type: Optional[str] = None\n    # 适用媒体类别 None-全部 对应二级分类\n    category: Optional[str] = None\n"
  },
  {
    "path": "app/schemas/servarr.py",
    "content": "from typing import Optional\nfrom pydantic import BaseModel, Field\n\n\nclass RadarrMovie(BaseModel):\n    id: Optional[int] = None\n    title: Optional[str] = None\n    year: Optional[str | int] = None\n    isAvailable: bool = False\n    monitored: bool = False\n    tmdbId: Optional[int] = None\n    imdbId: Optional[str] = None\n    titleSlug: Optional[str] = None\n    folderName: Optional[str] = None\n    path: Optional[str] = None\n    profileId: Optional[int] = None\n    qualityProfileId: Optional[int] = None\n    added: Optional[str] = None\n    hasFile: bool = False\n\n\nclass SonarrSeries(BaseModel):\n    id: Optional[int] = None\n    title: Optional[str] = None\n    sortTitle: Optional[str] = None\n    seasonCount: Optional[int] = None\n    status: Optional[str] = None\n    overview: Optional[str] = None\n    network: Optional[str] = None\n    airTime: Optional[str] = None\n    images: list = Field(default_factory=list)\n    remotePoster: Optional[str] = None\n    seasons: list = Field(default_factory=list)\n    year: Optional[str | int] = None\n    path: Optional[str] = None\n    profileId: Optional[int] = None\n    languageProfileId: Optional[int] = None\n    seasonFolder: bool = False\n    monitored: bool = False\n    useSceneNumbering: bool = False\n    runtime: Optional[int] = None\n    tmdbId: Optional[int] = None\n    imdbId: Optional[str] = None\n    tvdbId: Optional[int] = None\n    tvRageId: Optional[int] = None\n    tvMazeId: Optional[int] = None\n    firstAired: Optional[str] = None\n    seriesType: Optional[str] = None\n    cleanTitle: Optional[str] = None\n    titleSlug: Optional[str] = None\n    certification: Optional[str] = None\n    genres: list = Field(default_factory=list)\n    tags: list = Field(default_factory=list)\n    added: Optional[str] = None\n    ratings: Optional[dict] = None\n    qualityProfileId: Optional[int] = None\n    statistics: dict = Field(default_factory=dict)\n    isAvailable: Optional[bool] = False\n    hasFile: Optional[bool] = False\n"
  },
  {
    "path": "app/schemas/servcookie.py",
    "content": "from fastapi import Query\nfrom pydantic import BaseModel\n\n\nclass CookieData(BaseModel):\n    encrypted: str = Query(min_length=1, max_length=1024 * 1024 * 50)\n    uuid: str = Query(min_length=5, pattern=\"^[a-zA-Z0-9]+$\")\n\n\nclass CookiePassword(BaseModel):\n    password: str\n"
  },
  {
    "path": "app/schemas/site.py",
    "content": "from typing import Optional, Any, Union, Dict\n\nfrom pydantic import BaseModel, Field, ConfigDict\n\n\nclass Site(BaseModel):\n    # ID\n    id: Optional[int] = None\n    # 站点名称\n    name: Optional[str] = None\n    # 站点主域名Key\n    domain: Optional[str] = None\n    # 站点地址\n    url: Optional[str] = None\n    # 站点优先级\n    pri: Optional[int] = 0\n    # RSS地址\n    rss: Optional[str] = None\n    # Cookie\n    cookie: Optional[str] = None\n    # User-Agent\n    ua: Optional[str] = None\n    # ApiKey\n    apikey: Optional[str] = None\n    # Token\n    token: Optional[str] = None\n    # 是否使用代理\n    proxy: Optional[int] = 0\n    # 过滤规则\n    filter: Optional[str] = None\n    # 是否演染\n    render: Optional[int] = 0\n    # 是否公开站点\n    public: Optional[int] = 0\n    # 备注\n    note: Optional[Any] = None\n    # 超时时间\n    timeout: Optional[int] = 15\n    # 流控单位周期\n    limit_interval: Optional[int] = None\n    # 流控次数\n    limit_count: Optional[int] = None\n    # 流控间隔\n    limit_seconds: Optional[int] = None\n    # 是否启用\n    is_active: Optional[bool] = True\n    # 下载器\n    downloader: Optional[str] = None\n\n    model_config = ConfigDict(from_attributes=True)\n\n\nclass SiteStatistic(BaseModel):\n    # 站点ID\n    domain: Optional[str] = None\n    # 成功次数\n    success: Optional[int] = 0\n    # 失败次数\n    fail: Optional[int] = 0\n    # 平均响应时间\n    seconds: Optional[int] = 0\n    # 最后状态\n    lst_state: Optional[int] = 0\n    # 最后修改时间\n    lst_mod_date: Optional[str] = None\n    # 备注\n    note: Optional[Any] = None\n\n    model_config = ConfigDict(from_attributes=True)\n\n\nclass SiteUserData(BaseModel):\n    # 站点域名\n    domain: Optional[str] = None\n    # 用户名\n    username: Optional[str] = None\n    # 用户ID\n    userid: Optional[Union[str, int]] = None\n    # 用户等级\n    user_level: Optional[str] = None\n    # 加入时间\n    join_at: Optional[str] = None\n    # 积分\n    bonus: Optional[float] = 0.0\n    # 上传量\n    upload: Optional[int] = 0\n    # 下载量\n    download: Optional[int] = 0\n    # 分享率\n    ratio: Optional[float] = 0.0\n    # 做种数\n    seeding: Optional[int] = 0\n    # 下载数\n    leeching: Optional[int] = 0\n    # 做种体积\n    seeding_size: Optional[int] = 0\n    # 下载体积\n    leeching_size: Optional[int] = 0\n    # 做种人数, 种子大小\n    seeding_info: Optional[list] = Field(default_factory=list)\n    # 未读消息\n    message_unread: Optional[int] = 0\n    # 未读消息内容\n    message_unread_contents: Optional[list] = Field(default_factory=list)\n    # 错误信息\n    err_msg: Optional[str] = None\n    # 更新日期\n    updated_day: Optional[str] = None\n    # 更新时间\n    updated_time: Optional[str] = None\n\n\nclass SiteAuth(BaseModel):\n    site: Optional[str] = None\n    params: Optional[Dict[str, Union[int, str]]] = Field(default_factory=dict)\n\n\nclass SiteCategory(BaseModel):\n    id: Optional[int] = None\n    cat: Optional[str] = None\n    desc: Optional[str] = None\n"
  },
  {
    "path": "app/schemas/subscribe.py",
    "content": "from typing import Optional, List, Dict, Any\n\nfrom pydantic import BaseModel, Field, ConfigDict\n\n\nclass Subscribe(BaseModel):\n    id: Optional[int] = None\n    # 订阅名称\n    name: Optional[str] = None\n    # 订阅年份\n    year: Optional[str] = None\n    # 订阅类型 电影/电视剧\n    type: Optional[str] = None\n    # 搜索关键字\n    keyword: Optional[str] = None\n    tmdbid: Optional[int] = None\n    doubanid: Optional[str] = None\n    bangumiid: Optional[int] = None\n    mediaid: Optional[str] = None\n    # 季号\n    season: Optional[int] = None\n    # 海报\n    poster: Optional[str] = None\n    # 背景图\n    backdrop: Optional[str] = None\n    # 评分\n    vote: Optional[float] = 0.0\n    # 描述\n    description: Optional[str] = None\n    # 过滤规则\n    filter: Optional[str] = None\n    # 包含\n    include: Optional[str] = None\n    # 排除\n    exclude: Optional[str] = None\n    # 质量\n    quality: Optional[str] = None\n    # 分辨率\n    resolution: Optional[str] = None\n    # 特效\n    effect: Optional[str] = None\n    # 总集数\n    total_episode: Optional[int] = 0\n    # 开始集数\n    start_episode: Optional[int] = 0\n    # 缺失集数\n    lack_episode: Optional[int] = 0\n    # 附加信息\n    note: Optional[Any] = None\n    # 状态：N-新建， R-订阅中\n    state: Optional[str] = None\n    # 最后更新时间\n    last_update: Optional[str] = None\n    # 订阅用户\n    username: Optional[str] = None\n    # 订阅站点\n    sites: Optional[List[int]] = Field(default_factory=list)\n    # 下载器\n    downloader: Optional[str] = None\n    # 是否洗版\n    best_version: Optional[int] = 0\n    # 当前优先级\n    current_priority: Optional[int] = None\n    # 保存路径\n    save_path: Optional[str] = None\n    # 是否使用 imdbid 搜索\n    search_imdbid: Optional[int] = 0\n    # 时间\n    date: Optional[str] = None\n    # 自定义识别词\n    custom_words: Optional[str] = None\n    # 自定义媒体类别\n    media_category: Optional[str] = None\n    # 过滤规则组\n    filter_groups: Optional[List[str]] = Field(default_factory=list)\n    # 剧集组\n    episode_group: Optional[str] = None\n\n    model_config = ConfigDict(from_attributes=True)\n\n\nclass SubscribeShare(BaseModel):\n    # 分享ID\n    id: Optional[int] = None\n    # 订阅ID\n    subscribe_id: Optional[int] = None\n    # 分享标题\n    share_title: Optional[str] = None\n    # 分享说明\n    share_comment: Optional[str] = None\n    # 分享人\n    share_user: Optional[str] = None\n    # 分享人唯一ID\n    share_uid: Optional[str] = None\n    # 订阅名称\n    name: Optional[str] = None\n    # 订阅年份\n    year: Optional[str] = None\n    # 订阅类型 电影/电视剧\n    type: Optional[str] = None\n    # 搜索关键字\n    keyword: Optional[str] = None\n    tmdbid: Optional[int] = None\n    doubanid: Optional[str] = None\n    bangumiid: Optional[int] = None\n    # 季号\n    season: Optional[int] = None\n    # 海报\n    poster: Optional[str] = None\n    # 背景图\n    backdrop: Optional[str] = None\n    # 评分\n    vote: Optional[float] = 0.0\n    # 描述\n    description: Optional[str] = None\n    # 包含\n    include: Optional[str] = None\n    # 排除\n    exclude: Optional[str] = None\n    # 质量\n    quality: Optional[str] = None\n    # 分辨率\n    resolution: Optional[str] = None\n    # 特效\n    effect: Optional[str] = None\n    # 总集数\n    total_episode: Optional[int] = 0\n    # 时间\n    date: Optional[str] = None\n    # 自定义识别词\n    custom_words: Optional[str] = None\n    # 自定义媒体类别\n    media_category: Optional[str] = None\n    # 自定义剧集组\n    episode_group: Optional[str] = None\n    # 复用人次\n    count: Optional[int] = 0\n\n\nclass SubscribeShareStatistics(BaseModel):\n    # 分享人\n    share_user: Optional[str] = None\n    # 分享数量\n    share_count: Optional[int] = 0\n    # 总复用人次\n    total_reuse_count: Optional[int] = 0\n\n\nclass SubscribeDownloadFileInfo(BaseModel):\n    # 种子名称\n    torrent_title: Optional[str] = None\n    # 站点名称\n    site_name: Optional[str] = None\n    # 下载器\n    downloader: Optional[str] = None\n    # hash\n    hash: Optional[str] = None\n    # 文件路径\n    file_path: Optional[str] = None\n\n\nclass SubscribeLibraryFileInfo(BaseModel):\n    # 存储\n    storage: Optional[str] = \"local\"\n    # 文件路径\n    file_path: Optional[str] = None\n\n\nclass SubscribeEpisodeInfo(BaseModel):\n    # 标题\n    title: Optional[str] = None\n    # 描述\n    description: Optional[str] = None\n    # 背景图\n    backdrop: Optional[str] = None\n    # 下载文件信息\n    download: Optional[List[SubscribeDownloadFileInfo]] = Field(default_factory=list)\n    # 媒体库文件信息\n    library: Optional[List[SubscribeLibraryFileInfo]] = Field(default_factory=list)\n\n\nclass SubscrbieInfo(BaseModel):\n    # 订阅信息\n    subscribe: Optional[Subscribe] = None\n    # 集信息 {集号: {download: 文件路径，library: 文件路径, backdrop: url, title: 标题, description: 描述}}\n    episodes: Optional[Dict[int, SubscribeEpisodeInfo]] = Field(default_factory=dict)\n"
  },
  {
    "path": "app/schemas/system.py",
    "content": "from dataclasses import dataclass\nfrom typing import Optional, Any\n\nfrom pydantic import BaseModel, Field\n\n\n@dataclass\nclass ServiceInfo:\n    \"\"\"\n    封装服务相关信息的数据类\n    \"\"\"\n\n    # 名称\n    name: Optional[str] = None\n    # 实例\n    instance: Optional[Any] = None\n    # 模块\n    module: Optional[Any] = None\n    # 类型\n    type: Optional[str] = None\n    # 配置\n    config: Optional[Any] = None\n\n\nclass MediaServerConf(BaseModel):\n    \"\"\"\n    媒体服务器配置\n    \"\"\"\n\n    # 名称\n    name: Optional[str] = None\n    # 类型 emby/jellyfin/plex/trimemedia/ugreen\n    type: Optional[str] = None\n    # 配置\n    config: Optional[dict] = Field(default_factory=dict)\n    # 是否启用\n    enabled: Optional[bool] = False\n    # 同步媒体体库列表\n    sync_libraries: Optional[list] = Field(default_factory=list)\n\n\nclass DownloaderConf(BaseModel):\n    \"\"\"\n    下载器配置\n    \"\"\"\n\n    # 名称\n    name: Optional[str] = None\n    # 类型 qbittorrent/transmission/rtorrent\n    type: Optional[str] = None\n    # 是否默认\n    default: Optional[bool] = False\n    # 配置\n    config: Optional[dict] = Field(default_factory=dict)\n    # 是否启用\n    enabled: Optional[bool] = False\n    # 路径映射\n    path_mapping: Optional[list[tuple[str, str]]] = Field(default_factory=list)\n\n\nclass NotificationConf(BaseModel):\n    \"\"\"\n    通知配置\n    \"\"\"\n\n    # 名称\n    name: Optional[str] = None\n    # 类型 telegram/wechat/vocechat/synologychat/slack/webpush/qqbot\n    type: Optional[str] = None\n    # 配置\n    config: Optional[dict] = Field(default_factory=dict)\n    # 场景开关\n    switchs: Optional[list] = Field(default_factory=list)\n    # 是否启用\n    enabled: Optional[bool] = False\n\n\nclass NotificationSwitchConf(BaseModel):\n    \"\"\"\n    通知场景开关配置\n    \"\"\"\n\n    # 场景名称\n    type: str = None\n    # 通知范围 all/user/admin\n    action: Optional[str] = \"all\"\n\n\nclass StorageConf(BaseModel):\n    \"\"\"\n    存储配置\n    \"\"\"\n\n    # 类型 local/alipan/u115/rclone/alist\n    type: Optional[str] = None\n    # 名称\n    name: Optional[str] = None\n    # 配置\n    config: Optional[dict] = Field(default_factory=dict)\n\n\nclass TransferDirectoryConf(BaseModel):\n    \"\"\"\n    文件整理目录配置\n    \"\"\"\n\n    # 名称\n    name: Optional[str] = None\n    # 优先级\n    priority: Optional[int] = 0\n    # 存储\n    storage: Optional[str] = None\n    # 下载目录\n    download_path: Optional[str] = None\n    # 适用媒体类型\n    media_type: Optional[str] = None\n    # 适用媒体类别\n    media_category: Optional[str] = None\n    # 下载类型子目录\n    download_type_folder: Optional[bool] = False\n    # 下载类别子目录\n    download_category_folder: Optional[bool] = False\n    # 监控方式 downloader/monitor，None为不监控\n    monitor_type: Optional[str] = None\n    # 监控模式 fast / compatibility\n    monitor_mode: Optional[str] = \"fast\"\n    # 整理方式 move/copy/link/softlink\n    transfer_type: Optional[str] = None\n    # 文件覆盖模式 always/size/never/latest\n    overwrite_mode: Optional[str] = None\n    # 整理到媒体库目录\n    library_path: Optional[str] = None\n    # 媒体库目录存储\n    library_storage: Optional[str] = None\n    # 智能重命名\n    renaming: Optional[bool] = False\n    # 刮削\n    scraping: Optional[bool] = False\n    # 是否发送通知\n    notify: Optional[bool] = True\n    # 媒体库类型子目录\n    library_type_folder: Optional[bool] = False\n    # 媒体库类别子目录\n    library_category_folder: Optional[bool] = False\n"
  },
  {
    "path": "app/schemas/tmdb.py",
    "content": "from typing import Optional\n\nfrom pydantic import BaseModel, Field\n\n\nclass TmdbSeason(BaseModel):\n    \"\"\"\n    TMDB季信息\n    \"\"\"\n    air_date: Optional[str] = None\n    episode_count: Optional[int] = None\n    name: Optional[str] = None\n    overview: Optional[str] = None\n    poster_path: Optional[str] = None\n    season_number: Optional[int] = None\n    vote_average: Optional[float] = None\n\n\nclass TmdbEpisode(BaseModel):\n    \"\"\"\n    TMDB集信息\n    \"\"\"\n    air_date: Optional[str] = None\n    episode_number: Optional[int] = None\n    episode_type: Optional[str] = None\n    name: Optional[str] = None\n    overview: Optional[str] = None\n    runtime: Optional[int] = None\n    season_number: Optional[int] = None\n    still_path: Optional[str] = None\n    vote_average: Optional[float] = None\n    crew: Optional[list] = Field(default_factory=list)\n    guest_stars: Optional[list] = Field(default_factory=list)\n"
  },
  {
    "path": "app/schemas/token.py",
    "content": "from typing import Optional\n\nfrom pydantic import BaseModel, Field\n\n\nclass Token(BaseModel):\n    # 令牌\n    access_token: str\n    # 令牌类型\n    token_type: str\n    # 超级用户\n    super_user: bool\n    # 用户ID\n    user_id: int\n    # 用户名\n    user_name: str\n    # 头像\n    avatar: Optional[str] = None\n    # 权限级别\n    level: int = 1\n    # 详细权限\n    permissions: Optional[dict] = Field(default_factory=dict)\n    # 是否显示配置向导\n    wizard: Optional[bool] = None\n\n\nclass TokenPayload(BaseModel):\n    # 用户ID\n    sub: Optional[int] = None\n    # 用户名\n    username: Optional[str] = None\n    # 超级用户\n    super_user: Optional[bool] = None\n    # 权限级别\n    level: Optional[int] = None\n    # 令牌用途 authentication\\resource\n    purpose: Optional[str] = None\n"
  },
  {
    "path": "app/schemas/transfer.py",
    "content": "from pathlib import Path\nfrom typing import Optional, List, Any, Callable\n\nfrom pydantic import BaseModel, Field\n\nfrom app.schemas.context import MetaInfo, MediaInfo\nfrom app.schemas.file import FileItem\nfrom app.schemas.history import DownloadHistory\nfrom app.schemas.system import TransferDirectoryConf\nfrom app.schemas.tmdb import TmdbEpisode\n\n\nclass TransferTorrent(BaseModel):\n    \"\"\"\n    待转移任务信息\n    \"\"\"\n    downloader: Optional[str] = None\n    title: Optional[str] = None\n    path: Optional[Path] = None\n    hash: Optional[str] = None\n    tags: Optional[str] = None\n    size: Optional[int] = 0\n    userid: Optional[str] = None\n    progress: Optional[float] = 0.0\n    state: Optional[str] = None\n\n\nclass DownloadingTorrent(BaseModel):\n    \"\"\"\n    下载中任务信息\n    \"\"\"\n    downloader: Optional[str] = None\n    hash: Optional[str] = None\n    title: Optional[str] = None\n    name: Optional[str] = None\n    year: Optional[str] = None\n    season_episode: Optional[str] = None\n    size: Optional[float] = 0.0\n    progress: Optional[float] = 0.0\n    state: Optional[str] = 'downloading'\n    upspeed: Optional[str] = None\n    dlspeed: Optional[str] = None\n    media: Optional[dict] = Field(default_factory=dict)\n    userid: Optional[str] = None\n    username: Optional[str] = None\n    left_time: Optional[str] = None\n\n\nclass TransferTask(BaseModel):\n    \"\"\"\n    文件整理任务\n    \"\"\"\n    fileitem: FileItem\n    meta: Optional[Any] = None\n    mediainfo: Optional[Any] = None\n    target_directory: Optional[TransferDirectoryConf] = None\n    target_storage: Optional[str] = None\n    target_path: Optional[Path] = None\n    transfer_type: Optional[str] = None\n    scrape: Optional[bool] = False\n    library_type_folder: Optional[bool] = False\n    library_category_folder: Optional[bool] = False\n    episodes_info: Optional[List[TmdbEpisode]] = None\n    username: Optional[str] = None\n    downloader: Optional[str] = None\n    download_hash: Optional[str] = None\n    download_history: Optional[DownloadHistory] = None\n    manual: Optional[bool] = False\n    background: Optional[bool] = True\n\n    def to_dict(self):\n        \"\"\"\n        返回字典\n        \"\"\"\n        dicts = vars(self).copy()\n        dicts[\"fileitem\"] = self.fileitem.model_dump() if self.fileitem else None\n        dicts[\"meta\"] = self.meta.model_dump() if self.meta else None\n        dicts[\"mediainfo\"] = self.mediainfo.model_dump() if self.mediainfo else None\n        dicts[\"target_directory\"] = self.target_directory.model_dump() if self.target_directory else None\n        return dicts\n\n\nclass TransferJobTask(BaseModel):\n    \"\"\"\n    文件整理作业任务\n    \"\"\"\n    fileitem: Optional[FileItem] = None\n    meta: Optional[MetaInfo] = None\n    state: Optional[str] = None\n    downloader: Optional[str] = None\n    download_hash: Optional[str] = None\n\n\nclass TransferJob(BaseModel):\n    \"\"\"\n    文件整理作业\n    \"\"\"\n    media: Optional[MediaInfo] = None\n    season: Optional[int] = None\n    tasks: Optional[List[TransferJobTask]] = Field(default_factory=list)\n\n\nclass TransferInfo(BaseModel):\n    \"\"\"\n    文件整理结果\n    \"\"\"\n    # 是否成功标志\n    success: bool = True\n    # 整理⼁路径\n    fileitem: Optional[FileItem] = None\n    # 转移后的目录项，媒体的根目录\n    target_diritem: Optional[FileItem] = None\n    # 转移后路径\n    target_item: Optional[FileItem] = None\n    # 整理方式\n    transfer_type: Optional[str] = None\n    # 处理文件数\n    file_count: Optional[int] = Field(default=0)\n    # 处理文件清单\n    file_list: Optional[list] = Field(default_factory=list)\n    # 目标文件清单\n    file_list_new: Optional[list] = Field(default_factory=list)\n    # 总文件大小\n    total_size: Optional[int] = Field(default=0)\n    # 失败清单\n    fail_list: Optional[list] = Field(default_factory=list)\n    # 错误信息\n    message: Optional[str] = None\n    # 是否需要刮削\n    need_scrape: Optional[bool] = False\n    # 是否需要通知\n    need_notify: Optional[bool] = False\n\n    def to_dict(self):\n        \"\"\"\n        返回字典\n        \"\"\"\n        dicts = vars(self).copy()\n        dicts[\"fileitem\"] = self.fileitem.model_dump() if self.fileitem else None\n        dicts[\"target_item\"] = self.target_item.model_dump() if self.target_item else None\n        return dicts\n\n\nclass TransferQueue(BaseModel):\n    \"\"\"\n    异步整理队列信息\n    \"\"\"\n    # 任务信息\n    task: Optional[TransferTask] = None\n    # 回调函数\n    callback: Optional[Callable] = None\n    # 整理结果\n    result: Optional[TransferInfo] = None\n\n\nclass EpisodeFormat(BaseModel):\n    \"\"\"\n    剧集自定义识别格式\n    \"\"\"\n    format: Optional[str] = None\n    detail: Optional[str] = None\n    part: Optional[str] = None\n    offset: Optional[str] = None\n\n\nclass ManualTransferItem(BaseModel):\n    # 文件项\n    fileitem: FileItem = None\n    # 日志ID\n    logid: Optional[int] = None\n    # 目标存储\n    target_storage: Optional[str] = None\n    # 目标路径\n    target_path: Optional[str] = None\n    # TMDB ID\n    tmdbid: Optional[int] = None\n    # 豆瓣ID\n    doubanid: Optional[str] = None\n    # 类型\n    type_name: Optional[str] = None\n    # 季号\n    season: Optional[int] = None\n    # 整理方式\n    transfer_type: Optional[str] = None\n    # 自定义格式\n    episode_format: Optional[str] = None\n    # 指定集数\n    episode_detail: Optional[str] = None\n    # 指定PART\n    episode_part: Optional[str] = None\n    # 集数偏移\n    episode_offset: Optional[str] = None\n    # 最小文件大小\n    min_filesize: Optional[int] = 0\n    # 刮削\n    scrape: bool = False\n    # 媒体库类型子目录\n    library_type_folder: Optional[bool] = None\n    # 媒体库类别子目录\n    library_category_folder: Optional[bool] = None\n    # 复用历史识别信息\n    from_history: Optional[bool] = False\n    # 剧集组\n    episode_group: Optional[str] = None\n"
  },
  {
    "path": "app/schemas/types.py",
    "content": "from enum import Enum\nfrom typing import Optional\n\n\n# 媒体类型\nclass MediaType(Enum):\n    MOVIE = '电影'\n    TV = '电视剧'\n    COLLECTION = '系列'\n    UNKNOWN = '未知'\n\n    @staticmethod\n    def from_agent(key: str) -> Optional[\"MediaType\"]:\n        \"\"\"'movie' -> MediaType.MOVIE, 'tv' -> MediaType.TV, 否则 None\"\"\"\n        _map = {\"movie\": MediaType.MOVIE, \"tv\": MediaType.TV}\n        return _map.get(key.strip().lower() if key else \"\")\n\n    def to_agent(self) -> str:\n        \"\"\"MediaType.MOVIE -> 'movie', MediaType.TV -> 'tv', 其他返回 .value\"\"\"\n        return {MediaType.MOVIE: \"movie\", MediaType.TV: \"tv\"}.get(self, self.value)\n\n\ndef media_type_to_agent(value) -> Optional[str]:\n    \"\"\"将 MediaType 枚举或中文字符串统一转为 'movie'/'tv'\"\"\"\n    if isinstance(value, MediaType):\n        return value.to_agent()\n    if isinstance(value, str):\n        mt = MediaType.from_agent(value)\n        return mt.to_agent() if mt else value\n    return None\n\n\n# 排序类型枚举\nclass SortType(Enum):\n    TIME = \"time\"  # 按时间排序\n    COUNT = \"count\"  # 按人数排序\n    RATING = \"rating\"  # 按评分排序\n\n\n# 种子状态\nclass TorrentStatus(Enum):\n    TRANSFER = \"可转移\"\n    DOWNLOADING = \"下载中\"\n\n\n# 异步广播事件\nclass EventType(Enum):\n    # 插件需要重载\n    PluginReload = \"plugin.reload\"\n    # 触发插件动作\n    PluginAction = \"plugin.action\"\n    # 插件触发事件\n    PluginTriggered = \"plugin.triggered\"\n    # 执行命令\n    CommandExcute = \"command.excute\"\n    # 站点已删除\n    SiteDeleted = \"site.deleted\"\n    # 站点已更新\n    SiteUpdated = \"site.updated\"\n    # 站点已刷新\n    SiteRefreshed = \"site.refreshed\"\n    # 媒体文件整理完成\n    TransferComplete = \"transfer.complete\"\n    # 媒体文件整理失败\n    TransferFailed = \"transfer.failed\"\n    # 字幕整理完成\n    SubtitleTransferComplete = \"transfer.subtitle.complete\"\n    # 字幕整理失败\n    SubtitleTransferFailed = \"transfer.subtitle.failed\"\n    # 音频文件整理完成\n    AudioTransferComplete = \"transfer.audio.complete\"\n    # 音频文件整理失败\n    AudioTransferFailed = \"transfer.audio.failed\"\n    # 下载已添加\n    DownloadAdded = \"download.added\"\n    # 删除历史记录\n    HistoryDeleted = \"history.deleted\"\n    # 删除下载源文件\n    DownloadFileDeleted = \"downloadfile.deleted\"\n    # 删除下载任务\n    DownloadDeleted = \"download.deleted\"\n    # 收到用户外来消息\n    UserMessage = \"user.message\"\n    # 收到Webhook消息\n    WebhookMessage = \"webhook.message\"\n    # 发送消息通知\n    NoticeMessage = \"notice.message\"\n    # 订阅已添加\n    SubscribeAdded = \"subscribe.added\"\n    # 订阅已调整\n    SubscribeModified = \"subscribe.modified\"\n    # 订阅已删除\n    SubscribeDeleted = \"subscribe.deleted\"\n    # 订阅已完成\n    SubscribeComplete = \"subscribe.complete\"\n    # 系统错误\n    SystemError = \"system.error\"\n    # 刮削元数据\n    MetadataScrape = \"metadata.scrape\"\n    # 模块需要重载\n    ModuleReload = \"module.reload\"\n    # 配置项更新\n    ConfigChanged = \"config.updated\"\n    # 消息交互动作\n    MessageAction = \"message.action\"\n    # 执行工作流\n    WorkflowExecute = \"workflow.execute\"\n\n\n# EventType中文名称翻译字典\nEVENT_TYPE_NAMES = {\n    EventType.PluginReload: \"插件重载\",\n    EventType.PluginAction: \"触发插件动作\",\n    EventType.PluginTriggered: \"触发插件事件\",\n    EventType.CommandExcute: \"执行命令\",\n    EventType.SiteDeleted: \"站点已删除\",\n    EventType.SiteUpdated: \"站点已更新\",\n    EventType.SiteRefreshed: \"站点已刷新\",\n    EventType.TransferComplete: \"整理完成\",\n    EventType.TransferFailed: \"整理失败\",\n    EventType.SubtitleTransferComplete: \"字幕整理完成\",\n    EventType.SubtitleTransferFailed: \"字幕整理失败\",\n    EventType.AudioTransferComplete: \"音频整理完成\",\n    EventType.AudioTransferFailed: \"音频整理失败\",\n    EventType.DownloadAdded: \"添加下载\",\n    EventType.HistoryDeleted: \"删除历史记录\",\n    EventType.DownloadFileDeleted: \"删除下载源文件\",\n    EventType.DownloadDeleted: \"删除下载任务\",\n    EventType.UserMessage: \"收到用户消息\",\n    EventType.WebhookMessage: \"收到Webhook消息\",\n    EventType.NoticeMessage: \"发送消息通知\",\n    EventType.SubscribeAdded: \"添加订阅\",\n    EventType.SubscribeModified: \"订阅已调整\",\n    EventType.SubscribeDeleted: \"订阅已删除\",\n    EventType.SubscribeComplete: \"订阅已完成\",\n    EventType.SystemError: \"系统错误\",\n    EventType.MetadataScrape: \"刮削元数据\",\n    EventType.ModuleReload: \"模块重载\",\n    EventType.ConfigChanged: \"配置项更新\",\n    EventType.MessageAction: \"消息交互动作\",\n    EventType.WorkflowExecute: \"执行工作流\",\n}\n\n\n# 同步链式事件\nclass ChainEventType(Enum):\n    # 名称识别\n    NameRecognize = \"name.recognize\"\n    # 认证验证\n    AuthVerification = \"auth.verification\"\n    # 认证拦截\n    AuthIntercept = \"auth.intercept\"\n    # 命令注册\n    CommandRegister = \"command.register\"\n    # 整理重命名\n    TransferRename = \"transfer.rename\"\n    # 整理拦截\n    TransferIntercept = \"transfer.intercept\"\n    # 资源选择\n    ResourceSelection = \"resource.selection\"\n    # 资源下载\n    ResourceDownload = \"resource.download\"\n    # 探索数据源\n    DiscoverSource = \"discover.source\"\n    # 媒体识别转换\n    MediaRecognizeConvert = \"media.recognize.convert\"\n    # 推荐数据源\n    RecommendSource = \"recommend.source\"\n    # 工作流执行\n    WorkflowExecution = \"workflow.execution\"\n    # 存储操作选择\n    StorageOperSelection = \"storage.operation\"\n\n\n# 系统配置Key字典\nclass SystemConfigKey(Enum):\n    # 下载器配置\n    Downloaders = \"Downloaders\"\n    # 媒体服务器配置\n    MediaServers = \"MediaServers\"\n    # 消息通知配置\n    Notifications = \"Notifications\"\n    # 通知场景开关设置\n    NotificationSwitchs = \"NotificationSwitchs\"\n    # 目录配置\n    Directories = \"Directories\"\n    # 存储配置\n    Storages = \"Storages\"\n    # 搜索站点范围\n    IndexerSites = \"IndexerSites\"\n    # 订阅站点范围\n    RssSites = \"RssSites\"\n    # 自定义制作组/字幕组\n    CustomReleaseGroups = \"CustomReleaseGroups\"\n    # 自定义占位符\n    Customization = \"Customization\"\n    # 自定义识别词\n    CustomIdentifiers = \"CustomIdentifiers\"\n    # 转移屏蔽词\n    TransferExcludeWords = \"TransferExcludeWords\"\n    # 种子优先级规则\n    TorrentsPriority = \"TorrentsPriority\"\n    # 用户自定义规则\n    CustomFilterRules = \"CustomFilterRules\"\n    # 用户规则组\n    UserFilterRuleGroups = \"UserFilterRuleGroups\"\n    # 搜索默认过滤规则组\n    SearchFilterRuleGroups = \"SearchFilterRuleGroups\"\n    # 订阅默认过滤规则组\n    SubscribeFilterRuleGroups = \"SubscribeFilterRuleGroups\"\n    # 订阅默认参数\n    SubscribeDefaultParams = \"SubscribeDefaultParams\"\n    # 洗版默认过滤规则组\n    BestVersionFilterRuleGroups = \"BestVersionFilterRuleGroups\"\n    # 订阅统计\n    SubscribeReport = \"SubscribeReport\"\n    # 用户自定义CSS\n    UserCustomCSS = \"UserCustomCSS\"\n    # 用户已安装的插件\n    UserInstalledPlugins = \"UserInstalledPlugins\"\n    # 插件文件夹分组配置\n    PluginFolders = \"PluginFolders\"\n    # 默认电影订阅规则\n    DefaultMovieSubscribeConfig = \"DefaultMovieSubscribeConfig\"\n    # 默认电视剧订阅规则\n    DefaultTvSubscribeConfig = \"DefaultTvSubscribeConfig\"\n    # 用户站点认证参数\n    UserSiteAuthParams = \"UserSiteAuthParams\"\n    # Follow订阅分享者\n    FollowSubscribers = \"FollowSubscribers\"\n    # 通知发送时间\n    NotificationSendTime = \"NotificationSendTime\"\n    # AI智能体配置\n    AIAgentConfig = \"AIAgentConfig\"\n    # 通知消息格式模板\n    NotificationTemplates = \"NotificationTemplates\"\n    # 刮削开关设置\n    ScrapingSwitchs = \"ScrapingSwitchs\"\n    # 插件安装统计\n    PluginInstallReport = \"PluginInstallReport\"\n    # 配置向导状态\n    SetupWizardState = \"SetupWizardState\"\n    # 绿联影视登录会话缓存\n    UgreenSessionCache = \"UgreenSessionCache\"\n\n\n# 处理进度Key字典\nclass ProgressKey(Enum):\n    # 搜索\n    Search = \"search\"\n    # 整理\n    FileTransfer = \"filetransfer\"\n    # 批量重命名\n    BatchRename = \"batchrename\"\n\n\n# 媒体图片类型\nclass MediaImageType(Enum):\n    Poster = \"poster_path\"\n    Backdrop = \"backdrop_path\"\n\n\n# 消息类型\nclass NotificationType(Enum):\n    # 资源下载\n    Download = \"资源下载\"\n    # 整理入库\n    Organize = \"整理入库\"\n    # 订阅\n    Subscribe = \"订阅\"\n    # 站点消息\n    SiteMessage = \"站点\"\n    # 媒体服务器通知\n    MediaServer = \"媒体服务器\"\n    # 处理失败需要人工干预\n    Manual = \"手动处理\"\n    # 插件消息\n    Plugin = \"插件\"\n    # 其它消息\n    Other = \"其它\"\n\n\nclass ContentType(str, Enum):\n    \"\"\"\n    消息内容类型\n    操作状态的通知消息类型标识\n    \"\"\"\n    # 订阅添加成功\n    SubscribeAdded = \"subscribeAdded\"\n    # 订阅完成\n    SubscribeComplete = \"subscribeComplete\"\n    # 入库成功\n    OrganizeSuccess = \"organizeSuccess\"\n    # 下载开始(添加下载任务成功)\n    DownloadAdded = \"downloadAdded\"\n\n\n# 消息渠道\nclass MessageChannel(Enum):\n    \"\"\"\n    消息渠道\n    \"\"\"\n    Wechat = \"微信\"\n    Telegram = \"Telegram\"\n    Slack = \"Slack\"\n    Discord = \"Discord\"\n    SynologyChat = \"SynologyChat\"\n    VoceChat = \"VoceChat\"\n    Web = \"Web\"\n    WebPush = \"WebPush\"\n    QQ = \"QQ\"\n\n\n# 下载器类型\nclass DownloaderType(Enum):\n    # Qbittorrent\n    Qbittorrent = \"Qbittorrent\"\n    # Transmission\n    Transmission = \"Transmission\"\n    # Rtorrent\n    Rtorrent = \"Rtorrent\"\n    # Aria2\n    # Aria2 = \"Aria2\"\n\n\n# 媒体服务器类型\nclass MediaServerType(Enum):\n    # Emby\n    Emby = \"Emby\"\n    # Jellyfin\n    Jellyfin = \"Jellyfin\"\n    # Plex\n    Plex = \"Plex\"\n    # 飞牛影视\n    TrimeMedia = \"TrimeMedia\"\n    # 绿联影视\n    Ugreen = \"Ugreen\"\n\n\n# 识别器类型\nclass MediaRecognizeType(Enum):\n    # 豆瓣\n    Douban = \"豆瓣\"\n    # TMDB\n    TMDB = \"TheMovieDb\"\n    # TVDB\n    TVDB = \"TheTvDb\"\n    # bangumi\n    Bangumi = \"Bangumi\"\n\n\n# 用户配置Key字典\nclass UserConfigKey(Enum):\n    # 监控面板\n    Dashboard = \"Dashboard\"\n\n\n# 支持的存储类型\nclass StorageSchema(Enum):\n    # 存储类型\n    Local = \"local\"\n    Alipan = \"alipan\"\n    U115 = \"u115\"\n    Rclone = \"rclone\"\n    Alist = \"alist\"\n    SMB = \"smb\"\n\n\n# 模块类型\nclass ModuleType(Enum):\n    # 下载器\n    Downloader = \"downloader\"\n    # 媒体服务器\n    MediaServer = \"mediaserver\"\n    # 消息服务\n    Notification = \"notification\"\n    # 媒体识别\n    MediaRecognize = \"mediarecognize\"\n    # 站点索引\n    Indexer = \"indexer\"\n    # 其它\n    Other = \"other\"\n\n\n# 其他杂项模块类型\nclass OtherModulesType(Enum):\n    # 字幕\n    Subtitle = \"站点字幕\"\n    # Fanart\n    Fanart = \"Fanart\"\n    # 文件整理\n    FileManager = \"文件整理\"\n    # 过滤器\n    Filter = \"过滤器\"\n    # 站点索引\n    Indexer = \"站点索引\"\n    # PostgreSQL\n    PostgreSQL = \"PostgreSQL\"\n    # Redis\n    Redis = \"Redis\"\n\n\nclass NameValueEnum(Enum):\n    \"\"\"支持通过 name 或 value 实例化的枚举基类\"\"\"\n\n    @classmethod\n    def _missing_(cls, value):\n        if isinstance(value, str):\n            for member in cls:\n                if member.name.lower() == value.lower() or member.value == value:\n                    return member\n        return None\n\n\n# 刮削策略\nclass ScrapingPolicy(NameValueEnum):\n    MISSINGONLY = \"仅缺失\"\n    SKIP = \"跳过\"\n    OVERWRITE = \"覆盖\"\n\n\n# 刮削目标类型\nclass ScrapingTarget(NameValueEnum):\n    MOVIE = \"电影\"\n    TV = \"电视剧\"\n    SEASON = \"季\"\n    EPISODE = \"集\"\n\n\n# 刮削元数据类型\nclass ScrapingMetadata(NameValueEnum):\n    NFO = \"NFO\"\n    POSTER = \"海报\"\n    BACKDROP = \"背景图\"\n    LOGO = \"Logo\"\n    BANNER = \"横幅图\"\n    THUMB = \"缩略图\"\n    DISC = \"光盘图\"\n"
  },
  {
    "path": "app/schemas/user.py",
    "content": "from typing import Optional\n\nfrom pydantic import BaseModel, Field, ConfigDict\n\n\n# Shared properties\nclass UserBase(BaseModel):\n    # 用户名\n    name: str\n    # 邮箱，未启用\n    email: Optional[str] = None\n    # 状态\n    is_active: Optional[bool] = True\n    # 超级管理员\n    is_superuser: bool = False\n    # 头像\n    avatar: Optional[str] = None\n    # 是否开启二次验证\n    is_otp: Optional[bool] = False\n    # 权限\n    permissions: Optional[dict] = Field(default_factory=dict)\n    # 个性化设置\n    settings: Optional[dict] = Field(default_factory=dict)\n\n    model_config = ConfigDict(from_attributes=True)\n\n\n# Properties to receive via API on creation\nclass UserCreate(UserBase):\n    name: str\n    email: Optional[str] = None\n    password: Optional[str] = None\n    settings: Optional[dict] = Field(default_factory=dict)\n    permissions: Optional[dict] = Field(default_factory=dict)\n\n\n# Properties to receive via API on update\nclass UserUpdate(UserBase):\n    id: int\n    name: str\n    email: Optional[str] = None\n    password: Optional[str] = None\n    settings: Optional[dict] = Field(default_factory=dict)\n    permissions: Optional[dict] = Field(default_factory=dict)\n\n\nclass UserInDBBase(UserBase):\n    id: Optional[int] = None\n\n    model_config = ConfigDict(from_attributes=True)\n\n\n# Additional properties to return via API\nclass User(UserInDBBase):\n    name: str\n    email: Optional[str] = None\n\n\n# Additional properties stored in DB\nclass UserInDB(UserInDBBase):\n    hashed_password: str\n"
  },
  {
    "path": "app/schemas/workflow.py",
    "content": "from typing import Optional, List\n\nfrom pydantic import BaseModel, Field, ConfigDict\n\nfrom app.schemas.context import Context, MediaInfo\nfrom app.schemas.download import DownloadTask\nfrom app.schemas.file import FileItem\nfrom app.schemas.site import Site\nfrom app.schemas.subscribe import Subscribe\n\n\nclass Workflow(BaseModel):\n    \"\"\"\n    工作流信息\n    \"\"\"\n    id: Optional[int] = Field(default=None, description=\"工作流ID\")\n    name: Optional[str] = Field(default=None, description=\"工作流名称\")\n    description: Optional[str] = Field(default=None, description=\"工作流描述\")\n    timer: Optional[str] = Field(default=None, description=\"定时器\")\n    trigger_type: Optional[str] = Field(default='timer', description=\"触发类型：timer-定时触发 event-事件触发 manual-手动触发\")\n    event_type: Optional[str] = Field(default=None, description=\"事件类型（当trigger_type为event时使用）\")\n    event_conditions: Optional[dict] = Field(default={}, description=\"事件条件（JSON格式，用于过滤事件）\")\n    state: Optional[str] = Field(default=None, description=\"状态\")\n    current_action: Optional[str] = Field(default=None, description=\"已执行动作\")\n    result: Optional[str] = Field(default=None, description=\"任务执行结果\")\n    run_count: Optional[int] = Field(default=0, description=\"已执行次数\")\n    actions: Optional[list] = Field(default=[], description=\"任务列表\")\n    flows: Optional[list] = Field(default=[], description=\"任务流\")\n    add_time: Optional[str] = Field(default=None, description=\"创建时间\")\n    last_time: Optional[str] = Field(default=None, description=\"最后执行时间\")\n\n    model_config = ConfigDict(from_attributes=True)\n\n\nclass ActionParams(BaseModel):\n    \"\"\"\n    动作基础参数\n    \"\"\"\n    loop: Optional[bool] = Field(default=False, description=\"是否需要循环\")\n    loop_interval: Optional[int] = Field(default=0, description=\"循环间隔 (秒)\")\n\n\nclass Action(BaseModel):\n    \"\"\"\n    动作信息\n    \"\"\"\n    id: Optional[str] = Field(default=None, description=\"动作ID\")\n    type: Optional[str] = Field(default=None, description=\"动作类型 (类名)\")\n    name: Optional[str] = Field(default=None, description=\"动作名称\")\n    description: Optional[str] = Field(default=None, description=\"动作描述\")\n    position: Optional[dict] = Field(default={}, description=\"位置\")\n    data: Optional[dict] = Field(default={}, description=\"参数\")\n\n\nclass ActionExecution(BaseModel):\n    \"\"\"\n    动作执行情况\n    \"\"\"\n    action: Optional[str] = Field(default=None, description=\"当前动作（名称）\")\n    result: Optional[bool] = Field(default=None, description=\"执行结果\")\n    message: Optional[str] = Field(default=None, description=\"执行消息\")\n\n\nclass ActionContext(BaseModel):\n    \"\"\"\n    动作基础上下文，各动作通用数据\n    \"\"\"\n    content: Optional[str] = Field(default=None, description=\"文本类内容\")\n    torrents: Optional[List[Context]] = Field(default=[], description=\"资源列表\")\n    medias: Optional[List[MediaInfo]] = Field(default=[], description=\"媒体列表\")\n    fileitems: Optional[List[FileItem]] = Field(default=[], description=\"文件列表\")\n    downloads: Optional[List[DownloadTask]] = Field(default=[], description=\"下载任务列表\")\n    sites: Optional[List[Site]] = Field(default=[], description=\"站点列表\")\n    subscribes: Optional[List[Subscribe]] = Field(default=[], description=\"订阅列表\")\n    execute_history: Optional[List[ActionExecution]] = Field(default=[], description=\"执行历史\")\n    progress: Optional[int] = Field(default=0, description=\"执行进度（%）\")\n\n\nclass ActionFlow(BaseModel):\n    \"\"\"\n    工作流流程\n    \"\"\"\n    id: Optional[str] = Field(default=None, description=\"流程ID\")\n    source: Optional[str] = Field(default=None, description=\"源动作\")\n    target: Optional[str] = Field(default=None, description=\"目标动作\")\n    animated: Optional[bool] = Field(default=True, description=\"是否动画流程\")\n\n\nclass WorkflowShare(BaseModel):\n    \"\"\"\n    工作流分享信息\n    \"\"\"\n    id: Optional[int] = Field(default=None, description=\"分享ID\")\n    share_title: Optional[str] = Field(default=None, description=\"分享标题\")\n    share_comment: Optional[str] = Field(default=None, description=\"分享说明\")\n    share_user: Optional[str] = Field(default=None, description=\"分享人\")\n    share_uid: Optional[str] = Field(default=None, description=\"分享人唯一ID\")\n    name: Optional[str] = Field(default=None, description=\"工作流名称\")\n    description: Optional[str] = Field(default=None, description=\"工作流描述\")\n    timer: Optional[str] = Field(default=None, description=\"定时器\")\n    trigger_type: Optional[str] = Field(default=None, description=\"触发类型\")\n    event_type: Optional[str] = Field(default=None, description=\"事件类型\")\n    event_conditions: Optional[str] = Field(default=None, description=\"事件条件\")\n    actions: Optional[str] = Field(default=None, description=\"任务列表(JSON字符串)\")\n    flows: Optional[str] = Field(default=None, description=\"任务流(JSON字符串)\")\n    context: Optional[str] = Field(default=None, description=\"执行上下文(JSON字符串)\")\n    date: Optional[str] = Field(default=None, description=\"分享时间\")\n    count: Optional[int] = Field(default=0, description=\"复用人次\")\n\n    model_config = ConfigDict(from_attributes=True)\n"
  },
  {
    "path": "app/startup/__init__.py",
    "content": ""
  },
  {
    "path": "app/startup/agent_initializer.py",
    "content": "import asyncio\nimport threading\n\nfrom app.agent import agent_manager\nfrom app.core.config import settings\nfrom app.log import logger\n\n\nclass AgentInitializer:\n    \"\"\"\n    AI智能体初始化器\n    \"\"\"\n    \n    def __init__(self):\n        self._initialized = False\n    \n    async def initialize(self) -> bool:\n        \"\"\"\n        初始化AI智能体管理器\n        \"\"\"\n        try:\n            if not settings.AI_AGENT_ENABLE:\n                logger.info(\"AI智能体功能未启用\")\n                return True\n            \n            await agent_manager.initialize()\n            self._initialized = True\n            logger.info(\"AI智能体管理器初始化成功\")\n            return True\n            \n        except Exception as e:\n            logger.error(f\"AI智能体管理器初始化失败: {e}\")\n            return False\n    \n    async def cleanup(self) -> None:\n        \"\"\"\n        清理AI智能体管理器\n        \"\"\"\n        try:\n            if not self._initialized:\n                return\n                \n            await agent_manager.close()\n            self._initialized = False\n            logger.info(\"AI智能体管理器已关闭\")\n            \n        except Exception as e:\n            logger.error(f\"关闭AI智能体管理器时发生错误: {e}\")\n\n\n# 全局AI智能体初始化器实例\nagent_initializer = AgentInitializer()\n\n\ndef init_agent():\n    \"\"\"\n    初始化AI智能体（同步版本，用于在后台线程中运行）\n    \"\"\"\n    try:\n        if not settings.AI_AGENT_ENABLE:\n            logger.info(\"AI智能体功能未启用\")\n            return True\n        \n        # 在新的事件循环中初始化AI智能体管理器\n        def run_init():\n            loop = asyncio.new_event_loop()\n            asyncio.set_event_loop(loop)\n            try:\n                success = loop.run_until_complete(agent_initializer.initialize())\n                if success:\n                    logger.info(\"AI智能体管理器初始化成功\")\n                else:\n                    logger.error(\"AI智能体管理器初始化失败\")\n                return success\n            except Exception as err:\n                logger.error(f\"AI智能体管理器初始化失败: {err}\")\n                return False\n            finally:\n                loop.close()\n        \n        # 在后台线程中初始化\n        init_thread = threading.Thread(target=run_init, daemon=True)\n        init_thread.start()\n        \n        return True\n        \n    except Exception as e:\n        logger.error(f\"初始化AI智能体时发生错误: {e}\")\n        return False\n\n\nasync def stop_agent():\n    \"\"\"\n    停止AI智能体（异步版本，用于在应用关闭时调用）\n    \"\"\"\n    try:\n        await agent_initializer.cleanup()\n    except Exception as e:\n        logger.error(f\"停止AI智能体时发生错误: {e}\")\n"
  },
  {
    "path": "app/startup/command_initializer.py",
    "content": "from app.command import Command\n\n\ndef init_command():\n    \"\"\"\n    初始化命令\n    \"\"\"\n    Command()\n\n\ndef stop_command():\n    \"\"\"\n    停止命令\n    \"\"\"\n    pass\n\n\ndef restart_command():\n    \"\"\"\n    重启命令\n    \"\"\"\n    Command().init_commands()\n"
  },
  {
    "path": "app/startup/lifecycle.py",
    "content": "import asyncio\nfrom contextlib import asynccontextmanager\n\nfrom fastapi import FastAPI\n\nfrom app.chain.system import SystemChain\nfrom app.core.config import global_vars\nfrom app.helper.system import SystemHelper\nfrom app.startup.command_initializer import init_command, stop_command, restart_command\nfrom app.startup.modules_initializer import init_modules, stop_modules\nfrom app.startup.monitor_initializer import stop_monitor, init_monitor\nfrom app.startup.plugins_initializer import init_plugins, stop_plugins, sync_plugins\nfrom app.startup.routers_initializer import init_routers\nfrom app.startup.scheduler_initializer import stop_scheduler, init_scheduler, init_plugin_scheduler\nfrom app.startup.workflow_initializer import init_workflow, stop_workflow\n\n\nasync def init_extra():\n    \"\"\"\n    同步插件及重启相关依赖服务\n    \"\"\"\n    if await sync_plugins():\n        # 重新注册插件定时服务\n        init_plugin_scheduler()\n        # 重新注册命令\n        restart_command()\n    # 设置系统已修改标志\n    SystemHelper().set_system_modified()\n    # 重启完成\n    SystemChain().restart_finish()\n\n\n@asynccontextmanager\nasync def lifespan(app: FastAPI):\n    \"\"\"\n    定义应用的生命周期事件\n    \"\"\"\n    print(\"Starting up...\")\n    # 存储当前循环\n    global_vars.set_loop(asyncio.get_event_loop())\n    # 初始化路由\n    init_routers(app)\n    # 初始化模块\n    init_modules()\n    # 恢复插件备份\n    SystemChain().restore_plugins()\n    # 初始化插件\n    init_plugins()\n    # 初始化定时器\n    init_scheduler()\n    # 初始化监控器\n    init_monitor()\n    # 初始化命令\n    init_command()\n    # 初始化工作流\n    init_workflow()\n    # 插件同步到本地\n    sync_plugins_task = asyncio.create_task(init_extra())\n    try:\n        # 在此处 yield，表示应用已经启动，控制权交回 FastAPI 主事件循环\n        yield\n    finally:\n        print(\"Shutting down...\")\n        # 取消同步插件任务\n        try:\n            sync_plugins_task.cancel()\n            await sync_plugins_task\n        except asyncio.CancelledError:\n            pass\n        except Exception as e:\n            print(str(e))\n        # 备份插件\n        SystemChain().backup_plugins()\n        # 停止工作流\n        stop_workflow()\n        # 停止命令\n        stop_command()\n        # 停止监控器\n        stop_monitor()\n        # 停止定时器\n        stop_scheduler()\n        # 停止插件\n        stop_plugins()\n        # 停止模块\n        await stop_modules()\n"
  },
  {
    "path": "app/startup/modules_initializer.py",
    "content": "import sys\n\nfrom app.helper.redis import RedisHelper, AsyncRedisHelper\n\n# SitesHelper涉及资源包拉取，提前引入并容错提示\ntry:\n    from app.helper.sites import SitesHelper  # noqa\nexcept ImportError as e:\n    SitesHelper = None\n    error_message = f\"错误: {str(e)}\\n站点认证及索引相关资源导入失败，请尝试重建容器或手动拉取资源\"\n    print(error_message, file=sys.stderr)\n    sys.exit(1)\n\nfrom app.utils.system import SystemUtils\nfrom app.log import logger\nfrom app.core.config import settings\nfrom app.core.module import ModuleManager\nfrom app.core.event import EventManager\nfrom app.helper.thread import ThreadHelper\nfrom app.helper.display import DisplayHelper\nfrom app.helper.doh import DohHelper\nfrom app.helper.resource import ResourceHelper\nfrom app.helper.message import MessageHelper, stop_message\nfrom app.helper.subscribe import SubscribeHelper\nfrom app.db import close_database\nfrom app.db.systemconfig_oper import SystemConfigOper\nfrom app.command import CommandChain\nfrom app.schemas import Notification, NotificationType\nfrom app.schemas.types import SystemConfigKey\nfrom app.startup.agent_initializer import init_agent, stop_agent\n\n\ndef start_frontend():\n    \"\"\"\n    启动前端服务\n    \"\"\"\n    # 仅Windows可执行文件支持内嵌nginx\n    if not SystemUtils.is_frozen() \\\n            or not SystemUtils.is_windows():\n        return\n    # 临时Nginx目录\n    nginx_path = settings.ROOT_PATH / 'nginx'\n    if not nginx_path.exists():\n        return\n    # 配置目录下的Nginx目录\n    run_nginx_dir = settings.CONFIG_PATH.with_name('nginx')\n    if not run_nginx_dir.exists():\n        # 移动到配置目录\n        SystemUtils.move(nginx_path, run_nginx_dir)\n    # 启动Nginx\n    import subprocess\n    subprocess.Popen(\"start nginx.exe\",\n                     cwd=run_nginx_dir,\n                     shell=True)\n\n\ndef stop_frontend():\n    \"\"\"\n    停止前端服务\n    \"\"\"\n    if not SystemUtils.is_frozen() \\\n            or not SystemUtils.is_windows():\n        return\n    import subprocess\n    subprocess.Popen(f\"taskkill /f /im nginx.exe\", shell=True)\n\n\ndef clear_temp():\n    \"\"\"\n    清理临时文件和图片缓存\n    \"\"\"\n    # 清理临时目录中3天前的文件\n    SystemUtils.clear(settings.TEMP_PATH, days=settings.TEMP_FILE_DAYS)\n    # 清理图片缓存目录中7天前的文件\n    SystemUtils.clear(settings.CACHE_PATH / \"images\", days=settings.GLOBAL_IMAGE_CACHE_DAYS)\n\n\ndef user_auth():\n    \"\"\"\n    用户认证检查\n    \"\"\"\n    sites_helper = SitesHelper()\n    if sites_helper.auth_level >= 2:\n        return\n    auth_conf = SystemConfigOper().get(SystemConfigKey.UserSiteAuthParams)\n    status, msg = sites_helper.check_user(**auth_conf) if auth_conf else sites_helper.check_user()\n    if status:\n        logger.info(f\"{msg} 用户认证成功\")\n    else:\n        logger.info(f\"用户认证失败，{msg}\")\n\n\ndef check_auth():\n    \"\"\"\n    检查认证状态\n    \"\"\"\n    if SitesHelper().auth_level < 2:\n        err_msg = \"用户认证失败，站点相关功能将无法使用！\"\n        MessageHelper().put(f\"注意：{err_msg}\", title=\"用户认证\", role=\"system\")\n        CommandChain().post_message(\n            Notification(\n                mtype=NotificationType.Manual,\n                title=\"MoviePilot用户认证\",\n                text=err_msg,\n                link=settings.MP_DOMAIN('#/site')\n            )\n        )\n\n\nasync def stop_modules():\n    \"\"\"\n    服务关闭\n    \"\"\"\n    # 停止AI智能体\n    await stop_agent()\n    # 停止模块\n    ModuleManager().stop()\n    # 停止事件消费\n    EventManager().stop()\n    # 停止虚拟显示\n    DisplayHelper().stop()\n    # 停止线程池\n    ThreadHelper().shutdown()\n    # 停止消息服务\n    stop_message()\n    # 关闭Redis缓存连接\n    RedisHelper().close()\n    await AsyncRedisHelper().close()\n    # 停止数据库连接\n    await close_database()\n    # 停止前端服务\n    stop_frontend()\n    # 清理临时文件\n    clear_temp()\n\n\ndef init_modules():\n    \"\"\"\n    启动模块\n    \"\"\"\n    # 虚拟显示\n    DisplayHelper()\n    # DoH\n    DohHelper()\n    # 站点管理\n    SitesHelper()\n    # 资源包检测\n    ResourceHelper()\n    # 用户认证\n    user_auth()\n    # 加载模块\n    ModuleManager()\n    # 启动事件消费\n    EventManager().start()\n    # 初始化订阅分享\n    SubscribeHelper()\n    # 初始化AI智能体\n    init_agent()\n    # 启动前端服务\n    start_frontend()\n    # 检查认证状态\n    check_auth()\n"
  },
  {
    "path": "app/startup/monitor_initializer.py",
    "content": "from app.monitor import Monitor\n\n\ndef init_monitor():\n    \"\"\"\n    初始化监控器\n    \"\"\"\n    Monitor()\n\n\ndef stop_monitor():\n    \"\"\"\n    停止监控器\n    \"\"\"\n    Monitor().stop()\n"
  },
  {
    "path": "app/startup/plugins_initializer.py",
    "content": "from app.core.config import global_vars\nfrom app.core.plugin import PluginManager\nfrom app.log import logger\n\n\nasync def sync_plugins() -> bool:\n    \"\"\"\n    初始化安装插件，并动态注册后台任务及API\n    \"\"\"\n    try:\n        loop = global_vars.loop\n        plugin_manager = PluginManager()\n\n        sync_result = await execute_task(loop, plugin_manager.sync, \"插件同步到本地\")\n        resolved_dependencies = await execute_task(loop, plugin_manager.install_plugin_missing_dependencies,\n                                                   \"缺失依赖项安装\")\n        # 判断是否需要进行插件初始化\n        if not sync_result and not resolved_dependencies:\n            logger.debug(\"没有新的插件同步到本地或缺失依赖项需要安装\")\n            return False\n\n        # 继续执行后续的插件初始化步骤\n        logger.info(\"正在重新初始化插件\")\n        # 重新初始化插件\n        plugin_manager.init_config()\n        # 重新注册插件API\n        register_plugin_api()\n        logger.info(\"所有插件初始化完成\")\n        return True\n    except Exception as e:\n        logger.error(f\"插件初始化过程中出现异常: {e}\")\n        return False\n\n\nasync def execute_task(loop, task_func, task_name):\n    \"\"\"\n    执行后台任务\n    \"\"\"\n    try:\n        result = await loop.run_in_executor(None, task_func)\n        if isinstance(result, list) and result:\n            logger.debug(f\"{task_name} 已完成，共处理 {len(result)} 个项目\")\n        else:\n            logger.debug(f\"没有新的 {task_name} 需要处理\")\n        return result\n    except Exception as e:\n        logger.error(f\"{task_name} 时发生错误：{e}\", exc_info=True)\n        return []\n\n\ndef register_plugin_api():\n    \"\"\"\n    插件启动后注册插件API\n    \"\"\"\n    from app.api.endpoints import plugin\n    plugin.register_plugin_api()\n\n\ndef init_plugins():\n    \"\"\"\n    初始化插件\n    \"\"\"\n    PluginManager().start()\n    register_plugin_api()\n\n\ndef stop_plugins():\n    \"\"\"\n    停止插件\n    \"\"\"\n    try:\n        plugin_manager = PluginManager()\n        plugin_manager.stop()\n        plugin_manager.stop_monitor()\n    except Exception as e:\n        logger.error(f\"停止插件时发生错误：{e}\", exc_info=True)\n"
  },
  {
    "path": "app/startup/routers_initializer.py",
    "content": "from fastapi import FastAPI\n\nfrom app.core.config import settings\n\n\ndef init_routers(app: FastAPI):\n    \"\"\"\n    初始化路由\n    \"\"\"\n    from app.api.apiv1 import api_router\n    from app.api.servarr import arr_router\n    from app.api.servcookie import cookie_router\n    # API路由\n    app.include_router(api_router, prefix=settings.API_V1_STR)\n    # Radarr、Sonarr路由\n    app.include_router(arr_router, prefix=\"/api/v3\")\n    # CookieCloud路由\n    app.include_router(cookie_router, prefix=\"/cookiecloud\")\n"
  },
  {
    "path": "app/startup/scheduler_initializer.py",
    "content": "from app.scheduler import Scheduler\n\n\ndef init_scheduler():\n    \"\"\"\n    初始化定时器\n    \"\"\"\n    Scheduler()\n\n\ndef stop_scheduler():\n    \"\"\"\n    停止定时器\n    \"\"\"\n    Scheduler().stop()\n\n\ndef restart_scheduler():\n    \"\"\"\n    重启定时器\n    \"\"\"\n    Scheduler().init()\n\n\ndef init_plugin_scheduler():\n    \"\"\"\n    初始化插件定时器\n    \"\"\"\n    Scheduler().init_plugin_jobs()\n"
  },
  {
    "path": "app/startup/workflow_initializer.py",
    "content": "from app.workflow import WorkFlowManager\n\n\ndef init_workflow():\n    \"\"\"\n    初始化工作流\n    \"\"\"\n    WorkFlowManager()\n\n\ndef stop_workflow():\n    \"\"\"\n    停止工作流\n    \"\"\"\n    WorkFlowManager().stop()\n"
  },
  {
    "path": "app/utils/__init__.py",
    "content": ""
  },
  {
    "path": "app/utils/common.py",
    "content": "import asyncio\nimport inspect\nimport time\nfrom functools import wraps\nfrom typing import Any, Callable\n\nfrom app.schemas import ImmediateException\n\n\ndef retry(ExceptionToCheck: Any,\n          tries: int = 3, delay: int = 3, backoff: int = 2, logger: Any = None):\n    \"\"\"\n    :param ExceptionToCheck: 需要捕获的异常\n    :param tries: 重试次数\n    :param delay: 延迟时间\n    :param backoff: 延迟倍数\n    :param logger: 日志对象\n    \"\"\"\n\n    def deco_retry(f):\n        def f_retry(*args, **kwargs):\n            mtries, mdelay = tries, delay\n            while mtries > 1:\n                try:\n                    return f(*args, **kwargs)\n                except ImmediateException:\n                    raise\n                except ExceptionToCheck as e:\n                    msg = f\"{str(e)}, {mdelay} 秒后重试 ...\"\n                    if logger:\n                        logger.warn(msg)\n                    else:\n                        print(msg)\n                    time.sleep(mdelay)\n                    mtries -= 1\n                    mdelay *= backoff\n            return f(*args, **kwargs)\n\n        async def async_f_retry(*args, **kwargs):\n            mtries, mdelay = tries, delay\n            while mtries > 1:\n                try:\n                    return await f(*args, **kwargs)\n                except ImmediateException:\n                    raise\n                except ExceptionToCheck as e:\n                    msg = f\"{str(e)}, {mdelay} 秒后重试 ...\"\n                    if logger:\n                        logger.warn(msg)\n                    else:\n                        print(msg)\n                    await asyncio.sleep(mdelay)\n                    mtries -= 1\n                    mdelay *= backoff\n            return await f(*args, **kwargs)\n\n        # 根据函数类型返回相应的包装器\n        if inspect.iscoroutinefunction(f):\n            return async_f_retry\n        else:\n            return f_retry\n\n    return deco_retry\n\n\ndef log_execution_time(logger: Any = None):\n    \"\"\"\n    记录函数执行时间的装饰器\n    :param logger: 日志记录器对象，用于记录异常信息\n    \"\"\"\n\n    def decorator(func: Callable):\n        @wraps(func)\n        def wrapper(*args, **kwargs):\n            start_time = time.time()\n            result = func(*args, **kwargs)\n            end_time = time.time()\n            msg = f\"{func.__name__} execution time: {end_time - start_time:.2f} seconds\"\n            if logger:\n                logger.debug(msg)\n            else:\n                print(msg)\n            return result\n\n        @wraps(func)\n        async def async_wrapper(*args, **kwargs):\n            start_time = time.time()\n            result = await func(*args, **kwargs)\n            end_time = time.time()\n            msg = f\"{func.__name__} execution time: {end_time - start_time:.2f} seconds\"\n            if logger:\n                logger.debug(msg)\n            else:\n                print(msg)\n            return result\n\n        # 根据函数类型返回相应的包装器\n        if inspect.iscoroutinefunction(func):\n            return async_wrapper\n        else:\n            return wrapper\n\n    return decorator\n"
  },
  {
    "path": "app/utils/crypto.py",
    "content": "import base64\nimport hashlib\nfrom hashlib import md5\nfrom typing import Union, Optional, Tuple\n\nfrom Crypto import Random\nfrom Crypto.Cipher import AES\nfrom cryptography.hazmat.backends import default_backend\nfrom cryptography.hazmat.primitives import hashes, serialization\nfrom cryptography.hazmat.primitives.asymmetric import padding as asym_padding, rsa\n\n\nclass RSAUtils:\n\n    @staticmethod\n    def generate_rsa_key_pair(key_size: int = 2048) -> Tuple[str, str]:\n        \"\"\"\n        生成RSA密钥对\n        :return: 私钥和公钥（Base64 编码，无标识符）\n        \"\"\"\n        # 生成RSA密钥对\n        private_key = rsa.generate_private_key(\n            public_exponent=65537,\n            key_size=key_size,\n        )\n\n        public_key = private_key.public_key()\n\n        # 导出私钥为DER格式\n        private_key_der = private_key.private_bytes(\n            encoding=serialization.Encoding.DER,\n            format=serialization.PrivateFormat.PKCS8,\n            encryption_algorithm=serialization.NoEncryption()\n        )\n\n        # 导出公钥为DER格式\n        public_key_der = public_key.public_bytes(\n            encoding=serialization.Encoding.DER,\n            format=serialization.PublicFormat.SubjectPublicKeyInfo\n        )\n\n        # 将DER格式的密钥编码为Base64\n        private_key_b64 = base64.b64encode(private_key_der).decode(\"utf-8\")\n        public_key_b64 = base64.b64encode(public_key_der).decode(\"utf-8\")\n\n        return private_key_b64, public_key_b64\n\n    @staticmethod\n    def verify_rsa_keys(private_key: Optional[str], public_key: Optional[str]) -> bool:\n        \"\"\"\n        使用 RSA 验证私钥和公钥是否匹配\n\n        :param private_key: 私钥字符串 (Base64 编码，无标识符)\n        :param public_key: 公钥字符串 (Base64 编码，无标识符)\n        :return: 如果匹配则返回 True，否则返回 False\n        \"\"\"\n        if not private_key or not public_key:\n            return False\n\n        try:\n            # 解码 Base64 编码的公钥和私钥\n            public_key_bytes = base64.b64decode(public_key)\n            private_key_bytes = base64.b64decode(private_key)\n\n            # 加载公钥\n            public_key = serialization.load_der_public_key(public_key_bytes, backend=default_backend())\n\n            # 加载私钥\n            private_key = serialization.load_der_private_key(private_key_bytes, password=None,\n                                                             backend=default_backend())\n\n            # 测试加解密\n            message = b'test'\n            encrypted_message = public_key.encrypt(\n                message,\n                asym_padding.OAEP(\n                    mgf=asym_padding.MGF1(algorithm=hashes.SHA256()),\n                    algorithm=hashes.SHA256(),\n                    label=None\n                )\n            )\n\n            decrypted_message = private_key.decrypt(\n                encrypted_message,\n                asym_padding.OAEP(\n                    mgf=asym_padding.MGF1(algorithm=hashes.SHA256()),\n                    algorithm=hashes.SHA256(),\n                    label=None\n                )\n            )\n\n            return message == decrypted_message\n        except Exception as e:\n            print(f\"RSA 密钥验证失败: {e}\")\n            return False\n\n\nclass HashUtils:\n    @staticmethod\n    def md5(data: Union[str, bytes], encoding: str = \"utf-8\") -> str:\n        \"\"\"\n        生成数据的MD5哈希值，并以字符串形式返回\n\n        :param data: 输入的数据，类型为字符串\n        :param encoding: 字符串编码类型，默认使用UTF-8\n        :return: 生成的MD5哈希字符串\n        \"\"\"\n        if isinstance(data, str):\n            data = data.encode(encoding)\n        return hashlib.md5(data).hexdigest()\n\n    @staticmethod\n    def sha1(data: Union[str, bytes], encoding: str = \"utf-8\") -> str:\n        \"\"\"\n        生成数据的SHA-1哈希值，并以字符串形式返回\n\n        :param data: 输入的数据，类型为字符串或字节\n        :param encoding: 字符串编码类型，默认使用UTF-8\n        :return: 生成的SHA-1哈希字符串\n        \"\"\"\n        if isinstance(data, str):\n            data = data.encode(encoding)\n        return hashlib.sha1(data).hexdigest()\n\n    @staticmethod\n    def md5_bytes(data: Union[str, bytes], encoding: str = \"utf-8\") -> bytes:\n        \"\"\"\n        生成数据的MD5哈希值，并以字节形式返回\n\n        :param data: 输入的数据，类型为字符串\n        :param encoding: 字符串编码类型，默认使用UTF-8\n        :return: 生成的MD5哈希二进制数据\n        \"\"\"\n        if isinstance(data, str):\n            data = data.encode(encoding)\n        return hashlib.md5(data).digest()\n\n\nclass CryptoJsUtils:\n\n    @staticmethod\n    def bytes_to_key(data: bytes, salt: bytes, output=48) -> bytes:\n        \"\"\"\n        生成加密/解密所需的密钥和初始化向量 (IV)\n        \"\"\"\n        # extended from https://gist.github.com/gsakkis/4546068\n        assert len(salt) == 8, len(salt)\n        data += salt\n        key = md5(data).digest()\n        final_key = key\n        while len(final_key) < output:\n            key = md5(key + data).digest()\n            final_key += key\n        return final_key[:output]\n\n    @staticmethod\n    def encrypt(message: bytes, passphrase: bytes) -> bytes:\n        \"\"\"\n        使用 CryptoJS 兼容的加密策略对消息进行加密\n        \"\"\"\n        # This is a modified copy of https://stackoverflow.com/questions/36762098/how-to-decrypt-password-from-javascript-cryptojs-aes-encryptpassword-passphras\n        # 生成8字节的随机盐值\n        salt = Random.new().read(8)\n        # 通过密码短语和盐值生成密钥和IV\n        key_iv = CryptoJsUtils.bytes_to_key(passphrase, salt, 32 + 16)\n        key = key_iv[:32]\n        iv = key_iv[32:]\n        # 创建AES加密器（CBC模式）\n        aes = AES.new(key, AES.MODE_CBC, iv)\n        # 应用PKCS#7填充\n        padding_length = 16 - (len(message) % 16)\n        padding = bytes([padding_length] * padding_length)\n        padded_message = message + padding\n        # 加密消息\n        encrypted = aes.encrypt(padded_message)\n        # 构建加密数据格式：b\"Salted__\" + salt + encrypted_message\n        salted_encrypted = b\"Salted__\" + salt + encrypted\n        # 返回Base64编码的加密数据\n        return base64.b64encode(salted_encrypted)\n\n    @staticmethod\n    def decrypt(encrypted: Union[str, bytes], passphrase: bytes) -> bytes:\n        \"\"\"\n        使用 CryptoJS 兼容的解密策略对加密消息进行解密\n        \"\"\"\n        # 确保输入是字节类型\n        if isinstance(encrypted, str):\n            encrypted = encrypted.encode(\"utf-8\")\n        # Base64 解码\n        encrypted = base64.b64decode(encrypted)\n        # 检查前8字节是否为 \"Salted__\"\n        assert encrypted.startswith(b\"Salted__\"), \"Invalid encrypted data format\"\n        # 提取盐值\n        salt = encrypted[8:16]\n        # 通过密码短语和盐值生成密钥和IV\n        key_iv = CryptoJsUtils.bytes_to_key(passphrase, salt, 32 + 16)\n        key = key_iv[:32]\n        iv = key_iv[32:]\n        # 创建AES解密器（CBC模式）\n        aes = AES.new(key, AES.MODE_CBC, iv)\n        # 解密加密部分\n        decrypted_padded = aes.decrypt(encrypted[16:])\n        # 移除PKCS#7填充\n        padding_length = decrypted_padded[-1]\n        if isinstance(padding_length, str):\n            padding_length = ord(padding_length)\n        decrypted = decrypted_padded[:-padding_length]\n        return decrypted\n"
  },
  {
    "path": "app/utils/debounce.py",
    "content": "import asyncio\nimport functools\nimport inspect\nfrom abc import ABC, abstractmethod\nfrom threading import Timer, Lock\nfrom typing import Callable, Any, Optional\n\nfrom app.log import logger\n\n\nclass BaseDebouncer(ABC):\n    \"\"\"\n    防抖器的抽象基类。定义了防抖器的基本接口和日志功能。\n    所有防抖器实现类必须继承此类并实现其抽象方法。\n    \"\"\"\n    def __init__(self, func: Callable, interval: float, *,\n                 leading: bool = False, enable_logging: bool = False, source: str = \"\"):\n        \"\"\"\n        初始化防抖器实例。\n        :param func: 要防抖的函数或协程\n        :param interval: 防抖间隔，单位秒\n        :param leading: 是否启用前沿模式\n        :param enable_logging: 是否启用日志记录\n        :param source: 日志来源标识\n        \"\"\"\n        self.func = func\n        self.interval = interval\n        self.leading = leading\n        self.enable_logging = enable_logging\n        self.source = source\n\n    @abstractmethod\n    def __call__(self, *args, **kwargs) -> None:\n        \"\"\"\n        定义防抖调用的契约，子类必须实现。\n        \"\"\"\n        pass\n\n    @abstractmethod\n    def cancel(self) -> None:\n        \"\"\"\n        定义取消挂起调用的契约，子类必须实现。\n        \"\"\"\n        pass\n\n    def format_log(self, message: str) -> str:\n        \"\"\"\n        格式化日志消息，加入 source 前缀。\n        \"\"\"\n        return f\"[{self.source}] {message}\" if self.source else message\n\n    def log(self, level: str, message: str):\n        \"\"\"\n        根据日志级别记录日志。\n        \"\"\"\n        if self.enable_logging:\n            log_method = getattr(logger, level, logger.debug)\n            log_method(self.format_log(message))\n\n    def log_debug(self, message: str):\n        \"\"\"\n        记录调试日志。\n        \"\"\"\n        self.log(\"debug\", message)\n\n    def log_info(self, message: str):\n        \"\"\"\n        记录信息日志。\n        \"\"\"\n        self.log(\"info\", message)\n\n    def log_warning(self, message: str):\n        \"\"\"\n        记录警告日志。\n        \"\"\"\n        self.log(\"warning\", message)\n\n    def error(self, message: str):\n        \"\"\"\n        记录错误日志。\n        \"\"\"\n        self.log(\"error\", message)\n\n    def critical(self, message: str):\n        \"\"\"\n        记录严重错误日志。\n        \"\"\"\n        self.log(\"critical\", message)\n\n\nclass Debouncer(BaseDebouncer):\n    \"\"\"\n    同步防抖实现类\n    \"\"\"\n\n    def __init__(self, *args, **kwargs):\n        \"\"\"\n        初始化防抖器实例。\n        \"\"\"\n        super().__init__(*args, **kwargs)\n        self.timer: Optional[Timer] = None\n        self.lock = Lock()\n        # 用于前沿模式，标记是否处于“冷却”或“不应期”\n        self.is_cooling_down = False\n\n    def __call__(self, *args, **kwargs) -> None:\n        \"\"\"\n        调用防抖函数。\n        :param args:\n        :param kwargs:\n        :return:\n        \"\"\"\n        with self.lock:\n            if self.leading:\n                self._call_leading(*args, **kwargs)\n            else:\n                self._call_trailing(*args, **kwargs)\n\n    def _call_leading(self, *args, **kwargs):\n        \"\"\"\n        前沿模式的逻辑。\n        \"\"\"\n        # 如果不在冷却期，则立即执行\n        if not self.is_cooling_down:\n            self.log_info(\"前沿模式: 立即执行函数。\")\n            self.func(*args, **kwargs)\n\n        # 无论是否执行，都重置冷却计时器\n        if self.timer and self.timer.is_alive():\n            self.timer.cancel()\n\n        # 设置自己进入冷却期\n        self.is_cooling_down = True\n\n        # 在间隔结束后，将冷却状态解除\n        self.timer = Timer(self.interval, self._end_cool_down)\n        self.timer.start()\n        self.log_debug(f\"前沿模式: 进入 {self.interval} 秒的冷却期。\")\n\n    def _end_cool_down(self):\n        \"\"\"\n        计时器到期后，解除冷却状态\n        \"\"\"\n        with self.lock:\n            self.is_cooling_down = False\n            self.log_debug(\"前沿模式: 冷却时间结束，可以再次立即执行。\")\n\n    def _call_trailing(self, *args, **kwargs):\n        \"\"\"\n        后沿模式的逻辑。\n        \"\"\"\n        # 【日志点】记录计时器被重置\n        if self.timer and self.timer.is_alive():\n            self.timer.cancel()\n            self.log_debug(\"后沿模式: 检测到新的调用，已重置计时器。\")\n\n        def execute():\n            self.log_info(\"后沿模式: 计时结束，开始执行函数。\")\n            self.func(*args, **kwargs)\n\n        self.timer = Timer(self.interval, execute)\n        self.timer.start()\n        self.log_debug(f\"后沿模式: 计时器已启动，将在 {self.interval} 秒后执行。\")\n\n    def cancel(self) -> None:\n        \"\"\"\n        取消任何挂起的调用，并重置状态。\n        \"\"\"\n        with self.lock:\n            if self.timer and self.timer.is_alive():\n                self.timer.cancel()\n                self.timer = None\n                self.log_info(\"防抖器被手动取消。\")\n            self.is_cooling_down = False\n\n\nclass AsyncDebouncer(BaseDebouncer):\n    \"\"\"\n    异步防抖实现类。\n    \"\"\"\n    def __init__(self, *args, **kwargs):\n        \"\"\"\n        初始化异步防抖器实例。\n        \"\"\"\n        super().__init__(*args, **kwargs)\n        self.task: Optional[asyncio.Task] = None\n        self.lock = asyncio.Lock()\n        self.is_cooling_down = False\n\n    async def __call__(self, *args, **kwargs) -> None:\n        \"\"\"\n        异步调用防抖函数。\n        \"\"\"\n        async with self.lock:\n            if self.leading:\n                await self._call_leading(*args, **kwargs)\n            else:\n                await self._call_trailing(*args, **kwargs)\n\n    async def _call_leading(self, *args, **kwargs):\n        \"\"\"\n        前沿模式的逻辑。\n        \"\"\"\n        if not self.is_cooling_down:\n            self.log_info(\"前沿模式 (async): 立即执行协程。\")\n            await self.func(*args, **kwargs)\n\n        if self.task and not self.task.done():\n            self.task.cancel()\n\n        self.is_cooling_down = True\n        self.task = asyncio.create_task(self._end_cool_down())\n        self.log_debug(f\"前沿模式 (async): 进入 {self.interval} 秒的冷却期。\")\n\n    async def _end_cool_down(self):\n        \"\"\"\n        计时器到期后，解除冷却状态\n        \"\"\"\n        await asyncio.sleep(self.interval)\n        async with self.lock:\n            self.is_cooling_down = False\n            self.log_debug(\"前沿模式 (async): 冷却时间结束。\")\n\n    async def _call_trailing(self, *args, **kwargs):\n        \"\"\"\n        后沿模式的逻辑。\n        \"\"\"\n        if self.task and not self.task.done():\n            self.task.cancel()\n            self.log_debug(\"后沿模式 (async): 检测到新的调用，已取消旧任务。\")\n\n        self.task = asyncio.create_task(self._delayed_execute(*args, **kwargs))\n        self.log_debug(f\"后沿模式 (async): 任务已创建，将在 {self.interval} 秒后执行。\")\n\n    async def _delayed_execute(self, *args, **kwargs):\n        \"\"\"\n        延迟执行实际的协程函数。\n        \"\"\"\n        try:\n            await asyncio.sleep(self.interval)\n            self.log_info(\"后沿模式 (async): 延迟结束，开始执行协程。\")\n            await self.func(*args, **kwargs)\n        except asyncio.CancelledError:\n            # 任务被取消是正常行为，无需处理\n            pass\n\n    async def cancel(self) -> None:\n        \"\"\"\n        取消任何挂起的调用，并重置状态。\n        \"\"\"\n        async with self.lock:\n            if self.task and not self.task.done():\n                self.task.cancel()\n                self.task = None\n                self.log_info(\"异步防抖器被手动取消。\")\n            self.is_cooling_down = False\n\n\ndef debounce(interval: float, *, leading: bool = False,\n             enable_logging: bool = False, source: str = \"\") -> Callable:\n    \"\"\"\n    支持同步和异步的防抖装饰器工厂。\n    \"\"\"\n\n    def decorator(func: Callable) -> Callable:\n        # 检查函数类型，并选择合适的引擎\n        if inspect.iscoroutinefunction(func):\n            # 异步函数，使用 AsyncDebouncer\n            instance = AsyncDebouncer(func, interval,\n                                      leading=leading,\n                                      enable_logging=enable_logging,\n                                      source=source)\n\n            @functools.wraps(func)\n            async def async_wrapper(*args, **kwargs) -> Any:\n                await instance(*args, **kwargs)\n\n            async_wrapper.cancel = instance.cancel\n            return async_wrapper\n\n        else:\n            # 同步函数，使用 Debouncer\n            instance = Debouncer(func, interval,\n                                 leading=leading,\n                                 enable_logging=enable_logging,\n                                 source=source)\n\n            @functools.wraps(func)\n            def wrapper(*args, **kwargs) -> Any:\n                instance(*args, **kwargs)\n\n            wrapper.cancel = instance.cancel\n            return wrapper\n\n    return decorator\n"
  },
  {
    "path": "app/utils/dom.py",
    "content": "from typing import Union\n\n\nclass DomUtils:\n\n    @staticmethod\n    def tag_value(tag_item, tag_name: str, attname: str = \"\", default: Union[str, int] = None):\n        \"\"\"\n        解析XML标签值\n        \"\"\"\n        tagNames = tag_item.getElementsByTagName(tag_name)\n        if tagNames:\n            if attname:\n                attvalue = tagNames[0].getAttribute(attname)\n                if attvalue:\n                    return attvalue\n            else:\n                firstChild = tagNames[0].firstChild\n                if firstChild:\n                    return firstChild.data\n        return default\n\n    @staticmethod\n    def add_node(doc, parent, name: str, value: str = None):\n        \"\"\"\n        添加一个DOM节点\n        \"\"\"\n        node = doc.createElement(name)\n        parent.appendChild(node)\n        if value is not None:\n            text = doc.createTextNode(str(value))\n            node.appendChild(text)\n        return node\n"
  },
  {
    "path": "app/utils/gc.py",
    "content": "\"\"\"\n内存回收装饰器模块\n提供装饰器用于在函数执行后立即回收内存\n\"\"\"\nimport gc\nimport functools\nimport psutil\nimport os\nfrom typing import Callable, Any, Optional\n\nfrom app.log import logger\n\n\ndef memory_gc(force_collect: bool = True, \n              log_memory_usage: bool = False) -> Callable:\n    \"\"\"\n    内存回收装饰器\n    \n    Args:\n        force_collect: 是否强制执行垃圾回收，默认True\n        log_memory_usage: 是否记录内存使用日志，默认False\n    \n    Returns:\n        装饰器函数\n    \"\"\"\n    def decorator(func: Callable) -> Callable:\n        @functools.wraps(func)\n        def wrapper(*args, **kwargs) -> Any:\n            # 记录函数执行前的内存使用情况\n            memory_before = None\n            memory_after = None\n            if log_memory_usage:\n                memory_before = get_memory_usage()\n                logger.info(f\"函数 {func.__name__} 执行前内存使用: {memory_before}\")\n            \n            try:\n                # 执行原函数\n                result = func(*args, **kwargs)\n                \n                # 记录函数执行后的内存使用情况\n                if log_memory_usage:\n                    memory_after = get_memory_usage()\n                    logger.info(f\"函数 {func.__name__} 执行后内存使用: {memory_after}\")\n                    if memory_before:\n                        memory_diff = memory_after - memory_before\n                        logger.info(f\"函数 {func.__name__} 内存变化: {memory_diff} MB\")\n                \n                return result\n                \n            finally:\n                # 强制垃圾回收\n                if force_collect:\n                    collected = gc.collect()\n                    if log_memory_usage:\n                        logger.info(f\"函数 {func.__name__} 垃圾回收完成，回收对象数: {collected}\")\n                \n                # 记录垃圾回收后的内存使用情况\n                if log_memory_usage:\n                    memory_after_gc = get_memory_usage()\n                    logger.info(f\"函数 {func.__name__} 垃圾回收后内存使用: {memory_after_gc}\")\n                    if memory_after:\n                        memory_freed = memory_after - memory_after_gc\n                        logger.info(f\"函数 {func.__name__} 释放内存: {memory_freed} MB\")\n        \n        return wrapper\n    return decorator\n\n\ndef get_memory_usage() -> float:\n    \"\"\"\n    获取当前进程的内存使用情况（MB）\n    \n    Returns:\n        内存使用量（MB）\n    \"\"\"\n    try:\n        process = psutil.Process(os.getpid())\n        memory_info = process.memory_info()\n        return memory_info.rss / 1024 / 1024  # 转换为MB\n    except Exception as e:\n        logger.warning(f\"获取内存使用情况失败: {e}\")\n        return 0.0\n\n\ndef memory_monitor(threshold_mb: Optional[float] = None) -> Callable:\n    \"\"\"\n    内存监控装饰器，当内存使用超过阈值时自动触发垃圾回收\n    \n    Args:\n        threshold_mb: 内存阈值（MB），超过此值将触发垃圾回收\n    \n    Returns:\n        装饰器函数\n    \"\"\"\n    def decorator(func: Callable) -> Callable:\n        @functools.wraps(func)\n        def wrapper(*args, **kwargs) -> Any:\n            # 检查内存使用情况\n            current_memory = get_memory_usage()\n            \n            if threshold_mb and current_memory > threshold_mb:\n                logger.warning(f\"内存使用超过阈值 {threshold_mb}MB，当前使用: {current_memory}MB\")\n                collected = gc.collect()\n                logger.info(f\"自动垃圾回收完成，回收对象数: {collected}\")\n            \n            # 执行原函数\n            result = func(*args, **kwargs)\n            \n            # 执行后再次检查并回收\n            if threshold_mb:\n                memory_after = get_memory_usage()\n                if memory_after > threshold_mb:\n                    collected = gc.collect()\n                    logger.info(f\"函数执行后垃圾回收完成，回收对象数: {collected}\")\n            \n            return result\n        \n        return wrapper\n    return decorator\n\n\n# 便捷的装饰器别名\nmemory_cleanup = memory_gc\nauto_gc = memory_gc(force_collect=True, log_memory_usage=True)\nmemory_watch = memory_monitor\n"
  },
  {
    "path": "app/utils/http.py",
    "content": "import re\nimport sys\nfrom contextlib import contextmanager, asynccontextmanager\nfrom pathlib import Path\nfrom typing import Any, Optional, Tuple, Union\n\nimport chardet\nimport httpx\nimport requests\nimport urllib3\nfrom requests import Response, Session\nfrom urllib3.exceptions import InsecureRequestWarning\nfrom urllib.parse import unquote, quote\n\nfrom app.core.config import settings\nfrom app.log import logger\n\nurllib3.disable_warnings(InsecureRequestWarning)\n\n\ndef _url_decode_if_latin(original: str) -> str:\n    \"\"\"\n    解码URL编码的字符串，只解码文本，二进程数据保持不变\n    :param original: URL编码字符串\n    :return: 解码后的字符串或原始二进制数据\n    \"\"\"\n    try:\n        # 先解码\n        decoded = unquote(original, encoding='latin-1')\n        # 再完整编码\n        fully_encoded = quote(decoded, safe='')\n        # 验证\n        decoded_again = unquote(fully_encoded, encoding='latin-1')\n        if decoded_again == decoded:\n            return decoded\n    except Exception as e:\n        logger.error(f\"latin-1解码URL编码失败：{e}\")\n    return original\n\ndef cookie_parse(cookies_str: str, array: bool = False) -> Union[list, dict]:\n    \"\"\"\n    解析cookie，转化为字典或者数组\n    :param cookies_str: cookie字符串\n    :param array: 是否转化为数组\n    :return: 字典或者数组\n    \"\"\"\n    if not cookies_str:\n        return {}\n\n    cookie_dict = {}\n    cookies = cookies_str.split(\";\")\n    for cookie in cookies:\n        cstr = cookie.split(\"=\", 1)  # 只分割第一个=，因为value可能包含=\n        if len(cstr) > 1:\n            # URL解码Cookie值（但保留Cookie名不解码）\n            cookie_dict[cstr[0].strip()] = _url_decode_if_latin(cstr[1].strip())\n    if array:\n        return [{\"name\": k, \"value\": v} for k, v in cookie_dict.items()]\n    return cookie_dict\n\n\ndef get_caller():\n    \"\"\"\n    获取调用者的名称，识别是否为插件调用\n    \"\"\"\n    # 调用者名称\n    caller_name = None\n\n    try:\n        frame = sys._getframe(3)  # noqa\n    except (AttributeError, ValueError):\n        return None\n\n    while frame:\n        filepath = Path(frame.f_code.co_filename)\n        parts = filepath.parts\n        if \"app\" in parts:\n            if not caller_name and \"plugins\" in parts:\n                try:\n                    plugins_index = parts.index(\"plugins\")\n                    if plugins_index + 1 < len(parts):\n                        plugin_candidate = parts[plugins_index + 1]\n                        if plugin_candidate != \"__init__.py\":\n                            caller_name = plugin_candidate\n                        break\n                except ValueError:\n                    pass\n            if \"main.py\" in parts:\n                break\n        elif len(parts) != 1:\n            break\n        try:\n            frame = frame.f_back\n        except AttributeError:\n            break\n    return caller_name\n\n\nclass RequestUtils:\n    \"\"\"\n    HTTP请求工具类，提供同步HTTP请求的基本功能\n    \"\"\"\n\n    def __init__(self,\n                 headers: dict = None,\n                 ua: str = None,\n                 cookies: Union[str, dict] = None,\n                 proxies: dict = None,\n                 session: Session = None,\n                 timeout: int = None,\n                 referer: str = None,\n                 content_type: str = None,\n                 accept_type: str = None):\n        \"\"\"\n        :param headers: 请求头部信息\n        :param ua: User-Agent字符串\n        :param cookies: Cookie字符串或字典\n        :param proxies: 代理设置\n        :param session: requests.Session实例，如果为None则创建新的Session\n        :param timeout: 请求超时时间，默认为20秒\n        :param referer: Referer头部信息\n        :param content_type: 请求的Content-Type，默认为 \"application/x-www-form-urlencoded; charset=UTF-8\"\n        :param accept_type: Accept头部信息，默认为 \"application/json\"\n        \"\"\"\n        self._proxies = proxies\n        self._session = session\n        self._timeout = timeout or 20\n        if not content_type:\n            content_type = \"application/x-www-form-urlencoded; charset=UTF-8\"\n        if headers:\n            self._headers = headers\n        else:\n            if ua and ua == settings.USER_AGENT:\n                caller_name = get_caller()\n                if caller_name:\n                    ua = f\"{settings.USER_AGENT} Plugin/{caller_name}\"\n            self._headers = {\n                \"User-Agent\": ua,\n                \"Content-Type\": content_type,\n                \"Accept\": accept_type,\n                \"referer\": referer\n            }\n        if cookies:\n            if isinstance(cookies, str):\n                self._cookies = cookie_parse(cookies)\n            else:\n                self._cookies = cookies\n        else:\n            self._cookies = None\n\n    @contextmanager\n    def response_manager(self, method: str, url: str, **kwargs):\n        \"\"\"\n        响应管理器上下文管理器，确保响应对象被正确关闭\n        :param method: HTTP方法\n        :param url: 请求的URL\n        :param kwargs: 其他请求参数\n        \"\"\"\n        response = None\n        try:\n            response = self.request(method=method, url=url, **kwargs)\n            yield response\n        finally:\n            if response:\n                try:\n                    response.close()\n                except Exception as e:\n                    logger.debug(f\"关闭响应失败: {e}\")\n\n    def request(self, method: str, url: str, raise_exception: bool = False, **kwargs) -> Optional[Response]:\n        \"\"\"\n        发起HTTP请求\n        :param method: HTTP方法，如 get, post, put 等\n        :param url: 请求的URL\n        :param raise_exception: 是否在发生异常时抛出异常，否则默认拦截异常返回None\n        :param kwargs: 其他请求参数，如headers, cookies, proxies等\n        :return: HTTP响应对象\n        :raises: requests.exceptions.RequestException 仅raise_exception为True时会抛出\n        \"\"\"\n        if self._session is None:\n            req_method = requests.request\n        else:\n            req_method = self._session.request\n        kwargs.setdefault(\"headers\", self._headers)\n        kwargs.setdefault(\"cookies\", self._cookies)\n        kwargs.setdefault(\"proxies\", self._proxies)\n        kwargs.setdefault(\"timeout\", self._timeout)\n        kwargs.setdefault(\"verify\", False)\n        kwargs.setdefault(\"stream\", False)\n        try:\n            return req_method(method, url, **kwargs)\n        except requests.exceptions.RequestException as e:\n            # 获取更详细的错误信息\n            error_msg = str(e) if str(e) else f\"未知网络错误 (URL: {url}, Method: {method.upper()})\"\n            logger.debug(f\"请求失败: {error_msg}\")\n            if raise_exception:\n                raise\n            return None\n\n    def get(self, url: str, params: dict = None, **kwargs) -> Optional[str]:\n        \"\"\"\n        发送GET请求\n        :param url: 请求的URL\n        :param params: 请求的参数\n        :param kwargs: 其他请求参数，如headers, cookies, proxies等\n        :return: 响应的内容，若发生RequestException则返回None\n        \"\"\"\n        response = self.request(method=\"get\", url=url, params=params, **kwargs)\n        if response:\n            try:\n                content = str(response.content, \"utf-8\")\n                return content\n            except Exception as e:\n                logger.debug(f\"处理响应内容失败: {e}\")\n                return None\n            finally:\n                response.close()\n        return None\n\n    def post(self, url: str, data: Any = None, json: dict = None, **kwargs) -> Optional[Response]:\n        \"\"\"\n        发送POST请求\n        :param url: 请求的URL\n        :param data: 请求的数据\n        :param json: 请求的JSON数据\n        :param kwargs: 其他请求参数，如headers, cookies, proxies等\n        :return: HTTP响应对象，若发生RequestException则返回None\n        \"\"\"\n        return self.request(method=\"post\", url=url, data=data, json=json, **kwargs)\n\n    def put(self, url: str, data: Any = None, **kwargs) -> Optional[Response]:\n        \"\"\"\n        发送PUT请求\n        :param url: 请求的URL\n        :param data: 请求的数据\n        :param kwargs: 其他请求参数，如headers, cookies, proxies等\n        :return: HTTP响应对象，若发生RequestException则返回None\n        \"\"\"\n        return self.request(method=\"put\", url=url, data=data, **kwargs)\n\n    def get_res(self,\n                url: str,\n                params: dict = None,\n                data: Any = None,\n                json: dict = None,\n                allow_redirects: bool = True,\n                raise_exception: bool = False,\n                **kwargs) -> Optional[Response]:\n        \"\"\"\n        发送GET请求并返回响应对象\n        :param url: 请求的URL\n        :param params: 请求的参数\n        :param data: 请求的数据\n        :param json: 请求的JSON数据\n        :param allow_redirects: 是否允许重定向\n        :param raise_exception: 是否在发生异常时抛出异常，否则默认拦截异常返回None\n        :param kwargs: 其他请求参数，如headers, cookies, proxies等\n        :return: HTTP响应对象，若发生RequestException则返回None\n        :raises: requests.exceptions.RequestException 仅raise_exception为True时会抛出\n        \"\"\"\n        return self.request(method=\"get\",\n                            url=url,\n                            params=params,\n                            data=data,\n                            json=json,\n                            allow_redirects=allow_redirects,\n                            raise_exception=raise_exception,\n                            **kwargs)\n\n    @contextmanager\n    def get_stream(self, url: str, params: dict = None, **kwargs):\n        \"\"\"\n        获取流式响应的上下文管理器，适用于大文件下载\n        :param url: 请求的URL\n        :param params: 请求的参数\n        :param kwargs: 其他请求参数\n        \"\"\"\n        kwargs['stream'] = True\n        response = self.request(method=\"get\", url=url, params=params, **kwargs)\n        try:\n            yield response\n        finally:\n            if response:\n                response.close()\n\n    def post_res(self,\n                 url: str,\n                 data: Any = None,\n                 params: dict = None,\n                 allow_redirects: bool = True,\n                 files: Any = None,\n                 json: dict = None,\n                 raise_exception: bool = False,\n                 **kwargs) -> Optional[Response]:\n        \"\"\"\n        发送POST请求并返回响应对象\n        :param url: 请求的URL\n        :param data: 请求的数据\n        :param params: 请求的参数\n        :param allow_redirects: 是否允许重定向\n        :param files: 请求的文件\n        :param json: 请求的JSON数据\n        :param raise_exception: 是否在发生异常时抛出异常，否则默认拦截异常返回None\n        :param kwargs: 其他请求参数，如headers, cookies, proxies等\n        :return: HTTP响应对象，若发生RequestException则返回None\n        :raises: requests.exceptions.RequestException 仅raise_exception为True时会抛出\n        \"\"\"\n        return self.request(method=\"post\",\n                            url=url,\n                            data=data,\n                            params=params,\n                            allow_redirects=allow_redirects,\n                            files=files,\n                            json=json,\n                            raise_exception=raise_exception,\n                            **kwargs)\n\n    def put_res(self,\n                url: str,\n                data: Any = None,\n                params: dict = None,\n                allow_redirects: bool = True,\n                files: Any = None,\n                json: dict = None,\n                raise_exception: bool = False,\n                **kwargs) -> Optional[Response]:\n        \"\"\"\n        发送PUT请求并返回响应对象\n        :param url: 请求的URL\n        :param data: 请求的数据\n        :param params: 请求的参数\n        :param allow_redirects: 是否允许重定向\n        :param files: 请求的文件\n        :param json: 请求的JSON数据\n        :param raise_exception: 是否在发生异常时抛出异常，否则默认拦截异常返回None\n        :param kwargs: 其他请求参数，如headers, cookies, proxies等\n        :return: HTTP响应对象，若发生RequestException则返回None\n        :raises: requests.exceptions.RequestException 仅raise_exception为True时会抛出\n        \"\"\"\n        return self.request(method=\"put\",\n                            url=url,\n                            data=data,\n                            params=params,\n                            allow_redirects=allow_redirects,\n                            files=files,\n                            json=json,\n                            raise_exception=raise_exception,\n                            **kwargs)\n\n    def delete_res(self,\n                   url: str,\n                   data: Any = None,\n                   params: dict = None,\n                   allow_redirects: bool = True,\n                   raise_exception: bool = False,\n                   **kwargs) -> Optional[Response]:\n        \"\"\"\n        发送DELETE请求并返回响应对象\n        :param url: 请求的URL\n        :param data: 请求的数据\n        :param params: 请求的参数\n        :param allow_redirects: 是否允许重定向\n        :param raise_exception: 是否在发生异常时抛出异常，否则默认拦截异常返回None\n        :param kwargs: 其他请求参数，如headers, cookies, proxies等\n        :return: HTTP响应对象，若发生RequestException则返回None\n        :raises: requests.exceptions.RequestException 仅raise_exception为True时会抛出\n        \"\"\"\n        return self.request(method=\"delete\",\n                            url=url,\n                            data=data,\n                            params=params,\n                            allow_redirects=allow_redirects,\n                            raise_exception=raise_exception,\n                            **kwargs)\n\n    def get_json(self, url: str, params: dict = None, **kwargs) -> Optional[dict]:\n        \"\"\"\n        发送GET请求并返回JSON数据，自动关闭连接\n        :param url: 请求的URL\n        :param params: 请求的参数\n        :param kwargs: 其他请求参数\n        :return: JSON数据，若发生异常则返回None\n        \"\"\"\n        response = self.request(method=\"get\", url=url, params=params, **kwargs)\n        if response:\n            try:\n                data = response.json()\n                return data\n            except Exception as e:\n                logger.debug(f\"解析JSON失败: {e}\")\n                return None\n            finally:\n                response.close()\n        return None\n\n    def post_json(self, url: str, data: Any = None, json: dict = None, **kwargs) -> Optional[dict]:\n        \"\"\"\n        发送POST请求并返回JSON数据，自动关闭连接\n        :param url: 请求的URL\n        :param data: 请求的数据\n        :param json: 请求的JSON数据\n        :param kwargs: 其他请求参数\n        :return: JSON数据，若发生异常则返回None\n        \"\"\"\n        if json is None:\n            json = {}\n        response = self.request(method=\"post\", url=url, data=data, json=json, **kwargs)\n        if response:\n            try:\n                data = response.json()\n                return data\n            except Exception as e:\n                logger.debug(f\"解析JSON失败: {e}\")\n                return None\n            finally:\n                response.close()\n        return None\n\n    @staticmethod\n    def parse_cache_control(header: str) -> Tuple[str, Optional[int]]:\n        \"\"\"\n        解析 Cache-Control 头，返回 cache_directive 和 max_age\n        :param header: Cache-Control 头部的字符串\n        :return: cache_directive 和 max_age\n        \"\"\"\n        cache_directive = \"\"\n        max_age = None\n\n        if not header:\n            return cache_directive, max_age\n\n        directives = [directive.strip() for directive in header.split(\",\")]\n        for directive in directives:\n            if directive.startswith(\"max-age\"):\n                try:\n                    max_age = int(directive.split(\"=\")[1])\n                except Exception as e:\n                    logger.debug(f\"Invalid max-age directive in Cache-Control header: {directive}, {e}\")\n            elif directive in {\"no-cache\", \"private\", \"public\", \"no-store\", \"must-revalidate\"}:\n                cache_directive = directive\n\n        return cache_directive, max_age\n\n    @staticmethod\n    def generate_cache_headers(etag: Optional[str], cache_control: Optional[str] = \"public\",\n                               max_age: Optional[int] = 86400) -> dict:\n        \"\"\"\n        生成 HTTP 响应的 ETag 和 Cache-Control 头\n        :param etag: 响应的 ETag 值。如果为 None，则不添加 ETag 头部。\n        :param cache_control: Cache-Control 指令，例如 \"public\"、\"private\" 等。默认为 \"public\"\n        :param max_age: Cache-Control 的 max-age 值（秒）。默认为 86400 秒（1天）\n        :return: HTTP 头部的字典\n        \"\"\"\n        cache_headers = {}\n\n        if etag:\n            cache_headers[\"ETag\"] = etag\n\n        if cache_control and max_age is not None:\n            cache_headers[\"Cache-Control\"] = f\"{cache_control}, max-age={max_age}\"\n        elif cache_control:\n            cache_headers[\"Cache-Control\"] = cache_control\n        elif max_age is not None:\n            cache_headers[\"Cache-Control\"] = f\"max-age={max_age}\"\n\n        return cache_headers\n\n    @staticmethod\n    def detect_encoding_from_html_response(response: Response,\n                                           performance_mode: bool = False, confidence_threshold: float = 0.8):\n        \"\"\"\n        根据HTML响应内容探测编码信息\n\n        :param response: HTTP 响应对象\n        :param performance_mode: 是否使用性能模式，默认为 False (兼容模式)\n        :param confidence_threshold: chardet 检测置信度阈值，默认为 0.8\n        :return: 解析得到的字符编码\n        \"\"\"\n        fallback_encoding = None\n        try:\n            if not performance_mode:\n                # 兼容模式：使用chardet分析后，再处理 BOM 和 meta 信息\n                # 1. 使用 chardet 库进一步分析内容\n                detection = chardet.detect(response.content)\n                if detection[\"confidence\"] > confidence_threshold:\n                    return detection.get(\"encoding\")\n                # 保存 chardet 的结果备用\n                fallback_encoding = detection.get(\"encoding\")\n\n                # 2. 检查响应体中的 BOM 标记（例如 UTF-8 BOM）\n                if response.content[:3] == b\"\\xef\\xbb\\xbf\":  # UTF-8 BOM\n                    return \"utf-8\"\n\n                # 3. 如果是 HTML 响应体，检查其中的 <meta charset=\"...\"> 标签\n                if re.search(r\"charset=[\\\"']?utf-8[\\\"']?\", response.text, re.IGNORECASE):\n                    return \"utf-8\"\n\n                # 4. 尝试从 response headers 中获取编码信息\n                content_type = response.headers.get(\"Content-Type\", \"\")\n                if re.search(r\"charset=[\\\"']?utf-8[\\\"']?\", content_type, re.IGNORECASE):\n                    return \"utf-8\"\n\n            else:\n                # 性能模式：优先从 headers 和 BOM 标记获取，最后使用 chardet 分析\n                # 1. 尝试从 response headers 中获取编码信息\n                content_type = response.headers.get(\"Content-Type\", \"\")\n                if re.search(r\"charset=[\\\"']?utf-8[\\\"']?\", content_type, re.IGNORECASE):\n                    return \"utf-8\"\n                # 2. 检查响应体中的 BOM 标记（例如 UTF-8 BOM）\n                if response.content[:3] == b\"\\xef\\xbb\\xbf\":\n                    return \"utf-8\"\n\n                # 3. 如果是 HTML 响应体，检查其中的 <meta charset=\"...\"> 标签\n                if re.search(r\"charset=[\\\"']?utf-8[\\\"']?\", response.text, re.IGNORECASE):\n                    return \"utf-8\"\n                # 4. 使用 chardet 库进一步分析内容\n                detection = chardet.detect(response.content)\n                if detection.get(\"confidence\", 0) > confidence_threshold:\n                    return detection.get(\"encoding\")\n                # 保存 chardet 的结果备用\n                fallback_encoding = detection.get(\"encoding\")\n\n            # 5. 如果上述方法都无法确定，信任 chardet 的结果（即使置信度较低），否则返回默认字符集\n            return fallback_encoding or \"utf-8\"\n        except Exception as e:\n            logger.debug(f\"Error when detect_encoding_from_response: {str(e)}\")\n            return fallback_encoding or \"utf-8\"\n\n    @staticmethod\n    def get_decoded_html_content(response: Response,\n                                 performance_mode: bool = False, confidence_threshold: float = 0.8) -> str:\n        \"\"\"\n        获取HTML响应的解码文本内容\n\n        :param response: HTTP 响应对象\n        :param performance_mode: 是否使用性能模式，默认为 False (兼容模式)\n        :param confidence_threshold: chardet 检测置信度阈值，默认为 0.8\n        :return: 解码后的响应文本内容\n        \"\"\"\n        try:\n            if not response:\n                return \"\"\n            if response.content:\n                # 1. 获取编码信息\n                encoding = (RequestUtils.detect_encoding_from_html_response(response, performance_mode,\n                                                                            confidence_threshold)\n                            or response.apparent_encoding)\n                # 2. 根据解析得到的编码进行解码\n                try:\n                    # 尝试用推测的编码解码\n                    return response.content.decode(encoding)\n                except Exception as e:\n                    logger.debug(f\"Decoding failed, error message: {str(e)}\")\n                    # 如果解码失败，尝试 fallback 使用 apparent_encoding\n                    response.encoding = response.apparent_encoding\n                    return response.text\n            else:\n                return response.text\n        except Exception as e:\n            logger.debug(f\"Error when getting decoded content: {str(e)}\")\n            return response.text\n\n\nclass AsyncRequestUtils:\n    \"\"\"\n    异步HTTP请求工具类，提供异步HTTP请求的基本功能\n    \"\"\"\n\n    def __init__(self,\n                 headers: dict = None,\n                 ua: str = None,\n                 cookies: Union[str, dict] = None,\n                 proxies: dict = None,\n                 client: httpx.AsyncClient = None,\n                 timeout: int = None,\n                 referer: str = None,\n                 content_type: str = None,\n                 accept_type: str = None):\n        \"\"\"\n        :param headers: 请求头部信息\n        :param ua: User-Agent字符串\n        :param cookies: Cookie字符串或字典\n        :param proxies: 代理设置\n        :param client: httpx.AsyncClient实例，如果为None则创建新的客户端\n        :param timeout: 请求超时时间，默认为20秒\n        :param referer: Referer头部信息\n        :param content_type: 请求的Content-Type，默认为 \"application/x-www-form-urlencoded; charset=UTF-8\"\n        :param accept_type: Accept头部信息，默认为 \"application/json\"\n        \"\"\"\n        self._proxies = self._convert_proxies_for_httpx(proxies)\n        self._client = client\n        self._timeout = timeout or 20\n        if not content_type:\n            content_type = \"application/x-www-form-urlencoded; charset=UTF-8\"\n        if headers:\n            # 过滤掉None值的headers\n            self._headers = {k: v for k, v in headers.items() if v is not None}\n        else:\n            if ua and ua == settings.USER_AGENT:\n                caller_name = get_caller()\n                if caller_name:\n                    ua = f\"{settings.USER_AGENT} Plugin/{caller_name}\"\n            self._headers = {}\n            if ua:\n                self._headers[\"User-Agent\"] = ua\n            if content_type:\n                self._headers[\"Content-Type\"] = content_type\n            if accept_type:\n                self._headers[\"Accept\"] = accept_type\n            if referer:\n                self._headers[\"referer\"] = referer\n        if cookies:\n            if isinstance(cookies, str):\n                self._cookies = cookie_parse(cookies)\n            else:\n                self._cookies = cookies\n        else:\n            self._cookies = None\n\n    @staticmethod\n    def _convert_proxies_for_httpx(proxies: dict) -> Optional[str]:\n        \"\"\"\n        将requests格式的代理配置转换为httpx兼容的格式\n        \n        :param proxies: requests格式的代理配置 {\"http\": \"http://proxy:port\", \"https\": \"http://proxy:port\"}\n        :return: httpx兼容的代理字符串或None\n        \"\"\"\n        if not proxies:\n            return None\n\n        # 如果已经是字符串格式，直接返回\n        if isinstance(proxies, str):\n            return proxies\n\n        # 如果是字典格式，提取http或https代理\n        if isinstance(proxies, dict):\n            # 优先使用https代理，如果没有则使用http代理\n            proxy_url = proxies.get(\"https\") or proxies.get(\"http\")\n            if proxy_url:\n                return proxy_url\n\n        return None\n\n    @asynccontextmanager\n    async def response_manager(self, method: str, url: str, **kwargs):\n        \"\"\"\n        异步响应管理器上下文管理器，确保响应对象被正确关闭\n        :param method: HTTP方法\n        :param url: 请求的URL\n        :param kwargs: 其他请求参数\n        \"\"\"\n        response = None\n        try:\n            response = await self.request(method=method, url=url, **kwargs)\n            yield response\n        finally:\n            if response:\n                try:\n                    await response.aclose()\n                except Exception as e:\n                    logger.debug(f\"关闭异步响应失败: {e}\")\n\n    async def request(self, method: str, url: str, raise_exception: bool = False, **kwargs) -> Optional[httpx.Response]:\n        \"\"\"\n        发起异步HTTP请求\n        :param method: HTTP方法，如 get, post, put 等\n        :param url: 请求的URL\n        :param raise_exception: 是否在发生异常时抛出异常，否则默认拦截异常返回None\n        :param kwargs: 其他请求参数，如headers, cookies, proxies等\n        :return: HTTP响应对象\n        :raises: httpx.RequestError 仅raise_exception为True时会抛出\n        \"\"\"\n        if self._client is None:\n            # 创建临时客户端\n            async with httpx.AsyncClient(\n                    proxy=self._proxies,\n                    timeout=self._timeout,\n                    verify=False,\n                    follow_redirects=True,\n                    cookies=self._cookies  # 在创建客户端时传入Cookie\n            ) as client:\n                return await self._make_request(client, method, url, raise_exception, **kwargs)\n        else:\n            return await self._make_request(self._client, method, url, raise_exception, **kwargs)\n\n    async def _make_request(self, client: httpx.AsyncClient, method: str, url: str, raise_exception: bool = False,\n                            **kwargs) -> Optional[httpx.Response]:\n        \"\"\"\n        执行实际的异步请求\n        \"\"\"\n        kwargs.setdefault(\"headers\", self._headers)\n        # Cookie已经在AsyncClient创建时设置，不要在request时再设置，否则会被覆盖\n        # kwargs.setdefault(\"cookies\", self._cookies)\n\n        try:\n            return await client.request(method, url, **kwargs)\n        except httpx.RequestError as e:\n            # 获取更详细的错误信息\n            error_msg = str(e) if str(e) else f\"未知网络错误 (URL: {url}, Method: {method.upper()})\"\n            logger.debug(f\"异步请求失败: {error_msg}\")\n            if raise_exception:\n                raise\n            return None\n\n    async def get(self, url: str, params: dict = None, **kwargs) -> Optional[str]:\n        \"\"\"\n        发送异步GET请求\n        :param url: 请求的URL\n        :param params: 请求的参数\n        :param kwargs: 其他请求参数，如headers, cookies, proxies等\n        :return: 响应的内容，若发生RequestError则返回None\n        \"\"\"\n        response = await self.request(method=\"get\", url=url, params=params, **kwargs)\n        if response:\n            try:\n                content = response.text\n                return content\n            except Exception as e:\n                logger.debug(f\"处理异步响应内容失败: {e}\")\n                return None\n            finally:\n                await response.aclose()  # 确保连接被关闭\n        return None\n\n    async def post(self, url: str, data: Any = None, json: dict = None, **kwargs) -> Optional[httpx.Response]:\n        \"\"\"\n        发送异步POST请求\n        :param url: 请求的URL\n        :param data: 请求的数据\n        :param json: 请求的JSON数据\n        :param kwargs: 其他请求参数，如headers, cookies, proxies等\n        :return: HTTP响应对象，若发生RequestError则返回None\n        \"\"\"\n        return await self.request(method=\"post\", url=url, data=data, json=json, **kwargs)\n\n    async def put(self, url: str, data: Any = None, **kwargs) -> Optional[httpx.Response]:\n        \"\"\"\n        发送异步PUT请求\n        :param url: 请求的URL\n        :param data: 请求的数据\n        :param kwargs: 其他请求参数，如headers, cookies, proxies等\n        :return: HTTP响应对象，若发生RequestError则返回None\n        \"\"\"\n        return await self.request(method=\"put\", url=url, data=data, **kwargs)\n\n    async def get_res(self,\n                      url: str,\n                      params: dict = None,\n                      data: Any = None,\n                      json: dict = None,\n                      allow_redirects: bool = True,\n                      raise_exception: bool = False,\n                      **kwargs) -> Optional[httpx.Response]:\n        \"\"\"\n        发送异步GET请求并返回响应对象\n        :param url: 请求的URL\n        :param params: 请求的参数\n        :param data: 请求的数据\n        :param json: 请求的JSON数据\n        :param allow_redirects: 是否允许重定向\n        :param raise_exception: 是否在发生异常时抛出异常，否则默认拦截异常返回None\n        :param kwargs: 其他请求参数，如headers, cookies, proxies等\n        :return: HTTP响应对象，若发生RequestError则返回None\n        :raises: httpx.RequestError 仅raise_exception为True时会抛出\n        \"\"\"\n        return await self.request(method=\"get\",\n                                  url=url,\n                                  params=params,\n                                  data=data,\n                                  json=json,\n                                  follow_redirects=allow_redirects,\n                                  raise_exception=raise_exception,\n                                  **kwargs)\n\n    @asynccontextmanager\n    async def get_stream(self, url: str, params: dict = None, **kwargs):\n        \"\"\"\n        获取异步流式响应的上下文管理器，适用于大文件下载\n        :param url: 请求的URL\n        :param params: 请求的参数\n        :param kwargs: 其他请求参数\n        \"\"\"\n        kwargs['stream'] = True\n        response = await self.request(method=\"get\", url=url, params=params, **kwargs)\n        try:\n            yield response\n        finally:\n            if response:\n                await response.aclose()\n\n    async def post_res(self,\n                       url: str,\n                       data: Any = None,\n                       params: dict = None,\n                       allow_redirects: bool = True,\n                       files: Any = None,\n                       json: dict = None,\n                       raise_exception: bool = False,\n                       **kwargs) -> Optional[httpx.Response]:\n        \"\"\"\n        发送异步POST请求并返回响应对象\n        :param url: 请求的URL\n        :param data: 请求的数据\n        :param params: 请求的参数\n        :param allow_redirects: 是否允许重定向\n        :param files: 请求的文件\n        :param json: 请求的JSON数据\n        :param raise_exception: 是否在发生异常时抛出异常，否则默认拦截异常返回None\n        :param kwargs: 其他请求参数，如headers, cookies, proxies等\n        :return: HTTP响应对象，若发生RequestError则返回None\n        :raises: httpx.RequestError 仅raise_exception为True时会抛出\n        \"\"\"\n        return await self.request(method=\"post\",\n                                  url=url,\n                                  data=data,\n                                  params=params,\n                                  follow_redirects=allow_redirects,\n                                  files=files,\n                                  json=json,\n                                  raise_exception=raise_exception,\n                                  **kwargs)\n\n    async def put_res(self,\n                      url: str,\n                      data: Any = None,\n                      params: dict = None,\n                      allow_redirects: bool = True,\n                      files: Any = None,\n                      json: dict = None,\n                      raise_exception: bool = False,\n                      **kwargs) -> Optional[httpx.Response]:\n        \"\"\"\n        发送异步PUT请求并返回响应对象\n        :param url: 请求的URL\n        :param data: 请求的数据\n        :param params: 请求的参数\n        :param allow_redirects: 是否允许重定向\n        :param files: 请求的文件\n        :param json: 请求的JSON数据\n        :param raise_exception: 是否在发生异常时抛出异常，否则默认拦截异常返回None\n        :param kwargs: 其他请求参数，如headers, cookies, proxies等\n        :return: HTTP响应对象，若发生RequestError则返回None\n        :raises: httpx.RequestError 仅raise_exception为True时会抛出\n        \"\"\"\n        return await self.request(method=\"put\",\n                                  url=url,\n                                  data=data,\n                                  params=params,\n                                  follow_redirects=allow_redirects,\n                                  files=files,\n                                  json=json,\n                                  raise_exception=raise_exception,\n                                  **kwargs)\n\n    async def delete_res(self,\n                         url: str,\n                         data: Any = None,\n                         params: dict = None,\n                         allow_redirects: bool = True,\n                         raise_exception: bool = False,\n                         **kwargs) -> Optional[httpx.Response]:\n        \"\"\"\n        发送异步DELETE请求并返回响应对象\n        :param url: 请求的URL\n        :param data: 请求的数据\n        :param params: 请求的参数\n        :param allow_redirects: 是否允许重定向\n        :param raise_exception: 是否在发生异常时抛出异常，否则默认拦截异常返回None\n        :param kwargs: 其他请求参数，如headers, cookies, proxies等\n        :return: HTTP响应对象，若发生RequestError则返回None\n        :raises: httpx.RequestError 仅raise_exception为True时会抛出\n        \"\"\"\n        return await self.request(method=\"delete\",\n                                  url=url,\n                                  data=data,\n                                  params=params,\n                                  follow_redirects=allow_redirects,\n                                  raise_exception=raise_exception,\n                                  **kwargs)\n\n    async def get_json(self, url: str, params: dict = None, **kwargs) -> Optional[dict]:\n        \"\"\"\n        发送异步GET请求并返回JSON数据，自动关闭连接\n        :param url: 请求的URL\n        :param params: 请求的参数\n        :param kwargs: 其他请求参数\n        :return: JSON数据，若发生异常则返回None\n        \"\"\"\n        response = await self.request(method=\"get\", url=url, params=params, **kwargs)\n        if response:\n            try:\n                data = response.json()\n                return data\n            except Exception as e:\n                logger.debug(f\"解析异步JSON失败: {e}\")\n                return None\n            finally:\n                await response.aclose()\n        return None\n\n    async def post_json(self, url: str, data: Any = None, json: dict = None, **kwargs) -> Optional[dict]:\n        \"\"\"\n        发送异步POST请求并返回JSON数据，自动关闭连接\n        :param url: 请求的URL\n        :param data: 请求的数据\n        :param json: 请求的JSON数据\n        :param kwargs: 其他请求参数\n        :return: JSON数据，若发生异常则返回None\n        \"\"\"\n        if json is None:\n            json = {}\n        response = await self.request(method=\"post\", url=url, data=data, json=json, **kwargs)\n        if response:\n            try:\n                data = response.json()\n                return data\n            except Exception as e:\n                logger.debug(f\"解析异步JSON失败: {e}\")\n                return None\n            finally:\n                await response.aclose()\n        return None\n"
  },
  {
    "path": "app/utils/ip.py",
    "content": "import ipaddress\nimport socket\nfrom urllib.parse import urlparse\n\n\nclass IpUtils:\n\n    @staticmethod\n    def is_ipv4(ip):\n        \"\"\"\n        判断是不是ipv4\n        \"\"\"\n        try:\n            socket.inet_pton(socket.AF_INET, ip)\n        except AttributeError:  # no inet_pton here,sorry\n            try:\n                socket.inet_aton(ip)\n            except socket.error:\n                return False\n            return ip.count('.') == 3\n        except socket.error:  # not a valid ip\n            return False\n        return True\n\n    @staticmethod\n    def is_ipv6(ip):\n        \"\"\"\n        判断是不是ipv6\n        \"\"\"\n        try:\n            socket.inet_pton(socket.AF_INET6, ip)\n        except socket.error:  # not a valid ip\n            return False\n        return True\n\n    @staticmethod\n    def is_internal(hostname):\n        \"\"\"\n        判断一个host是内网还是外网\n        \"\"\"\n        hostname = urlparse(hostname).hostname\n        if IpUtils.is_ip(hostname):\n            return IpUtils.is_private_ip(hostname)\n        else:\n            return IpUtils.is_internal_domain(hostname)\n\n    @staticmethod\n    def is_ip(addr):\n        \"\"\"\n        判断是不是ip\n        \"\"\"\n        try:\n            socket.inet_aton(addr)\n            return True\n        except socket.error:\n            return False\n\n    @staticmethod\n    def is_internal_domain(domain):\n        \"\"\"\n        判断域名是否为内部域名\n        \"\"\"\n        # 获取域名对应的 IP 地址\n        try:\n            ip = socket.gethostbyname(domain)\n        except socket.error:\n            return False\n\n        # 判断 IP 地址是否属于内网 IP 地址范围\n        return IpUtils.is_private_ip(ip)\n\n    @staticmethod\n    def is_private_ip(ip_str):\n        \"\"\"\n        判断是不是内网ip\n        \"\"\"\n        try:\n            return ipaddress.ip_address(ip_str.strip()).is_private\n        except Exception as e:\n            print(str(e))\n            return False\n"
  },
  {
    "path": "app/utils/limit.py",
    "content": "import functools\nimport inspect\nimport threading\nimport time\nfrom collections import deque\nfrom typing import Any, Tuple, List, Callable, Optional\n\nfrom app.log import logger\nfrom app.schemas import RateLimitExceededException, LimitException\n\n\n# 抽象基类\nclass BaseRateLimiter:\n    \"\"\"\n    限流器基类，定义了限流器的通用接口，用于子类实现不同的限流策略\n    所有限流器都必须实现 can_call、reset 方法\n    \"\"\"\n\n    def __init__(self, source: str = \"\", enable_logging: bool = True):\n        \"\"\"\n        初始化 BaseRateLimiter 实例\n        :param source: 业务来源或上下文信息，默认为空字符串\n        :param enable_logging: 是否启用日志记录，默认为 True\n        \"\"\"\n        self.source = source\n        self.enable_logging = enable_logging\n        self.lock = threading.Lock()\n\n    @property\n    def reset_on_success(self) -> bool:\n        \"\"\"\n        是否在成功调用后自动重置限流器状态，默认为 False\n        \"\"\"\n        return False\n\n    def can_call(self) -> Tuple[bool, str]:\n        \"\"\"\n        检查是否可以进行调用\n        :return: 如果允许调用，返回 True 和空消息，否则返回 False 和限流消息\n        \"\"\"\n        raise NotImplementedError\n\n    def reset(self):\n        \"\"\"\n        重置限流状态\n        \"\"\"\n        raise NotImplementedError\n\n    def trigger_limit(self):\n        \"\"\"\n        触发限流\n        \"\"\"\n        pass\n\n    def record_call(self):\n        \"\"\"\n        记录一次调用\n        \"\"\"\n        pass\n\n    def format_log(self, message: str) -> str:\n        \"\"\"\n        格式化日志消息\n        :param message: 日志内容\n        :return: 格式化后的日志消息\n        \"\"\"\n        return f\"[{self.source}] {message}\" if self.source else message\n\n    def log(self, level: str, message: str):\n        \"\"\"\n        根据日志级别记录日志\n        :param level: 日志级别\n        :param message: 日志内容\n        \"\"\"\n        if self.enable_logging:\n            log_method = getattr(logger, level, None)\n            if not callable(log_method):\n                log_method = logger.info\n            log_method(self.format_log(message))\n\n    def log_info(self, message: str):\n        \"\"\"\n        记录信息日志\n        \"\"\"\n        self.log(\"info\", message)\n\n    def log_warning(self, message: str):\n        \"\"\"\n        记录警告日志\n        \"\"\"\n        self.log(\"warning\", message)\n\n\n# 指数退避限流器\nclass ExponentialBackoffRateLimiter(BaseRateLimiter):\n    \"\"\"\n    基于指数退避的限流器，用于处理单次调用频率的控制\n    每次触发限流时，等待时间会成倍增加，直到达到最大等待时间\n    \"\"\"\n\n    def __init__(\n        self,\n        base_wait: float = 60.0,\n        max_wait: float = 600.0,\n        backoff_factor: float = 2.0,\n        source: str = \"\",\n        enable_logging: bool = True,\n    ):\n        \"\"\"\n        初始化 ExponentialBackoffRateLimiter 实例\n        :param base_wait: 基础等待时间（秒），默认值为 60 秒（1 分钟）\n        :param max_wait: 最大等待时间（秒），默认值为 600 秒（10 分钟）\n        :param backoff_factor: 等待时间的递增倍数，默认值为 2.0，表示指数退避\n        :param source: 业务来源或上下文信息，默认值为 \"\"\n        :param enable_logging: 是否启用日志记录，默认为 True\n        \"\"\"\n        super().__init__(source, enable_logging)\n        self.next_allowed_time = 0.0\n        self.current_wait = base_wait\n        self.base_wait = base_wait\n        self.max_wait = max_wait\n        self.backoff_factor = backoff_factor\n        self.source = source\n\n    @property\n    def reset_on_success(self) -> bool:\n        \"\"\"\n        指数退避限流器在调用成功后应重置等待时间\n        \"\"\"\n        return True\n\n    def can_call(self) -> Tuple[bool, str]:\n        \"\"\"\n        检查是否可以进行调用，如果当前时间超过下一次允许调用的时间，则允许调用\n        :return: 如果允许调用，返回 True 和空消息，否则返回 False 和限流消息\n        \"\"\"\n        current_time = time.time()\n        with self.lock:\n            if current_time >= self.next_allowed_time:\n                return True, \"\"\n            wait_time = self.next_allowed_time - current_time\n            message = f\"限流期间，跳过调用，将在 {wait_time:.2f} 秒后允许继续调用\"\n            self.log_info(message)\n            return False, self.format_log(message)\n\n    def reset(self):\n        \"\"\"\n        重置等待时间\n        当调用成功时调用此方法，重置当前等待时间为基础等待时间\n        \"\"\"\n        with self.lock:\n            if self.next_allowed_time != 0 or self.current_wait > self.base_wait:\n                self.log_info(f\"调用成功，重置限流等待时间为 {self.base_wait} 秒\")\n            self.next_allowed_time = 0.0\n            self.current_wait = self.base_wait\n\n    def trigger_limit(self):\n        \"\"\"\n        触发限流\n        当触发限流异常时调用此方法，增加下一次允许调用的时间并更新当前等待时间\n        \"\"\"\n        current_time = time.time()\n        with self.lock:\n            self.next_allowed_time = current_time + self.current_wait\n            self.current_wait = min(\n                self.current_wait * self.backoff_factor, self.max_wait\n            )\n            wait_time = self.next_allowed_time - current_time\n            self.log_warning(f\"触发限流，将在 {wait_time:.2f} 秒后允许继续调用\")\n\n\n# 时间窗口限流器\nclass WindowRateLimiter(BaseRateLimiter):\n    \"\"\"\n    基于时间窗口的限流器，用于限制在特定时间窗口内的调用次数\n    如果超过允许的最大调用次数，则限流直到窗口期结束\n    \"\"\"\n\n    def __init__(\n        self,\n        max_calls: int,\n        window_seconds: float,\n        source: str = \"\",\n        enable_logging: bool = True,\n    ):\n        \"\"\"\n        初始化 WindowRateLimiter 实例\n        :param max_calls: 在时间窗口内允许的最大调用次数\n        :param window_seconds: 时间窗口的持续时间（秒）\n        :param source: 业务来源或上下文信息，默认值为 \"\"\n        :param enable_logging: 是否启用日志记录，默认为 True\n        \"\"\"\n        super().__init__(source, enable_logging)\n        self.max_calls = max_calls\n        self.window_seconds = window_seconds\n        self.call_times = deque()\n\n    def can_call(self) -> Tuple[bool, str]:\n        \"\"\"\n        检查是否可以进行调用，如果在时间窗口内的调用次数少于最大允许次数，则允许调用。\n        :return: 如果允许调用，返回 True 和空消息，否则返回 False 和限流消息\n        \"\"\"\n        current_time = time.time()\n        with self.lock:\n            # 清理超出时间窗口的调用记录\n            while (\n                self.call_times\n                and current_time - self.call_times[0] > self.window_seconds\n            ):\n                self.call_times.popleft()\n\n            if len(self.call_times) < self.max_calls:\n                return True, \"\"\n            else:\n                wait_time = self.window_seconds - (current_time - self.call_times[0])\n                message = f\"限流期间，跳过调用，将在 {wait_time:.2f} 秒后允许继续调用\"\n                self.log_info(message)\n                return False, self.format_log(message)\n\n    def reset(self):\n        \"\"\"\n        重置时间窗口内的调用记录\n        当调用成功时调用此方法，清空时间窗口内的调用记录\n        \"\"\"\n        with self.lock:\n            self.call_times.clear()\n\n    def record_call(self):\n        \"\"\"\n        记录当前时间戳，用于限流检查\n        \"\"\"\n        current_time = time.time()\n        with self.lock:\n            self.call_times.append(current_time)\n\n\n# 组合限流器\nclass CompositeRateLimiter(BaseRateLimiter):\n    \"\"\"\n    组合限流器，可以组合多个限流策略\n    当任意一个限流策略触发限流时，都会阻止调用\n    \"\"\"\n\n    def __init__(\n        self,\n        limiters: List[BaseRateLimiter],\n        source: str = \"\",\n        enable_logging: bool = True,\n    ):\n        \"\"\"\n        初始化 CompositeRateLimiter 实例\n        :param limiters: 要组合的限流器列表\n        :param source: 业务来源或上下文信息，默认值为 \"\"\n        :param enable_logging: 是否启用日志记录，默认为 True\n        \"\"\"\n        super().__init__(source, enable_logging)\n        self.limiters = limiters\n\n    def can_call(self) -> Tuple[bool, str]:\n        \"\"\"\n        检查是否可以进行调用，当组合的任意限流器触发限流时，阻止调用。\n        :return: 如果所有限流器都允许调用，返回 True 和空消息，否则返回 False 和限流信息。\n        \"\"\"\n        for limiter in self.limiters:\n            can_call, message = limiter.can_call()\n            if not can_call:\n                return False, message\n        return True, \"\"\n\n    def reset(self):\n        \"\"\"\n        重置所有组合的限流器状态\n        \"\"\"\n        for limiter in self.limiters:\n            limiter.reset()\n\n    def record_call(self):\n        \"\"\"\n        记录所有组合的限流器的调用时间\n        \"\"\"\n        for limiter in self.limiters:\n            limiter.record_call()\n\n\n# 通用装饰器：自定义限流器实例\ndef rate_limit_handler(\n    limiter: BaseRateLimiter, raise_on_limit: bool = False\n) -> Callable:\n    \"\"\"\n    通用装饰器，允许用户传递自定义的限流器实例，用于处理限流逻辑\n    该装饰器可灵活支持任意继承自 BaseRateLimiter 的限流器\n\n    :param limiter: 限流器实例，必须继承自 BaseRateLimiter\n    :param raise_on_limit: 控制在限流时是否抛出异常，默认为 False\n    :return: 装饰器函数\n    \"\"\"\n\n    def decorator(func: Callable) -> Callable:\n        @functools.wraps(func)\n        def wrapper(*args, **kwargs) -> Optional[Any]:\n            # 检查是否传入了 \"raise_exception\" 参数，优先使用该参数，否则使用默认的 raise_on_limit 值\n            raise_exception = kwargs.get(\"raise_exception\", raise_on_limit)\n\n            # 检查是否可以进行调用，调用 limiter.can_call() 方法\n            can_call, message = limiter.can_call()\n            if not can_call:\n                # 如果调用受限，并且 raise_exception 为 True，则抛出限流异常\n                if raise_exception:\n                    raise RateLimitExceededException(message)\n                # 如果不抛出异常，则返回 None 表示跳过调用\n                return None\n\n            # 如果调用允许，执行目标函数，并记录一次调用\n            try:\n                result = func(*args, **kwargs)\n                limiter.record_call()\n                if limiter.reset_on_success:\n                    limiter.reset()\n                return result\n            except LimitException as e:\n                # 如果目标函数触发了限流相关的异常，执行限流器的触发逻辑（如递增等待时间）\n                limiter.trigger_limit()\n                logger.error(limiter.format_log(f\"触发限流：{str(e)}\"))\n                # 如果 raise_exception 为 True，则抛出异常，否则返回 None\n                if raise_exception:\n                    raise e\n                return None\n\n        @functools.wraps(func)\n        async def async_wrapper(*args, **kwargs) -> Optional[Any]:\n            # 检查是否传入了 \"raise_exception\" 参数，优先使用该参数，否则使用默认的 raise_on_limit 值\n            raise_exception = kwargs.get(\"raise_exception\", raise_on_limit)\n\n            # 检查是否可以进行调用，调用 limiter.can_call() 方法\n            can_call, message = limiter.can_call()\n            if not can_call:\n                # 如果调用受限，并且 raise_exception 为 True，则抛出限流异常\n                if raise_exception:\n                    raise RateLimitExceededException(message)\n                # 如果不抛出异常，则返回 None 表示跳过调用\n                return None\n\n            # 如果调用允许，执行目标函数，并记录一次调用\n            try:\n                result = await func(*args, **kwargs)\n                limiter.record_call()\n                if limiter.reset_on_success:\n                    limiter.reset()\n                return result\n            except LimitException as e:\n                # 如果目标函数触发了限流相关的异常，执行限流器的触发逻辑（如递增等待时间）\n                limiter.trigger_limit()\n                logger.error(limiter.format_log(f\"触发限流：{str(e)}\"))\n                # 如果 raise_exception 为 True，则抛出异常，否则返回 None\n                if raise_exception:\n                    raise e\n                return None\n\n        # 根据函数类型返回相应的包装器\n        if inspect.iscoroutinefunction(func):\n            return async_wrapper\n        else:\n            return wrapper\n\n    return decorator\n\n\n# 装饰器：指数退避限流\ndef rate_limit_exponential(\n    base_wait: float = 60.0,\n    max_wait: float = 600.0,\n    backoff_factor: float = 2.0,\n    raise_on_limit: bool = False,\n    source: str = \"\",\n    enable_logging: bool = True,\n) -> Callable:\n    \"\"\"\n    装饰器，用于应用指数退避限流策略\n    通过逐渐增加调用等待时间控制调用频率。每次触发限流时，等待时间会成倍增加，直到达到最大等待时间\n\n    :param base_wait: 基础等待时间（秒），默认值为 60 秒（1 分钟）\n    :param max_wait: 最大等待时间（秒），默认值为 600 秒（10 分钟）\n    :param backoff_factor: 等待时间递增的倍数，默认值为 2.0，表示指数退避\n    :param raise_on_limit: 控制在限流时是否抛出异常，默认为 False\n    :param source: 业务来源或上下文信息，默认为空字符串\n    :param enable_logging: 是否启用日志记录，默认为 True\n    :return: 装饰器函数\n    \"\"\"\n    # 实例化 ExponentialBackoffRateLimiter，并传入相关参数\n    limiter = ExponentialBackoffRateLimiter(\n        base_wait, max_wait, backoff_factor, source, enable_logging\n    )\n    # 使用通用装饰器逻辑包装该限流器\n    return rate_limit_handler(limiter, raise_on_limit)\n\n\n# 装饰器：时间窗口限流\ndef rate_limit_window(\n    max_calls: int,\n    window_seconds: float,\n    raise_on_limit: bool = False,\n    source: str = \"\",\n    enable_logging: bool = True,\n) -> Callable:\n    \"\"\"\n    装饰器，用于应用时间窗口限流策略\n    在固定的时间窗口内限制调用次数，当调用次数超过最大值时，触发限流，直到时间窗口结束\n\n    :param max_calls: 时间窗口内允许的最大调用次数\n    :param window_seconds: 时间窗口的持续时间（秒）\n    :param raise_on_limit: 控制在限流时是否抛出异常，默认为 False\n    :param source: 业务来源或上下文信息，默认为空字符串\n    :param enable_logging: 是否启用日志记录，默认为 True\n    :return: 装饰器函数\n    \"\"\"\n    # 实例化 WindowRateLimiter，并传入相关参数\n    limiter = WindowRateLimiter(max_calls, window_seconds, source, enable_logging)\n    # 使用通用装饰器逻辑包装该限流器\n    return rate_limit_handler(limiter, raise_on_limit)\n\n\nclass QpsRateLimiter:\n    \"\"\"\n    速率控制器，精确控制 QPS\n    \"\"\"\n\n    def __init__(self, qps: float | int):\n        if qps <= 0:\n            qps = float(\"inf\")\n        self.interval = 1.0 / qps\n        self.lock = threading.Lock()\n        self.next_call_time = time.monotonic()\n\n    def acquire(self) -> None:\n        \"\"\"\n        获取调用许可，阻塞直到满足速率限制\n        \"\"\"\n        sleep_duration = 0\n        with self.lock:\n            now = time.monotonic()\n            sleep_duration = self.next_call_time - now\n            self.next_call_time = max(now, self.next_call_time) + self.interval\n        if sleep_duration > 0:\n            time.sleep(sleep_duration)\n\n\nclass RateStats:\n    \"\"\"\n    请求速率统计：记录时间戳，计算 QPS / QPM / QPH\n    \"\"\"\n\n    def __init__(self, window_seconds: float = 7200, source: str = \"\"):\n        \"\"\"\n        :param window_seconds: 统计窗口（秒），默认 2 小时，用于计算 QPH\n        :param source: 日志来源标识\n        \"\"\"\n        self._window = window_seconds\n        self._source = source\n        self._lock = threading.Lock()\n        self._timestamps: deque = deque()\n\n    def record(self) -> None:\n        \"\"\"\n        记录一次请求\n        \"\"\"\n        t = time.time()\n        with self._lock:\n            self._timestamps.append(t)\n            while self._timestamps and t - self._timestamps[0] > self._window:\n                self._timestamps.popleft()\n\n    def _count_since(self, seconds: float) -> int:\n        t = time.time()\n        with self._lock:\n            return sum(1 for ts in self._timestamps if t - ts <= seconds)\n\n    def get_qps(self) -> float:\n        \"\"\"\n        最近 1 秒内请求数\n        \"\"\"\n        return self._count_since(1.0)\n\n    def get_qpm(self) -> float:\n        \"\"\"\n        最近 1 分钟内请求数\n        \"\"\"\n        return self._count_since(60.0)\n\n    def get_qph(self) -> float:\n        \"\"\"\n        最近 1 小时内请求数\n        \"\"\"\n        return self._count_since(3600.0)\n\n    def log_stats(self, level: str = \"info\") -> None:\n        \"\"\"\n        输出当前 QPS/QPM/QPH\n        \"\"\"\n        qps, qpm, qph = self.get_qps(), self.get_qpm(), self.get_qph()\n        msg = f\"QPS={qps} QPM={qpm} QPH={qph}\"\n        if self._source:\n            msg = f\"[{self._source}] {msg}\"\n        log_fn = getattr(logger, level, logger.info)\n        log_fn(msg)\n"
  },
  {
    "path": "app/utils/mixins.py",
    "content": "import inspect\n\nfrom app.core.event import eventmanager, Event\nfrom app.log import logger\nfrom app.schemas.types import EventType\n\n\nclass ConfigReloadMixin:\n    \"\"\"配置重载混入类\n\n    继承此 Mixin 类的类，会在配置变更时自动调用 on_config_changed 方法。\n    在类中定义 CONFIG_WATCH 集合，指定需要监听的配置项\n    重写 on_config_changed 方法实现具体的重载逻辑\n    可选地重写 get_reload_name 方法提供模块名称（用于日志显示）\n    \"\"\"\n\n    def __init_subclass__(cls, **kwargs):\n        super().__init_subclass__(**kwargs)\n\n        config_watch = getattr(cls, 'CONFIG_WATCH', None)\n        if not config_watch:\n            return\n\n        # 检查 on_config_changed 方法是否为异步\n        is_async = inspect.iscoroutinefunction(cls.on_config_changed)\n\n        method_name = 'handle_config_changed'\n\n        # 创建事件处理函数\n        def create_handler(is_async):\n            if is_async:\n                async def wrapper(self: ConfigReloadMixin, event: Event):\n                    if not event:\n                        return\n                    changed_keys = getattr(event.event_data, \"key\", set()) & config_watch\n                    if not changed_keys:\n                        return\n                    logger.info(f\"配置 {', '.join(changed_keys)} 变更，重载 {self.get_reload_name()}...\")\n                    await self.on_config_changed()\n            else:\n                def wrapper(self: ConfigReloadMixin, event: Event):\n                    if not event:\n                        return\n                    changed_keys = getattr(event.event_data, \"key\", set()) & config_watch\n                    if not changed_keys:\n                        return\n                    logger.info(f\"配置 {', '.join(changed_keys)} 变更，重载 {self.get_reload_name()}...\")\n                    self.on_config_changed()\n\n            return wrapper\n\n        # 创建并设置处理函数\n        handler = create_handler(is_async)\n        handler.__module__ = cls.__module__\n        handler.__qualname__ = f'{cls.__name__}.{method_name}'\n        setattr(cls, method_name, handler)\n        # 添加为事件处理器\n        eventmanager.add_event_listener(EventType.ConfigChanged, handler)\n\n    def on_config_changed(self):\n        \"\"\"子类重写此方法实现具体重载逻辑\"\"\"\n        pass\n\n    def get_reload_name(self):\n        \"\"\"功能/模块名称\"\"\"\n        return self.__class__.__name__\n"
  },
  {
    "path": "app/utils/object.py",
    "content": "import ast\nimport dis\nimport inspect\nimport textwrap\nfrom types import FunctionType\nfrom typing import Any, Callable, get_type_hints\n\n\nclass ObjectUtils:\n\n    @staticmethod\n    def is_obj(obj: Any):\n        if isinstance(obj, list) \\\n                or isinstance(obj, dict) \\\n                or isinstance(obj, tuple):\n            return True\n        elif isinstance(obj, int) \\\n                or isinstance(obj, float) \\\n                or isinstance(obj, bool) \\\n                or isinstance(obj, bytes) \\\n                or isinstance(obj, str):\n            return False\n        return True\n\n    @staticmethod\n    def is_objstr(obj: Any):\n        if not isinstance(obj, str):\n            return False\n        return str(obj).startswith(\"{\") \\\n            or str(obj).startswith(\"[\") \\\n            or str(obj).startswith(\"(\")\n\n    @staticmethod\n    def arguments(func: Callable) -> int:\n        \"\"\"\n        返回函数的参数个数\n        \"\"\"\n        signature = inspect.signature(func)\n        parameters = signature.parameters\n\n        return len(list(parameters.keys()))\n\n    @staticmethod\n    def check_method(func: Callable[..., Any]) -> bool:\n        \"\"\"\n        检查函数是否已实现\n        \"\"\"\n        try:\n            src = inspect.getsource(func)\n            tree = ast.parse(textwrap.dedent(src))\n            node = tree.body[0]\n            if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):\n                return True\n            body = node.body\n\n            for stmt in body:\n                # 跳过 pass\n                if isinstance(stmt, ast.Pass):\n                    continue\n                # 跳过 docstring 或 ...\n                if isinstance(stmt, ast.Expr):\n                    expr = stmt.value\n                    if isinstance(expr, ast.Constant):\n                        if isinstance(expr.value, str) or expr.value is Ellipsis:\n                            continue\n                # 检查 raise NotImplementedError\n                if isinstance(stmt, ast.Raise):\n                    exc = stmt.exc\n                    if isinstance(exc, ast.Call) and getattr(exc.func, \"id\", None) == \"NotImplementedError\":\n                        continue\n                    if isinstance(exc, ast.Name) and exc.id == \"NotImplementedError\":\n                        continue\n\n                return True\n            return False\n        except Exception as err:\n            print(err)\n            # 源代码分析失败时，进行字节码分析\n            code_obj = func.__code__  # type: ignore[attr-defined]\n            instructions = list(dis.get_instructions(code_obj))\n            # 检查是否为仅返回None的简单结构\n            if len(instructions) == 2:\n                first, second = instructions\n                if (first.opname == 'LOAD_CONST' and\n                        second.opname == 'RETURN_VALUE'):\n                    # 验证加载的常量是否为None\n                    const_index = first.arg\n                    if (const_index < len(code_obj.co_consts) and\n                            code_obj.co_consts[const_index] is None):\n                        # 未实现的空函数\n                        return False\n            # 其他情况认为已实现\n            return True\n\n    @staticmethod\n    def check_signature(func: FunctionType, *args) -> bool:\n        \"\"\"\n        检查输出与函数的参数类型是否一致\n        \"\"\"\n        # 获取函数的参数信息\n        signature = inspect.signature(func)\n        parameters = signature.parameters\n        if len(args) != len(parameters):\n            return False\n        try:\n            # 获取解析后的类型提示\n            type_hints = get_type_hints(func)\n        except TypeError:\n            type_hints = {}\n        for arg, (param_name, param) in zip(args, parameters.items()):\n            # 优先使用解析后的类型提示\n            param_type = type_hints.get(param_name, None)\n            if param_type is None:\n                # 处理原始注解（可能为字符串或Cython类型）\n                param_annotation = param.annotation\n                if param_annotation is inspect.Parameter.empty:\n                    continue\n                # 处理字符串类型的注解\n                if isinstance(param_annotation, str):\n                    # 尝试解析字符串为实际类型\n                    module = inspect.getmodule(func)\n                    global_vars = module.__dict__ if module else globals()\n                    try:\n                        param_type = eval(param_annotation, global_vars)\n                    except Exception as err:\n                        print(str(err))\n                        continue\n                else:\n                    param_type = param_annotation\n            if param_type is None:\n                continue\n            if not isinstance(arg, param_type):\n                return False\n        return True\n"
  },
  {
    "path": "app/utils/otp.py",
    "content": "import pyotp\n\n\nclass OtpUtils:\n    @staticmethod\n    def generate_secret_key(username: str) -> (str, str):\n        try:\n            secret = pyotp.random_base32()\n            uri = pyotp.totp.TOTP(secret).provisioning_uri(name='MoviePilot',\n                                                           issuer_name='MoviePilot(' + username + ')')\n            return secret, uri\n        except Exception as err:\n            print(str(err))\n            return \"\", \"\"\n\n    @staticmethod\n    def is_legal(otp_uri: str, password: str) -> bool:\n        \"\"\"\n        校验二次验证是否正确\n        \"\"\"\n        try:\n            return pyotp.TOTP(pyotp.parse_uri(otp_uri).secret).verify(password)\n        except Exception as err:\n            print(str(err))\n            return False\n\n    @staticmethod\n    def check(secret: str, password: str) -> bool:\n        \"\"\"\n        校验二次验证是否正确\n        \"\"\"\n        try:\n            totp = pyotp.TOTP(secret)\n            return totp.verify(password)\n        except Exception as err:\n            print(str(err))\n            return False\n\n    @staticmethod\n    def get_secret(otp_uri: str) -> str:\n        \"\"\"\n        获取uri中的secret\n        \"\"\"\n        try:\n            return pyotp.parse_uri(otp_uri).secret\n        except Exception as err:\n            print(str(err))\n            return \"\"\n"
  },
  {
    "path": "app/utils/security.py",
    "content": "from hashlib import sha256\nfrom pathlib import Path\nfrom typing import List, Optional, Set, Union\nfrom urllib.parse import quote, urlparse\n\nfrom anyio import Path as AsyncPath\n\nfrom app.log import logger\n\n\nclass SecurityUtils:\n\n    @staticmethod\n    def is_safe_path(base_path: Path, user_path: Path,\n                     allowed_suffixes: Optional[Union[Set[str], List[str]]] = None) -> bool:\n        \"\"\"\n        验证用户提供的路径是否在基准目录内，并检查文件类型是否合法，防止目录遍历攻击\n\n        :param base_path: 基准目录，允许访问的根目录\n        :param user_path: 用户提供的路径，需检查其是否位于基准目录内\n        :param allowed_suffixes: 允许的文件后缀名集合，用于验证文件类型\n        :return: 如果用户路径安全且位于基准目录内，且文件类型合法，返回 True；否则返回 False\n        :raises Exception: 如果解析路径时发生错误，则捕获并记录异常\n        \"\"\"\n        try:\n            # resolve() 将相对路径转换为绝对路径，并处理符号链接和'..'\n            base_path_resolved = base_path.resolve()\n            user_path_resolved = user_path.resolve()\n\n            # 检查用户路径是否在基准目录或基准目录的子目录内\n            if base_path_resolved != user_path_resolved and base_path_resolved not in user_path_resolved.parents:\n                return False\n\n            if allowed_suffixes is not None:\n                allowed_suffixes = set(allowed_suffixes)\n                if user_path.suffix.lower() not in allowed_suffixes:\n                    return False\n\n            return True\n        except Exception as e:\n            logger.debug(f\"Error occurred while validating paths: {e}\")\n            return False\n\n    @staticmethod\n    async def async_is_safe_path(base_path: AsyncPath, user_path: AsyncPath,\n                                 allowed_suffixes: Optional[Union[Set[str], List[str]]] = None) -> bool:\n        \"\"\"\n        异步验证用户提供的路径是否在基准目录内，并检查文件类型是否合法，防止目录遍历攻击\n\n        :param base_path: 基准目录，允许访问的根目录\n        :param user_path: 用户提供的路径，需检查其是否位于基准目录内\n        :param allowed_suffixes: 允许的文件后缀名集合，用于验证文件类型\n        :return: 如果用户路径安全且位于基准目录内，且文件类型合法，返回 True；否则返回 False\n        :raises Exception: 如果解析路径时发生错误，则捕获并记录异常\n        \"\"\"\n        try:\n            # resolve() 将相对路径转换为绝对路径，并处理符号链接和'..'\n            base_path_resolved = await base_path.resolve()\n            user_path_resolved = await user_path.resolve()\n\n            # 检查用户路径是否在基准目录或基准目录的子目录内\n            if base_path_resolved != user_path_resolved and base_path_resolved not in user_path_resolved.parents:\n                return False\n\n            if allowed_suffixes is not None:\n                allowed_suffixes = set(allowed_suffixes)\n                if user_path.suffix.lower() not in allowed_suffixes:\n                    return False\n\n            return True\n        except Exception as e:\n            logger.debug(f\"Error occurred while validating paths: {e}\")\n            return False\n\n    @staticmethod\n    def is_safe_url(url: str, allowed_domains: Union[Set[str], List[str]], strict: bool = False) -> bool:\n        \"\"\"\n        验证URL是否在允许的域名列表中，包括带有端口的域名\n\n        :param url: 需要验证的 URL\n        :param allowed_domains: 允许的域名集合，域名可以包含端口\n        :param strict: 是否严格匹配一级域名（默认为 False，允许多级域名）\n        :return: 如果URL合法且在允许的域名列表中，返回 True；否则返回 False\n        \"\"\"\n        try:\n            # 解析URL\n            parsed_url = urlparse(url)\n\n            # 如果 URL 没有包含有效的 scheme，或者无法从中提取到有效的 netloc，则认为该 URL 是无效的\n            if not parsed_url.scheme or not parsed_url.netloc:\n                return False\n\n            # 仅允许 http 或 https 协议\n            if parsed_url.scheme not in {\"http\", \"https\"}:\n                return False\n\n            # 获取完整的 netloc（包括 IP 和端口）并转换为小写\n            netloc = parsed_url.netloc.lower()\n            if not netloc:\n                return False\n\n            # 检查每个允许的域名\n            allowed_domains = {d.lower() for d in allowed_domains}\n            for domain in allowed_domains:\n                parsed_allowed_url = urlparse(domain)\n                allowed_netloc = parsed_allowed_url.netloc or parsed_allowed_url.path\n\n                if strict:\n                    # 严格模式下，要求完全匹配域名和端口\n                    if netloc == allowed_netloc:\n                        return True\n                else:\n                    # 非严格模式下，允许子域名匹配\n                    if netloc == allowed_netloc or netloc.endswith('.' + allowed_netloc):\n                        return True\n\n            return False\n        except Exception as e:\n            logger.debug(f\"Error occurred while validating URL: {e}\")\n            return False\n\n    @staticmethod\n    def sanitize_url_path(url: str, max_length: int = 120) -> str:\n        \"\"\"\n        将 URL 的路径部分进行编码，确保合法字符，并对路径长度进行压缩处理（如果超出最大长度）\n\n        :param url: 需要处理的 URL\n        :param max_length: 路径允许的最大长度，超出时进行压缩\n        :return: 处理后的路径字符串\n        \"\"\"\n        # 解析 URL，获取路径部分\n        parsed_url = urlparse(url)\n        path = parsed_url.path.lstrip(\"/\")\n\n        # 对路径中的特殊字符进行编码\n        safe_path = quote(path)\n\n        # 如果路径过长，进行压缩处理\n        if len(safe_path) > max_length:\n            # 使用 SHA-256 对路径进行哈希，取前 16 位作为压缩后的路径\n            hash_value = sha256(safe_path.encode()).hexdigest()[:16]\n            # 使用哈希值代替过长的路径，同时保留文件扩展名\n            file_extension = Path(safe_path).suffix.lower() if Path(safe_path).suffix else \"\"\n            safe_path = f\"compressed_{hash_value}{file_extension}\"\n\n        return safe_path\n"
  },
  {
    "path": "app/utils/singleton.py",
    "content": "import abc\nimport threading\nimport weakref\n\n\nclass Singleton(abc.ABCMeta, type):\n    \"\"\"\n    类单例模式（按参数）\n    \"\"\"\n\n    _instances: dict = {}\n\n    def __call__(cls, *args, **kwargs):\n        key = (cls, args, frozenset(kwargs.items()))\n        if key not in cls._instances:\n            cls._instances[key] = super().__call__(*args, **kwargs)\n        return cls._instances[key]\n\n\nclass AbstractSingleton(abc.ABC, metaclass=Singleton):\n    \"\"\"\n    抽像类单例模式\n    \"\"\"\n    pass\n\n\nclass SingletonClass(abc.ABCMeta, type):\n    \"\"\"\n    类单例模式（按类）\n    \"\"\"\n\n    _instances: dict = {}\n\n    def __call__(cls, *args, **kwargs):\n        if cls not in cls._instances:\n            cls._instances[cls] = super().__call__(*args, **kwargs)\n        return cls._instances[cls]\n\n\nclass AbstractSingletonClass(abc.ABC, metaclass=SingletonClass):\n    \"\"\"\n    抽像类单例模式（按类）\n    \"\"\"\n    pass\n\n\nclass WeakSingleton(abc.ABCMeta, type):\n    \"\"\"\n    弱引用单例模式 - 当没有强引用时自动清理\n    \"\"\"\n    _instances: weakref.WeakKeyDictionary = weakref.WeakKeyDictionary()\n    _lock = threading.RLock()\n\n    def __call__(cls, *args, **kwargs):\n        with cls._lock:\n            if cls not in cls._instances:\n                cls._instances[cls] = super().__call__(*args, **kwargs)\n            return cls._instances[cls]\n"
  },
  {
    "path": "app/utils/site.py",
    "content": "from lxml import etree\n\nfrom app.utils.string import StringUtils\n\n\nclass SiteUtils:\n\n    @classmethod\n    def is_logged_in(cls, html_text: str) -> bool:\n        \"\"\"\n        判断站点是否已经登陆\n        :param html_text:\n        :return:\n        \"\"\"\n        html = etree.HTML(html_text)\n        try:\n            if not StringUtils.is_valid_html_element(html):\n                return False\n            # 存在明显的密码输入框，说明未登录\n            if html.xpath(\"//input[@type='password']\"):\n                return False\n            # 是否存在登出和用户面板等链接\n            xpaths = [\n                '//a[contains(@href, \"logout\")'\n                ' or contains(@data-url, \"logout\")'\n                ' or contains(@href, \"mybonus\") '\n                ' or contains(@onclick, \"logout\")'\n                ' or contains(@href, \"usercp\")'\n                ' or contains(@lay-on, \"logout\")]',\n                '//form[contains(@action, \"logout\")]',\n                '//div[@class=\"user-info-side\"]',\n                '//a[@id=\"myitem\"]'\n            ]\n            for xpath in xpaths:\n                if html.xpath(xpath):\n                    return True\n            return False\n        finally:\n            if html is not None:\n                del html\n\n    @classmethod\n    def is_checkin(cls, html_text: str) -> bool:\n        \"\"\"\n        判断站点是否已经签到\n        :return True已签到 False未签到\n        \"\"\"\n        html = etree.HTML(html_text)\n        try:\n            if not StringUtils.is_valid_html_element(html):\n                return False\n            # 站点签到支持的识别XPATH\n            xpaths = [\n                '//a[@id=\"signed\"]',\n                '//a[contains(@href, \"attendance\")]',\n                '//a[contains(text(), \"签到\")]',\n                '//a/b[contains(text(), \"签 到\")]',\n                '//span[@id=\"sign_in\"]/a',\n                '//a[contains(@href, \"addbonus\")]',\n                '//input[@class=\"dt_button\"][contains(@value, \"打卡\")]',\n                '//a[contains(@href, \"sign_in\")]',\n                '//a[contains(@onclick, \"do_signin\")]',\n                '//a[@id=\"do-attendance\"]',\n                '//shark-icon-button[@href=\"attendance.php\"]'\n            ]\n            for xpath in xpaths:\n                if html.xpath(xpath):\n                    return False\n            return True\n        finally:\n            if html is not None:\n                del html\n"
  },
  {
    "path": "app/utils/string.py",
    "content": "import bisect\nimport datetime\nimport hashlib\nimport random\nimport re\nfrom typing import Union, Tuple, Optional, Any, List, Generator\nfrom urllib import parse\n\nimport cn2an\nimport dateparser\nimport dateutil.parser\n\nfrom app.schemas.types import MediaType\n\n_special_domains = [\n    'u2.dmhy.org',\n    'pt.ecust.pp.ua',\n    'pt.gtkpw.xyz',\n    'pt.gtk.pw'\n]\n\n# 内置版本号转换字典\n_version_map = {\"stable\": -1, \"rc\": -2, \"beta\": -3, \"alpha\": -4}\n# 不符合的版本号\n_other_version = -5\n_max_media_title_words = 10\n_min_media_title_length = 2\n_non_media_title_pattern = re.compile(r\"^#|^请[问帮你]|[?？]$|^继续$\")\n_chat_intent_pattern = re.compile(r\"帮我|请问|怎么|如何|为什么|可以|能否|推荐|介绍|谢谢|想看|找一下|搜一下\")\n_media_feature_pattern = re.compile(\n    r\"第\\s*[0-9一二三四五六七八九十百零]+\\s*[季集]|S\\d{1,2}(?:E\\d{1,4})?|E\\d{1,4}|(?:19|20)\\d{2}\",\n    re.IGNORECASE\n)\n_media_separator_pattern = re.compile(r\"[\\s\\-_.:：·'\\\"()\\[\\]【】]+\")\n_media_sentence_punctuation_pattern = re.compile(r\"[，。！？!?,；;]\")\n_media_title_char_pattern = re.compile(r\"[\\u4e00-\\u9fffA-Za-z]\")\n\n\nclass StringUtils:\n\n    @staticmethod\n    def num_filesize(text: Union[str, int, float]) -> int:\n        \"\"\"\n        将文件大小文本转化为字节\n        \"\"\"\n        if not text:\n            return 0\n        if not isinstance(text, str):\n            text = str(text)\n        if text.isdigit():\n            return int(text)\n        text = text.replace(\",\", \"\").replace(\" \", \"\").upper()\n        size = re.sub(r\"[KMGTPI]*B?\", \"\", text, flags=re.IGNORECASE)\n        try:\n            size = float(size)\n        except ValueError:\n            return 0\n        if text.find(\"PB\") != -1 or text.find(\"PIB\") != -1:\n            size *= 1024 ** 5\n        elif text.find(\"TB\") != -1 or text.find(\"TIB\") != -1:\n            size *= 1024 ** 4\n        elif text.find(\"GB\") != -1 or text.find(\"GIB\") != -1:\n            size *= 1024 ** 3\n        elif text.find(\"MB\") != -1 or text.find(\"MIB\") != -1:\n            size *= 1024 ** 2\n        elif text.find(\"KB\") != -1 or text.find(\"KIB\") != -1:\n            size *= 1024\n        return round(size)\n\n    @staticmethod\n    def str_timelong(time_sec: Union[str, int, float]) -> str:\n        \"\"\"\n        将数字转换为时间描述\n        \"\"\"\n        if not isinstance(time_sec, int) or not isinstance(time_sec, float):\n            try:\n                time_sec = float(time_sec)\n            except ValueError:\n                return \"\"\n        d = [(0, '秒'), (60 - 1, '分'), (3600 - 1, '小时'), (86400 - 1, '天')]\n        s = [x[0] for x in d]\n        index = bisect.bisect_left(s, time_sec) - 1\n        if index == -1:\n            return str(time_sec)\n        else:\n            b, u = d[index]\n        return str(round(time_sec / (b + 1))) + u\n\n    @staticmethod\n    def str_secends(time_sec: Union[str, int, float]) -> str:\n        \"\"\"\n        将秒转为时分秒字符串\n        \"\"\"\n        hours = time_sec // 3600\n        remainder_seconds = time_sec % 3600\n        minutes = remainder_seconds // 60\n        seconds = remainder_seconds % 60\n\n        time: str = str(int(seconds)) + '秒'\n        if minutes:\n            time = str(int(minutes)) + '分' + time\n        if hours:\n            time = str(int(hours)) + '时' + time\n        return time\n\n    @staticmethod\n    def is_chinese(word: Union[str, list]) -> bool:\n        \"\"\"\n        判断是否含有中文\n        \"\"\"\n        if not word:\n            return False\n        if isinstance(word, list):\n            word = \" \".join(word)\n        chn = re.compile(r'[\\u4e00-\\u9fff]')\n        if chn.search(word):\n            return True\n        else:\n            return False\n\n    @staticmethod\n    def is_japanese(word: str) -> bool:\n        \"\"\"\n        判断是否含有日文\n        \"\"\"\n        jap = re.compile(r'[\\u3040-\\u309F\\u30A0-\\u30FF]')\n        if jap.search(word):\n            return True\n        else:\n            return False\n\n    @staticmethod\n    def is_korean(word: str) -> bool:\n        \"\"\"\n        判断是否包含韩文\n        \"\"\"\n        kor = re.compile(r'[\\uAC00-\\uD7FF]')\n        if kor.search(word):\n            return True\n        else:\n            return False\n\n    @staticmethod\n    def is_all_chinese(word: str) -> bool:\n        \"\"\"\n        判断是否全是中文\n        \"\"\"\n        for ch in word:\n            if ch == ' ':\n                continue\n            if '\\u4e00' <= ch <= '\\u9fff':\n                continue\n            else:\n                return False\n        return True\n\n    @staticmethod\n    def is_english_word(word: str) -> bool:\n        \"\"\"\n        判断是否为英文单词，有空格时返回False\n        \"\"\"\n        return word.encode().isalpha()\n\n    @staticmethod\n    def str_int(text: str) -> int:\n        \"\"\"\n        web字符串转int\n        :param text:\n        :return:\n        \"\"\"\n        if text:\n            text = text.strip()\n        if not text:\n            return 0\n        try:\n            return int(text.replace(',', ''))\n        except ValueError:\n            return 0\n\n    @staticmethod\n    def str_float(text: str) -> float:\n        \"\"\"\n        web字符串转float\n        :param text:\n        :return:\n        \"\"\"\n        if text:\n            text = text.strip()\n        if not text:\n            return 0.0\n        try:\n            text = text.replace(',', '')\n            if text:\n                return float(text)\n        except ValueError:\n            pass\n        return 0.0\n\n    @staticmethod\n    def clear(text: Union[list, str], replace_word: str = \"\",\n              allow_space: bool = False) -> Union[list, str]:\n        \"\"\"\n        忽略特殊字符\n        \"\"\"\n        # 需要忽略的特殊字符\n        CONVERT_EMPTY_CHARS = r\"[、.。,，·:：;；!！'’\\\"“”()（）\\[\\]【】「」\\-—―\\+\\|\\\\_/&#～~]\"\n        if not text:\n            return text\n        if not isinstance(text, list):\n            text = re.sub(r\"[\\u200B-\\u200D\\uFEFF]\",\n                          \"\",\n                          re.sub(r\"%s\" % CONVERT_EMPTY_CHARS, replace_word, text),\n                          flags=re.IGNORECASE)\n            if not allow_space:\n                return re.sub(r\"\\s+\", \"\", text)\n            else:\n                return re.sub(r\"\\s+\", \" \", text).strip()\n        else:\n            return [StringUtils.clear(x) for x in text]\n\n    @staticmethod\n    def clear_upper(text: Optional[str]) -> str:\n        \"\"\"\n        去除特殊字符，同时大写\n        \"\"\"\n        if not text:\n            return \"\"\n        return StringUtils.clear(text).upper().strip()\n\n    @staticmethod\n    def str_filesize(size: Union[str, float, int], pre: int = 2) -> str:\n        \"\"\"\n        将字节计算为文件大小描述（带单位的格式化后返回）\n        \"\"\"\n        if size is None:\n            return \"\"\n        size = re.sub(r\"\\s|B|iB\", \"\", str(size), re.I)\n        if size.replace(\".\", \"\").isdigit():\n            try:\n                size = float(size)\n                d = [(1024 - 1, 'K'), (1024 ** 2 - 1, 'M'), (1024 ** 3 - 1, 'G'), (1024 ** 4 - 1, 'T')]\n                s = [x[0] for x in d]\n                index = bisect.bisect_left(s, size) - 1  # noqa\n                if index == -1:\n                    return str(size) + \"B\"\n                else:\n                    b, u = d[index]\n                return str(round(size / (b + 1), pre)) + u\n            except ValueError:\n                return \"\"\n        if re.findall(r\"[KMGTP]\", size, re.I):\n            return size\n        else:\n            return size + \"B\"\n\n    @staticmethod\n    def format_size(size_bytes: int) -> str:\n        \"\"\"\n        将字节转换为人类可读格式\n        \"\"\"\n        if not size_bytes or size_bytes == 0:\n            return \"0 B\"\n\n        units = [\"B\", \"KB\", \"MB\", \"GB\", \"TB\", \"PB\"]\n        size = float(size_bytes)\n        unit_index = 0\n\n        while size >= 1024 and unit_index < len(units) - 1:\n            size /= 1024\n            unit_index += 1\n\n        # 保留两位小数\n        if unit_index == 0:\n            return f\"{int(size)} {units[unit_index]}\"\n        return f\"{size:.2f} {units[unit_index]}\"\n\n    @staticmethod\n    def url_equal(url1: str, url2: str) -> bool:\n        \"\"\"\n        比较两个地址是否为同一个网站\n        \"\"\"\n        if not url1 or not url2:\n            return False\n        if url1.startswith(\"http\"):\n            url1 = parse.urlparse(url1).netloc\n        if url2.startswith(\"http\"):\n            url2 = parse.urlparse(url2).netloc\n        if url1.replace(\"www.\", \"\") == url2.replace(\"www.\", \"\"):\n            return True\n        return False\n\n    @staticmethod\n    def get_url_netloc(url: str) -> Tuple[str, str]:\n        \"\"\"\n        获取URL的协议和域名部分\n        \"\"\"\n        if not url:\n            return \"\", \"\"\n        if not url.startswith(\"http\"):\n            return \"http\", url\n        addr = parse.urlparse(url)\n        return addr.scheme, addr.netloc\n\n    @staticmethod\n    def get_url_domain(url: str) -> str:\n        \"\"\"\n        获取URL的域名部分，只保留最后两级\n        \"\"\"\n        if not url:\n            return \"\"\n        for domain in _special_domains:\n            if domain in url:\n                return domain\n        _, netloc = StringUtils.get_url_netloc(url)\n        if netloc:\n            locs = netloc.split(\".\")\n            if len(locs) > 3:\n                return netloc\n            return \".\".join(locs[-2:])\n        return \"\"\n\n    @staticmethod\n    def get_url_sld(url: str) -> str:\n        \"\"\"\n        获取URL的二级域名部分，不含端口，若为IP则返回IP\n        \"\"\"\n        if not url:\n            return \"\"\n        _, netloc = StringUtils.get_url_netloc(url)\n        if not netloc:\n            return \"\"\n        netloc = netloc.split(\":\")[0].split(\".\")\n        if len(netloc) >= 2:\n            return netloc[-2]\n        return netloc[0]\n\n    @staticmethod\n    def get_url_host(url: str) -> str:\n        \"\"\"\n        获取URL的一级域名\n        \"\"\"\n        if not url:\n            return \"\"\n        _, netloc = StringUtils.get_url_netloc(url)\n        if not netloc:\n            return \"\"\n        return netloc.split(\".\")[-2]\n\n    @staticmethod\n    def get_base_url(url: str) -> str:\n        \"\"\"\n        获取URL根地址\n        \"\"\"\n        if not url:\n            return \"\"\n        scheme, netloc = StringUtils.get_url_netloc(url)\n        return f\"{scheme}://{netloc}\"\n\n    @staticmethod\n    def clear_file_name(name: str) -> Optional[str]:\n        if not name:\n            return None\n        return re.sub(r\"[*?\\\\/\\\"<>~|]\", \"\", name, flags=re.IGNORECASE).replace(\":\", \"：\")\n\n    @staticmethod\n    def generate_random_str(randomlength: int = 16) -> str:\n        \"\"\"\n        生成一个指定长度的随机字符串\n        \"\"\"\n        random_str = ''\n        base_str = 'ABCDEFGHIGKLMNOPQRSTUVWXYZabcdefghigklmnopqrstuvwxyz0123456789'\n        length = len(base_str) - 1\n        for i in range(randomlength):\n            random_str += base_str[random.randint(0, length)]\n        return random_str\n\n    @staticmethod\n    def get_time(date: Any) -> Optional[datetime.datetime]:\n        try:\n            return dateutil.parser.parse(date)\n        except dateutil.parser.ParserError:\n            return None\n\n    @staticmethod\n    def unify_datetime_str(datetime_str: str) -> str:\n        \"\"\"\n        日期时间格式化 统一转成 2020-10-14 07:48:04 这种格式\n        # 场景1: 带有时区的日期字符串 eg: Sat, 15 Oct 2022 14:02:54 +0800\n        # 场景2: 中间带T的日期字符串 eg: 2020-10-14T07:48:04\n        # 场景3: 中间带T的日期字符串 eg: 2020-10-14T07:48:04.208\n        # 场景4: 日期字符串以GMT结尾 eg: Fri, 14 Oct 2022 07:48:04 GMT\n        # 场景5: 日期字符串以UTC结尾 eg: Fri, 14 Oct 2022 07:48:04 UTC\n        # 场景6: 日期字符串以Z结尾 eg: Fri, 14 Oct 2022 07:48:04Z\n        # 场景7: 日期字符串为相对时间 eg: 1 month, 2 days ago\n        :param datetime_str:\n        :return:\n        \"\"\"\n        # 传入的参数如果是None 或者空字符串 直接返回\n        if not datetime_str:\n            return datetime_str\n\n        try:\n            return dateparser.parse(datetime_str).strftime('%Y-%m-%d %H:%M:%S')\n        except Exception as e:\n            print(str(e))\n            return datetime_str\n\n    @staticmethod\n    def format_timestamp(timestamp: str, date_format: str = '%Y-%m-%d %H:%M:%S') -> str:\n        \"\"\"\n        时间戳转日期\n        :param timestamp:\n        :param date_format:\n        :return:\n        \"\"\"\n        if isinstance(timestamp, str) and not timestamp.isdigit():\n            return timestamp\n        try:\n            return datetime.datetime.fromtimestamp(int(timestamp)).strftime(date_format)\n        except Exception as e:\n            print(str(e))\n            return timestamp\n\n    @staticmethod\n    def str_to_timestamp(date_str: str) -> float:\n        \"\"\"\n        日期转时间戳\n        :param date_str:\n        :return:\n        \"\"\"\n        if not date_str:\n            return 0\n        try:\n            return dateparser.parse(date_str).timestamp()\n        except Exception as e:\n            print(str(e))\n            return 0\n\n    @staticmethod\n    def to_bool(text: str, default_val: bool = False) -> bool:\n        \"\"\"\n        字符串转bool\n        :param text: 要转换的值\n        :param default_val: 默认值\n        :return:\n        \"\"\"\n        if isinstance(text, str) and not text:\n            return default_val\n        if isinstance(text, bool):\n            return text\n        if isinstance(text, int) or isinstance(text, float):\n            return True if text > 0 else False\n        if isinstance(text, str) and text.lower() in ['y', 'true', '1', 'yes', 'on']:\n            return True\n        return False\n\n    @staticmethod\n    def str_from_cookiejar(cj: dict) -> str:\n        \"\"\"\n        将cookiejar转换为字符串\n        :param cj:\n        :return:\n        \"\"\"\n        return '; '.join(['='.join(item) for item in cj.items()])\n\n    @staticmethod\n    def get_idlist(content: str, dicts: List[dict]):\n        \"\"\"\n        从字符串中提取id列表\n        :param content: 字符串\n        :param dicts: 字典\n        :return:\n        \"\"\"\n        if not content:\n            return []\n        id_list = []\n        content_list = content.split()\n        for dic in dicts:\n            if dic.get('name') in content_list and dic.get('id') not in id_list:\n                id_list.append(dic.get('id'))\n                content = content.replace(dic.get('name'), '')\n        return id_list, re.sub(r'\\s+', ' ', content).strip()\n\n    @staticmethod\n    def md5_hash(data: Any) -> str:\n        \"\"\"\n        MD5 HASH\n        \"\"\"\n        if not data:\n            return \"\"\n        return hashlib.md5(str(data).encode()).hexdigest()\n\n    @staticmethod\n    def str_timehours(minutes: int) -> str:\n        \"\"\"\n        将分钟转换成小时和分钟\n        :param minutes:\n        :return:\n        \"\"\"\n        if not minutes:\n            return \"\"\n        hours = minutes // 60\n        minutes = minutes % 60\n        if hours:\n            return \"%s小时%s分\" % (hours, minutes)\n        else:\n            return \"%s分钟\" % minutes\n\n    @staticmethod\n    def str_amount(amount: object, curr=\"$\") -> str:\n        \"\"\"\n        格式化显示金额\n        \"\"\"\n        if not amount:\n            return \"0\"\n        return curr + format(amount, \",\")\n\n    @staticmethod\n    def count_words(text: str) -> int:\n        \"\"\"\n        计算字符串中包含的单词或汉字的数量，需要兼容中英文混合的情况\n        :param text: 要计算的字符串\n        :return: 字符串中包含的词数量\n        \"\"\"\n        if not text:\n            return 0\n        # 使用正则表达式匹配汉字和英文单词\n        chinese_pattern = '[\\u4e00-\\u9fa5]'\n        english_pattern = '[a-zA-Z]+'\n\n        # 匹配汉字和英文单词\n        chinese_matches = re.findall(chinese_pattern, text)\n        english_matches = re.findall(english_pattern, text)\n\n        # 过滤掉空格和数字\n        chinese_words = [word for word in chinese_matches if word.isalpha()]\n        english_words = [word for word in english_matches if word.isalpha()]\n\n        # 计算汉字和英文单词的数量\n        chinese_count = len(chinese_words)\n        english_count = len(english_words)\n\n        return chinese_count + english_count\n\n    @staticmethod\n    def is_media_title_like(text: str) -> bool:\n        \"\"\"\n        判断文本是否像影视剧名称\n        \"\"\"\n        if not text:\n            return False\n        text = re.sub(r'\\s+', ' ', text).strip()\n        if not text:\n            return False\n        if _non_media_title_pattern.search(text) \\\n                or StringUtils.count_words(text) > _max_media_title_words:\n            return False\n        if \"://\" in text or text.startswith(\"magnet:?\"):\n            return False\n        if _chat_intent_pattern.search(text):\n            return False\n        if _media_sentence_punctuation_pattern.search(text):\n            return False\n\n        # 先移除季/集/年份等媒体特征，再移除分隔符，只保留核心名称用于最终判定\n        candidate = _media_feature_pattern.sub(\"\", text)\n        candidate = _media_separator_pattern.sub(\"\", candidate)\n        return len(candidate) >= _min_media_title_length and _media_title_char_pattern.search(candidate) is not None\n\n    @staticmethod\n    def split_text(text: str, max_length: int) -> Generator:\n        \"\"\"\n        把文本拆分为固定字节长度的数组，优先按换行拆分，避免单词内拆分\n        \"\"\"\n        if not text:\n            yield ''\n        # 分行\n        lines = re.split('\\n', text)\n        buf = ''\n        for line in lines:\n            if len(line.encode('utf-8')) > max_length:\n                # 超长行继续拆分\n                blank = \"\"\n                if re.match(r'^[A-Za-z0-9.\\s]+', line):\n                    # 英文行按空格拆分\n                    parts = line.split()\n                    blank = \" \"\n                else:\n                    # 中文行按字符拆分\n                    parts = line\n                part = ''\n                for p in parts:\n                    if len((part + p).encode('utf-8')) > max_length:\n                        # 超长则Yield\n                        yield (buf + part).strip()\n                        buf = ''\n                        part = f\"{blank}{p}\"\n                    else:\n                        part = f\"{part}{blank}{p}\"\n                if part:\n                    # 将最后的部分追加到buf\n                    buf += part\n            else:\n                if len((buf + \"\\n\" + line).encode('utf-8')) > max_length:\n                    # buf超长则Yield\n                    yield buf.strip()\n                    buf = line\n                else:\n                    # 短行直接追加到buf\n                    if buf:\n                        buf = f\"{buf}\\n{line}\"\n                    else:\n                        buf = line\n        if buf:\n            # 处理文本末尾剩余部分\n            yield buf.strip()\n\n    @staticmethod\n    def get_keyword(content: str) \\\n            -> Tuple[Optional[MediaType], Optional[str], Optional[int], Optional[int], Optional[str], Optional[str]]:\n        \"\"\"\n        从搜索关键字中拆分中年份、季、集、类型\n        \"\"\"\n        if not content:\n            return None, None, None, None, None, None\n\n        # 去掉查询中的电影或电视剧关键字\n        mtype = MediaType.TV if re.search(r'^(电视剧|动漫|\\s+电视剧|\\s+动漫)', content) else None\n        content = re.sub(r'^(电影|电视剧|动漫|\\s+电影|\\s+电视剧|\\s+动漫)', '', content).strip()\n\n        # 稍微切一下剧集吧\n        season_num = None\n        episode_num = None\n        season_re = re.search(r'第\\s*([0-9一二三四五六七八九十]+)\\s*季', content, re.IGNORECASE)\n        if season_re:\n            mtype = MediaType.TV\n            season_num = int(cn2an.cn2an(season_re.group(1), mode='smart'))\n\n        episode_re = re.search(r'第\\s*([0-9一二三四五六七八九十百零]+)\\s*集', content, re.IGNORECASE)\n        if episode_re:\n            mtype = MediaType.TV\n            episode_num = int(cn2an.cn2an(episode_re.group(1), mode='smart'))\n            if episode_num and not season_num:\n                season_num = 1\n\n        year_re = re.search(r'[\\s(]+(\\d{4})[\\s)]*', content)\n        year = year_re.group(1) if year_re else None\n\n        key_word = re.sub(\n            r'第\\s*[0-9一二三四五六七八九十]+\\s*季|第\\s*[0-9一二三四五六七八九十百零]+\\s*集|[\\s(]+(\\d{4})[\\s)]*', '',\n            content, flags=re.IGNORECASE).strip()\n        key_word = re.sub(r'\\s+', ' ', key_word) if key_word else year\n\n        return mtype, key_word, season_num, episode_num, year, content\n\n    @staticmethod\n    def str_title(s: Optional[str]) -> str:\n        \"\"\"\n        大写首字母兼容None\n        \"\"\"\n        return s.title() if s else s\n\n    @staticmethod\n    def escape_markdown(content: str) -> str:\n        \"\"\"\n        Escapes Markdown characters in a string of Markdown.\n\n        Credits to: simonsmh\n\n        :param content: The string of Markdown to escape.\n        :type content: :obj:`str`\n\n        :return: The escaped string.\n        :rtype: :obj:`str`\n        \"\"\"\n\n        parses = re.sub(r\"([_*\\[\\]()~`>#+\\-=|.!{}])\", r\"\\\\\\1\", content)\n        reparse = re.sub(r\"\\\\\\\\([_*\\[\\]()~`>#+\\-=|.!{}])\", r\"\\1\", parses)\n        return reparse\n\n    @staticmethod\n    def get_domain_address(address: str, prefix: bool = True) -> Tuple[Optional[str], Optional[int]]:\n        \"\"\"\n        从地址中获取域名和端口号\n        :param address: 地址\n        :param prefix：返回域名是否要包含协议前缀\n        \"\"\"\n        if not address:\n            return None, None\n        # 去掉末尾的/\n        address = address.rstrip(\"/\")\n        if prefix and not address.startswith(\"http\"):\n            # 如果需要包含协议前缀，但地址不包含协议前缀，则添加\n            address = \"http://\" + address\n        elif not prefix and address.startswith(\"http\"):\n            # 如果不需要包含协议前缀，但地址包含协议前缀，则去掉\n            address = address.split(\"://\")[-1]\n        # 拆分域名和端口号\n        parts = address.split(\":\")\n        if len(parts) > 3:\n            # 处理不希望包含多个冒号的情况（除了协议后的冒号）\n            return None, None\n        elif len(parts) == 3:\n            port = int(parts[-1])\n            # 不含端口地址\n            domain = \":\".join(parts[:-1]).rstrip('/')\n        elif len(parts) == 2:\n            port = 443 if address.startswith(\"https\") else 80\n            domain = address\n        else:\n            return None, None\n        return domain, port\n\n    @staticmethod\n    def str_series(array: List[int]) -> str:\n        \"\"\"\n        将季集列表转化为字符串简写\n        \"\"\"\n\n        # 确保数组按照升序排列\n        array.sort()\n\n        result = []\n        start = array[0]\n        end = array[0]\n\n        for i in range(1, len(array)):\n            if array[i] == end + 1:\n                end = array[i]\n            else:\n                if start == end:\n                    result.append(str(start))\n                else:\n                    result.append(f\"{start}-{end}\")\n                start = array[i]\n                end = array[i]\n\n        # 处理最后一个序列\n        if start == end:\n            result.append(str(start))\n        else:\n            result.append(f\"{start}-{end}\")\n\n        return \",\".join(result)\n\n    @staticmethod\n    def format_ep(nums: List[int]) -> str:\n        \"\"\"\n        将剧集列表格式化为连续区间\n        \"\"\"\n        if not nums:\n            return \"\"\n        if len(nums) == 1:\n            return f\"E{nums[0]:02d}\"\n        # 将数组升序排序\n        nums.sort()\n        formatted_ranges = []\n        start = nums[0]\n        end = nums[0]\n\n        for i in range(1, len(nums)):\n            if nums[i] == end + 1:\n                end = nums[i]\n            else:\n                if start == end:\n                    formatted_ranges.append(f\"E{start:02d}\")\n                else:\n                    formatted_ranges.append(f\"E{start:02d}-E{end:02d}\")\n                start = end = nums[i]\n\n        if start == end:\n            formatted_ranges.append(f\"E{start:02d}\")\n        else:\n            formatted_ranges.append(f\"E{start:02d}-E{end:02d}\")\n\n        formatted_string = \"、\".join(formatted_ranges)\n        return formatted_string\n\n    @staticmethod\n    def is_number(text: str) -> bool:\n        \"\"\"\n        判断字符是否为可以转换为整数或者浮点数\n        \"\"\"\n        if not text:\n            return False\n        try:\n            float(text)\n            return True\n        except ValueError:\n            return False\n\n    @staticmethod\n    def find_common_prefix(str1: str, str2: str) -> str:\n        if not str1 or not str2:\n            return ''\n        common_prefix = []\n        min_len = min(len(str1), len(str2))\n\n        for i in range(min_len):\n            if str1[i] == str2[i]:\n                common_prefix.append(str1[i])\n            else:\n                break\n\n        return ''.join(common_prefix)\n\n    @staticmethod\n    def compare_version(v1: str, compare_type: str, v2: str, verbose: bool = False) \\\n            -> Tuple[Optional[bool], str | Exception] | Optional[bool]:\n        \"\"\"\n        比较两个版本号的大小\n\n        :param v1: 比对的来源版本号\n        :param v2: 比对的目标版本号\n        :param verbose: 是否输出比对结果的时候输出详细消息，默认 False 不输出\n        :param compare_type: 识别模式。支持直接使用符号进行比对\n        'ge' or '>=' ：来源 >= 目标\n        'le' or '<=' ：来源 <= 目标\n        'eq' or '==' ：来源 == 目标\n        'gt' or '>'  ：来源 > 目标\n        'lt' or '<'  ：来源 < 目标\n        :return\n        \"\"\"\n\n        def __preprocess_version(version: str) -> list:\n            \"\"\"\n            预处理版本号，去除首尾空字符串与换行符，去除开头大小写v，并拆分版本号\n            \"\"\"\n            return re.split(r'[.-]', version.strip().lstrip('vV'))\n\n        def __conversion_version(version_list) -> list:\n            \"\"\"\n            英文字符转换为数字\n            :param version_list : 版本号列表，格式：['1', '2', '3', 'beta']\n            \"\"\"\n            result = []\n            for item in version_list:\n                # stable = -1，rc = -2，beta = -3，alpha = -4\n                if item.isdigit():\n                    result.append(int(item))\n                # 其余不符合的，都为-5\n                else:\n                    value = _version_map.get(item, _other_version)\n                    result.append(value)\n            return result\n\n        try:\n            if not v1 or not v2:\n                raise ValueError(\"要比较的版本号不全\")\n            if not compare_type:\n                raise ValueError(\"缺少比对模式，无法比对\")\n            if compare_type not in {\"ge\", \"gt\", \"le\", \"lt\", \"eq\", \"==\", \">=\", \">\", \"<=\", \"<\"}:\n                raise ValueError(f\"设置的版本比对模式 {compare_type} 不是有效的模式！\")\n\n            # 拆分获取版本号各个分段值做成列表\n            v1_list = __conversion_version(__preprocess_version(version=v1))\n            v2_list = __conversion_version(__preprocess_version(version=v2))\n\n            # 补全版本号位置，保持长度一致\n            max_length = max(len(v1_list), len(v2_list))\n            v1_list += [0] * (max_length - len(v1_list))\n            v2_list += [0] * (max_length - len(v2_list))\n\n            ver_comparison, ver_comparison_err = None, None\n            for v1_value, v2_value in zip(v1_list, v2_list):\n                # 来源==目标\n                if compare_type in {\"eq\", \"==\"}:\n                    if v1_value != v2_value:\n                        ver_comparison, ver_comparison_err = None, \"不等于\"\n                        break\n                    else:\n                        ver_comparison, ver_comparison_err = \"等于\", None\n\n                # 来源>=目标\n                elif compare_type in {\"ge\", \">=\"}:\n                    if v1_value > v2_value:\n                        ver_comparison, ver_comparison_err = \"大于\", None\n                        break\n                    elif v1_value < v2_value:\n                        ver_comparison, ver_comparison_err = None, \"小于\"\n                        break\n                    else:\n                        ver_comparison, ver_comparison_err = \"等于\", None\n\n                # 来源>目标\n                elif compare_type in {\"gt\", \">\"}:\n                    if v1_value > v2_value:\n                        ver_comparison, ver_comparison_err = \"大于\", None\n                        break\n                    elif v1_value < v2_value:\n                        ver_comparison, ver_comparison_err = None, \"小于\"\n                        break\n                    else:\n                        ver_comparison, ver_comparison_err = None, \"等于\"\n\n                # 来源<=目标\n                elif compare_type in {\"le\", \"<=\"}:\n                    if v1_value > v2_value:\n                        ver_comparison, ver_comparison_err = None, \"大于\"\n                        break\n                    elif v1_value < v2_value:\n                        ver_comparison, ver_comparison_err = \"小于\", None\n                        break\n                    else:\n                        ver_comparison, ver_comparison_err = \"等于\", None\n\n                # 来源<目标\n                elif compare_type in {\"lt\", \"<\"}:\n                    if v1_value > v2_value:\n                        ver_comparison, ver_comparison_err = None, \"大于\"\n                        break\n                    elif v1_value < v2_value:\n                        ver_comparison, ver_comparison_err = \"小于\", None\n                        break\n                    else:\n                        ver_comparison, ver_comparison_err = None, \"等于\"\n\n            msg = f\"版本号 {v1} {ver_comparison if ver_comparison else ver_comparison_err} 目标版本号 {v2} ！\"\n\n            return (True if ver_comparison else False, msg) if verbose else True if ver_comparison else False\n\n        except Exception as e:\n            return (None, e) if verbose else None\n\n    @staticmethod\n    def diff_time_str(time_str: str):\n        \"\"\"\n        输入YYYY-MM-DD HH24:MI:SS 格式的时间字符串，返回距离现在的剩余时间：xx天xx小时xx分钟\n        \"\"\"\n        if not time_str:\n            return ''\n        try:\n            time_obj = datetime.datetime.strptime(time_str, '%Y-%m-%d %H:%M:%S')\n        except ValueError:\n            return time_str\n        now = datetime.datetime.now()\n        diff = time_obj - now\n        diff_seconds = diff.seconds\n        diff_days = diff.days\n        diff_hours = diff_seconds // 3600\n        diff_minutes = (diff_seconds % 3600) // 60\n        if diff_days > 0:\n            return f'{diff_days}天{diff_hours}小时{diff_minutes}分钟'\n        elif diff_hours > 0:\n            return f'{diff_hours}小时{diff_minutes}分钟'\n        elif diff_minutes > 0:\n            return f'{diff_minutes}分钟'\n        else:\n            return ''\n\n    @staticmethod\n    def safe_strip(value) -> Optional[str]:\n        \"\"\"\n        去除字符串两端的空白字符\n        :return: 如果输入值不是 None，返回去除空白字符后的字符串，否则返回 None\n        \"\"\"\n        return value.strip() if value is not None else None\n\n    @staticmethod\n    def is_valid_html_element(elem) -> bool:\n        \"\"\"\n        检查elem是否为有效的HTML元素。元素必须为非None并且具有非零长度。\n\n        :param elem: 要检查的HTML元素\n        :return: 如果elem有效（非None且长度大于0），返回True；否则返回False\n        \"\"\"\n        return elem is not None and len(elem) > 0\n\n    @staticmethod\n    def is_link(text: str) -> bool:\n        \"\"\"\n        检查文件是否为链接地址，支持各类协议\n        :param text: 要检查的文本\n        :return: 如果URL有效，返回True；否则返回False\n        \"\"\"\n        if not text:\n            return False\n        # 检查是否以http、https、ftp等协议开头\n        if re.match(r'^(http|https|ftp|ftps|sftp|ws|wss)://', text):\n            return True\n        # 检查是否为IP地址或域名\n        if re.match(r'^[a-zA-Z0-9.-]+(\\.[a-zA-Z]{2,})?$', text):\n            return True\n        return False\n\n    @staticmethod\n    def is_magnet_link(content: Union[str, bytes]) -> bool:\n        \"\"\"\n        判断内容是否为磁力链接\n        \"\"\"\n        if not content:\n            return False\n        if isinstance(content, str) and content.startswith(\"magnet:\"):\n            return True\n        if isinstance(content, bytes) and content.startswith(b\"magnet:\"):\n            return True\n        return False\n\n    @staticmethod\n    def natural_sort_key(text: str) -> List[Union[int, str]]:\n        \"\"\"\n        自然排序\n        将字符串拆分为数字和非数字部分，数字部分转换为整数，非数字部分转换为小写字母\n        :param text: 要处理的字符串\n        :return 用于排序的数字和字符串列表\n        \"\"\"\n        if text is None:\n            return []\n\n        if not isinstance(text, str):\n            text = str(text)\n\n        return [int(part) if part.isdigit() else part.lower() for part in re.split(r'(\\d+)', text)]\n"
  },
  {
    "path": "app/utils/structures.py",
    "content": "from typing import Dict, List, Set, TypeVar, Any, Union\n\nK = TypeVar(\"K\")\nV = TypeVar(\"V\")\n\n\nclass DictUtils:\n    @staticmethod\n    def filter_keys_to_subset(source: Dict[K, V], reference: Dict[K, V]) -> Dict[K, V]:\n        \"\"\"\n        过滤 source 字典，使其键成为 reference 字典键的子集\n\n        :param source: 要被过滤的字典\n        :param reference: 参考字典，定义允许的键\n        :return: 过滤后的字典，只包含在 reference 中存在的键\n        \"\"\"\n        if not isinstance(source, dict) or not isinstance(reference, dict):\n            return {}\n\n        return {key: value for key, value in source.items() if key in reference}\n\n    @staticmethod\n    def is_keys_subset(source: Dict[K, V], reference: Dict[K, V]) -> bool:\n        \"\"\"\n        判断 source 字典的键是否为 reference 字典键的子集\n\n        :param source: 要检查的字典\n        :param reference: 参考字典\n        :return: 如果 source 的键是 reference 的键子集，则返回 True，否则返回 False\n        \"\"\"\n        if not isinstance(source, dict) or not isinstance(reference, dict):\n            return False\n\n        return all(key in reference for key in source)\n\n\nclass ListUtils:\n    @staticmethod\n    def flatten(nested_list: Union[List[List[Any]], List[Any]]) -> List[Any]:\n        \"\"\"\n        将嵌套的列表展平成单个列表\n\n        :param nested_list: 嵌套的列表\n        :return: 展平后的列表\n        \"\"\"\n        if not isinstance(nested_list, list):\n            return []\n\n        # 检查是否嵌套，若不嵌套直接返回\n        if not any(isinstance(sublist, list) for sublist in nested_list):\n            return nested_list\n\n        return [item for sublist in nested_list if isinstance(sublist, list) for item in sublist]\n\n\nclass SetUtils:\n    @staticmethod\n    def flatten(nested_sets: Union[Set[Set[Any]], Set[Any]]) -> Set[Any]:\n        \"\"\"\n        将嵌套的集合展开为单个集合\n\n        :param nested_sets: 嵌套的集合\n        :return: 展开的集合\n        \"\"\"\n        if not isinstance(nested_sets, set):\n            return set()\n\n        # 检查是否嵌套，若不嵌套直接返回\n        if not any(isinstance(subset, set) for subset in nested_sets):\n            return nested_sets\n\n        return {item for subset in nested_sets if isinstance(subset, set) for item in subset}\n"
  },
  {
    "path": "app/utils/system.py",
    "content": "import datetime\nimport hashlib\nimport os\nimport platform\nimport re\nimport shutil\nimport subprocess\nimport sys\nimport uuid\nfrom pathlib import Path\nfrom typing import List, Optional, Tuple, Union\n\nimport psutil\n\nfrom app import schemas\n\n\nclass SystemUtils:\n    \"\"\"\n    系统工具类，提供系统相关的操作和信息获取方法。\n    \"\"\"\n\n    @staticmethod\n    def execute(cmd: str) -> str:\n        \"\"\"\n        执行命令，获得返回结果\n        \"\"\"\n        try:\n            with os.popen(cmd) as p:\n                return p.readline().strip()\n        except Exception as err:\n            print(str(err))\n            return \"\"\n\n    @staticmethod\n    def execute_with_subprocess(pip_command: list) -> Tuple[bool, str]:\n        \"\"\"\n        执行命令并捕获标准输出和错误输出，记录日志。\n\n        :param pip_command: 要执行的命令，以列表形式提供\n        :return: (命令是否成功, 输出信息或错误信息)\n        \"\"\"\n        try:\n            # 使用 subprocess.run 捕获标准输出和标准错误\n            result = subprocess.run(pip_command, check=True, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)\n            # 合并 stdout 和 stderr\n            output = result.stdout + result.stderr\n            return True, output\n        except subprocess.CalledProcessError as e:\n            error_message = f\"命令：{' '.join(pip_command)}，执行失败，错误信息：{e.stderr.strip()}\"\n            return False, error_message\n        except Exception as e:\n            error_message = f\"未知错误，命令：{' '.join(pip_command)}，错误：{str(e)}\"\n            return False, error_message\n\n    @staticmethod\n    def is_docker() -> bool:\n        \"\"\"\n        判断是否为Docker环境\n        \"\"\"\n        return Path(\"/.dockerenv\").exists()\n\n    @staticmethod\n    def is_synology() -> bool:\n        \"\"\"\n        判断是否为群晖系统\n        \"\"\"\n        if SystemUtils.is_windows():\n            return False\n        return \"synology\" in SystemUtils.execute('uname -a')\n\n    @staticmethod\n    def is_windows() -> bool:\n        \"\"\"\n        判断是否为Windows系统\n        \"\"\"\n        return os.name == \"nt\"\n\n    @staticmethod\n    def is_frozen() -> bool:\n        \"\"\"\n        判断是否为冻结的二进制文件\n        \"\"\"\n        return getattr(sys, 'frozen', False)\n\n    @staticmethod\n    def is_macos() -> bool:\n        \"\"\"\n        判断是否为MacOS系统\n        \"\"\"\n        return platform.system() == 'Darwin'\n\n    @staticmethod\n    def is_aarch64() -> bool:\n        \"\"\"\n        判断是否为ARM64架构\n        \"\"\"\n        return platform.machine().lower() in ('aarch64', 'arm64')\n\n    @staticmethod\n    def is_aarch() -> bool:\n        \"\"\"\n        判断是否为ARM32架构\n        \"\"\"\n        arch_name = platform.machine().lower()\n        return arch_name.startswith(('arm', 'aarch')) and arch_name not in ('aarch64', 'arm64')\n\n    @staticmethod\n    def is_x86_64() -> bool:\n        \"\"\"\n        判断是否为AMD64架构\n        \"\"\"\n        return platform.machine().lower() in ('amd64', 'x86_64')\n\n    @staticmethod\n    def is_x86_32() -> bool:\n        \"\"\"\n        判断是否为AMD32架构\n        \"\"\"\n        return platform.machine().lower() in ('i386', 'i686', 'x86', '386', 'x86_32')\n\n    @staticmethod\n    def platform() -> str:\n        \"\"\"\n        获取系统平台\n        \"\"\"\n        if SystemUtils.is_windows():\n            return \"Windows\"\n        elif SystemUtils.is_macos():\n            return \"MacOS\"\n        elif SystemUtils.is_aarch64():\n            return \"Arm64\"\n        else:\n            return \"Linux\"\n\n    @staticmethod\n    def cpu_arch() -> str:\n        \"\"\"\n        获取CPU架构\n        \"\"\"\n        if SystemUtils.is_x86_64():\n            return \"x86_64\"\n        elif SystemUtils.is_x86_32():\n            return \"x86_32\"\n        elif SystemUtils.is_aarch64():\n            return \"Arm64\"\n        elif SystemUtils.is_aarch():\n            return \"Arm32\"\n        else:\n            return platform.machine()\n\n    @staticmethod\n    def copy(src: Path, dest: Path) -> Tuple[int, str]:\n        \"\"\"\n        复制\n        \"\"\"\n        try:\n            shutil.copy2(src, dest)\n            return 0, \"\"\n        except Exception as err:\n            return -1, str(err)\n\n    @staticmethod\n    def move(src: Path, dest: Path) -> Tuple[int, str]:\n        \"\"\"\n        移动\n        \"\"\"\n        try:\n            # 直接移动到目标路径，避免中间改名步骤触发目录监控\n            shutil.move(src, dest)\n            return 0, \"\"\n        except Exception as err:\n            return -1, str(err)\n\n    @staticmethod\n    def link(src: Path, dest: Path) -> Tuple[int, str]:\n        \"\"\"\n        硬链接\n        \"\"\"\n        try:\n            # 准备目标路径，增加后缀 .mp\n            tmp_path = dest.with_suffix(dest.suffix + \".mp\")\n            # 检查目标路径是否已存在，如果存在则先unlink\n            if tmp_path.exists():\n                tmp_path.unlink()\n            tmp_path.hardlink_to(src)\n            # 硬链接完成，移除 .mp 后缀\n            shutil.move(tmp_path, dest)\n            return 0, \"\"\n        except Exception as err:\n            return -1, str(err)\n\n    @staticmethod\n    def softlink(src: Path, dest: Path) -> Tuple[int, str]:\n        \"\"\"\n        软链接\n        \"\"\"\n        try:\n            dest.symlink_to(src)\n            return 0, \"\"\n        except Exception as err:\n            return -1, str(err)\n\n    @staticmethod\n    def list_files(directory: Path, extensions: list = None,\n                   min_filesize: int = 0, recursive: bool = True) -> List[Path]:\n        \"\"\"\n        获取目录下所有指定扩展名的文件（包括子目录）\n        :param directory: 指定的父目录\n        :param extensions: 需要包含的扩展名列表，例如 ['mkv', 'mp4']\n        :param min_filesize: 文件最低大小，单位 MB\n        :param recursive: 是否递归查找，可选参数，默认 True\n        :return: 文件 Path 列表\n        \"\"\"\n\n        if not min_filesize:\n            min_filesize = 0\n\n        if not directory.exists():\n            return []\n\n        if directory.is_file():\n            return [directory]\n\n        files = []\n        # 预编译正则表达式\n        if extensions:\n            pattern = re.compile(r\".*(\" + \"|\".join(extensions) + r\")$\", re.IGNORECASE)\n        else:\n            pattern = re.compile(r\".*\")\n\n        def _scan_directory(dir_path: Path, is_recursive: bool):\n            try:\n                with os.scandir(dir_path) as entries:\n                    for entry in entries:\n                        try:\n                            if entry.is_file(follow_symlinks=False):\n                                entry_path = Path(entry.path)\n                                if (pattern.match(entry.name) and\n                                    (min_filesize <= 0 or entry.stat().st_size >= min_filesize * 1024 * 1024)):\n                                    files.append(entry_path)\n                            elif entry.is_dir() and is_recursive:\n                                _scan_directory(Path(entry.path), is_recursive)\n                        except (OSError, PermissionError):\n                            continue\n            except (OSError, PermissionError):\n                pass\n\n        _scan_directory(directory, recursive)\n        return files\n\n    @staticmethod\n    def exits_files(directory: Path, extensions: list, min_filesize: int = 0, recursive: bool = True) -> bool:\n        \"\"\"\n        判断目录下是否存在指定扩展名的文件\n\n        :param directory: 指定的父目录\n        :param extensions: 需要包含的扩展名列表，例如 ['mkv', 'mp4']\n        :param min_filesize: 文件最低大小，单位 MB\n        :param recursive: 是否递归查找，可选参数，默认 True\n        :return: True存在 False不存在\n        \"\"\"\n\n        if not directory.exists():\n            return False\n\n        # 预编译正则表达式\n        if extensions:\n            pattern = re.compile(r\".*(\" + \"|\".join(extensions) + r\")$\", re.IGNORECASE)\n        else:\n            pattern = re.compile(r\".*\")\n\n        if directory.is_file():\n            # 检查单个文件是否符合条件\n            if extensions and not pattern.match(directory.name):\n                return False\n            if min_filesize > 0 and directory.stat().st_size < min_filesize * 1024 * 1024:\n                return False\n            return True\n\n        def _search_files(dir_path: Path, is_recursive: bool) -> bool:\n            try:\n                with os.scandir(dir_path) as entries:\n                    for entry in entries:\n                        try:\n                            if entry.is_file(follow_symlinks=False):\n                                # 检查文件是否符合条件\n                                if (pattern.match(entry.name) and\n                                    (min_filesize <= 0 or entry.stat().st_size >= min_filesize * 1024 * 1024)):\n                                    return True\n                            elif entry.is_dir() and is_recursive:\n                                # 递归搜索子目录\n                                if _search_files(Path(entry.path), is_recursive):\n                                    return True\n                        except (OSError, PermissionError):\n                            continue\n            except (OSError, PermissionError):\n                pass\n            return False\n\n        return _search_files(directory, recursive)\n\n    @staticmethod\n    def list_sub_files(directory: Path, extensions: list) -> List[Path]:\n        \"\"\"\n        列出当前目录下的所有指定扩展名的文件(不包括子目录)\n        \"\"\"\n        if not directory.exists():\n            return []\n\n        if directory.is_file():\n            return [directory]\n\n        files = []\n\n        # 预编译正则表达式\n        if extensions:\n            pattern = re.compile(r\".*(\" + \"|\".join(extensions) + r\")$\", re.IGNORECASE)\n        else:\n            pattern = re.compile(r\".*\")\n\n        try:\n            with os.scandir(directory) as entries:\n                for entry in entries:\n                    if entry.is_file() and pattern.match(entry.name):\n                        files.append(Path(entry.path))\n        except OSError:\n            pass\n\n        return files\n\n    @staticmethod\n    def list_sub_directory(directory: Path) -> List[Path]:\n        \"\"\"\n        列出当前目录下的所有子目录（不递归）\n        \"\"\"\n        if not directory.exists():\n            return []\n\n        if directory.is_file():\n            return []\n\n        dirs = []\n\n        # 遍历目录\n        for path in directory.iterdir():\n            if path.is_dir():\n                if not SystemUtils.is_windows() and path.name.startswith(\".\"):\n                    continue\n                if path.name == \"@eaDir\":\n                    continue\n                dirs.append(path)\n\n        return dirs\n\n    @staticmethod\n    def list_sub_file(directory: Path) -> List[Path]:\n        \"\"\"\n        列出当前目录下的所有子目录和文件（不递归）\n        \"\"\"\n        if not directory.exists():\n            return []\n\n        if directory.is_file():\n            return [directory]\n\n        items = []\n\n        # 遍历目录\n        for path in directory.iterdir():\n            if path.is_file():\n                items.append(path)\n\n        return items\n\n    @staticmethod\n    def get_directory_size(path: Path) -> int:\n        \"\"\"\n        计算目录的大小\n\n        参数:\n            directory_path (Path): 目录路径\n\n        返回:\n            int: 目录的大小（以字节为单位）\n        \"\"\"\n        if not path or not path.exists():\n            return 0\n\n        def _calc_dir_size(dir_path):\n            total = 0\n            try:\n                with os.scandir(dir_path) as entries:\n                    for entry in entries:\n                        if entry.is_file():\n                            total += entry.stat().st_size\n                        elif entry.is_dir():\n                            total += _calc_dir_size(entry.path)\n            except OSError:\n                pass\n            return total\n\n        return _calc_dir_size(path) if path.is_dir() else path.stat().st_size\n\n    @staticmethod\n    def space_usage(dir_list: Union[Path, List[Path]]) -> Tuple[float, float]:\n        \"\"\"\n        计算多个目录的总可用空间/剩余空间（单位：Byte），并去除重复磁盘\n        \"\"\"\n        if not dir_list:\n            return 0.0, 0.0\n        if not isinstance(dir_list, list):\n            dir_list = [dir_list]\n        # 存储不重复的磁盘\n        disk_set = set()\n        # 存储总剩余空间\n        total_free_space = 0.0\n        # 存储总空间\n        total_space = 0.0\n        for dir_path in dir_list:\n            if not dir_path:\n                continue\n            if not dir_path.exists():\n                continue\n            # 获取目录所在磁盘\n            if os.name == \"nt\":\n                disk = dir_path.drive\n            else:\n                disk = os.stat(dir_path).st_dev\n            # 如果磁盘未出现过，则计算其剩余空间并加入总剩余空间中\n            if disk not in disk_set:\n                disk_set.add(disk)\n                total_space += SystemUtils.total_space(dir_path)\n                total_free_space += SystemUtils.free_space(dir_path)\n        return total_space, total_free_space\n\n    @staticmethod\n    def free_space(path: Path) -> float:\n        \"\"\"\n        获取指定路径的剩余空间（单位：Byte）\n        \"\"\"\n        if not os.path.exists(path):\n            return 0.0\n        return psutil.disk_usage(str(path)).free\n\n    @staticmethod\n    def total_space(path: Path) -> float:\n        \"\"\"\n        获取指定路径的总空间（单位：Byte）\n        \"\"\"\n        if not os.path.exists(path):\n            return 0.0\n        return psutil.disk_usage(str(path)).total\n\n    @staticmethod\n    def processes() -> List[schemas.ProcessInfo]:\n        \"\"\"\n        获取所有进程\n        \"\"\"\n        processes = []\n        for proc in psutil.process_iter(['pid', 'name', 'create_time', 'memory_info', 'status']):\n            try:\n                if proc.status() != psutil.STATUS_ZOMBIE:\n                    runtime = datetime.datetime.now() - datetime.datetime.fromtimestamp(\n                        int(getattr(proc, 'create_time', 0)()))\n                    mem_info = getattr(proc, 'memory_info', None)()\n                    if mem_info is not None:\n                        mem_mb = round(mem_info.rss / (1024 * 1024), 1)\n                        processes.append(schemas.ProcessInfo(\n                            pid=proc.pid, name=proc.name(), run_time=runtime.seconds, memory=mem_mb\n                        ))\n            except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):\n                pass\n        return processes\n\n    @staticmethod\n    def is_bluray_dir(dir_path: Path) -> bool:\n        \"\"\"\n        判断是否为蓝光原盘目录\n\n        (该方法已弃用，改用`StorageChain().is_bluray_folder)`\n        \"\"\"\n        if not dir_path.is_dir():\n            return False\n        # 蓝光原盘目录必备的文件或文件夹\n        required_files = ['BDMV', 'CERTIFICATE']\n        # 检查目录下是否存在所需文件或文件夹\n        for item in required_files:\n            if (dir_path / item).exists():\n                return True\n        return False\n\n    @staticmethod\n    def get_windows_drives():\n        \"\"\"\n        获取Windows所有盘符\n        \"\"\"\n        vols = []\n        for i in range(65, 91):\n            vol = chr(i) + ':'\n            if os.path.isdir(vol):\n                vols.append(vol)\n        return vols\n\n    @staticmethod\n    def cpu_usage():\n        \"\"\"\n        获取CPU使用率\n        \"\"\"\n        return psutil.cpu_percent()\n\n    @staticmethod\n    def memory_usage() -> List[int]:\n        \"\"\"\n        获取当前程序的内存使用量和使用率\n        \"\"\"\n        current_process = psutil.Process()\n        process_memory = current_process.memory_info().rss\n        system_memory = psutil.virtual_memory().total\n        process_memory_percent = (process_memory / system_memory) * 100\n        return [process_memory, int(process_memory_percent)]\n\n    @staticmethod\n    def network_usage() -> List[int]:\n        \"\"\"\n        获取当前网络流量（上行和下行流量，单位：bytes/s）\n        \"\"\"\n        import time\n        # 获取初始网络统计\n        net_io_1 = psutil.net_io_counters()\n        time.sleep(1)  # 等待1秒\n        # 获取1秒后的网络统计\n        net_io_2 = psutil.net_io_counters()\n\n        # 计算1秒内的流量变化\n        upload_speed = net_io_2.bytes_sent - net_io_1.bytes_sent\n        download_speed = net_io_2.bytes_recv - net_io_1.bytes_recv\n\n        return [upload_speed, download_speed]\n\n    @staticmethod\n    def is_hardlink(src: Path, dest: Path) -> bool:\n        \"\"\"\n        判断是否为硬链接（可能无法支持宿主机挂载smb盘符映射docker的场景）\n        \"\"\"\n        try:\n            if not src.exists() or not dest.exists():\n                return False\n            if src.is_file():\n                # 如果是文件，直接比较文件\n                return src.samefile(dest)\n            else:\n                for src_file in src.glob(\"**/*\"):\n                    if src_file.is_dir():\n                        continue\n                    # 计算目标文件路径\n                    relative_path = src_file.relative_to(src)\n                    target_file = dest.joinpath(relative_path)\n                    # 检查是否是硬链接\n                    if not target_file.exists() or not src_file.samefile(target_file):\n                        return False\n                return True\n        except Exception as e:\n            print(f\"Error occurred: {e}\")\n            return False\n\n    @staticmethod\n    def is_network_filesystem(directory: Path) -> bool:\n        \"\"\"\n        检测是否为网络文件系统\n        :param directory: 目录路径\n        :return: 是否为网络文件系统\n        \"\"\"\n        try:\n            system = platform.system()\n            if system == 'Linux':\n                # 检查挂载信息\n                result = subprocess.run(['df', '-T', str(directory)],\n                                        capture_output=True, text=True, timeout=5)\n                if result.returncode == 0:\n                    output = result.stdout.lower()\n                    # 以下本地文件系统含有fuse关键字\n                    local_fs = [\n                        \"fuse.shfs\",  # Unraid\n                        \"zfuse.zfsv\",  # 极空间(zfuse.zfsv2、zfuse.zfsv3、...)\n                        # TBD\n                    ]\n                    if any(fs in output for fs in local_fs):\n                        return False\n                    network_fs = ['nfs', 'cifs', 'smbfs', 'fuse', 'sshfs', 'ftpfs']\n                    return any(fs in output for fs in network_fs)\n            elif system == 'Darwin':\n                # macOS 检查\n                result = subprocess.run(['df', '-T', str(directory)],\n                                        capture_output=True, text=True, timeout=5)\n                if result.returncode == 0:\n                    output = result.stdout.lower()\n                    return 'nfs' in output or 'smbfs' in output\n            elif system == 'Windows':\n                # Windows 检查网络驱动器\n                return str(directory).startswith('\\\\\\\\')\n        except Exception as e:\n            print(f\"Error occurred: {e}\")\n        return False\n\n    @staticmethod\n    def is_same_disk(src: Path, dest: Path) -> bool:\n        \"\"\"\n        判断两个路径是否在同一磁盘\n        \"\"\"\n        if not src.exists() or not dest.exists():\n            return False\n        if os.name == \"nt\":\n            return src.drive == dest.drive\n        return os.stat(src).st_dev == os.stat(dest).st_dev\n\n    @staticmethod\n    def get_config_path(config_dir: Optional[str] = None) -> Path:\n        \"\"\"\n        获取配置路径\n        \"\"\"\n        if not config_dir:\n            config_dir = os.getenv(\"CONFIG_DIR\")\n        if config_dir:\n            return Path(config_dir)\n        if SystemUtils.is_docker():\n            return Path(\"/config\")\n        elif SystemUtils.is_frozen():\n            return Path(sys.executable).parent / \"config\"\n        else:\n            return Path(__file__).parents[2] / \"config\"\n\n    @staticmethod\n    def get_env_path() -> Path:\n        \"\"\"\n        获取配置路径\n        \"\"\"\n        return SystemUtils.get_config_path() / \"app.env\"\n\n    @staticmethod\n    def clear(temp_path: Path, days: int):\n        \"\"\"\n        清理指定目录中指定天数前的文件，递归删除子文件及空文件夹\n        \"\"\"\n        if not temp_path.exists():\n            return\n        # 遍历目录及子目录中的所有文件和文件夹\n        for file in temp_path.rglob('*'):\n            # 如果是文件并且符合时间条件，则删除\n            if file.is_file() and (\n                    datetime.datetime.now() - datetime.datetime.fromtimestamp(file.stat().st_mtime)).days > days:\n                file.unlink()\n        # 删除空的文件夹\n        for folder in sorted(temp_path.rglob('*'), reverse=True):\n            # 确保是空文件夹\n            if folder.is_dir() and not any(folder.iterdir()):\n                folder.rmdir()\n\n    @staticmethod\n    def generate_user_unique_id():\n        \"\"\"\n        根据优先级依次尝试生成稳定唯一ID：\n        1. 文件系统唯一标识符。\n        2. MAC 地址。\n        3. 主机名。\n        \"\"\"\n\n        def get_filesystem_unique_id():\n            \"\"\"\n            获取文件系统的唯一标识符。\n            使用根目录的设备号和 inode。\n            \"\"\"\n            try:\n                stat_info = os.stat(\"/\")\n                fs_id = f\"{stat_info.st_dev}-{stat_info.st_ino}\"\n                return hashlib.sha256(fs_id.encode(\"utf-8\")).hexdigest()\n            except Exception as e:\n                print(str(e))\n                return None\n\n        def get_mac_address_id():\n            \"\"\"\n            获取设备的 MAC 地址并生成唯一标识符。\n            \"\"\"\n            try:\n                mac_address = uuid.getnode()\n                if (mac_address >> 40) % 2:  # 检查是否是虚拟MAC地址\n                    raise ValueError(\"MAC地址可能是虚拟地址\")\n                mac_str = f\"{mac_address:012x}\"\n                return hashlib.sha256(mac_str.encode(\"utf-8\")).hexdigest()\n            except Exception as e:\n                print(str(e))\n                return None\n\n        for method in [get_filesystem_unique_id, get_mac_address_id]:\n            unique_id = method()\n            if unique_id:\n                return unique_id\n        return None\n"
  },
  {
    "path": "app/utils/timer.py",
    "content": "import datetime\nimport random\nfrom typing import List\n\n\nclass TimerUtils:\n\n    @staticmethod\n    def random_scheduler(num_executions: int = 1,\n                         begin_hour: int = 7,\n                         end_hour: int = 23,\n                         min_interval: int = 20,\n                         max_interval: int = 40) -> List[datetime.datetime]:\n        \"\"\"\n        按执行次数生成随机定时器\n        :param num_executions: 执行次数\n        :param begin_hour: 开始时间\n        :param end_hour: 结束时间\n        :param min_interval: 最小间隔分钟\n        :param max_interval: 最大间隔分钟\n        \"\"\"\n        trigger: list = []\n        # 当前时间\n        now = datetime.datetime.now()\n        # 创建随机的时间触发器\n        random_trigger = now.replace(hour=begin_hour, minute=0, second=0, microsecond=0)\n        for _ in range(num_executions):\n            # 随机生成下一个任务的时间间隔\n            interval_minutes = random.randint(min_interval, max_interval)\n            random_interval = datetime.timedelta(minutes=interval_minutes)\n            # 记录上一个任务的时间触发器\n            last_random_trigger = random_trigger\n            # 更新当前时间为下一个任务的时间触发器\n            random_trigger += random_interval\n            # 达到结束时间或者时间出现倒退时退出\n            if random_trigger.hour > end_hour \\\n                    or random_trigger.hour < last_random_trigger.hour:\n                break\n            # 添加到队列\n            trigger.append(random_trigger)\n\n        return trigger\n\n    @staticmethod\n    def random_even_scheduler(num_executions: int = 1,\n                              begin_hour: int = 7,\n                              end_hour: int = 23) -> List[datetime.datetime]:\n        \"\"\"\n        按执行次数尽可能平均生成随机定时器\n        :param num_executions: 执行次数\n        :param begin_hour: 计划范围开始的小时数\n        :param end_hour: 计划范围结束的小时数\n        \"\"\"\n        trigger_times = []\n        start_time = datetime.datetime.now().replace(hour=begin_hour, minute=0, second=0, microsecond=0)\n        end_time = datetime.datetime.now().replace(hour=end_hour, minute=0, second=0, microsecond=0)\n\n        # 计算范围内的总分钟数\n        total_minutes = int((end_time - start_time).total_seconds() / 60)\n        # 计算每个执行时间段的平均长度\n        segment_length = total_minutes // num_executions\n\n        for i in range(num_executions):\n            # 在每个段内随机选择一个点\n            start_segment = segment_length * i\n            end_segment = start_segment + segment_length\n            minute = random.randint(start_segment, end_segment - 1)\n            trigger_time = start_time + datetime.timedelta(minutes=minute)\n            trigger_times.append(trigger_time)\n\n        return trigger_times\n\n    @staticmethod\n    def time_difference(input_datetime: datetime) -> str:\n        \"\"\"\n        判断输入时间与当前的时间差，如果输入时间大于当前时间则返回时间差，否则返回空字符串\n        \"\"\"\n        if not input_datetime:\n            return \"\"\n        current_datetime = datetime.datetime.now(datetime.timezone.utc).astimezone()\n        time_difference = input_datetime - current_datetime\n\n        if time_difference.total_seconds() < 0:\n            return \"\"\n\n        days = time_difference.days\n        hours, remainder = divmod(time_difference.seconds, 3600)\n        minutes, second = divmod(remainder, 60)\n\n        time_difference_string = \"\"\n        if days > 0:\n            time_difference_string += f\"{days}天\"\n        if hours > 0:\n            time_difference_string += f\"{hours}小时\"\n        if minutes > 0:\n            time_difference_string += f\"{minutes}分钟\"\n        if not time_difference_string and second:\n            time_difference_string = f\"{second}秒\"\n\n        return time_difference_string\n\n    @staticmethod\n    def diff_minutes(input_datetime: datetime) -> int:\n        \"\"\"\n        计算当前时间与输入时间的分钟差\n        \"\"\"\n        if not input_datetime:\n            return 0\n        time_difference = datetime.datetime.now() - input_datetime\n        return int(time_difference.total_seconds() / 60)\n"
  },
  {
    "path": "app/utils/tokens.py",
    "content": "import re\n\n\nclass Tokens:\n    _text: str = \"\"\n    _index: int = 0\n    _tokens: list = []\n\n    def __init__(self, text):\n        self._text = text\n        self._tokens = []\n        self.load_text(text)\n\n    def load_text(self, text):\n        splitted_text = re.split(r\"\\.|\\s+|\\(|\\)|\\[|]|-|【|】|/|～|;|&|\\||#|_|「|」|~\", text)\n        for sub_text in splitted_text:\n            if sub_text:\n                self._tokens.append(sub_text)\n\n    def cur(self):\n        if self._index >= len(self._tokens):\n            return None\n        else:\n            token = self._tokens[self._index]\n            return token\n\n    def get_next(self):\n        token = self.cur()\n        if token:\n            self._index = self._index + 1\n        return token\n\n    def peek(self):\n        index = self._index + 1\n        if index >= len(self._tokens):\n            return None\n        else:\n            return self._tokens[index]\n\n    @property\n    def tokens(self):\n        return self._tokens\n"
  },
  {
    "path": "app/utils/ugreen_crypto.py",
    "content": "from __future__ import annotations\n\nimport base64\nimport hashlib\nimport json\nimport os\nimport uuid\nfrom dataclasses import dataclass\nfrom typing import Any, Mapping, Sequence\nfrom urllib.parse import quote, urlencode, urlsplit, urlunsplit\n\nfrom cryptography.hazmat.primitives import serialization\nfrom cryptography.hazmat.primitives.asymmetric import padding\nfrom cryptography.hazmat.primitives.ciphers.aead import AESGCM\n\n\n@dataclass\nclass UgreenEncryptedRequest:\n    url: str\n    headers: dict[str, str]\n    params: dict[str, str]\n    json: dict[str, Any] | None\n    aes_key: str\n    plain_query: str\n\n\nclass UgreenCrypto:\n    \"\"\"\n    绿联接口请求加解密工具。\n    \"\"\"\n\n    def __init__(\n        self,\n        public_key: str,\n        token: str | None = None,\n        client_id: str | None = None,\n        client_version: str | None = \"76363\",\n        ug_agent: str | None = \"PC/WEB\",\n        language: str = \"zh-CN\",\n    ) -> None:\n        self.public_key_pem = self.normalize_public_key(public_key)\n        self.public_key = serialization.load_pem_public_key(\n            self.public_key_pem.encode(\"utf-8\")\n        )\n        self.token = token\n        self.client_id = client_id\n        self.client_version = client_version\n        self.ug_agent = ug_agent\n        self.language = language\n\n    @staticmethod\n    def normalize_public_key(public_key: str) -> str:\n        key = (public_key or \"\").strip().strip('\"').replace(\"\\\\n\", \"\\n\")\n        if \"BEGIN\" in key:\n            return key if key.endswith(\"\\n\") else f\"{key}\\n\"\n        return (\n            \"-----BEGIN RSA PUBLIC KEY-----\\n\"\n            f\"{key}\\n\"\n            \"-----END RSA PUBLIC KEY-----\\n\"\n        )\n\n    @staticmethod\n    def generate_aes_key() -> str:\n        return uuid.uuid4().hex\n\n    @staticmethod\n    def _flatten_query(prefix: str, value: Any) -> list[tuple[str, str]]:\n        pairs: list[tuple[str, str]] = []\n        if isinstance(value, Mapping):\n            for key, item in value.items():\n                next_prefix = f\"{prefix}[{key}]\" if prefix else str(key)\n                pairs.extend(UgreenCrypto._flatten_query(next_prefix, item))\n            return pairs\n        if isinstance(value, Sequence) and not isinstance(\n            value, (str, bytes, bytearray)\n        ):\n            for item in value:\n                next_prefix = f\"{prefix}[]\"\n                pairs.extend(UgreenCrypto._flatten_query(next_prefix, item))\n            return pairs\n        if isinstance(value, bool):\n            pairs.append((prefix, \"true\" if value else \"false\"))\n            return pairs\n        if value is None:\n            pairs.append((prefix, \"\"))\n            return pairs\n        pairs.append((prefix, str(value)))\n        return pairs\n\n    @classmethod\n    def encode_query(cls, params: Mapping[str, Any] | None) -> str:\n        if not params:\n            return \"\"\n        pairs: list[tuple[str, str]] = []\n        for key, value in params.items():\n            pairs.extend(cls._flatten_query(str(key), value))\n        return urlencode(pairs, doseq=False, quote_via=quote, safe=\"\")\n\n    def rsa_encrypt_long(self, plaintext: str) -> str:\n        if not plaintext:\n            return \"\"\n        key_size = self.public_key.key_size // 8\n        max_chunk = key_size - 11\n        encrypted_chunks: list[bytes] = []\n        raw = plaintext.encode(\"utf-8\")\n        for start in range(0, len(raw), max_chunk):\n            chunk = raw[start : start + max_chunk]\n            encrypted_chunks.append(\n                self.public_key.encrypt(chunk, padding.PKCS1v15())\n            )\n        return base64.b64encode(b\"\".join(encrypted_chunks)).decode(\"utf-8\")\n\n    @staticmethod\n    def aes_gcm_encrypt(plaintext: str, aes_key: str) -> str:\n        iv = os.urandom(12)\n        cipher = AESGCM(aes_key.encode(\"utf-8\"))\n        encrypted = cipher.encrypt(iv, plaintext.encode(\"utf-8\"), None)\n        # encrypt 返回 ciphertext + tag\n        return base64.b64encode(iv + encrypted).decode(\"utf-8\")\n\n    @staticmethod\n    def aes_gcm_decrypt(payload_b64: str, aes_key: str) -> str:\n        raw = base64.b64decode(payload_b64)\n        iv = raw[:12]\n        encrypted = raw[12:]\n        cipher = AESGCM(aes_key.encode(\"utf-8\"))\n        plain = cipher.decrypt(iv, encrypted, None)\n        return plain.decode(\"utf-8\")\n\n    @staticmethod\n    def build_security_key(token: str) -> str:\n        return hashlib.md5(token.encode(\"utf-8\")).hexdigest()\n\n    @staticmethod\n    def _normalize_body(data: Any) -> str:\n        if isinstance(data, str):\n            return data\n        if isinstance(data, (bytes, bytearray)):\n            return bytes(data).decode(\"utf-8\")\n        return json.dumps(data, ensure_ascii=False, separators=(\",\", \":\"))\n\n    def encrypt_body(self, data: Any, aes_key: str) -> dict[str, str]:\n        plain = self._normalize_body(data)\n        return {\n            \"encrypt_req_body\": self.aes_gcm_encrypt(plain, aes_key),\n            \"req_body_sha256\": hashlib.sha256(plain.encode(\"utf-8\")).hexdigest(),\n        }\n\n    def build_headers(\n        self,\n        aes_key: str,\n        token: str | None = None,\n        extra_headers: Mapping[str, str] | None = None,\n        encrypt_token: bool = True,\n    ) -> dict[str, str]:\n        token_value = token if token is not None else self.token\n        headers: dict[str, str] = dict(extra_headers or {})\n\n        if self.client_id:\n            headers.setdefault(\"Client-Id\", self.client_id)\n        if self.client_version:\n            headers.setdefault(\"Client-Version\", self.client_version)\n        if self.ug_agent:\n            headers.setdefault(\"UG-Agent\", self.ug_agent)\n        headers.setdefault(\"X-Specify-Language\", self.language)\n        headers.setdefault(\"Accept\", \"application/json, text/plain, */*\")\n\n        if token_value:\n            headers[\"X-Ugreen-Security-Key\"] = self.build_security_key(token_value)\n            headers[\"X-Ugreen-Security-Code\"] = self.rsa_encrypt_long(aes_key)\n            headers[\"X-Ugreen-Token\"] = (\n                self.rsa_encrypt_long(token_value) if encrypt_token else token_value\n            )\n        return headers\n\n    def build_encrypted_request(\n        self,\n        url: str,\n        method: str = \"GET\",\n        params: Mapping[str, Any] | None = None,\n        data: Any | None = None,\n        extra_headers: Mapping[str, str] | None = None,\n        token: str | None = None,\n        encrypt_token: bool = True,\n        encrypt_body: bool = True,\n    ) -> UgreenEncryptedRequest:\n        \"\"\"\n        构建绿联加密请求。\n\n        关键点：\n        - 传入的是明文 `params`；\n        - 方法内部会将其序列化并加密成 `encrypt_query`；\n        - 业务侧不需要、也不应该手工拼接 `encrypt_query`。\n        \"\"\"\n        parsed = urlsplit(url)\n        clean_url = urlunsplit(\n            (parsed.scheme, parsed.netloc, parsed.path, \"\", parsed.fragment)\n        )\n\n        url_query_plain = parsed.query\n        input_query_plain = self.encode_query(params)\n        plain_query = \"&\".join(filter(None, [url_query_plain, input_query_plain]))\n\n        aes_key = self.generate_aes_key()\n        encrypted_query = self.aes_gcm_encrypt(plain_query, aes_key)\n\n        req_json = None\n        if data is not None:\n            req_json = self.encrypt_body(data, aes_key) if encrypt_body else data\n\n        headers = self.build_headers(\n            aes_key=aes_key,\n            token=token,\n            extra_headers=extra_headers,\n            encrypt_token=encrypt_token,\n        )\n        if req_json is not None:\n            headers.setdefault(\"Content-Type\", \"application/json\")\n\n        _ = method  # 保留参数，便于上层统一调用\n\n        return UgreenEncryptedRequest(\n            url=clean_url,\n            headers=headers,\n            # 绿联接口约定：查询参数统一透传为 encrypt_query\n            params={\"encrypt_query\": encrypted_query},\n            json=req_json,\n            aes_key=aes_key,\n            plain_query=plain_query,\n        )\n\n    def decrypt_response(self, response_json: Any, aes_key: str) -> Any:\n        if not isinstance(response_json, Mapping):\n            return response_json\n        encrypted = response_json.get(\"encrypt_resp_body\")\n        if not encrypted:\n            return response_json\n        plain = self.aes_gcm_decrypt(str(encrypted), aes_key)\n        try:\n            return json.loads(plain)\n        except json.JSONDecodeError:\n            return plain\n"
  },
  {
    "path": "app/utils/url.py",
    "content": "import mimetypes\nfrom pathlib import Path\nfrom typing import Optional, Union, Tuple\nfrom urllib import parse\nfrom urllib.parse import parse_qs, urlencode, urljoin, urlparse, urlunparse\n\nfrom app.log import logger\n\n\nclass UrlUtils:\n\n    @staticmethod\n    def standardize_base_url(host: str) -> str:\n        \"\"\"\n        标准化提供的主机地址，确保它以http://或https://开头，并且以斜杠(/)结尾\n        :param host: 提供的主机地址字符串\n        :return: 标准化后的主机地址字符串\n        \"\"\"\n        if not host:\n            return host\n        if not host.endswith(\"/\"):\n            host += \"/\"\n        if not host.startswith(\"http://\") and not host.startswith(\"https://\"):\n            host = \"http://\" + host\n        return host\n\n    @staticmethod\n    def adapt_request_url(host: str, endpoint: str) -> Optional[str]:\n        \"\"\"\n        基于传入的host，适配请求的URL，确保每个请求的URL是完整的，用于在发送请求前自动处理和修正请求的URL\n        :param host: 主机头\n        :param endpoint: 端点\n        :return: 完整的请求URL字符串\n        \"\"\"\n        if not host and not endpoint:\n            return None\n        if endpoint.startswith((\"http://\", \"https://\")):\n            return endpoint\n        host = UrlUtils.standardize_base_url(host)\n        return urljoin(host, endpoint) if host else endpoint\n\n    @staticmethod\n    def combine_url(host: str, path: Optional[str] = None, query: Optional[dict] = None) -> Optional[str]:\n        \"\"\"\n        使用给定的主机头、路径和查询参数组合生成完整的URL\n        :param host: str, 主机头，例如 https://example.com\n        :param path: Optional[str], 包含路径和可能已经包含的查询参数的端点，例如 /path/to/resource?current=1\n        :param query: Optional[dict], 可选，额外的查询参数，例如 {\"key\": \"value\"}\n        :return: str, 完整的请求URL字符串\n        \"\"\"\n        try:\n            # 如果路径为空，则默认为 '/'\n            if path is None:\n                path = '/'\n            host = UrlUtils.standardize_base_url(host)\n            # 使用 urljoin 合并 host 和 path\n            url = urljoin(host, path)\n            # 解析当前 URL 的组成部分\n            url_parts = urlparse(url)\n            # 解析已存在的查询参数，并与额外的查询参数合并\n            query_params = parse_qs(url_parts.query)\n            if query:\n                for key, value in query.items():\n                    query_params[key] = value\n\n            # 重新构建查询字符串\n            query_string = urlencode(query_params, doseq=True)\n            # 构建完整的 URL\n            new_url_parts = url_parts._replace(query=query_string)\n            complete_url = urlunparse(new_url_parts)\n            return str(complete_url)\n        except Exception as e:\n            logger.debug(f\"Error combining URL: {e}\")\n            return None\n\n    @staticmethod\n    def get_mime_type(path_or_url: Union[str, Path], default_type: str = \"application/octet-stream\") -> str:\n        \"\"\"\n        根据文件路径或 URL 获取 MIME 类型，如果无法获取则返回默认类型\n\n        :param path_or_url: 文件路径 (Path) 或 URL (str)\n        :param default_type: 无法获取类型时返回的默认 MIME 类型\n        :return: 获取到的 MIME 类型或默认类型\n        \"\"\"\n        try:\n            # 如果是 Path 类型，转换为字符串\n            if isinstance(path_or_url, Path):\n                path_or_url = str(path_or_url)\n\n            # 尝试根据路径或 URL 获取 MIME 类型\n            mime_type, _ = mimetypes.guess_type(path_or_url)\n            # 如果无法推测到类型，返回默认类型\n            if not mime_type:\n                return default_type\n            return mime_type\n        except Exception as e:\n            logger.debug(f\"Error get_mime_type: {e}\")\n            return default_type\n\n    @staticmethod\n    def quote(s: str) -> str:\n        \"\"\"\n        将字符串编码为 URL 安全的格式\n\n        :param s: 要编码的字符串\n        :return: 编码后的字符串\n        \"\"\"\n        return parse.quote(s)\n\n    @staticmethod\n    def parse_url_params(url: str) -> Optional[Tuple[str, str, int, str]]:\n        \"\"\"\n        解析给定的 URL，并提取协议、主机名、端口和路径信息\n\n        :param url: str\n            需要解析的 URL 字符串\n            可以是完整的 URL（例如：\"http://example.com:8080/path\"）或不带协议的地址（例如：\"example.com:1234\"）\n        :return: Optional[Tuple[str, str, int, str]]\n            - str: 协议（例如：\"http\", \"https\"）\n            - str: 主机名或 IP 地址（例如：\"example.com\", \"192.168.1.1\"）\n            - int: 端口号（例如：80, 443）\n            - str: URL 的路径部分（例如：\"/\", \"/path\"）\n            如果输入地址无效或无法解析，则返回 None\n        \"\"\"\n        try:\n            if not url:\n                return None\n\n            url = UrlUtils.standardize_base_url(host=url)\n            parsed = urlparse(url)\n\n            if not parsed.hostname:\n                return None\n            protocol = parsed.scheme\n            hostname = parsed.hostname\n            port = parsed.port or (443 if protocol == \"https\" else 80)\n            path = parsed.path or \"/\"\n\n            return protocol, hostname, port, path\n        except Exception as e:\n            logger.debug(f\"Error parse_url_params: {e}\")\n            return None\n"
  },
  {
    "path": "app/utils/web.py",
    "content": "from app.utils.http import RequestUtils\n\n\nclass WebUtils:\n\n    @staticmethod\n    def get_location(ip: str):\n        \"\"\"\n        查询IP所属地\n        \"\"\"\n        return WebUtils.get_location1(ip) or WebUtils.get_location2(ip)\n\n    @staticmethod\n    def get_location1(ip: str):\n        \"\"\"\n        https://api.mir6.com/api/ip\n        {\n            \"code\": 200,\n            \"msg\": \"success\",\n            \"data\": {\n                \"ip\": \"240e:97c:2f:1::5c\",\n                \"dec\": \"47925092370311863177116789888333643868\",\n                \"country\": \"中国\",\n                \"countryCode\": \"CN\",\n                \"province\": \"广东省\",\n                \"city\": \"广州市\",\n                \"districts\": \"\",\n                \"idc\": \"\",\n                \"isp\": \"中国电信\",\n                \"net\": \"数据中心\",\n                \"zipcode\": \"510000\",\n                \"areacode\": \"020\",\n                \"protocol\": \"IPv6\",\n                \"location\": \"中国[CN] 广东省 广州市\",\n                \"myip\": \"125.89.7.89\",\n                \"time\": \"2023-09-01 17:28:23\"\n            }\n        }\n        \"\"\"\n        try:\n            r = RequestUtils().get_res(f\"https://api.mir6.com/api/ip?ip={ip}&type=json\")\n            if r:\n                return r.json().get(\"data\", {}).get(\"location\") or ''\n        except Exception as err:\n            print(str(err))\n        return \"\"\n\n    @staticmethod\n    def get_location2(ip: str):\n        \"\"\"\n        https://whois.pconline.com.cn/ipJson.jsp?json=true&ip=\n        {\n          \"ip\": \"122.8.12.22\",\n          \"pro\": \"上海市\",\n          \"proCode\": \"310000\",\n          \"city\": \"上海市\",\n          \"cityCode\": \"310000\",\n          \"region\": \"\",\n          \"regionCode\": \"0\",\n          \"addr\": \"上海市 铁通\",\n          \"regionNames\": \"\",\n          \"err\": \"\"\n        }\n        \"\"\"\n        try:\n            r = RequestUtils().get_res(f\"https://whois.pconline.com.cn/ipJson.jsp?json=true&ip={ip}\")\n            if r:\n                return r.json().get(\"addr\") or ''\n        except Exception as err:\n            print(str(err))\n        return \"\"\n"
  },
  {
    "path": "app/workflow/__init__.py",
    "content": "import threading\nfrom time import sleep\nfrom typing import Dict, Any, Optional\nfrom typing import List, Tuple\n\nfrom app.core.config import global_vars\nfrom app.core.event import eventmanager, Event\nfrom app.db.models import Workflow\nfrom app.db.workflow_oper import WorkflowOper\nfrom app.helper.module import ModuleHelper\nfrom app.log import logger\nfrom app.schemas import ActionContext, Action\nfrom app.schemas.types import EventType\nfrom app.utils.singleton import Singleton\n\n\nclass WorkFlowManager(metaclass=Singleton):\n    \"\"\"\n    工作流管理器\n    \"\"\"\n\n    def __init__(self):\n        # 所有动作定义\n        self._lock = threading.Lock()\n        self._actions: Dict[str, Any] = {}\n        self._event_workflows: Dict[str, List[int]] = {}\n        self.init()\n\n    def init(self):\n        \"\"\"\n        初始化\n        \"\"\"\n\n        def filter_func(obj: Any):\n            \"\"\"\n            过滤函数，确保只加载新定义的类\n            \"\"\"\n            if not isinstance(obj, type):\n                return False\n            if not hasattr(obj, 'execute') or not hasattr(obj, \"name\"):\n                return False\n            if obj.__name__ == \"BaseAction\":\n                return False\n            return obj.__module__.startswith(\"app.workflow.actions\")\n\n        # 加载所有动作\n        self._actions = {}\n        actions = ModuleHelper.load(\n            \"app.workflow.actions\",\n            filter_func=lambda _, obj: filter_func(obj)\n        )\n        for action in actions:\n            logger.debug(f\"加载动作: {action.__name__}\")\n            try:\n                self._actions[action.__name__] = action\n            except Exception as err:\n                logger.error(f\"加载动作失败: {action.__name__} - {err}\")\n\n        # 加载工作流事件触发器\n        self.load_workflow_events()\n\n    def stop(self):\n        \"\"\"\n        停止\n        \"\"\"\n        self._actions = {}\n        self._event_workflows = {}\n\n    def excute(self, workflow_id: int, action: Action,\n               context: ActionContext = None) -> Tuple[bool, str, ActionContext]:\n        \"\"\"\n        执行工作流动作\n        \"\"\"\n        if not context:\n            context = ActionContext()\n        if action.type in self._actions:\n            # 实例化之前，清理掉类对象的数据\n\n            # 实例化\n            action_obj = self._actions[action.type](action.id)\n            # 执行\n            logger.info(f\"执行动作: {action.id} - {action.name}\")\n            try:\n                result_context = action_obj.execute(workflow_id, action.data, context)\n            except Exception as err:\n                logger.error(f\"{action.name} 执行失败: {err}\")\n                return False, f\"{err}\", context\n            loop = action.data.get(\"loop\")\n            loop_interval = action.data.get(\"loop_interval\")\n            if loop and loop_interval:\n                while not action_obj.done:\n                    if global_vars.is_workflow_stopped(workflow_id):\n                        break\n                    # 等待\n                    logger.info(f\"{action.name} 等待 {loop_interval} 秒后继续执行 ...\")\n                    sleep(loop_interval)\n                    # 执行\n                    logger.info(f\"继续执行动作: {action.id} - {action.name}\")\n                    result_context = action_obj.execute(workflow_id, action.data, result_context)\n            if action_obj.success:\n                logger.info(f\"{action.name} 执行成功\")\n            else:\n                logger.error(f\"{action.name} 执行失败！\")\n            return action_obj.success, action_obj.message, result_context\n        else:\n            logger.error(f\"未找到动作: {action.type} - {action.name}\")\n            return False, \" \", context\n\n    def list_actions(self) -> List[dict]:\n        \"\"\"\n        获取所有动作\n        \"\"\"\n        return [\n            {\n                \"type\": key,\n                \"name\": action.name,\n                \"description\": action.description,\n                \"data\": {\n                    \"label\": action.name,\n                    **action.data\n                }\n            } for key, action in self._actions.items()\n        ]\n\n    def update_workflow_event(self, workflow: Workflow):\n        \"\"\"\n        更新工作流事件触发器\n        \"\"\"\n        # 确保先移除旧的事件监听器\n        self.remove_workflow_event(workflow_id=workflow.id, event_type_str=workflow.event_type)\n        # 如果工作流是事件触发类型且未被禁用\n        if workflow.trigger_type == \"event\" and workflow.state != 'P':\n            # 注册事件触发器\n            self.register_workflow_event(workflow.id, workflow.event_type)\n\n    def load_workflow_events(self, workflow_id: Optional[int] = None):\n        \"\"\"\n        加载工作流触发事件\n        \"\"\"\n        workflows = []\n        if workflow_id:\n            workflow = WorkflowOper().get(workflow_id)\n            if workflow:\n                workflows = [workflow]\n        else:\n            workflows = WorkflowOper().get_event_triggered_workflows()\n        try:\n            for workflow in workflows:\n                self.update_workflow_event(workflow)\n        except Exception as e:\n            logger.error(f\"加载事件触发工作流失败: {e}\")\n\n    def register_workflow_event(self, workflow_id: int, event_type_str: str):\n        \"\"\"\n        注册工作流事件触发器\n        \"\"\"\n        try:\n            event_type = EventType(event_type_str)\n        except ValueError:\n            logger.error(f\"无效的事件类型: {event_type_str}\")\n            return\n        if event_type in EventType:\n            # 确保先移除旧的事件监听器\n            self.remove_workflow_event(workflow_id, event_type.value)\n            with self._lock:\n                # 添加新的事件监听器\n                eventmanager.add_event_listener(event_type, self._handle_event)\n                # 记录工作流事件触发器\n                if event_type.value not in self._event_workflows:\n                    self._event_workflows[event_type.value] = []\n                self._event_workflows[event_type.value].append(workflow_id)\n                logger.info(f\"已注册工作流 {workflow_id} 事件触发器: {event_type.value}\")\n\n    def remove_workflow_event(self, workflow_id: int, event_type_str: str):\n        \"\"\"\n        移除工作流事件触发器\n        \"\"\"\n        try:\n            event_type = EventType(event_type_str)\n        except ValueError:\n            logger.error(f\"无效的事件类型: {event_type_str}\")\n            return\n        if event_type in EventType:\n            with self._lock:\n                eventmanager.remove_event_listener(event_type, self._handle_event)\n                if event_type.value in self._event_workflows:\n                    if workflow_id in self._event_workflows[event_type.value]:\n                        self._event_workflows[event_type.value].remove(workflow_id)\n                        if not self._event_workflows[event_type.value]:\n                            del self._event_workflows[event_type.value]\n                logger.info(f\"已移除工作流 {workflow_id} 事件触发器\")\n\n    def _handle_event(self, event: Event):\n        \"\"\"\n        处理事件，触发相应的工作流\n        \"\"\"\n        try:\n            event_type_str = str(event.event_type.value)\n            with self._lock:\n                if event_type_str not in self._event_workflows:\n                    return\n                workflow_ids = self._event_workflows[event_type_str].copy()\n            for workflow_id in workflow_ids:\n                self._trigger_workflow(workflow_id, event)\n        except Exception as e:\n            logger.error(f\"处理工作流事件失败: {e}\")\n\n    def _trigger_workflow(self, workflow_id: int, event: Event):\n        \"\"\"\n        触发工作流执行\n        \"\"\"\n        try:\n            # 检查工作流是否存在且启用\n            workflow = WorkflowOper().get(workflow_id)\n            if not workflow or workflow.state == 'P':\n                return\n\n            # 检查事件条件\n            if not self._check_event_conditions(workflow, event):\n                logger.debug(f\"工作流 {workflow.name} 事件条件不匹配，跳过执行\")\n                return\n\n            # 检查工作流是否正在运行\n            if workflow.state == 'R':\n                logger.warning(f\"工作流 {workflow.name} 正在运行中，跳过重复触发\")\n                return\n\n            logger.info(f\"事件 {event.event_type.value} 触发工作流: {workflow.name}\")\n\n            # 发送工作流执行事件以启动工作流\n            eventmanager.send_event(EventType.WorkflowExecute, {\n                \"workflow_id\": workflow_id,\n            })\n\n        except Exception as e:\n            logger.error(f\"触发工作流 {workflow_id} 失败: {e}\")\n\n    def _check_event_conditions(self, workflow, event: Event) -> bool:\n        \"\"\"\n        检查事件是否满足工作流的触发条件\n        \"\"\"\n        if not workflow.event_conditions:\n            return True\n\n        conditions = workflow.event_conditions\n        event_data = event.event_data or {}\n\n        # 检查字段匹配条件\n        for field, expected_value in conditions.items():\n            if field not in event_data:\n                return False\n            actual_value = event_data[field]\n            # 支持多种条件匹配方式\n            if isinstance(expected_value, dict):\n                # 复杂条件匹配\n                if not self._check_complex_condition(actual_value, expected_value):\n                    return False\n            else:\n                # 简单值匹配\n                if actual_value != expected_value:\n                    return False\n        return True\n\n    @staticmethod\n    def _check_complex_condition(actual_value: any, condition: dict) -> bool:\n        \"\"\"\n        检查复杂条件匹配\n        支持的操作符：equals, not_equals, contains, not_contains, in, not_in, regex\n        \"\"\"\n        for operator, expected_value in condition.items():\n            if operator == \"equals\":\n                if actual_value != expected_value:\n                    return False\n            elif operator == \"not_equals\":\n                if actual_value == expected_value:\n                    return False\n            elif operator == \"contains\":\n                if expected_value not in str(actual_value):\n                    return False\n            elif operator == \"not_contains\":\n                if expected_value in str(actual_value):\n                    return False\n            elif operator == \"in\":\n                if actual_value not in expected_value:\n                    return False\n            elif operator == \"not_in\":\n                if actual_value in expected_value:\n                    return False\n            elif operator == \"regex\":\n                import re\n                if not re.search(expected_value, str(actual_value)):\n                    return False\n        return True\n\n    def get_event_workflows(self) -> dict:\n        \"\"\"\n        获取所有事件触发的工作流\n        \"\"\"\n        with self._lock:\n            return self._event_workflows.copy()\n"
  },
  {
    "path": "app/workflow/actions/__init__.py",
    "content": "from abc import ABC, abstractmethod\nfrom typing import Union\n\nfrom app.chain import ChainBase\nfrom app.db.systemconfig_oper import SystemConfigOper\nfrom app.schemas import ActionContext, ActionParams\n\n\nclass ActionChain(ChainBase):\n    pass\n\n\nclass BaseAction(ABC):\n    \"\"\"\n    工作流动作基类\n    \"\"\"\n\n    # 动作ID\n    _action_id = None\n    # 完成标志\n    _done_flag = False\n    # 执行信息\n    _message = \"\"\n    # 缓存键值\n    _cache_key = \"WorkflowCache-%s\"\n\n    def __init__(self, action_id: str):\n        self._action_id = action_id\n        self.systemconfigoper = SystemConfigOper()\n\n    @classmethod\n    @property\n    @abstractmethod\n    def name(cls) -> str:  # noqa\n        pass\n\n    @classmethod\n    @property\n    @abstractmethod\n    def description(cls) -> str:  # noqa\n        pass\n\n    @classmethod\n    @property\n    @abstractmethod\n    def data(cls) -> dict:  # noqa\n        pass\n\n    @property\n    def done(self) -> bool:\n        \"\"\"\n        判断动作是否完成\n        \"\"\"\n        return self._done_flag\n\n    @property\n    @abstractmethod\n    def success(self) -> bool:\n        \"\"\"\n        判断动作是否成功\n        \"\"\"\n        pass\n\n    @property\n    def message(self) -> str:\n        \"\"\"\n        执行信息\n        \"\"\"\n        return self._message\n\n    def job_done(self, message: str = None):\n        \"\"\"\n        标记动作完成\n        \"\"\"\n        self._message = message\n        self._done_flag = True\n\n    def check_cache(self, workflow_id: int, key: str) -> bool:\n        \"\"\"\n        检查是否处理过\n        \"\"\"\n        workflow_key = self._cache_key % workflow_id\n        workflow_cache = self.systemconfigoper.get(workflow_key) or {}\n        action_cache = workflow_cache.get(self._action_id) or []\n        return key in action_cache\n\n    def save_cache(self, workflow_id: int, data: Union[list, str]):\n        \"\"\"\n        保存缓存\n        \"\"\"\n        workflow_key = self._cache_key % workflow_id\n        workflow_cache = self.systemconfigoper.get(workflow_key) or {}\n        action_cache = workflow_cache.get(self._action_id) or []\n        if isinstance(data, list):\n            action_cache.extend(data)\n        else:\n            action_cache.append(data)\n        workflow_cache[self._action_id] = action_cache\n        self.systemconfigoper.set(workflow_key, workflow_cache)\n\n    @abstractmethod\n    def execute(self, workflow_id: int, params: ActionParams, context: ActionContext) -> ActionContext:\n        \"\"\"\n        执行动作\n        \"\"\"\n        raise NotImplementedError\n"
  },
  {
    "path": "app/workflow/actions/add_download.py",
    "content": "from typing import Optional\n\nfrom pydantic import Field\n\nfrom app.workflow.actions import BaseAction\nfrom app.chain.download import DownloadChain\nfrom app.chain.media import MediaChain\nfrom app.core.config import global_vars\nfrom app.core.metainfo import MetaInfo\nfrom app.log import logger\nfrom app.schemas import ActionParams, ActionContext, DownloadTask, MediaType\n\n\nclass AddDownloadParams(ActionParams):\n    \"\"\"\n    添加下载资源参数\n    \"\"\"\n    downloader: Optional[str] = Field(default=None, description=\"下载器\")\n    save_path: Optional[str] = Field(default=None, description=\"保存路径, 支持<storage>:<path>, 如rclone:/MP, smb:/server/share/Movies等\")\n    labels: Optional[str] = Field(default=None, description=\"标签（,分隔）\")\n    only_lack: Optional[bool] = Field(default=False, description=\"仅下载缺失的资源\")\n\n\nclass AddDownloadAction(BaseAction):\n    \"\"\"\n    添加下载资源\n    \"\"\"\n\n    def __init__(self, action_id: str):\n        super().__init__(action_id)\n        self._added_downloads = []\n        self._has_error = False\n\n    @classmethod\n    @property\n    def name(cls) -> str:  # noqa\n        return \"添加下载\"\n\n    @classmethod\n    @property\n    def description(cls) -> str:  # noqa\n        return \"根据资源列表添加下载任务\"\n\n    @classmethod\n    @property\n    def data(cls) -> dict:  # noqa\n        return AddDownloadParams().model_dump()\n\n    @property\n    def success(self) -> bool:\n        return not self._has_error\n\n    def execute(self, workflow_id: int, params: dict, context: ActionContext) -> ActionContext:\n        \"\"\"\n        将上下文中的torrents添加到下载任务中\n        \"\"\"\n        params = AddDownloadParams(**params)\n        _started = False\n        for t in context.torrents:\n            if global_vars.is_workflow_stopped(workflow_id):\n                break\n            # 检查缓存\n            cache_key = f\"{t.torrent_info.site}-{t.torrent_info.title}\"\n            if self.check_cache(workflow_id, cache_key):\n                logger.info(f\"{t.torrent_info.title} 已添加过下载，跳过\")\n                continue\n            if not t.meta_info:\n                t.meta_info = MetaInfo(title=t.torrent_info.title, subtitle=t.torrent_info.description)\n            if not t.media_info:\n                t.media_info = MediaChain().recognize_media(meta=t.meta_info)\n            if not t.media_info:\n                self._has_error = True\n                logger.warning(f\"{t.torrent_info.title} 未识别到媒体信息，无法下载\")\n                continue\n            if params.only_lack:\n                exists_info = DownloadChain().media_exists(t.media_info)\n                if exists_info:\n                    if t.media_info.type == MediaType.MOVIE:\n                        # 电影\n                        logger.warning(f\"{t.torrent_info.title} 媒体库中已存在，跳过\")\n                        continue\n                    else:\n                        # 电视剧\n                        exists_seasons = exists_info.seasons or {}\n                        if len(t.meta_info.season_list) > 1:\n                            # 多季不下载\n                            logger.warning(f\"{t.meta_info.title} 有多季，跳过\")\n                            continue\n                        else:\n                            exists_episodes = exists_seasons.get(t.meta_info.begin_season)\n                            if exists_episodes:\n                                if set(t.meta_info.episode_list).issubset(exists_episodes):\n                                    logger.warning(\n                                        f\"{t.meta_info.title} 第 {t.meta_info.begin_season} 季第 {t.meta_info.episode_list} 集已存在，跳过\")\n                                    continue\n\n            _started = True\n            did = DownloadChain().download_single(context=t,\n                                                  downloader=params.downloader,\n                                                  save_path=params.save_path,\n                                                  label=params.labels)\n            if did:\n                self._added_downloads.append(did)\n                # 保存缓存\n                self.save_cache(workflow_id, cache_key)\n\n        if self._added_downloads:\n            logger.info(f\"已添加 {len(self._added_downloads)} 个下载任务\")\n            context.downloads.extend(\n                [DownloadTask(download_id=did, downloader=params.downloader) for did in self._added_downloads]\n            )\n        elif _started:\n            self._has_error = True\n\n        self.job_done(f\"已添加 {len(self._added_downloads)} 个下载任务\")\n        return context\n"
  },
  {
    "path": "app/workflow/actions/add_subscribe.py",
    "content": "from app.workflow.actions import BaseAction\nfrom app.chain.subscribe import SubscribeChain\nfrom app.core.config import settings, global_vars\nfrom app.core.context import MediaInfo\nfrom app.db.subscribe_oper import SubscribeOper\nfrom app.log import logger\nfrom app.schemas import ActionParams, ActionContext\n\n\nclass AddSubscribeParams(ActionParams):\n    \"\"\"\n    添加订阅参数\n    \"\"\"\n    pass\n\n\nclass AddSubscribeAction(BaseAction):\n    \"\"\"\n    添加订阅\n    \"\"\"\n\n    def __init__(self, action_id: str):\n        super().__init__(action_id)\n        self._added_subscribes = []\n        self._has_error = False\n\n    @classmethod\n    @property\n    def name(cls) -> str:  # noqa\n        return \"添加订阅\"\n\n    @classmethod\n    @property\n    def description(cls) -> str:  # noqa\n        return \"根据媒体列表添加订阅\"\n\n    @classmethod\n    @property\n    def data(cls) -> dict:  # noqa\n        return AddSubscribeParams().model_dump()\n\n    @property\n    def success(self) -> bool:\n        return not self._has_error\n\n    def execute(self, workflow_id: int, params: dict, context: ActionContext) -> ActionContext:\n        \"\"\"\n        将medias中的信息添加订阅，如果订阅不存在的话\n        \"\"\"\n        _started = False\n        for media in context.medias:\n            if global_vars.is_workflow_stopped(workflow_id):\n                break\n            # 检查缓存\n            cache_key = f\"{media.type}-{media.title}-{media.year}-{media.season}\"\n            if self.check_cache(workflow_id, cache_key):\n                logger.info(f\"{media.title} {media.year} 已添加过订阅，跳过\")\n                continue\n            mediainfo = MediaInfo()\n            mediainfo.from_dict(media.model_dump())\n            subscribechain = SubscribeChain()\n            if subscribechain.exists(mediainfo):\n                logger.info(f\"{media.title} 已存在订阅\")\n                continue\n            # 添加订阅\n            _started = True\n            sid, message = subscribechain.add(mtype=mediainfo.type,\n                                              title=mediainfo.title,\n                                              year=mediainfo.year,\n                                              tmdbid=mediainfo.tmdb_id,\n                                              season=mediainfo.season,\n                                              doubanid=mediainfo.douban_id,\n                                              bangumiid=mediainfo.bangumi_id,\n                                              username=settings.SUPERUSER)\n            if sid:\n                self._added_subscribes.append(sid)\n                # 保存缓存\n                self.save_cache(workflow_id, cache_key)\n\n        if self._added_subscribes:\n            logger.info(f\"已添加 {len(self._added_subscribes)} 个订阅\")\n            for sid in self._added_subscribes:\n                context.subscribes.append(SubscribeOper().get(sid))\n        elif _started:\n            self._has_error = True\n\n        self.job_done(f\"已添加 {len(self._added_subscribes)} 个订阅\")\n        return context\n"
  },
  {
    "path": "app/workflow/actions/fetch_downloads.py",
    "content": "from app.workflow.actions import BaseAction, ActionChain\nfrom app.core.config import global_vars\nfrom app.schemas import ActionParams, ActionContext\nfrom app.log import logger\n\n\nclass FetchDownloadsParams(ActionParams):\n    \"\"\"\n    获取下载任务参数\n    \"\"\"\n    pass\n\n\nclass FetchDownloadsAction(BaseAction):\n    \"\"\"\n    获取下载任务\n    \"\"\"\n\n    def __init__(self, action_id: str):\n        super().__init__(action_id)\n        self._downloads = []\n\n    @classmethod\n    @property\n    def name(cls) -> str: # noqa\n        return \"获取下载任务\"\n\n    @classmethod\n    @property\n    def description(cls) -> str: # noqa\n        return \"获取下载队列中的任务状态\"\n\n    @classmethod\n    @property\n    def data(cls) -> dict: # noqa\n        return FetchDownloadsParams().model_dump()\n\n    @property\n    def success(self) -> bool:\n        return self.done\n\n    def execute(self, workflow_id: int, params: dict, context: ActionContext) -> ActionContext:\n        \"\"\"\n        更新downloads中的下载任务状态\n        \"\"\"\n        __all_complete = False\n        for download in self._downloads:\n            if global_vars.is_workflow_stopped(workflow_id):\n                break\n            logger.info(f\"获取下载任务 {download.download_id} 状态 ...\")\n            torrents = ActionChain().list_torrents(hashs=[download.download_id])\n            if not torrents:\n                download.completed = True\n                continue\n            for t in torrents:\n                download.path = t.path\n                if t.progress >= 100:\n                    logger.info(f\"下载任务 {download.download_id} 已完成\")\n                    download.completed = True\n                else:\n                    logger.info(f\"下载任务 {download.download_id} 未完成\")\n                    download.completed = False\n        if all([d.completed for d in self._downloads]):\n            self.job_done()\n        return context\n"
  },
  {
    "path": "app/workflow/actions/fetch_medias.py",
    "content": "from typing import List, Optional\n\nfrom pydantic import Field\n\nfrom app.workflow.actions import BaseAction\nfrom app.chain.recommend import RecommendChain\nfrom app.schemas import ActionParams, ActionContext\nfrom app.core.config import settings, global_vars\nfrom app.core.event import eventmanager\nfrom app.log import logger\nfrom app.schemas import RecommendSourceEventData, MediaInfo\nfrom app.schemas.types import ChainEventType\nfrom app.utils.http import RequestUtils\n\n\nclass FetchMediasParams(ActionParams):\n    \"\"\"\n    获取媒体数据参数\n    \"\"\"\n    source_type: Optional[str] = Field(default=\"ranking\", description=\"来源\")\n    sources: Optional[List[str]] = Field(default=[], description=\"榜单\")\n    api_path: Optional[str] = Field(default=None, description=\"API路径\")\n\n\nclass FetchMediasAction(BaseAction):\n    \"\"\"\n    获取媒体数据\n    \"\"\"\n\n    def __init__(self, action_id: str):\n        super().__init__(action_id)\n\n        self._medias = []\n        self._has_error = False\n        self.__inner_sources = [\n            {\n                \"func\": RecommendChain().tmdb_trending,\n                \"name\": '流行趋势',\n                \"api_path\": \"recommend/tmdb_trending\"\n            },\n            {\n                \"func\": RecommendChain().douban_movie_showing,\n                \"name\": '正在热映',\n                \"api_path\": \"recommend/douban_showing\"\n            },\n            {\n                \"func\": RecommendChain().bangumi_calendar,\n                \"name\": 'Bangumi每日放送',\n                \"api_path\": \"recommend/bangumi_calendar\"\n            },\n            {\n                \"func\": RecommendChain().tmdb_movies,\n                \"name\": 'TMDB热门电影',\n                \"api_path\": \"recommend/tmdb_movies\"\n            },\n            {\n                \"func\": RecommendChain().tmdb_tvs,\n                \"name\": 'TMDB热门电视剧',\n                \"api_path\": \"recommend/tmdb_tvs?with_original_language=zh|en|ja|ko\"\n            },\n            {\n                \"func\": RecommendChain().douban_movie_hot,\n                \"name\": '豆瓣热门电影',\n                \"api_path\": \"recommend/douban_movie_hot\"\n            },\n            {\n                \"func\": RecommendChain().douban_tv_hot,\n                \"name\": '豆瓣热门电视剧',\n                \"api_path\": \"recommend/douban_tv_hot\"\n            },\n            {\n                \"func\": RecommendChain().douban_tv_animation,\n                \"name\": '豆瓣热门动漫',\n                \"api_path\": \"recommend/douban_tv_animation\"\n            },\n            {\n                \"func\": RecommendChain().douban_movies,\n                \"name\": '豆瓣最新电影',\n                \"api_path\": \"recommend/douban_movies\"\n            },\n            {\n                \"func\": RecommendChain().douban_tvs,\n                \"name\": '豆瓣最新电视剧',\n                \"api_path\": \"recommend/douban_tvs\"\n            },\n            {\n                \"func\": RecommendChain().douban_movie_top250,\n                \"name\": '豆瓣电影TOP250',\n                \"api_path\": \"recommend/douban_movie_top250\"\n            },\n            {\n                \"func\": RecommendChain().douban_tv_weekly_chinese,\n                \"name\": '豆瓣国产剧集榜',\n                \"api_path\": \"recommend/douban_tv_weekly_chinese\"\n            },\n            {\n                \"func\": RecommendChain().douban_tv_weekly_global,\n                \"name\": '豆瓣全球剧集榜',\n                \"api_path\": \"recommend/douban_tv_weekly_global\"\n            }\n        ]\n\n        # 广播事件，请示额外的推荐数据源支持\n        event_data = RecommendSourceEventData()\n        event = eventmanager.send_event(ChainEventType.RecommendSource, event_data)\n        # 使用事件返回的上下文数据\n        if event and event.event_data:\n            event_data: RecommendSourceEventData = event.event_data\n            if event_data.extra_sources:\n                self.__inner_sources.extend([s.model_dump() for s in event_data.extra_sources])\n\n    @classmethod\n    @property\n    def name(cls) -> str: # noqa\n        return \"获取媒体数据\"\n\n    @classmethod\n    @property\n    def description(cls) -> str: # noqa\n        return \"获取榜单等媒体数据列表\"\n\n    @classmethod\n    @property\n    def data(cls) -> dict: # noqa\n        return FetchMediasParams().model_dump()\n\n    @property\n    def success(self) -> bool:\n        return not self._has_error\n\n    def __get_source(self, source: str):\n        \"\"\"\n        获取数据源\n        \"\"\"\n        for s in self.__inner_sources:\n            if s['api_path'] == source:\n                return s\n        return None\n\n    def execute(self, workflow_id: int, params: dict, context: ActionContext) -> ActionContext:\n        \"\"\"\n        获取媒体数据，填充到medias\n        \"\"\"\n        params = FetchMediasParams(**params)\n        try:\n            if params.source_type == \"ranking\":\n                for api_path in params.sources:\n                    if global_vars.is_workflow_stopped(workflow_id):\n                        break\n                    source = self.__get_source(api_path)\n                    if not source:\n                        continue\n                    logger.info(f\"获取媒体数据 {source} ...\")\n                    name = source.get(\"name\")\n                    results = []\n                    if source.get(\"func\"):\n                        results = source['func']()\n                    else:\n                        # 调用内部API获取数据\n                        api_url = f\"http://127.0.0.1:{settings.PORT}/api/v1/{source['api_path']}?token={settings.API_TOKEN}\"\n                        res = RequestUtils(timeout=15).post_res(api_url)\n                        if res:\n                            results = res.json()\n                    if results:\n                        logger.info(f\"{name} 获取到 {len(results)} 条数据\")\n                        self._medias.extend([MediaInfo(**r) for r in results])\n                    else:\n                        logger.error(f\"{name} 获取数据失败\")\n            else:\n                # 调用内部API获取数据\n                api_url = f\"http://127.0.0.1:{settings.PORT}{params.api_path}?token={settings.API_TOKEN}\"\n                res = RequestUtils(timeout=15).post_res(api_url)\n                if res:\n                    results = res.json()\n                    if results:\n                        logger.info(f\"{params.api_path} 获取到 {len(results)} 条数据\")\n                        self._medias.extend([MediaInfo(**r) for r in results])\n        except Exception as e:\n            logger.error(f\"获取媒体数据失败: {e}\")\n            self._has_error = True\n\n        if self._medias:\n            context.medias.extend(self._medias)\n\n        self.job_done(f\"获取到 {len(self._medias)} 条媒数据\")\n        return context\n"
  },
  {
    "path": "app/workflow/actions/fetch_rss.py",
    "content": "from typing import Optional\n\nfrom pydantic import Field\n\nfrom app.workflow.actions import BaseAction, ActionChain\nfrom app.core.config import settings, global_vars\nfrom app.core.context import Context\nfrom app.core.metainfo import MetaInfo\nfrom app.helper.rss import RssHelper\nfrom app.log import logger\nfrom app.schemas import ActionParams, ActionContext, TorrentInfo\n\n\nclass FetchRssParams(ActionParams):\n    \"\"\"\n    获取RSS资源列表参数\n    \"\"\"\n    url: str = Field(default=None, description=\"RSS地址\")\n    proxy: Optional[bool] = Field(default=False, description=\"是否使用代理\")\n    timeout: Optional[int] = Field(default=15, description=\"超时时间\")\n    content_type: Optional[str] = Field(default=None, description=\"Content-Type\")\n    referer: Optional[str] = Field(default=None, description=\"Referer\")\n    ua: Optional[str] = Field(default=None, description=\"User-Agent\")\n    match_media: Optional[bool] = Field(default=False, description=\"匹配媒体信息\")\n\n\nclass FetchRssAction(BaseAction):\n    \"\"\"\n    获取RSS资源列表\n    \"\"\"\n\n    def __init__(self, action_id: str):\n        super().__init__(action_id)\n        self._rss_torrents = []\n        self._has_error = False\n\n    @classmethod\n    @property\n    def name(cls) -> str:  # noqa\n        return \"获取RSS资源\"\n\n    @classmethod\n    @property\n    def description(cls) -> str:  # noqa\n        return \"订阅RSS地址获取资源\"\n\n    @classmethod\n    @property\n    def data(cls) -> dict:  # noqa\n        return FetchRssParams().model_dump()\n\n    @property\n    def success(self) -> bool:\n        return not self._has_error\n\n    def execute(self, workflow_id: int, params: dict, context: ActionContext) -> ActionContext:\n        \"\"\"\n        请求RSS地址获取数据，并解析为资源列表\n        \"\"\"\n        params = FetchRssParams(**params)\n        if not params.url:\n            return context\n\n        headers = {}\n        if params.content_type:\n            headers[\"Content-Type\"] = params.content_type\n        if params.referer:\n            headers[\"Referer\"] = params.referer\n        if params.ua:\n            headers[\"User-Agent\"] = params.ua\n\n        rss_items = RssHelper().parse(url=params.url,\n                                      proxy=settings.PROXY if params.proxy else None,\n                                      timeout=params.timeout,\n                                      headers=headers)\n        if rss_items is None or rss_items is False:\n            logger.error(f'RSS地址 {params.url} 请求失败！')\n            self._has_error = True\n            return context\n\n        if not rss_items:\n            logger.error(f'RSS地址 {params.url} 未获取到RSS数据！')\n            return context\n\n        # 组装种子\n        for item in rss_items:\n            if global_vars.is_workflow_stopped(workflow_id):\n                break\n            if not item.get(\"title\"):\n                continue\n            torrentinfo = TorrentInfo(\n                title=item.get(\"title\"),\n                enclosure=item.get(\"enclosure\"),\n                page_url=item.get(\"link\"),\n                size=item.get(\"size\"),\n                pubdate=item[\"pubdate\"].strftime(\"%Y-%m-%d %H:%M:%S\") if item.get(\"pubdate\") else None,\n            )\n            meta = MetaInfo(title=torrentinfo.title, subtitle=torrentinfo.description)\n            mediainfo = None\n            if params.match_media:\n                mediainfo = ActionChain().recognize_media(meta)\n                if not mediainfo:\n                    logger.warning(f\"{torrentinfo.title} 未识别到媒体信息\")\n                    continue\n            self._rss_torrents.append(Context(meta_info=meta, media_info=mediainfo, torrent_info=torrentinfo))\n\n        if self._rss_torrents:\n            logger.info(f\"获取到 {len(self._rss_torrents)} 个RSS资源\")\n            context.torrents.extend(self._rss_torrents)\n\n        self.job_done(f\"获取到 {len(self._rss_torrents)} 个资源\")\n        return context\n"
  },
  {
    "path": "app/workflow/actions/fetch_torrents.py",
    "content": "import random\nimport time\nfrom typing import Optional, List\n\nfrom pydantic import Field\n\nfrom app.workflow.actions import BaseAction\nfrom app.chain.search import SearchChain\nfrom app.core.config import global_vars\nfrom app.log import logger\nfrom app.schemas import ActionParams, ActionContext, MediaType\n\n\nclass FetchTorrentsParams(ActionParams):\n    \"\"\"\n    获取站点资源参数\n    \"\"\"\n    search_type: Optional[str] = Field(default=\"keyword\", description=\"搜索类型\")\n    name: Optional[str] = Field(default=None, description=\"资源名称\")\n    year: Optional[str] = Field(default=None, description=\"年份\")\n    type: Optional[str] = Field(default=None, description=\"资源类型 (电影/电视剧)\")\n    season: Optional[int] = Field(default=None, description=\"季度\")\n    sites: Optional[List[int]] = Field(default=[], description=\"站点列表\")\n    match_media: Optional[bool] = Field(default=False, description=\"匹配媒体信息\")\n\n\nclass FetchTorrentsAction(BaseAction):\n    \"\"\"\n    搜索站点资源\n    \"\"\"\n\n    def __init__(self, action_id: str):\n        super().__init__(action_id)\n        self._torrents = []\n\n    @classmethod\n    @property\n    def name(cls) -> str:  # noqa\n        return \"搜索站点资源\"\n\n    @classmethod\n    @property\n    def description(cls) -> str:  # noqa\n        return \"搜索站点种子资源列表\"\n\n    @classmethod\n    @property\n    def data(cls) -> dict:  # noqa\n        return FetchTorrentsParams().model_dump()\n\n    @property\n    def success(self) -> bool:\n        return self.done\n\n    def execute(self, workflow_id: int, params: dict, context: ActionContext) -> ActionContext:\n        \"\"\"\n        搜索站点，获取资源列表\n        \"\"\"\n        params = FetchTorrentsParams(**params)\n        searchchain = SearchChain()\n        if params.search_type == \"keyword\":\n            # 按关键字搜索\n            torrents = searchchain.search_by_title(title=params.name, sites=params.sites)\n            for torrent in torrents:\n                if global_vars.is_workflow_stopped(workflow_id):\n                    break\n                if params.year and torrent.meta_info.year != params.year:\n                    continue\n                if params.type and torrent.media_info and torrent.media_info.type != MediaType(params.type):\n                    continue\n                if params.season and torrent.meta_info.begin_season != params.season:\n                    continue\n                # 识别媒体信息\n                if params.match_media:\n                    torrent.media_info = searchchain.recognize_media(torrent.meta_info)\n                    if not torrent.media_info:\n                        logger.warning(f\"{torrent.torrent_info.title} 未识别到媒体信息\")\n                        continue\n                self._torrents.append(torrent)\n        else:\n            # 搜索媒体列表\n            for media in context.medias:\n                if global_vars.is_workflow_stopped(workflow_id):\n                    break\n                torrents = searchchain.search_by_id(tmdbid=media.tmdb_id,\n                                                    doubanid=media.douban_id,\n                                                    mtype=MediaType(media.type),\n                                                    sites=params.sites)\n                for torrent in torrents:\n                    self._torrents.append(torrent)\n\n                # 随机休眠 5-30秒\n                sleep_time = random.randint(5, 30)\n                logger.info(f\"随机休眠 {sleep_time} 秒 ...\")\n                time.sleep(sleep_time)\n\n        if self._torrents:\n            context.torrents.extend(self._torrents)\n            logger.info(f\"共搜索到 {len(self._torrents)} 条资源\")\n\n        self.job_done(f\"搜索到 {len(self._torrents)} 个资源\")\n        return context\n"
  },
  {
    "path": "app/workflow/actions/filter_medias.py",
    "content": "from typing import Optional\n\nfrom pydantic import Field\n\nfrom app.workflow.actions import BaseAction\nfrom app.core.config import global_vars\nfrom app.log import logger\nfrom app.schemas import ActionParams, ActionContext\n\n\nclass FilterMediasParams(ActionParams):\n    \"\"\"\n    过滤媒体数据参数\n    \"\"\"\n    type: Optional[str] = Field(default=None, description=\"媒体类型 (电影/电视剧)\")\n    vote: Optional[float] = Field(default=None, description=\"评分（支持小数）\")\n    year: Optional[str] = Field(default=None, description=\"年份\")\n\n\nclass FilterMediasAction(BaseAction):\n    \"\"\"\n    过滤媒体数据\n    \"\"\"\n\n    def __init__(self, action_id: str):\n        super().__init__(action_id)\n        self._medias = []\n\n    @classmethod\n    @property\n    def name(cls) -> str: # noqa\n        return \"过滤媒体数据\"\n\n    @classmethod\n    @property\n    def description(cls) -> str: # noqa\n        return \"对媒体数据列表进行过滤\"\n\n    @classmethod\n    @property\n    def data(cls) -> dict: # noqa\n        return FilterMediasParams().model_dump()\n\n    @property\n    def success(self) -> bool:\n        return self.done\n\n    def execute(self, workflow_id: int, params: dict, context: ActionContext) -> ActionContext:\n        \"\"\"\n        过滤medias中媒体数据\n        \"\"\"\n        params = FilterMediasParams(**params)\n        for media in context.medias:\n            if global_vars.is_workflow_stopped(workflow_id):\n                break\n            if params.type and media.type != params.type:\n                continue\n            if params.vote is not None and media.vote_average < params.vote:\n                continue\n            if params.year and media.year != params.year:\n                continue\n            self._medias.append(media)\n\n        logger.info(f\"过滤后剩余 {len(self._medias)} 条媒体数据\")\n\n        context.medias = self._medias\n\n        self.job_done(f\"过滤后剩余 {len(self._medias)} 条媒体数据\")\n        return context\n"
  },
  {
    "path": "app/workflow/actions/filter_torrents.py",
    "content": "from typing import Optional, List\n\nfrom pydantic import Field\n\nfrom app.workflow.actions import BaseAction, ActionChain\nfrom app.core.config import global_vars\nfrom app.helper.torrent import TorrentHelper\nfrom app.log import logger\nfrom app.schemas import ActionParams, ActionContext\n\n\nclass FilterTorrentsParams(ActionParams):\n    \"\"\"\n    过滤资源数据参数\n    \"\"\"\n    rule_groups: Optional[List[str]] = Field(default=[], description=\"规则组\")\n    quality: Optional[str] = Field(default=None, description=\"资源质量\")\n    resolution: Optional[str] = Field(default=None, description=\"资源分辨率\")\n    effect: Optional[str] = Field(default=None, description=\"特效\")\n    include: Optional[str] = Field(default=None, description=\"包含规则\")\n    exclude: Optional[str] = Field(default=None, description=\"排除规则\")\n    size: Optional[str] = Field(default=None, description=\"资源大小范围（MB）\")\n\n\nclass FilterTorrentsAction(BaseAction):\n    \"\"\"\n    过滤资源数据\n    \"\"\"\n\n    def __init__(self, action_id: str):\n        super().__init__(action_id)\n        self._torrents = []\n\n    @classmethod\n    @property\n    def name(cls) -> str: # noqa\n        return \"过滤资源\"\n\n    @classmethod\n    @property\n    def description(cls) -> str: # noqa\n        return \"对资源列表数据进行过滤\"\n\n    @classmethod\n    @property\n    def data(cls) -> dict: # noqa\n        return FilterTorrentsParams().model_dump()\n\n    @property\n    def success(self) -> bool:\n        return self.done\n\n    def execute(self, workflow_id: int, params: dict, context: ActionContext) -> ActionContext:\n        \"\"\"\n        过滤torrents中的资源\n        \"\"\"\n        params = FilterTorrentsParams(**params)\n        for torrent in context.torrents:\n            if global_vars.is_workflow_stopped(workflow_id):\n                break\n            if TorrentHelper().filter_torrent(\n                    torrent_info=torrent.torrent_info,\n                    filter_params={\n                        \"quality\": params.quality,\n                        \"resolution\": params.resolution,\n                        \"effect\": params.effect,\n                        \"include\": params.include,\n                        \"exclude\": params.exclude,\n                        \"size\": params.size\n                    }\n            ):\n                if ActionChain().filter_torrents(\n                        rule_groups=params.rule_groups,\n                        torrent_list=[torrent.torrent_info],\n                        mediainfo=torrent.media_info\n                ):\n                    self._torrents.append(torrent)\n\n        logger.info(f\"过滤后剩余 {len(self._torrents)} 个资源\")\n\n        context.torrents = self._torrents\n\n        self.job_done(f\"过滤后剩余 {len(self._torrents)} 个资源\")\n        return context\n"
  },
  {
    "path": "app/workflow/actions/invoke_plugin.py",
    "content": "from pydantic import Field\n\nfrom app.workflow.actions import BaseAction\nfrom app.core.plugin import PluginManager\nfrom app.log import logger\nfrom app.schemas import ActionParams, ActionContext\n\n\nclass InvokePluginParams(ActionParams):\n    \"\"\"\n    调用插件动作参数\n    \"\"\"\n    plugin_id: str = Field(default=None, description=\"插件ID\")\n    action_id: str = Field(default=None, description=\"动作ID\")\n    action_params: dict = Field(default={}, description=\"动作参数\")\n\n\nclass InvokePluginAction(BaseAction):\n    \"\"\"\n    调用插件\n    \"\"\"\n\n    def __init__(self, action_id: str):\n        super().__init__(action_id)\n        self._success = False\n\n    @classmethod\n    @property\n    def name(cls) -> str: # noqa\n        return \"调用插件\"\n\n    @classmethod\n    @property\n    def description(cls) -> str: # noqa\n        return \"调用插件提供的动作\"\n\n    @classmethod\n    @property\n    def data(cls) -> dict: # noqa\n        return InvokePluginParams().model_dump()\n\n    @property\n    def success(self) -> bool:\n        return self._success\n\n    def execute(self, workflow_id: int, params: dict, context: ActionContext) -> ActionContext:\n        \"\"\"\n        执行插件定义的动作\n        \"\"\"\n        params = InvokePluginParams(**params)\n        if not params.plugin_id or not params.action_id:\n            return context\n        try:\n            plugin_actions = PluginManager().get_plugin_actions(params.plugin_id)\n            if not plugin_actions:\n                logger.error(f\"插件不存在: {params.plugin_id}\")\n                return context\n            actions = plugin_actions[0].get(\"actions\", [])\n            action = next((action for action in actions if action.get(\"action_id\") == params.action_id), None)\n            if not action or not action.get(\"func\"):\n                logger.error(f\"插件动作不存在: {params.plugin_id} - {params.action_id}\")\n                return context\n            # 执行插件动作\n            self._success, context = action[\"func\"](context, **params.action_params)\n        except Exception as e:\n            self._success = False\n            logger.error(f\"调用插件动作失败: {e}\")\n            return context\n        self.job_done()\n        return context\n"
  },
  {
    "path": "app/workflow/actions/note.py",
    "content": "from app.workflow.actions import BaseAction\nfrom app.schemas import ActionContext\n\n\nclass NoteAction(BaseAction):\n    \"\"\"\n    备注\n    \"\"\"\n\n    @classmethod\n    @property\n    def name(cls) -> str: # noqa\n        return \"备注\"\n\n    @classmethod\n    @property\n    def description(cls) -> str: # noqa\n        return \"给工作流添加备注\"\n\n    @classmethod\n    @property\n    def data(cls) -> dict: # noqa\n        return {}\n\n    @property\n    def success(self) -> bool:\n        return True\n\n    def execute(self, workflow_id: int, params: dict, context: ActionContext) -> ActionContext:\n        return context\n"
  },
  {
    "path": "app/workflow/actions/scan_file.py",
    "content": "from pathlib import Path\nfrom typing import Optional\n\nfrom pydantic import Field\n\nfrom app.workflow.actions import BaseAction\nfrom app.chain.storage import StorageChain\nfrom app.core.config import global_vars, settings\nfrom app.log import logger\nfrom app.schemas import ActionParams, ActionContext\n\n\nclass ScanFileParams(ActionParams):\n    \"\"\"\n    整理文件参数\n    \"\"\"\n    # 存储\n    storage: Optional[str] = Field(default=\"local\", description=\"存储\")\n    directory: Optional[str] = Field(default=None, description=\"目录\")\n\n\nclass ScanFileAction(BaseAction):\n    \"\"\"\n    整理文件\n    \"\"\"\n\n    def __init__(self, action_id: str):\n        super().__init__(action_id)\n        self._fileitems = []\n        self._has_error = False\n\n    @classmethod\n    @property\n    def name(cls) -> str: # noqa\n        return \"扫描目录\"\n\n    @classmethod\n    @property\n    def description(cls) -> str: # noqa\n        return \"扫描目录文件到队列\"\n\n    @classmethod\n    @property\n    def data(cls) -> dict: # noqa\n        return ScanFileParams().model_dump()\n\n    @property\n    def success(self) -> bool:\n        return not self._has_error\n\n    def execute(self, workflow_id: int, params: dict, context: ActionContext) -> ActionContext:\n        \"\"\"\n        扫描目录中的所有文件，记录到fileitems\n        \"\"\"\n        params = ScanFileParams(**params)\n        if not params.storage or not params.directory:\n            return context\n        storagechain = StorageChain()\n        fileitem = storagechain.get_file_item(params.storage, Path(params.directory))\n        if not fileitem:\n            logger.error(f\"目录不存在: 【{params.storage}】{params.directory}\")\n            self._has_error = True\n            return context\n        files = storagechain.list_files(fileitem, recursion=True)\n        for file in files:\n            if global_vars.is_workflow_stopped(workflow_id):\n                break\n            media_exts = settings.RMT_MEDIAEXT + settings.RMT_SUBEXT + settings.RMT_AUDIOEXT\n            if not file.extension or f\".{file.extension.lower()}\" not in media_exts:\n                continue\n            # 添加文件到队列，而不是目录\n            self._fileitems.append(file)\n\n        if self._fileitems:\n            context.fileitems.extend(self._fileitems)\n\n        self.job_done(f\"扫描到 {len(self._fileitems)} 个文件\")\n        return context\n"
  },
  {
    "path": "app/workflow/actions/scrape_file.py",
    "content": "from pathlib import Path\n\nfrom app.workflow.actions import BaseAction\nfrom app.core.config import global_vars\nfrom app.schemas import ActionParams, ActionContext\nfrom app.chain.media import MediaChain\nfrom app.chain.storage import StorageChain\nfrom app.core.metainfo import MetaInfoPath\nfrom app.log import logger\n\n\nclass ScrapeFileParams(ActionParams):\n    \"\"\"\n    刮削文件参数\n    \"\"\"\n    pass\n\n\nclass ScrapeFileAction(BaseAction):\n    \"\"\"\n    刮削文件\n    \"\"\"\n\n    def __init__(self, action_id: str):\n        super().__init__(action_id)\n        self._scraped_files = []\n        self._has_error = False\n\n    @classmethod\n    @property\n    def name(cls) -> str: # noqa\n        return \"刮削文件\"\n\n    @classmethod\n    @property\n    def description(cls) -> str: # noqa\n        return \"刮削媒体信息和图片\"\n\n    @classmethod\n    @property\n    def data(cls) -> dict: # noqa\n        return ScrapeFileParams().model_dump()\n\n    @property\n    def success(self) -> bool:\n        return not self._has_error\n\n    def execute(self, workflow_id: int, params: dict, context: ActionContext) -> ActionContext:\n        \"\"\"\n        刮削fileitems中的所有文件\n        \"\"\"\n        # 失败次数\n        _failed_count = 0\n        for fileitem in context.fileitems:\n            if global_vars.is_workflow_stopped(workflow_id):\n                break\n            if fileitem in self._scraped_files:\n                continue\n            if not StorageChain().exists(fileitem):\n                continue\n            # 检查缓存\n            cache_key = f\"{fileitem.path}\"\n            if self.check_cache(workflow_id, cache_key):\n                logger.info(f\"{fileitem.path} 已刮削过，跳过\")\n                continue\n            meta = MetaInfoPath(Path(fileitem.path))\n            mediachain = MediaChain()\n            mediainfo = mediachain.recognize_media(meta)\n            if not mediainfo:\n                _failed_count += 1\n                logger.info(f\"{fileitem.path} 未识别到媒体信息，无法刮削\")\n                continue\n            mediachain.scrape_metadata(fileitem=fileitem, meta=meta, mediainfo=mediainfo)\n            self._scraped_files.append(fileitem)\n            # 保存缓存\n            self.save_cache(workflow_id, cache_key)\n\n        if not self._scraped_files and _failed_count:\n            self._has_error = True\n\n        self.job_done(f\"成功刮削 {len(self._scraped_files)} 个文件，失败 {_failed_count} 个\")\n        return context\n"
  },
  {
    "path": "app/workflow/actions/send_event.py",
    "content": "from app.workflow.actions import BaseAction\nfrom app.core.event import eventmanager\nfrom app.schemas import ActionParams, ActionContext\nfrom app.schemas.types import ChainEventType\n\n\nclass SendEventParams(ActionParams):\n    \"\"\"\n    发送事件参数\n    \"\"\"\n    pass\n\n\nclass SendEventAction(BaseAction):\n    \"\"\"\n    发送事件\n    \"\"\"\n\n    @classmethod\n    @property\n    def name(cls) -> str: # noqa\n        return \"发送事件\"\n\n    @classmethod\n    @property\n    def description(cls) -> str: # noqa\n        return \"发送任务执行事件\"\n\n    @classmethod\n    @property\n    def data(cls) -> dict: # noqa\n        return SendEventParams().model_dump()\n\n    @property\n    def success(self) -> bool:\n        return self.done\n\n    def execute(self, workflow_id: int, params: dict, context: ActionContext) -> ActionContext:\n        \"\"\"\n        发送工作流事件，以更插件干预工作流执行\n        \"\"\"\n        # 触发资源下载事件，更新执行上下文\n        event = eventmanager.send_event(ChainEventType.WorkflowExecution, context)\n        if event and event.event_data:\n            context = event.event_data\n\n        self.job_done()\n        return context\n"
  },
  {
    "path": "app/workflow/actions/send_message.py",
    "content": "from typing import List, Optional, Union\n\nfrom pydantic import Field\n\nfrom app.workflow.actions import BaseAction, ActionChain\nfrom app.schemas import ActionParams, ActionContext, Notification\nfrom app.core.config import settings\n\n\nclass SendMessageParams(ActionParams):\n    \"\"\"\n    发送消息参数\n    \"\"\"\n    client: Optional[List[str]] = Field(default=[], description=\"消息渠道\")\n    userid: Optional[Union[str, int]] = Field(default=None, description=\"用户ID\")\n\n\nclass SendMessageAction(BaseAction):\n    \"\"\"\n    发送消息\n    \"\"\"\n\n    def __init__(self, action_id: str):\n        super().__init__(action_id)\n\n    @classmethod\n    @property\n    def name(cls) -> str: # noqa\n        return \"发送消息\"\n\n    @classmethod\n    @property\n    def description(cls) -> str: # noqa\n        return \"发送任务执行消息\"\n\n    @classmethod\n    @property\n    def data(cls) -> dict: # noqa\n        return SendMessageParams().model_dump()\n\n    @property\n    def success(self) -> bool:\n        return self.done\n\n    def execute(self, workflow_id: int, params: dict, context: ActionContext) -> ActionContext:\n        \"\"\"\n        发送messages中的消息\n        \"\"\"\n        params = SendMessageParams(**params)\n        msg_text = f\"当前进度：{context.progress}%\"\n        index = 1\n        if context.execute_history:\n            for history in context.execute_history:\n                if not history.message:\n                    continue\n                msg_text += f\"\\n{index}. {history.action}：{history.message}\"\n                index += 1\n            # 发送消息\n            if not params.client:\n                params.client = [\"\"]\n            for client in params.client:\n                ActionChain().post_message(\n                    Notification(\n                        source=client,\n                        userid=params.userid,\n                        title=\"【工作流执行结果】\",\n                        text=msg_text,\n                        link=settings.MP_DOMAIN(\"#/workflow\")\n                    )\n                )\n\n        self.job_done()\n        return context\n"
  },
  {
    "path": "app/workflow/actions/transfer_file.py",
    "content": "import copy\nfrom pathlib import Path\nfrom typing import Optional\n\nfrom pydantic import Field\n\nfrom app.workflow.actions import BaseAction\nfrom app.core.config import global_vars\nfrom app.db.transferhistory_oper import TransferHistoryOper\nfrom app.schemas import ActionParams, ActionContext\nfrom app.chain.storage import StorageChain\nfrom app.chain.transfer import TransferChain\nfrom app.log import logger\n\n\nclass TransferFileParams(ActionParams):\n    \"\"\"\n    整理文件参数\n    \"\"\"\n    # 来源\n    source: Optional[str] = Field(default=\"downloads\", description=\"来源\")\n\n\nclass TransferFileAction(BaseAction):\n    \"\"\"\n    整理文件\n    \"\"\"\n\n    def __init__(self, action_id: str):\n        super().__init__(action_id)\n        self._fileitems = []\n        self._has_error = False\n\n    @classmethod\n    @property\n    def name(cls) -> str:  # noqa\n        return \"整理文件\"\n\n    @classmethod\n    @property\n    def description(cls) -> str:  # noqa\n        return \"整理队列中的文件\"\n\n    @classmethod\n    @property\n    def data(cls) -> dict:  # noqa\n        return TransferFileParams().model_dump()\n\n    @property\n    def success(self) -> bool:\n        return not self._has_error\n\n    def execute(self, workflow_id: int, params: dict, context: ActionContext) -> ActionContext:\n        \"\"\"\n        从 downloads / fileitems 中整理文件，记录到fileitems\n        \"\"\"\n\n        def check_continue():\n            \"\"\"\n            检查是否继续整理文件\n            \"\"\"\n            if global_vars.is_workflow_stopped(workflow_id):\n                return False\n            return True\n\n        params = TransferFileParams(**params)\n        # 失败次数\n        _failed_count = 0\n        storagechain = StorageChain()\n        transferchain = TransferChain()\n        transferhis = TransferHistoryOper()\n        if params.source == \"downloads\":\n            # 从下载任务中整理文件\n            for download in context.downloads:\n                if global_vars.is_workflow_stopped(workflow_id):\n                    break\n                if not download.completed:\n                    logger.info(f\"下载任务 {download.download_id} 未完成\")\n                    continue\n                # 检查缓存\n                cache_key = f\"{download.download_id}\"\n                if self.check_cache(workflow_id, cache_key):\n                    logger.info(f\"{download.path} 已整理过，跳过\")\n                    continue\n                fileitem = storagechain.get_file_item(storage=\"local\", path=Path(download.path))\n                if not fileitem:\n                    logger.info(f\"文件 {download.path} 不存在\")\n                    continue\n                transferd = transferhis.get_by_src(fileitem.path, storage=fileitem.storage)\n                if transferd:\n                    # 已经整理过的文件不再整理\n                    continue\n                logger.info(f\"开始整理文件 {download.path} ...\")\n                state, errmsg = transferchain.do_transfer(fileitem, background=False)\n                if not state:\n                    _failed_count += 1\n                    logger.error(f\"整理文件 {download.path} 失败: {errmsg}\")\n                    continue\n                logger.info(f\"整理文件 {download.path} 完成\")\n                self._fileitems.append(fileitem)\n                self.save_cache(workflow_id, cache_key)\n        else:\n            # 从 fileitems 中整理文件\n            for fileitem in copy.deepcopy(context.fileitems):\n                if not check_continue():\n                    break\n                # 检查缓存\n                cache_key = f\"{fileitem.path}\"\n                if self.check_cache(workflow_id, cache_key):\n                    logger.info(f\"{fileitem.path} 已整理过，跳过\")\n                    continue\n                transferd = transferhis.get_by_src(fileitem.path, storage=fileitem.storage)\n                if transferd:\n                    # 已经整理过的文件不再整理\n                    continue\n                logger.info(f\"开始整理文件 {fileitem.path} ...\")\n                state, errmsg = transferchain.do_transfer(fileitem, background=False,\n                                                          continue_callback=check_continue)\n                if not state:\n                    _failed_count += 1\n                    logger.error(f\"整理文件 {fileitem.path} 失败: {errmsg}\")\n                    continue\n                logger.info(f\"整理文件 {fileitem.path} 完成\")\n                # 从 fileitems 中移除已整理的文件\n                context.fileitems.remove(fileitem)\n                self._fileitems.append(fileitem)\n                # 记录已整理的文件\n                self.save_cache(workflow_id, cache_key)\n\n        if self._fileitems:\n            context.fileitems.extend(self._fileitems)\n        elif _failed_count:\n            self._has_error = True\n\n        self.job_done(f\"整理成功 {len(self._fileitems)} 个文件，失败 {_failed_count} 个\")\n        return context\n"
  },
  {
    "path": "config/category.yaml",
    "content": "####### 配置说明 #######\n# 1. 该配置文件用于配置电影和电视剧的分类策略，配置后程序会按照配置的分类策略名称进行分类，配置文件采用yaml格式，需要严格附合语法规则\n# 2. 配置文件中的一级分类名称：`movie`、`tv` 为固定名称不可修改，二级名称同时也是目录名称，会按先后顺序匹配，匹配后程序会按这个名称建立二级目录\n# 3. 支持的分类条件：\n#   `original_language` 语种，具体含义参考下方字典\n#   `production_countries` 国家或地区（电影）、`origin_country` 国家或地区（电视剧），具体含义参考下方字典\n#   `genre_ids` 内容类型，具体含义参考下方字典\n#   `release_year` 发行年份，格式：YYYY，电影实际对应`release_date`字段，电视剧实际对应`first_air_date`字段，支持范围设定，如：`YYYY-YYYY`\n#   themoviedb 详情API返回的其它一级字段\n# 4. 配置多项条件时需要同时满足，一个条件需要匹配多个值是使用`,`分隔\n# 5. !条件值表示排除该值\n\n# 配置电影的分类策略\nmovie:\n  # 分类名同时也是目录名\n  动画电影:\n    # 匹配 genre_ids 内容类型，16是动漫\n    genre_ids: '16'\n  华语电影:\n    # 匹配语种\n    original_language: 'zh,cn,bo,za'\n  # 未匹配以上条件时，分类为外语电影\n  外语电影:\n\n# 配置电视剧的分类策略\ntv:\n  # 分类名同时也是目录名\n  国漫:\n    # 匹配 genre_ids 内容类型，16是动漫\n    genre_ids: '16'\n    # 匹配 origin_country 国家，CN是中国大陆，TW是中国台湾，HK是中国香港\n    origin_country: 'CN,TW,HK'\n  日番:\n    # 匹配 genre_ids 内容类型，16是动漫\n    genre_ids: '16'\n    # 匹配 origin_country 国家，JP是日本\n    origin_country: 'JP'\n  纪录片:\n     # 匹配 genre_ids 内容类型，99是纪录片\n    genre_ids: '99'\n  儿童:\n    # 匹配 genre_ids 内容类型，10762是儿童\n    genre_ids: '10762'\n  综艺:\n    # 匹配 genre_ids 内容类型，10764 10767都是综艺\n    genre_ids: '10764,10767'\n  国产剧:\n    # 匹配 origin_country 国家，CN是中国大陆，TW是中国台湾，HK是中国香港\n    origin_country: 'CN,TW,HK'\n  欧美剧:\n    # 匹配 origin_country 国家，主要欧美国家列表\n    origin_country: 'US,FR,GB,DE,ES,IT,NL,PT,RU,UK'\n  日韩剧:\n    # 匹配 origin_country 国家，主要亚洲国家列表\n    origin_country: 'JP,KP,KR,TH,IN,SG'\n  # 未匹配以上分类，则命名为未分类\n  未分类:\n\n## genre_ids 内容类型 字典，注意部分中英文是不一样的\n#\t28\tAction\n#\t12\tAdventure\n#\t16\tAnimation\n#\t35\tComedy\n#\t80\tCrime\n#\t99\tDocumentary\n#\t18\tDrama\n#\t10751\tFamily\n#\t14\tFantasy\n#\t36\tHistory\n#\t27\tHorror\n#\t10402\tMusic\n#\t9648\tMystery\n#\t10749\tRomance\n#\t878  Science Fiction\n#\t10770\tTV Movie\n#\t53\tThriller\n#\t10752\tWar\n#\t37\tWestern\n#\t28\t动作\n#\t12\t冒险\n#\t16\t动画\n#\t35\t喜剧\n#\t80\t犯罪\n#\t99\t纪录\n#\t18\t剧情\n#\t10751\t家庭\n#\t14\t奇幻\n#\t36\t历史\n#\t27\t恐怖\n#\t10402\t音乐\n#\t9648\t悬疑\n#\t10749\t爱情\n#\t878\t科幻\n#\t10770\t电视电影\n#\t53\t惊悚\n#\t10752\t战争\n#\t37\t西部\n\n## original_language 语种 字典\n#\taf\t南非语\n#\tar\t阿拉伯语\n#\taz\t阿塞拜疆语\n#\tbe\t比利时语\n#\tbg\t保加利亚语\n#\tca\t加泰隆语\n#\tcs\t捷克语\n#\tcy\t威尔士语\n#\tda\t丹麦语\n#\tde\t德语\n#\tdv\t第维埃语\n#\tel\t希腊语\n#\ten\t英语\n#\teo\t世界语\n#\tes\t西班牙语\n#\tet\t爱沙尼亚语\n#\teu\t巴士克语\n#\tfa\t法斯语\n#\tfi\t芬兰语\n#\tfo\t法罗语\n#\tfr\t法语\n#\tgl\t加里西亚语\n#\tgu\t古吉拉特语\n#\the\t希伯来语\n#\thi\t印地语\n#\thr\t克罗地亚语\n#\thu\t匈牙利语\n#\thy\t亚美尼亚语\n#\tid\t印度尼西亚语\n#\tis\t冰岛语\n#\tit\t意大利语\n#\tja\t日语\n#\tka\t格鲁吉亚语\n#\tkk\t哈萨克语\n#\tkn\t卡纳拉语\n#\tko\t朝鲜语\n#\tkok\t孔卡尼语\n#\tky\t吉尔吉斯语\n#\tlt\t立陶宛语\n#\tlv\t拉脱维亚语\n#\tmi\t毛利语\n#\tmk\t马其顿语\n#\tmn\t蒙古语\n#\tmr\t马拉地语\n#\tms\t马来语\n#\tmt\t马耳他语\n#\tnb\t挪威语(伯克梅尔)\n#\tnl\t荷兰语\n#\tns\t北梭托语\n#\tpa\t旁遮普语\n#\tpl\t波兰语\n#\tpt\t葡萄牙语\n#\tqu\t克丘亚语\n#\tro\t罗马尼亚语\n#\tru\t俄语\n#\tsa\t梵文\n#\tse\t北萨摩斯语\n#\tsk\t斯洛伐克语\n#\tsl\t斯洛文尼亚语\n#\tsq\t阿尔巴尼亚语\n#\tsv\t瑞典语\n#\tsw\t斯瓦希里语\n#\tsyr\t叙利亚语\n#\tta\t泰米尔语\n#\tte\t泰卢固语\n#\tth\t泰语\n#\ttl\t塔加路语\n#\ttn\t茨瓦纳语\n#\ttr\t土耳其语\n#\tts\t宗加语\n#\ttt\t鞑靼语\n#\tuk\t乌克兰语\n#\tur\t乌都语\n#\tuz\t乌兹别克语\n#\tvi\t越南语\n#\txh\t班图语\n#\tzh\t中文\n#\tcn\t中文\n#\tzu\t祖鲁语\n\n## origin_country/production_countries 国家地区 字典\n#\tAR\t阿根廷\n#\tAU\t澳大利亚\n#\tBE\t比利时\n#\tBR\t巴西\n#\tCA\t加拿大\n#\tCH\t瑞士\n#\tCL\t智利\n#\tCO\t哥伦比亚\n#\tCZ\t捷克\n#\tDE\t德国\n#\tDK\t丹麦\n#\tEG\t埃及\n#\tES\t西班牙\n#\tFR\t法国\n#\tGR\t希腊\n#\tHK\t香港\n#\tIL\t以色列\n#\tIN\t印度\n#\tIQ\t伊拉克\n#\tIR\t伊朗\n#\tIT\t意大利\n#\tJP\t日本\n#\tMM\t缅甸\n#\tMO\t澳门\n#\tMX\t墨西哥\n#\tMY\t马来西亚\n#\tNL\t荷兰\n#\tNO\t挪威\n#\tPH\t菲律宾\n#\tPK\t巴基斯坦\n#\tPL\t波兰\n#\tRU\t俄罗斯\n#\tSE\t瑞典\n#\tSG\t新加坡\n#\tTH\t泰国\n#\tTR\t土耳其\n#\tUS\t美国\n#\tVN\t越南\n#\tCN\t中国 内地\n#\tGB\t英国\n#\tTW\t中国台湾\n#\tNZ\t新西兰\n#\tSA\t沙特阿拉伯\n#\tLA\t老挝\n#\tKP\t朝鲜 北朝鲜\n#\tKR\t韩国 南朝鲜\n#\tPT\t葡萄牙\n#\tMN\t蒙古国 蒙古\n"
  },
  {
    "path": "database/env.py",
    "content": "from logging.config import fileConfig\n\nfrom sqlalchemy import engine_from_config\nfrom sqlalchemy import pool\n\nfrom alembic import context\n\nfrom app.db import Base\n# this is the Alembic Config object, which provides\n# access to the values within the .ini file in use.\nconfig = context.config\n\n# Interpret the config file for Python logging.\n# This line sets up loggers basically.\nif config.config_file_name is not None:\n    fileConfig(config.config_file_name)\n\n# add your model's MetaData object here\n# for 'autogenerate' support\n# from myapp import mymodel\n# target_metadata = mymodel.Base.metadata\ntarget_metadata = Base.metadata\n\n# other values from the config, defined by the needs of env.py,\n# can be acquired:\n# my_important_option = config.get_main_option(\"my_important_option\")\n# ... etc.\n\n\ndef run_migrations_offline() -> None:\n    \"\"\"Run migrations in 'offline' mode.\n\n    This configures the context with just a URL\n    and not an Engine, though an Engine is acceptable\n    here as well.  By skipping the Engine creation\n    we don't even need a DBAPI to be available.\n\n    Calls to context.execute() here emit the given string to the\n    script output.\n\n    \"\"\"\n    url = config.get_main_option(\"sqlalchemy.url\")\n    \n    # 根据数据库类型配置不同的参数\n    if url and \"postgresql\" in url:\n        # PostgreSQL配置\n        context.configure(\n            url=url,\n            target_metadata=target_metadata,\n            literal_binds=True,\n            dialect_opts={\"paramstyle\": \"named\"},\n        )\n    else:\n        # SQLite配置\n        context.configure(\n            url=url,\n            target_metadata=target_metadata,\n            literal_binds=True,\n            dialect_opts={\"paramstyle\": \"named\"},\n            render_as_batch=True\n        )\n\n    with context.begin_transaction():\n        context.run_migrations()\n\n\ndef run_migrations_online() -> None:\n    \"\"\"Run migrations in 'online' mode.\n\n    In this scenario we need to create an Engine\n    and associate a connection with the context.\n\n    \"\"\"\n    connectable = engine_from_config(\n        config.get_section(config.config_ini_section),\n        prefix=\"sqlalchemy.\",\n        poolclass=pool.NullPool,\n    )\n\n    with connectable.connect() as connection:\n        url = config.get_main_option(\"sqlalchemy.url\")\n        \n        # 根据数据库类型配置不同的参数\n        if url and \"postgresql\" in url:\n            # PostgreSQL配置\n            context.configure(\n                connection=connection, \n                target_metadata=target_metadata\n            )\n        else:\n            # SQLite配置\n            context.configure(\n                connection=connection, \n                target_metadata=target_metadata,\n                render_as_batch=True\n            )\n\n        with context.begin_transaction():\n            context.run_migrations()\n\n\nif context.is_offline_mode():\n    run_migrations_offline()\nelse:\n    run_migrations_online()\n"
  },
  {
    "path": "database/gen.py",
    "content": "import importlib\nfrom pathlib import Path\n\nfrom alembic.config import Config as AlembicConfig\nfrom alembic.command import revision as alembic_revision\n\nfrom app.core.config import settings\n\n# 导入模块，避免建表缺失\nfor module in Path(__file__).with_name(\"models\").glob(\"*.py\"):\n    importlib.import_module(f\"app.db.models.{module.stem}\")\n\ndb_version = input(\"请输入版本号：\")\ndb_location = settings.CONFIG_PATH / 'user.db'\nscript_location = settings.ROOT_PATH / 'database'\nalembic_cfg = AlembicConfig()\nalembic_cfg.set_main_option('script_location', str(script_location))\nalembic_cfg.set_main_option('sqlalchemy.url', f\"sqlite:///{db_location}\")\nalembic_revision(alembic_cfg, db_version, True)\n"
  },
  {
    "path": "database/script.py.mako",
    "content": "\"\"\"${message}\n\nRevision ID: ${up_revision}\nRevises: ${down_revision | comma,n}\nCreate Date: ${create_date}\n\n\"\"\"\nfrom alembic import op\nimport sqlalchemy as sa\n${imports if imports else \"\"}\n\n# revision identifiers, used by Alembic.\nrevision = ${repr(up_revision)}\ndown_revision = ${repr(down_revision)}\nbranch_labels = ${repr(branch_labels)}\ndepends_on = ${repr(depends_on)}\n\n\ndef upgrade() -> None:\n    ${upgrades if upgrades else \"pass\"}\n\n\ndef downgrade() -> None:\n    ${downgrades if downgrades else \"pass\"}\n"
  },
  {
    "path": "database/versions/0fb94bf69b38_2_0_2.py",
    "content": "\"\"\"2.0.2\n\nRevision ID: 0fb94bf69b38\nRevises: 262735d025da\nCreate Date: 2024-09-30 10:03:58.546036\n\n\"\"\"\nimport contextlib\n\nfrom alembic import op\nimport sqlalchemy as sa\n\n\n# revision identifiers, used by Alembic.\nrevision = '0fb94bf69b38'\ndown_revision = '262735d025da'\nbranch_labels = None\ndepends_on = None\n\n\ndef upgrade() -> None:\n    # ### commands auto generated by Alembic - please adjust! ###\n    # 站点数据统计增加站点名称\n    conn = op.get_bind()\n    inspector = sa.inspect(conn)\n    columns = inspector.get_columns('siteuserdata')\n    # 检查 'name' 字段是否已存在\n    if not any(c['name'] == 'name' for c in columns):\n        op.add_column('siteuserdata', sa.Column('name', sa.String(), nullable=True))\n    # ### end Alembic commands ###\n\n\ndef downgrade() -> None:\n    pass\n"
  },
  {
    "path": "database/versions/262735d025da_2_0_1.py",
    "content": "\"\"\"2.0.1\n\nRevision ID: 262735d025da\nRevises: 294b007932ef\nCreate Date: 2024-09-11 08:07:02.753307\n\n\"\"\"\n\nfrom app.db.systemconfig_oper import SystemConfigOper\nfrom app.schemas.types import SystemConfigKey\n\n# revision identifiers, used by Alembic.\nrevision = '262735d025da'\ndown_revision = '294b007932ef'\nbranch_labels = None\ndepends_on = None\n\n\ndef upgrade() -> None:\n    # ### commands auto generated by Alembic - please adjust! ###\n    # 初始化消息通知范围\n    _systemconfig = SystemConfigOper()\n    if not _systemconfig.get(SystemConfigKey.NotificationSwitchs):\n        _systemconfig.set(SystemConfigKey.NotificationSwitchs, [\n            {\n                'type': '资源下载',\n                'action': 'all',\n            },\n            {\n                'type': '整理入库',\n                'action': 'all',\n            },\n            {\n                'type': '订阅',\n                'action': 'all',\n            },\n            {\n                'type': '站点',\n                'action': 'admin',\n            },\n            {\n                'type': '媒体服务器',\n                'action': 'admin',\n            },\n            {\n                'type': '手动处理',\n                'action': 'admin',\n            },\n            {\n                'type': '插件',\n                'action': 'admin',\n            },\n            {\n                'type': '其它',\n                'action': 'admin',\n            },\n        ])\n    # ### end Alembic commands ###\n\n\ndef downgrade() -> None:\n    pass\n"
  },
  {
    "path": "database/versions/279a949d81b6_2_1_1.py",
    "content": "\"\"\"2.1.1\n\nRevision ID: 279a949d81b6\nRevises: ca5461f314f2\nCreate Date: 2025-02-14 19:02:24.989349\n\n\"\"\"\n\nfrom app.chain.torrents import TorrentsChain\n\n# revision identifiers, used by Alembic.\nrevision = '279a949d81b6'\ndown_revision = 'ca5461f314f2'\nbranch_labels = None\ndepends_on = None\n\n\ndef upgrade() -> None:\n    # 清理一次缓存\n    TorrentsChain().clear_torrents()\n\n\ndef downgrade() -> None:\n    pass\n"
  },
  {
    "path": "database/versions/294b007932ef_2_0_0.py",
    "content": "\"\"\"2.0.0\n\nRevision ID: 294b007932ef\nRevises:\nCreate Date: 2024-07-20 08:43:40.741251\n\n\"\"\"\n\nimport secrets\n\nfrom app.core.config import settings\nfrom app.core.security import get_password_hash\nfrom app.db import SessionFactory\nfrom app.db.models import *\nfrom app.db.systemconfig_oper import SystemConfigOper\nfrom app.log import logger\nfrom app.schemas.types import SystemConfigKey\n\n# revision identifiers, used by Alembic.\nrevision = '294b007932ef'\ndown_revision = None\nbranch_labels = None\ndepends_on = None\n\n\ndef upgrade() -> None:\n    \"\"\"\n    v2.0.0 数据库初始化\n    \"\"\"\n    with SessionFactory() as db:\n        # 初始化超级管理员\n        _user = User.get_by_name(db=db, name=settings.SUPERUSER)\n        if not _user:\n            if settings.SUPERUSER_PASSWORD:\n                init_password = settings.SUPERUSER_PASSWORD\n            else:\n                # 生成随机密码\n                init_password = secrets.token_urlsafe(16)\n                logger.info(\n                    f\"【超级管理员初始密码】{init_password} 请登录系统后在设定中修改。 注：该密码只会显示一次，请注意保存。\")\n            _user = User(\n                name=settings.SUPERUSER,\n                hashed_password=get_password_hash(init_password),\n                email=\"admin@movie-pilot.org\",\n                is_superuser=True,\n                avatar=\"\"\n            )\n            _user.create(db)\n        # 初始化本地存储\n        _systemconfig = SystemConfigOper()\n        if not _systemconfig.get(SystemConfigKey.Storages):\n            _systemconfig.set(SystemConfigKey.Storages, [\n                {\n                    \"type\": \"local\",\n                    \"name\": \"本地\",\n                    \"config\": {}\n                },\n                {\n                    \"type\": \"alipan\",\n                    \"name\": \"阿里云盘\",\n                    \"config\": {}\n                },\n                {\n                    \"type\": \"u115\",\n                    \"name\": \"115网盘\",\n                    \"config\": {}\n                },\n                {\n                    \"type\": \"rclone\",\n                    \"name\": \"RClone\",\n                    \"config\": {}\n                }\n            ])\n\n\ndef downgrade() -> None:\n    pass\n"
  },
  {
    "path": "database/versions/3891a5e722a1_2_1_7.py",
    "content": "\"\"\"2.1.7\n\nRevision ID: 3891a5e722a1\nRevises: 3df653756eec\nCreate Date: 2025-06-28 08:40:14.516836\n\n\"\"\"\nfrom alembic import op\nimport sqlalchemy as sa\nfrom sqlalchemy.dialects import sqlite\n\nfrom app.db.systemconfig_oper import SystemConfigOper\nfrom app.schemas.types import SystemConfigKey\n\n# revision identifiers, used by Alembic.\nrevision = '3891a5e722a1'\ndown_revision = '3df653756eec'\nbranch_labels = None\ndepends_on = None\n\n\ndef upgrade() -> None:\n    # ### commands auto generated by Alembic - please adjust! ###\n    # rename AList存储\n    _systemconfig = SystemConfigOper()\n    _storages = _systemconfig.get(SystemConfigKey.Storages)\n    if _storages:\n        for storage in _storages:\n            if storage[\"type\"] == \"alist\":\n                storage[\"name\"] = \"OpenList\"\n                break\n        _systemconfig.set(SystemConfigKey.Storages, _storages)\n    # ### end Alembic commands ###\n\n\ndef downgrade() -> None:\n    pass\n"
  },
  {
    "path": "database/versions/3df653756eec_2_1_6.py",
    "content": "\"\"\"2.1.6\n\nRevision ID: 3df653756eec\nRevises: 486e56a62dcb\nCreate Date: 2025-06-11 19:52:57.185355\n\n\"\"\"\nimport json\n\nfrom app.db import SessionFactory\nfrom app.db.models import User\n\n# revision identifiers, used by Alembic.\nrevision = '3df653756eec'\ndown_revision = '486e56a62dcb'\nbranch_labels = None\ndepends_on = None\n\n\ndef upgrade() -> None:\n    # ### commands auto generated by Alembic - please adjust! ###\n    with SessionFactory() as db:\n        # 所有用户\n        users = User.list(db)\n        for user in users:\n            if user.is_superuser:\n                continue\n            if not user.permissions:\n                permissions = {\n                    \"discovery\": True,\n                    \"search\": True,\n                    \"subscribe\": True,\n                    \"manage\": False,\n                }\n                user.update(db, {\n                    \"permissions\": permissions,\n                })\n    # ### end Alembic commands ###\n\n\ndef downgrade() -> None:\n    pass\n"
  },
  {
    "path": "database/versions/41ef1dd7467c_2_2_2.py",
    "content": "\"\"\"2.2.2\n\nRevision ID: 41ef1dd7467c\nRevises: a946dae52526\nCreate Date: 2026-01-13 13:02:41.614029\n\n\"\"\"\n\nfrom alembic import op\nfrom sqlalchemy import text\n\nfrom app.log import logger\n\n# revision identifiers, used by Alembic.\nrevision = \"41ef1dd7467c\"\ndown_revision = \"a946dae52526\"\nbranch_labels = None\ndepends_on = None\n\n\ndef upgrade() -> None:\n    # systemconfig表 去重\n    connection = op.get_bind()\n\n    select_stmt = text(\n        \"\"\"\n        SELECT id, key, value\n        FROM SystemConfig\n        WHERE id NOT IN (\n            SELECT MAX(id)\n            FROM SystemConfig\n            GROUP BY key\n        )\n    \"\"\"\n    )\n    to_delete = connection.execute(select_stmt).fetchall()\n    for row in to_delete:\n        logger.warn(\n            f\"已删除重复的 SystemConfig 项：key={row.key}, value={row.value}, id={row.id}\"\n        )\n        delete_stmt = text(\"DELETE FROM SystemConfig WHERE id = :id\")\n        connection.execute(delete_stmt, {\"id\": row.id})\n\n    logger.info(\"SystemConfig 表去重操作已完成。\")\n\n\ndef downgrade() -> None:\n    pass\n"
  },
  {
    "path": "database/versions/4666ce24a443_2_1_8.py",
    "content": "\"\"\"2.1.8\n\nRevision ID: 4666ce24a443\nRevises: 3891a5e722a1\nCreate Date: 2025-07-22 13:54:04.196126\n\n\"\"\"\nimport contextlib\n\nimport sqlalchemy as sa\nfrom alembic import op\n\n# revision identifiers, used by Alembic.\nrevision = '4666ce24a443'\ndown_revision = '3891a5e722a1'\nbranch_labels = None\ndepends_on = None\n\n\ndef upgrade() -> None:\n    conn = op.get_bind()\n    inspector = sa.inspect(conn)\n    columns = inspector.get_columns('workflow')\n\n    if not any(c['name'] == 'trigger_type' for c in columns):\n        op.add_column('workflow', sa.Column('trigger_type', sa.String(), nullable=True, default='timer'))\n\n    if not any(c['name'] == 'event_type' for c in columns):\n        op.add_column('workflow', sa.Column('event_type', sa.String(), nullable=True))\n\n    if not any(c['name'] == 'event_conditions' for c in columns):\n        op.add_column('workflow', sa.Column('event_conditions', sa.JSON(), nullable=True, default={}))\n\n\ndef downgrade() -> None:\n    pass\n"
  },
  {
    "path": "database/versions/486e56a62dcb_2_1_5.py",
    "content": "\"\"\"2.1.5\n\nRevision ID: 486e56a62dcb\nRevises: 89d24811e894\nCreate Date: 2025-05-13 19:49:51.271319\n\n\"\"\"\nimport re\n\nfrom app.db.systemconfig_oper import SystemConfigOper\nfrom app.schemas.types import SystemConfigKey\n\n# revision identifiers, used by Alembic.\nrevision = '486e56a62dcb'\ndown_revision = '89d24811e894'\nbranch_labels = None\ndepends_on = None\n\n\ndef upgrade() -> None:\n    # ### commands auto generated by Alembic - please adjust! ###\n    ### 将消息模板中的 `season`(为单数字, 且重命名需要这个字段)替换为 `season_fmt`(Sxx格式字符串) ###\n    _systemconfig = SystemConfigOper()\n    templates = _systemconfig.get(SystemConfigKey.NotificationTemplates)\n    if isinstance(templates, dict):\n        _re = r'(?<={{)(?![^}]*[%|])(\\s*)season(\\s*)(?=}})|(?<={%)if\\s+(?![^%]*[%|])season\\s*(?=%)'\n        for k, v in templates.items():\n            # 替换season为season_fmt\n            result = re.sub(_re, r'\\1season_fmt\\2', v)\n            templates[k] = result\n        # 将更新后的模板存回系统配置\n        _systemconfig.set(SystemConfigKey.NotificationTemplates, templates)\n    # ### end Alembic commands ###\n\n\ndef downgrade() -> None:\n    pass\n"
  },
  {
    "path": "database/versions/4b544f5d3b07_2_1_3.py",
    "content": "\"\"\"2.1.3\n\nRevision ID: 4b544f5d3b07\nRevises: 610bb05ddeef\nCreate Date: 2025-04-03 11:21:42.780337\n\n\"\"\"\nimport contextlib\n\nfrom alembic import op\nimport sqlalchemy as sa\nfrom sqlalchemy.dialects import sqlite\n\n# revision identifiers, used by Alembic.\nrevision = '4b544f5d3b07'\ndown_revision = '610bb05ddeef'\nbranch_labels = None\ndepends_on = None\n\n\ndef upgrade() -> None:\n    conn = op.get_bind()\n    inspector = sa.inspect(conn)\n\n    # 检查并添加 downloadhistory.episode_group\n    dh_columns = inspector.get_columns('downloadhistory')\n    if not any(c['name'] == 'episode_group' for c in dh_columns):\n        op.add_column('downloadhistory', sa.Column('episode_group', sa.String, nullable=True))\n\n    # 检查并添加 subscribe.episode_group\n    s_columns = inspector.get_columns('subscribe')\n    if not any(c['name'] == 'episode_group' for c in s_columns):\n        op.add_column('subscribe', sa.Column('episode_group', sa.String, nullable=True))\n\n    # 检查并添加 subscribehistory.episode_group\n    sh_columns = inspector.get_columns('subscribehistory')\n    if not any(c['name'] == 'episode_group' for c in sh_columns):\n        op.add_column('subscribehistory', sa.Column('episode_group', sa.String, nullable=True))\n\n    # 检查并添加 transferhistory.episode_group\n    th_columns = inspector.get_columns('transferhistory')\n    if not any(c['name'] == 'episode_group' for c in th_columns):\n        op.add_column('transferhistory', sa.Column('episode_group', sa.String, nullable=True))\n\n\ndef downgrade() -> None:\n    pass\n"
  },
  {
    "path": "database/versions/55390f1f77c1_2_0_9.py",
    "content": "\"\"\"2.0.9\n\nRevision ID: 55390f1f77c1\nRevises: bf28a012734c\nCreate Date: 2024-12-24 13:29:32.225532\n\n\"\"\"\nimport contextlib\n\nimport sqlalchemy as sa\nfrom alembic import op\n\n# revision identifiers, used by Alembic.\nrevision = '55390f1f77c1'\ndown_revision = 'bf28a012734c'\nbranch_labels = None\ndepends_on = None\n\n\ndef upgrade() -> None:\n    conn = op.get_bind()\n    inspector = sa.inspect(conn)\n    columns = inspector.get_columns('transferhistory')\n    if not any(c['name'] == 'downloader' for c in columns):\n        op.add_column('transferhistory', sa.Column('downloader', sa.String(), nullable=True))\n\n\ndef downgrade() -> None:\n    pass\n"
  },
  {
    "path": "database/versions/58edfac72c32_2_2_3.py",
    "content": "\"\"\"2.2.3\n添加 downloadhistory.custom_words 字段，用于整理时应用订阅识别词\n\nRevision ID: 58edfac72c32\nRevises: 41ef1dd7467c\nCreate Date: 2026-01-19\n\"\"\"\nfrom alembic import op\nimport sqlalchemy as sa\n\n# revision identifiers, used by Alembic.\nrevision = \"58edfac72c32\"\ndown_revision = \"41ef1dd7467c\"\nbranch_labels = None\ndepends_on = None\n\n\ndef upgrade() -> None:\n    conn = op.get_bind()\n    inspector = sa.inspect(conn)\n\n    # 检查并添加 downloadhistory.custom_words\n    dh_columns = inspector.get_columns('downloadhistory')\n    if not any(c['name'] == 'custom_words' for c in dh_columns):\n        op.add_column('downloadhistory', sa.Column('custom_words', sa.String, nullable=True))\n\n\ndef downgrade() -> None:\n    # 降级时删除字段\n    op.drop_column('downloadhistory', 'custom_words')\n"
  },
  {
    "path": "database/versions/5b3355c964bb_2_2_0.py",
    "content": "\"\"\"2.2.0\n\nRevision ID: 5b3355c964bb\nRevises: d58298a0879f\nCreate Date: 2025-08-19 12:27:08.451371\n\n\"\"\"\nimport sqlalchemy as sa\nfrom alembic import op\n\nfrom app.log import logger\nfrom app.core.config import settings\n\n# revision identifiers, used by Alembic.\nrevision = '5b3355c964bb'\ndown_revision = 'd58298a0879f'\nbranch_labels = None\ndepends_on = None\n\n\ndef upgrade() -> None:\n    # ### commands auto generated by Alembic - please adjust! ###\n    if settings.DB_TYPE.lower() == \"postgresql\":\n        # 将SQLite的Sequence转换为PostgreSQL的Identity\n        fix_postgresql_sequences()\n    # ### end Alembic commands ###\n\n\ndef fix_postgresql_sequences():\n    \"\"\"\n    修复PostgreSQL数据库中的序列问题\n    将SQLite迁移过来的Sequence转换为PostgreSQL的Identity\n    \"\"\"\n    connection = op.get_bind()\n\n    # 获取所有表名\n    result = connection.execute(sa.text(\"\"\"\n        SELECT table_name \n        FROM information_schema.tables \n        WHERE table_schema = 'public' \n        AND table_type = 'BASE TABLE'\n    \"\"\"))\n    tables = [row[0] for row in result.fetchall()]\n\n    logger.info(f\"发现 {len(tables)} 个表需要检查序列\")\n\n    for table_name in tables:\n        fix_table_sequence(connection, table_name)\n\n\ndef fix_table_sequence(connection, table_name):\n    \"\"\"\n    修复单个表的序列\n    \"\"\"\n    try:\n        # 跳过alembic_version表，它没有id列\n        if table_name == 'alembic_version':\n            logger.debug(f\"跳过表 {table_name}，这是Alembic版本表\")\n            return\n\n        # 检查表是否有id列\n        result = connection.execute(sa.text(f\"\"\"\n            SELECT is_identity, column_default\n            FROM information_schema.columns \n            WHERE table_name = '{table_name}' \n            AND column_name = 'id'\n        \"\"\"))\n\n        id_column = result.fetchone()\n        if not id_column:\n            logger.debug(f\"表 {table_name} 没有id列，跳过\")\n            return\n\n        is_identity, column_default = id_column\n\n        # 检查是否已经是Identity类型\n        if is_identity == 'YES' or (column_default and 'GENERATED BY DEFAULT AS IDENTITY' in column_default):\n            logger.debug(f\"表 {table_name} 的id列已经是Identity类型，跳过\")\n            return\n\n        # 检查是否有序列\n        logger.info(f\"表 {table_name} 存在序列，需要修复\")\n        convert_to_identity(connection, table_name)\n\n    except Exception as e:\n        logger.error(f\"修复表 {table_name} 序列时出错: {e}\")\n        # 回滚当前事务，避免影响后续操作\n        connection.rollback()\n\n\ndef convert_to_identity(connection, table_name):\n    \"\"\"\n    将序列转换为Identity，保持原有约束不变\n    \"\"\"\n    try:\n        # 获取当前序列的最大值\n        result = connection.execute(sa.text(f\"\"\"\n            SELECT COALESCE(MAX(id), 0) + 1 as next_value\n            FROM \"{table_name}\"\n        \"\"\"))\n        next_value = result.fetchone()[0]\n        \n        # 直接修改列属性，添加Identity，保持其他约束不变\n        # 这种方式不会删除主键约束和索引\n        connection.execute(sa.text(f\"\"\"\n            ALTER TABLE \"{table_name}\" \n            ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY (START WITH {next_value})\n        \"\"\"))\n\n        logger.info(f\"表 {table_name} 序列已转换为Identity，起始值为 {next_value}\")\n\n    except Exception as e:\n        # 如果是已经存在的Identity错误，则忽略\n        if \"already an identity column\" in str(e):\n            logger.warn(f\"表 {table_name} 的id列已经是Identity类型，忽略此错误: {e}\")\n            return\n        logger.error(f\"转换表 {table_name} 序列时出错: {e}\")\n        raise\n"
  },
  {
    "path": "database/versions/610bb05ddeef_2_1_2.py",
    "content": "\"\"\"2.1.2\n\nRevision ID: 610bb05ddeef\nRevises: 279a949d81b6\nCreate Date: 2025-02-24 07:52:00.042837\n\n\"\"\"\nimport contextlib\n\nfrom alembic import op\nimport sqlalchemy as sa\nfrom sqlalchemy.dialects import sqlite\n\n# revision identifiers, used by Alembic.\nrevision = '610bb05ddeef'\ndown_revision = '279a949d81b6'\nbranch_labels = None\ndepends_on = None\n\n\ndef upgrade() -> None:\n    conn = op.get_bind()\n    inspector = sa.inspect(conn)\n    columns = inspector.get_columns('workflow')\n    if not any(c['name'] == 'flows' for c in columns):\n        op.add_column('workflow', sa.Column('flows', sa.JSON(), nullable=True))\n\n\ndef downgrade() -> None:\n    pass\n"
  },
  {
    "path": "database/versions/89d24811e894_2_1_4.py",
    "content": "\"\"\"2.1.4\n\nRevision ID: 89d24811e894\nRevises: 4b544f5d3b07\nCreate Date: 2025-05-03 17:29:07.635618\n\n\"\"\"\n\nfrom app.db.systemconfig_oper import SystemConfigOper\nfrom app.schemas.types import SystemConfigKey\n\n# revision identifiers, used by Alembic.\nrevision = '89d24811e894'\ndown_revision = '4b544f5d3b07'\nbranch_labels = None\ndepends_on = None\n\n\ndef upgrade() -> None:\n    # ### commands auto generated by Alembic - please adjust! ###\n    value = {\n        \"organizeSuccess\": \"\"\"\n{\n    'title': '{{ title_year }}'\n            '{% if season_episode %} {{ season_episode }}{% endif %} 已入库',\n    'text': '{% if vote_average %}评分：{{ vote_average }}，{% endif %}'\n            '类型：{{ type }}'\n            '{% if category %}，类别：{{ category }}{% endif %}'\n            '{% if resource_term %}，质量：{{ resource_term }}{% endif %}，'\n            '共{{ file_count }}个文件，大小：{{ total_size }}'\n            '{% if err_msg %}，以下文件处理失败：{{ err_msg }}{% endif %}'\n}\"\"\",\n        \"downloadAdded\": \"\"\"\n{\n    'title': '{{ title_year }}'\n            '{% if download_episodes %} {{ season_fmt }} {{ download_episodes }}{% else %}{{ season_episode }}{% endif %} 开始下载',\n    'text': '{% if site_name %}站点：{{ site_name }}{% endif %}'\n            '{% if resource_term %}\\\\n质量：{{ resource_term }}{% endif %}'\n            '{% if size %}\\\\n大小：{{ size }}{% endif %}'\n            '{% if torrent_title %}\\\\n种子：{{ torrent_title }}{% endif %}'\n            '{% if pubdate %}\\\\n发布时间：{{ pubdate }}{% endif %}'\n            '{% if freedate %}\\\\n免费时间：{{ freedate }}{% endif %}'\n            '{% if seeders %}\\\\n做种数：{{ seeders }}{% endif %}'\n            '{% if volume_factor %}\\\\n促销：{{ volume_factor }}{% endif %}'\n            '{% if hit_and_run %}\\\\nHit&Run：{{ hit_and_run }}{% endif %}'\n            '{% if labels %}\\\\n标签：{{ labels }}{% endif %}'\n            '{% if description %}\\\\n描述：{{ description }}{% endif %}'\n}\"\"\",\n        \"subscribeAdded\": \"{'title': '{{ title_year }}{% if season_fmt %} {{ season_fmt }}{% endif %} 已添加订阅'}\",\n        \"subscribeComplete\": \"\"\"\n{\n    'title': '{{ title_year }}'\n            '{% if season_fmt %} {{ season_fmt }}{% endif %} 已完成{{ msgstr }}',\n    'text': '{% if vote_average %}评分：{{ vote_average }}{% endif %}'\n            '{% if username %}，来自用户：{{ username }}{% endif %}'\n            '{% if actors %}\\\\n演员：{{ actors }}{% endif %}'\n            '{% if overview %}\\\\n简介：{{ overview }}{% endif %}'\n}\"\"\"\n    }\n    _systemconfig = SystemConfigOper()\n    if not _systemconfig.get(SystemConfigKey.NotificationTemplates):\n        _systemconfig.set(SystemConfigKey.NotificationTemplates, value)\n\n    # ### end Alembic commands ###\n\n\ndef downgrade() -> None:\n    pass\n"
  },
  {
    "path": "database/versions/a295e41830a6_2_0_6.py",
    "content": "\"\"\"2.0.6\n\nRevision ID: a295e41830a6\nRevises: ecf3c693fdf3\nCreate Date: 2024-11-14 12:49:13.838120\n\n\"\"\"\n\nfrom app.db.systemconfig_oper import SystemConfigOper\nfrom app.schemas.types import SystemConfigKey\n\n# revision identifiers, used by Alembic.\nrevision = 'a295e41830a6'\ndown_revision = 'ecf3c693fdf3'\nbranch_labels = None\ndepends_on = None\n\n\ndef upgrade() -> None:\n    # ### commands auto generated by Alembic - please adjust! ###\n    # 初始化AList存储\n    _systemconfig = SystemConfigOper()\n    _storages = _systemconfig.get(SystemConfigKey.Storages)\n    if _storages:\n        if \"alist\" not in [storage[\"type\"] for storage in _storages]:\n            _storages.append({\n                \"type\": \"alist\",\n                \"name\": \"AList\",\n                \"config\": {}\n            })\n            _systemconfig.set(SystemConfigKey.Storages, _storages)\n\n    # ### end Alembic commands ###\n\n\ndef downgrade() -> None:\n    pass\n"
  },
  {
    "path": "database/versions/a73f2dbf5c09_2_0_4.py",
    "content": "\"\"\"2.0.4\n\nRevision ID: a73f2dbf5c09\nRevises: e2dbe1421fa4\nCreate Date: 2024-10-16 15:05:01.775429\n\n\"\"\"\n\nfrom app.db.systemconfig_oper import SystemConfigOper\nfrom app.schemas.types import SystemConfigKey\n\n# revision identifiers, used by Alembic.\nrevision = 'a73f2dbf5c09'\ndown_revision = 'e2dbe1421fa4'\nbranch_labels = None\ndepends_on = None\n\n\ndef upgrade() -> None:\n    # ### commands auto generated by Alembic - please adjust! ###\n    # 初始化下载优先规则\n    SystemConfigOper().set(SystemConfigKey.TorrentsPriority, [\"torrent\", \"upload\", \"seeder\"])\n    # ### end Alembic commands ###\n\n\ndef downgrade() -> None:\n    pass\n"
  },
  {
    "path": "database/versions/a946dae52526_2_2_1.py",
    "content": "\"\"\"2.2.1\n\nRevision ID: a946dae52526\nRevises: 5b3355c964bb\nCreate Date: 2025-08-20 17:50:00.000000\n\n\"\"\"\nimport sqlalchemy as sa\nfrom alembic import op\n\nfrom app.log import logger\nfrom app.core.config import settings\n\n# revision identifiers, used by Alembic.\nrevision = 'a946dae52526'\ndown_revision = '5b3355c964bb'\nbranch_labels = None\ndepends_on = None\n\n\ndef upgrade() -> None:\n    \"\"\"\n    升级：将SiteUserData表的userid字段从Integer改为String\n    \"\"\"\n    connection = op.get_bind()\n    \n    if settings.DB_TYPE.lower() == \"postgresql\":\n        # PostgreSQL数据库迁移\n        migrate_postgresql_userid(connection)\n\n\ndef downgrade() -> None:\n    \"\"\"\n    降级：将SiteUserData表的userid字段从String改回Integer\n    \"\"\"\n    pass\n\n\ndef migrate_postgresql_userid(connection):\n    \"\"\"\n    PostgreSQL数据库userid字段迁移\n    \"\"\"\n    try:\n        logger.info(\"开始PostgreSQL数据库userid字段迁移...\")\n        \n        # 1. 创建临时列\n        connection.execute(sa.text(\"\"\"\n            ALTER TABLE siteuserdata \n            ADD COLUMN userid_new VARCHAR\n        \"\"\"))\n        \n        # 2. 将现有数据转换为字符串并复制到新列\n        connection.execute(sa.text(\"\"\"\n            UPDATE siteuserdata \n            SET userid_new = CAST(userid AS VARCHAR)\n            WHERE userid IS NOT NULL\n        \"\"\"))\n        \n        # 3. 删除旧列\n        connection.execute(sa.text(\"\"\"\n            ALTER TABLE siteuserdata \n            DROP COLUMN userid\n        \"\"\"))\n        \n        # 4. 重命名新列\n        connection.execute(sa.text(\"\"\"\n            ALTER TABLE siteuserdata \n            RENAME COLUMN userid_new TO userid\n        \"\"\"))\n        \n        logger.info(\"PostgreSQL数据库userid字段迁移完成\")\n        \n    except Exception as e:\n        logger.error(f\"PostgreSQL数据库userid字段迁移失败: {e}\")\n        raise\n\n\n\n\n\n"
  },
  {
    "path": "database/versions/bf28a012734c_2_0_8.py",
    "content": "\"\"\"2.0.8\n\nRevision ID: bf28a012734c\nRevises: eaf9cbc49027\nCreate Date: 2024-12-23 18:29:31.202143\n\n\"\"\"\nimport contextlib\n\nimport sqlalchemy as sa\nfrom alembic import op\n\n# revision identifiers, used by Alembic.\nrevision = 'bf28a012734c'\ndown_revision = 'eaf9cbc49027'\nbranch_labels = None\ndepends_on = None\n\n\ndef upgrade() -> None:\n    conn = op.get_bind()\n    inspector = sa.inspect(conn)\n    columns = inspector.get_columns('downloadhistory')\n    if not any(c['name'] == 'downloader' for c in columns):\n        op.add_column('downloadhistory', sa.Column('downloader', sa.String(), nullable=True))\n\n\ndef downgrade() -> None:\n    pass\n"
  },
  {
    "path": "database/versions/ca5461f314f2_2_1_0.py",
    "content": "\"\"\"2.1.0\n\nRevision ID: ca5461f314f2\nRevises: 55390f1f77c1\nCreate Date: 2025-02-06 18:28:00.644571\n\n\"\"\"\nimport contextlib\n\nimport sqlalchemy as sa\nfrom alembic import op\n\n# revision identifiers, used by Alembic.\nrevision = 'ca5461f314f2'\ndown_revision = '55390f1f77c1'\nbranch_labels = None\ndepends_on = None\n\n\ndef upgrade() -> None:\n    conn = op.get_bind()\n    inspector = sa.inspect(conn)\n\n    # 检查并添加 subscribe.mediaid\n    s_columns = inspector.get_columns('subscribe')\n    if not any(c['name'] == 'mediaid' for c in s_columns):\n        op.add_column('subscribe', sa.Column('mediaid', sa.String(), nullable=True))\n\n    # 检查并创建索引\n    s_indexes = inspector.get_indexes('subscribe')\n    if not any(i['name'] == 'ix_subscribe_mediaid' for i in s_indexes):\n        op.create_index('ix_subscribe_mediaid', 'subscribe', ['mediaid'], unique=False)\n\n    # 检查并添加 subscribehistory.mediaid\n    sh_columns = inspector.get_columns('subscribehistory')\n    if not any(c['name'] == 'mediaid' for c in sh_columns):\n        op.add_column('subscribehistory', sa.Column('mediaid', sa.String(), nullable=True))\n\n\ndef downgrade() -> None:\n    pass\n"
  },
  {
    "path": "database/versions/d58298a0879f_2_1_9.py",
    "content": "\"\"\"2.1.9\n\nRevision ID: d58298a0879f\nRevises: 4666ce24a443\nCreate Date: 2025-08-19 11:56:39.652032\n\n\"\"\"\n\n# revision identifiers, used by Alembic.\nrevision = 'd58298a0879f'\ndown_revision = '4666ce24a443'\nbranch_labels = None\ndepends_on = None\n\n\ndef upgrade() -> None:\n    pass\n\n\ndef downgrade() -> None:\n    pass\n"
  },
  {
    "path": "database/versions/e2dbe1421fa4_2_0_3.py",
    "content": "\"\"\"2.0.3\n\nRevision ID: e2dbe1421fa4\nRevises: 0fb94bf69b38\nCreate Date: 2024-10-09 13:44:13.926529\n\n\"\"\"\nimport contextlib\n\nfrom alembic import op\nimport sqlalchemy as sa\n\nfrom app.log import logger\nfrom app.db import SessionFactory\nfrom app.db.models import UserConfig\n\n# revision identifiers, used by Alembic.\nrevision = 'e2dbe1421fa4'\ndown_revision = '0fb94bf69b38'\nbranch_labels = None\ndepends_on = None\n\n\ndef upgrade() -> None:\n    conn = op.get_bind()\n    inspector = sa.inspect(conn)\n\n    # 检查并添加 downloadhistory.media_category\n    dh_columns = inspector.get_columns('downloadhistory')\n    if not any(c['name'] == 'media_category' for c in dh_columns):\n        op.add_column('downloadhistory', sa.Column('media_category', sa.String(), nullable=True))\n\n    # 检查并添加 subscribe 表的列\n    sub_columns = inspector.get_columns('subscribe')\n    if not any(c['name'] == 'custom_words' for c in sub_columns):\n        op.add_column('subscribe', sa.Column('custom_words', sa.String(), nullable=True))\n    if not any(c['name'] == 'media_category' for c in sub_columns):\n        op.add_column('subscribe', sa.Column('media_category', sa.String(), nullable=True))\n    if not any(c['name'] == 'filter_groups' for c in sub_columns):\n        op.add_column('subscribe', sa.Column('filter_groups', sa.JSON(), nullable=True))\n\n    # 定义需要检查和转换的表和列\n    columns_to_alter = {\n        'subscribe': 'note',\n        'downloadhistory': 'note',\n        'mediaserveritem': 'note',\n        'message': 'note',\n        'plugindata': 'value',\n        'site': 'note',\n        'sitestatistic': 'note',\n        'systemconfig': 'value',\n        'userconfig': 'value'\n    }\n\n    for table, column_name in columns_to_alter.items():\n        try:\n            cols = inspector.get_columns(table)\n            # 找到对应的列信息\n            target_col = next((c for c in cols if c['name'] == column_name), None)\n            # 如果列存在且类型不是JSON，则进行修改\n            if target_col and not isinstance(target_col['type'], sa.JSON):\n                # PostgreSQL需要指定USING子句来处理类型转换\n                if conn.dialect.name == 'postgresql':\n                    op.alter_column(table, column_name,\n                                    existing_type=sa.String(),\n                                    type_=sa.JSON(),\n                                    postgresql_using=f'\"{column_name}\"::json')\n                else:\n                    op.alter_column(table, column_name,\n                                    existing_type=sa.String(),\n                                    type_=sa.JSON())\n        except Exception as e:\n            logger.error(f\"Could not alter column {column_name} in table {table}: {e}\")\n\n    with SessionFactory() as db:\n        UserConfig.truncate(db)\n\n\ndef downgrade() -> None:\n    pass\n"
  },
  {
    "path": "database/versions/eaf9cbc49027_2_0_7.py",
    "content": "\"\"\"2.0.7\n\nRevision ID: eaf9cbc49027\nRevises: a295e41830a6\nCreate Date: 2024-11-16 00:26:09.505188\n\n\"\"\"\nimport contextlib\n\nfrom alembic import op\nimport sqlalchemy as sa\n\n# revision identifiers, used by Alembic.\nrevision = 'eaf9cbc49027'\ndown_revision = 'a295e41830a6'\nbranch_labels = None\ndepends_on = None\n\n\ndef upgrade() -> None:\n    conn = op.get_bind()\n    inspector = sa.inspect(conn)\n\n    # 检查并添加 site.downloader\n    site_columns = inspector.get_columns('site')\n    if not any(c['name'] == 'downloader' for c in site_columns):\n        op.add_column('site', sa.Column('downloader', sa.String(), nullable=True))\n\n    # 检查并添加 subscribe.downloader\n    subscribe_columns = inspector.get_columns('subscribe')\n    if not any(c['name'] == 'downloader' for c in subscribe_columns):\n        op.add_column('subscribe', sa.Column('downloader', sa.String(), nullable=True))\n\n\ndef downgrade() -> None:\n    pass\n"
  },
  {
    "path": "database/versions/ecf3c693fdf3_2_0_5.py",
    "content": "\"\"\"2.0.5\n\nRevision ID: ecf3c693fdf3\nRevises: a73f2dbf5c09\nCreate Date: 2024-10-21 12:36:20.631963\n\n\"\"\"\nimport contextlib\n\nfrom alembic import op\nimport sqlalchemy as sa\n\nfrom app.log import logger\n\n\n# revision identifiers, used by Alembic.\nrevision = 'ecf3c693fdf3'\ndown_revision = 'a73f2dbf5c09'\nbranch_labels = None\ndepends_on = None\n\n\ndef upgrade() -> None:\n    conn = op.get_bind()\n    inspector = sa.inspect(conn)\n    table_name = 'subscribehistory'\n    columns = inspector.get_columns(table_name)\n\n    try:\n        sites_col = next((c for c in columns if c['name'] == 'sites'), None)\n        # 如果 'sites' 列存在且类型不是 JSON，则进行修改\n        if sites_col and not isinstance(sites_col['type'], sa.JSON):\n            if conn.dialect.name == 'postgresql':\n                op.alter_column(table_name, 'sites',\n                                existing_type=sa.String(),\n                                type_=sa.JSON(),\n                                postgresql_using='sites::json')\n            else:\n                op.alter_column(table_name, 'sites',\n                                existing_type=sa.String(),\n                                type_=sa.JSON())\n    except Exception as e:\n        logger.error(f\"Could not alter column 'sites' in table {table_name}: {e}\")\n\n    if not any(c['name'] == 'custom_words' for c in columns):\n        op.add_column(table_name, sa.Column('custom_words', sa.String(), nullable=True))\n\n    if not any(c['name'] == 'media_category' for c in columns):\n        op.add_column(table_name, sa.Column('media_category', sa.String(), nullable=True))\n\n    if not any(c['name'] == 'filter_groups' for c in columns):\n        op.add_column(table_name, sa.Column('filter_groups', sa.JSON(), nullable=True))\n\n\ndef downgrade() -> None:\n    pass\n"
  },
  {
    "path": "docker/Dockerfile",
    "content": "FROM python:3.12.8-slim-bookworm AS base\n\n\n# 准备软件包\nFROM base AS prepare_package\n\nENV LANG=\"C.UTF-8\" \\\n    TZ=\"Asia/Shanghai\" \\\n    HOME=\"/moviepilot\" \\\n    CONFIG_DIR=\"/config\" \\\n    TERM=\"xterm\" \\\n    DISPLAY=:987 \\\n    PUID=0 \\\n    PGID=0 \\\n    UMASK=000 \\\n    VENV_PATH=\"/opt/venv\"\n\nENV PATH=\"${VENV_PATH}/bin:${PATH}\"\n\nRUN apt-get update && apt-get install -y --no-install-recommends \\\n    nginx \\\n    gettext-base \\\n    locales \\\n    procps \\\n    gosu \\\n    bash \\\n    curl \\\n    wget \\\n    busybox \\\n    tini \\\n    jq \\\n    fuse3 \\\n    rsync \\\n    ffmpeg \\\n    nano \\\n    libjemalloc2 \\\n    && dpkg-reconfigure --frontend noninteractive tzdata \\\n    && curl https://rclone.org/install.sh | bash \\\n    && ln -s /usr/lib/*-linux-gnu/libjemalloc.so.2 /usr/local/lib/libjemalloc.so \\\n    && apt-get autoremove -y \\\n    && apt-get clean \\\n    && rm -rf \\\n    /tmp/* \\\n    /var/lib/apt/lists/* \\\n    /var/tmp/*\n\n\n# 准备 python 环境\nFROM base AS prepare_venv\n\n# 设置环境变量\nENV LANG=\"C.UTF-8\" \\\n    TZ=\"Asia/Shanghai\" \\\n    VENV_PATH=\"/opt/venv\"\n\nENV PATH=\"${VENV_PATH}/bin:${PATH}\"\n\n# 安装系统构建依赖\nRUN apt-get update && apt-get install -y --no-install-recommends \\\n    build-essential \\\n    curl \\\n    busybox \\\n    jq \\\n    wget\n\n# 安装 Python 构建依赖并创建虚拟环境\nWORKDIR /app\nCOPY requirements.in requirements.in\nRUN python3 -m venv ${VENV_PATH} \\\n    && pip install --upgrade \"pip<25.0\" \\\n    && pip install \"Cython\" \"pip-tools<7.5\" \\\n    && pip-compile requirements.in \\\n    && pip install -r requirements.txt\n\n# 下载准备代码\nFROM prepare_package AS prepare_code\n\nWORKDIR /app\n\nCOPY . .\nRUN FRONTEND_VERSION=$(sed -n \"s/^FRONTEND_VERSION\\s*=\\s*'\\([^']*\\)'/\\1/p\" /app/version.py) \\\n    && curl -sL \"https://github.com/jxxghp/MoviePilot-Frontend/releases/download/${FRONTEND_VERSION}/dist.zip\" | busybox unzip -d / - \\\n    && mv /dist /public \\\n    && curl -sL \"https://github.com/jxxghp/MoviePilot-Plugins/archive/refs/heads/main.zip\" | busybox unzip -d /tmp - \\\n    && mv -f /tmp/MoviePilot-Plugins-main/plugins.v2/* /app/app/plugins/ \\\n    && cat /tmp/MoviePilot-Plugins-main/package.json | jq -r 'to_entries[] | select(.value.v2 == true) | .key' | awk '{print tolower($0)}' | \\\n    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 \\\n    && curl -sL \"https://github.com/jxxghp/MoviePilot-Resources/archive/refs/heads/main.zip\" | busybox unzip -d /tmp - \\\n    && mv -f /tmp/MoviePilot-Resources-main/resources.v2/* /app/app/helper/\n\n# final 阶段: 安装运行时依赖和配置最终镜像\nFROM prepare_package AS final\n\nENV LD_PRELOAD=\"/usr/local/lib/libjemalloc.so\"\n\n# python 环境\nCOPY --from=prepare_venv --chmod=777 ${VENV_PATH} ${VENV_PATH}\n\n# playwright 环境\nRUN playwright install-deps chromium \\\n    && playwright install-deps firefox \\\n    && apt-get autoremove -y \\\n    && apt-get clean \\\n    && rm -rf \\\n    /tmp/* \\\n    /var/lib/apt/lists/* \\\n    /var/tmp/*\n\n# 准备运行代码\nWORKDIR /app\n\nCOPY --from=prepare_code /app /app\nCOPY --from=prepare_code /public /public\n\nRUN cp -f /app/docker/nginx.common.conf /etc/nginx/common.conf \\\n    && cp -f /app/docker/nginx.template.conf /etc/nginx/nginx.template.conf \\\n    && cp -f /app/docker/update.sh /usr/local/bin/mp_update.sh \\\n    && cp -f /app/docker/entrypoint.sh /entrypoint.sh \\\n    && cp -f /app/docker/docker_http_proxy.conf /etc/nginx/docker_http_proxy.conf \\\n    && chmod +x /entrypoint.sh /usr/local/bin/mp_update.sh \\\n    && mkdir -p ${HOME} \\\n    && groupadd -r moviepilot -g 918 \\\n    && useradd -r moviepilot -g moviepilot -d ${HOME} -s /bin/bash -u 918 \\\n    && python_ver=$(python3 -V | awk '{print $2}') \\\n    && echo \"/app/\" > ${VENV_PATH}/lib/python${python_ver%.*}/site-packages/app.pth \\\n    && echo 'fs.inotify.max_user_watches=5242880' >> /etc/sysctl.conf \\\n    && echo 'fs.inotify.max_user_instances=5242880' >> /etc/sysctl.conf \\\n    && echo \"zh_CN.UTF-8 UTF-8\" >> /etc/locale.gen \\\n    && locale-gen zh_CN.UTF-8\n\nEXPOSE 3000\nVOLUME [ \"${CONFIG_DIR}\" ]\nENTRYPOINT [ \"/usr/bin/tini\", \"-g\", \"--\", \"/entrypoint.sh\" ]\n"
  },
  {
    "path": "docker/cert.sh",
    "content": "#!/bin/bash\nset -e\n\nGreen=\"\\033[32m\"\nRed=\"\\033[31m\"\nYellow='\\033[33m'\nFont=\"\\033[0m\"\nINFO=\"[${Green}INFO${Font}]\"\nERROR=\"[${Red}ERROR${Font}]\"\nWARN=\"[${Yellow}WARN${Font}]\"\nfunction INFO() {\n    echo -e \"${INFO} ${1}\"\n}\nfunction ERROR() {\n    echo -e \"${ERROR} ${1}\"\n}\nfunction WARN() {\n    echo -e \"${WARN} ${1}\"\n}\n\n# 核心条件验证\nif [ \"${ENABLE_SSL}\" = \"true\" ] && \\\n   [ \"${AUTO_ISSUE_CERT}\" = \"true\" ] && \\\n   [ -n \"${SSL_DOMAIN}\" ]; then\n\n    # 创建证书目录\n    mkdir -p /config/certs/\"${SSL_DOMAIN}\"\n    chown moviepilot:moviepilot /config/certs -R\n\n    # 安装acme.sh（使用官方安装脚本）\n    if [ ! -d \"/config/acme.sh\" ]; then\n        INFO \"→ 安装acme.sh...\"\n\n        # 设置安装环境变量\n        export LE_WORKING_DIR=\"/config/acme.sh\"\n        export LE_CONFIG_HOME=\"/config/acme.sh/data\"\n        export LE_CERT_HOME=\"/config/certs\"\n\n        # 执行官方安装命令（添加错误处理）\n        INFO \"正在下载并安装 acme.sh...\"\n        \n        # 构建安装命令\n        INSTALL_CMD=\"curl -sSL https://get.acme.sh | sh -s -- --install-online\"\n        if [ -n \"${SSL_EMAIL}\" ]; then\n            INSTALL_CMD=\"${INSTALL_CMD} --accountemail ${SSL_EMAIL}\"\n        else\n            WARN \"未设置SSL_EMAIL，建议配置邮箱用于证书过期提醒\"\n        fi\n        \n        if ! eval \"${INSTALL_CMD}\"; then\n            ERROR \"acme.sh 安装失败\"\n            exit 1\n        fi\n\n        # 验证安装是否成功\n        if [ ! -f \"/config/acme.sh/acme.sh\" ]; then\n            ERROR \"acme.sh 安装后文件不存在，安装可能失败\"\n            exit 1\n        fi\n\n        INFO \"acme.sh 安装成功\"\n    fi\n\n    # 签发证书（仅当证书不存在时）\n    if [ ! -f \"/config/certs/${SSL_DOMAIN}/fullchain.pem\" ]; then\n        # 必要参数检查\n        REQUIRED_VARS=(\"DNS_PROVIDER\")\n        for var in \"${REQUIRED_VARS[@]}\"; do\n            eval \"value=\\${${var}}\"\n            [ -z \"$value\" ] && { ERROR \"必须设置环境变量: ${var}\"; exit 1; }\n        done\n\n        INFO \"→ 签发证书: ${SSL_DOMAIN} (DNS验证方式: ${DNS_PROVIDER})\"\n\n        # 加载ACME环境变量（带安全过滤）\n        INFO \"正在加载ACME环境变量...\"\n        env | grep '^ACME_ENV_' | while read -r line; do\n            key=\"${line#ACME_ENV_}\"\n            key=\"${key%%=*}\"\n            value=\"${line#ACME_ENV_${key}=}\"\n\n            # 过滤非法变量名\n            if [[ \"$key\" =~ ^[a-zA-Z_][a-zA-Z0-9_]*$ ]]; then\n                export \"$key\"=\"$value\"\n                INFO \"已加载环境变量: ${key}=******\"\n            else\n                WARN \"跳过无效变量名: ${key}\"\n            fi\n        done\n\n        # 签发证书（添加错误处理）\n        INFO \"正在签发证书...\"\n        if ! /config/acme.sh/acme.sh --issue \\\n            --dns \"${DNS_PROVIDER}\" \\\n            --domain \"${SSL_DOMAIN}\" \\\n            --key-file /config/certs/\"${SSL_DOMAIN}\"/privkey.pem \\\n            --fullchain-file /config/certs/\"${SSL_DOMAIN}\"/fullchain.pem \\\n            --reloadcmd \"nginx -s reload\" \\\n            --force; then\n            ERROR \"证书签发失败\"\n            exit 1\n        fi\n\n        # 创建稳定符号链接\n        ln -sf /config/certs/\"${SSL_DOMAIN}\" /config/certs/latest\n        INFO \"证书签发成功\"\n    else\n        INFO \"证书已存在，跳过签发步骤\"\n    fi\n\n    # 配置自动更新任务\n    INFO \"→ 配置cron自动更新...\"\n    echo \"0 3 * * * /config/acme.sh/acme.sh --cron --home /config/acme.sh && nginx -s reload\" > /etc/cron.d/acme\n    chmod 644 /etc/cron.d/acme\n    service cron start\n\nelif [ \"${ENABLE_SSL}\" = \"true\" ] && [ \"${AUTO_ISSUE_CERT}\" = \"true\" ] && [ -z \"${SSL_DOMAIN}\" ]; then\n    WARN \"已启用自动签发证书但未设置SSL_DOMAIN，跳过证书管理\"\nelif [ \"${ENABLE_SSL}\" = \"true\" ] && [ \"${AUTO_ISSUE_CERT}\" = \"false\" ]; then\n    INFO \"SSL已启用但自动签发证书已禁用，将使用手动配置的证书\"\n    # 检查证书文件是否存在\n    if [ -f \"/config/certs/latest/fullchain.pem\" ] && [ -f \"/config/certs/latest/privkey.pem\" ]; then\n        INFO \"检测到证书文件，SSL配置正常\"\n    else\n        WARN \"未检测到证书文件，请确保手动配置了正确的证书路径\"\n    fi\nfi"
  },
  {
    "path": "docker/docker_http_proxy.conf",
    "content": "worker_processes 1;\nuser root;\ndaemon on;\npid /var/run/nginx_proxy.pid;\n\nevents {\n    worker_connections  1024;\n}\n\nhttp {\n    include       mime.types;\n    default_type  application/octet-stream;\n    upstream docker {\n        server unix:/var/run/docker.sock fail_timeout=0;\n    }\n    server {\n        listen 127.0.0.1:38379;\n        server_name localhost;\n\n        access_log /dev/stdout combined;\n        error_log /dev/stdout;\n\n        location / {\n            proxy_pass http://docker;\n            proxy_redirect off;\n\n            proxy_set_header Host $host;\n            proxy_set_header X-Real-IP $remote_addr;\n            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n\n            client_max_body_size 10m;\n            client_body_buffer_size 128k;\n\n            proxy_connect_timeout 90;\n            proxy_send_timeout 120;\n            proxy_read_timeout 120;\n\n            proxy_buffer_size 4k;\n            proxy_buffers 4 32k;\n            proxy_busy_buffers_size 64k;\n            proxy_temp_file_write_size 64k;\n        }\n    }\n}\n"
  },
  {
    "path": "docker/entrypoint.sh",
    "content": "#!/bin/bash\n# shellcheck shell=bash\n# shellcheck disable=SC2016\n# shellcheck disable=SC2155\n\nGreen=\"\\033[32m\"\nRed=\"\\033[31m\"\nYellow='\\033[33m'\nFont=\"\\033[0m\"\nINFO=\"[${Green}INFO${Font}]\"\nERROR=\"[${Red}ERROR${Font}]\"\nWARN=\"[${Yellow}WARN${Font}]\"\nfunction INFO() {\n    echo -e \"${INFO} ${1}\"\n}\nfunction ERROR() {\n    echo -e \"${ERROR} ${1}\"\n}\nfunction WARN() {\n    echo -e \"${WARN} ${1}\"\n}\n\n# 设置虚拟环境路径（兼容群晖等系统必须这样配置）\nVENV_PATH=\"${VENV_PATH:-/opt/venv}\"\nexport PATH=\"${VENV_PATH}/bin:$PATH\"\n\n# 校正设置目录\nCONFIG_DIR=\"${CONFIG_DIR:-/config}\"\n\n# 记录非系统环境（docker容器表）提供的变量\ndeclare -ga VARS_SET_BY_SCRIPT=()\n\n# 环境变量补全\n# 优先级: 系统环境变量 -> .env 文件 (即使为空字符串) -> 预设默认值\n# 精准适配 Python 端 set_key (quote_mode=\"always\", 单引号包裹, \\' 转义)\nfunction load_config_from_app_env() {\n\n    local env_file=\"${CONFIG_DIR}/app.env\"\n\n    # 定义 [\"变量名\"]=\"预设默认值\"\n    # 禁止填入 CONFIG_DIR 变量，ACME_ENV_ 开头的变量暂时不处理，还是交由 cert.sh 处理\n    declare -A vars_and_default_values=(\n        # update.sh\n        [\"PIP_PROXY\"]=\"\"\n        [\"GITHUB_PROXY\"]=\"\"\n        [\"PROXY_HOST\"]=\"\"\n        [\"GITHUB_TOKEN\"]=\"\"\n        [\"MOVIEPILOT_AUTO_UPDATE\"]=\"release\"\n\n        # database\n        [\"DB_TYPE\"]=\"sqlite\"\n        [\"DB_POSTGRESQL_HOST\"]=\"localhost\"\n        [\"DB_POSTGRESQL_PORT\"]=\"5432\"\n        [\"DB_POSTGRESQL_DATABASE\"]=\"moviepilot\"\n        [\"DB_POSTGRESQL_USERNAME\"]=\"moviepilot\"\n        [\"DB_POSTGRESQL_PASSWORD\"]=\"moviepilot\"\n        [\"DB_POSTGRESQL_POOL_SIZE\"]=\"20\"\n        [\"DB_POSTGRESQL_MAX_OVERFLOW\"]=\"30\"\n\n        # cert\n        [\"ENABLE_SSL\"]=\"false\"\n        [\"SSL_DOMAIN\"]=\"\"\n        [\"NGINX_PORT\"]=\"3000\"\n        [\"PORT\"]=\"3001\"\n        [\"NGINX_CLIENT_MAX_BODY_SIZE\"]=\"10m\"\n    )\n\n    INFO \"开始加载配置 (配置文件: ${env_file})...\"\n\n    shopt -s extglob\n\n    declare -A values_from_env_file\n    if [ -f \"${env_file}\" ]; then\n        INFO \"检测到 ${env_file} 文件，尝试解析...\"\n        while IFS= read -r line || [ -n \"$line\" ]; do\n            if [[ \"$line\" =~ ^[[:space:]]*# || -z \"$line\" ]]; then\n                continue\n            fi\n\n            local key_in_file value_raw_in_file\n            if [[ \"$line\" =~ ^[[:space:]]*([A-Za-z_][A-Za-z0-9_]*)[[:space:]]*=(.*) ]]; then\n                key_in_file=\"${BASH_REMATCH[1]}\"\n                value_raw_in_file=\"${BASH_REMATCH[2]}\"\n\n                if [[ -n \"${vars_and_default_values[$key_in_file]+_}\" ]]; then\n                    local temp_val_after_initial_trim\n                    temp_val_after_initial_trim=\"${value_raw_in_file#\"${value_raw_in_file%%[![:space:]]*}\"}\"\n                    temp_val_after_initial_trim=\"${temp_val_after_initial_trim%\"${temp_val_after_initial_trim##*[![:space:]]}\"}\"\n\n                    local val_before_quote_check=\"${temp_val_after_initial_trim}\"\n                    if [[ ! (\"${temp_val_after_initial_trim:0:1}\" == \"'\" && \"${temp_val_after_initial_trim: -1}\" == \"'\") ]]; then\n                        if [[ \"${temp_val_after_initial_trim}\" =~ ^(.*)[[:space:]]+# ]]; then\n                            val_before_quote_check=\"${BASH_REMATCH[1]}\"\n                            val_before_quote_check=\"${val_before_quote_check%%+([[:space:]])}\"\n                        elif [[ \"${temp_val_after_initial_trim:0:1}\" == \"#\" ]]; then\n                            val_before_quote_check=\"\"\n                        fi\n                    fi\n\n                    local parsed_value_from_file\n                    if [[ \"${val_before_quote_check:0:1}\" == \"'\" && \"${val_before_quote_check: -1}\" == \"'\" && ${#val_before_quote_check} -ge 2 ]]; then\n                        parsed_value_from_file=\"${val_before_quote_check:1:${#val_before_quote_check}-2}\"\n                        parsed_value_from_file=\"${parsed_value_from_file//\\\\\\'/__MP_PARSER_SQUOTE__}\"\n                        parsed_value_from_file=\"${parsed_value_from_file//__MP_PARSER_SQUOTE__/\\'}\"\n                    elif [ -z \"${val_before_quote_check}\" ]; then\n                        parsed_value_from_file=\"\"\n                    else\n                        WARN \"位于 ${env_file} 中的键 ${key_in_file} 对应值 ${val_before_quote_check} 未按规范使用单引号包裹，将采用字面量解析。\"\n                        parsed_value_from_file=\"${val_before_quote_check}\"\n                    fi\n                    values_from_env_file[\"${key_in_file}\"]=\"${parsed_value_from_file}\"\n                fi\n            else\n                WARN \"跳过 ${env_file} 中格式不正确的行: $line\"\n            fi\n        done < <(sed -e '1s/^\\xEF\\xBB\\xBF//' -e 's/\\r$//g' \"${env_file}\")\n        INFO \"${env_file} 解析完毕。\"\n     else\n        INFO \"${env_file} 文件不存在，跳过文件加载。\"\n     fi\n\n    INFO \"正在根据优先级确定并导出配置值...\"\n    for var_name in \"${!vars_and_default_values[@]}\"; do\n        local fallback_value=\"${vars_and_default_values[$var_name]}\"\n        local final_value\n        local value_source=\"未设置\"\n        # 标志变量是否来自初始环境\n        local set_by_initial_env=false\n\n        # 检查变量是否在环境中已设置（可能为空）\n        if eval \"[ -n \\\"\\${${var_name}+x}\\\" ]\"; then\n            # 获取其值\n            final_value=\"$(eval echo \\\"\\$\"${var_name}\"\\\")\"\n            value_source=\"系统环境变量\"\n            set_by_initial_env=true\n        elif [[ -n \"${values_from_env_file[\"${var_name}\"]+_}\" ]]; then\n            final_value=\"${values_from_env_file[\"${var_name}\"]}\"\n            value_source=\".env 文件\"\n        else\n            final_value=\"${fallback_value}\"\n            value_source=\"内置默认值\"\n        fi\n\n        # 不论来源如何，都导出变量，以便脚本的其余部分和子进程使用\n        # (例如 envsubst, mp_update.sh, cert.sh)\n        if declare -gx \"${var_name}=${final_value}\"; then\n            if [ -z \"${final_value}\" ]; then\n                 INFO \"变量 ${var_name}, 值为空 (来源: ${value_source})。\"\n            else\n                 INFO \"变量 ${var_name}, 值: ${final_value} (来源: ${value_source})。\"\n            fi\n\n            # 如果变量不是来自初始环境变量，则记录下来以便稍后 unset\n            if ! ${set_by_initial_env}; then\n                # 检查是否已在数组中，避免重复添加\n                local found_in_script_vars=false\n                for item in \"${VARS_SET_BY_SCRIPT[@]}\"; do\n                    if [[ \"$item\" == \"$var_name\" ]]; then\n                        found_in_script_vars=true\n                        break\n                    fi\n                done\n                if ! ${found_in_script_vars}; then\n                    VARS_SET_BY_SCRIPT+=(\"${var_name}\")\n                fi\n            fi\n        else\n            ERROR \"导出变量 ${var_name}, 值: '${final_value}'失败 (来源: ${value_source}) \"\n        fi\n    done\n\n    shopt -u extglob\n    INFO \"配置加载流程执行完毕。\"\n}\n\n# 优雅退出\nfunction graceful_exit() {\n    local exit_code=${1:-0}\n    local reason=${2:-python_exit}\n\n    if [ \"$reason\" = \"signal\" ]; then\n        INFO \"→ 收到停止信号，执行精准清理程序...\"\n    else\n        INFO \"→ 主进程已退出 (代码: $exit_code)，执行清理程序...\"\n    fi\n\n    # 第一步：停止前端 Nginx\n    # 默认配置启动的 Nginx，默认 PID 在 /var/run/nginx.pid\n    INFO \"→ [1/3] 正在关闭前端 Nginx...\"\n    nginx -c /etc/nginx/nginx.conf -s stop 2>/dev/null || true\n\n    # 第二步：等待 Python 退出\n    # 由于使用了 tini -g，Python 已经收到了信号，我们只需等待\n    if [ -n \"$PYTHON_PID\" ] && ps -p \"$PYTHON_PID\" > /dev/null; then\n        INFO \"→ [2/3] 正在等待 Python (PID: $PYTHON_PID) 完成清理...\"\n        # 这里的 wait 会阻塞，直到 Python 真正退出\n        wait \"$PYTHON_PID\" 2>/dev/null || true\n    fi\n\n    # 第三步：最后关闭 Docker Proxy\n    # 必须指定配置文件路径，否则 nginx -s stop 找不到它\n    INFO \"→ [3/3] 后端已安全退出，正在关闭 Docker Proxy...\"\n    if [ -S \"/var/run/docker.sock\" ]; then\n        nginx -c /etc/nginx/docker_http_proxy.conf -s stop 2>/dev/null || true\n    fi\n\n    # 根据退出码判断最终日志性质\n    # 0: 正常退出\n    # 130/143: 被系统信号终止（通常也视为预期的清理退出）\n    if [ \"$exit_code\" -eq 0 ] || [ \"$exit_code\" -eq 130 ] || [ \"$exit_code\" -eq 143 ]; then\n        INFO \"→ 所有服务已按序清理，容器正常退出 (ExitCode: $exit_code)。\"\n    else\n        # 非预期退出码，使用 ERROR 级别并加重提示\n        ERROR \"→ 清理完成，但主进程检测到异常退出 (ExitCode: $exit_code)！\"\n    fi\n    exit \"$exit_code\"\n}\n\n# 使用env配置\nload_config_from_app_env\n\n# 生成HTTPS配置块\nif [ \"${ENABLE_SSL}\" = \"true\" ]; then\n    export HTTPS_SERVER_CONF=$(cat <<EOF\n    server {\n        include /etc/nginx/mime.types;\n        default_type application/octet-stream;\n\n        listen ${SSL_NGINX_PORT:-443} ssl;\n        listen [::]:${SSL_NGINX_PORT:-443} ssl;\n        server_name ${SSL_DOMAIN:-moviepilot};\n\n        # SSL证书路径\n        ssl_certificate ${CONFIG_DIR}/certs/latest/fullchain.pem;\n        ssl_certificate_key ${CONFIG_DIR}/certs/latest/privkey.pem;\n\n        # SSL安全配置\n        ssl_protocols TLSv1.2 TLSv1.3;\n        ssl_ciphers 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384';\n        ssl_prefer_server_ciphers on;\n        ssl_session_cache shared:SSL:10m;\n        ssl_session_timeout 10m;\n\n        # 公共配置\n        include common.conf;\n    }\nEOF\n)\nelse\n    export HTTPS_SERVER_CONF=\"# HTTPS未启用\"\nfi\n\n# 使用 `envsubst` 将模板文件中的 ${NGINX_PORT} 替换为实际的环境变量值\nenvsubst '${NGINX_PORT}${PORT}${NGINX_CLIENT_MAX_BODY_SIZE}${ENABLE_SSL}${HTTPS_SERVER_CONF}' < /etc/nginx/nginx.template.conf > /etc/nginx/nginx.conf\n\n# 自动更新\ncd /\nsource /usr/local/bin/mp_update.sh\ncd /app || exit\n\n# 更改 moviepilot userid 和 groupid\ngroupmod -o -g \"${PGID}\" moviepilot\nusermod -o -u \"${PUID}\" moviepilot\n\n# 更改文件权限\nchown -R moviepilot:moviepilot \\\n    \"${HOME}\" \\\n    /app \\\n    /public \\\n    \"${CONFIG_DIR}\" \\\n    /var/lib/nginx \\\n    /var/log/nginx\nchown moviepilot:moviepilot /etc/hosts /tmp\n\n# 下载浏览器内核\nif [[ \"$HTTPS_PROXY\" =~ ^https?:// ]] || [[ \"$HTTPS_PROXY\" =~ ^https?:// ]] || [[ \"$PROXY_HOST\" =~ ^https?:// ]]; then\n  HTTPS_PROXY=\"${HTTPS_PROXY:-${https_proxy:-$PROXY_HOST}}\" gosu moviepilot:moviepilot playwright install ${PLAYWRIGHT_BROWSER_TYPE:-chromium}\nelse\n  gosu moviepilot:moviepilot playwright install ${PLAYWRIGHT_BROWSER_TYPE:-chromium}\nfi\n\n# 证书管理\nsource /app/docker/cert.sh\n\n# 启动前端nginx服务\nINFO \"→ 启动前端nginx服务...\"\nnginx\n\n# 捕获信号并跳转到函数\ntrap 'graceful_exit 130 \"signal\"' SIGINT\ntrap 'graceful_exit 143 \"signal\"' SIGTERM\n\n# 启动docker http proxy nginx\nif [ -S \"/var/run/docker.sock\" ]; then\n    INFO \"→ 启动 Docker Proxy...\"\n    nginx -c /etc/nginx/docker_http_proxy.conf\n    # 上面nginx是通过root启动的，会将目录权限改成root，所以需要重新再设置一遍权限\n    chown -R moviepilot:moviepilot \\\n        /var/lib/nginx \\\n        /var/log/nginx\nfi\n\n# 设置后端服务权限掩码\numask \"${UMASK}\"\n\n# 清除非系统环境导入的变量，保证转移到 dumb-init 的时候，不会带入不必要的环境变量\nINFO \"准备为 Python 应用清理的非系统环境导入的变量...\"\nif [ ${#VARS_SET_BY_SCRIPT[@]} -gt 0 ]; then\n    for var_to_unset in \"${VARS_SET_BY_SCRIPT[@]}\"; do\n        # 再次确认变量确实存在于当前环境中（虽然理论上应该存在）\n        if eval \"[ -n \\\"\\${${var_to_unset}+x}\\\" ]\"; then\n            INFO \"取消设置环境变量: ${var_to_unset}\"\n            unset \"${var_to_unset}\"\n        else\n            WARN \"变量 ${var_to_unset} 已不存在，无需取消设置。\"\n        fi\n    done\nelse\n    INFO \"没有由非系统环境导入的变量需要清理。\"\nfi\n\n# 启动后端服务\nINFO \"→ 启动后端服务...\"\nif [ \"${START_NOGOSU:-false}\" = \"true\" ]; then\n    \"${VENV_PATH}/bin/python3\" app/main.py > /dev/stdout 2> /dev/stderr &\nelse\n    gosu moviepilot:moviepilot \"${VENV_PATH}/bin/python3\" app/main.py > /dev/stdout 2> /dev/stderr &\nPYTHON_PID=$!\n\n# 等待 Python 进程退出。\n# 如果收到信号，trap 会中断 wait，并执行 graceful_exit。\n# 如果 Python 正常退出，wait 会结束，然后我们手动调用 graceful_exit。\nwait \"$PYTHON_PID\" 2>/dev/null\nexit_code=$?\n\n# 如果 Python 自己退出了（非信号触发），执行清理\ngraceful_exit \"$exit_code\" \"python_exit\"\n"
  },
  {
    "path": "docker/nginx.common.conf",
    "content": "\n# 公共根目录\nroot /public;\n\n# 主应用路由\nlocation / {\n    expires off;\n    add_header Cache-Control \"no-cache, no-store, must-revalidate\";\n    try_files $uri $uri/ /index.html;\n}\n\n# 本地CookieCloud\nlocation /cookiecloud {\n    proxy_pass http://backend_api;\n    rewrite ^.+mock-server/?(.*)$ /$1 break;\n    proxy_http_version 1.1;\n    proxy_buffering off;\n    proxy_cache off;\n    proxy_redirect off;\n    proxy_set_header Connection \"\";\n    proxy_set_header Upgrade $http_upgrade;\n    proxy_set_header X-Real-IP $remote_addr;\n    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n    proxy_set_header Host $http_host;\n    proxy_set_header X-Nginx-Proxy true;\n\n    # 超时设置\n    proxy_read_timeout 600s;\n}\n\n# SSE特殊配置\nlocation ~ ^/api/v1/system/(message|progress/) {\n    # SSE MIME类型设置\n    default_type text/event-stream;\n\n    # 禁用缓存\n    add_header Cache-Control no-cache;\n    add_header X-Accel-Buffering no;\n    proxy_buffering off;\n    proxy_cache off;\n\n    # 代理设置\n    proxy_pass http://backend_api;\n    proxy_http_version 1.1;\n    proxy_set_header Connection \"\";\n    proxy_set_header Host $host;\n    proxy_set_header X-Real-IP $remote_addr;\n    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n\n    # 超时设置\n    proxy_read_timeout 3600s;\n}\n\n# API代理配置\nlocation /api {\n    proxy_pass http://backend_api;\n    rewrite ^.+mock-server/?(.*)$ /$1 break;\n    proxy_http_version 1.1;\n    proxy_buffering off;\n    proxy_cache off;\n    proxy_redirect off;\n    proxy_set_header Connection \"\";\n    proxy_set_header Upgrade $http_upgrade;\n    proxy_set_header X-Real-IP $remote_addr;\n    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n    proxy_set_header Host $http_host;\n    proxy_set_header X-Nginx-Proxy true;\n\n    # 超时设置\n    proxy_read_timeout 600s;\n}\n\n# 图片类静态资源\nlocation ~* \\.(png|jpg|jpeg|gif|ico|svg)$ {\n    expires 1y;\n    add_header Cache-Control \"public, immutable\";\n}\n\n# JS 和 CSS 静态资源缓存（排除 /api/v1 路径）\nlocation ~* ^/(?!api/v1).*\\.(js|css)$ {\n    try_files $uri =404;\n    expires 30d;\n    add_header Cache-Control \"public\";\n    add_header Vary Accept-Encoding;\n}\n\n# assets目录\nlocation /assets {\n    expires 1y;\n    add_header Cache-Control \"public, immutable\";\n}\n\n# 站点图标\nlocation /api/v1/site/icon/ {\n    # 站点图标缓存\n    proxy_cache my_cache;\n    # 缓存响应码为200和302的请求1小时\n    proxy_cache_valid 200 302 1h;\n    # 缓存其他响应码的请求5分钟\n    proxy_cache_valid any 5m;\n    # 缓存键的生成规则\n    proxy_cache_key \"$scheme$request_method$host$request_uri\";\n    proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;\n\n    # 向后端API转发请求\n    proxy_pass http://backend_api;\n}"
  },
  {
    "path": "docker/nginx.template.conf",
    "content": "user moviepilot;\nworker_processes auto;\npid /var/run/nginx.pid; \nworker_cpu_affinity auto;\n\n\nevents {\n    worker_connections 1024;\n}\n\n\nhttp {\n    # 设置缓存路径和缓存区大小\n    proxy_cache_path /tmp levels=1:2 keys_zone=my_cache:10m max_size=100m inactive=60m use_temp_path=off;\n\n    sendfile on;\n\n    keepalive_timeout 3600;\n\n    client_max_body_size ${NGINX_CLIENT_MAX_BODY_SIZE};\n\n    gzip on;\n    gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;\n    gzip_proxied any;\n    gzip_min_length 256;\n    gzip_vary on;\n    gzip_comp_level 6;\n\n    # HTTP\n    server {\n        include /etc/nginx/mime.types;\n        default_type application/octet-stream;\n\n        listen ${NGINX_PORT};\n        listen [::]:${NGINX_PORT};\n        server_name moviepilot;\n\n        # 公共配置\n        include common.conf;\n    }\n\n    # HTTPS\n    ${HTTPS_SERVER_CONF}\n\n    upstream backend_api {\n        # 后端API的地址和端口\n        server 127.0.0.1:${PORT};\n        # 可以添加更多后端服务器作为负载均衡\n    }\n\n}\n"
  },
  {
    "path": "docker/update.sh",
    "content": "#!/bin/bash\n# shellcheck shell=bash\n# shellcheck disable=SC2086\n# shellcheck disable=SC2144\n\nGreen=\"\\033[32m\"\nRed=\"\\033[31m\"\nYellow='\\033[33m'\nFont=\"\\033[0m\"\nINFO=\"[${Green}INFO${Font}]\"\nERROR=\"[${Red}ERROR${Font}]\"\nWARN=\"[${Yellow}WARN${Font}]\"\nfunction INFO() {\n    echo -e \"${INFO} ${1}\"\n}\nfunction ERROR() {\n    echo -e \"${ERROR} ${1}\"\n}\nfunction WARN() {\n    echo -e \"${WARN} ${1}\"\n}\n\n# 设置虚拟环境路径（兼容群晖等系统必须这样配置）\nVENV_PATH=\"${VENV_PATH:-/opt/venv}\"\nexport PATH=\"${VENV_PATH}/bin:$PATH\"\n\n# 下载及解压\nfunction download_and_unzip() {\n    local retries=0\n    local max_retries=3\n    local url=\"$1\"\n    local target_dir=\"$2\"\n    INFO \"→ 正在下载 ${url}...\"\n    while [ $retries -lt $max_retries ]; do\n        if curl ${CURL_OPTIONS} \"${url}\" ${CURL_HEADERS} | busybox unzip -d ${TMP_PATH} - > /dev/null; then\n            if [ -e ${TMP_PATH}/MoviePilot-* ]; then\n                mv ${TMP_PATH}/MoviePilot-* ${TMP_PATH}/\"${target_dir}\"\n            fi\n            break\n        else\n            WARN \"下载 ${url} 失败，正在进行第 $((retries + 1)) 次重试...\"\n            retries=$((retries + 1))\n        fi\n    done\n    if [ $retries -eq $max_retries ]; then\n        ERROR \"下载 ${url} 失败，已达到最大重试次数！\"\n        return 1\n    else\n        return 0\n    fi\n}\n\n# 下载程序资源，$1: 后端版本路径\nfunction install_backend_and_download_resources() {\n    # 更新后端程序\n    if ! download_and_unzip \"${GITHUB_PROXY}https://github.com/jxxghp/MoviePilot/archive/refs/${1}\" \"App\"; then\n        WARN \"后端程序下载失败，继续使用旧的程序来启动...\"\n        return 1\n    fi\n    INFO \"后端程序下载成功\"\n    \n    # 检查依赖是否有变化\n    INFO \"→ 检查依赖变化...\"\n    if [ -f \"${TMP_PATH}/App/requirements.in\" ]; then\n        if ! cmp -s /app/requirements.in \"${TMP_PATH}/App/requirements.in\"; then\n            INFO \"检测到依赖变化，正在更新虚拟环境...\"\n            # 备份当前requirements.txt\n            cp /app/requirements.txt /tmp/requirements.txt.backup\n            # 复制新的requirements.in\n            cp \"${TMP_PATH}/App/requirements.in\" /app/requirements.in\n            # 重新编译依赖\n            if ! ${VENV_PATH}/bin/pip-compile /app/requirements.in; then\n                ERROR \"依赖编译失败，恢复原依赖\"\n                cp /tmp/requirements.txt.backup /app/requirements.txt\n                return 1\n            fi\n            # 安装新依赖\n            if ! ${VENV_PATH}/bin/pip install ${PIP_OPTIONS} --root-user-action=ignore -r /app/requirements.txt; then\n                ERROR \"依赖安装失败，恢复原依赖\"\n                cp /tmp/requirements.txt.backup /app/requirements.txt\n                return 1\n            fi\n            INFO \"依赖更新成功\"\n        else\n            INFO \"依赖无变化，跳过依赖更新\"\n        fi\n    else\n        WARN \"未找到requirements.in文件，跳过依赖检查\"\n    fi\n    \n    # 如果是\"heads/v2.zip\"，则查找v2开头的最新版本号\n    if [[ \"${1}\" == \"heads/v2.zip\" ]]; then\n        INFO \"→ 正在获取前端最新版本号...\"\n        # 获取所有发布的版本列表，并筛选出以v2开头的版本号\n        releases=$(curl ${CURL_OPTIONS} \"https://api.github.com/repos/jxxghp/MoviePilot-Frontend/releases\" ${CURL_HEADERS} | jq -r '.[].tag_name' | grep \"^v2\\.\")\n        if [ -z \"$releases\" ]; then\n            WARN \"未找到任何v2前端版本，继续启动...\"\n            return 1\n        else\n            # 找到最新的v2版本\n            frontend_version=$(echo \"$releases\" | sort -V | tail -n 1)\n        fi\n        INFO \"前端最新版本号：${frontend_version}\"\n    else\n        INFO \"→ 正在获取前端版本号...\"\n        # 从后端文件中读取前端版本号\n        frontend_version=$(sed -n \"s/^FRONTEND_VERSION\\s*=\\s*'\\([^']*\\)'/\\1/p\" ${TMP_PATH}/App/version.py)\n        if [[ \"${frontend_version}\" != *v* ]]; then\n            WARN \"前端版本号获取失败，继续启动...\"\n            return 1\n        fi\n        INFO \"前端版本号：${frontend_version}\"\n    fi\n    # 更新前端程序\n    if ! download_and_unzip \"${GITHUB_PROXY}https://github.com/jxxghp/MoviePilot-Frontend/releases/download/${frontend_version}/dist.zip\" \"dist\"; then\n        WARN \"前端程序下载失败，继续使用旧的程序来启动...\"\n        return 1\n    fi\n    INFO \"前端程序下载成功\"\n    # 备份插件目录\n    INFO \"→ 正在备份插件目录...\"\n    rm -rf /plugins\n    mkdir -p /plugins\n    cp -a /app/app/plugins/* /plugins/\n    rm -f /plugins/__init__.py\n    # 备份站点资源\n    INFO \"→ 正在备份站点资源目录...\"\n    rm -rf /resources_bakcup\n    mkdir /resources_bakcup\n    cp -a /app/app/helper/user.sites.v2.bin /resources_bakcup\n    cp -a /app/app/helper/sites.cp* /resources_bakcup\n    # 清空程序目录\n    rm -rf /app\n    mkdir -p /app\n    # 复制新后端程序\n    cp -a ${TMP_PATH}/App/* /app/\n    # 复制新前端程序\n    rm -rf /public\n    mkdir -p /public\n    cp -a ${TMP_PATH}/dist/* /public/\n    INFO \"程序部分更新成功，前端版本：${frontend_version}，后端版本：${1}\"\n    # 恢复插件目录\n    cp -a /plugins/* /app/app/plugins/\n    # 更新站点资源\n    INFO \"→ 开始更新站点资源...\"\n    if ! download_and_unzip \"${GITHUB_PROXY}https://github.com/jxxghp/MoviePilot-Resources/archive/refs/heads/main.zip\" \"Resources\"; then\n        cp -a /resources_bakcup/* /app/app/helper/\n        rm -rf /resources_bakcup\n        WARN \"站点资源下载失败，继续使用旧的资源来启动...\"\n        return 1\n    fi\n    # 复制新站点资源\n    cp -a ${TMP_PATH}/Resources/resources.v2/* /app/app/helper/\n    INFO \"站点资源更新成功\"\n    # 清理临时目录\n    rm -rf \"${TMP_PATH}\"\n    return 0\n}\n\nfunction test_connectivity_pip() {\n    ${VENV_PATH}/bin/pip uninstall -y pip-hello-world > /dev/null 2>&1\n    case \"$1\" in\n    0)\n        if [[ -n \"${PIP_PROXY}\" ]]; then\n            if ${VENV_PATH}/bin/pip install -i ${PIP_PROXY} pip-hello-world > /dev/null 2>&1; then\n                PIP_OPTIONS=\"-i ${PIP_PROXY}\"\n                PIP_LOG=\"镜像代理模式\"\n                return 0\n            fi\n        fi\n        return 1\n        ;;\n    1)\n        if [[ -n \"${PROXY_HOST}\" ]]; then\n            if ${VENV_PATH}/bin/pip install --proxy=${PROXY_HOST} pip-hello-world > /dev/null 2>&1; then\n                PIP_OPTIONS=\"--proxy=${PROXY_HOST}\"\n                PIP_LOG=\"全局代理模式\"\n                return 0\n            fi\n        fi\n        return 1\n        ;;\n    2)\n        PIP_OPTIONS=\"\"\n        PIP_LOG=\"不使用代理\"\n        return 0\n        ;;\n    esac\n}\n\n# 测试Github连通性\nfunction test_connectivity_github() {\n    case \"$1\" in\n    0)\n        if [[ -n \"${GITHUB_PROXY}\" ]]; then\n            if curl -sL \"${GITHUB_PROXY}https://raw.githubusercontent.com/jxxghp/MoviePilot/main/README.md\" > /dev/null 2>&1; then\n                GITHUB_LOG=\"镜像代理模式\"\n                return 0\n            fi\n        fi\n        return 1\n        ;;\n    1)\n        if [[ -n \"${PROXY_HOST}\" ]]; then\n            if curl -sL -x ${PROXY_HOST} https://raw.githubusercontent.com/jxxghp/MoviePilot/main/README.md > /dev/null 2>&1; then\n                CURL_OPTIONS=\"-sL -x ${PROXY_HOST}\"\n                GITHUB_LOG=\"全局代理模式\"\n                return 0\n            fi\n        fi\n        return 1\n        ;;\n    2)\n        CURL_OPTIONS=\"-sL\"\n        GITHUB_LOG=\"不使用代理\"\n        return 0\n        ;;\n    esac\n}\n\n# 版本号比较\nfunction compare_versions() {\n    local v1=\"$1\"\n    local v2=\"$2\"\n    # 去掉开头的 v 或 V\n    v1=\"${v1#[vV]}\"\n    v2=\"${v2#[vV]}\"\n    local current_ver_parts=()\n    local release_ver_parts=()\n    IFS='.-' read -ra current_ver_parts <<< \"$v1\"\n    IFS='.-' read -ra release_ver_parts <<< \"$v2\"\n    local i\n    local current_ver\n    local release_ver\n\n    for ((i = 0; i < ${#current_ver_parts[@]} || i < ${#release_ver_parts[@]}; i++)); do\n        # 版本号不足位补 0\n        local current_ver_part=\"${current_ver_parts[i]:-0}\"\n        local release_ver_part=\"${release_ver_parts[i]:-0}\"\n        current_ver=$(get_priority \"$current_ver_part\")\n        release_ver=$(get_priority \"$release_ver_part\")\n\n        # 任意一个为-5，不在合法版本号内，无法比较\n        if (( current_ver == -5 || release_ver == -5 )); then\n            ERROR \"存在不合法版本号，无法判断，跳过更新步骤...\"\n            return 1\n        else\n            if (( current_ver > release_ver )); then\n                WARN \"当前版本高于远程版本，跳过更新步骤...\"\n                return 1\n            elif (( current_ver < release_ver )); then\n                INFO \"发现新版本，开始自动升级...\"\n                install_backend_and_download_resources \"tags/$2.zip\"\n                return 0\n            else\n                continue\n            fi\n        fi\n    done\n    WARN \"当前版本已是最新版本，跳过更新步骤...\"\n}\n\n# 优先级转换\nfunction get_priority() {\n    local version=\"$1\"\n    if [[ $version =~ ^[0-9]+$ ]]; then\n        echo $version\n    else\n        case $version in\n            \"stable\")\n                echo -1\n                ;;\n            \"rc\")\n                echo -2\n                ;;\n            \"beta\")\n                echo -3\n                ;;\n            \"alpha\")\n                echo -4\n                ;;\n            # 非数字的不合法版本号\n            *)\n                echo -5\n                ;;\n        esac\n    fi\n}\n\nif [[ \"${MOVIEPILOT_AUTO_UPDATE}\" = \"true\" ]] || [[ \"${MOVIEPILOT_AUTO_UPDATE}\" = \"release\" ]] || [[ \"${MOVIEPILOT_AUTO_UPDATE}\" = \"dev\" ]]; then\n    TMP_PATH=$(mktemp -d)\n    if [ ! -d \"${TMP_PATH}\" ]; then\n        # 如果自动生成 tmp 文件夹失败则手动指定，避免出现数据丢失等情况\n        TMP_PATH=/tmp/mp_update_path\n        if [ -d /tmp/mp_update_path ]; then\n            rm -rf /tmp/mp_update_path\n        fi\n        mkdir -p /tmp/mp_update_path\n    fi\n    # 优先级：镜像站 > 全局 > 不代理\n    # pip\n    retries=0\n    while true; do\n        if test_connectivity_pip ${retries}; then\n            break\n        else\n            retries=$((retries + 1))\n        fi\n    done\n    # Github\n    retries=0\n    while true; do\n        if test_connectivity_github ${retries}; then\n            break\n        else\n            retries=$((retries + 1))\n        fi\n    done\n    INFO \"PIP：${PIP_LOG}，Github：${GITHUB_LOG}\"\n    if [ -n \"${GITHUB_TOKEN}\" ]; then\n        CURL_HEADERS=\"--oauth2-bearer ${GITHUB_TOKEN}\"\n    else\n        CURL_HEADERS=\"\"\n    fi\n    if [ \"${MOVIEPILOT_AUTO_UPDATE}\" = \"dev\" ]; then\n        INFO \"Dev 更新模式\"\n        install_backend_and_download_resources \"heads/v2.zip\"\n    else\n        INFO \"Release 更新模式\"\n        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:]]*$//')\n        if [[ \"${old_version}\" == *APP_VERSION* ]]; then\n            current_version=$(echo \"${old_version}\" | sed -rn \"s/APP_VERSION\\s*=\\s*['\\\"](.*)['\\\"]/\\1/gp\")\n            INFO \"当前版本号：${current_version}\"\n            # 获取所有发布的版本列表，并筛选出以v2开头的版本号\n            releases=$(curl ${CURL_OPTIONS} \"https://api.github.com/repos/jxxghp/MoviePilot/releases\" ${CURL_HEADERS} | jq -r '.[].tag_name' | grep \"^v2\\.\")\n            if [ -z \"$releases\" ]; then\n                WARN \"未找到任何v2后端版本，继续启动...\"\n            else\n                # 找到最新的v2版本\n                latest_v2=$(echo \"$releases\" | sort -V | tail -n 1)\n                INFO \"最新的v2后端版本号：${latest_v2}\"\n                # 使用版本号比较函数进行比较，并下载最新版本\n                compare_versions \"${current_version}\" \"${latest_v2}\"\n            fi\n        else\n            WARN \"当前版本号获取失败，继续启动...\"\n        fi\n    fi\n    if [ -d \"${TMP_PATH}\" ]; then\n        rm -rf \"${TMP_PATH}\"\n    fi\nelif [[ \"${MOVIEPILOT_AUTO_UPDATE}\" = \"false\" ]]; then\n    INFO \"程序自动升级已关闭，如需自动升级请在创建容器时设置环境变量：MOVIEPILOT_AUTO_UPDATE=release\"\nelse\n    INFO \"MOVIEPILOT_AUTO_UPDATE 变量设置错误\"\nfi\n"
  },
  {
    "path": "docs/development-setup.md",
    "content": "## 开发环境设置指南\n\n本文档旨在帮助开发者快速设置开发环境，并介绍如何使用 `pip-tools` 管理依赖项和使用 `safety` 进行安全检查。\n\n### 环境准备\n\n在开始之前，请确保您的系统已安装以下软件：\n\n- **Python 3.12 或更高版本** (暂时兼容 3.11 ，推荐使用 3.12+)\n- **pip** (Python 包管理器)\n- **Git** (用于版本控制)\n\n### 1. 创建虚拟环境\n\n在项目根目录下创建并激活虚拟环境：\n\n- 在 Windows 上：\n\n  ```bash\n  python -m venv venv\n  .\\venv\\Scripts\\activate\n  ```\n\n- 在 macOS/Linux 上：\n\n  ```bash\n  python3 -m venv venv\n  source venv/bin/activate\n  ```\n\n虚拟环境确保项目的依赖项与系统全局环境隔离，防止冲突。\n\n### 2. 使用 pip-tools 管理依赖项\n\n我们使用 `pip-tools` 来管理项目的 Python 依赖项，这有助于保持 `requirements.txt` 文件的一致性和更新性。\n\n#### 安装 pip-tools\n\n首先，您需要安装 `pip-tools` 以便管理依赖项：\n\n```bash\npip install pip-tools\n```\n\n#### 管理依赖项\n\n1. **修改 `requirements.in` 文件**：\n\n   `requirements.in` 文件是项目依赖项的源文件。要添加或更新依赖项，请直接编辑该文件。\n\n2. **更新特定的依赖项**：\n\n   如果你只想更新 `requirements.in` 中的某个特定依赖包，而不影响其他依赖项，可以使用 `--upgrade-package` 选项，指定要升级的包：\n\n   ```bash\n   pip-compile --upgrade-package <package-name> requirements.in\n   ```\n\n   例如，要只升级 `requests` 这个包，你可以运行以下命令：\n\n   ```bash\n   pip-compile --upgrade-package requests requirements.in\n   ```\n\n3. **全量更新依赖项**：\n\n   如果你想更新 `requirements.in` 中的所有依赖包，运行以下命令生成或更新 `requirements.txt` 文件：\n\n   ```bash\n   pip-compile requirements.in\n   ```\n\n   这将根据 `requirements.in` 中指定的依赖项生成一个锁定的 `requirements.txt` 文件。\n\n4. **安装依赖项**：\n\n   使用以下命令安装 `requirements.txt` 文件中列出的依赖项：\n\n   ```bash\n   pip install -r requirements.txt\n   ```\n\n### 3. 运行安全检查\n\n我们使用 `safety` 工具来检查依赖项中是否存在已知的安全漏洞。请确保在每次更新依赖项后都运行安全检查，以确保项目的安全性。\n\n#### 安装 safety\n\n您可以使用以下命令安装 `safety`：\n\n```bash\npip install safety\n```\n\n#### 执行安全检查\n\n运行以下命令以检查 `requirements.txt` 文件中列出的依赖项是否存在安全漏洞：\n\n```bash\nsafety check -r requirements.txt --policy-file=safety.policy.yml > safety_report.txt\n```\n\n这将生成一个名为 `safety_report.txt` 的报告文件，您可以查看其中的漏洞报告并进行相应处理。\n\n### 4. 提交代码前的检查\n\n在提交代码之前，请确保完成以下步骤：\n\n1. **确保依赖项已更新**：如果您对 `requirements.in` 进行了更改，请重新生成 `requirements.txt` 并安装依赖项。\n\n2. **运行安全检查**：确保 `safety` 检查通过，没有新的安全漏洞。\n\n3. **运行测试**：如果项目中包含测试，请确保所有测试都通过。运行以下命令以执行测试：\n\n   ```bash\n   pytest\n   ```\n\n### 5. 参考资源\n\n- [pip-tools 官方文档](https://github.com/jazzband/pip-tools)\n- [safety 官方文档](https://pyup.io/safety/)"
  },
  {
    "path": "docs/mcp-api.md",
    "content": "# MoviePilot MCP (Model Context Protocol) API 文档\n\nMoviePilot 实现了标准的 **Model Context Protocol (MCP)**，允许 AI 智能体（如 Claude, GPT 等）直接调用 MoviePilot 的功能进行媒体管理、搜索、订阅和下载。\n\n## 1. 基础信息\n\n*   **基础路径**: `/api/v1/mcp`\n*   **协议版本**: `2025-11-25, 2025-06-18, 2024-11-05`\n*   **传输协议**: HTTP (JSON-RPC 2.0)\n*   **认证方式**: \n    *   Header: `X-API-KEY: <你的API_KEY>`\n    *   Query: `?apikey=<你的API_KEY>`\n\n## 2. 标准 MCP 协议 (JSON-RPC 2.0)\n\n### 端点\n**POST** `/api/v1/mcp`\n\n### 支持的方法\n- `initialize`: 初始化会话，协商协议版本和能力。\n- `notifications/initialized`: 客户端确认初始化完成。\n- `tools/list`: 获取可用工具列表。\n- `tools/call`: 调用特定工具。\n- `ping`: 连接存活检测。\n\n---\n\n## 4. 客户端配置示例\n\n### Claude Desktop (Anthropic)\n\n在Claude Desktop的配置文件中添加MoviePilot的MCP服务器配置：\n\n**macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json`  \n**Windows**: `%APPDATA%\\Claude\\claude_desktop_config.json`\n\n使用请求头方式：\n```json\n{\n  \"mcpServers\": {\n    \"moviepilot\": {\n      \"url\": \"http://localhost:3001/api/v1/mcp\",\n      \"headers\": {\n        \"X-API-KEY\": \"your_api_key_here\"\n      }\n    }\n  }\n}\n```\n\n或使用查询参数方式：\n```json\n{\n  \"mcpServers\": {\n    \"moviepilot\": {\n      \"url\": \"http://localhost:3001/api/v1/mcp?apikey=your_api_key_here\"\n    }\n  }\n}\n```\n\n## 5. 错误码说明\n\n| 错误码 | 消息 | 说明 |\n| :--- | :--- | :--- |\n| -32700 | Parse error | JSON 格式错误 |\n| -32600 | Invalid Request | 无效的 JSON-RPC 请求 |\n| -32601 | Method not found | 方法不存在 |\n| -32602 | Invalid params | 参数验证失败 |\n| -32002 | Session not found | 会话不存在或已过期 |\n| -32003 | Not initialized | 会话未完成初始化流程 |\n| -32603 | Internal error | 服务器内部错误 |\n\n## 6. RESTful API\n所有工具相关的API端点都在 `/api/v1/mcp` 路径下（保持向后兼容）。\n\n### 1. 列出所有工具\n\n**GET** `/api/v1/mcp/tools`\n\n获取所有可用的MCP工具列表。\n\n**认证**: 需要API KEY，在请求头中添加 `X-API-KEY: <api_key>` 或在查询参数中添加 `apikey=<api_key>`\n\n**响应示例**:\n```json\n[\n  {\n    \"name\": \"add_subscribe\",\n    \"description\": \"Add media subscription to create automated download rules...\",\n    \"inputSchema\": {\n      \"type\": \"object\",\n      \"properties\": {\n        \"title\": {\n          \"type\": \"string\",\n          \"description\": \"The title of the media to subscribe to\"\n        },\n        \"year\": {\n          \"type\": \"string\",\n          \"description\": \"Release year of the media\"\n        },\n        ...\n      },\n      \"required\": [\"title\", \"year\", \"media_type\"]\n    }\n  },\n  ...\n]\n```\n\n### 2. 调用工具\n\n**POST** `/api/v1/mcp/tools/call`\n\n调用指定的MCP工具。\n\n**认证**: 需要API KEY，在请求头中添加 `X-API-KEY: <api_key>` 或在查询参数中添加 `apikey=<api_key>`\n\n**请求体**:\n```json\n{\n  \"tool_name\": \"add_subscribe\",\n  \"arguments\": {\n    \"title\": \"流浪地球\",\n    \"year\": \"2019\",\n    \"media_type\": \"movie\"\n  }\n}\n```\n\n**响应示例**:\n```json\n{\n  \"success\": true,\n  \"result\": \"成功添加订阅：流浪地球 (2019)\",\n  \"error\": null\n}\n```\n\n**错误响应示例**:\n```json\n{\n  \"success\": false,\n  \"result\": null,\n  \"error\": \"调用工具失败: 参数验证失败\"\n}\n```\n\n### 3. 获取工具详情\n\n**GET** `/api/v1/mcp/tools/{tool_name}`\n\n获取指定工具的详细信息。\n\n**认证**: 需要API KEY，在请求头中添加 `X-API-KEY: <api_key>` 或在查询参数中添加 `apikey=<api_key>`\n\n**路径参数**:\n- `tool_name`: 工具名称\n\n**响应示例**:\n```json\n{\n  \"name\": \"add_subscribe\",\n  \"description\": \"Add media subscription to create automated download rules...\",\n  \"inputSchema\": {\n    \"type\": \"object\",\n    \"properties\": {\n      \"title\": {\n        \"type\": \"string\",\n        \"description\": \"The title of the media to subscribe to\"\n      },\n      ...\n    },\n    \"required\": [\"title\", \"year\", \"media_type\"]\n  }\n}\n```\n\n### 4. 获取工具参数Schema\n\n**GET** `/api/v1/mcp/tools/{tool_name}/schema`\n\n获取指定工具的参数Schema（JSON Schema格式）。\n\n**认证**: 需要API KEY，在请求头中添加 `X-API-KEY: <api_key>` 或在查询参数中添加 `apikey=<api_key>`\n\n**路径参数**:\n- `tool_name`: 工具名称\n\n**响应示例**:\n```json\n{\n  \"type\": \"object\",\n  \"properties\": {\n    \"title\": {\n      \"type\": \"string\",\n      \"description\": \"The title of the media to subscribe to\"\n    },\n    \"year\": {\n      \"type\": \"string\",\n      \"description\": \"Release year of the media\"\n    },\n    ...\n  },\n  \"required\": [\"title\", \"year\", \"media_type\"]\n}\n```\n"
  },
  {
    "path": "docs/postgresql-setup.md",
    "content": "# PostgreSQL 数据库配置指南\n\nMoviePilot 现在支持 PostgreSQL 数据库，您可以根据需要选择使用 SQLite 或 PostgreSQL。\n\n## 配置选项\n\n### 1. 数据库类型选择\n\n在 `config/app.env` 文件中设置：\n\n```bash\n# 使用 SQLite（默认）\nDB_TYPE=sqlite\n\n# 使用 PostgreSQL\nDB_TYPE=postgresql\n```\n\n### 2. PostgreSQL 配置参数\n\n当 `DB_TYPE=postgresql` 时，以下配置生效：\n\n```bash\n# PostgreSQL 主机地址\nDB_POSTGRESQL_HOST=localhost\n\n# PostgreSQL 端口\nDB_POSTGRESQL_PORT=5432\n\n# PostgreSQL 数据库名\nDB_POSTGRESQL_DATABASE=moviepilot\n\n# PostgreSQL 用户名\nDB_POSTGRESQL_USERNAME=moviepilot\n\n# PostgreSQL 密码\nDB_POSTGRESQL_PASSWORD=moviepilot\n\n# PostgreSQL 连接池大小\nDB_POSTGRESQL_POOL_SIZE=20\n\n# PostgreSQL 连接池溢出数量\nDB_POSTGRESQL_MAX_OVERFLOW=30\n```\n\n## Docker 部署\n\n### 使用外部 PostgreSQL\n\n如果您想使用外部的 PostgreSQL 服务：\n\n1. 确保外部 PostgreSQL 服务已启动并可访问\n2. 设置环境变量指向外部服务：\n```bash\nDB_TYPE=postgresql\nDB_POSTGRESQL_HOST=your-postgresql-host\nDB_POSTGRESQL_PORT=5432\nDB_POSTGRESQL_DATABASE=moviepilot\nDB_POSTGRESQL_USERNAME=your-username\nDB_POSTGRESQL_PASSWORD=your-password\n```\n\n## 数据迁移\n\n### 从 SQLite 迁移到 PostgreSQL\n\n1. 备份现有的 SQLite 数据库文件（`config/user.db`）\n2. 修改配置为 PostgreSQL\n3. 启动应用，数据库表会自动创建\n4. 使用数据库迁移工具或手动导入数据\n\n#### 注意事项\n完成数据迁移后需要对postgresql中的表进行索引初始值进行更新，否则会出现唯一索引已存在的异常\n例如：\n```json\n【EventType.SiteUpdated 事件处理出错】\n\nSiteChain.cache_site_userdata\n(psycopg2.errors.UniqueViolation) duplicate key value violates unique constraint \"siteuserdata_pkey\"\nDETAIL:  Key (id)=(18) already exists.\n\n[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]\n[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'}]\n(Background on this error at: https://sqlalche.me/e/20/gkpj)\n```\n\n需要对每一个表分别执行下面的语句(下面的SQL以`workflowc`数据表为例，每张表请自行修改，其中`user`表因为关键字原因，应该写成`public.user`的方式)：\n\n```sql\nDO $$\nDECLARE\n    max_id INTEGER;\nBEGIN\n    -- 查询最大 ID 值\n    SELECT COALESCE(MAX(id), 0) INTO max_id FROM workflow;\n\n    -- 调整序列\n    EXECUTE format('ALTER SEQUENCE workflow_id_seq RESTART WITH %s', max_id + 1);\nEND $$;\n```\n\n### 从 PostgreSQL 迁移到 SQLite\n\n1. 导出 PostgreSQL 数据\n2. 修改配置为 SQLite\n3. 启动应用，数据库表会自动创建\n4. 导入数据到 SQLite\n\n## 数据备份\n\n### PostgreSQL 数据备份\n\nPostgreSQL 数据存储在 `${CONFIG_DIR}/postgresql/` 目录中，您可以通过以下方式进行备份：\n\n#### 1. 文件级备份\n```bash\n# 备份整个PostgreSQL数据目录\ntar -czf postgresql_backup_$(date +%Y%m%d_%H%M%S).tar.gz config/postgresql/\n```\n\n#### 2. 数据库级备份\n```bash\n# 进入容器\ndocker exec -it moviepilot bash\n\n# 使用pg_dump备份\npg_dump -h localhost -U moviepilot -d moviepilot > /config/moviepilot_backup.sql\n\n# 或使用pg_dumpall备份所有数据库\npg_dumpall -h localhost -U moviepilot > /config/all_databases_backup.sql\n```\n\n#### 3. 恢复数据\n```bash\n# 恢复单个数据库\npsql -h localhost -U moviepilot -d moviepilot < /config/moviepilot_backup.sql\n\n# 恢复所有数据库\npsql -h localhost -U moviepilot < /config/all_databases_backup.sql\n```\n\n## 性能优化\n\n### PostgreSQL 优化建议\n\n1. **连接池配置**：\n   - 根据应用负载调整 `DB_POSTGRESQL_POOL_SIZE`\n   - 设置合适的 `DB_POSTGRESQL_MAX_OVERFLOW`\n\n2. **数据库配置**：\n   - 调整 `shared_buffers`\n   - 配置 `work_mem`\n   - 设置合适的 `maintenance_work_mem`\n\n3. **索引优化**：\n   - 为常用查询字段添加索引\n   - 定期执行 `VACUUM` 和 `ANALYZE`\n\n## 故障排除\n\n### 常见问题\n\n1. **连接失败**：\n   - 检查 PostgreSQL 服务是否启动\n   - 验证连接参数是否正确\n   - 确认网络连接和防火墙设置\n\n2. **权限问题**：\n   - 确保用户有足够的数据库权限\n   - 检查 `pg_hba.conf` 配置\n\n3. **性能问题**：\n   - 监控连接池使用情况\n   - 检查慢查询日志\n   - 优化数据库配置\n\n### 日志查看\n\nPostgreSQL 相关日志可以在以下位置查看：\n\n- Docker 容器：`${CONFIG_DIR}/postgresql/logs/`\n- 系统日志：`journalctl -u postgresql`\n\n## 注意事项\n\n1. **兼容性**：PostgreSQL 支持从 MoviePilot v2.0 开始\n2. **备份**：建议定期备份数据库\n3. **版本**：建议使用 PostgreSQL 12 或更高版本\n4. **字符集**：确保使用 UTF-8 字符集\n\n## 技术支持\n\n如果遇到问题，请：\n\n1. 查看应用日志\n2. 检查 PostgreSQL 日志\n3. 在 GitHub Issues 中报告问题\n"
  },
  {
    "path": "frozen.spec",
    "content": "# -*- mode: python ; coding: utf-8 -*-\n\ndef collect_pkg_data(package: str, include_py_files: bool = False, subdir: str = None):\n    \"\"\"\n    Collect all data files from the given package.\n    \"\"\"\n    from pathlib import Path\n    from PyInstaller.utils.hooks import get_package_paths, PY_IGNORE_EXTENSIONS\n    from PyInstaller.building.datastruct import TOC\n\n    data_toc = TOC()\n\n    # Accept only strings as packages.\n    if type(package) is not str:\n        raise ValueError\n    try:\n        pkg_base, pkg_dir = get_package_paths(package)\n    except ValueError:\n        return data_toc\n    if subdir:\n        pkg_path = Path(pkg_dir) / subdir\n    else:\n        pkg_path = Path(pkg_dir)\n    # Walk through all file in the given package, looking for data files.\n    if not pkg_path.exists():\n        return data_toc\n    for file in pkg_path.rglob('*'):\n        if file.is_file():\n            extension = file.suffix\n            if not include_py_files and (extension in PY_IGNORE_EXTENSIONS):\n                continue\n            data_toc.append((str(file.relative_to(pkg_base)), str(file), 'DATA'))\n    return data_toc\n\n\ndef collect_local_submodules(package: str):\n    \"\"\"\n    Collect all local submodules from the given package.\n    \"\"\"\n    import os\n    from pathlib import Path\n    package_dir = Path(package.replace('.', os.sep))\n    submodules = [package]\n    # Walk through all file in the given package, looking for data files.\n    if not package_dir.exists():\n        return []\n    for file in package_dir.rglob('*.py'):\n        if file.name == '__init__.py':\n            module = f\"{file.parent}\".replace(os.sep, '.')\n        else:\n            module = f\"{file.parent}.{file.stem}\".replace(os.sep, '.')\n        if module not in submodules:\n            submodules.append(module)\n    return submodules\n\n\nhiddenimports = [\n                    'passlib.handlers.bcrypt',\n                    'app.modules',\n                    'app.plugins',\n                ] + collect_local_submodules('app.modules') + collect_local_submodules('app.plugins')\n\nblock_cipher = None\n\na = Analysis(\n    ['app/main.py'],\n    pathex=[],\n    binaries=[],\n    datas=[],\n    hiddenimports=hiddenimports,\n    hookspath=[],\n    hooksconfig={},\n    runtime_hooks=[],\n    excludes=[],\n    noarchive=False,\n)\n\npyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)\n\nexe = EXE(\n    pyz,\n    a.scripts,\n    a.binaries,\n    a.zipfiles,\n    a.datas + [('./app.ico', './app.ico', 'DATA')],\n    collect_pkg_data('config'),\n    collect_pkg_data('nginx'),\n    collect_pkg_data('cf_clearance'),\n    collect_pkg_data('zhconv'),\n    collect_pkg_data('cn2an'),\n    collect_pkg_data('Pinyin2Hanzi'),\n    collect_pkg_data('database', include_py_files=True),\n    collect_pkg_data('app.helper'),\n    [],\n    name='MoviePilot',\n    debug=False,\n    bootloader_ignore_signals=False,\n    strip=False,\n    upx=True,\n    upx_exclude=[],\n    runtime_tmpdir=None,\n    console=False,\n    disable_windowed_traceback=False,\n    argv_emulation=False,\n    target_arch=None,\n    codesign_identity=None,\n    entitlements_file=None,\n    icon=\"app.ico\"\n)\n"
  },
  {
    "path": "requirements.in",
    "content": "Cython~=3.1.2\npydantic>=2.0.0,<3.0.0\npydantic-settings>=2.0.0,<3.0.0\nSQLAlchemy~=2.0.41\nuvicorn~=0.34.3\nfastapi~=0.115.14\npasslib~=1.7.4\nPyJWT~=2.10.1\npython-multipart~=0.0.9\naiofiles~=24.1.0\naioshutil~=1.5\nalembic~=1.16.2\nbcrypt~=4.0.1\nregex~=2024.11.6\ncn2an~=0.5.19\ndateparser~=1.2.2\npython-dateutil~=2.8.2\nzhconv~=1.4.3\nanitopy~=2.1.1\nrequests[socks]~=2.32.4\nurllib3~=2.5.0\nlxml~=6.0.0\npyquery~=2.0.1\nruamel.yaml~=0.18.14\nAPScheduler~=3.11.0\ncryptography~=45.0.4\npytz~=2025.2\npycryptodome~=3.23.0\nqbittorrent-api==2025.5.0\nplexapi~=4.17.0\ntransmission-rpc~=4.3.0\nJinja2~=3.1.6\npyparsing~=3.2.3\nfunc_timeout==4.3.5\nbs4~=0.0.2\nbeautifulsoup4~=4.13.4\npillow~=11.2.1\npillow-avif-plugin~=1.5.2\npyTelegramBotAPI~=4.27.0\ntelegramify-markdown~=0.5.2\nplaywright~=1.53.0\ncf_clearance~=0.31.0\ntorrentool~=1.2.0\nslack-bolt~=1.23.0\nslack-sdk~=3.35.0\ndiscord.py==2.6.4\nchardet~=5.2.0\nstarlette~=0.46.2\nPyVirtualDisplay~=3.0\npsutil~=7.0.0\npython-dotenv~=1.1.1\npython-hosts~=1.1.2\nwatchdog~=6.0.0\nwatchfiles~=1.1.0\ncacheout~=0.16.0\nclick~=8.2.1\nrequests-cache~=1.2.1\nparse~=1.20.2\ndocker~=7.1.0\npywin32==310; platform_system == \"Windows\"\ncachetools~=6.1.0\nfast-bencode~=1.1.7\npystray~=0.19.5\npyotp~=2.9.0\nwebauthn~=2.7.0\nPinyin2Hanzi~=0.1.1\npywebpush~=2.0.3\naiopathlib~=0.6.0\nasynctempfile~=0.5.0\naiosqlite~=0.21.0\npsycopg2-binary~=2.9.10\nasyncpg~=0.30.0\njieba~=0.42.1\nrsa~=4.9\nredis~=6.2.0\nasync_timeout~=5.0.1; python_full_version < \"3.11.3\"\npackaging~=25.0\noss2~=2.19.1\ntqdm~=4.67.1\nsetuptools~=78.1.0\npympler~=1.1\nsmbprotocol~=1.15.0\nsetproctitle~=1.3.6\nhttpx[socks]~=0.28.1\nlangchain~=0.3.27\nlangchain-core~=0.3.76\nlangchain-community~=0.3.29\nlangchain-openai~=0.3.33\nlangchain-google-genai~=2.0.10\nlangchain-deepseek~=0.1.4\nlangchain-experimental~=0.3.4\nopenai~=1.108.2\ngoogle-generativeai~=0.8.5\nddgs~=9.10.0\nwebsocket-client~=1.8.0\n"
  },
  {
    "path": "requirements.txt",
    "content": "-r requirements.in"
  },
  {
    "path": "safety.policy.yml",
    "content": "security:\n  ignore-unpinned-requirements: False\n  ignore-vulnerabilities:\n    70612:\n      reason: The official statement indicates that this vulnerability is not valid because users should use sandboxing when handling untrusted templates.\n    65532:\n      reason: Legacy issue related to tvdbapi usage.\n    40100:\n      reason: Legacy issue related to tvdbapi usage.\n    68094:\n      reason: This vulnerability is resolved by upgrading `python-multipart` to version 0.0.9.\n    65293:\n      reason: This vulnerability is resolved by upgrading `python-multipart` to version 0.0.9.\n    64930:\n      reason: This vulnerability is resolved by upgrading `python-multipart` to version 0.0.9.\n"
  },
  {
    "path": "setup.py",
    "content": "from setuptools import setup, Extension\nfrom Cython.Build import cythonize\nimport glob\n\n# 递归获取所有.py文件\nsources = glob.glob(\"app/**/*.py\", recursive=True)\n\n# 移除不需要编译的文件\nsources.remove(\"app/main.py\")\n\n# 配置编译参数（可选优化选项）\nextensions = [\n    Extension(\n        name=path.replace(\"/\", \".\").replace(\".py\", \"\"),\n        sources=[path],\n        extra_compile_args=[\"-O3\", \"-ffast-math\"],\n    )\n    for path in sources\n]\n\nsetup(\n    name=\"MoviePilot\",\n    author=\"jxxghp\",\n    ext_modules=cythonize(\n        extensions,\n        build_dir=\"build\",\n        compiler_directives={\n            \"language_level\": \"3\",\n            \"auto_pickle\": False,\n            \"embedsignature\": True,\n            \"annotation_typing\": True,\n            \"infer_types\": True,\n            \"binding\": True,\n        }\n    ),\n    script_args=[\"build_ext\", \"-j8\", \"--inplace\"],\n)\n"
  },
  {
    "path": "skills/moviepilot-cli/SKILL.md",
    "content": "---\nname: moviepilot-cli\ndescription: 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.\n---\n\n# MoviePilot CLI\n\n> **Path note:** All script paths in this skill are relative to this skill file.\n\nUse `scripts/mp-cli.js` to interact with the MoviePilot backend.\n\n## Discover Commands\n\n```bash\nnode scripts/mp-cli.js list           # list all available commands\nnode scripts/mp-cli.js show <command> # show parameters, required fields, and usage\n```\n\nAlways run `show <command>` before calling a command. Do not guess parameter names or argument formats.\n\n## Command Groups\n\n| Category     | Commands                                                                                                                                                         |\n| ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| Media Search | search_media, recognize_media, query_media_detail, get_recommendations, search_person, search_person_credits                                                     |\n| Torrent      | search_torrents, get_search_results                                                                                                                              |\n| Download     | add_download, query_download_tasks, delete_download, query_downloaders                                                                                           |\n| Subscription | add_subscribe, query_subscribes, update_subscribe, delete_subscribe, search_subscribe, query_subscribe_history, query_popular_subscribes, query_subscribe_shares |\n| Library      | query_library_exists, query_library_latest, transfer_file, scrape_metadata, query_transfer_history                                                               |\n| Files        | list_directory, query_directory_settings                                                                                                                         |\n| Sites        | query_sites, query_site_userdata, test_site, update_site, update_site_cookie                                                                                     |\n| System       | query_schedulers, run_scheduler, query_workflows, run_workflow, query_rule_groups, query_episode_schedule, send_message                                          |\n\n## Gotchas\n\n- **Don't guess command parameters.** Parameter names vary per command and are not inferrable. Always run `show <command>` first.\n- **`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.\n- **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.\n- **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.\n- **`add_download` is irreversible without manual cleanup.** Always present torrent details and wait for explicit confirmation before calling it.\n- **`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.\n- **`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.\n\n## Common Workflows\n\n### Search and Download\n\n```bash\n# 1. Search TMDB to get tmdb_id\nnode scripts/mp-cli.js search_media title=\"流浪地球2\" media_type=\"movie\"\n\n# [TV only, only if user specified a season] Validate season — see \"Season Validation\" section below\nnode scripts/mp-cli.js query_media_detail tmdb_id=... media_type=\"tv\"\n\n# 2. Search torrents using tmdb_id — results are cached server-side\n#    Response includes available filter options (resolution, release group, etc.)\n#    [Optional] If the user specifies sites, first run query_sites to get IDs, then pass them via sites param\nnode scripts/mp-cli.js query_sites                                                     # get site IDs\nnode scripts/mp-cli.js search_torrents tmdb_id=791373 media_type=\"movie\"               # use user's default sites\nnode scripts/mp-cli.js search_torrents tmdb_id=791373 media_type=\"movie\" sites='1,3'   # override with specific sites\n\n# 3. Present ALL available filter_options to the user and ask which ones to apply\n#    Show every field and its values — do not pre-select or omit any\n#    e.g. \"分辨率: 1080p, 2160p；字幕组: CMCT, PTer；请问需要筛选哪些条件？\"\n\n# 4. Filter cached results based on user preferences and your own judgment\n#    Filter params are ANDed — if results come back empty, drop the most restrictive field and retry\nnode scripts/mp-cli.js get_search_results resolution='1080p'\n\n# [Optional] Re-check available filter options from cached results (same shape as search_torrents; returns filter options only)\nnode scripts/mp-cli.js get_search_results show_filter_options=true\n\n# 5. Present ALL filtered results as a numbered list — do not pre-select or discard any\n#    Show for each: index, title, size, seeders, resolution, release group, volume_factor, freedate_diff\n#    Let the user pick by number; only then proceed to step 6\n\n# 6. After user confirms selection, check library and subscriptions before downloading\nnode scripts/mp-cli.js query_library_exists tmdb_id=123456 media_type=\"movie\"\nnode scripts/mp-cli.js query_subscribes tmdb_id=123456\n# If already in library or subscribed, warn the user and ask for confirmation to proceed\n\n# 7. Add download\n# Single item:\nnode scripts/mp-cli.js add_download torrent_url=\"abc1234:1\"\n# Multiple items:\nnode scripts/mp-cli.js add_download torrent_url=\"abc1234:1,def5678:2\"\n```\n\n### Add Subscription\n\n```bash\n# 1. Search to get tmdb_id (required for accurate identification)\nnode scripts/mp-cli.js search_media title=\"黑镜\" media_type=\"tv\"\n\n# 2. Subscribe — the system will auto-download new episodes\nnode scripts/mp-cli.js add_subscribe title=\"黑镜\" year=\"2011\" media_type=\"tv\" tmdb_id=42009\n```\n\n### Manage Subscriptions\n\n```bash\nnode scripts/mp-cli.js query_subscribes status=R                                   # list active\nnode scripts/mp-cli.js update_subscribe subscribe_id=123 resolution=\"1080p\"        # update filters\nnode scripts/mp-cli.js search_subscribe subscribe_id=123                           # search missing episodes\nnode scripts/mp-cli.js delete_subscribe subscribe_id=123                           # remove\n```\n\n## Season Validation (only when user specifies a season)\n\nSkip this section if the user did not mention a specific season.\n\n**Step 1 — Verify the season exists:**\n\n```bash\nnode scripts/mp-cli.js query_media_detail tmdb_id=<id> media_type=\"tv\"\n```\n\nCheck `season_info` against the season the user requested:\n\n- **Season exists:** use that season number directly, then proceed to torrent search.\n- **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.\n\n**Step 2 — Identify the correct episode range:**\n\n```bash\nnode scripts/mp-cli.js query_episode_schedule tmdb_id=<id> season=<latest_season>\n```\n\nUse `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.\n\n## Error Handling\n\n| Error                 | Resolution                                                                                                                                                                                                                                                                                           |\n| --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| 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.                                                                                                                                                             |\n| 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. |\n| Missing configuration | Ask the user for the backend host and API key. Once provided, run `node scripts/mp-cli.js -h <HOST> -k <KEY>` (no command) to save the config persistently — subsequent commands will use it automatically.                                                                                          |\n"
  },
  {
    "path": "skills/moviepilot-cli/scripts/mp-cli.js",
    "content": "#!/usr/bin/env node\n\n'use strict';\n\nconst fs = require('fs');\nconst os = require('os');\nconst path = require('path');\nconst http = require('http');\nconst https = require('https');\n\nconst SCRIPT_NAME = process.env.MP_SCRIPT_NAME || path.basename(process.argv[1] || 'mp-cli.js');\nconst CONFIG_DIR = path.join(os.homedir(), '.config', 'moviepilot_cli');\nconst CONFIG_FILE = path.join(CONFIG_DIR, 'config');\n\nlet commandsJson = [];\nlet commandsLoaded = false;\n\nlet optHost = '';\nlet optKey = '';\n\nconst envHost = process.env.MP_HOST || '';\nconst envKey = process.env.MP_API_KEY || '';\n\nlet mpHost = '';\nlet mpApiKey = '';\n\nfunction fail(message) {\n  console.error(message);\n  process.exit(1);\n}\n\nfunction spacePad(text = '', targetCol = 0) {\n  const spaces = text.length < targetCol ? targetCol - text.length + 2 : 2;\n  return ' '.repeat(spaces);\n}\n\nfunction printBox(title, lines) {\n  const rightPadding = 0;\n  const contentWidth =\n    lines.reduce((max, line) => Math.max(max, line.length), title.length) + rightPadding;\n  const innerWidth = contentWidth + 2;\n  const topLabel = `─ ${title}`;\n\n  console.error(`┌${topLabel}${'─'.repeat(Math.max(innerWidth - topLabel.length, 0))}┐`);\n  for (const line of lines) {\n    console.error(`│ ${line}${' '.repeat(contentWidth - line.length)} │`);\n  }\n  console.error(`└${'─'.repeat(innerWidth)}┘`);\n}\n\nfunction readConfig() {\n  let cfgHost = '';\n  let cfgKey = '';\n\n  if (!fs.existsSync(CONFIG_FILE)) {\n    return { cfgHost, cfgKey };\n  }\n\n  const content = fs.readFileSync(CONFIG_FILE, 'utf8');\n  for (const line of content.split(/\\r?\\n/)) {\n    if (!line.trim() || /^\\s*#/.test(line)) {\n      continue;\n    }\n\n    const index = line.indexOf('=');\n    if (index === -1) {\n      continue;\n    }\n\n    const key = line.slice(0, index).replace(/\\s+/g, '');\n    const value = line.slice(index + 1);\n\n    if (key === 'MP_HOST') {\n      cfgHost = value;\n    } else if (key === 'MP_API_KEY') {\n      cfgKey = value;\n    }\n  }\n\n  return { cfgHost, cfgKey };\n}\n\nfunction saveConfig(host, key) {\n  fs.mkdirSync(CONFIG_DIR, { recursive: true });\n  fs.writeFileSync(CONFIG_FILE, `MP_HOST=${host}\\nMP_API_KEY=${key}\\n`, 'utf8');\n  fs.chmodSync(CONFIG_FILE, 0o600);\n}\n\nfunction loadConfig() {\n  const { cfgHost: initialHost, cfgKey: initialKey } = readConfig();\n  let cfgHost = initialHost;\n  let cfgKey = initialKey;\n\n  if (optHost || optKey) {\n    const nextHost = optHost || cfgHost;\n    const nextKey = optKey || cfgKey;\n    saveConfig(nextHost, nextKey);\n    cfgHost = nextHost;\n    cfgKey = nextKey;\n  }\n\n  mpHost = optHost || mpHost || envHost || cfgHost;\n  mpApiKey = optKey || mpApiKey || envKey || cfgKey;\n}\n\nfunction normalizeType(schema = {}) {\n  if (schema.type) {\n    return schema.type;\n  }\n  if (Array.isArray(schema.anyOf)) {\n    const candidate = schema.anyOf.find((item) => item && item.type && item.type !== 'null');\n    return candidate?.type || 'string';\n  }\n  return 'string';\n}\n\nfunction normalizeItemType(schema = {}) {\n  const items = schema.items;\n  if (!items) {\n    return null;\n  }\n  if (items.type) {\n    return items.type;\n  }\n  if (Array.isArray(items.anyOf)) {\n    const candidate = items.anyOf.find((item) => item && item.type && item.type !== 'null');\n    return candidate?.type || null;\n  }\n  return null;\n}\n\nfunction normalizeCommand(tool = {}) {\n  const properties = tool?.inputSchema?.properties || {};\n  const required = Array.isArray(tool?.inputSchema?.required) ? tool.inputSchema.required : [];\n  const fields = Object.entries(properties)\n    .filter(([fieldName]) => fieldName !== 'explanation')\n    .map(([fieldName, schema]) => ({\n      name: fieldName,\n      type: normalizeType(schema),\n      description: schema?.description || '',\n      required: required.includes(fieldName),\n      item_type: normalizeItemType(schema),\n    }));\n\n  return {\n    name: tool?.name,\n    description: tool?.description || '',\n    fields,\n  };\n}\n\nfunction request(method, targetUrl, headers = {}, body, timeout = 120000) {\n  return new Promise((resolve, reject) => {\n    let url;\n    try {\n      url = new URL(targetUrl);\n    } catch (error) {\n      reject(new Error(`Invalid URL: ${targetUrl}`));\n      return;\n    }\n\n    const transport = url.protocol === 'https:' ? https : http;\n    const req = transport.request(\n      {\n        method,\n        hostname: url.hostname,\n        port: url.port || undefined,\n        path: `${url.pathname}${url.search}`,\n        headers,\n      },\n      (res) => {\n        const chunks = [];\n        res.on('data', (chunk) => chunks.push(chunk));\n        res.on('end', () => {\n          resolve({\n            statusCode: res.statusCode ? String(res.statusCode) : '',\n            body: Buffer.concat(chunks).toString('utf8'),\n          });\n        });\n      }\n    );\n\n    req.setTimeout(timeout, () => {\n      req.destroy(new Error(`Request timed out after ${timeout}ms`));\n    });\n\n    req.on('error', reject);\n\n    if (body !== undefined) {\n      req.write(body);\n    }\n\n    req.end();\n  });\n}\n\nasync function loadCommandsJson() {\n  if (commandsLoaded) {\n    return;\n  }\n\n  const { statusCode, body } = await request('GET', `${mpHost}/api/v1/mcp/tools`, {\n    'X-API-KEY': mpApiKey,\n  });\n\n  if (statusCode !== '200') {\n    console.error(`Error: failed to load command definitions (HTTP ${statusCode || 'unknown'})`);\n    process.exit(1);\n  }\n\n  let response;\n  try {\n    response = JSON.parse(body);\n  } catch {\n    fail('Error: backend returned invalid JSON for command definitions');\n  }\n\n  commandsJson = Array.isArray(response)\n    ? response.map((tool) => normalizeCommand(tool))\n    : [];\n\n  commandsLoaded = true;\n}\n\nasync function loadCommandJson(commandName) {\n  const { statusCode, body } = await request('GET', `${mpHost}/api/v1/mcp/tools/${commandName}`, {\n    'X-API-KEY': mpApiKey,\n  });\n\n  if (statusCode === '404') {\n    console.error(`Error: command '${commandName}' not found`);\n    console.error(`Run 'node ${SCRIPT_NAME} list' to see available commands`);\n    process.exit(1);\n  }\n\n  if (statusCode !== '200') {\n    console.error(`Error: failed to load command definition (HTTP ${statusCode || 'unknown'})`);\n    process.exit(1);\n  }\n\n  let response;\n  try {\n    response = JSON.parse(body);\n  } catch {\n    fail(`Error: backend returned invalid JSON for command '${commandName}'`);\n  }\n\n  return normalizeCommand(response);\n}\n\nfunction ensureConfig() {\n  loadConfig();\n  let ok = true;\n\n  if (!mpHost) {\n    console.error('Error: backend host is not configured.');\n    console.error('    Use: -h HOST to set it');\n    console.error('    Or set environment variable: MP_HOST=http://localhost:3001');\n    ok = false;\n  }\n\n  if (!mpApiKey) {\n    console.error('Error: API key is not configured.');\n    console.error('    Use: -k KEY to set it');\n    console.error('    Or set environment variable: MP_API_KEY=your_key');\n    ok = false;\n  }\n\n  if (!ok) {\n    process.exit(1);\n  }\n}\n\nfunction printValue(value) {\n  if (typeof value === 'string') {\n    process.stdout.write(`${value}\\n`);\n    return;\n  }\n\n  process.stdout.write(`${JSON.stringify(value)}\\n`);\n}\n\nfunction formatUsageValue(field) {\n  if (field?.type === 'array') {\n    return \"'<value1>,<value2>'\";\n  }\n  return '<value>';\n}\n\nasync function cmdList() {\n  await loadCommandsJson();\n  const sortedCommands = [...commandsJson].sort((left, right) => left.name.localeCompare(right.name));\n  for (const command of sortedCommands) {\n    process.stdout.write(`${command.name}\\n`);\n  }\n}\n\nasync function cmdShow(commandName) {\n  if (!commandName) {\n    fail(`Usage: ${SCRIPT_NAME} show <command>`);\n  }\n\n  const command = await loadCommandJson(commandName);\n\n  const commandLabel = 'Command:';\n  const descriptionLabel = 'Description:';\n  const paramsLabel = 'Parameters:';\n  const usageLabel = 'Usage:';\n  const detailLabelWidth = Math.max(\n    commandLabel.length,\n    descriptionLabel.length,\n    paramsLabel.length,\n    usageLabel.length\n  );\n\n  process.stdout.write(`${commandLabel} ${command.name}\\n`);\n  process.stdout.write(`${descriptionLabel} ${command.description || '(none)'}\\n\\n`);\n\n  if (command.fields.length === 0) {\n    process.stdout.write(`${paramsLabel}${spacePad(paramsLabel, detailLabelWidth)}(none)\\n`);\n  } else {\n    const fieldLines = command.fields.map((field) => [\n      field.required ? `${field.name}*` : field.name,\n      field.type,\n      field.description,\n    ]);\n\n    const nameWidth = Math.max(...fieldLines.map(([name]) => name.length), 0);\n    const typeWidth = Math.max(...fieldLines.map(([, type]) => type.length), 0);\n\n    process.stdout.write(`${paramsLabel}\\n`);\n    for (const [fieldName, fieldType, fieldDesc] of fieldLines) {\n      process.stdout.write(\n        `  ${fieldName}${spacePad(fieldName, nameWidth)}${fieldType}${spacePad(fieldType, typeWidth)}${fieldDesc}\\n`\n      );\n    }\n  }\n\n  const usageLine = `${command.name}`;\n  const reqPart = command.fields\n    .filter((field) => field.required)\n    .map((field) => ` ${field.name}=${formatUsageValue(field)}`)\n    .join('');\n  const optPart = command.fields\n    .filter((field) => !field.required)\n    .map((field) => ` [${field.name}=${formatUsageValue(field)}]`)\n    .join('');\n\n  process.stdout.write(`\\n${usageLabel} ${usageLine}${reqPart}${optPart}\\n`);\n}\n\nfunction buildArguments(pairs) {\n  const args = { explanation: 'CLI invocation' };\n\n  for (const kv of pairs) {\n    if (!kv.includes('=')) {\n      fail(`Error: argument must be in key=value format, got: '${kv}'`);\n    }\n\n    const index = kv.indexOf('=');\n    args[kv.slice(0, index)] = kv.slice(index + 1);\n  }\n\n  return args;\n}\n\nasync function cmdRun(commandName, pairs) {\n  if (!commandName) {\n    fail(`Usage: ${SCRIPT_NAME} <command> [key=value ...]`);\n  }\n\n  const requestBody = JSON.stringify({\n    tool_name: commandName,\n    arguments: buildArguments(pairs),\n  });\n\n  const { statusCode, body } = await request(\n    'POST',\n    `${mpHost}/api/v1/mcp/tools/call`,\n    {\n      'Content-Type': 'application/json',\n      'Content-Length': Buffer.byteLength(requestBody),\n      'X-API-KEY': mpApiKey,\n    },\n    requestBody\n  );\n\n  if (statusCode && statusCode !== '200' && statusCode !== '201') {\n    console.error(`Warning: HTTP status ${statusCode}`);\n  }\n\n  try {\n    const parsed = JSON.parse(body);\n    if (Object.prototype.hasOwnProperty.call(parsed, 'error') && parsed.error) {\n      printValue(parsed);\n      return;\n    }\n\n    if (Object.prototype.hasOwnProperty.call(parsed, 'result')) {\n      if (typeof parsed.result === 'string') {\n        try {\n          printValue(JSON.parse(parsed.result));\n        } catch {\n          printValue(parsed.result);\n        }\n      } else {\n        printValue(parsed.result);\n      }\n      return;\n    }\n\n    printValue(parsed);\n  } catch {\n    process.stdout.write(`${body}\\n`);\n  }\n}\n\nfunction printUsage() {\n  const { cfgHost, cfgKey } = readConfig();\n  let effectiveHost = mpHost || envHost || cfgHost;\n  let effectiveKey = mpApiKey || envKey || cfgKey;\n\n  if (optHost) {\n    effectiveHost = optHost;\n  }\n  if (optKey) {\n    effectiveKey = optKey;\n  }\n\n  if (!effectiveHost || !effectiveKey) {\n    const warningLines = [];\n    if (!effectiveHost) {\n      const opt = '-h HOST';\n      const desc = 'set backend host';\n      warningLines.push(`${opt}${spacePad(opt)}${desc}`);\n    }\n    if (!effectiveKey) {\n      const opt = '-k KEY';\n      const desc = 'set API key';\n      warningLines.push(`${opt}${spacePad(opt)}${desc}`);\n    }\n    printBox('Warning: not configured', warningLines);\n    console.error('');\n  }\n\n  process.stdout.write(`Usage: ${SCRIPT_NAME} [-h HOST] [-k KEY] [COMMAND] [ARGS...]\\n\\n`);\n  const optionWidth = Math.max('-h HOST'.length, '-k KEY'.length);\n  process.stdout.write('Options:\\n');\n  process.stdout.write(`    -h HOST${spacePad('-h HOST', optionWidth)}backend host\\n`);\n  process.stdout.write(`    -k KEY${spacePad('-k KEY', optionWidth)}API key\\n\\n`);\n  const commandWidth = Math.max(\n    '(no command)'.length,\n    'list'.length,\n    'show <command>'.length,\n    '<command> [k=v...]'.length\n  );\n  process.stdout.write('Commands:\\n');\n  process.stdout.write(\n    `    (no command)${spacePad('(no command)', commandWidth)}save config when -h and -k are provided\\n`\n  );\n  process.stdout.write(`    list${spacePad('list', commandWidth)}list all commands\\n`);\n  process.stdout.write(\n    `    show <command>${spacePad('show <command>', commandWidth)}show command details and usage example\\n`\n  );\n  process.stdout.write(\n    `    <command> [k=v...]${spacePad('<command> [k=v...]', commandWidth)}run a command\\n`\n  );\n}\n\nasync function main() {\n  const args = [];\n  const argv = process.argv.slice(2);\n\n  for (let index = 0; index < argv.length; index += 1) {\n    const arg = argv[index];\n\n    if (arg === '--help' || arg === '-?') {\n      printUsage();\n      process.exit(0);\n    }\n\n    if (arg === '-h') {\n      index += 1;\n      optHost = argv[index] || '';\n      continue;\n    }\n\n    if (arg === '-k') {\n      index += 1;\n      optKey = argv[index] || '';\n      continue;\n    }\n\n    if (arg === '--') {\n      args.push(...argv.slice(index + 1));\n      break;\n    }\n\n    if (arg.startsWith('-')) {\n      console.error(`Unknown option: ${arg}`);\n      printUsage();\n      process.exit(1);\n    }\n\n    args.push(arg);\n  }\n\n  if ((optHost && !optKey) || (!optHost && optKey)) {\n    fail('Error: -h and -k must be provided together');\n  }\n\n  const command = args[0] || '';\n\n  if (command === 'list') {\n    ensureConfig();\n    await cmdList();\n    return;\n  }\n\n  if (command === 'show') {\n    ensureConfig();\n    await cmdShow(args[1] || '');\n    return;\n  }\n\n  if (!command) {\n    if (optHost || optKey) {\n      loadConfig();\n      process.stdout.write('Configuration saved.\\n');\n      return;\n    }\n\n    printUsage();\n    return;\n  }\n\n  ensureConfig();\n  await cmdRun(command, args.slice(1));\n}\n\nmain().catch((error) => {\n  fail(`Error: ${error.message}`);\n});\n"
  },
  {
    "path": "tests/__init__.py",
    "content": ""
  },
  {
    "path": "tests/cases/__init__.py",
    "content": ""
  },
  {
    "path": "tests/cases/files.py",
    "content": "#!/usr/bin/env python\n# -*- coding:utf-8 -*-\n# 文件列表结构 list[tuple(名称, 子文件列表 或 文件大小)]\nbluray_files = [\n    (\n        \"FOLDER\",\n        [\n            (\n                \"Digimon\",\n                [\n                    (\n                        \"Digimon BluRay (2055)\",\n                        [\n                            (\n                                \"BDMV\",\n                                [\n                                    (\n                                        \"STREAM\",\n                                        [\n                                            (\"00000.m2ts\", 104857600),\n                                            (\"00001.m2ts\", 104857600),\n                                        ],\n                                    ),\n                                ],\n                            ),\n                            (\"CERTIFICATE\", None),\n                        ],\n                    ),\n                    (\n                        \"Digimon BluRay (2099)\",\n                        [\n                            (\n                                \"BDMV\",\n                                [\n                                    (\n                                        \"STREAM\",\n                                        [\n                                            (\"00000.m2ts\", 104857600),\n                                            (\"00001.m2ts\", 104857600),\n                                            (\"00002.m2ts.!qB\", 104857600),\n                                        ],\n                                    ),\n                                ],\n                            ),\n                            (\"CERTIFICATE\", None),\n                        ],\n                    ),\n                    (\"Digimon (2199)\", [(\"Digimon.2199.mp4\", 104857600)]),\n                ],\n            ),\n            (\n                \"Pokemon BluRay (2016)\",\n                [\n                    (\n                        \"BDMV\",\n                        [\n                            (\n                                \"STREAM\",\n                                [\n                                    (\"00000.m2ts\", 104857600),\n                                    (\"00001.m2ts\", 104857600),\n                                ],\n                            )\n                        ],\n                    ),\n                    (\"CERTIFICATE\", None),\n                ],\n            ),\n            (\n                \"Pokemon BluRay (2021)\",\n                [\n                    (\n                        \"BDMV\",\n                        [\n                            (\n                                \"STREAM\",\n                                [\n                                    (\"00000.m2ts\", 104857600),\n                                    (\"00001.m2ts\", 104857600),\n                                ],\n                            )\n                        ],\n                    ),\n                    (\"CERTIFICATE\", None),\n                ],\n            ),\n            (\n                \"Pokemon (2028)\",\n                [\n                    (\"Pokemon.2028.mkv\", 104857600),\n                    (\"Pokemon.2028.hdr.mkv.!qB\", 104857600),\n                ],\n            ),\n            (\"Pokemon.2029.mp4\", 104857600),\n            (\"Pokemon.2039.mp4\", 104857600),\n            (\"Pokemon (2030)\", [(\"S\", 104857600)]),\n            (\"Pokemon (2031)\", [(\"Pokemon (2031).mp4\", 104857600)]),\n        ],\n    )\n]\n"
  },
  {
    "path": "tests/cases/groups.py",
    "content": "release_group_cases = [\n    # 0ff 组（示例结构）\n    {\n        \"domain\": \"0ff\",\n        \"groups\": [\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-FFAB\", \"group\": \"FFAB\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-FFWEB\", \"group\": \"FFWEB\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-FFCD\", \"group\": \"FFCD\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-FFEDU\", \"group\": \"FFEDU\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-FFEB\", \"group\": \"FFEB\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-FFTV\", \"group\": \"FFTV\"}\n        ]\n    },\n    # audiences 组（示例结构）\n    {\n        \"domain\": \"audiences\",\n        \"groups\": [\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-Audies\", \"group\": \"Audies\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-ADE\", \"group\": \"ADE\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-ADAudio\", \"group\": \"ADAudio\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-ADEbook\", \"group\": \"ADEbook\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-ADMusic\", \"group\": \"ADMusic\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-ADWeb\", \"group\": \"ADWeb\"}\n        ]\n    },\n    # ---- 以下为新增结构化部分 ----\n    # beitai 组\n    {\n        \"domain\": \"beitai\",\n        \"groups\": [\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-BeiTai\", \"group\": \"BeiTai\"}\n        ]\n    },\n    # btschool 组\n    {\n        \"domain\": \"btschool\",\n        \"groups\": [\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-BtsCHOOL\", \"group\": \"BtsCHOOL\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-BtsHD\", \"group\": \"BtsHD\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-BtsPAD\", \"group\": \"BtsPAD\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-BtsTV\", \"group\": \"BtsTV\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-Zone\", \"group\": \"Zone\"}\n        ]\n    },\n    # carpt 组\n    {\n        \"domain\": \"carpt\",\n        \"groups\": [\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-CarPT\", \"group\": \"CarPT\"}\n        ]\n    },\n    # chd 组\n    {\n        \"domain\": \"chd\",\n        \"groups\": [\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-CHD\", \"group\": \"CHD\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-CHDBits\", \"group\": \"CHDBits\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-CHDPAD\", \"group\": \"CHDPAD\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-CHDTV\", \"group\": \"CHDTV\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-CHDHKTV\", \"group\": \"CHDHKTV\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-CHDWEB\", \"group\": \"CHDWEB\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-StBOX\", \"group\": \"StBOX\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-OneHD\", \"group\": \"OneHD\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-Lee\", \"group\": \"Lee\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-xiaopie\", \"group\": \"xiaopie\"}\n        ]\n    },\n    # eastgame 组\n    {\n        \"domain\": \"eastgame\",\n        \"groups\": [\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-TLF\", \"group\": \"TLF\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-iNT-TLF\", \"group\": \"iNT-TLF\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-HALFC-TLF\", \"group\": \"HALFC-TLF\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-MiniSD-TLF\", \"group\": \"MiniSD-TLF\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-MiniHD-TLF\", \"group\": \"MiniHD-TLF\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-MiniFHD-TLF\", \"group\": \"MiniFHD-TLF\"}\n        ]\n    },\n    # gainbound 组\n    {\n        \"domain\": \"gainbound\",\n        \"groups\": [\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-DGB\", \"group\": \"DGB\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-GBWEB\", \"group\": \"GBWEB\"}\n        ]\n    },\n    # hares 组\n    {\n        \"domain\": \"hares\",\n        \"groups\": [\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-Hares\", \"group\": \"Hares\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-HaresMV\", \"group\": \"HaresMV\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-HaresTV\", \"group\": \"HaresTV\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-HaresWeb\", \"group\": \"HaresWeb\"}\n        ]\n    },\n    # hdarea 组\n    {\n        \"domain\": \"hdarea\",\n        \"groups\": [\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-HDApad\", \"group\": \"HDApad\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-HDArea\", \"group\": \"HDArea\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-HDATV\", \"group\": \"HDATV\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-EPiC\", \"group\": \"EPiC\"}\n        ]\n    },\n    # hdchina 组\n    {\n        \"domain\": \"hdchina\",\n        \"groups\": [\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-HDC\", \"group\": \"HDC\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-HDChina\", \"group\": \"HDChina\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-HDCTV\", \"group\": \"HDCTV\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-k9611\", \"group\": \"k9611\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-tudou\", \"group\": \"tudou\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-iHD\", \"group\": \"iHD\"}\n        ]\n    },\n    # hddolby 组\n    {\n        \"domain\": \"hddolby\",\n        \"groups\": [\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-Dream\", \"group\": \"Dream\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-DBTV\", \"group\": \"DBTV\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-HDo\", \"group\": \"HDo\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-QHStudIo\", \"group\": \"QHStudIo\"}\n        ]\n    },\n    # hdfans 组\n    {\n        \"domain\": \"hdfans\",\n        \"groups\": [\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-beAst\", \"group\": \"beAst\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-beAstTV\", \"group\": \"beAstTV\"}\n        ]\n    },\n    # hdhome 组\n    {\n        \"domain\": \"hdhome\",\n        \"groups\": [\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-HDH\", \"group\": \"HDH\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-HDHome\", \"group\": \"HDHome\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-HDHPad\", \"group\": \"HDHPad\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-HDHTV\", \"group\": \"HDHTV\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-HDHWEB\", \"group\": \"HDHWEB\"}\n        ]\n    },\n    # hdpt 组\n    {\n        \"domain\": \"hdpt\",\n        \"groups\": [\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-HDPT\", \"group\": \"HDPT\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-HDPTWeb\", \"group\": \"HDPTWeb\"}\n        ]\n    },\n    # hdsky 组\n    {\n        \"domain\": \"hdsky\",\n        \"groups\": [\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-HDS\", \"group\": \"HDS\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-HDSky\", \"group\": \"HDSky\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-HDSTV\", \"group\": \"HDSTV\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-HDSPad\", \"group\": \"HDSPad\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-HDSWEB\", \"group\": \"HDSWEB\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-AQLJ\", \"group\": \"AQLJ\"}\n        ]\n    },\n    # hdzone 组\n    {\n        \"domain\": \"hdzone\",\n        \"groups\": [\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-HDZ\", \"group\": \"HDZ\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-HDZone\", \"group\": \"HDZone\"}\n        ]\n    },\n    # hhanclub 组\n    {\n        \"domain\": \"hhanclub\",\n        \"groups\": [\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-HHWEB\", \"group\": \"HHWEB\"}\n        ]\n    },\n    # htpt 组\n    {\n        \"domain\": \"htpt\",\n        \"groups\": [\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-HTPT\", \"group\": \"HTPT\"}\n        ]\n    },\n    # keepfrds 组\n    {\n        \"domain\": \"keepfrds\",\n        \"groups\": [\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-FRDS\", \"group\": \"FRDS\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-Yumi@FRDS\", \"group\": \"Yumi@FRDS\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-cXcY@FRDS\", \"group\": \"cXcY@FRDS\"}\n        ]\n    },\n    # lemonhd 组\n    {\n        \"domain\": \"lemonhd\",\n        \"groups\": [\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-LeagueCD\", \"group\": \"LeagueCD\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-LeagueHD\", \"group\": \"LeagueHD\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-LeagueMV\", \"group\": \"LeagueMV\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-LeagueTV\", \"group\": \"LeagueTV\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-LeagueNF\", \"group\": \"LeagueNF\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-LeagueWEB\", \"group\": \"LeagueWEB\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-LHD\", \"group\": \"LHD\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-i18n\", \"group\": \"i18n\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-CiNT\", \"group\": \"CiNT\"}\n        ]\n    },\n    # mteam 组\n    {\n        \"domain\": \"mteam\",\n        \"groups\": [\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-MTeam\", \"group\": \"MTeam\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-MTeamTV\", \"group\": \"MTeamTV\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-MPAD\", \"group\": \"MPAD\"}\n        ]\n    },\n    # ourbits 组\n    {\n        \"domain\": \"ourbits\",\n        \"groups\": [\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-OurBits\", \"group\": \"OurBits\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-OurTV\", \"group\": \"OurTV\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-FLTTH\", \"group\": \"FLTTH\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-Ao\", \"group\": \"Ao\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-PbK\", \"group\": \"PbK\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-MGs\", \"group\": \"MGs\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-iLoveHD\", \"group\": \"iLoveHD\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-iLoveTV\", \"group\": \"iLoveTV\"}\n        ]\n    },\n    # panda 组\n    {\n        \"domain\": \"panda\",\n        \"groups\": [\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-Panda\", \"group\": \"Panda\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-AilMWeb\", \"group\": \"AilMWeb\"}\n        ]\n    },\n    # piggo 组\n    {\n        \"domain\": \"piggo\",\n        \"groups\": [\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-PiGoNF\", \"group\": \"PiGoNF\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-PiGoHB\", \"group\": \"PiGoHB\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-PiGoWEB\", \"group\": \"PiGoWEB\"}\n        ]\n    },\n    # pterclub 组\n    {\n        \"domain\": \"pterclub\",\n        \"groups\": [\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-PTer\", \"group\": \"PTer\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-PTerDIY\", \"group\": \"PTerDIY\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-PTerGame\", \"group\": \"PTerGame\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-PTerMV\", \"group\": \"PTerMV\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-PTerTV\", \"group\": \"PTerTV\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-PTerWEB\", \"group\": \"PTerWEB\"}\n        ]\n    },\n    # pthome 组\n    {\n        \"domain\": \"pthome\",\n        \"groups\": [\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-PTH\", \"group\": \"PTH\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-PTHAudio\", \"group\": \"PTHAudio\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-PTHeBook\", \"group\": \"PTHeBook\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-PTHmusic\", \"group\": \"PTHmusic\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-PTHome\", \"group\": \"PTHome\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-PTHtv\", \"group\": \"PTHtv\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-PTHWEB\", \"group\": \"PTHWEB\"}\n        ]\n    },\n    # ptsbao 组\n    {\n        \"domain\": \"ptsbao\",\n        \"groups\": [\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-PTsbao\", \"group\": \"PTsbao\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-OPS\", \"group\": \"OPS\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-FFansAIeNcE\", \"group\": \"FFansAIeNcE\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-FFansBD\", \"group\": \"FFansBD\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-FFansDVD\", \"group\": \"FFansDVD\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-FFansDIY\", \"group\": \"FFansDIY\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-FFansTV\", \"group\": \"FFansTV\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-FFansWEB\", \"group\": \"FFansWEB\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-FHDMv\", \"group\": \"FHDMv\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-SGXT\", \"group\": \"SGXT\"}\n        ]\n    },\n    # putao 组\n    {\n        \"domain\": \"putao\",\n        \"groups\": [\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-PuTao\", \"group\": \"PuTao\"}\n        ]\n    },\n    # ssd 组\n    {\n        \"domain\": \"ssd\",\n        \"groups\": [\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-CMCT\", \"group\": \"CMCT\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-CMCT@制作者\", \"group\": \"CMCT\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-CMCTV\", \"group\": \"CMCTV\"}\n        ]\n    },\n    # sharkpt 组\n    {\n        \"domain\": \"sharkpt\",\n        \"groups\": [\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-Shark\", \"group\": \"Shark\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-SharkWEB\", \"group\": \"SharkWEB\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-SharkDIY\", \"group\": \"SharkDIY\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-SharkTV\", \"group\": \"SharkTV\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-SharkMV\", \"group\": \"SharkMV\"}\n        ]\n    },\n    # tjupt 组\n    {\n        \"domain\": \"tjupt\",\n        \"groups\": [\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-TJUPT\", \"group\": \"TJUPT\"}\n        ]\n    },\n    # ttg 组\n    {\n        \"domain\": \"ttg\",\n        \"groups\": [\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-TTG\", \"group\": \"TTG\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-WiKi\", \"group\": \"WiKi\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-NGB\", \"group\": \"NGB\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-DoA\", \"group\": \"DoA\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-ARiN\", \"group\": \"ARiN\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-ExREN\", \"group\": \"ExREN\"}\n        ]\n    },\n    # others 组\n    {\n        \"domain\": \"others\",\n        \"groups\": [\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-BMDru\", \"group\": \"BMDru\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-BeyondHD\", \"group\": \"BeyondHD\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-BTN\", \"group\": \"BTN\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-Cfandora\", \"group\": \"Cfandora\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-Ctrlhd\", \"group\": \"Ctrlhd\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-CMRG\", \"group\": \"CMRG\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-DON\", \"group\": \"DON\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-EVO\", \"group\": \"EVO\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-FLUX\", \"group\": \"FLUX\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-HONE\", \"group\": \"HONE\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-HONEyG\", \"group\": \"HONEyG\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-NoGroup\", \"group\": \"NoGroup\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-NTb\", \"group\": \"NTb\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-NTG\", \"group\": \"NTG\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-PandaMoon\", \"group\": \"PandaMoon\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-SMURF\", \"group\": \"SMURF\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-TEPES\", \"group\": \"TEPES\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-Taengoo\", \"group\": \"Taengoo\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-TrollHD \", \"group\": \"TrollHD \"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-UBWEB\", \"group\": \"UBWEB\"}\n        ]\n    },\n    # anime 组\n    {\n        \"domain\": \"anime\",\n        \"groups\": [\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-ANi\", \"group\": \"ANi\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-HYSUB\", \"group\": \"HYSUB\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-KTXP\", \"group\": \"KTXP\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-LoliHouse\", \"group\": \"LoliHouse\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-MCE\", \"group\": \"MCE\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-Nekomoe kissaten\", \"group\": \"Nekomoe kissaten\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-SweetSub\", \"group\": \"SweetSub\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-MingY\", \"group\": \"MingY\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-Lilith-Raws\", \"group\": \"Lilith-Raws\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-NC-Raws\", \"group\": \"NC-Raws\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-织梦字幕组\", \"group\": \"织梦字幕组\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-枫叶字幕组\", \"group\": \"枫叶字幕组\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-猎户手抄部\", \"group\": \"猎户手抄部\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-喵萌奶茶屋\", \"group\": \"喵萌奶茶屋\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-漫猫字幕社\", \"group\": \"漫猫字幕社\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-霜庭云花Sub\", \"group\": \"霜庭云花Sub\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-北宇治字幕组\", \"group\": \"北宇治字幕组\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-氢气烤肉架\", \"group\": \"氢气烤肉架\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-云歌字幕组\", \"group\": \"云歌字幕组\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-萌樱字幕组\", \"group\": \"萌樱字幕组\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-极影字幕社\", \"group\": \"极影字幕社\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-悠哈璃羽字幕社\", \"group\": \"悠哈璃羽字幕社\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-❀拨雪寻春❀\", \"group\": \"❀拨雪寻春❀\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-沸羊羊制作\", \"group\": \"沸羊羊制作\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-沸羊羊字幕组\", \"group\": \"沸羊羊字幕组\"},\n            {\n                \"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-桜都字幕组\",\n                \"group\": \"桜都字幕组\",\n            },\n            {\n                \"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-樱都字幕组\",\n                \"group\": \"樱都字幕组\",\n            },\n        ]\n    },\n    # frog 组\n    {\n        \"domain\": \"frog\",\n        \"groups\": [\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-FROG\", \"group\": \"FROG\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-FROGE\", \"group\": \"FROGE\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-FROGWeb\", \"group\": \"FROGWeb\"},\n        ]\n    },\n    # ubits 组\n    {\n        \"domain\": \"ubits\",\n        \"groups\": [\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-UBits\", \"group\": \"UBits\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-UBWEB\", \"group\": \"UBWEB\"},\n            {\"title\": \"Bluey S03 2021 2160p WEB-DL H.265 AAC 2.0-UBTV\", \"group\": \"UBTV\"},\n        ]\n    },\n]\n"
  },
  {
    "path": "tests/cases/meta.py",
    "content": "meta_cases = [{\n    \"title\": \"The Long Season 2017 2160p WEB-DL H265 120FPS AAC-XXX\",\n    \"subtitle\": \"\",\n    \"target\": {\n        \"type\": \"未知\",\n        \"cn_name\": \"\",\n        \"en_name\": \"The Long Season\",\n        \"year\": \"2017\",\n        \"part\": \"\",\n        \"season\": \"\",\n        \"episode\": \"\",\n        \"restype\": \"WEB-DL\",\n        \"pix\": \"2160p\",\n        \"video_codec\": \"H265\",\n        \"audio_codec\": \"AAC\",\n        \"fps\": 120\n    }\n}, {\n    \"title\": \"Cherry Season S01 2014 2160p 60fps WEB-DL H265 AAC-XXX\",\n    \"subtitle\": \"\",\n    \"target\": {\n        \"type\": \"电视剧\",\n        \"cn_name\": \"\",\n        \"en_name\": \"Cherry Season\",\n        \"year\": \"2014\",\n        \"part\": \"\",\n        \"season\": \"S01\",\n        \"episode\": \"\",\n        \"restype\": \"WEB-DL\",\n        \"pix\": \"2160p\",\n        \"video_codec\": \"H265\",\n        \"audio_codec\": \"AAC\",\n        \"fps\": 60\n    }\n}, {\n    \"title\": \"【爪爪字幕组】★7月新番[欢迎来到实力至上主义的教室 第二季/Youkoso Jitsuryoku Shijou Shugi no Kyoushitsu e S2][11][1080p][HEVC][GB][MP4][招募翻译校对]\",\n    \"subtitle\": \"\",\n    \"target\": {\n        \"type\": \"电视剧\",\n        \"cn_name\": \"欢迎来到实力至上主义的教室\",\n        \"en_name\": \"Youkoso Jitsuryoku Shijou Shugi No Kyoushitsu E\",\n        \"year\": \"\",\n        \"part\": \"\",\n        \"season\": \"S02\",\n        \"episode\": \"E11\",\n        \"restype\": \"\",\n        \"pix\": \"1080p\",\n        \"video_codec\": \"HEVC\",\n        \"audio_codec\": \"\",\n        \"fps\": None\n    }\n}, {\n    \"title\": \"National.Parks.Adventure.AKA.America.Wild:.National.Parks.Adventure.3D.2016.1080p.Blu-ray.AVC.TrueHD.7.1\",\n    \"subtitle\": \"\",\n    \"target\": {\n        \"type\": \"未知\",\n        \"cn_name\": \"\",\n        \"en_name\": \"National Parks Adventure\",\n        \"year\": \"2016\",\n        \"part\": \"\",\n        \"season\": \"\",\n        \"episode\": \"\",\n        \"restype\": \"BluRay 3D\",\n        \"pix\": \"1080p\",\n        \"video_codec\": \"AVC\",\n        \"audio_codec\": \"TrueHD 7.1\",\n        \"fps\": None\n    }\n}, {\n    \"title\": \"[秋叶原冥途战争][Akiba Maid Sensou][2022][WEB-DL][1080][TV Series][第01话][LeagueWEB]\",\n    \"subtitle\": \"\",\n    \"target\": {\n        \"type\": \"电视剧\",\n        \"cn_name\": \"\",\n        \"en_name\": \"Akiba Maid Sensou\",\n        \"year\": \"2022\",\n        \"part\": \"\",\n        \"season\": \"S01\",\n        \"episode\": \"E01\",\n        \"restype\": \"\",\n        \"pix\": \"1080p\",\n        \"video_codec\": \"\",\n        \"audio_codec\": \"\",\n        \"fps\": None\n    }\n}, {\n    \"title\": \"哆啦A梦：大雄的宇宙小战争 2021 (2022) - 1080p.mp4\",\n    \"subtitle\": \"\",\n    \"target\": {\n        \"type\": \"未知\",\n        \"cn_name\": \"哆啦A梦：大雄的宇宙小战争 2021\",\n        \"en_name\": \"\",\n        \"year\": \"2022\",\n        \"part\": \"\",\n        \"season\": \"\",\n        \"episode\": \"\",\n        \"restype\": \"\",\n        \"pix\": \"1080p\",\n        \"video_codec\": \"\",\n        \"audio_codec\": \"\",\n        \"fps\": None\n    }\n}, {\n    \"title\": \"新精武门1991 (1991).mkv\",\n    \"subtitle\": \"\",\n    \"target\": {\n        \"type\": \"未知\",\n        \"cn_name\": \"新精武门1991\",\n        \"en_name\": \"\",\n        \"year\": \"1991\",\n        \"part\": \"\",\n        \"season\": \"\",\n        \"episode\": \"\",\n        \"restype\": \"\",\n        \"pix\": \"\",\n        \"video_codec\": \"\",\n        \"audio_codec\": \"\",\n        \"fps\": None\n    }\n}, {\n    \"title\": \"24 S01 1080p WEB-DL AAC2.0 H.264-BTN\",\n    \"subtitle\": \"\",\n    \"target\": {\n        \"type\": \"电视剧\",\n        \"cn_name\": \"\",\n        \"en_name\": \"24\",\n        \"year\": \"\",\n        \"part\": \"\",\n        \"season\": \"S01\",\n        \"episode\": \"\",\n        \"restype\": \"WEB-DL\",\n        \"pix\": \"1080p\",\n        \"video_codec\": \"H264\",\n        \"audio_codec\": \"AAC 2.0\",\n        \"fps\": None\n    }\n}, {\n    \"title\": \"Qi Refining for 3000 Years S01E06 2022 1080p B-Blobal WEB-DL X264 AAC-AnimeS@AdWeb\",\n    \"subtitle\": \"\",\n    \"target\": {\n        \"type\": \"电视剧\",\n        \"cn_name\": \"\",\n        \"en_name\": \"Qi Refining For 3000 Years\",\n        \"year\": \"2022\",\n        \"part\": \"\",\n        \"season\": \"S01\",\n        \"episode\": \"E06\",\n        \"restype\": \"WEB-DL\",\n        \"pix\": \"1080p\",\n        \"video_codec\": \"x264\",\n        \"audio_codec\": \"AAC\",\n        \"fps\": None\n    }\n}, {\n    \"title\": \"Noumin Kanren no Skill Bakka Agetetara Naze ka Tsuyoku Natta S01E02 2022 1080p B-Global WEB-DL X264 AAC-AnimeS@ADWeb[2022年10月新番]\",\n    \"subtitle\": \"\",\n    \"target\": {\n        \"type\": \"电视剧\",\n        \"cn_name\": \"\",\n        \"en_name\": \"Noumin Kanren No Skill Bakka Agetetara Naze Ka Tsuyoku Natta\",\n        \"year\": \"2022\",\n        \"part\": \"\",\n        \"season\": \"S01\",\n        \"episode\": \"E02\",\n        \"restype\": \"WEB-DL\",\n        \"pix\": \"1080p\",\n        \"video_codec\": \"x264\",\n        \"audio_codec\": \"AAC\",\n        \"fps\": None\n    }\n}, {\n    \"title\": \"dou luo da lu S01E229 2018 2160p WEB-DL H265 AAC-ADWeb[[国漫连载] 斗罗大陆 第229集 4k | 国语中字]\",\n    \"subtitle\": \"\",\n    \"target\": {\n        \"type\": \"电视剧\",\n        \"cn_name\": \"\",\n        \"en_name\": \"Dou Luo Da Lu\",\n        \"year\": \"2018\",\n        \"part\": \"\",\n        \"season\": \"S01\",\n        \"episode\": \"E229\",\n        \"restype\": \"WEB-DL\",\n        \"pix\": \"2160p\",\n        \"video_codec\": \"H265\",\n        \"audio_codec\": \"AAC\",\n        \"fps\": None\n    }\n}, {\n    \"title\": \"Thor Love and Thunder (2022) [1080p] [WEBRip] [5.1]\",\n    \"subtitle\": \"\",\n    \"target\": {\n        \"type\": \"未知\",\n        \"cn_name\": \"\",\n        \"en_name\": \"Thor Love And Thunder\",\n        \"year\": \"2022\",\n        \"part\": \"\",\n        \"season\": \"\",\n        \"episode\": \"\",\n        \"restype\": \"\",\n        \"pix\": \"1080p\",\n        \"video_codec\": \"\",\n        \"audio_codec\": \"5.1\",\n        \"fps\": None\n    }\n}, {\n    \"title\": \"[Animations(动画片)][[诛仙][Jade Dynasty][2022][WEB-DL][2160][TV Series][TV 08][LeagueWEB]][诛仙/诛仙动画 第一季 第08集 | 类型:动画 [国语中字]][680.12 MB]\",\n    \"subtitle\": \"\",\n    \"target\": {\n        \"type\": \"电视剧\",\n        \"cn_name\": \"\",\n        \"en_name\": \"Jade Dynasty\",\n        \"year\": \"2022\",\n        \"part\": \"\",\n        \"season\": \"S01\",\n        \"episode\": \"E08\",\n        \"restype\": \"\",\n        \"pix\": \"\",\n        \"video_codec\": \"\",\n        \"audio_codec\": \"\",\n        \"fps\": None\n    }\n}, {\n    \"title\": \"钢铁侠2 (2010) 1080p AC3.mp4\",\n    \"subtitle\": \"\",\n    \"target\": {\n        \"type\": \"未知\",\n        \"cn_name\": \"钢铁侠2\",\n        \"en_name\": \"\",\n        \"year\": \"2010\",\n        \"part\": \"\",\n        \"season\": \"\",\n        \"episode\": \"\",\n        \"restype\": \"\",\n        \"pix\": \"1080p\",\n        \"video_codec\": \"\",\n        \"audio_codec\": \"AC3\",\n        \"fps\": None\n    }\n}, {\n    \"title\": \"Wonder Woman 1984 2020 BluRay 1080p Atmos TrueHD 7.1 X264-EPiC\",\n    \"subtitle\": \"\",\n    \"target\": {\n        \"type\": \"未知\",\n        \"cn_name\": \"\",\n        \"en_name\": \"Wonder Woman 1984\",\n        \"year\": \"2020\",\n        \"part\": \"\",\n        \"season\": \"\",\n        \"episode\": \"\",\n        \"restype\": \"BluRay\",\n        \"pix\": \"1080p\",\n        \"video_codec\": \"x264\",\n        \"audio_codec\": \"Atmos TrueHD 7.1\",\n        \"fps\": None\n    }\n}, {\n    \"title\": \"9-1-1 - S04E03 - Future Tense WEBDL-1080p.mp4\",\n    \"subtitle\": \"\",\n    \"target\": {\n        \"type\": \"电视剧\",\n        \"cn_name\": \"\",\n        \"en_name\": \"9 1 1\",\n        \"year\": \"\",\n        \"part\": \"\",\n        \"season\": \"S04\",\n        \"episode\": \"E03\",\n        \"restype\": \"WEB-DL\",\n        \"pix\": \"1080p\",\n        \"video_codec\": \"\",\n        \"audio_codec\": \"\",\n        \"fps\": None\n    }\n}, {\n    \"title\": \"【幻月字幕组】【22年日剧】【据幸存的六人所说】【04】【1080P】【中日双语】\",\n    \"subtitle\": \"\",\n    \"target\": {\n        \"type\": \"电视剧\",\n        \"cn_name\": \"据幸存的六人所说\",\n        \"en_name\": \"\",\n        \"year\": \"\",\n        \"part\": \"\",\n        \"season\": \"S01\",\n        \"episode\": \"E04\",\n        \"restype\": \"\",\n        \"pix\": \"1080p\",\n        \"video_codec\": \"\",\n        \"audio_codec\": \"\",\n        \"fps\": None\n    }\n}, {\n    \"title\": \"【爪爪字幕组】★7月新番[即使如此依旧步步进逼/Soredemo Ayumu wa Yosetekuru][09][1080p][HEVC][GB][MP4][招募翻译校对]\",\n    \"subtitle\": \"\",\n    \"target\": {\n        \"type\": \"电视剧\",\n        \"cn_name\": \"\",\n        \"en_name\": \"Soredemo Ayumu Wa Yosetekuru\",\n        \"year\": \"\",\n        \"part\": \"\",\n        \"season\": \"S01\",\n        \"episode\": \"E09\",\n        \"restype\": \"\",\n        \"pix\": \"1080p\",\n        \"video_codec\": \"HEVC\",\n        \"audio_codec\": \"\",\n        \"fps\": None\n    }\n}, {\n    \"title\": \"[猎户不鸽发布组] 不死者之王 第四季 OVERLORD Ⅳ [02] [1080p] [简中内封] [2022年7月番]\",\n    \"subtitle\": \"\",\n    \"target\": {\n        \"type\": \"电视剧\",\n        \"cn_name\": \"不死者之王\",\n        \"en_name\": \"Overlord Ⅳ\",\n        \"year\": \"\",\n        \"part\": \"\",\n        \"season\": \"S04\",\n        \"episode\": \"E02\",\n        \"restype\": \"\",\n        \"pix\": \"1080p\",\n        \"video_codec\": \"\",\n        \"audio_codec\": \"\",\n        \"fps\": None\n    }\n}, {\n    \"title\": \"[GM-Team][国漫][寻剑 第1季][Sword Quest Season 1][2002][02][AVC][GB][1080P]\",\n    \"subtitle\": \"\",\n    \"target\": {\n        \"type\": \"电视剧\",\n        \"cn_name\": \"\",\n        \"en_name\": \"Sword Quest\",\n        \"year\": \"2002\",\n        \"part\": \"\",\n        \"season\": \"S01\",\n        \"episode\": \"E02\",\n        \"restype\": \"\",\n        \"pix\": \"1080p\",\n        \"video_codec\": \"AVC\",\n        \"audio_codec\": \"\",\n        \"fps\": None\n    }\n}, {\n    \"title\": \" [猎户不鸽发布组] 组长女儿与照料专员 / 组长女儿与保姆 Kumichou Musume to Sewagakari [09] [1080p+] [简中内嵌] [2022年7月番]\",\n    \"subtitle\": \"\",\n    \"target\": {\n        \"type\": \"电视剧\",\n        \"cn_name\": \"组长女儿与保姆\",\n        \"en_name\": \"Kumichou Musume To Sewagakari\",\n        \"year\": \"\",\n        \"part\": \"\",\n        \"season\": \"S01\",\n        \"episode\": \"E09\",\n        \"restype\": \"\",\n        \"pix\": \"1080p\",\n        \"video_codec\": \"\",\n        \"audio_codec\": \"\",\n        \"fps\": None\n    }\n}, {\n    \"title\": \"Nande Koko ni Sensei ga!? 2019 Blu-ray Remux 1080p AVC LPCM-7³ ACG\",\n    \"subtitle\": \"\",\n    \"target\": {\n        \"type\": \"未知\",\n        \"cn_name\": \"\",\n        \"en_name\": \"Nande Koko Ni Sensei Ga!?\",\n        \"year\": \"2019\",\n        \"part\": \"\",\n        \"season\": \"\",\n        \"episode\": \"\",\n        \"restype\": \"BluRay REMUX\",\n        \"pix\": \"1080p\",\n        \"video_codec\": \"AVC\",\n        \"audio_codec\": \"LPCM 7³\",\n        \"fps\": None\n    }\n}, {\n    \"title\": \"30.Rock.S02E01.1080p.UHD.BluRay.X264-BORDURE.mkv\",\n    \"subtitle\": \"\",\n    \"target\": {\n        \"type\": \"电视剧\",\n        \"cn_name\": \"\",\n        \"en_name\": \"30 Rock\",\n        \"year\": \"\",\n        \"part\": \"\",\n        \"season\": \"S02\",\n        \"episode\": \"E01\",\n        \"restype\": \"UHD BluRay\",\n        \"pix\": \"1080p\",\n        \"video_codec\": \"x264\",\n        \"audio_codec\": \"\",\n        \"fps\": None\n    }\n}, {\n    \"title\": \"[Gal to Kyouryuu][02][BDRIP][1080P][H264_FLAC].mkv\",\n    \"subtitle\": \"\",\n    \"target\": {\n        \"type\": \"电视剧\",\n        \"cn_name\": \"\",\n        \"en_name\": \"Gal To Kyouryuu\",\n        \"year\": \"\",\n        \"part\": \"\",\n        \"season\": \"S01\",\n        \"episode\": \"E02\",\n        \"restype\": \"\",\n        \"pix\": \"1080p\",\n        \"video_codec\": \"H264\",\n        \"audio_codec\": \"FLAC\",\n        \"fps\": None\n    }\n}, {\n    \"title\": \"[AI-Raws] 逆境無頼カイジ #13 (BD HEVC 1920x1080 yuv444p10le FLAC)[7CFEE642].mkv\",\n    \"subtitle\": \"\",\n    \"target\": {\n        \"type\": \"电视剧\",\n        \"cn_name\": \"逆境無頼カイジ\",\n        \"en_name\": \"\",\n        \"year\": \"\",\n        \"part\": \"\",\n        \"season\": \"S01\",\n        \"episode\": \"E13\",\n        \"restype\": \"BD\",\n        \"pix\": \"1080p\",\n        \"video_codec\": \"HEVC\",\n        \"audio_codec\": \"FLAC\",\n        \"fps\": None\n    }\n}, {\n    \"title\": \"Mr. Robot - S02E06 - eps2.4_m4ster-s1ave.aes SDTV.mp4\",\n    \"subtitle\": \"\",\n    \"target\": {\n        \"type\": \"电视剧\",\n        \"cn_name\": \"\",\n        \"en_name\": \"Mr Robot\",\n        \"year\": \"\",\n        \"part\": \"\",\n        \"season\": \"S02\",\n        \"episode\": \"E06\",\n        \"restype\": \"\",\n        \"pix\": \"\",\n        \"video_codec\": \"\",\n        \"audio_codec\": \"\",\n        \"fps\": None\n    }\n}, {\n    \"title\": \"[神印王座][Throne of Seal][2022][WEB-DL][2160][TV Series][TV 22][LeagueWEB] 神印王座 第一季 第22集 | 类型:动画 [国语中字][967.44 MB]\",\n    \"subtitle\": \"\",\n    \"target\": {\n        \"type\": \"电视剧\",\n        \"cn_name\": \"\",\n        \"en_name\": \"Throne Of Seal\",\n        \"year\": \"2022\",\n        \"part\": \"\",\n        \"season\": \"S01\",\n        \"episode\": \"E22\",\n        \"restype\": \"\",\n        \"pix\": \"\",\n        \"video_codec\": \"\",\n        \"audio_codec\": \"\",\n        \"fps\": None\n    }\n}, {\n    \"title\": \"S02E1000.mkv\",\n    \"subtitle\": \"\",\n    \"target\": {\n        \"type\": \"电视剧\",\n        \"cn_name\": \"\",\n        \"en_name\": \"\",\n        \"year\": \"\",\n        \"part\": \"\",\n        \"season\": \"S02\",\n        \"episode\": \"E1000\",\n        \"restype\": \"\",\n        \"pix\": \"\",\n        \"video_codec\": \"\",\n        \"audio_codec\": \"\",\n        \"fps\": None\n    }\n}, {\n    \"title\": \"西部世界 12.mkv\",\n    \"subtitle\": \"\",\n    \"target\": {\n        \"type\": \"电视剧\",\n        \"cn_name\": \"西部世界\",\n        \"en_name\": \"\",\n        \"year\": \"\",\n        \"part\": \"\",\n        \"season\": \"S01\",\n        \"episode\": \"E12\",\n        \"restype\": \"\",\n        \"pix\": \"\",\n        \"video_codec\": \"\",\n        \"audio_codec\": \"\",\n        \"fps\": None\n    }\n}, {\n    \"title\": \"[ANi] OVERLORD 第四季 - 04 [1080P][Baha][WEB-DL][AAC AVC][CHT].mp4\",\n    \"subtitle\": \"\",\n    \"target\": {\n        \"type\": \"电视剧\",\n        \"cn_name\": \"\",\n        \"en_name\": \"Overlord\",\n        \"year\": \"\",\n        \"part\": \"\",\n        \"season\": \"S04\",\n        \"episode\": \"E04\",\n        \"restype\": \"\",\n        \"pix\": \"1080p\",\n        \"video_codec\": \"AVC\",\n        \"audio_codec\": \"AAC\",\n        \"fps\": None\n    }\n}, {\n    \"title\": \"[SweetSub&LoliHouse] Made in Abyss S2 - 03v2 [WebRip 1080p HEVC-10bit AAC ASSx2].mkv\",\n    \"subtitle\": \"\",\n    \"target\": {\n        \"type\": \"电视剧\",\n        \"cn_name\": \"\",\n        \"en_name\": \"Made In Abyss\",\n        \"year\": \"\",\n        \"part\": \"\",\n        \"season\": \"S02\",\n        \"episode\": \"E03\",\n        \"restype\": \"\",\n        \"pix\": \"1080p\",\n        \"video_codec\": \"\",\n        \"audio_codec\": \"AAC\",\n        \"fps\": None\n    }\n}, {\n    \"title\": \"[GM-Team][国漫][斗破苍穹 第5季][Fights Break Sphere V][2022][05][HEVC][GB][4K]\",\n    \"subtitle\": \"\",\n    \"target\": {\n        \"type\": \"电视剧\",\n        \"cn_name\": \"\",\n        \"en_name\": \"Fights Break Sphere V\",\n        \"year\": \"2022\",\n        \"part\": \"\",\n        \"season\": \"S05\",\n        \"episode\": \"E05\",\n        \"restype\": \"\",\n        \"pix\": \"2160p\",\n        \"video_codec\": \"HEVC\",\n        \"audio_codec\": \"\",\n        \"fps\": None\n    }\n}, {\n    \"title\": \"Ousama Ranking S01E02-[1080p][BDRIP][X265.FLAC].mkv\",\n    \"subtitle\": \"\",\n    \"target\": {\n        \"type\": \"电视剧\",\n        \"cn_name\": \"\",\n        \"en_name\": \"Ousama Ranking\",\n        \"year\": \"\",\n        \"part\": \"\",\n        \"season\": \"S01\",\n        \"episode\": \"E02\",\n        \"restype\": \"BDRIP\",\n        \"pix\": \"1080p\",\n        \"video_codec\": \"x265\",\n        \"audio_codec\": \"FLAC\",\n        \"fps\": None\n    }\n}, {\n    \"title\": \"[Nekomoe kissaten&LoliHouse] Soredemo Ayumu wa Yosetekuru - 01v2 [WebRip 1080p HEVC-10bit EAC3 ASSx2].mkv\",\n    \"subtitle\": \"\",\n    \"target\": {\n        \"type\": \"电视剧\",\n        \"cn_name\": \"\",\n        \"en_name\": \"Soredemo Ayumu Wa Yosetekuru\",\n        \"year\": \"\",\n        \"part\": \"\",\n        \"season\": \"S01\",\n        \"episode\": \"E01\",\n        \"restype\": \"\",\n        \"pix\": \"1080p\",\n        \"video_codec\": \"\",\n        \"audio_codec\": \"EAC3\",\n        \"fps\": None\n    }\n}, {\n    \"title\": \"[喵萌奶茶屋&LoliHouse] 金装的薇尔梅 / Kinsou no Vermeil - 01 [WebRip 1080p HEVC-10bit AAC][简繁内封字幕]\",\n    \"subtitle\": \"\",\n    \"target\": {\n        \"type\": \"电视剧\",\n        \"cn_name\": \"金装的薇尔梅\",\n        \"en_name\": \"Kinsou No Vermeil\",\n        \"year\": \"\",\n        \"part\": \"\",\n        \"season\": \"S01\",\n        \"episode\": \"E01\",\n        \"restype\": \"\",\n        \"pix\": \"1080p\",\n        \"video_codec\": \"\",\n        \"audio_codec\": \"AAC\",\n        \"fps\": None\n    }\n}, {\n    \"title\": \"Hataraku.Maou-sama.S02E05.2022.1080p.CR.WEB-DL.X264.AAC-ADWeb.mkv\",\n    \"subtitle\": \"\",\n    \"target\": {\n        \"type\": \"电视剧\",\n        \"cn_name\": \"\",\n        \"en_name\": \"Hataraku Maou Sama\",\n        \"year\": \"2022\",\n        \"part\": \"\",\n        \"season\": \"S02\",\n        \"episode\": \"E05\",\n        \"restype\": \"WEB-DL\",\n        \"pix\": \"1080p\",\n        \"video_codec\": \"x264\",\n        \"audio_codec\": \"AAC\",\n        \"fps\": None\n    }\n}, {\n    \"title\": \"The Witch Part 2：The Other One 2022 1080p WEB-DL AAC5.1 H264-tG1R0\",\n    \"subtitle\": \"\",\n    \"target\": {\n        \"type\": \"未知\",\n        \"cn_name\": \"\",\n        \"en_name\": \"The Witch Part 2：The Other One\",\n        \"year\": \"2022\",\n        \"part\": \"\",\n        \"season\": \"\",\n        \"episode\": \"\",\n        \"restype\": \"WEB-DL\",\n        \"pix\": \"1080p\",\n        \"video_codec\": \"H264\",\n        \"audio_codec\": \"AAC 5.1\",\n        \"fps\": None\n    }\n}, {\n    \"title\": \"一夜新娘 - S02E07 - 第 7 集.mp4\",\n    \"subtitle\": \"\",\n    \"target\": {\n        \"type\": \"电视剧\",\n        \"cn_name\": \"一夜新娘\",\n        \"en_name\": \"\",\n        \"year\": \"\",\n        \"part\": \"\",\n        \"season\": \"S02\",\n        \"episode\": \"E07\",\n        \"restype\": \"\",\n        \"pix\": \"\",\n        \"video_codec\": \"\",\n        \"audio_codec\": \"\",\n        \"fps\": None\n    }\n}, {\n    \"title\": \"[ANi] 處刑少女的生存之道 - 07 [1080P][Baha][WEB-DL][AAC AVC][CHT].mp4\",\n    \"subtitle\": \"\",\n    \"target\": {\n        \"type\": \"电视剧\",\n        \"cn_name\": \"處刑少女的生存之道\",\n        \"en_name\": \"\",\n        \"year\": \"\",\n        \"part\": \"\",\n        \"season\": \"S01\",\n        \"episode\": \"E07\",\n        \"restype\": \"\",\n        \"pix\": \"1080p\",\n        \"video_codec\": \"AVC\",\n        \"audio_codec\": \"AAC\",\n        \"fps\": None\n    }\n}, {\n    \"title\": \"Stand-up.Comedy.S01E01.PartA.2022.1080p.WEB-DL.H264.AAC-TJUPT.mp4\",\n    \"subtitle\": \"\",\n    \"target\": {\n        \"type\": \"电视剧\",\n        \"cn_name\": \"\",\n        \"en_name\": \"Stand Up Comedy\",\n        \"year\": \"2022\",\n        \"part\": \"PartA\",\n        \"season\": \"S01\",\n        \"episode\": \"E01\",\n        \"restype\": \"WEB-DL\",\n        \"pix\": \"1080p\",\n        \"video_codec\": \"H264\",\n        \"audio_codec\": \"AAC\",\n        \"fps\": None\n    }\n}, {\n    \"title\": \"教父3.The.Godfather.Part.III.1990.1080p.NF.WEBRip.H264.DDP5.1-PTerWEB.mkv\",\n    \"subtitle\": \"\",\n    \"target\": {\n        \"type\": \"未知\",\n        \"cn_name\": \"教父3\",\n        \"en_name\": \"The Godfather Part Iii\",\n        \"year\": \"1990\",\n        \"part\": \"\",\n        \"season\": \"\",\n        \"episode\": \"\",\n        \"restype\": \"WEBRip\",\n        \"pix\": \"1080p\",\n        \"video_codec\": \"H264\",\n        \"audio_codec\": \"DDP 5.1\",\n        \"fps\": None\n    }\n}, {\n    \"title\": \"A.Quiet.Place.Part.II.2020.1080p.UHD.BluRay.DD+7.1.DoVi.X265-PuTao\",\n    \"subtitle\": \"\",\n    \"target\": {\n        \"type\": \"未知\",\n        \"cn_name\": \"\",\n        \"en_name\": \"A Quiet Place Part Ii\",\n        \"year\": \"2020\",\n        \"part\": \"\",\n        \"season\": \"\",\n        \"episode\": \"\",\n        \"restype\": \"UHD BluRay DoVi\",\n        \"pix\": \"1080p\",\n        \"video_codec\": \"x265\",\n        \"audio_codec\": \"DD+ 7.1\",\n        \"fps\": None\n    }\n}, {\n    \"title\": \"Childhood.In.A.Capsule.S01E16.2022.1080p.KKTV.WEB-DL.X264.AAC-ADWeb.mkv\",\n    \"subtitle\": \"\",\n    \"target\": {\n        \"type\": \"电视剧\",\n        \"cn_name\": \"\",\n        \"en_name\": \"Childhood In A Capsule\",\n        \"year\": \"2022\",\n        \"part\": \"\",\n        \"season\": \"S01\",\n        \"episode\": \"E16\",\n        \"restype\": \"WEB-DL\",\n        \"pix\": \"1080p\",\n        \"video_codec\": \"x264\",\n        \"audio_codec\": \"AAC\",\n        \"fps\": None\n    }\n}, {\n    \"title\": \"[桜都字幕组] 异世界归来的舅舅 / Isekai Ojisan [01][1080p][简体内嵌]\",\n    \"subtitle\": \"\",\n    \"target\": {\n        \"type\": \"电视剧\",\n        \"cn_name\": \"\",\n        \"en_name\": \"Isekai Ojisan\",\n        \"year\": \"\",\n        \"part\": \"\",\n        \"season\": \"S01\",\n        \"episode\": \"E01\",\n        \"restype\": \"\",\n        \"pix\": \"1080p\",\n        \"video_codec\": \"\",\n        \"audio_codec\": \"\",\n        \"fps\": None\n    }\n}, {\n    \"title\": \"【喵萌奶茶屋】★04月新番★[夏日重現/Summer Time Rendering][15][720p][繁日雙語][招募翻譯片源]\",\n    \"subtitle\": \"\",\n    \"target\": {\n        \"type\": \"电视剧\",\n        \"cn_name\": \"\",\n        \"en_name\": \"Summer Time Rendering\",\n        \"year\": \"\",\n        \"part\": \"\",\n        \"season\": \"S01\",\n        \"episode\": \"E15\",\n        \"restype\": \"\",\n        \"pix\": \"720p\",\n        \"video_codec\": \"\",\n        \"audio_codec\": \"\",\n        \"fps\": None\n    }\n}, {\n    \"title\": \"[NC-Raws] 打工吧！魔王大人 第二季 / Hataraku Maou-sama!! - 02 (B-Global 1920x1080 HEVC AAC MKV)\",\n    \"subtitle\": \"\",\n    \"target\": {\n        \"type\": \"电视剧\",\n        \"cn_name\": \"打工吧！魔王大人\",\n        \"en_name\": \"Hataraku Maou-Sama!!\",\n        \"year\": \"\",\n        \"part\": \"\",\n        \"season\": \"S02\",\n        \"episode\": \"E02\",\n        \"restype\": \"\",\n        \"pix\": \"1080p\",\n        \"video_codec\": \"HEVC\",\n        \"audio_codec\": \"AAC\",\n        \"fps\": None\n    }\n}, {\n    \"title\": \"The Witch Part 2 The Other One 2022 1080p WEB-DL AAC5.1 H.264-tG1R0\",\n    \"subtitle\": \"\",\n    \"target\": {\n        \"type\": \"未知\",\n        \"cn_name\": \"\",\n        \"en_name\": \"The Witch Part 2 The Other One\",\n        \"year\": \"2022\",\n        \"part\": \"\",\n        \"season\": \"\",\n        \"episode\": \"\",\n        \"restype\": \"WEB-DL\",\n        \"pix\": \"1080p\",\n        \"video_codec\": \"H264\",\n        \"audio_codec\": \"AAC 5.1\",\n        \"fps\": None\n    }\n}, {\n    \"title\": \"The 355 2022 BluRay 1080p DTS-HD MA5.1 X265.10bit-BeiTai\",\n    \"subtitle\": \"\",\n    \"target\": {\n        \"type\": \"未知\",\n        \"cn_name\": \"\",\n        \"en_name\": \"The 355\",\n        \"year\": \"2022\",\n        \"part\": \"\",\n        \"season\": \"\",\n        \"episode\": \"\",\n        \"restype\": \"BluRay\",\n        \"pix\": \"1080p\",\n        \"video_codec\": \"x265 10bit\",\n        \"audio_codec\": \"DTS-HD MA 5.1\",\n        \"fps\": None\n    }\n}, {\n    \"title\": \"Sense8 s01-s02 2015-2017 1080P WEB-DL X265 AC3￡cXcY@FRDS\",\n    \"subtitle\": \"\",\n    \"target\": {\n        \"type\": \"电视剧\",\n        \"cn_name\": \"\",\n        \"en_name\": \"Sense8\",\n        \"year\": \"2015\",\n        \"part\": \"\",\n        \"season\": \"S01-S02\",\n        \"episode\": \"\",\n        \"restype\": \"WEB-DL\",\n        \"pix\": \"1080p\",\n        \"video_codec\": \"x265\",\n        \"audio_codec\": \"\",\n        \"fps\": None\n    }\n}, {\n    \"title\": \"The Heart of Genius S01 13-14 2022 1080p WEB-DL H264 AAC\",\n    \"subtitle\": \"\",\n    \"target\": {\n        \"type\": \"电视剧\",\n        \"cn_name\": \"\",\n        \"en_name\": \"The Heart Of Genius\",\n        \"year\": \"2022\",\n        \"part\": \"\",\n        \"season\": \"S01\",\n        \"episode\": \"E13-E14\",\n        \"restype\": \"WEB-DL\",\n        \"pix\": \"1080p\",\n        \"video_codec\": \"H264\",\n        \"audio_codec\": \"AAC\",\n        \"fps\": None\n    }\n}, {\n    \"title\": \"The Heart of Genius E13-14 2022 1080p WEB-DL H264 AAC\",\n    \"subtitle\": \"\",\n    \"target\": {\n        \"type\": \"电视剧\",\n        \"cn_name\": \"\",\n        \"en_name\": \"The Heart Of Genius\",\n        \"year\": \"2022\",\n        \"part\": \"\",\n        \"season\": \"S01\",\n        \"episode\": \"E13-E14\",\n        \"restype\": \"WEB-DL\",\n        \"pix\": \"1080p\",\n        \"video_codec\": \"H264\",\n        \"audio_codec\": \"AAC\",\n        \"fps\": None\n    }\n}, {\n    \"title\": \"2022.8.2.Twelve.Monkeys.1995.GBR.4K.REMASTERED.BluRay.1080p.X264.DTS [3.4 GB]\",\n    \"subtitle\": \"\",\n    \"target\": {\n        \"type\": \"未知\",\n        \"cn_name\": \"\",\n        \"en_name\": \"Twelve Monkeys\",\n        \"year\": \"1995\",\n        \"part\": \"\",\n        \"season\": \"\",\n        \"episode\": \"\",\n        \"restype\": \"BluRay\",\n        \"pix\": \"4k\",\n        \"video_codec\": \"x264\",\n        \"audio_codec\": \"DTS\",\n        \"fps\": None\n    }\n}, {\n    \"title\": \"[NC-Raws] 王者天下 第四季 - 17 (Baha 1920x1080 AVC AAC MP4) [3B1AA7BB].mp4\",\n    \"subtitle\": \"\",\n    \"target\": {\n        \"type\": \"电视剧\",\n        \"cn_name\": \"王者天下\",\n        \"en_name\": \"\",\n        \"year\": \"\",\n        \"part\": \"\",\n        \"season\": \"S04\",\n        \"episode\": \"E17\",\n        \"restype\": \"\",\n        \"pix\": \"1080p\",\n        \"video_codec\": \"AVC\",\n        \"audio_codec\": \"AAC\",\n        \"fps\": None\n    }\n}, {\n    \"title\": \"Sense8 S2E1 2015-2017 1080P WEB-DL X265 AC3￡cXcY@FRDS\",\n    \"subtitle\": \"\",\n    \"target\": {\n        \"type\": \"电视剧\",\n        \"cn_name\": \"\",\n        \"en_name\": \"Sense8\",\n        \"year\": \"2015\",\n        \"part\": \"\",\n        \"season\": \"S02\",\n        \"episode\": \"E01\",\n        \"restype\": \"WEB-DL\",\n        \"pix\": \"1080p\",\n        \"video_codec\": \"x265\",\n        \"audio_codec\": \"\",\n        \"fps\": None\n    }\n}, {\n    \"title\": \"[xyx98]传颂之物/Utawarerumono/うたわれるもの[BDrip][1920x1080][TV 01-26 Fin][hevc-yuv420p10 flac_ac3][ENG PGS]\",\n    \"subtitle\": \"\",\n    \"target\": {\n        \"type\": \"电视剧\",\n        \"cn_name\": \"\",\n        \"en_name\": \"うたわれるもの\",\n        \"year\": \"\",\n        \"part\": \"\",\n        \"season\": \"S01\",\n        \"episode\": \"E01-E26\",\n        \"restype\": \"\",\n        \"pix\": \"1080p\",\n        \"video_codec\": \"\",\n        \"audio_codec\": \"flac\",\n        \"fps\": None\n    }\n}, {\n    \"title\": \"[云歌字幕组][7月新番][欢迎来到实力至上主义的教室 第二季][01][X264 10bit][1080p][简体中文].mp4\",\n    \"subtitle\": \"\",\n    \"target\": {\n        \"type\": \"电视剧\",\n        \"cn_name\": \"欢迎来到实力至上主义的教室\",\n        \"en_name\": \"\",\n        \"year\": \"\",\n        \"part\": \"\",\n        \"season\": \"S02\",\n        \"episode\": \"E01\",\n        \"restype\": \"\",\n        \"pix\": \"1080p\",\n        \"video_codec\": \"X264\",\n        \"audio_codec\": \"\",\n        \"fps\": None\n    }\n}, {\n    \"title\": \"[诛仙][Jade Dynasty][2022][WEB-DL][2160][TV Series][TV 04][LeagueWEB]\",\n    \"subtitle\": \"\",\n    \"target\": {\n        \"type\": \"电视剧\",\n        \"cn_name\": \"\",\n        \"en_name\": \"Jade Dynasty\",\n        \"year\": \"2022\",\n        \"part\": \"\",\n        \"season\": \"S01\",\n        \"episode\": \"E04\",\n        \"restype\": \"\",\n        \"pix\": \"\",\n        \"video_codec\": \"\",\n        \"audio_codec\": \"\",\n        \"fps\": None\n    }\n}, {\n    \"title\": \"Rick and Morty.S06E06.JuRicksic.Mort.1080p.HMAX.WEBRip.DD5.1.X264-NTb[rartv]\",\n    \"subtitle\": \"\",\n    \"target\": {\n        \"type\": \"电视剧\",\n        \"cn_name\": \"\",\n        \"en_name\": \"Rick And Morty\",\n        \"year\": \"\",\n        \"part\": \"\",\n        \"season\": \"S06\",\n        \"episode\": \"E06\",\n        \"restype\": \"WEBRip\",\n        \"pix\": \"1080p\",\n        \"video_codec\": \"x264\",\n        \"audio_codec\": \"DD 5.1\",\n        \"fps\": None\n    }\n}, {\n    \"title\": \"rick and Morty.S06E05.JuRicksic.Mort.1080p.HMAX.WEBRip.DD5.1.X264-NTb[rartv]\",\n    \"subtitle\": \"\",\n    \"target\": {\n        \"type\": \"电视剧\",\n        \"cn_name\": \"\",\n        \"en_name\": \"Rick And Morty\",\n        \"year\": \"\",\n        \"part\": \"\",\n        \"season\": \"S06\",\n        \"episode\": \"E05\",\n        \"restype\": \"WEBRip\",\n        \"pix\": \"1080p\",\n        \"video_codec\": \"x264\",\n        \"audio_codec\": \"DD 5.1\",\n        \"fps\": None\n    }\n}, {\n    \"title\": \"[Hall_of_C] 诛仙 Zhu Xian (Jade Dynasty) - Episode 19\",\n    \"subtitle\": \"\",\n    \"target\": {\n        \"type\": \"电视剧\",\n        \"cn_name\": \"诛仙\",\n        \"en_name\": \"Zhu Xian Jade Dynasty\",\n        \"year\": \"\",\n        \"part\": \"\",\n        \"season\": \"S01\",\n        \"episode\": \"E19\",\n        \"restype\": \"\",\n        \"pix\": \"\",\n        \"video_codec\": \"\",\n        \"audio_codec\": \"\",\n        \"fps\": None\n    }\n}, {\n    \"title\": \"I Woke Up a Vampire S02 2023 2160p NF WEB-DL DDP5.1 Atmos H 265-HHWEB\",\n    \"subtitle\": \"醒来变成吸血鬼 第二季 | 全8集 | 4K | 类型: 喜剧/家庭/奇幻 | 导演: TommyLynch | 主演: NikoCeci/ZebastinBorjeau/安娜·阿劳约/KaileenAngelicChang/KrisSiddiqi\",\n    \"target\": {\n        \"type\": \"电视剧\",\n        \"cn_name\": \"\",\n        \"en_name\": \"I Woke Up A Vampire\",\n        \"year\": \"2023\",\n        \"part\": \"\",\n        \"season\": \"S02\",\n        \"episode\": \"\",\n        \"restype\": \"WEB-DL\",\n        \"pix\": \"2160p\",\n        \"video_codec\": \"H265\",\n        \"audio_codec\": \"DDP 5.1 Atmos\",\n        \"fps\": None\n    }\n}, {\n    \"title\": \"Shadows of the Void S01 2024 1080p WEB-DL H264 AAC-HHWEB\",\n    \"subtitle\": \"虚无边境 | 第01-02集 | 1080p | 类型: 动画 | 导演: 巴西 | 主演: 山新/周一菡/皇贞季/Kenz/李佳怡 [内嵌中字]\",\n    \"target\": {\n        \"type\": \"电视剧\",\n        \"cn_name\": \"\",\n        \"en_name\": \"Shadows Of The Void\",\n        \"year\": \"2024\",\n        \"part\": \"\",\n        \"season\": \"S01\",\n        \"episode\": \"E01-E02\",\n        \"restype\": \"WEB-DL\",\n        \"pix\": \"1080p\",\n        \"video_codec\": \"H264\",\n        \"audio_codec\": \"AAC\",\n        \"fps\": None\n    }\n}, {\n    \"title\": \"【极影字幕社】★1月新番 Metallic Rouge/金属口红 第13话 GB 1080P MP4（字幕社招人内详）\",\n    \"subtitle\": \"\",\n    \"target\": {\n        \"type\": \"电视剧\",\n        \"cn_name\": \"金属口红\",\n        \"en_name\": \"Metallic Rouge\",\n        \"year\": \"\",\n        \"part\": \"\",\n        \"season\": \"S01\",\n        \"episode\": \"E13\",\n        \"restype\": \"\",\n        \"pix\": \"1080p\",\n        \"video_codec\": \"\",\n        \"audio_codec\": \"\",\n        \"fps\": None\n    }\n}, {\n    \"title\": \"Mai Xiang S01 2019 2160p WEB-DL H.265 DDP2.0-HHWEB\",\n    \"subtitle\": \"麦香 | 全36集 | 4K | 类型:剧情/爱情/家庭 | 主演:傅晶/章呈赫/王伟/沙景昌/何音\",\n    \"target\": {\n        \"type\": \"电视剧\",\n        \"cn_name\": \"麦香\",\n        \"en_name\": \"Mai Xiang\",\n        \"year\": \"2019\",\n        \"part\": \"\",\n        \"season\": \"S01\",\n        \"episode\": \"\",\n        \"restype\": \"WEB-DL\",\n        \"pix\": \"2160p\",\n        \"video_codec\": \"H265\",\n        \"audio_codec\": \"DDP 2.0\",\n        \"fps\": None\n    }\n}, {\n    \"path\": \"/volume1/电视剧/西部世界 第二季 (2016)/5.mkv\",\n    \"target\": {\n        \"type\": \"电视剧\",\n        \"cn_name\": \"西部世界\",\n        \"en_name\": \"\",\n        \"year\": \"2016\",\n        \"part\": \"\",\n        \"season\": \"S02\",\n        \"episode\": \"E05\",\n        \"restype\": \"\",\n        \"pix\": \"\",\n        \"video_codec\": \"\",\n        \"audio_codec\": \"\",\n        \"fps\": None\n    }\n}, {\n    \"path\": \"/movies/The Vampire Diaries (2009) [tmdbid=18165]/The.Vampire.Diaries.S01E01.1080p.mkv\",\n    \"target\": {\n        \"type\": \"电视剧\",\n        \"cn_name\": \"\",\n        \"en_name\": \"The Vampire Diaries\",\n        \"year\": \"2009\",\n        \"part\": \"\",\n        \"season\": \"S01\",\n        \"episode\": \"E01\",\n        \"restype\": \"\",\n        \"pix\": \"1080p\",\n        \"video_codec\": \"\",\n        \"audio_codec\": \"\",\n        \"tmdbid\": 18165,\n        \"fps\": None\n    }\n}, {\n    \"path\": \"/movies/Inception (2010) [tmdbid-27205]/Inception.2010.1080p.mkv\",\n    \"target\": {\n        \"type\": \"未知\",\n        \"cn_name\": \"\",\n        \"en_name\": \"Inception\",\n        \"year\": \"2010\",\n        \"part\": \"\",\n        \"season\": \"\",\n        \"episode\": \"\",\n        \"restype\": \"\",\n        \"pix\": \"1080p\",\n        \"video_codec\": \"\",\n        \"audio_codec\": \"\",\n        \"tmdbid\": 27205,\n        \"fps\": None\n    }\n}, {\n    \"path\": \"/movies/Breaking Bad (2008) [tmdb=1396]/Season 2/\",\n    \"target\": {\n        \"type\": \"电视剧\",\n        \"cn_name\": \"\",\n        \"en_name\": \"Breaking Bad\",\n        \"year\": \"2008\",\n        \"part\": \"\",\n        \"season\": \"S02\",\n        \"episode\": \"\",\n        \"restype\": \"\",\n        \"pix\": \"\",\n        \"video_codec\": \"\",\n        \"audio_codec\": \"\",\n        \"tmdbid\": 1396\n    }\n}, {\n    \"path\": \"/movies/Breaking Bad (2008) [tmdb=1396]/S2/\",\n    \"target\": {\n        \"type\": \"电视剧\",\n        \"cn_name\": \"\",\n        \"en_name\": \"Breaking Bad\",\n        \"year\": \"2008\",\n        \"part\": \"\",\n        \"season\": \"S02\",\n        \"episode\": \"\",\n        \"restype\": \"\",\n        \"pix\": \"\",\n        \"video_codec\": \"\",\n        \"audio_codec\": \"\",\n        \"tmdbid\": 1396\n    }\n}, {\n    \"path\": \"/movies/Breaking Bad (2008) [tmdb=1396]/Season 1/Breaking.Bad.S01E01.1080p.mkv\",\n    \"target\": {\n        \"type\": \"电视剧\",\n        \"cn_name\": \"\",\n        \"en_name\": \"Breaking Bad\",\n        \"year\": \"2008\",\n        \"part\": \"\",\n        \"season\": \"S01\",\n        \"episode\": \"E01\",\n        \"restype\": \"\",\n        \"pix\": \"1080p\",\n        \"video_codec\": \"\",\n        \"audio_codec\": \"\",\n        \"tmdbid\": 1396,\n        \"fps\": None\n    }\n}, {\n    \"path\": \"/tv/Game of Thrones (2011) {tmdb=1399}/Season 1/Game.of.Thrones.S01E01.1080p.mkv\",\n    \"target\": {\n        \"type\": \"电视剧\",\n        \"cn_name\": \"\",\n        \"en_name\": \"Game Of Thrones\",\n        \"year\": \"2011\",\n        \"part\": \"\",\n        \"season\": \"S01\",\n        \"episode\": \"E01\",\n        \"restype\": \"\",\n        \"pix\": \"1080p\",\n        \"video_codec\": \"\",\n        \"audio_codec\": \"\",\n        \"tmdbid\": 1399,\n        \"fps\": None\n    }\n}, {\n    \"path\": \"/movies/Avatar (2009) {tmdb-19995}/Avatar.2009.1080p.mkv\",\n    \"target\": {\n        \"type\": \"未知\",\n        \"cn_name\": \"\",\n        \"en_name\": \"Avatar\",\n        \"year\": \"2009\",\n        \"part\": \"\",\n        \"season\": \"\",\n        \"episode\": \"\",\n        \"restype\": \"\",\n        \"pix\": \"1080p\",\n        \"video_codec\": \"\",\n        \"audio_codec\": \"\",\n        \"tmdbid\": 19995,\n        \"fps\": None\n    }\n}, {\n    \"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\",\n    \"target\": {\n        \"type\": \"未知\",\n        \"cn_name\": \"\",\n        \"en_name\": \"12 Years A Slave\",\n        \"year\": \"2013\",\n        \"part\": \"\",\n        \"season\": \"\",\n        \"episode\": \"\",\n        \"restype\": \"BluRay\",\n        \"pix\": \"1080p\",\n        \"video_codec\": \"x265 10bit\",\n        \"audio_codec\": \"2Audio\"\n    }\n}]\n"
  },
  {
    "path": "tests/manual/ugreen_media_cli.py",
    "content": "from __future__ import annotations\n\nimport argparse\nimport base64\nimport getpass\nimport json\nimport os\nimport sys\nimport uuid\nfrom typing import Any, Mapping\nfrom urllib.parse import urlsplit, urlunsplit\n\n# 兼容直接运行脚本：避免 app/utils 被放在 sys.path 首位导致标准库模块被同名文件遮蔽\nif __name__ == \"__main__\" and __package__ is None:\n    script_dir = os.path.dirname(os.path.abspath(__file__))\n    project_root = os.path.abspath(os.path.join(script_dir, \"..\", \"..\"))\n    if script_dir in sys.path:\n        sys.path.remove(script_dir)\n    if project_root not in sys.path:\n        sys.path.insert(0, project_root)\n\nimport requests\n\nfrom app.utils.ugreen_crypto import UgreenCrypto\n\n\nclass UgreenLoginError(Exception):\n    pass\n\n\ndef _normalize_base_url(raw: str) -> str:\n    value = (raw or \"\").strip()\n    if not value:\n        raise UgreenLoginError(\"服务器地址不能为空\")\n    if not value.startswith((\"http://\", \"https://\")):\n        value = f\"http://{value}\"\n    parsed = urlsplit(value)\n    if not parsed.netloc:\n        raise UgreenLoginError(f\"无效服务器地址: {raw}\")\n    return urlunsplit((parsed.scheme, parsed.netloc, \"\", \"\", \"\")).rstrip(\"/\")\n\n\ndef _json_or_raise(resp: requests.Response, stage: str) -> dict[str, Any]:\n    try:\n        data = resp.json()\n    except Exception as exc:  # pragma: no cover - 网络异常路径\n        raise UgreenLoginError(\n            f\"{stage} 返回非 JSON，HTTP {resp.status_code}，响应片段: {resp.text[:200]}\"\n        ) from exc\n    if not isinstance(data, dict):\n        raise UgreenLoginError(f\"{stage} 返回格式异常: {type(data).__name__}\")\n    return data\n\n\ndef _decode_public_key(raw: str) -> str:\n    value = (raw or \"\").strip()\n    if not value:\n        raise UgreenLoginError(\"未获取到公钥\")\n    if \"BEGIN\" in value:\n        return value\n    try:\n        return base64.b64decode(value).decode(\"utf-8\")\n    except Exception as exc:\n        raise UgreenLoginError(\"公钥解码失败\") from exc\n\n\ndef _raise_if_failed(payload: Mapping[str, Any], stage: str) -> None:\n    if payload.get(\"code\") == 200:\n        return\n    raise UgreenLoginError(\n        f\"{stage}失败: code={payload.get('code')} msg={payload.get('msg')}\"\n    )\n\n\ndef _build_common_headers(\n    client_id: str, client_version: str, language: str\n) -> dict[str, str]:\n    return {\n        \"Accept\": \"application/json, text/plain, */*\",\n        \"Client-Id\": client_id,\n        \"Client-Version\": client_version,\n        \"UG-Agent\": \"PC/WEB\",\n        \"X-Specify-Language\": language,\n    }\n\n\ndef _login_and_get_access(\n    session: requests.Session,\n    base_url: str,\n    username: str,\n    password: str,\n    keepalive: bool,\n    headers: Mapping[str, str],\n    timeout: float,\n    verify_ssl: bool,\n) -> tuple[str, str]:\n    check_resp = session.post(\n        f\"{base_url}/ugreen/v1/verify/check\",\n        json={\"username\": username},\n        headers=dict(headers),\n        timeout=timeout,\n        verify=verify_ssl,\n    )\n    check_json = _json_or_raise(check_resp, \"获取登录公钥\")\n    _raise_if_failed(check_json, \"获取登录公钥\")\n\n    rsa_token = (\n        check_resp.headers.get(\"x-rsa-token\")\n        or check_resp.headers.get(\"X-Rsa-Token\")\n        or check_json.get(\"xRsaToken\")\n        or check_json.get(\"x-rsa-token\")\n    )\n    if not rsa_token:\n        data = check_json.get(\"data\")\n        if isinstance(data, Mapping):\n            rsa_token = data.get(\"xRsaToken\") or data.get(\"x-rsa-token\")\n    if not rsa_token:\n        raise UgreenLoginError(\"登录公钥为空（x-rsa-token）\")\n\n    login_public_key = _decode_public_key(str(rsa_token))\n    encrypted_password = UgreenCrypto(public_key=login_public_key).rsa_encrypt_long(\n        password\n    )\n\n    login_payload = {\n        \"username\": username,\n        \"password\": encrypted_password,\n        \"keepalive\": keepalive,\n        \"otp\": True,\n        \"is_simple\": True,\n    }\n    login_resp = session.post(\n        f\"{base_url}/ugreen/v1/verify/login\",\n        json=login_payload,\n        headers=dict(headers),\n        timeout=timeout,\n        verify=verify_ssl,\n    )\n    login_json = _json_or_raise(login_resp, \"登录\")\n    _raise_if_failed(login_json, \"登录\")\n\n    data = login_json.get(\"data\")\n    if not isinstance(data, Mapping):\n        raise UgreenLoginError(\"登录成功但响应 data 为空\")\n\n    token = str(data.get(\"token\") or \"\").strip()\n    public_key = str(data.get(\"public_key\") or \"\").strip()\n    if not token:\n        raise UgreenLoginError(\"登录成功但未拿到 token\")\n    if not public_key:\n        raise UgreenLoginError(\"登录成功但未拿到 public_key\")\n    return token, _decode_public_key(public_key)\n\n\ndef _fetch_media_lib(\n    session: requests.Session,\n    base_url: str,\n    token: str,\n    public_key: str,\n    client_id: str,\n    client_version: str,\n    language: str,\n    page: int,\n    page_size: int,\n    timeout: float,\n    verify_ssl: bool,\n) -> Any:\n    crypto = UgreenCrypto(\n        public_key=public_key,\n        token=token,\n        client_id=client_id,\n        client_version=client_version,\n        ug_agent=\"PC/WEB\",\n        language=language,\n    )\n    req = crypto.build_encrypted_request(\n        url=f\"{base_url}/ugreen/v1/video/homepage/media_list\",\n        method=\"GET\",\n        params={\"page\": page, \"page_size\": page_size},\n    )\n    media_resp = session.get(\n        req.url,\n        headers=req.headers,\n        params=req.params,\n        timeout=timeout,\n        verify=verify_ssl,\n    )\n    media_json = _json_or_raise(media_resp, \"获取媒体库\")\n    return crypto.decrypt_response(media_json, req.aes_key)\n\n\ndef parse_args(argv: list[str]) -> argparse.Namespace:\n    parser = argparse.ArgumentParser(\n        description=\"登录绿联 NAS 并调用媒体库接口（自动处理请求加密/响应解密）\"\n    )\n    parser.add_argument(\"--host\", help=\"服务器地址，例如: http://192.168.20.101:9999\")\n    parser.add_argument(\"--username\", help=\"用户名\")\n    parser.add_argument(\"--password\", help=\"密码（不传则交互输入）\")\n    parser.add_argument(\"--client-id\", help=\"可选，默认自动生成 UUID-WEB\")\n    parser.add_argument(\"--client-version\", default=\"76363\", help=\"默认: 76363\")\n    parser.add_argument(\"--language\", default=\"zh-CN\", help=\"默认: zh-CN\")\n    parser.add_argument(\"--page\", type=int, default=1, help=\"默认: 1\")\n    parser.add_argument(\"--page-size\", type=int, default=50, help=\"默认: 50\")\n    parser.add_argument(\"--timeout\", type=float, default=20.0, help=\"默认: 20 秒\")\n    parser.add_argument(\"--insecure\", action=\"store_true\", help=\"忽略 HTTPS 证书校验\")\n    parser.add_argument(\n        \"--no-keepalive\",\n        action=\"store_true\",\n        help=\"关闭保持登录（默认保持登录）\",\n    )\n    parser.add_argument(\"--pretty\", action=\"store_true\", help=\"美化输出 JSON\")\n    parser.add_argument(\"--output\", help=\"将解密后的结果写入文件\")\n    return parser.parse_args(argv)\n\n\ndef main(argv: list[str] | None = None) -> int:\n    args = parse_args(argv or sys.argv[1:])\n\n    host = args.host or input(\"服务器地址: \").strip()\n    username = args.username or input(\"用户名: \").strip()\n    password = args.password or getpass.getpass(\"密码: \")\n    client_id = (args.client_id or f\"{uuid.uuid4()}-WEB\").strip()\n    keepalive = not args.no_keepalive\n    verify_ssl = not args.insecure\n\n    try:\n        base_url = _normalize_base_url(host)\n        if args.insecure:\n            requests.packages.urllib3.disable_warnings()  # type: ignore[attr-defined]\n\n        session = requests.Session()\n        headers = _build_common_headers(\n            client_id=client_id,\n            client_version=args.client_version,\n            language=args.language,\n        )\n\n        token, public_key = _login_and_get_access(\n            session=session,\n            base_url=base_url,\n            username=username,\n            password=password,\n            keepalive=keepalive,\n            headers=headers,\n            timeout=args.timeout,\n            verify_ssl=verify_ssl,\n        )\n        decoded = _fetch_media_lib(\n            session=session,\n            base_url=base_url,\n            token=token,\n            public_key=public_key,\n            client_id=client_id,\n            client_version=args.client_version,\n            language=args.language,\n            page=args.page,\n            page_size=args.page_size,\n            timeout=args.timeout,\n            verify_ssl=verify_ssl,\n        )\n\n        if isinstance(decoded, Mapping):\n            if decoded.get(\"code\") != 200:\n                raise UgreenLoginError(\n                    f\"媒体库接口失败: code={decoded.get('code')} msg={decoded.get('msg')}\"\n                )\n            media_count = None\n            data = decoded.get(\"data\")\n            if isinstance(data, Mapping) and isinstance(data.get(\"media_lib_info_list\"), list):\n                media_count = len(data[\"media_lib_info_list\"])\n            print(\n                f\"调用成功: code={decoded.get('code')} msg={decoded.get('msg')} \"\n                f\"media_lib_info_list={media_count}\"\n            )\n\n        text = json.dumps(\n            decoded,\n            ensure_ascii=False,\n            indent=2 if args.pretty else None,\n            separators=(\",\", \":\") if not args.pretty else None,\n        )\n        if args.output:\n            with open(args.output, \"w\", encoding=\"utf-8\") as f:\n                f.write(text)\n                f.write(\"\\n\")\n            print(f\"解密结果已写入: {args.output}\")\n        else:\n            print(text)\n        return 0\n    except UgreenLoginError as exc:\n        print(f\"错误: {exc}\", file=sys.stderr)\n        return 1\n    except requests.RequestException as exc:\n        print(f\"网络错误: {exc}\", file=sys.stderr)\n        return 2\n\n\nif __name__ == \"__main__\":\n    raise SystemExit(main())\n"
  },
  {
    "path": "tests/run.py",
    "content": "import unittest\n\nfrom tests.test_bluray import BluRayTest\nfrom tests.test_mediascrape import (\n    TestMediaScrapingPaths,\n    TestMediaScrapingNFO,\n    TestMediaScrapingImages,\n    TestMediaScrapingTVDirectory,\n    TestMediaScrapeEvents\n)\nfrom tests.test_metainfo import MetaInfoTest\nfrom tests.test_object import ObjectUtilsTest\n\n\nif __name__ == '__main__':\n    suite = unittest.TestSuite()\n\n    # 测试名称识别\n    suite.addTest(MetaInfoTest('test_metainfo'))\n    suite.addTest(MetaInfoTest('test_emby_format_ids'))\n    suite.addTest(ObjectUtilsTest('test_check_method'))\n\n    # 测试自定义识别词功能\n    suite.addTest(MetaInfoTest('test_metainfopath_with_custom_words'))\n    suite.addTest(MetaInfoTest('test_metainfopath_without_custom_words'))\n    suite.addTest(MetaInfoTest('test_metainfopath_with_empty_custom_words'))\n    suite.addTest(MetaInfoTest('test_custom_words_apply_words_recording'))\n\n    # 测试蓝光目录识别\n    suite.addTest(BluRayTest())\n\n    # 测试媒体刮削\n    suite.addTest(unittest.TestLoader().loadTestsFromTestCase(TestMediaScrapingPaths))\n    suite.addTest(unittest.TestLoader().loadTestsFromTestCase(TestMediaScrapingNFO))\n    suite.addTest(unittest.TestLoader().loadTestsFromTestCase(TestMediaScrapingImages))\n    suite.addTest(unittest.TestLoader().loadTestsFromTestCase(TestMediaScrapingTVDirectory))\n    suite.addTest(unittest.TestLoader().loadTestsFromTestCase(TestMediaScrapeEvents))\n\n    # 运行测试\n    runner = unittest.TextTestRunner()\n    runner.run(suite)\n"
  },
  {
    "path": "tests/test_bluray.py",
    "content": "#!/usr/bin/env python\n# -*- coding:utf-8 -*-\nfrom pathlib import Path\nfrom typing import Optional\nfrom unittest import TestCase\nfrom unittest.mock import patch\n\nfrom app import schemas\nfrom app.chain.media import MediaChain\nfrom app.chain.storage import StorageChain\nfrom app.chain.transfer import TransferChain\nfrom app.core.context import MediaInfo\nfrom app.core.event import Event\nfrom app.core.metainfo import MetaInfoPath\nfrom app.db.models.transferhistory import TransferHistory\nfrom app.log import logger\nfrom app.schemas.types import EventType\nfrom tests.cases.files import bluray_files\n\n\nclass BluRayTest(TestCase):\n    def __init__(self, methodName=\"test\"):\n        super().__init__(methodName)\n        self.__history = []\n        self.__root = schemas.FileItem(\n            path=\"/\", name=\"\", type=\"dir\", extension=\"\", size=0\n        )\n        self.__all = {self.__root.path: self.__root}\n\n        def __build_child(parent: schemas.FileItem, files: list[tuple[str, list | int]]):\n            parent.children = []\n            for name, children in files:\n                sep = \"\" if parent.path.endswith(\"/\") else \"/\"\n                file_item = schemas.FileItem(\n                    path=f\"{parent.path}{sep}{name}\",\n                    name=name,\n                    extension=Path(name).suffix[1:],\n                    basename=Path(name).stem,\n                    type=\"file\" if isinstance(children, int) else \"dir\",\n                    size=children if isinstance(children, int) else 0,\n                )\n                parent.children.append(file_item)\n                self.__all[file_item.path] = file_item\n                if isinstance(children, list):\n                    __build_child(file_item, children)\n\n        __build_child(self.__root, bluray_files)\n\n    def _test_do_transfer(self):\n        def __test_do_transfer(path: str):\n            self.__history.clear()\n            TransferChain().do_transfer(\n                force=False,\n                background=False,\n                fileitem=StorageChain().get_file_item(None, Path(path)),\n            )\n            return self.__history\n\n        self.assertEqual(\n            [\n                \"/FOLDER/Digimon/Digimon BluRay (2055)\",\n                \"/FOLDER/Digimon/Digimon BluRay (2099)\",\n                \"/FOLDER/Digimon/Digimon (2199)/Digimon.2199.mp4\",\n            ],\n            __test_do_transfer(\"/FOLDER/Digimon\"),\n        )\n\n        self.assertEqual(\n            [\n                \"/FOLDER/Digimon/Digimon BluRay (2055)\",\n            ],\n            __test_do_transfer(\"/FOLDER/Digimon/Digimon BluRay (2055)\"),\n        )\n\n        self.assertEqual(\n            [\n                \"/FOLDER/Digimon/Digimon BluRay (2055)\",\n            ],\n            __test_do_transfer(\"/FOLDER/Digimon/Digimon BluRay (2055)/BDMV\"),\n        )\n\n        self.assertEqual(\n            [\n                \"/FOLDER/Digimon/Digimon BluRay (2055)\",\n            ],\n            __test_do_transfer(\"/FOLDER/Digimon/Digimon BluRay (2055)/BDMV/STREAM\"),\n        )\n\n        self.assertEqual(\n            [\n                \"/FOLDER/Digimon/Digimon BluRay (2055)\",\n            ],\n            __test_do_transfer(\n                \"/FOLDER/Digimon/Digimon BluRay (2055)/BDMV/STREAM/00001.m2ts\"\n            ),\n        )\n\n        self.assertEqual(\n            [\n                \"/FOLDER/Digimon/Digimon (2199)/Digimon.2199.mp4\",\n            ],\n            __test_do_transfer(\"/FOLDER/Digimon/Digimon (2199)\"),\n        )\n\n        self.assertEqual(\n            [\n                \"/FOLDER/Digimon/Digimon (2199)/Digimon.2199.mp4\",\n            ],\n            __test_do_transfer(\"/FOLDER/Digimon/Digimon (2199)/Digimon.2199.mp4\"),\n        )\n\n        self.assertEqual(\n            [\n                \"/FOLDER/Pokemon.2029.mp4\",\n            ],\n            __test_do_transfer(\"/FOLDER/Pokemon.2029.mp4\"),\n        )\n\n        self.assertEqual(\n            [\n                \"/FOLDER/Digimon/Digimon BluRay (2055)\",\n                \"/FOLDER/Digimon/Digimon BluRay (2099)\",\n                \"/FOLDER/Digimon/Digimon (2199)/Digimon.2199.mp4\",\n                \"/FOLDER/Pokemon BluRay (2016)\",\n                \"/FOLDER/Pokemon BluRay (2021)\",\n                \"/FOLDER/Pokemon (2028)/Pokemon.2028.mkv\",\n                \"/FOLDER/Pokemon.2029.mp4\",\n                \"/FOLDER/Pokemon.2039.mp4\",\n                \"/FOLDER/Pokemon (2031)/Pokemon (2031).mp4\",\n            ],\n            __test_do_transfer(\"/\"),\n        )\n\n    def _test_scrape_metadata(self, mock_metadata_nfo):\n        def __test_scrape_metadata(path: str, excepted_nfo_count: int = 1):\n            \"\"\"\n            分别测试手动和自动刮削\n            \"\"\"\n            fileitem = StorageChain().get_file_item(None, Path(path))\n            meta = MetaInfoPath(Path(fileitem.path))\n            mediainfo = MediaInfo(tmdb_info={\"id\": 1, \"title\": \"Test\"})\n\n            # 测试手动刮削\n            logger.debug(f\"测试手动刮削 {path}\")\n            mock_metadata_nfo.call_count = 0\n            MediaChain().scrape_metadata(\n                fileitem=fileitem, meta=meta, mediainfo=mediainfo, overwrite=True\n            )\n            # 确保调用了指定次数的metadata_nfo\n            self.assertEqual(mock_metadata_nfo.call_count, excepted_nfo_count)\n\n            # 测试自动刮削\n            logger.debug(f\"测试自动刮削 {path}\")\n            mock_metadata_nfo.call_count = 0\n            MediaChain().scrape_metadata_event(\n                Event(\n                    event_type=EventType.MetadataScrape,\n                    event_data={\n                        \"meta\": meta,\n                        \"mediainfo\": mediainfo,\n                        \"fileitem\": fileitem,\n                        \"file_list\": [fileitem.path],\n                        \"overwrite\": False,\n                    },\n                )\n            )\n            # 调用了指定次数的metadata_nfo\n            self.assertEqual(mock_metadata_nfo.call_count, excepted_nfo_count)\n\n        # 刮削原盘目录\n        __test_scrape_metadata(\"/FOLDER/Digimon/Digimon BluRay (2099)\")\n        # 刮削电影文件\n        __test_scrape_metadata(\"/FOLDER/Digimon/Digimon (2199)/Digimon.2199.mp4\")\n        # 刮削电影目录\n        __test_scrape_metadata(\"/FOLDER\", excepted_nfo_count=2)\n\n    @patch(\"app.chain.ChainBase.metadata_img\", return_value=None)  # 避免获取图片\n    @patch(\"app.chain.ChainBase.__init__\", return_value=None)  # 避免不必要的模块初始化\n    @patch(\"app.db.transferhistory_oper.TransferHistoryOper.get_by_src\")\n    @patch(\"app.chain.storage.StorageChain.list_files\")\n    @patch(\"app.chain.storage.StorageChain.get_parent_item\")\n    @patch(\"app.chain.storage.StorageChain.get_file_item\")\n    def test(\n        self,\n        mock_get_file_item,\n        mock_get_parent_item,\n        mock_list_files,\n        mock_get_by_src,\n        *_,\n    ):\n        def get_file_item(storage: str, path: Path):\n            path_posix = path.as_posix()\n            return self.__all.get(path_posix)\n\n        def get_parent_item(fileitem: schemas.FileItem):\n            return get_file_item(None, Path(fileitem.path).parent)\n\n        def list_files(fileitem: schemas.FileItem, recursion: bool = False):\n            if fileitem.type != \"dir\":\n                return None\n            if recursion:\n                result = []\n                file_path = f\"{fileitem.path}/\"\n                for path, item in self.__all.items():\n                    if path.startswith(file_path):\n                        result.append(item)\n                return result\n            else:\n                return fileitem.children\n\n        def get_by_src(src: str, storage: Optional[str] = None):\n            self.__history.append(src)\n            result = TransferHistory()\n            result.status = True\n            return result\n\n        mock_get_file_item.side_effect = get_file_item\n        mock_get_parent_item.side_effect = get_parent_item\n        mock_list_files.side_effect = list_files\n        mock_get_by_src.side_effect = get_by_src\n\n        self._test_do_transfer()\n\n        with patch(\n            \"app.chain.media.MediaChain.metadata_nfo\", return_value=None\n        ) as mock:\n            self._test_scrape_metadata(mock_metadata_nfo=mock)\n"
  },
  {
    "path": "tests/test_mediascrape.py",
    "content": "import sys\nimport unittest\nfrom pathlib import Path\nfrom unittest.mock import patch, MagicMock\n\nsys.modules['app.helper.sites'] = MagicMock()\nsys.modules['app.db.systemconfig_oper'] = MagicMock()\nsys.modules['app.db.systemconfig_oper'].SystemConfigOper.return_value.get.return_value = None\n\nfrom app import schemas\nfrom app.chain.media import MediaChain, ScrapingOption\nfrom app.core.context import MediaInfo\nfrom app.core.event import Event\nfrom app.core.metainfo import MetaInfo\nfrom app.schemas.types import EventType, MediaType, ScrapingTarget, ScrapingMetadata, ScrapingPolicy\n\n\nclass TestMediaScrapingPaths(unittest.TestCase):\n    def setUp(self):\n        self.media_chain = MediaChain()\n        self.media_chain.storagechain = MagicMock()\n\n    def test_movie_file_nfo_path(self):\n        fileitem = schemas.FileItem(path=\"/movies/avatar.mkv\", name=\"avatar.mkv\", type=\"file\", storage=\"local\")\n        parent_item = schemas.FileItem(path=\"/movies\", name=\"movies\", type=\"dir\", storage=\"local\")\n        self.media_chain.storagechain.get_parent_item.return_value = parent_item\n\n        target_item, target_path = self.media_chain._get_target_fileitem_and_path(\n            current_fileitem=fileitem,\n            item_type=ScrapingTarget.MOVIE,\n            metadata_type=ScrapingMetadata.NFO\n        )\n        self.assertEqual(target_item, parent_item)\n        self.assertEqual(target_path, Path(\"/movies/avatar.nfo\"))\n\n    def test_movie_dir_nfo_path(self):\n        fileitem = schemas.FileItem(path=\"/movies/Avatar (2009)\", name=\"Avatar (2009)\", type=\"dir\", storage=\"local\")\n\n        target_item, target_path = self.media_chain._get_target_fileitem_and_path(\n            current_fileitem=fileitem,\n            item_type=ScrapingTarget.MOVIE,\n            metadata_type=ScrapingMetadata.NFO\n        )\n        self.assertEqual(target_item, fileitem)\n        self.assertEqual(target_path, Path(\"/movies/Avatar (2009)/Avatar (2009).nfo\"))\n\n    def test_tv_dir_nfo_path(self):\n        fileitem = schemas.FileItem(path=\"/tv/Show\", name=\"Show\", type=\"dir\", storage=\"local\")\n        target_item, target_path = self.media_chain._get_target_fileitem_and_path(\n            current_fileitem=fileitem,\n            item_type=ScrapingTarget.TV,\n            metadata_type=ScrapingMetadata.NFO\n        )\n        self.assertEqual(target_item, fileitem)\n        self.assertEqual(target_path, Path(\"/tv/Show/tvshow.nfo\"))\n\n    def test_season_dir_nfo_path(self):\n        fileitem = schemas.FileItem(path=\"/tv/Show/Season 1\", name=\"Season 1\", type=\"dir\", storage=\"local\")\n        target_item, target_path = self.media_chain._get_target_fileitem_and_path(\n            current_fileitem=fileitem,\n            item_type=ScrapingTarget.SEASON,\n            metadata_type=ScrapingMetadata.NFO\n        )\n        self.assertEqual(target_item, fileitem)\n        self.assertEqual(target_path, Path(\"/tv/Show/Season 1/season.nfo\"))\n\n    def test_episode_file_nfo_path(self):\n        fileitem = schemas.FileItem(path=\"/tv/Show/Season 1/S01E01.mp4\", name=\"S01E01.mp4\", type=\"file\", storage=\"local\")\n        parent_item = schemas.FileItem(path=\"/tv/Show/Season 1\", name=\"Season 1\", type=\"dir\", storage=\"local\")\n        self.media_chain.storagechain.get_parent_item.return_value = parent_item\n        target_item, target_path = self.media_chain._get_target_fileitem_and_path(\n            current_fileitem=fileitem,\n            item_type=ScrapingTarget.EPISODE,\n            metadata_type=ScrapingMetadata.NFO\n        )\n        self.assertEqual(target_item, parent_item)\n        self.assertEqual(target_path, Path(\"/tv/Show/Season 1/S01E01.nfo\"))\n\n\nclass TestMediaScrapingNFO(unittest.TestCase):\n    def setUp(self):\n        self.media_chain = MediaChain()\n        self.media_chain.storagechain = MagicMock()\n        self.media_chain.metadata_nfo = MagicMock(return_value=\"<nfo></nfo>\")\n        self.media_chain._save_file = MagicMock()\n        self.media_chain.scraping_policies = MagicMock()\n\n        self.fileitem = schemas.FileItem(path=\"/movies/Avatar (2009)\", name=\"Avatar (2009)\", type=\"dir\", storage=\"local\")\n        self.meta = MetaInfo(\"Avatar (2009)\")\n        self.mediainfo = MediaInfo()\n\n    def test_scrape_nfo_off(self):\n        self.media_chain.scraping_policies.option.return_value = ScrapingOption(\"movie\", \"nfo\", ScrapingPolicy.SKIP)\n        self.media_chain._scrape_nfo_generic(self.fileitem, self.meta, self.mediainfo, ScrapingTarget.MOVIE)\n        self.media_chain.metadata_nfo.assert_not_called()\n        self.media_chain._save_file.assert_not_called()\n\n    def test_scrape_nfo_on_exists_skip(self):\n        self.media_chain.scraping_policies.option.return_value = ScrapingOption(\"movie\", \"nfo\", ScrapingPolicy.MISSINGONLY)\n        # mock file exists\n        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\")\n\n        self.media_chain._scrape_nfo_generic(self.fileitem, self.meta, self.mediainfo, ScrapingTarget.MOVIE)\n        self.media_chain.metadata_nfo.assert_not_called()\n        self.media_chain._save_file.assert_not_called()\n\n    def test_scrape_nfo_on_not_exists_scrape(self):\n        self.media_chain.scraping_policies.option.return_value = ScrapingOption(\"movie\", \"nfo\", ScrapingPolicy.MISSINGONLY)\n        # mock file not exists\n        self.media_chain.storagechain.get_file_item.return_value = None\n\n        self.media_chain._scrape_nfo_generic(self.fileitem, self.meta, self.mediainfo, ScrapingTarget.MOVIE)\n        self.media_chain.metadata_nfo.assert_called_once()\n        self.media_chain._save_file.assert_called_once()\n\n    def test_scrape_nfo_overwrite_exists_scrape(self):\n        self.media_chain.scraping_policies.option.return_value = ScrapingOption(\"movie\", \"nfo\", ScrapingPolicy.OVERWRITE)\n        # mock file exists\n        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\")\n\n        self.media_chain._scrape_nfo_generic(self.fileitem, self.meta, self.mediainfo, ScrapingTarget.MOVIE)\n        self.media_chain.metadata_nfo.assert_called_once()\n        self.media_chain._save_file.assert_called_once()\n\n\nclass TestMediaScrapingImages(unittest.TestCase):\n    def setUp(self):\n        self.media_chain = MediaChain()\n        self.original_download = self.media_chain._download_and_save_image\n        self.media_chain.storagechain = MagicMock()\n        self.media_chain.metadata_img = MagicMock()\n        self.media_chain._download_and_save_image = MagicMock()\n        self.media_chain.scraping_policies = MagicMock()\n\n    def tearDown(self):\n        self.media_chain._download_and_save_image = self.original_download\n\n    def test_scrape_images_mapping(self):\n        fileitem = schemas.FileItem(path=\"/movies/Avatar\", name=\"Avatar\", type=\"dir\", storage=\"local\")\n        mediainfo = MediaInfo()\n        self.media_chain.metadata_img.return_value = {\n            \"poster.jpg\": \"http://poster\",\n            \"fanart.jpg\": \"http://fanart\",\n            \"logo.png\": \"http://logo\"\n        }\n        self.media_chain.scraping_policies.option.return_value = ScrapingOption(\"movie\", \"poster\", ScrapingPolicy.OVERWRITE)\n        self.media_chain.storagechain.get_file_item.return_value = None\n\n        self.media_chain._scrape_images_generic(fileitem, mediainfo, ScrapingTarget.MOVIE)\n\n        # Check download called for mapped metadata\n        calls = self.media_chain._download_and_save_image.call_args_list\n        self.assertEqual(len(calls), 3)\n        urls = [call.kwargs[\"url\"] for call in calls]\n        self.assertIn(\"http://poster\", urls)\n        self.assertIn(\"http://fanart\", urls)\n        self.assertIn(\"http://logo\", urls)\n\n    def test_scrape_images_season_filter(self):\n        fileitem = schemas.FileItem(path=\"/tv/Show/Season 1\", name=\"Season 1\", type=\"dir\", storage=\"local\")\n        mediainfo = MediaInfo()\n        self.media_chain.metadata_img.return_value = {\n            \"season01-poster.jpg\": \"http://season01\",\n            \"season02-poster.jpg\": \"http://season02\"\n        }\n        self.media_chain.scraping_policies.option.return_value = ScrapingOption(\"season\", \"poster\", ScrapingPolicy.OVERWRITE)\n        self.media_chain.storagechain.get_file_item.return_value = None\n\n        self.media_chain._scrape_images_generic(fileitem, mediainfo, ScrapingTarget.SEASON, season_number=1)\n\n        calls = self.media_chain._download_and_save_image.call_args_list\n        self.assertEqual(len(calls), 1)\n        self.assertEqual(calls[0].kwargs[\"url\"], \"http://season01\")\n\n    @patch(\"app.chain.media.RequestUtils\")\n    @patch(\"app.chain.media.NamedTemporaryFile\")\n    @patch(\"app.chain.media.Path.chmod\")\n    @patch(\"app.chain.media.settings\")\n    def test_download_and_save_image(self, mock_settings, mock_chmod, mock_temp_file, mock_request_utils):\n        # We need to test _download_and_save_image directly so we remove mock\n        self.media_chain = MediaChain()\n        self.media_chain._download_and_save_image = self.original_download\n        self.media_chain.storagechain = MagicMock()\n\n        fileitem = schemas.FileItem(path=\"/movies/Avatar\", name=\"Avatar\", type=\"dir\", storage=\"local\")\n        target_path = Path(\"/movies/Avatar/poster.jpg\")\n        url = \"http://poster\"\n\n        # mock temp file\n        tmp_mock = MagicMock()\n        tmp_mock.name = \"/tmp/mockfile\"\n        mock_temp_file.return_value.__enter__.return_value = tmp_mock\n\n        # mock stream\n        mock_stream = MagicMock()\n        mock_stream.status_code = 200\n        mock_stream.iter_content.return_value = [b\"data1\", b\"data2\"]\n\n        mock_instance = mock_request_utils.return_value\n        mock_instance.get_stream.return_value.__enter__.return_value = mock_stream\n\n        self.media_chain.storagechain.upload_file.return_value = fileitem\n\n        self.media_chain._download_and_save_image(fileitem, target_path, url)\n\n        mock_request_utils.assert_called_with(proxies=mock_settings.PROXY, ua=mock_settings.NORMAL_USER_AGENT)\n        mock_instance.get_stream.assert_called_with(url=url)\n        tmp_mock.write.assert_any_call(b\"data1\")\n        tmp_mock.write.assert_any_call(b\"data2\")\n        mock_chmod.assert_called()\n        self.media_chain.storagechain.upload_file.assert_called_once()\n        call_args = self.media_chain.storagechain.upload_file.call_args.kwargs\n        self.assertEqual(call_args[\"fileitem\"], fileitem)\n        self.assertEqual(call_args[\"new_name\"], \"poster.jpg\")\n\n\nclass TestMediaScrapingTVDirectory(unittest.TestCase):\n    def setUp(self):\n        self.media_chain = MediaChain()\n        self.media_chain.storagechain = MagicMock()\n        self.media_chain._scrape_nfo_generic = MagicMock()\n        self.media_chain._scrape_images_generic = MagicMock()\n\n    @patch(\"app.chain.media.settings\")\n    def test_initialize_tv_directory_specials(self, mock_settings):\n        # mock specials directory recognition\n        mock_settings.RENAME_FORMAT_S0_NAMES = [\"Specials\", \"SPs\"]\n        mock_settings.RMT_MEDIAEXT = [\".mp4\", \".mkv\"]\n\n        fileitem = schemas.FileItem(path=\"/tv/Show/Specials\", name=\"Specials\", type=\"dir\", storage=\"local\")\n        meta = MetaInfo(\"Show\")\n        mediainfo = MediaInfo(type=MediaType.TV)\n        self.media_chain.storagechain.list_files.return_value = []\n\n        self.media_chain._handle_tv_scraping(fileitem, meta, mediainfo, init_folder=True, parent=None, overwrite=False, recursive=True)\n\n        self.media_chain._scrape_nfo_generic.assert_called_with(\n            current_fileitem=fileitem,\n            meta=meta,\n            mediainfo=mediainfo,\n            item_type=ScrapingTarget.SEASON,\n            overwrite=False,\n            season_number=0\n        )\n        self.media_chain._scrape_images_generic.assert_called_with(\n            current_fileitem=fileitem,\n            mediainfo=mediainfo,\n            item_type=ScrapingTarget.SEASON,\n            parent_fileitem=None,\n            overwrite=False,\n            season_number=0\n        )\n\n    def test_initialize_tv_directory_season(self):\n        fileitem = schemas.FileItem(path=\"/tv/Show/Season 1\", name=\"Season 1\", type=\"dir\", storage=\"local\")\n        meta = MetaInfo(\"Show\")\n        mediainfo = MediaInfo(type=MediaType.TV)\n        self.media_chain.storagechain.list_files.return_value = []\n\n        self.media_chain._handle_tv_scraping(fileitem, meta, mediainfo, init_folder=True, parent=None, overwrite=False, recursive=True)\n\n        self.media_chain._scrape_nfo_generic.assert_called_with(\n            current_fileitem=fileitem,\n            meta=meta,\n            mediainfo=mediainfo,\n            item_type=ScrapingTarget.SEASON,\n            overwrite=False,\n            season_number=1\n        )\n\n\nclass TestMediaScrapeEvents(unittest.TestCase):\n    def setUp(self):\n        self.media_chain = MediaChain()\n\n    @patch(\"app.chain.media.MediaChain.scrape_metadata\")\n    @patch(\"app.chain.media.StorageChain.get_item\")\n    @patch(\"app.chain.media.StorageChain.get_parent_item\")\n    def test_scrape_metadata_event_file(\n        self, mock_get_parent, mock_get_item, mock_scrape_metadata\n    ):\n        fileitem = schemas.FileItem(path=\"/movies/movie.mkv\", name=\"movie.mkv\", type=\"file\", storage=\"local\")\n        parent_item = schemas.FileItem(path=\"/movies\", name=\"movies\", type=\"dir\", storage=\"local\")\n\n        mock_get_item.return_value = fileitem\n        mock_get_parent.return_value = parent_item\n\n        mediainfo = MediaInfo()\n        event = Event(\n            event_type=EventType.MetadataScrape,\n            event_data={\n                \"fileitem\": fileitem,\n                \"mediainfo\": mediainfo,\n                \"overwrite\": True\n            }\n        )\n\n        self.media_chain.scrape_metadata_event(event)\n\n        mock_scrape_metadata.assert_called_once_with(\n            fileitem=fileitem,\n            mediainfo=mediainfo,\n            init_folder=False,\n            parent=parent_item,\n            overwrite=True\n        )\n\n    @patch(\"app.chain.media.MediaChain.scrape_metadata\")\n    @patch(\"app.chain.media.StorageChain.get_item\")\n    @patch(\"app.chain.media.StorageChain.is_bluray_folder\")\n    def test_scrape_metadata_event_dir_bluray(\n        self, mock_is_bluray, mock_get_item, mock_scrape_metadata\n    ):\n        fileitem = schemas.FileItem(path=\"/movies/bluray_movie\", name=\"bluray_movie\", type=\"dir\", storage=\"local\")\n\n        mock_get_item.return_value = fileitem\n        mock_is_bluray.return_value = True\n\n        mediainfo = MediaInfo()\n        event = Event(\n            event_type=EventType.MetadataScrape,\n            event_data={\n                \"fileitem\": fileitem,\n                \"file_list\": [\"/movies/bluray_movie/BDMV/index.bdmv\"],\n                \"mediainfo\": mediainfo,\n                \"overwrite\": False\n            }\n        )\n\n        self.media_chain.scrape_metadata_event(event)\n\n        mock_scrape_metadata.assert_called_once_with(\n            fileitem=fileitem,\n            mediainfo=mediainfo,\n            init_folder=True,\n            recursive=False,\n            overwrite=False\n        )\n\n    @patch(\"app.chain.media.MediaChain.scrape_metadata\")\n    @patch(\"app.chain.media.StorageChain.get_item\")\n    @patch(\"app.chain.media.StorageChain.is_bluray_folder\")\n    @patch(\"app.chain.media.StorageChain.get_file_item\")\n    def test_scrape_metadata_event_dir_with_filelist(\n        self, mock_get_file_item, mock_is_bluray, mock_get_item, mock_scrape_metadata\n    ):\n        fileitem = schemas.FileItem(path=\"/tv/show\", name=\"show\", type=\"dir\", storage=\"local\")\n\n        mock_get_item.return_value = fileitem\n        mock_is_bluray.return_value = False\n\n        def side_effect_get_file_item(storage, path):\n            path_str = str(path)\n            return schemas.FileItem(path=path_str, name=Path(path_str).name, type=\"dir\" if \".\" not in path_str else \"file\", storage=\"local\")\n\n        mock_get_file_item.side_effect = side_effect_get_file_item\n\n        mediainfo = MediaInfo()\n        event = Event(\n            event_type=EventType.MetadataScrape,\n            event_data={\n                \"fileitem\": fileitem,\n                \"file_list\": [\"/tv/show/Season 1/S01E01.mp4\"],\n                \"mediainfo\": mediainfo,\n                \"overwrite\": True\n            }\n        )\n\n        self.media_chain.scrape_metadata_event(event)\n\n        calls = mock_scrape_metadata.call_args_list\n        self.assertEqual(len(calls), 3)\n\n        paths = [call.kwargs['fileitem'].path for call in calls]\n        self.assertIn(\"/tv/show\", paths)\n        self.assertIn(\"/tv/show/Season 1\", paths)\n        self.assertIn(\"/tv/show/Season 1/S01E01.mp4\", paths)\n\n    @patch(\"app.chain.media.MediaChain.scrape_metadata\")\n    @patch(\"app.chain.media.StorageChain.get_item\")\n    def test_scrape_metadata_event_dir_full(\n        self, mock_get_item, mock_scrape_metadata\n    ):\n        fileitem = schemas.FileItem(path=\"/movies/movie\", name=\"movie\", type=\"dir\", storage=\"local\")\n\n        mock_get_item.return_value = fileitem\n\n        mediainfo = MediaInfo()\n        meta = MetaInfo(\"movie\")\n        event = Event(\n            event_type=EventType.MetadataScrape,\n            event_data={\n                \"fileitem\": fileitem,\n                \"meta\": meta,\n                \"mediainfo\": mediainfo,\n                \"overwrite\": True\n            }\n        )\n\n        self.media_chain.scrape_metadata_event(event)\n\n        mock_scrape_metadata.assert_called_once_with(\n            fileitem=fileitem,\n            meta=meta,\n            mediainfo=mediainfo,\n            init_folder=True,\n            overwrite=True\n        )\n\n    @patch(\"app.chain.media.MediaChain._handle_movie_scraping\")\n    @patch(\"app.chain.media.MediaChain.recognize_by_meta\")\n    def test_scrape_metadata_movie(\n        self, mock_recognize, mock_handle_movie\n    ):\n        fileitem = schemas.FileItem(path=\"/movies/movie.mkv\", name=\"movie.mkv\", type=\"file\", storage=\"local\")\n        meta = MetaInfo(\"Movie\")\n        mediainfo = MediaInfo(type=MediaType.MOVIE)\n\n        self.media_chain.scrape_metadata(\n            fileitem=fileitem,\n            meta=meta,\n            mediainfo=mediainfo,\n            init_folder=True,\n            overwrite=False,\n            recursive=True\n        )\n\n        mock_recognize.assert_not_called()\n        mock_handle_movie.assert_called_once_with(\n            fileitem=fileitem,\n            meta=meta,\n            mediainfo=mediainfo,\n            init_folder=True,\n            parent=None,\n            overwrite=False,\n            recursive=True\n        )\n\n    @patch(\"app.chain.media.MediaChain._handle_tv_scraping\")\n    @patch(\"app.chain.media.MediaChain.recognize_by_meta\")\n    def test_scrape_metadata_tv(\n        self, mock_recognize, mock_handle_tv\n    ):\n        fileitem = schemas.FileItem(path=\"/tv/show\", name=\"show\", type=\"dir\", storage=\"local\")\n        meta = MetaInfo(\"Show\")\n        mediainfo = MediaInfo(type=MediaType.TV)\n\n        self.media_chain.scrape_metadata(\n            fileitem=fileitem,\n            meta=meta,\n            mediainfo=mediainfo,\n            init_folder=True,\n            overwrite=False,\n            recursive=True\n        )\n\n        mock_handle_tv.assert_called_once_with(\n            fileitem=fileitem,\n            meta=meta,\n            mediainfo=mediainfo,\n            init_folder=True,\n            parent=None,\n            overwrite=False,\n            recursive=True\n        )\n\n    @patch(\"app.chain.media.MediaChain._handle_movie_scraping\")\n    @patch(\"app.chain.media.MediaChain.recognize_by_meta\")\n    def test_scrape_metadata_recognize_fallback(\n        self, mock_recognize, mock_handle_movie\n    ):\n        fileitem = schemas.FileItem(path=\"/movies/movie.mkv\", name=\"movie.mkv\", type=\"file\", storage=\"local\")\n        mediainfo = MediaInfo(type=MediaType.MOVIE)\n        mock_recognize.return_value = mediainfo\n\n        self.media_chain.scrape_metadata(\n            fileitem=fileitem,\n            init_folder=True,\n            overwrite=False,\n            recursive=True\n        )\n\n        mock_recognize.assert_called_once()\n        mock_handle_movie.assert_called_once()\n        args, kwargs = mock_handle_movie.call_args\n        self.assertEqual(kwargs['mediainfo'], mediainfo)\n        self.assertEqual(kwargs['meta'].name, \"Movie\")\n\n    @patch(\"app.chain.media.MediaChain._handle_movie_scraping\")\n    @patch(\"app.chain.media.MediaChain._handle_tv_scraping\")\n    def test_scrape_metadata_invalid_extension(\n        self, mock_handle_tv, mock_handle_movie\n    ):\n        fileitem = schemas.FileItem(path=\"/movies/movie.txt\", name=\"movie.txt\", type=\"file\", storage=\"local\")\n\n        self.media_chain.scrape_metadata(\n            fileitem=fileitem\n        )\n\n        mock_handle_movie.assert_not_called()\n        mock_handle_tv.assert_not_called()\n\n    @patch(\"app.chain.media.MediaChain.scrape_metadata\")\n    @patch(\"app.chain.media.StorageChain.get_item\")\n    @patch(\"app.chain.media.StorageChain.is_bluray_folder\")\n    @patch(\"app.chain.media.StorageChain.get_file_item\")\n    def test_scrape_metadata_event_dir_with_multiple_files(\n        self, mock_get_file_item, mock_is_bluray, mock_get_item, mock_scrape_metadata\n    ):\n        fileitem = schemas.FileItem(path=\"/movies/collection\", name=\"collection\", type=\"dir\", storage=\"local\")\n\n        mock_get_item.return_value = fileitem\n        mock_is_bluray.return_value = False\n\n        def side_effect_get_file_item(storage, path):\n            path_str = str(path)\n            return schemas.FileItem(path=path_str, name=Path(path_str).name, type=\"dir\" if \".\" not in path_str else \"file\", storage=\"local\")\n\n        mock_get_file_item.side_effect = side_effect_get_file_item\n\n        mediainfo = MediaInfo()\n        event = Event(\n            event_type=EventType.MetadataScrape,\n            event_data={\n                \"fileitem\": fileitem,\n                \"file_list\": [\n                    \"/movies/collection/movie1.mp4\",\n                    \"/movies/collection/movie2.mkv\",\n                    \"/movies/collection/movie3.avi\"\n                ],\n                \"mediainfo\": mediainfo,\n                \"overwrite\": True\n            }\n        )\n\n        self.media_chain.scrape_metadata_event(event)\n\n        calls = mock_scrape_metadata.call_args_list\n        # Should scrape directory and then each file item\n        self.assertEqual(len(calls), 4)\n\n        paths = [call.kwargs['fileitem'].path for call in calls]\n        self.assertIn(\"/movies/collection\", paths)\n        self.assertIn(\"/movies/collection/movie1.mp4\", paths)\n        self.assertIn(\"/movies/collection/movie2.mkv\", paths)\n        self.assertIn(\"/movies/collection/movie3.avi\", paths)\n\n    @patch(\"app.chain.media.MediaChain.scrape_metadata\")\n    @patch(\"app.chain.media.StorageChain.get_item\")\n    @patch(\"app.chain.media.StorageChain.is_bluray_folder\")\n    @patch(\"app.chain.media.StorageChain.get_file_item\")\n    def test_scrape_metadata_event_dir_with_tv_multi_seasons_episodes(\n        self, mock_get_file_item, mock_is_bluray, mock_get_item, mock_scrape_metadata\n    ):\n        fileitem = schemas.FileItem(path=\"/tv/MultiSeasonShow\", name=\"MultiSeasonShow\", type=\"dir\", storage=\"local\")\n\n        mock_get_item.return_value = fileitem\n        mock_is_bluray.return_value = False\n\n        def side_effect_get_file_item(storage, path):\n            path_str = str(path)\n            return schemas.FileItem(path=path_str, name=Path(path_str).name, type=\"dir\" if \".\" not in path_str else \"file\", storage=\"local\")\n\n        mock_get_file_item.side_effect = side_effect_get_file_item\n\n        mediainfo = MediaInfo()\n        event = Event(\n            event_type=EventType.MetadataScrape,\n            event_data={\n                \"fileitem\": fileitem,\n                \"file_list\": [\n                    \"/tv/MultiSeasonShow/Season 1/S01E01.mp4\",\n                    \"/tv/MultiSeasonShow/Season 1/S01E02.mp4\",\n                    \"/tv/MultiSeasonShow/Season 2/S02E01.mkv\",\n                    \"/tv/MultiSeasonShow/Season 2/S02E02.mkv\",\n                    \"/tv/MultiSeasonShow/Specials/S00E01.mp4\"\n                ],\n                \"mediainfo\": mediainfo,\n                \"overwrite\": False\n            }\n        )\n\n        self.media_chain.scrape_metadata_event(event)\n\n        calls = mock_scrape_metadata.call_args_list\n        # main dir + 3 season dirs + 5 episode files\n        self.assertEqual(len(calls), 9)\n\n        paths = [call.kwargs['fileitem'].path for call in calls]\n        self.assertIn(\"/tv/MultiSeasonShow\", paths)\n        self.assertIn(\"/tv/MultiSeasonShow/Season 1\", paths)\n        self.assertIn(\"/tv/MultiSeasonShow/Season 2\", paths)\n        self.assertIn(\"/tv/MultiSeasonShow/Specials\", paths)\n        self.assertIn(\"/tv/MultiSeasonShow/Season 1/S01E01.mp4\", paths)\n        self.assertIn(\"/tv/MultiSeasonShow/Season 1/S01E02.mp4\", paths)\n        self.assertIn(\"/tv/MultiSeasonShow/Season 2/S02E01.mkv\", paths)\n        self.assertIn(\"/tv/MultiSeasonShow/Season 2/S02E02.mkv\", paths)\n        self.assertIn(\"/tv/MultiSeasonShow/Specials/S00E01.mp4\", paths)\n\n    @patch(\"app.chain.media.MediaChain.recognize_by_meta\")\n    def test_scrape_metadata_recognize_fail(\n        self, mock_recognize\n    ):\n        fileitem = schemas.FileItem(path=\"/movies/movie.mkv\", name=\"movie.mkv\", type=\"file\", storage=\"local\")\n        mock_recognize.return_value = None\n\n        with patch('app.chain.media.logger.warn') as mock_logger:\n            self.media_chain.scrape_metadata(\n                fileitem=fileitem\n            )\n            mock_logger.assert_called_with(f\"{Path(fileitem.path)} 无法识别文件媒体信息！\")\n\nif __name__ == \"__main__\":\n    unittest.main()\n"
  },
  {
    "path": "tests/test_metainfo.py",
    "content": "# -*- coding: utf-8 -*-\nfrom pathlib import Path\nfrom unittest import TestCase\n\nfrom app.core.metainfo import MetaInfo, MetaInfoPath\nfrom tests.cases.meta import meta_cases\n\n\nclass MetaInfoTest(TestCase):\n    def setUp(self) -> None:\n        pass\n\n    def tearDown(self) -> None:\n        pass\n\n    def test_metainfo(self):\n        for info in meta_cases:\n            if info.get(\"path\"):\n                meta_info = MetaInfoPath(path=Path(info.get(\"path\")))\n            else:\n                meta_info = MetaInfo(title=info.get(\"title\"), subtitle=info.get(\"subtitle\"), custom_words=[\"#\"])\n            target = {\n                \"type\": meta_info.type.value,\n                \"cn_name\": meta_info.cn_name or \"\",\n                \"en_name\": meta_info.en_name or \"\",\n                \"year\": meta_info.year or \"\",\n                \"part\": meta_info.part or \"\",\n                \"season\": meta_info.season,\n                \"episode\": meta_info.episode,\n                \"restype\": meta_info.edition,\n                \"pix\": meta_info.resource_pix or \"\",\n                \"video_codec\": meta_info.video_encode or \"\",\n                \"audio_codec\": meta_info.audio_encode or \"\",\n                \"fps\": meta_info.fps or None\n            }\n\n            # 检查tmdbid\n            if info.get(\"target\").get(\"tmdbid\"):\n                target[\"tmdbid\"] = meta_info.tmdbid\n\n            self.assertEqual(target, info.get(\"target\"))\n\n    def test_emby_format_ids(self):\n        \"\"\"\n        测试Emby格式ID识别\n        \"\"\"\n        # 测试文件路径\n        test_paths = [\n            # 文件名中包含tmdbid\n            (\"/movies/The Vampire Diaries (2009) [tmdbid=18165]/The.Vampire.Diaries.S01E01.1080p.mkv\", 18165),\n            # 目录名中包含tmdbid\n            (\"/movies/Inception (2010) [tmdbid-27205]/Inception.2010.1080p.mkv\", 27205),\n            # 父目录名中包含tmdbid\n            (\"/movies/Breaking Bad (2008) [tmdb=1396]/Season 1/Breaking.Bad.S01E01.1080p.mkv\", 1396),\n            # 祖父目录名中包含tmdbid\n            (\"/tv/Game of Thrones (2011) {tmdb=1399}/Season 1/Game.of.Thrones.S01E01.1080p.mkv\", 1399),\n            # 测试{tmdb-xxx}格式\n            (\"/movies/Avatar (2009) {tmdb-19995}/Avatar.2009.1080p.mkv\", 19995),\n        ]\n\n        for path_str, expected_tmdbid in test_paths:\n            meta = MetaInfoPath(Path(path_str))\n            self.assertEqual(meta.tmdbid, expected_tmdbid,\n                             f\"路径 {path_str} 期望的tmdbid为 {expected_tmdbid}，实际识别为 {meta.tmdbid}\")\n\n    def test_metainfopath_with_custom_words(self):\n        \"\"\"测试 MetaInfoPath 使用自定义识别词\"\"\"\n        # 测试替换词：将\"测试替换\"替换为空\n        custom_words = [\"测试替换 => \"]\n        path = Path(\"/movies/电影测试替换名称 (2024)/movie.mkv\")\n        meta = MetaInfoPath(path, custom_words=custom_words)\n        # 验证替换生效：cn_name 不应包含\"测试替换\"\n        if meta.cn_name:\n            self.assertNotIn(\"测试替换\", meta.cn_name)\n\n    def test_metainfopath_without_custom_words(self):\n        \"\"\"测试 MetaInfoPath 不传入自定义识别词\"\"\"\n        path = Path(\"/movies/Normal Movie (2024)/movie.mkv\")\n        meta = MetaInfoPath(path)\n        # 验证正常识别，不报错\n        self.assertIsNotNone(meta)\n\n    def test_metainfopath_with_empty_custom_words(self):\n        \"\"\"测试 MetaInfoPath 传入空的自定义识别词\"\"\"\n        path = Path(\"/movies/Test Movie (2024)/movie.mkv\")\n        meta = MetaInfoPath(path, custom_words=[])\n        # 验证不报错，正常识别\n        self.assertIsNotNone(meta)\n\n    def test_custom_words_apply_words_recording(self):\n        \"\"\"测试 apply_words 记录功能\"\"\"\n        custom_words = [\"替换词 => 新词\"]\n        title = \"电影替换词.2024.mkv\"\n        meta = MetaInfo(title=title, custom_words=custom_words)\n        # 验证 apply_words 属性存在\n        self.assertTrue(hasattr(meta, 'apply_words'))\n        # 如果替换词被应用，应该记录在 apply_words 中\n        if meta.apply_words:\n            self.assertIn(\"替换词 => 新词\", meta.apply_words)\n"
  },
  {
    "path": "tests/test_object.py",
    "content": "from unittest import TestCase\n\nfrom app.utils.object import ObjectUtils\n\n\nclass ObjectUtilsTest(TestCase):\n\n    def test_check_method(self):\n        def implemented_function():\n            return \"Hello\"\n\n        def pass_function():\n            pass\n\n        def docstring_function():\n            \"\"\"This is a docstring.\"\"\"\n\n        def ellipsis_function():\n            ...\n\n        def not_implemented_function():\n            raise NotImplementedError\n\n        def not_implemented_function_with_call():\n            raise NotImplementedError()\n\n        async def multiple_lines_async_def(_param1: str,\n                                           _param2: str):\n            pass\n\n        def empty_function():\n            return\n\n        self.assertTrue(ObjectUtils.check_method(implemented_function))\n        self.assertFalse(ObjectUtils.check_method(pass_function))\n        self.assertFalse(ObjectUtils.check_method(docstring_function))\n        self.assertFalse(ObjectUtils.check_method(ellipsis_function))\n        self.assertFalse(ObjectUtils.check_method(not_implemented_function))\n        self.assertFalse(ObjectUtils.check_method(not_implemented_function_with_call))\n        self.assertFalse(ObjectUtils.check_method(multiple_lines_async_def))\n        self.assertTrue(ObjectUtils.check_method(empty_function))\n"
  },
  {
    "path": "tests/test_release_group.py",
    "content": "from unittest import TestCase\nfrom tests.cases.groups import release_group_cases\nfrom app.core.meta.releasegroup import ReleaseGroupsMatcher\n\n\nclass MetaInfoTest(TestCase):\n    def test_release_group(self):\n        for info in release_group_cases:\n            print(f\"开始测试 {info.get('domain')}\")\n            for item in info.get('groups', []):\n                release_group = ReleaseGroupsMatcher().match(item.get(\"title\"))\n                print(f\"\\tmatch release group {release_group}, should be: {item.get('group')}\")\n                self.assertEqual(item.get(\"group\"), release_group)\n            print(f\"完成 {info.get('domain')}\")\n"
  },
  {
    "path": "tests/test_string.py",
    "content": "from unittest import TestCase\n\nfrom app.utils.string import StringUtils\n\n\nclass StringUtilsTest(TestCase):\n\n    def test_is_media_title_like_true(self):\n        self.assertTrue(StringUtils.is_media_title_like(\"盗梦空间\"))\n        self.assertTrue(StringUtils.is_media_title_like(\"The Lord of the Rings\"))\n        self.assertTrue(StringUtils.is_media_title_like(\"庆余年 第2季\"))\n        self.assertTrue(StringUtils.is_media_title_like(\"The Office S01E01\"))\n        self.assertTrue(StringUtils.is_media_title_like(\"权力的游戏 Game of Thrones\"))\n        self.assertTrue(StringUtils.is_media_title_like(\"Spider-Man: No Way Home 2021\"))\n\n    def test_is_media_title_like_false(self):\n        self.assertFalse(StringUtils.is_media_title_like(\"\"))\n        self.assertFalse(StringUtils.is_media_title_like(\"   \"))\n        self.assertFalse(StringUtils.is_media_title_like(\"a\"))\n        self.assertFalse(StringUtils.is_media_title_like(\"第2季\"))\n        self.assertFalse(StringUtils.is_media_title_like(\"S01E01\"))\n        self.assertFalse(StringUtils.is_media_title_like(\"#推荐电影\"))\n        self.assertFalse(StringUtils.is_media_title_like(\"请帮我推荐一部电影\"))\n        self.assertFalse(StringUtils.is_media_title_like(\"盗梦空间怎么样？\"))\n        self.assertFalse(StringUtils.is_media_title_like(\"我想看盗梦空间\"))\n        self.assertFalse(StringUtils.is_media_title_like(\"继续\"))\n"
  },
  {
    "path": "tests/test_telegram.py",
    "content": "# -*- coding: utf-8 -*-\n\"\"\"\nTelegram模块单元测试\n\"\"\"\nimport unittest\n\nfrom app.core.context import MediaInfo, Context, TorrentInfo\nfrom app.core.metainfo import MetaInfo\nfrom app.modules.telegram.telegram import Telegram\nfrom app.schemas.types import MediaType\n\n\nclass TestTelegram(unittest.TestCase):\n\n    def setUp(self):\n        \"\"\"测试前准备\"\"\"\n        # 创建Telegram实例，使用虚假的token和chat_id防止真实发送\n        self.telegram = Telegram(TELEGRAM_TOKEN='', TELEGRAM_CHAT_ID='')\n\n    def tearDown(self):\n        \"\"\"测试后清理\"\"\"\n        pass\n\n    def test_send_msg_success(self):\n        \"\"\"测试发送普通消息成功\"\"\"\n        # 调用send_msg方法\n        result = self.telegram.send_msg(\n            title=\"📥 开始下载\\n唐朝诡事录 (2022)S03E31-E32\",\n            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]\"\n        )\n\n        # 验证返回值\n        self.assertTrue(result is True)\n\n    def test_send_msg_with_longtext(self):\n        \"\"\"测试发送长消息\"\"\"\n        result = self.telegram.send_msg(\n            title=\"MoviePilot助手\",\n            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您对哪部剧比较感兴趣，或者想了解更多信息呢？\",\n        )\n\n\n    def test_send_medias_msg_success(self):\n        \"\"\"测试发送媒体列表消息成功\"\"\"\n        # 创建模拟的媒体信息列表\n        media1 = MediaInfo()\n        media1.type = MediaType.MOVIE\n        media1.title = \"测试电影1\"\n        media1.year = \"2023\"\n        media1.vote_average = 8.5\n        media1.poster_path = \"https://raw.githubusercontent.com/jxxghp/MoviePilot-Frontend/refs/heads/v2/public/logo.png\"\n        media1.tmdb_id=123123\n\n        media2 = MediaInfo()\n        media2.type = MediaType.TV\n        media2.title = \"测试电视剧1\"\n        media2.year = \"2023\"\n        media2.vote_average = 9.0\n        media2.poster_path = \"https://raw.githubusercontent.com/jxxghp/MoviePilot-Frontend/refs/heads/v2/public/logo.png\"\n\n        medias = [media1, media2]\n\n        result = self.telegram.send_medias_msg(\n            medias=medias,\n            title=\"推荐媒体列表\"\n        )\n\n        self.assertTrue(result is True)\n\n    def test_send_medias_msg_without_vote_average(self):\n        \"\"\"测试发送无评分的媒体列表消息\"\"\"\n        # 创建模拟的媒体信息列表（无评分）\n        media1 = MediaInfo()\n        media1.type = MediaType.MOVIE\n        media1.title = \"测试电影1\"\n        media1.year = \"2023\"\n        media1.poster_path = \"https://raw.githubusercontent.com/jxxghp/MoviePilot-Frontend/refs/heads/v2/public/logo.png\"\n        media1.tmdb_id=123123\n        medias = [media1]\n\n        result = self.telegram.send_medias_msg(\n            medias=medias,\n            title=\"推荐媒体列表\"\n        )\n\n        self.assertTrue(result is True)\n\n    def test_send_medias_msg_with_link_and_buttons(self):\n        \"\"\"测试发送带链接和按钮的媒体列表消息\"\"\"\n        media1 = MediaInfo()\n        media1.type = MediaType.MOVIE\n        media1.title = \"测试*-|\\.电影1\"\n        media1.year = \"2023\"\n        media1.vote_average = 8.5\n        media1.poster_path = \"https://raw.githubusercontent.com/jxxghp/MoviePilot-Frontend/refs/heads/v2/public/logo.png\"\n        media1.tmdb_id=123123\n\n        medias = [media1]\n\n        buttons = [[\n            {\"text\": \"测试按钮\", \"callback_data\": \"test_callback\"}\n        ]]\n\n        result = self.telegram.send_medias_msg(\n            medias=medias,\n            title=\"推荐媒体列表\",\n            link=\"http://example.com\",\n            buttons=buttons\n        )\n\n        self.assertTrue(result is True)\n\n\n\n    def test_send_torrents_msg_success(self):\n        \"\"\"测试发送种子列表消息成功\"\"\"\n        # 创建模拟的种子信息\n        media_info = MediaInfo()\n        media_info.type = MediaType.TV\n        media_info.title = \"唐朝诡事录\"\n        media_info.year = \"2025\"\n        media_info.poster_path = \"https://raw.githubusercontent.com/jxxghp/MoviePilot-Frontend/refs/heads/v2/public/logo.png\"\n\n        torrent_info = TorrentInfo()\n        torrent_info.site_name = \"测试*-|\\.站点\"\n        torrent_info.title = \"唐朝诡事录\"\n        torrent_info.description = \"唐朝诡事录之长安3 / 唐朝诡事录3 / 唐朝诡事录 第三部 / 唐朝诡事录·长安 / 唐诡3 / Horror Stories of Tang Dynasty Ⅲ / Strange Legend of Tang Dynasty Ⅲ 第3季 第31-32集 | 主演: 杨旭文 杨志刚 郜思雯 [内封简繁英多国软字幕] 【去头尾广告纯享版】[非伪去头] *发现未去净的广告或片头片尾，奖励魔力1W\"\n        torrent_info.page_url = \"http://example.com/torrent\"\n        torrent_info.size = 1024 * 1024 * 1024  # 1GB\n        torrent_info.seeders = 10\n        torrent_info.uploadvolumefactor = 1.0\n        torrent_info.downloadvolumefactor = 0.0\n\n        meta_info = MetaInfo(title=\"唐朝诡事录\")\n\n        context = Context()\n        context.media_info = media_info\n        context.torrent_info = torrent_info\n        context.meta_info = meta_info\n\n        torrents = [context]\n\n        result = self.telegram.send_torrents_msg(\n            torrents=torrents,\n            title=\"种子列表\"\n        )\n\n        self.assertTrue(result is True)\n\n    def test_send_torrents_msg_with_link_and_buttons(self):\n        \"\"\"测试发送带链接和按钮的种子列表消息\"\"\"\n        media_info = MediaInfo()\n        media_info.type = MediaType.MOVIE\n        media_info.title = \"^测试电影~_测试_\"\n        media_info.year = \"2023\"\n        media_info.poster_path = \"https://raw.githubusercontent.com/jxxghp/MoviePilot-Frontend/refs/heads/v2/public/logo.png\"\n\n        torrent_info = TorrentInfo()\n        torrent_info.site_name = \"^测试~站点_测试_\"\n        torrent_info.title = \"测试种子标题\"\n        torrent_info.description = \"测试种子描述\"\n        torrent_info.page_url = \"http://example.com/torrent\"\n        torrent_info.size = 1024 * 1024 * 1024  # 1GB\n        torrent_info.seeders = 10\n        torrent_info.uploadvolumefactor = 1.0\n        torrent_info.downloadvolumefactor = 0.0\n\n        meta_info = MetaInfo(title=\"测试种子标题\")\n\n        context = Context()\n        context.media_info = media_info\n        context.torrent_info = torrent_info\n        context.meta_info = meta_info\n\n        torrents = [context]\n\n        buttons = [[\n            {\"text\": \"测试按钮\", \"callback_data\": \"test_callback\"}\n        ]]\n\n        result = self.telegram.send_torrents_msg(\n            torrents=torrents,\n            title=\"种子列表\",\n            link=\"http://example.com\",\n            buttons=buttons\n        )\n\n        self.assertTrue(result is True)\n\n    def test_send_msg_with_buttons_and_link(self):\n        \"\"\"测试发送带按钮和链接的消息\"\"\"\n        buttons = [[\n            {\"text\": \"测试按钮\", \"callback_data\": \"test_callback\"}\n        ]]\n\n        result = self.telegram.send_msg(\n            title=\"测试标题\",\n            text=\"*测试内容*\",\n            link=\"http://example.com\",\n            buttons=buttons\n        )\n\n        # 验证返回值\n        self.assertTrue(result is True)\n\n    def test_send_msg_with_url_buttons(self):\n        \"\"\"测试发送带URL按钮的消息\"\"\"\n        buttons = [[\n            {\"text\": \"URL按钮\", \"url\": \"http://example.com\"}\n        ]]\n\n        result = self.telegram.send_msg(\n            title=\"测试标题\",\n            text=\"测试内容\",\n            buttons=buttons\n        )\n\n        # 验证返回值\n        self.assertTrue(result is True)\n\n\n    def test_send_msg_markdown_escaping(self):\n        \"\"\"测试Markdown特殊字符转义\"\"\"\n        result = self.telegram.send_msg(\n            title=\"测试标题\",\n            text=\"_测试_||内容||\"\n        )\n\n        # 验证返回值\n        self.assertTrue(result is True)\n\nif __name__ == '__main__':\n    unittest.main()"
  },
  {
    "path": "tests/test_transfer_history_retransfer.py",
    "content": "from types import ModuleType, SimpleNamespace\nimport sys\n\n# The endpoint import pulls in a wide plugin/helper graph. Some optional modules are\n# not present in this test environment, so stub them before importing the endpoint.\nsys.modules.setdefault(\"app.helper.sites\", ModuleType(\"app.helper.sites\"))\nsetattr(sys.modules[\"app.helper.sites\"], \"SitesHelper\", object)\n\nfrom app.api.endpoints.transfer import manual_transfer\nfrom app.schemas import ManualTransferItem\n\n\ndef test_manual_transfer_from_history_preserves_download_context(monkeypatch):\n    history = SimpleNamespace(\n        status=0,\n        mode=\"copy\",\n        src_fileitem={\"storage\": \"local\", \"path\": \"/downloads/test.mkv\", \"name\": \"test.mkv\", \"type\": \"file\"},\n        dest_fileitem=None,\n        downloader=\"qbittorrent\",\n        download_hash=\"abc123\",\n        type=\"电视剧\",\n        tmdbid=\"100\",\n        doubanid=\"200\",\n        seasons=\"S01\",\n        episodes=\"E01-E02\",\n        episode_group=\"WEB-DL\",\n    )\n\n    captured = {}\n\n    def fake_get(_db, logid):\n        assert logid == 1\n        return history\n\n    class FakeTransferChain:\n        def manual_transfer(self, **kwargs):\n            captured.update(kwargs)\n            return True, \"\"\n\n    monkeypatch.setattr(\"app.api.endpoints.transfer.TransferHistory.get\", fake_get)\n    monkeypatch.setattr(\"app.api.endpoints.transfer.TransferChain\", FakeTransferChain)\n\n    resp = manual_transfer(\n        transer_item=ManualTransferItem(logid=1, from_history=True),\n        background=True,\n        db=object(),\n        _=\"token\",\n    )\n\n    assert resp.success is True\n    assert captured[\"downloader\"] == \"qbittorrent\"\n    assert captured[\"download_hash\"] == \"abc123\"\n    assert captured[\"episode_group\"] == \"WEB-DL\"\n    assert captured[\"season\"] == 1\n"
  },
  {
    "path": "tests/test_ugreen_api.py",
    "content": "import unittest\nfrom types import SimpleNamespace\nfrom unittest.mock import patch\n\nfrom app.modules.ugreen.api import Api\n\n\nclass _FakeResponse:\n    def __init__(self, payload: dict, headers: dict | None = None):\n        self._payload = payload\n        self.headers = headers or {}\n\n    def json(self):\n        return self._payload\n\n\nclass _FakeSession:\n    def __init__(self, get_responses=None, post_responses=None):\n        self._get_responses = list(get_responses or [])\n        self._post_responses = list(post_responses or [])\n        self.calls: list[tuple[str, dict]] = []\n        self.cookies = SimpleNamespace(\n            get_dict=lambda: {},\n            update=lambda *_args, **_kwargs: None,\n        )\n\n    def get(self, *args, **kwargs):\n        if args:\n            kwargs = {\"url\": args[0], **kwargs}\n        self.calls.append((\"GET\", kwargs))\n        return self._get_responses.pop(0) if self._get_responses else _FakeResponse({})\n\n    def post(self, *args, **kwargs):\n        if args:\n            kwargs = {\"url\": args[0], **kwargs}\n        self.calls.append((\"POST\", kwargs))\n        return self._post_responses.pop(0) if self._post_responses else _FakeResponse({})\n\n    @staticmethod\n    def close():\n        return None\n\n\nclass _FakeCrypto:\n    def __init__(self, *args, **kwargs):\n        pass\n\n    @staticmethod\n    def rsa_encrypt_long(raw: str) -> str:\n        return f\"enc:{raw}\"\n\n    @staticmethod\n    def build_encrypted_request(url: str, method: str = \"GET\", params=None, **kwargs):\n        return SimpleNamespace(url=url, headers={}, params=params or {}, json=None, aes_key=\"k\")\n\n    @staticmethod\n    def decrypt_response(payload, aes_key):\n        return payload\n\n\nclass UgreenApiVerifySslTest(unittest.TestCase):\n    def test_request_json_default_verify_ssl_true(self):\n        api = Api(host=\"https://example.com\")\n        fake_session = _FakeSession(\n            get_responses=[_FakeResponse({\"code\": 200})],\n            post_responses=[_FakeResponse({\"code\": 200})],\n        )\n        api._session = fake_session\n\n        api._request_json(url=\"https://example.com/a\", method=\"GET\")\n        api._request_json(url=\"https://example.com/b\", method=\"POST\", json_data={\"x\": 1})\n\n        self.assertEqual(fake_session.calls[0][1].get(\"verify\"), True)\n        self.assertEqual(fake_session.calls[1][1].get(\"verify\"), True)\n\n    def test_login_logout_follow_verify_ssl_flag(self):\n        api = Api(host=\"https://example.com\", verify_ssl=False)\n        fake_session = _FakeSession(\n            get_responses=[_FakeResponse({})],\n            post_responses=[\n                _FakeResponse({\"code\": 200, \"msg\": \"ok\", \"data\": {}}, headers={\"x-rsa-token\": \"BEGIN TEST\"}),\n                _FakeResponse(\n                    {\n                        \"code\": 200,\n                        \"msg\": \"ok\",\n                        \"data\": {\n                            \"token\": \"token-value\",\n                            \"public_key\": \"BEGIN LOGIN KEY\",\n                            \"static_token\": \"static-token\",\n                            \"is_ugk\": False,\n                        },\n                    }\n                ),\n            ],\n        )\n        api._session = fake_session\n\n        with patch(\"app.modules.ugreen.api.UgreenCrypto\", _FakeCrypto):\n            token = api.login(\"tester\", \"pwd\")\n            self.assertEqual(token, \"token-value\")\n            api.logout()\n\n        self.assertEqual(len(fake_session.calls), 3)\n        self.assertEqual(fake_session.calls[0][0], \"POST\")\n        self.assertEqual(fake_session.calls[1][0], \"POST\")\n        self.assertEqual(fake_session.calls[2][0], \"GET\")\n        self.assertEqual(fake_session.calls[0][1].get(\"verify\"), False)\n        self.assertEqual(fake_session.calls[1][1].get(\"verify\"), False)\n        self.assertEqual(fake_session.calls[2][1].get(\"verify\"), False)\n\n\nif __name__ == \"__main__\":\n    unittest.main()\n"
  },
  {
    "path": "tests/test_ugreen_crypto.py",
    "content": "import base64\nimport hashlib\nimport json\nimport unittest\n\nfrom cryptography.hazmat.primitives import serialization\nfrom cryptography.hazmat.primitives.asymmetric import padding, rsa\n\nfrom app.utils.ugreen_crypto import UgreenCrypto\n\n\ndef _generate_rsa_keys() -> tuple[str, rsa.RSAPrivateKey]:\n    private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)\n    public_pem = private_key.public_key().public_bytes(\n        encoding=serialization.Encoding.PEM,\n        format=serialization.PublicFormat.PKCS1,\n    ).decode(\"utf-8\")\n    return public_pem, private_key\n\n\ndef _rsa_decrypt_long(private_key: rsa.RSAPrivateKey, payload_b64: str) -> str:\n    encrypted = base64.b64decode(payload_b64)\n    chunk_size = private_key.key_size // 8\n    plain_chunks = []\n    for start in range(0, len(encrypted), chunk_size):\n        chunk = encrypted[start : start + chunk_size]\n        plain_chunks.append(private_key.decrypt(chunk, padding.PKCS1v15()))\n    return b\"\".join(plain_chunks).decode(\"utf-8\")\n\n\nclass UgreenCryptoTest(unittest.TestCase):\n    def setUp(self):\n        self.public_key, self.private_key = _generate_rsa_keys()\n        self.token = \"demo-token-for-test\"\n        self.crypto = UgreenCrypto(\n            public_key=self.public_key,\n            token=self.token,\n            client_id=\"test-client-id\",\n        )\n\n    def test_rsa_encrypt_long(self):\n        plain = \"A\" * 400\n        encrypted = self.crypto.rsa_encrypt_long(plain)\n        self.assertEqual(plain, _rsa_decrypt_long(self.private_key, encrypted))\n\n    def test_build_encrypted_request_and_decrypt_response(self):\n        req = self.crypto.build_encrypted_request(\n            url=\"http://127.0.0.1:9999/ugreen/v1/video/homepage/media_list\",\n            params={\"page\": 1, \"page_size\": 50},\n            data={\"foo\": \"bar\", \"count\": 2},\n        )\n\n        self.assertEqual(\n            req.plain_query,\n            \"page=1&page_size=50\",\n        )\n        self.assertEqual(\n            req.plain_query,\n            self.crypto.aes_gcm_decrypt(req.params[\"encrypt_query\"], req.aes_key),\n        )\n\n        self.assertEqual(\n            req.headers[\"X-Ugreen-Security-Key\"],\n            hashlib.md5(self.token.encode(\"utf-8\")).hexdigest(),\n        )\n        self.assertEqual(\n            req.aes_key,\n            _rsa_decrypt_long(self.private_key, req.headers[\"X-Ugreen-Security-Code\"]),\n        )\n        self.assertEqual(\n            self.token,\n            _rsa_decrypt_long(self.private_key, req.headers[\"X-Ugreen-Token\"]),\n        )\n\n        encrypted_body = req.json[\"encrypt_req_body\"]\n        body_plain = self.crypto.aes_gcm_decrypt(encrypted_body, req.aes_key)\n        self.assertEqual(json.loads(body_plain), {\"foo\": \"bar\", \"count\": 2})\n        self.assertEqual(\n            req.json[\"req_body_sha256\"],\n            hashlib.sha256(body_plain.encode(\"utf-8\")).hexdigest(),\n        )\n\n        server_payload = {\"code\": 0, \"msg\": \"ok\", \"data\": {\"items\": [1, 2, 3]}}\n        resp = {\n            \"encrypt_resp_body\": self.crypto.aes_gcm_encrypt(\n                json.dumps(server_payload, ensure_ascii=False, separators=(\",\", \":\")),\n                req.aes_key,\n            )\n        }\n        decoded = self.crypto.decrypt_response(resp, req.aes_key)\n        self.assertEqual(decoded, server_payload)\n\n\nif __name__ == \"__main__\":\n    unittest.main()\n"
  },
  {
    "path": "tests/test_ugreen_mediaserver.py",
    "content": "import unittest\nfrom unittest.mock import patch\nimport importlib.util\nimport sys\nimport types\nfrom pathlib import Path\n\nfrom app import schemas\n\ntry:\n    from app.api.endpoints import dashboard as dashboard_endpoint\nexcept Exception:\n    dashboard_endpoint = None\n\n\ndef _load_ugreen_class():\n    \"\"\"\n    在测试中动态加载 Ugreen，避免受可选依赖（如 pyquery/sqlalchemy）影响。\n    \"\"\"\n    module_name = \"_test_ugreen_module\"\n    if module_name in sys.modules:\n        return sys.modules[module_name].Ugreen\n\n    # 轻量日志桩\n    if \"app.log\" not in sys.modules:\n        log_module = types.ModuleType(\"app.log\")\n\n        class _Logger:\n            def info(self, *_args, **_kwargs):\n                pass\n\n            def warning(self, *_args, **_kwargs):\n                pass\n\n            def error(self, *_args, **_kwargs):\n                pass\n\n            def debug(self, *_args, **_kwargs):\n                pass\n\n        log_module.logger = _Logger()\n        sys.modules[\"app.log\"] = log_module\n\n    # SystemConfigOper 桩\n    if \"app.db.systemconfig_oper\" not in sys.modules:\n        db_module = types.ModuleType(\"app.db.systemconfig_oper\")\n\n        class _SystemConfigOper:\n            @staticmethod\n            def get(_key):\n                return {}\n\n            @staticmethod\n            def set(_key, _value):\n                return None\n\n        db_module.SystemConfigOper = _SystemConfigOper\n        sys.modules[\"app.db.systemconfig_oper\"] = db_module\n\n    # app.modules / app.modules.ugreen / app.modules.ugreen.api 桩\n    if \"app.modules\" not in sys.modules:\n        pkg = types.ModuleType(\"app.modules\")\n        pkg.__path__ = []\n        sys.modules[\"app.modules\"] = pkg\n    if \"app.modules.ugreen\" not in sys.modules:\n        subpkg = types.ModuleType(\"app.modules.ugreen\")\n        subpkg.__path__ = []\n        sys.modules[\"app.modules.ugreen\"] = subpkg\n    if \"app.modules.ugreen.api\" not in sys.modules:\n        api_module = types.ModuleType(\"app.modules.ugreen.api\")\n\n        class _Api:\n            host = \"\"\n            token = None\n\n        api_module.Api = _Api\n        sys.modules[\"app.modules.ugreen.api\"] = api_module\n\n    ugreen_path = Path(__file__).resolve().parents[1] / \"app\" / \"modules\" / \"ugreen\" / \"ugreen.py\"\n    spec = importlib.util.spec_from_file_location(module_name, ugreen_path)\n    module = importlib.util.module_from_spec(spec)\n    sys.modules[module_name] = module\n    assert spec and spec.loader\n    spec.loader.exec_module(module)\n    return module.Ugreen\n\n\nUgreen = _load_ugreen_class()\n\n\nclass _FakeUgreenApi:\n    host = \"http://127.0.0.1:9999\"\n    token = \"test-token\"\n\n    @staticmethod\n    def video_all(classification: int, page: int = 1, page_size: int = 1):\n        if classification == -102:\n            return {\"total_num\": 12}\n        if classification == -103:\n            return {\"total_num\": 34}\n        return {\"total_num\": 0}\n\n\nclass UgreenScanModeTest(unittest.TestCase):\n    def test_resolve_scan_type(self):\n        resolve = Ugreen._Ugreen__resolve_scan_type\n\n        self.assertEqual(resolve(scan_mode=\"new_and_modified\"), 1)\n        self.assertEqual(resolve(scan_mode=\"supplement_missing\"), 2)\n        self.assertEqual(resolve(scan_mode=\"full_override\"), 3)\n\n        self.assertEqual(resolve(scan_mode=\"1\"), 1)\n        self.assertEqual(resolve(scan_mode=\"2\"), 2)\n        self.assertEqual(resolve(scan_mode=\"3\"), 3)\n\n        self.assertEqual(resolve(scan_type=1), 1)\n        self.assertEqual(resolve(scan_type=2), 2)\n        self.assertEqual(resolve(scan_type=3), 3)\n\n        self.assertEqual(resolve(scan_mode=\"unknown\"), 2)\n        self.assertEqual(resolve(), 2)\n\n\nclass UgreenVerifySslTest(unittest.TestCase):\n    def test_resolve_verify_ssl(self):\n        resolve = Ugreen._Ugreen__resolve_verify_ssl\n        self.assertEqual(resolve(True), True)\n        self.assertEqual(resolve(False), False)\n        self.assertEqual(resolve(\"true\"), True)\n        self.assertEqual(resolve(\"1\"), True)\n        self.assertEqual(resolve(\"false\"), False)\n        self.assertEqual(resolve(\"0\"), False)\n        self.assertEqual(resolve(None), True)\n\n\nclass UgreenStatisticTest(unittest.TestCase):\n    def test_get_medias_count_episode_is_none(self):\n        ugreen = Ugreen.__new__(Ugreen)\n        ugreen._host = \"http://127.0.0.1:9999\"\n        ugreen._username = \"tester\"\n        ugreen._password = \"secret\"\n        ugreen._userinfo = {\"name\": \"tester\"}\n        ugreen._api = _FakeUgreenApi()\n\n        stat = ugreen.get_medias_count()\n        self.assertEqual(stat.movie_count, 12)\n        self.assertEqual(stat.tv_count, 34)\n        self.assertIsNone(stat.episode_count)\n\n\nclass DashboardStatisticTest(unittest.TestCase):\n    @unittest.skipIf(dashboard_endpoint is None, \"dashboard endpoint dependencies are missing\")\n    def test_statistic_all_episode_missing(self):\n        mocked_stats = [\n            schemas.Statistic(movie_count=10, tv_count=20, episode_count=None, user_count=2),\n            schemas.Statistic(movie_count=1, tv_count=2, episode_count=None, user_count=1),\n        ]\n        with patch(\n            \"app.api.endpoints.dashboard.DashboardChain.media_statistic\",\n            return_value=mocked_stats,\n        ):\n            ret = dashboard_endpoint.statistic(name=\"ugreen\", _=None)\n\n        self.assertEqual(ret.movie_count, 11)\n        self.assertEqual(ret.tv_count, 22)\n        self.assertEqual(ret.user_count, 3)\n        self.assertIsNone(ret.episode_count)\n\n    @unittest.skipIf(dashboard_endpoint is None, \"dashboard endpoint dependencies are missing\")\n    def test_statistic_mixed_episode_count(self):\n        mocked_stats = [\n            schemas.Statistic(movie_count=10, tv_count=20, episode_count=None, user_count=2),\n            schemas.Statistic(movie_count=1, tv_count=2, episode_count=6, user_count=1),\n        ]\n        with patch(\n            \"app.api.endpoints.dashboard.DashboardChain.media_statistic\",\n            return_value=mocked_stats,\n        ):\n            ret = dashboard_endpoint.statistic(name=\"all\", _=None)\n\n        self.assertEqual(ret.movie_count, 11)\n        self.assertEqual(ret.tv_count, 22)\n        self.assertEqual(ret.user_count, 3)\n        self.assertEqual(ret.episode_count, 6)\n\n\nif __name__ == \"__main__\":\n    unittest.main()\n"
  },
  {
    "path": "version.py",
    "content": "APP_VERSION = 'v2.9.15'\nFRONTEND_VERSION = 'v2.9.15'\n"
  }
]